diff --git a/README.md b/README.md index 72a25b7..00593f6 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ uv run cv4pve-tui | `S` | shutdown (confirm) | | `x` | stop - hard power-off (confirm) | | `r` | reboot (confirm) | -| `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) | -| `i` | info: backups, backup job, network (config and runtime IPs); `b` there = backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID | -| `i` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) | +| `Enter` | info: backups, backup job, network (config and runtime IPs) | +| `Enter` then `e` | in info: edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) | +| `Enter` then `b` | in info: backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID | +| `Enter` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) | | `t` | shell in a running LXC container (`ssh -t root@ pct enter `) | | `g` | refresh | | `c` | switch cv4pve-cli context (cluster) | diff --git a/src/cv4pve_tui/app.py b/src/cv4pve_tui/app.py index 8984d52..dbb6a9d 100644 --- a/src/cv4pve_tui/app.py +++ b/src/cv4pve_tui/app.py @@ -4,8 +4,10 @@ import subprocess import sys from datetime import datetime +from rich.text import Text from textual import work from textual.app import App, ComposeResult, SuspendNotSupported +from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen from textual.widgets import ( @@ -15,6 +17,7 @@ from textual.widgets import ( Footer, Header, Input, + Label, OptionList, Select, Static, @@ -30,17 +33,47 @@ def _message(exc: Exception) -> str: ) -class ConfirmScreen(ModalScreen[bool]): +class DialogScreen(ModalScreen): + """A centred, titled box (#dialog) with labelled rows and right-aligned buttons.""" + + # every widget in a row gets an explicit width: Horizontal alone squeezes Select to 1 + # and stretches Input to 100 %, pushing the rest off-screen + DEFAULT_CSS = """ + DialogScreen { align: center middle; } + DialogScreen #dialog { + width: 80; max-width: 100%; height: auto; max-height: 100%; overflow-y: auto; + border: round $accent; border-title-style: bold; padding: 0 1; + background: $surface; + } + DialogScreen .row, DialogScreen .buttons { height: auto; } + DialogScreen .buttons { align-horizontal: right; } + DialogScreen .row Label { width: 14; height: 3; content-align: left middle; } + DialogScreen .row Select, DialogScreen .row Input { width: 1fr; } + DialogScreen .row Select.mode { width: 16; } + DialogScreen .sub { + height: auto; border: round $accent; border-title-style: bold; padding: 0 1; + } + """ + + def box(self, title: str) -> Vertical: + """The #dialog container; use as `with self.box(title):` inside compose.""" + box = Vertical(id="dialog") + # titles carry guest names: never parse them as markup + box.border_title = Text(title) + return box + + +class ConfirmScreen(DialogScreen): BINDINGS = [("escape", "dismiss(False)", "No")] # noqa: RUF012 - Textual idiom - def __init__(self, message: str) -> None: + def __init__(self, message: str, title: str = "Confirm") -> None: super().__init__() - self.message = message + self.message, self.title_text = message, title def compose(self) -> ComposeResult: - with Vertical(): - yield Static(self.message) - with Horizontal(): + with self.box(self.title_text): + yield Static(self.message, markup=False) + with Horizontal(classes="buttons"): yield Button("Yes", id="yes", variant="error") yield Button("No", id="no") @@ -48,7 +81,7 @@ class ConfirmScreen(ModalScreen[bool]): self.dismiss(event.button.id == "yes") -class EditScreen(ModalScreen): +class EditScreen(DialogScreen): """Dismisses with a dict of validated values, or None when cancelled.""" BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom @@ -58,24 +91,29 @@ class EditScreen(ModalScreen): self.guest, self.config, self.disks = guest, config, disks def compose(self) -> ComposeResult: - with Vertical(): - yield Static( - f"Edit {self.guest['name']} ({self.guest['vmid']}); empty = unchanged" - ) - yield Input( - placeholder=f"cores (now {self.config.get('cores', '?')})", id="cores" - ) - yield Input( - placeholder=f"memory MiB (now {self.config.get('memory', '?')})", - id="memory", - ) - yield Select( - [(f"{k} ({v})", k) for k, v in self.disks.items()], - prompt="disk", - id="disk", - ) - yield Input(placeholder="grow by GiB", id="grow") - with Horizontal(): + with self.box(f"Edit resources: {self.guest['name']} ({self.guest['vmid']})"): + yield Static("Empty = unchanged") + with Horizontal(classes="row"): + yield Label("Cores") + yield Input( + placeholder=f"now {self.config.get('cores', '?')}", id="cores" + ) + with Horizontal(classes="row"): + yield Label("Memory (MiB)") + yield Input( + placeholder=f"now {self.config.get('memory', '?')}", id="memory" + ) + with Horizontal(classes="row"): + yield Label("Disk") + yield Select( + [(f"{k} ({v})", k) for k, v in self.disks.items()], + prompt="disk", + id="disk", + ) + with Horizontal(classes="row"): + yield Label("Grow by (GiB)") + yield Input(placeholder="grow by GiB", id="grow") + with Horizontal(classes="buttons"): yield Button("Apply", id="apply", variant="primary") yield Button("Cancel", id="cancel") @@ -104,7 +142,7 @@ class EditScreen(ModalScreen): return int(text) -class ContextScreen(ModalScreen[str | None]): +class ContextScreen(DialogScreen): """Dismisses with the chosen context name, or None when cancelled.""" BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom @@ -114,7 +152,7 @@ class ContextScreen(ModalScreen[str | None]): self.contexts = contexts def compose(self) -> ComposeResult: - with Vertical(): + with self.box("Switch context"): yield OptionList( *( Option( @@ -130,7 +168,7 @@ class ContextScreen(ModalScreen[str | None]): self.dismiss(event.option.id) -class BackupScreen(ModalScreen[str | None]): +class BackupScreen(DialogScreen): """Dismisses with the chosen storage, or None when cancelled.""" BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom @@ -140,17 +178,20 @@ class BackupScreen(ModalScreen[str | None]): self.guest, self.storages, self.chosen = guest, storages, chosen def compose(self) -> ComposeResult: - with Vertical(): + with self.box(f"Back up {self.guest['name']} ({self.guest['vmid']})"): yield Static( - f"Back up {self.guest['name']} ({self.guest['vmid']}) now: mode snapshot, zstd" + f"Back up {self.guest['name']} ({self.guest['vmid']}) now: mode snapshot, zstd", + markup=False, ) - yield Select( - [(s, s) for s in self.storages], - value=self.chosen, - allow_blank=False, - id="storage", - ) - with Horizontal(): + with Horizontal(classes="row"): + yield Label("Storage") + yield Select( + [(s, s) for s in self.storages], + value=self.chosen, + allow_blank=False, + id="storage", + ) + with Horizontal(classes="buttons"): yield Button("Yes", id="yes", variant="primary") yield Button("No", id="no") @@ -165,11 +206,10 @@ _IPV4_MODES = [("DHCP", "dhcp"), ("Static", "static"), ("None", "none")] _IPV6_MODES = [("SLAAC", "auto"), *_IPV4_MODES] -class NetScreen(ModalScreen): +class NetScreen(DialogScreen): """Edit bridge, VLAN, firewall and IP of one interface; confirms, then applies.""" BINDINGS = [("escape", "dismiss", "Cancel")] # noqa: RUF012 - Textual idiom - DEFAULT_CSS = "NetScreen Horizontal { height: auto; }" def __init__( self, guest: dict, config: dict, bridges: list[str], running: bool @@ -184,32 +224,38 @@ class NetScreen(ModalScreen): self.bridges = sorted(set(bridges) | used) def compose(self) -> ComposeResult: - with Vertical(): - yield Static( - f"Network of {self.guest['name']} ({self.guest['vmid']})", markup=False - ) - yield Select( - [(k, k) for k in self.nets], - value=next(iter(self.nets)), - allow_blank=False, - id="iface", - ) - with Horizontal(): + with self.box(f"Edit network: {self.guest['name']} ({self.guest['vmid']})"): + with Horizontal(classes="row"): + yield Label("Interface") + yield Select( + [(k, k) for k in self.nets], + value=next(iter(self.nets)), + allow_blank=False, + id="iface", + ) + with Horizontal(classes="row"): + yield Label("Bridge") yield Select( [(b, b) for b in self.bridges], allow_blank=False, id="bridge" ) + with Horizontal(classes="row"): + yield Label("VLAN") yield Input(placeholder="VLAN tag (empty = none)", id="tag") - yield Checkbox("Firewall", id="firewall") + with Horizontal(classes="row"): + yield Label("Firewall") + yield Checkbox("", id="firewall") if not self.ip_enabled: yield Static( "IP is configured inside the VM (no cloud-init drive)", id="ip_note" ) for fam, modes in (("ipv4", _IPV4_MODES), ("ipv6", _IPV6_MODES)): - with Horizontal(): - yield Select(modes, allow_blank=False, id=fam) - yield Input(placeholder="address/prefix", id=f"{fam}_cidr") - yield Input(placeholder="gateway", id=f"{fam}_gw") - with Horizontal(): + with Vertical(id=f"{fam}_section", classes="sub") as section: + section.border_title = fam.replace("ip", "IP") + with Horizontal(classes="row"): + yield Select(modes, allow_blank=False, id=fam, classes="mode") + yield Input(placeholder="address/prefix", id=f"{fam}_cidr") + yield Input(placeholder="gateway", id=f"{fam}_gw") + with Horizontal(classes="buttons"): yield Button("Apply", id="apply", variant="primary") yield Button("Cancel", id="cancel") @@ -314,7 +360,10 @@ class NetScreen(ModalScreen): notes.append("IP settings (cloud-init) apply at next boot.") who = f"{self.guest['name']} ({self.guest['vmid']})" self.app.push_screen( - ConfirmScreen("\n".join([f"Change network of {who}?", *lines, *notes])), + ConfirmScreen( + "\n".join([f"Change network of {who}?", *lines, *notes]), + "Network change", + ), lambda ok: ( self.app.start_net_change(self.guest, changes, old, self) if ok @@ -333,6 +382,7 @@ class InfoScreen(ModalScreen): ("a", "toggle_all", "All backups"), ("n", "net", "Edit network"), ("u", "undo", "Undo network"), + ("e", "edit", "Edit resources"), ] DEFAULT_CSS = """ InfoScreen DataTable { height: auto; max-height: 12; } @@ -447,7 +497,8 @@ class InfoScreen(ModalScreen): ip = cli.parse_net(str(config.get(f"ipconfig{name[3:]}", ""))) else: mac, ip = net.get("hwaddr", "-"), net - table.add_row( + _add_plain( + table, name, net.get("bridge", "-"), net.get("tag", "-"), @@ -461,8 +512,12 @@ class InfoScreen(ModalScreen): note.display = bool(unavailable) # no empty line when the addresses are known table = self.query_one("#addresses", DataTable).clear() for i in addresses: - table.add_row( - i["name"], i["mac"] or "-", ", ".join(i["ipv4"]), ", ".join(i["ipv6"]) + _add_plain( + table, + i["name"], + i["mac"] or "-", + ", ".join(i["ipv4"]), + ", ".join(i["ipv6"]), ) def _show_backups(self) -> None: @@ -472,7 +527,8 @@ class InfoScreen(ModalScreen): ] table = self.query_one("#backups", DataTable).clear() for b in shown: - table.add_row( + _add_plain( + table, datetime.fromtimestamp(b["ctime"]).strftime("%Y-%m-%d %H:%M") if b.get("ctime") else "-", @@ -489,6 +545,9 @@ class InfoScreen(ModalScreen): else "" ) + def action_edit(self) -> None: + self.app.edit_guest(self.guest) + def action_toggle_all(self) -> None: self.show_all = not self.show_all self._show_backups() @@ -528,7 +587,8 @@ class InfoScreen(ModalScreen): lines = [f"{k} = {v or '(remove)'}" for k, v in old.items()] self.app.push_screen( ConfirmScreen( - "\n".join(["Restore the previous network settings?", *lines]) + "\n".join(["Restore the previous network settings?", *lines]), + "Undo network change", ), lambda ok: self.app.start_net_change(self.guest, old, None) if ok else None, ) @@ -566,14 +626,18 @@ def _job_text(job: dict) -> str: ) +class GuestTable(DataTable): + """The guest table; shows its Enter (row selected -> info screen) in the footer.""" + + BINDINGS = [Binding("enter", "select_cursor", "Info")] # noqa: RUF012 - Textual idiom + + class GuestApp(App): BINDINGS = [ # noqa: RUF012 - Textual idiom ("s", "act('start')", "Start"), ("S", "act('shutdown')", "Shutdown"), ("x", "act('stop')", "Stop"), ("r", "act('reboot')", "Reboot"), - ("e", "edit", "Edit"), - ("i", "info", "Info"), ("t", "shell", "Shell"), ("g", "reload", "Refresh"), ("c", "context", "Context"), @@ -589,12 +653,23 @@ class GuestApp(App): self.generation = 0 self.switching = False + CSS = """ + #guests_section { + border: round $accent; border-title-style: bold; padding: 0 1; + } + #guests_section DataTable { height: 1fr; } + """ + def compose(self) -> ComposeResult: yield Header() - yield DataTable(id="guests", cursor_type="row") + with Vertical(id="guests_section") as section: + section.border_title = "Guests" + yield GuestTable(id="guests", cursor_type="row") yield Footer() def on_mount(self) -> None: + # one place for every sub_title change: mount, switch and failed switch + self.watch(self, "sub_title", self._show_context_frame) table = self.query_one("#guests", DataTable) table.add_columns( "VMID", @@ -609,6 +684,11 @@ class GuestApp(App): self._show_context() self.action_reload() + def _show_context_frame(self, sub_title: str) -> None: + self.query_one("#guests_section").border_subtitle = sub_title.removeprefix( + "context: " + ) + @work(thread=True) def _show_context(self) -> None: self._read_context() @@ -645,7 +725,7 @@ class GuestApp(App): table.clear() self.guests = {str(g["vmid"]): g for g in guests} for g in guests: - table.add_row( + _add_plain(table, g["vmid"], g["name"], g["type"], g["node"], g["status"], g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30), key=str(g["vmid"]), @@ -766,14 +846,12 @@ class GuestApp(App): if name == "stop": text = f"Hard power-off {who}? The guest is killed without shutting down." self.push_screen( - ConfirmScreen(text), + ConfirmScreen(text, name.capitalize()), lambda ok: self._start_job(guest, name, run) if ok else None, ) - def action_edit(self) -> None: - guest = self._selected() - if guest is None: - return + def edit_guest(self, guest: dict) -> None: + """Opened from the info screen (key e).""" if guest["vmid"] in self.busy: self.notify( f"{guest['name']} ({guest['vmid']}) is busy", severity="warning" @@ -786,6 +864,10 @@ class GuestApp(App): if guest is not None: self.push_screen(InfoScreen(guest)) + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if event.data_table.id == "guests": # Enter on a guest row + self.action_info() + def start_backup(self, guest: dict, storage: str) -> None: who = f"{guest['name']} ({guest['vmid']})" if guest["vmid"] in self.busy: @@ -913,7 +995,8 @@ class GuestApp(App): who = f"{guest['name']} ({guest['vmid']})" self.push_screen( ConfirmScreen( - f"Grow {values['disk']} of {who} by {values['grow']} GiB? This cannot be undone." + f"Grow {values['disk']} of {who} by {values['grow']} GiB? This cannot be undone.", + "Grow disk", ), lambda ok: ( self._apply(guest, values) if ok else None @@ -952,6 +1035,11 @@ def _reset_terminal() -> None: sys.stdout.flush() +def _add_plain(table: DataTable, *cells, **kwargs) -> None: + """DataTable parses str cells as markup; names and notes like "a[/b]" must stay literal.""" + table.add_row(*(Text(c) if isinstance(c, str) else c for c in cells), **kwargs) + + def _scaled(value, unit: int): return "-" if value is None else round(value / unit) diff --git a/tests/test_app.py b/tests/test_app.py index 9ca8dd5..229f6f1 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -378,6 +378,9 @@ async def test_busy_guest_refuses_second_action(fake): async def open_edit(app, pilot): + """Edit lives in the info screen (user request): Enter, then e.""" + await settle(app, pilot) + await pilot.press("enter") await settle(app, pilot) await pilot.press("e") await settle(app, pilot) @@ -443,7 +446,7 @@ async def test_edit_cancel_changes_nothing(fake): fill(app, cores="8") await press_button(app, pilot, "#cancel") assert fake.sets == [] and fake.resizes == [] - assert not isinstance(app.screen, ModalScreen) + assert isinstance(app.screen, appmod.InfoScreen) # edit closed, back in info async def test_grow_needs_confirm_then_resizes(fake): @@ -549,17 +552,20 @@ async def test_declining_grow_cancels_whole_edit(fake): assert fake.sets == [] and fake.resizes == [] -@pytest.mark.parametrize("key", ["S", "e"]) -async def test_escape_closes_modal_without_cli_call(fake, key): +@pytest.mark.parametrize("keys", [["S"], ["enter", "e"]]) +async def test_escape_closes_modal_without_cli_call(fake, keys): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) - await pilot.press(key) - await settle(app, pilot) - assert isinstance(app.screen, ModalScreen) + under = app.screen + for key in keys: + under = app.screen + await pilot.press(key) + await settle(app, pilot) + assert isinstance(app.screen, (ConfirmScreen, EditScreen)) await pilot.press("escape") await settle(app, pilot) - assert not isinstance(app.screen, ModalScreen) + assert app.screen is under # back where the dialog was opened from assert fake.actions == [] and fake.sets == [] @@ -1047,7 +1053,7 @@ async def open_info(app, pilot, down=0): await settle(app, pilot) for _ in range(down): await pilot.press("down") - await pilot.press("i") + await pilot.press("enter") await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) @@ -1165,7 +1171,7 @@ async def test_i_on_empty_table_does_nothing(fake): app = make_app() async with app.run_test() as pilot: await settle(app, pilot) - await pilot.press("i") + await pilot.press("enter") await settle(app, pilot) assert not isinstance(app.screen, appmod.InfoScreen) assert fake.backups_calls == [] @@ -1798,7 +1804,7 @@ async def test_undo_entry_is_per_guest(fake): await pilot.press("escape") # close InfoScreen await settle(app, pilot) await pilot.press("up") - await pilot.press("i") # VM 108: no change was made there + await pilot.press("enter") # VM 108: no change was made there await settle(app, pilot) assert isinstance(app.screen, appmod.InfoScreen) await pilot.press("u") @@ -1883,3 +1889,148 @@ async def test_info_has_separate_titled_backup_and_network_sections(fake): "unavailable", "addresses", } + + +# --- boxed design: every dialog a titled box, main table in a titled frame ---------- + +from textual.content import Content +from textual.widgets import Label + +SIZE = (100, 40) + + +def visible(app, widget): + r = widget.region + return ( + r.width >= 3 + and r.height >= 1 + and r.right <= app.size.width + and r.bottom <= app.size.height + ) + + +def label_texts(screen): + return {str(w.render()).strip() for w in screen.query(Label)} + + +async def test_net_screen_shows_every_field_inside_the_screen(fake): + # bug: in the old rows Select got width 1 and Input 100 %, pushing fields off-screen + fake.net_configs[100] = dict(CT_CONFIG) + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_net(app, pilot) + for wid in ("iface", "bridge", "tag", "firewall", "ipv4", "ipv4_cidr", "ipv4_gw", + "ipv6", "ipv6_cidr", "ipv6_gw", "apply", "cancel"): # fmt: skip + assert visible(app, app.screen.query_one(f"#{wid}")), wid + + +async def test_net_screen_labels_and_ip_sections(fake): + fake.net_configs[100] = dict(CT_CONFIG) + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_net(app, pilot) + assert {"Interface", "Bridge", "VLAN"} <= label_texts(app.screen) + assert "IPv4" in str(app.screen.query_one("#ipv4_section").border_title) + assert "IPv6" in str(app.screen.query_one("#ipv6_section").border_title) + + +async def test_edit_screen_fields_have_labels(fake): + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await open_edit(app, pilot) + assert {"Cores", "Memory (MiB)", "Disk", "Grow by (GiB)"} <= label_texts( + app.screen + ) + + +async def open_each_dialog(app, pilot): + """Yields after each dialog is open; closes it with escape afterwards.""" + await settle(app, pilot) + for keys in (["S"], ["enter", "e"], ["c"], ["enter", "b"], ["enter", "n"]): + for k in keys: + await pilot.press(k) + await settle(app, pilot) + yield keys + for _ in keys: + await pilot.press("escape") + await settle(app, pilot) + + +async def test_every_dialog_is_a_titled_centred_box(fake): + fake.net_configs[108] = dict(VM_CONFIG) + app = make_app() + async with app.run_test(size=SIZE) as pilot: + async for keys in open_each_dialog(app, pilot): + box = app.screen.query_one("#dialog") + assert str(box.border_title).strip(), keys + assert box.region.width < app.size.width, keys # a box, not full screen + assert box.region.x > 0, keys # centred + + +async def test_main_table_is_framed_with_title_and_context(fake): + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await settle(app, pilot) + frame = app.query_one("#guests_section") + assert "Guests" in str(frame.border_title) + assert "mainframe" in str(frame.border_subtitle) + + +@pytest.mark.parametrize("keys", [["enter", "e"], ["enter", "b"], ["enter", "n"]]) +async def test_guest_name_with_markup_characters_does_not_break_dialogs(fake, keys): + # border titles are parsed as markup; a name like "a[/b]" must be shown literally + fake.guests = [dict(VM, name="a[/b]")] + fake.net_configs[108] = dict(VM_CONFIG) + app = make_app() + async with app.run_test(size=SIZE) as pilot: + await settle(app, pilot) + for k in keys: + await pilot.press(k) + await settle(app, pilot) + title = app.screen.query_one("#dialog").border_title + assert ( + "a[/b]" in Content.from_markup(title).plain + ) # what is shown, not how it is stored + assert app.is_running + + +# --- keys: Enter opens the info screen; edit lives there (user request) ------------ + + +async def test_enter_on_a_guest_opens_its_info_screen(fake): + app = make_app() + async with app.run_test() as pilot: + await settle(app, pilot) + await pilot.press("down") # CT 100 + await pilot.press("enter") + await settle(app, pilot) + assert isinstance(app.screen, appmod.InfoScreen) + assert app.screen.guest["vmid"] == 100 + + +async def test_i_and_e_do_nothing_on_the_main_screen(fake): + app = make_app() + async with app.run_test() as pilot: + await settle(app, pilot) + for key in ("i", "e"): + await pilot.press(key) + await settle(app, pilot) + assert not isinstance(app.screen, ModalScreen), key + + +async def test_e_in_info_screen_edits_that_guest_and_returns_to_info(fake): + app = make_app() + async with app.run_test() as pilot: + await open_edit(app, pilot) + fill(app, cores="8") + await press_button(app, pilot, "#apply") + assert fake.sets == [{"cores": 8}] + assert isinstance(app.screen, appmod.InfoScreen) + + +async def test_footer_on_main_screen_shows_enter_for_info(fake): + app = make_app() + async with app.run_test() as pilot: + await settle(app, pilot) + shown = [ab.binding for ab in app.active_bindings.values() if ab.binding.show] + assert any(b.key == "enter" and b.description == "Info" for b in shown)