Wrap the key bindings bar over several lines
CI / pytest and ruff (pull_request) Successful in 4m43s
CI / pytest and ruff (push) Successful in 4m43s

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:
DuckbootandClaude Opus 5.5 committed 2026-11-03 08:35:03 +01:00
1 parent 0df928ab11
commit 9b472e8cf6
2 files changed
+126 -5

No files matched your search

+77 -5
View File
@@ -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