Files
ETPlot/gallery/tui.py
T
2026-05-08 16:32:28 +02:00

488 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Gallery TUI — interactive configuration and generation interface.
Built with Textual (https://textual.textualize.io/).
Key Textual concepts used here:
App — the root class; owns the event loop and screen stack
compose() — declarative method that yields widgets to build the UI tree
Collapsible — a section that can be expanded/collapsed by the user
Input — single-line editable text field
Button — clickable button that emits Button.Pressed messages
RichLog — scrollable log pane that accepts Rich markup
reactive — a descriptor that rerenders the UI whenever its value changes
watch_* — method called automatically when a reactive changes
@on — decorator that binds a method to a specific widget message
@work — decorator that runs a method in a background thread,
keeping the UI responsive during long operations
call_from_thread() — safely posts a UI update from a background thread
"""
import subprocess
import sys
from pathlib import Path
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, ScrollableContainer, Vertical
from textual.reactive import reactive
from textual.widgets import Button, Collapsible, Footer, Header, Input, Label, RichLog, Static
from gallery.config import ConfigManager, ensure_user_config, get_active_config_path
# ---------------------------------------------------------------------------
# Field definitions
# Each tuple: (widget_id, config_key, required)
# widget_id is used as the HTML-style id (#web-folder) in CSS selectors
# ---------------------------------------------------------------------------
CONFIG_FIELDS = [
("web-folder", "paths.web_folder", True),
("plot-root", "gallery.plot_root", False),
("png-dpi", "gallery.png_dpi", False),
("backup-folder", "gallery.backup_folder", False),
("cache-enabled", "metadata.cache_enabled", False),
("inherit-meta", "metadata.inherit_from_parent",False),
]
REQUIRED_IDS = {fid for fid, _, req in CONFIG_FIELDS if req}
class GalleryTUI(App):
"""Single-screen TUI for configuring and running the gallery generator."""
TITLE = "Gallery"
SUB_TITLE = "Scientific Plot Gallery Generator"
# DEFAULT_CSS is Textual's inline stylesheet (TCSS — a CSS subset).
# Each rule targets widgets by type, id (#), or class (.).
DEFAULT_CSS = """
/* Main scrollable area fills all available space */
ScrollableContainer {
height: 1fr;
padding: 0 1;
}
/* Active config file path shown at top */
#config-path-label {
color: $text-muted;
margin-bottom: 1;
}
/* Breathing room between collapsible sections */
Collapsible {
margin-bottom: 1;
}
/* One-line field rows — 3 cells tall (border + content + border), no gap */
.field-row {
height: 3;
margin-bottom: 0;
}
.field-row Label {
width: 16;
padding-top: 1;
color: $text-muted;
}
.field-row Input {
width: 1fr;
height: 3;
}
/* Red border on required inputs that are empty */
.required-empty {
border: tall $error;
}
/* Source rows — one row per source with inline name/path inputs */
.source-row {
height: 3;
margin-bottom: 1;
}
.source-remove-btn {
width: 5;
min-width: 5;
height: 3;
margin-right: 1;
}
.source-name {
width: 22;
height: 3;
margin-right: 1;
}
.source-path {
width: 1fr;
height: 3;
}
#add-source-row-btn {
width: auto;
height: 3;
}
/* Generate section — no extra top margin; Collapsible already has bottom margin */
#generate-section {
height: auto;
margin-top: 0;
margin-bottom: 1;
}
#generate-hint {
height: auto;
color: $text-muted;
padding: 0 1;
margin-bottom: 1;
}
#generate-section.ran #generate-hint {
display: none;
}
/* Style the generate button as a wide flat status box */
#generate-status {
width: 1fr;
height: 3;
background: $primary-darken-3;
border: tall $primary;
color: $primary-lighten-2;
text-align: left;
content-align: left middle;
}
#generate-status:hover {
background: $primary-darken-2;
}
#generate-status.running {
background: $warning-darken-3;
border: tall $warning;
color: $warning;
}
#generate-status.success {
background: $success-darken-3;
border: tall $success;
color: $success;
}
#generate-status.error {
background: $error-darken-3;
border: tall $error;
color: $error;
}
#generate-log {
height: 1;
margin-top: 0;
}
/* Bottom action bar — docked so it is always visible regardless of
how tall the ScrollableContainer grows */
#footer-bar {
dock: bottom;
height: 3;
align: right middle;
padding: 0 1;
border-top: solid $accent;
}
#footer-bar Button {
margin-left: 2;
}
/* Dirty indicator shown in subtitle */
#dirty-indicator {
color: $warning;
text-style: bold;
}
"""
# BINDINGS wires keyboard shortcuts to action_* methods.
# Entries with show=True appear in the Footer widget automatically.
# ctrl+S (capital S) is how Textual represents Ctrl+Shift+S.
BINDINGS = [
Binding("ctrl+s", "save_config", "Save Config"),
Binding("ctrl+S", "save_config", show=False), # Ctrl+Shift+S alias
Binding("ctrl+q", "quit", "Quit"),
]
# reactive is a Textual descriptor. When `dirty` changes value, Textual
# automatically calls `watch_dirty()` and re-renders any widget that
# depends on it.
dirty: reactive[bool] = reactive(False)
def __init__(self, config_path=None):
super().__init__()
self.config_path = Path(config_path) if config_path else get_active_config_path()
self.mgr = ConfigManager(self.config_path)
self._next_row_id: int = 0
self._log_lines: int = 0
# ------------------------------------------------------------------
# compose() — builds the widget tree declaratively.
# Textual calls this once at startup; yield order = render order.
# ------------------------------------------------------------------
def compose(self) -> ComposeResult:
yield Header()
with ScrollableContainer():
# Config file path — read-only info line
yield Static(id="config-path-label")
# ── Generate ────────────────────────────────────────────
# Placed first as the primary action. The hint below guides
# first-time users without cluttering the rest of the UI.
with Vertical(id="generate-section"):
yield Button("Generate", id="generate-status")
yield Static(
"Set a web folder path under Paths and add at least one source before generating. "
"Save your config first — then click Generate or press the button in the footer.",
id="generate-hint",
)
yield RichLog(id="generate-log", highlight=True, markup=True)
# ── Paths ───────────────────────────────────────────────
# Collapsible wraps any widgets in a togglable section.
with Collapsible(title="Paths", collapsed=False):
with Horizontal(classes="field-row"):
yield Label("Web folder")
yield Input(
id="web-folder",
placeholder="required — /path/to/public_html",
)
# ── Gallery settings ────────────────────────────────────
with Collapsible(title="Gallery Settings", collapsed=False):
with Horizontal(classes="field-row"):
yield Label("Plot root")
yield Input(id="plot-root", placeholder="gallery")
with Horizontal(classes="field-row"):
yield Label("PNG DPI")
yield Input(id="png-dpi", placeholder="400")
with Horizontal(classes="field-row"):
yield Label("Backup folder")
yield Input(id="backup-folder", placeholder="leave empty to disable")
with Horizontal(classes="field-row"):
yield Label("Cache metadata")
yield Input(id="cache-enabled", placeholder="true")
with Horizontal(classes="field-row"):
yield Label("Inherit meta")
yield Input(id="inherit-meta", placeholder="true")
# ── Sources ─────────────────────────────────────────────
# Each source is an inline editable row; rows are mounted
# dynamically in on_mount() after the config is read.
with Collapsible(title="Sources", collapsed=False):
yield Vertical(id="sources-list")
yield Button(" Add Source", id="add-source-row-btn", variant="primary")
# Bottom bar — always visible outside the scroll area
with Horizontal(id="footer-bar"):
yield Static("", id="dirty-indicator")
yield Button("Save Config", id="save-btn", variant="success")
yield Button("Generate", id="generate-btn", variant="primary")
yield Button("Quit", id="quit-btn", variant="error")
yield Footer()
# ------------------------------------------------------------------
# on_mount — called once after compose(); safe to query widgets here
# ------------------------------------------------------------------
def on_mount(self) -> None:
self._load_config_into_fields()
self.query_one("#config-path-label", Static).update(
f"Config: {self.config_path}"
)
def _make_source_row(self, name: str = "", path: str = "") -> Horizontal:
"""Return a single editable source row widget."""
row_id = f"source-row-{self._next_row_id}"
self._next_row_id += 1
return Horizontal(
Button("", classes="source-remove-btn", variant="error"),
Input(value=name, placeholder="name (optional)", classes="source-name"),
Input(value=path, placeholder="/path/to/plots", classes="source-path"),
classes="source-row",
id=row_id,
)
def _collect_sources(self) -> list[dict]:
"""Read current values from all source rows into a list of dicts."""
sources = []
for row in self.query(".source-row"):
path = row.query_one(".source-path", Input).value.strip()
name = row.query_one(".source-name", Input).value.strip()
if path:
sources.append({"name": name or Path(path).resolve().name, "path": path})
return sources
def _load_config_into_fields(self) -> None:
"""Read config file and populate every Input widget."""
for widget_id, config_key, _required in CONFIG_FIELDS:
try:
value = self.mgr.get(config_key)
inp: Input = self.query_one(f"#{widget_id}", Input)
inp.value = str(value) if value is not None else ""
except Exception:
pass
# Validate required fields on load
self._validate_required()
# Mount one editable row per configured source
sources_list = self.query_one("#sources-list", Vertical)
for row in self.query(".source-row"):
row.remove()
for s in self.mgr.list_sources():
sources_list.mount(self._make_source_row(s.get("name", ""), s.get("path", "")))
# Config is freshly loaded — not dirty yet
self.dirty = False
def _validate_required(self) -> None:
"""Add/remove .required-empty CSS class on required inputs."""
for widget_id in REQUIRED_IDS:
try:
inp: Input = self.query_one(f"#{widget_id}", Input)
# add_class / remove_class toggle CSS classes on a widget
if not inp.value.strip():
inp.add_class("required-empty")
else:
inp.remove_class("required-empty")
except Exception:
pass
# ------------------------------------------------------------------
# watch_dirty — Textual calls this automatically whenever `dirty` changes.
# Naming convention: watch_<reactive_name>
# ------------------------------------------------------------------
def watch_dirty(self, value: bool) -> None:
indicator: Static = self.query_one("#dirty-indicator", Static)
indicator.update("● unsaved changes" if value else "")
# ------------------------------------------------------------------
# Event handlers — @on(MessageType, "#widget-id") binds a method to a
# specific message from a specific widget (or any widget of that type).
# ------------------------------------------------------------------
@on(Input.Changed)
def _on_any_input_changed(self, event: Input.Changed) -> None:
"""Mark config dirty and re-validate required fields on every keystroke."""
self.dirty = True
self._validate_required()
# -- Save ------------------------------------------------------------
# action_* methods are called by BINDINGS — same name after "action_"
def action_save_config(self) -> None:
self._save_config()
@on(Button.Pressed, "#quit-btn")
def action_quit(self) -> None:
self.exit()
@on(Button.Pressed, "#save-btn")
def _save_config(self) -> None:
"""Write all field values back to the config file via ConfigManager.
On first save the package template is copied to ~/.config/gallery/config.yaml
so that the package-bundled file is never modified.
"""
# ensure_user_config() copies the template to ~/.config/gallery/config.yaml
# if it doesn't exist yet, then returns that path.
self.config_path = ensure_user_config()
self.mgr = ConfigManager(self.config_path)
self.query_one("#config-path-label", Static).update(f"Config: {self.config_path}")
for widget_id, config_key, _ in CONFIG_FIELDS:
try:
inp: Input = self.query_one(f"#{widget_id}", Input)
self.mgr.set(config_key, inp.value.strip() or '""')
except Exception as exc:
self.notify(f"Could not save {config_key}: {exc}", severity="error")
return
# Sync sources: remove all then re-add from current UI rows.
# ConfigManager.set("sources", ...) would lose the list structure,
# so we use the dedicated source helpers instead.
try:
for s in self.mgr.list_sources():
self.mgr.remove_source(s["name"])
for s in self._collect_sources():
self.mgr.add_source(s["name"], s["path"])
except Exception as exc:
self.notify(f"Could not save sources: {exc}", severity="error")
return
self.dirty = False
# notify() shows a transient toast message at the bottom of the screen
self.notify("Config saved.", severity="information")
# -- Source row add/remove -------------------------------------------
@on(Button.Pressed, "#add-source-row-btn")
def _add_source_row(self) -> None:
"""Append a new empty source row and focus its path input."""
row = self._make_source_row()
self.query_one("#sources-list", Vertical).mount(row)
self.call_after_refresh(lambda: row.query_one(".source-path", Input).focus())
self.dirty = True
@on(Button.Pressed, ".source-remove-btn")
def _remove_source_row(self, event: Button.Pressed) -> None:
"""Remove the row whose button was pressed."""
event.button.parent.remove()
self.dirty = True
# -- Generate --------------------------------------------------------
def _append_log(self, line: str) -> None:
"""Write one line to the log and grow its height up to 10 rows."""
log: RichLog = self.query_one("#generate-log", RichLog)
log.write(line)
self._log_lines += 1
log.styles.height = min(self._log_lines, 10)
@on(Button.Pressed, "#generate-btn")
@on(Button.Pressed, "#generate-status")
def _start_generate(self) -> None:
status: Button = self.query_one("#generate-status", Button)
log: RichLog = self.query_one("#generate-log", RichLog)
status.set_classes("running")
status.label = "Generate ● running…"
log.clear()
self._log_lines = 0
log.styles.height = 1
self.query_one("#generate-section").add_class("ran")
self._generate_worker()
# @work(thread=True) runs the decorated method in a background thread.
# Without this, the subprocess call would block the UI event loop and
# the screen would freeze until generation finishes.
@work(thread=True)
def _generate_worker(self) -> None:
log: RichLog = self.query_one("#generate-log", RichLog)
status: Button = self.query_one("#generate-status", Button)
def set_status(label: str, css_class: str) -> None:
# Bundled into one callable so both updates happen atomically
# in the event-loop thread.
status.set_classes(css_class)
status.label = label
# --config is a top-level flag (before the subcommand) in the CLI parser
cmd = [sys.executable, "-m", "gallery.cli",
"--config", str(self.config_path), "generate", "--verbose"]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
for line in proc.stdout:
line = line.rstrip()
if line:
# call_from_thread() is required when touching UI widgets
# from a background thread — Textual's event loop is not
# thread-safe, so all UI mutations must go through this.
self.call_from_thread(self._append_log, line)
proc.wait()
if proc.returncode == 0:
self.call_from_thread(set_status, "Generate ✓ done", "success")
else:
self.call_from_thread(set_status, f"Generate ✗ failed (exit {proc.returncode})", "error")
except Exception as exc:
self.call_from_thread(log.write, str(exc))
self.call_from_thread(set_status, "Generate ✗ error", "error")