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.containers import Horizontal, Vertical, VerticalScroll
from textual.content import Content from textual.content import Content
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widget import Widget
from textual.widgets import ( from textual.widgets import (
Button, Button,
Checkbox, Checkbox,
DataTable, DataTable,
Footer,
Header, Header,
Input, Input,
Label, Label,
@@ -583,7 +583,7 @@ class TasksScreen(ModalScreen):
section.border_title = Text(title) # guest names: never markup section.border_title = Text(title) # guest names: never markup
yield DataTable(id="tasks", cursor_type="row") yield DataTable(id="tasks", cursor_type="row")
yield Static("", id="tasks_note", markup=False) yield Static("", id="tasks_note", markup=False)
yield Footer() yield KeyBar()
def on_mount(self) -> None: def on_mount(self) -> None:
self.rows: list[dict] = [] self.rows: list[dict] = []
@@ -662,7 +662,7 @@ class UpdatesScreen(ModalScreen):
section.border_title = "Package updates" section.border_title = "Package updates"
yield DataTable(id="updates", cursor_type="row") yield DataTable(id="updates", cursor_type="row")
yield Static("", id="updates_note", markup=False) yield Static("", id="updates_note", markup=False)
yield Footer() yield KeyBar()
def on_mount(self) -> None: def on_mount(self) -> None:
self.query_one("#updates", DataTable).add_columns( self.query_one("#updates", DataTable).add_columns(
@@ -844,7 +844,7 @@ class InfoScreen(ModalScreen):
yield Static("In use", classes="label") yield Static("In use", classes="label")
yield Static("", id="unavailable", markup=False) yield Static("", id="unavailable", markup=False)
yield DataTable(id="addresses") yield DataTable(id="addresses")
yield Footer() yield KeyBar()
def on_mount(self) -> None: def on_mount(self) -> None:
self.query_one("#resources", DataTable).add_columns( 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 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): class GuestApp(App):
BINDINGS = [ # noqa: RUF012 - Textual idiom BINDINGS = [ # noqa: RUF012 - Textual idiom
("s", "act('start')", "Start"), ("s", "act('start')", "Start"),
@@ -1415,7 +1487,7 @@ class GuestApp(App):
yield FilterInput( yield FilterInput(
id="filter", placeholder="name, vmid, tag · is:running · type:lxc" id="filter", placeholder="name, vmid, tag · is:running · type:lxc"
) )
yield Footer() yield KeyBar()
def on_mount(self) -> None: def on_mount(self) -> None:
# one place for every sub_title change: mount, switch and failed switch # 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 contextlib
import itertools
import re import re
import subprocess import subprocess
import threading import threading
@@ -4872,3 +4873,51 @@ async def test_restore_before_the_info_screen_loaded_says_still_loading(
finally: finally:
release.set() release.set()
await settle(app, pilot) 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