feat: add Gallery TUI for interactive configuration and generation
- Implemented a terminal user interface (TUI) for configuring the gallery generator using Textual. - Included functionality to manage sources with add/remove - Integrated a log pane to display generation output from subprocess calls. - Implemented save functionality to persist configuration changes.
This commit is contained in:
@@ -248,14 +248,11 @@ export class ExportManager {
|
||||
// Generate a unique temporary filename
|
||||
const tempFileName = `export_request_${Date.now()}.json`;
|
||||
|
||||
// Get work directory from config or fallback
|
||||
const workDir = window.galleryConfig?.workDir || '/work/kschmidt/web';
|
||||
|
||||
// Save the request to a JSON file that can be picked up by a Python script
|
||||
const requestData = JSON.stringify(payload, null, 2);
|
||||
|
||||
|
||||
// Show improved export instructions with full command
|
||||
this.showExportInstructions(requestData, tempFileName, workDir);
|
||||
this.showExportInstructions(requestData, tempFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,9 +271,9 @@ export class ExportManager {
|
||||
/**
|
||||
* Show export instructions to user
|
||||
*/
|
||||
showExportInstructions(requestData, tempFileName, workDir) {
|
||||
showExportInstructions(requestData, tempFileName) {
|
||||
const tempFilePath = `/tmp/${tempFileName}`;
|
||||
const fullCommand = `echo '${requestData.replace(/'/g, "'\\''")}' > ${tempFilePath} && cd ${workDir} && python export_plots.py ${tempFilePath}`;
|
||||
const fullCommand = `echo '${requestData.replace(/'/g, "'\\''")}' > ${tempFilePath} && python export_plots.py ${tempFilePath}`;
|
||||
|
||||
const instructions = `
|
||||
<div class="export-instructions">
|
||||
@@ -304,7 +301,6 @@ export class ExportManager {
|
||||
<h4>📋 Command Breakdown:</h4>
|
||||
<ul>
|
||||
<li><strong>Creates temporary file:</strong> <code>${tempFilePath}</code></li>
|
||||
<li><strong>Changes to work directory:</strong> <code>${workDir}</code></li>
|
||||
<li><strong>Runs export script:</strong> <code>python export_plots.py</code></li>
|
||||
<li><strong>Output file:</strong> Will be saved in the work directory</li>
|
||||
</ul>
|
||||
|
||||
+34
-10
@@ -14,7 +14,10 @@ from pathlib import Path
|
||||
import argcomplete
|
||||
|
||||
from gallery import generate
|
||||
from gallery.config import ConfigManager, GalleryConfig, GallerySource, default_config_path
|
||||
from gallery.config import (
|
||||
ConfigManager, GalleryConfig, GallerySource,
|
||||
default_config_path, get_active_config_path, ensure_user_config,
|
||||
)
|
||||
|
||||
_WELCOME = """\
|
||||
Gallery - Scientific Plot Gallery Generator
|
||||
@@ -67,7 +70,7 @@ def _is_configured(config_path: Path) -> bool:
|
||||
def _source_names(prefix, parsed_args, **kwargs):
|
||||
"""Autocomplete helper: return configured source names."""
|
||||
try:
|
||||
config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else default_config_path()
|
||||
config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else get_active_config_path()
|
||||
return [s["name"] for s in ConfigManager(config_path).list_sources()]
|
||||
except Exception:
|
||||
return []
|
||||
@@ -76,7 +79,7 @@ def _source_names(prefix, parsed_args, **kwargs):
|
||||
def _config_keys(prefix, parsed_args, **kwargs):
|
||||
"""Autocomplete helper: return known dot-notation config keys."""
|
||||
try:
|
||||
config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else default_config_path()
|
||||
config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else get_active_config_path()
|
||||
data = ConfigManager(config_path).list_all()
|
||||
keys = []
|
||||
for section, values in data.items():
|
||||
@@ -94,6 +97,7 @@ def _config_keys(prefix, parsed_args, **kwargs):
|
||||
# Parser construction (separated so TUI can reuse the structure)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="gallery",
|
||||
@@ -135,6 +139,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
cfg_sub = cfg.add_subparsers(dest="action", metavar="ACTION")
|
||||
|
||||
sub.add_parser("install-completion", help="Install shell tab-completion (bash/zsh)")
|
||||
sub.add_parser("tui", help="Launch the interactive TUI")
|
||||
|
||||
cfg_sub.add_parser("list", help="Print all config values")
|
||||
cfg_sub.add_parser("path", help="Print the resolved config file path")
|
||||
@@ -149,8 +154,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
p_add = cfg_sub.add_parser("add-source", help="Add a plot source")
|
||||
p_add.add_argument("--name", default=None, help="Source name (default: bottom-level directory name)")
|
||||
p_add.add_argument("--path", required=True, metavar="DIR",
|
||||
help="Path to source directory").completer = argcomplete.completers.DirectoriesCompleter()
|
||||
p_add.add_argument("--path", required=True, metavar="DIR", help="Path to source directory").completer = (
|
||||
argcomplete.completers.DirectoriesCompleter()
|
||||
)
|
||||
|
||||
p_rm = cfg_sub.add_parser("remove-source", help="Remove a plot source by name")
|
||||
p_rm.add_argument("name", help="Source name to remove").completer = _source_names
|
||||
@@ -162,8 +168,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
# Command handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_generate(args: argparse.Namespace) -> int:
|
||||
config_path = Path(args.config) if args.config else default_config_path()
|
||||
config_path = Path(args.config) if args.config else get_active_config_path()
|
||||
try:
|
||||
config = GalleryConfig.from_yaml(config_path)
|
||||
|
||||
@@ -179,8 +186,12 @@ def _run_generate(args: argparse.Namespace) -> int:
|
||||
else:
|
||||
source_to_update = matching
|
||||
|
||||
success = generate(config=config, clean_first=args.clean, verbose=args.verbose,
|
||||
source_to_update=source_to_update)
|
||||
success = generate(
|
||||
config=config,
|
||||
clean_first=args.clean,
|
||||
verbose=args.verbose,
|
||||
source_to_update=source_to_update,
|
||||
)
|
||||
return 0 if success else 1
|
||||
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
@@ -192,7 +203,13 @@ def _run_generate(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _run_config(args: argparse.Namespace) -> int:
|
||||
config_path = Path(args.config) if args.config else default_config_path()
|
||||
if args.config:
|
||||
config_path = Path(args.config)
|
||||
elif args.action in ("set", "add-source", "remove-source"):
|
||||
# Writes go to the user config; create it from the template if needed
|
||||
config_path = ensure_user_config()
|
||||
else:
|
||||
config_path = get_active_config_path()
|
||||
mgr = ConfigManager(config_path)
|
||||
|
||||
if args.action == "path":
|
||||
@@ -200,6 +217,7 @@ def _run_config(args: argparse.Namespace) -> int:
|
||||
|
||||
elif args.action == "list":
|
||||
import yaml
|
||||
|
||||
print(yaml.dump(mgr.list_all(), default_flow_style=False).rstrip())
|
||||
|
||||
elif args.action == "sources":
|
||||
@@ -280,6 +298,7 @@ def _run_install_completion() -> int:
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
argcomplete.autocomplete(parser) # no-op when not completing; exits during completion
|
||||
@@ -291,8 +310,13 @@ def main():
|
||||
sys.exit(_run_config(args))
|
||||
elif args.command == "install-completion":
|
||||
sys.exit(_run_install_completion())
|
||||
elif args.command == "tui":
|
||||
from gallery.tui import GalleryTUI
|
||||
config_path = Path(args.config) if args.config else None
|
||||
GalleryTUI(config_path=config_path).run()
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(_WELCOME.format(config_path=default_config_path()))
|
||||
print(_WELCOME.format(config_path=get_active_config_path()))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
|
||||
@@ -5,17 +5,50 @@ This module provides dataclasses for managing configuration, including
|
||||
defaults for gallery generation settings.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import yaml
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
"""Return the path to the package-bundled default config."""
|
||||
"""Return the path to the package-bundled read-only template config."""
|
||||
return Path(__file__).parent / "config.yaml"
|
||||
|
||||
|
||||
def user_config_path() -> Path:
|
||||
"""Return the user-level config path (~/.config/gallery/config.yaml).
|
||||
|
||||
Follows the XDG Base Directory spec via platformdirs:
|
||||
Linux/macOS → ~/.config/gallery/config.yaml
|
||||
Windows → %APPDATA%/gallery/config.yaml
|
||||
"""
|
||||
return Path(user_config_dir("gallery", appauthor=False)) / "config.yaml"
|
||||
|
||||
|
||||
def get_active_config_path() -> Path:
|
||||
"""Return the config path to use, with this precedence:
|
||||
|
||||
1. User config (~/.config/gallery/config.yaml) — if it exists
|
||||
2. Package-bundled template — fallback
|
||||
"""
|
||||
ucp = user_config_path()
|
||||
return ucp if ucp.exists() else default_config_path()
|
||||
|
||||
|
||||
def ensure_user_config() -> Path:
|
||||
"""Ensure ~/.config/gallery/config.yaml exists, creating it from the
|
||||
package template if needed. Returns the path.
|
||||
"""
|
||||
ucp = user_config_path()
|
||||
if not ucp.exists():
|
||||
ucp.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(default_config_path(), ucp)
|
||||
return ucp
|
||||
|
||||
|
||||
def _load_raw(path: Path) -> Dict[str, Any]:
|
||||
with open(path, "r") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
@@ -182,7 +215,6 @@ class GalleryConfig:
|
||||
|
||||
data = {
|
||||
"paths": {
|
||||
"work_dir": str(Path.cwd()),
|
||||
"web_folder": str(self.web_folder),
|
||||
},
|
||||
"gallery": {
|
||||
|
||||
@@ -343,7 +343,6 @@
|
||||
window.galleryConfig = {
|
||||
searchDebounceMs: {{ ui.search_debounce_ms|default(300) }},
|
||||
maxRecentPlots: {{ ui.max_recent_plots|default(20) }},
|
||||
workDir: "{{ paths.work_dir }}",
|
||||
stats: {% if stats %}{{ stats|tojson }}{% else %}null{% endif %}
|
||||
};
|
||||
</script>
|
||||
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
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
|
||||
]
|
||||
|
||||
# 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 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_<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, "#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]")
|
||||
@@ -160,7 +160,6 @@ def render_gallery_page(
|
||||
output_html = web_dir / "index.html"
|
||||
with output_html.open("w") as f:
|
||||
paths_dict = {
|
||||
"work_dir": str(Path.cwd()),
|
||||
"web_folder": str(config.web_folder),
|
||||
}
|
||||
ui_dict = {
|
||||
|
||||
Reference in New Issue
Block a user