Wrap the key bindings bar over several lines
Textual's Footer is a single horizontal row, so on a narrow terminal the keys at the end were cut off. KeyBar replaces it on every screen: it reads the same active bindings, packs them greedily into rows that fit the width, and grows in height as needed. Generated with AI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #33.
This commit is contained in:
1 parent
0df928ab11
commit
9b472e8cf6
2 files changed
+126
-5
No files matched your search
+77
-5
@@ -12,11 +12,11 @@ from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.content import Content
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
Checkbox,
|
||||
DataTable,
|
||||
Footer,
|
||||
Header,
|
||||
Input,
|
||||
Label,
|
||||
@@ -583,7 +583,7 @@ class TasksScreen(ModalScreen):
|
||||
section.border_title = Text(title) # guest names: never markup
|
||||
yield DataTable(id="tasks", cursor_type="row")
|
||||
yield Static("", id="tasks_note", markup=False)
|
||||
yield Footer()
|
||||
yield KeyBar()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.rows: list[dict] = []
|
||||
@@ -662,7 +662,7 @@ class UpdatesScreen(ModalScreen):
|
||||
section.border_title = "Package updates"
|
||||
yield DataTable(id="updates", cursor_type="row")
|
||||
yield Static("", id="updates_note", markup=False)
|
||||
yield Footer()
|
||||
yield KeyBar()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#updates", DataTable).add_columns(
|
||||
@@ -844,7 +844,7 @@ class InfoScreen(ModalScreen):
|
||||
yield Static("In use", classes="label")
|
||||
yield Static("", id="unavailable", markup=False)
|
||||
yield DataTable(id="addresses")
|
||||
yield Footer()
|
||||
yield KeyBar()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#resources", DataTable).add_columns(
|
||||
@@ -1365,6 +1365,78 @@ class FilterInput(Input):
|
||||
BINDINGS = [Binding("escape", "app.clear_filter", "Clear filter")] # noqa: RUF012 - Textual idiom
|
||||
|
||||
|
||||
def wrap_rows(widths: list[int], width: int) -> list[list[int]]:
|
||||
"""Pack item widths greedily into rows no wider than `width`."""
|
||||
rows: list[list[int]] = []
|
||||
for w in widths:
|
||||
if rows and sum(rows[-1]) + w <= width:
|
||||
rows[-1].append(w)
|
||||
else:
|
||||
rows.append([w])
|
||||
return rows
|
||||
|
||||
|
||||
class KeyBar(Widget):
|
||||
"""Like Footer, but wraps the key bindings over several lines instead of clipping."""
|
||||
|
||||
COMPONENT_CLASSES = {"keybar--key", "keybar--description"} # noqa: RUF012 - Textual idiom
|
||||
DEFAULT_CSS = """
|
||||
KeyBar {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
background: $footer-background;
|
||||
color: $footer-foreground;
|
||||
.keybar--key {
|
||||
color: $footer-key-foreground;
|
||||
background: $footer-key-background;
|
||||
text-style: bold;
|
||||
}
|
||||
.keybar--description { color: $footer-description-foreground; }
|
||||
}
|
||||
"""
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.screen.bindings_updated_signal.subscribe(self, self._bindings_changed)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self.screen.bindings_updated_signal.unsubscribe(self)
|
||||
|
||||
def _bindings_changed(self, _screen) -> None:
|
||||
self.refresh(layout=True)
|
||||
|
||||
def _items(self) -> list[Text]:
|
||||
key_style = self.get_component_rich_style("keybar--key")
|
||||
desc_style = self.get_component_rich_style("keybar--description")
|
||||
items, actions = [], set()
|
||||
for _node, binding, enabled, _tip in self.screen.active_bindings.values():
|
||||
shown = binding.show or binding.action == "command_palette"
|
||||
if (
|
||||
not shown or binding.action in actions
|
||||
): # one entry per action, as Footer
|
||||
continue
|
||||
actions.add(binding.action)
|
||||
item = Text.assemble(
|
||||
(f" {self.app.get_key_display(binding)} ", key_style),
|
||||
(f"{binding.description} ", desc_style),
|
||||
)
|
||||
if not enabled:
|
||||
item.stylize("dim")
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def lines(self, width: int) -> list[Text]:
|
||||
items = self._items()
|
||||
rows = wrap_rows([i.cell_len for i in items], max(width, 1))
|
||||
it = iter(items)
|
||||
return [Text("").join(next(it) for _ in row) for row in rows] or [Text("")]
|
||||
|
||||
def get_content_height(self, container, viewport, width: int) -> int:
|
||||
return len(self.lines(width))
|
||||
|
||||
def render(self) -> Text:
|
||||
return Text("\n").join(self.lines(self.size.width))
|
||||
|
||||
|
||||
class GuestApp(App):
|
||||
BINDINGS = [ # noqa: RUF012 - Textual idiom
|
||||
("s", "act('start')", "Start"),
|
||||
@@ -1415,7 +1487,7 @@ class GuestApp(App):
|
||||
yield FilterInput(
|
||||
id="filter", placeholder="name, vmid, tag · is:running · type:lxc"
|
||||
)
|
||||
yield Footer()
|
||||
yield KeyBar()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
# one place for every sub_title change: mount, switch and failed switch
|
||||
|
||||
Reference in new issue
Block a user