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.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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user