From 9b472e8cf677858d01a8d427b9fe8e2b7aad9326 Mon Sep 17 00:00:00 2001 From: Per-Arne Hellarvik Date: Tue, 3 Nov 2026 08:35:03 +0100 Subject: [PATCH] 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 --- src/cv4pve_tui/app.py | 82 ++++++++++++++++++++++++++++++++++++++++--- tests/test_app.py | 49 ++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/src/cv4pve_tui/app.py b/src/cv4pve_tui/app.py index d594d82..57430df 100644 --- a/src/cv4pve_tui/app.py +++ b/src/cv4pve_tui/app.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index 6c34d48..76ac68f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,6 +4,7 @@ All cli functions are monkeypatched: cv4pve-cli is never executed. """ import contextlib +import itertools import re import subprocess import threading @@ -4872,3 +4873,51 @@ async def test_restore_before_the_info_screen_loaded_says_still_loading( finally: release.set() await settle(app, pilot) + + +# --- key bar wraps instead of overflowing --------------------------------------------- + + +@given(st.lists(st.integers(1, 30), max_size=25), st.integers(1, 120)) +def test_wrap_rows_keep_order_fit_width_and_pack_greedily(widths, width): + rows = appmod.wrap_rows(widths, width) + assert [w for row in rows for w in row] == widths + for row in rows: + assert row # no empty rows + assert sum(row) <= width or len(row) == 1 # only a lone item may overflow + for prev, nxt in itertools.pairwise(rows): + assert sum(prev) + nxt[0] > width # greedy: the next item did not fit + + +def keybar_text(app): + bar = app.screen.query_one(appmod.KeyBar) + return bar, "\n".join( + bar.lines(bar.size.width)[i].plain for i in range(bar.size.height) + ) + + +async def test_keybar_wraps_on_a_narrow_terminal(fake): + app = make_app() + async with app.run_test(size=(50, 30)) as pilot: + await settle(app, pilot) + bar, text = keybar_text(app) + assert bar.size.height > 1 + for desc in ("Start", "Tasks", "Updates", "Quit"): + assert desc in text + + +async def test_keybar_is_one_line_on_a_wide_terminal(fake): + app = make_app() + async with app.run_test(size=(400, 30)) as pilot: + await settle(app, pilot) + bar, text = keybar_text(app) + assert bar.size.height == 1 + assert "Quit" in text + + +async def test_keybar_follows_the_info_screen_bindings(fake): + app = make_app() + async with app.run_test(size=(50, 40)) as pilot: + await open_info(app, pilot) + _bar, text = keybar_text(app) + assert "Restore backup" in text