Show all dialogs and the guest table as titled boxes

The info screen's style now applies everywhere. The guest table sits
in a "Guests" frame with the active context in its bottom border.
Every dialog is a centred box with a title, labelled fields and the
buttons bottom right: confirm (titled by the action), edit resources,
switch context, back up and edit network.

Fixed:
- The network dialog hid most of its fields. In Horizontal rows
  Textual gave Select a width of 1 and Input 100 %, which pushed
  bridge, firewall, the IP mode selects and the gateways off-screen.
  Every field in a row now has an explicit width.
- A guest name or note containing markup, such as "a[/b]", crashed the
  guest table and the dialogs, because DataTable cells and border
  titles were parsed as markup. They are now plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-02 22:18:10 +01:00
1 parent 62490af913
commit 9ff00e425f
2 files changed
+246 -66

No files matched your search

+143 -66
View File
@@ -4,6 +4,7 @@ import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
from rich.text import Text
from textual import work from textual import work
from textual.app import App, ComposeResult, SuspendNotSupported from textual.app import App, ComposeResult, SuspendNotSupported
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
@@ -15,6 +16,7 @@ from textual.widgets import (
Footer, Footer,
Header, Header,
Input, Input,
Label,
OptionList, OptionList,
Select, Select,
Static, Static,
@@ -30,17 +32,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 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__() super().__init__()
self.message = message self.message, self.title_text = message, title
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(): with self.box(self.title_text):
yield Static(self.message) yield Static(self.message, markup=False)
with Horizontal(): with Horizontal(classes="buttons"):
yield Button("Yes", id="yes", variant="error") yield Button("Yes", id="yes", variant="error")
yield Button("No", id="no") yield Button("No", id="no")
@@ -48,7 +80,7 @@ class ConfirmScreen(ModalScreen[bool]):
self.dismiss(event.button.id == "yes") self.dismiss(event.button.id == "yes")
class EditScreen(ModalScreen): class EditScreen(DialogScreen):
"""Dismisses with a dict of validated values, or None when cancelled.""" """Dismisses with a dict of validated values, or None when cancelled."""
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
@@ -58,24 +90,29 @@ class EditScreen(ModalScreen):
self.guest, self.config, self.disks = guest, config, disks self.guest, self.config, self.disks = guest, config, disks
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(): with self.box(f"Edit resources: {self.guest['name']} ({self.guest['vmid']})"):
yield Static( yield Static("Empty = unchanged")
f"Edit {self.guest['name']} ({self.guest['vmid']}); empty = unchanged" with Horizontal(classes="row"):
) yield Label("Cores")
yield Input( yield Input(
placeholder=f"cores (now {self.config.get('cores', '?')})", id="cores" placeholder=f"now {self.config.get('cores', '?')}", id="cores"
) )
yield Input( with Horizontal(classes="row"):
placeholder=f"memory MiB (now {self.config.get('memory', '?')})", yield Label("Memory (MiB)")
id="memory", yield Input(
) placeholder=f"now {self.config.get('memory', '?')}", id="memory"
yield Select( )
[(f"{k} ({v})", k) for k, v in self.disks.items()], with Horizontal(classes="row"):
prompt="disk", yield Label("Disk")
id="disk", yield Select(
) [(f"{k} ({v})", k) for k, v in self.disks.items()],
yield Input(placeholder="grow by GiB", id="grow") prompt="disk",
with Horizontal(): 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("Apply", id="apply", variant="primary")
yield Button("Cancel", id="cancel") yield Button("Cancel", id="cancel")
@@ -104,7 +141,7 @@ class EditScreen(ModalScreen):
return int(text) return int(text)
class ContextScreen(ModalScreen[str | None]): class ContextScreen(DialogScreen):
"""Dismisses with the chosen context name, or None when cancelled.""" """Dismisses with the chosen context name, or None when cancelled."""
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
@@ -114,7 +151,7 @@ class ContextScreen(ModalScreen[str | None]):
self.contexts = contexts self.contexts = contexts
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(): with self.box("Switch context"):
yield OptionList( yield OptionList(
*( *(
Option( Option(
@@ -130,7 +167,7 @@ class ContextScreen(ModalScreen[str | None]):
self.dismiss(event.option.id) self.dismiss(event.option.id)
class BackupScreen(ModalScreen[str | None]): class BackupScreen(DialogScreen):
"""Dismisses with the chosen storage, or None when cancelled.""" """Dismisses with the chosen storage, or None when cancelled."""
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
@@ -140,17 +177,20 @@ class BackupScreen(ModalScreen[str | None]):
self.guest, self.storages, self.chosen = guest, storages, chosen self.guest, self.storages, self.chosen = guest, storages, chosen
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(): with self.box(f"Back up {self.guest['name']} ({self.guest['vmid']})"):
yield Static( 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( with Horizontal(classes="row"):
[(s, s) for s in self.storages], yield Label("Storage")
value=self.chosen, yield Select(
allow_blank=False, [(s, s) for s in self.storages],
id="storage", value=self.chosen,
) allow_blank=False,
with Horizontal(): id="storage",
)
with Horizontal(classes="buttons"):
yield Button("Yes", id="yes", variant="primary") yield Button("Yes", id="yes", variant="primary")
yield Button("No", id="no") yield Button("No", id="no")
@@ -165,11 +205,10 @@ _IPV4_MODES = [("DHCP", "dhcp"), ("Static", "static"), ("None", "none")]
_IPV6_MODES = [("SLAAC", "auto"), *_IPV4_MODES] _IPV6_MODES = [("SLAAC", "auto"), *_IPV4_MODES]
class NetScreen(ModalScreen): class NetScreen(DialogScreen):
"""Edit bridge, VLAN, firewall and IP of one interface; confirms, then applies.""" """Edit bridge, VLAN, firewall and IP of one interface; confirms, then applies."""
BINDINGS = [("escape", "dismiss", "Cancel")] # noqa: RUF012 - Textual idiom BINDINGS = [("escape", "dismiss", "Cancel")] # noqa: RUF012 - Textual idiom
DEFAULT_CSS = "NetScreen Horizontal { height: auto; }"
def __init__( def __init__(
self, guest: dict, config: dict, bridges: list[str], running: bool self, guest: dict, config: dict, bridges: list[str], running: bool
@@ -184,32 +223,38 @@ class NetScreen(ModalScreen):
self.bridges = sorted(set(bridges) | used) self.bridges = sorted(set(bridges) | used)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(): with self.box(f"Edit network: {self.guest['name']} ({self.guest['vmid']})"):
yield Static( with Horizontal(classes="row"):
f"Network of {self.guest['name']} ({self.guest['vmid']})", markup=False yield Label("Interface")
) yield Select(
yield Select( [(k, k) for k in self.nets],
[(k, k) for k in self.nets], value=next(iter(self.nets)),
value=next(iter(self.nets)), allow_blank=False,
allow_blank=False, id="iface",
id="iface", )
) with Horizontal(classes="row"):
with Horizontal(): yield Label("Bridge")
yield Select( yield Select(
[(b, b) for b in self.bridges], allow_blank=False, id="bridge" [(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 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: if not self.ip_enabled:
yield Static( yield Static(
"IP is configured inside the VM (no cloud-init drive)", id="ip_note" "IP is configured inside the VM (no cloud-init drive)", id="ip_note"
) )
for fam, modes in (("ipv4", _IPV4_MODES), ("ipv6", _IPV6_MODES)): for fam, modes in (("ipv4", _IPV4_MODES), ("ipv6", _IPV6_MODES)):
with Horizontal(): with Vertical(id=f"{fam}_section", classes="sub") as section:
yield Select(modes, allow_blank=False, id=fam) section.border_title = fam.replace("ip", "IP")
yield Input(placeholder="address/prefix", id=f"{fam}_cidr") with Horizontal(classes="row"):
yield Input(placeholder="gateway", id=f"{fam}_gw") yield Select(modes, allow_blank=False, id=fam, classes="mode")
with Horizontal(): 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("Apply", id="apply", variant="primary")
yield Button("Cancel", id="cancel") yield Button("Cancel", id="cancel")
@@ -314,7 +359,10 @@ class NetScreen(ModalScreen):
notes.append("IP settings (cloud-init) apply at next boot.") notes.append("IP settings (cloud-init) apply at next boot.")
who = f"{self.guest['name']} ({self.guest['vmid']})" who = f"{self.guest['name']} ({self.guest['vmid']})"
self.app.push_screen( 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: ( lambda ok: (
self.app.start_net_change(self.guest, changes, old, self) self.app.start_net_change(self.guest, changes, old, self)
if ok if ok
@@ -447,7 +495,8 @@ class InfoScreen(ModalScreen):
ip = cli.parse_net(str(config.get(f"ipconfig{name[3:]}", ""))) ip = cli.parse_net(str(config.get(f"ipconfig{name[3:]}", "")))
else: else:
mac, ip = net.get("hwaddr", "-"), net mac, ip = net.get("hwaddr", "-"), net
table.add_row( _add_plain(
table,
name, name,
net.get("bridge", "-"), net.get("bridge", "-"),
net.get("tag", "-"), net.get("tag", "-"),
@@ -461,8 +510,12 @@ class InfoScreen(ModalScreen):
note.display = bool(unavailable) # no empty line when the addresses are known note.display = bool(unavailable) # no empty line when the addresses are known
table = self.query_one("#addresses", DataTable).clear() table = self.query_one("#addresses", DataTable).clear()
for i in addresses: for i in addresses:
table.add_row( _add_plain(
i["name"], i["mac"] or "-", ", ".join(i["ipv4"]), ", ".join(i["ipv6"]) table,
i["name"],
i["mac"] or "-",
", ".join(i["ipv4"]),
", ".join(i["ipv6"]),
) )
def _show_backups(self) -> None: def _show_backups(self) -> None:
@@ -472,7 +525,8 @@ class InfoScreen(ModalScreen):
] ]
table = self.query_one("#backups", DataTable).clear() table = self.query_one("#backups", DataTable).clear()
for b in shown: for b in shown:
table.add_row( _add_plain(
table,
datetime.fromtimestamp(b["ctime"]).strftime("%Y-%m-%d %H:%M") datetime.fromtimestamp(b["ctime"]).strftime("%Y-%m-%d %H:%M")
if b.get("ctime") if b.get("ctime")
else "-", else "-",
@@ -528,7 +582,8 @@ class InfoScreen(ModalScreen):
lines = [f"{k} = {v or '(remove)'}" for k, v in old.items()] lines = [f"{k} = {v or '(remove)'}" for k, v in old.items()]
self.app.push_screen( self.app.push_screen(
ConfirmScreen( 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, lambda ok: self.app.start_net_change(self.guest, old, None) if ok else None,
) )
@@ -589,12 +644,23 @@ class GuestApp(App):
self.generation = 0 self.generation = 0
self.switching = False 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: def compose(self) -> ComposeResult:
yield Header() yield Header()
yield DataTable(id="guests", cursor_type="row") with Vertical(id="guests_section") as section:
section.border_title = "Guests"
yield DataTable(id="guests", cursor_type="row")
yield Footer() yield Footer()
def on_mount(self) -> None: 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 = self.query_one("#guests", DataTable)
table.add_columns( table.add_columns(
"VMID", "VMID",
@@ -609,6 +675,11 @@ class GuestApp(App):
self._show_context() self._show_context()
self.action_reload() 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) @work(thread=True)
def _show_context(self) -> None: def _show_context(self) -> None:
self._read_context() self._read_context()
@@ -645,7 +716,7 @@ class GuestApp(App):
table.clear() table.clear()
self.guests = {str(g["vmid"]): g for g in guests} self.guests = {str(g["vmid"]): g for g in guests}
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["vmid"], g["name"], g["type"], g["node"], g["status"],
g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30), g.get("maxcpu", "-"), _scaled(g.get("maxmem"), 2**20), _scaled(g.get("maxdisk"), 2**30),
key=str(g["vmid"]), key=str(g["vmid"]),
@@ -766,7 +837,7 @@ class GuestApp(App):
if name == "stop": if name == "stop":
text = f"Hard power-off {who}? The guest is killed without shutting down." text = f"Hard power-off {who}? The guest is killed without shutting down."
self.push_screen( self.push_screen(
ConfirmScreen(text), ConfirmScreen(text, name.capitalize()),
lambda ok: self._start_job(guest, name, run) if ok else None, lambda ok: self._start_job(guest, name, run) if ok else None,
) )
@@ -913,7 +984,8 @@ class GuestApp(App):
who = f"{guest['name']} ({guest['vmid']})" who = f"{guest['name']} ({guest['vmid']})"
self.push_screen( self.push_screen(
ConfirmScreen( 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: ( lambda ok: (
self._apply(guest, values) if ok else None self._apply(guest, values) if ok else None
@@ -952,6 +1024,11 @@ def _reset_terminal() -> None:
sys.stdout.flush() 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): def _scaled(value, unit: int):
return "-" if value is None else round(value / unit) return "-" if value is None else round(value / unit)
+103
View File
@@ -1883,3 +1883,106 @@ async def test_info_has_separate_titled_backup_and_network_sections(fake):
"unavailable", "unavailable",
"addresses", "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"], ["e"], ["c"], ["i", "b"], ["i", "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", [["e"], ["i", "b"], ["i", "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