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 a474540..dbb6a9d 100644 --- a/src/cv4pve_tui/app.py +++ b/src/cv4pve_tui/app.py @@ -7,6 +7,7 @@ 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 ( @@ -381,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; } @@ -543,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() @@ -621,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"), @@ -655,7 +664,7 @@ class GuestApp(App): yield Header() with Vertical(id="guests_section") as section: section.border_title = "Guests" - yield DataTable(id="guests", cursor_type="row") + yield GuestTable(id="guests", cursor_type="row") yield Footer() def on_mount(self) -> None: @@ -841,10 +850,8 @@ class GuestApp(App): 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" @@ -857,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: diff --git a/tests/test_app.py b/tests/test_app.py index 5ca54fd..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") @@ -1940,7 +1946,7 @@ async def test_edit_screen_fields_have_labels(fake): 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"], ["e"], ["c"], ["i", "b"], ["i", "n"]): + for keys in (["S"], ["enter", "e"], ["c"], ["enter", "b"], ["enter", "n"]): for k in keys: await pilot.press(k) await settle(app, pilot) @@ -1970,7 +1976,7 @@ async def test_main_table_is_framed_with_title_and_context(fake): assert "mainframe" in str(frame.border_subtitle) -@pytest.mark.parametrize("keys", [["e"], ["i", "b"], ["i", "n"]]) +@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]")] @@ -1986,3 +1992,45 @@ async def test_guest_name_with_markup_characters_does_not_break_dialogs(fake, ke "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)