""" 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 DataTable — scrollable table with rows/columns 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, DataTable, 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; } /* Sources table */ #sources-table { height: 8; margin-bottom: 1; } /* Add-source inline form (hidden by default) */ #add-source-form { display: none; height: auto; border: tall $accent; padding: 1; margin-bottom: 1; } #add-source-form.visible { display: block; } #add-source-form Label { color: $text-muted; margin-bottom: 0; } #add-source-form Input { margin-bottom: 1; } #add-source-form Horizontal { height: auto; align: right middle; } #add-source-form Button { margin-left: 1; } /* Generate log pane */ #generate-log { height: 12; } /* 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: Path | None = None): super().__init__() self.config_path = Path(config_path) if config_path else get_active_config_path() self.mgr = ConfigManager(self.config_path) # Local copy of sources so we can diff on save self._sources: list[dict] = [] # ------------------------------------------------------------------ # 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") # ── 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 ───────────────────────────────────────────── with Collapsible(title="Sources", collapsed=False): # DataTable renders a scrollable grid. # Rows are added in on_mount() once the widget exists. yield DataTable(id="sources-table", cursor_type="row") with Horizontal(): yield Button("+ Add Source", id="add-source-btn", variant="primary") yield Button("− Remove Selected", id="remove-source-btn", variant="warning") # Inline add-source form, hidden until user clicks "Add Source" with Vertical(id="add-source-form"): yield Label("Name (optional — defaults to directory name)") yield Input(id="new-name", placeholder="e.g. my_plots") yield Label("Path") yield Input(id="new-path", placeholder="/absolute/path/to/plots") with Horizontal(): yield Button("Confirm", id="confirm-add", variant="success") yield Button("Cancel", id="cancel-add") # ── Generate log ──────────────────────────────────────── with Collapsible(title="Generate Log", collapsed=False): # RichLog accepts Rich markup and plain text. # We stream subprocess output here line by line. yield RichLog(id="generate-log", highlight=True, markup=True) # 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._setup_sources_table() self._load_config_into_fields() self.query_one("#config-path-label", Static).update( f"Config: {self.config_path}" ) def _setup_sources_table(self) -> None: table: DataTable = self.query_one("#sources-table", DataTable) table.add_columns("Name", "Path") 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() # Populate sources table self._sources = list(self.mgr.list_sources()) self._refresh_sources_table() # Config is freshly loaded — not dirty yet self.dirty = False def _refresh_sources_table(self) -> None: table: DataTable = self.query_one("#sources-table", DataTable) table.clear() for s in self._sources: table.add_row(s.get("name", ""), s.get("path", "")) 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_ # ------------------------------------------------------------------ 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 in-memory list. # 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._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") # -- Add source form ------------------------------------------------- @on(Button.Pressed, "#add-source-btn") def _show_add_form(self) -> None: """Reveal the inline add-source form.""" form = self.query_one("#add-source-form") form.add_class("visible") self.query_one("#new-path", Input).focus() @on(Button.Pressed, "#cancel-add") def _hide_add_form(self) -> None: form = self.query_one("#add-source-form") form.remove_class("visible") self.query_one("#new-name", Input).value = "" self.query_one("#new-path", Input).value = "" @on(Button.Pressed, "#confirm-add") def _confirm_add_source(self) -> None: path_str = self.query_one("#new-path", Input).value.strip() name_str = self.query_one("#new-name", Input).value.strip() if not path_str: self.notify("Path is required.", severity="warning") return name = name_str or Path(path_str).resolve().name if any(s["name"] == name for s in self._sources): self.notify(f"Source '{name}' already exists.", severity="warning") return self._sources.append({"name": name, "path": path_str}) self._refresh_sources_table() self._hide_add_form() self.dirty = True # -- Remove source --------------------------------------------------- @on(Button.Pressed, "#remove-source-btn") def _remove_selected_source(self) -> None: table: DataTable = self.query_one("#sources-table", DataTable) # cursor_row is the index of the highlighted row if table.cursor_row is None or not self._sources: self.notify("Select a row first.", severity="warning") return idx = table.cursor_row if idx < len(self._sources): removed = self._sources.pop(idx) self._refresh_sources_table() self.dirty = True self.notify(f"Removed source '{removed['name']}'.") # -- Generate -------------------------------------------------------- @on(Button.Pressed, "#generate-btn") def _start_generate(self) -> None: log: RichLog = self.query_one("#generate-log", RichLog) log.clear() log.write("[bold green]Starting generation...[/bold green]") # Expand the log collapsible so output is visible for c in self.query(Collapsible): if "Generate Log" in str(c.title): c.collapsed = False 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) cmd = [sys.executable, "-m", "gallery.cli", "generate", "--config", str(self.config_path), "--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(log.write, line) proc.wait() msg = "[bold green]Done.[/bold green]" if proc.returncode == 0 \ else f"[bold red]Failed (exit {proc.returncode}).[/bold red]" self.call_from_thread(log.write, msg) except Exception as exc: self.call_from_thread(log.write, f"[bold red]Error: {exc}[/bold red]")