Wrap the key bindings bar over several lines #33

Merged
Duckboot merged 1 commits from fix/wrap-footer-keys into main 2026-11-03 08:40:32 +01:00
2 changed files with 126 additions and 5 deletions

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