2020ac4883
Delete the top-level implementation superseded by the gallery/ package conversion (generate_gallery.py, orchestration/, root templates/ and assets/, python/ scripts), stray scratch files, and docs describing a container/GitLab-CI coverage workflow that no longer exists. Also drop two half-wired, never-invoked features: the backup_folder config/TUI option (create_backup() was never called from the pipeline) and the unfinished export-to-LaTeX JS/CSS. Update README's install instructions to the current Gitea remote.
485 lines
19 KiB
Python
485 lines
19 KiB
Python
"""
|
||
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.widget import Widget
|
||
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),
|
||
("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("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")
|
||
async 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."""
|
||
parent = event.button.parent
|
||
assert isinstance(parent, Widget), "source-remove button must be mounted inside a source row widget"
|
||
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,
|
||
)
|
||
assert proc.stdout is not None, "Popen was called with stdout=PIPE"
|
||
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")
|