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
from datetime import datetime
from rich.text import Text
from textual import work
from textual.app import App, ComposeResult, SuspendNotSupported
from textual.containers import Horizontal, Vertical
@@ -15,6 +16,7 @@ from textual.widgets import (
Footer,
Header,
Input,
Label,
OptionList,
Select,
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
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 +80,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 +90,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 +141,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 +151,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 +167,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 +177,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 +205,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 +223,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 +359,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
@@ -447,7 +495,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 +510,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 +525,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 "-",
@@ -528,7 +582,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,
)
@@ -589,12 +644,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 DataTable(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 +675,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 +716,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,7 +837,7 @@ 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,
)
@@ -913,7 +984,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 +1024,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)