diff --git a/gallery/assets/js/export-manager.js b/gallery/assets/js/export-manager.js
index b60967b..ab221c3 100644
--- a/gallery/assets/js/export-manager.js
+++ b/gallery/assets/js/export-manager.js
@@ -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 = `
@@ -304,7 +301,6 @@ export class ExportManager {
📋 Command Breakdown:
- Creates temporary file:
${tempFilePath}
- - Changes to work directory:
${workDir}
- Runs export script:
python export_plots.py
- Output file: Will be saved in the work directory
diff --git a/gallery/cli.py b/gallery/cli.py
index 4464774..6aa747d 100644
--- a/gallery/cli.py
+++ b/gallery/cli.py
@@ -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)
diff --git a/gallery/config/__init__.py b/gallery/config/__init__.py
index 3c029b7..03f3a64 100644
--- a/gallery/config/__init__.py
+++ b/gallery/config/__init__.py
@@ -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": {
diff --git a/gallery/templates/gallery.html b/gallery/templates/gallery.html
index 8b82b68..1eacd2a 100644
--- a/gallery/templates/gallery.html
+++ b/gallery/templates/gallery.html
@@ -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 %}
};
diff --git a/gallery/tui.py b/gallery/tui.py
new file mode 100644
index 0000000..824639e
--- /dev/null
+++ b/gallery/tui.py
@@ -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_
+ # ------------------------------------------------------------------
+ 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]")
diff --git a/gallery/utils/processing.py b/gallery/utils/processing.py
index 927483c..6deb127 100644
--- a/gallery/utils/processing.py
+++ b/gallery/utils/processing.py
@@ -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 = {
diff --git a/pyproject.toml b/pyproject.toml
index e147e49..54a457e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,7 +37,9 @@ dependencies = [
"Jinja2>=3.0.0",
"PyYAML>=5.0",
"argcomplete>=3.0",
+ "platformdirs>=3.0",
"pytest",
+ "textual>=0.50",
]
[project.optional-dependencies]
@@ -66,4 +68,4 @@ line_length = 120
[tool.flake8]
max-line-length = 120
-extend-ignore = ["E203", "W503"]
\ No newline at end of file
+extend-ignore = ["E203", "W503"]
diff --git a/uv.lock b/uv.lock
index 34fb7e2..ffabb61 100644
--- a/uv.lock
+++ b/uv.lock
@@ -7,7 +7,8 @@ resolution-markers = [
"python_full_version == '3.11.*'",
"python_full_version == '3.10.*'",
"python_full_version == '3.9.*'",
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
[[package]]
@@ -24,7 +25,8 @@ name = "astroid"
version = "3.2.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
dependencies = [
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
@@ -72,7 +74,8 @@ name = "black"
version = "24.8.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
dependencies = [
{ name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
@@ -210,7 +213,8 @@ version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.9.*'",
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
@@ -252,7 +256,8 @@ name = "dill"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" }
wheels = [
@@ -295,10 +300,16 @@ source = { editable = "." }
dependencies = [
{ name = "argcomplete" },
{ name = "jinja2" },
+ { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
+ { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
+ { name = "platformdirs", version = "4.9.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pyyaml" },
+ { name = "textual", version = "0.73.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8.1'" },
+ { name = "textual", version = "6.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8.1' and python_full_version < '3.9'" },
+ { name = "textual", version = "8.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
]
[package.optional-dependencies]
@@ -323,10 +334,12 @@ requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = ">=22.0" },
{ name = "jinja2", specifier = ">=3.0.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=0.900" },
+ { name = "platformdirs", specifier = ">=3.0" },
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=2.0" },
{ name = "pytest" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
{ name = "pyyaml", specifier = ">=5.0" },
+ { name = "textual", specifier = ">=0.50" },
]
provides-extras = ["dev"]
@@ -348,7 +361,8 @@ version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.9.*'",
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
@@ -375,7 +389,8 @@ name = "isort"
version = "5.13.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303, upload-time = "2023-12-13T20:37:26.124Z" }
wheels = [
@@ -522,12 +537,96 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/f3/c017fe4337e263bac6a38d2768d687c06e82886d6c131c99179063006323/librt-0.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:e45e46ff5fdfc690e77bb8557d5ba56974c4006b744ddbd70cce99fec6bfbeb8", size = 70725, upload-time = "2026-05-05T16:31:22.182Z" },
]
+[[package]]
+name = "linkify-it-py"
+version = "2.0.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.9.*'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
+]
+dependencies = [
+ { name = "uc-micro-py", version = "1.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" },
+]
+
+[[package]]
+name = "linkify-it-py"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version >= '3.12' and python_full_version < '3.15'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+]
+dependencies = [
+ { name = "uc-micro-py", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.9.*'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
+]
+dependencies = [
+ { name = "mdurl", marker = "python_full_version < '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
+]
+
+[package.optional-dependencies]
+linkify = [
+ { name = "linkify-it-py", version = "2.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+]
+plugins = [
+ { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version >= '3.12' and python_full_version < '3.15'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+]
+dependencies = [
+ { name = "mdurl", marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[package.optional-dependencies]
+linkify = [
+ { name = "linkify-it-py", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+]
+
[[package]]
name = "markupsafe"
version = "2.1.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384, upload-time = "2024-02-02T16:31:22.863Z" }
wheels = [
@@ -695,12 +794,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
+[[package]]
+name = "mdit-py-plugins"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.9.*'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
+]
+dependencies = [
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" },
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.6.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version >= '3.12' and python_full_version < '3.15'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+]
+dependencies = [
+ { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d8/3d/e0e8d9d1cee04f758120915e2b2a3a07eb41f8cf4654b4734788a522bcd1/mdit_py_plugins-0.6.0.tar.gz", hash = "sha256:2436f14a7295837ac9228a36feeabda867c4abc488c8d019ad5c0bda88eee040", size = 56025, upload-time = "2026-05-07T12:20:42.295Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/d6/48f5b9e44e2e760855d7b489b1317cd7620e82dcb73197961e5cc1391348/mdit_py_plugins-0.6.0-py3-none-any.whl", hash = "sha256:f7e7a25d8b616fee99cb1e330da73451d11a8061baf39bb9663ab9ce0e005b90", size = 66655, upload-time = "2026-05-07T12:20:41.226Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
[[package]]
name = "mypy"
version = "1.14.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
dependencies = [
{ name = "mypy-extensions", marker = "python_full_version < '3.9'" },
@@ -890,7 +1034,8 @@ name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
@@ -918,7 +1063,8 @@ name = "platformdirs"
version = "4.3.6"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302, upload-time = "2024-09-17T19:06:50.688Z" }
wheels = [
@@ -957,7 +1103,8 @@ name = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" }
wheels = [
@@ -980,10 +1127,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version >= '3.12' and python_full_version < '3.15'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+ "python_full_version == '3.9.*'",
+]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
@@ -994,7 +1161,8 @@ name = "pylint"
version = "3.2.7"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
dependencies = [
{ name = "astroid", version = "3.2.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
@@ -1065,7 +1233,8 @@ name = "pytest"
version = "8.3.5"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" },
@@ -1093,7 +1262,7 @@ dependencies = [
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "packaging", marker = "python_full_version == '3.9.*'" },
{ name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
- { name = "pygments", marker = "python_full_version == '3.9.*'" },
+ { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
{ name = "tomli", marker = "python_full_version == '3.9.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
@@ -1117,7 +1286,7 @@ dependencies = [
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "packaging", marker = "python_full_version >= '3.10'" },
{ name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
- { name = "pygments", marker = "python_full_version >= '3.10'" },
+ { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "tomli", marker = "python_full_version == '3.10.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
@@ -1254,6 +1423,107 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" },
]
+[[package]]
+name = "rich"
+version = "14.3.4"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
+]
+dependencies = [
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
+ { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version >= '3.12' and python_full_version < '3.15'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+ "python_full_version == '3.9.*'",
+]
+dependencies = [
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
+ { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "textual"
+version = "0.73.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.8.1'",
+]
+dependencies = [
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify", "plugins"], marker = "python_full_version < '3.8.1'" },
+ { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8.1'" },
+ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8.1'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d8/e9/4939bf72d4a7d1a37aa5d55ad4438594a9d5e59875195dd89e9d8c14a9a9/textual-0.73.0.tar.gz", hash = "sha256:ccd1e873370577f557dfdf2b3411f2a4f68b57d4365f9d83a00d084afb15f5a6", size = 1291992, upload-time = "2024-07-18T15:42:55.233Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/f3/62ec72b437647787ac7305699e7e00318fd25827212a6b5b7fbb278ec17d/textual-0.73.0-py3-none-any.whl", hash = "sha256:4d93d80d203f7fb7ba51828a546e8777019700d529a1b405ceee313dea2edfc2", size = 564394, upload-time = "2024-07-18T15:42:52.883Z" },
+]
+
+[[package]]
+name = "textual"
+version = "6.2.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+]
+dependencies = [
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify", "plugins"], marker = "python_full_version >= '3.8.1' and python_full_version < '3.9'" },
+ { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8.1' and python_full_version < '3.9'" },
+ { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8.1' and python_full_version < '3.9'" },
+ { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8.1' and python_full_version < '3.9'" },
+ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8.1' and python_full_version < '3.9'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a2/30/38b615f7d4b16f6fdd73e4dcd8913e2d880bbb655e68a076e3d91181a7ee/textual-6.2.1.tar.gz", hash = "sha256:4699d8dfae43503b9c417bd2a6fb0da1c89e323fe91c4baa012f9298acaa83e1", size = 1570645, upload-time = "2025-10-01T16:11:24.467Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/93/02c7adec57a594af28388d85da9972703a4af94ae1399542555cd9581952/textual-6.2.1-py3-none-any.whl", hash = "sha256:3c7190633cd4d8bfe6049ae66808b98da91ded2edb85cef54e82bf77b03d2a54", size = 710702, upload-time = "2025-10-01T16:11:22.161Z" },
+]
+
+[[package]]
+name = "textual"
+version = "8.2.5"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version >= '3.12' and python_full_version < '3.15'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+ "python_full_version == '3.9.*'",
+]
+dependencies = [
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify"], marker = "python_full_version == '3.9.*'" },
+ { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify"], marker = "python_full_version >= '3.10'" },
+ { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
+ { name = "mdit-py-plugins", version = "0.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
+ { name = "platformdirs", version = "4.9.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
+ { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
+ { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/62/1e/1eedc5bac184d00aaa5f9a99095f7e266af3ec46fa926c1051be5d358da1/textual-8.2.5.tar.gz", hash = "sha256:6c894e65a879dadb4f6cf46ddcfedb0173ff7e0cb1fe605ff7b357a597bdbc90", size = 1851596, upload-time = "2026-04-30T08:02:58.956Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cd/01/c4555f9c8a692ff83d84930150540f743ce94c89234f9e9a15ff4baba3a8/textual-8.2.5-py3-none-any.whl", hash = "sha256:247d2aa2faf222749c321f88a736247f37ee2c023604079c7490bfacddfcd4b2", size = 727050, upload-time = "2026-04-30T08:03:01.421Z" },
+]
+
[[package]]
name = "tomli"
version = "2.4.1"
@@ -1313,7 +1583,8 @@ name = "tomlkit"
version = "0.13.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
wheels = [
@@ -1341,7 +1612,8 @@ name = "typing-extensions"
version = "4.13.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.9'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
wheels = [
@@ -1364,6 +1636,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+[[package]]
+name = "uc-micro-py"
+version = "1.0.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.9.*'",
+ "python_full_version >= '3.8.1' and python_full_version < '3.9'",
+ "python_full_version < '3.8.1'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
+]
+
+[[package]]
+name = "uc-micro-py"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version >= '3.12' and python_full_version < '3.15'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
+]
+
[[package]]
name = "zipp"
version = "3.23.1"