From 73916268a2bf37d2072294d5b20735308d222e02 Mon Sep 17 00:00:00 2001 From: Kylian Schmidt Date: Fri, 8 May 2026 16:32:28 +0200 Subject: [PATCH] Improve layout for adding sources --- README.md | 48 ++------- gallery/tui.py | 276 +++++++++++++++++++++++++++---------------------- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 166 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index fe0c9ce..8d0bd5b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Gallery: Scientific Plot Gallery Generator +# Gallery: Scientific Plot Organizer [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -A professional, production-ready Python package for creating responsive HTML galleries from scientific plot collections. Convert PDFs to PNG, organize plots hierarchically, and generate beautiful static websites. +**Create responsive HTML galleries for scientific plot collections. Convert PDFs to PNG, organize plots hierarchically, and generate beautiful static websites.** -## ✨ Features +## Features ### Core Functionality - **PDF to PNG Conversion**: Automatic high-quality thumbnail generation using ImageMagick @@ -16,47 +16,17 @@ A professional, production-ready Python package for creating responsive HTML gal ### Advanced Features - **Plot Comparison**: Side-by-side comparison tool for analyzing differences -- **Metadata Management**: YAML/JSON metadata with inheritance and display -- **Export Capabilities**: Batch export selected plots +- **Metadata Management**: YAML/JSON metadata with inheritance and display, to properly label each folder - **Recent Plots**: Quick access to recently viewed items - **Theme Support**: Dark/light theme toggle - **Keyboard Shortcuts**: Power-user navigation ### Developer-Friendly - **Python API**: Import and use programmatically in other projects -- **CLI Interface**: Command-line tool for git-clone based deployments -- **Configuration Flexibility**: YAML config file or pure Python objects -- **Error Handling**: Returns False instead of raising, with optional verbose output +- **CLI Interface**: Command-line command `gallery` +- **TUI Interface**: Textual-based terminal UI `gallery tui` +- **Configuration Flexibility**: Config file stored under user `$HOME/.config/gallery` - **Source Override**: Process single directories without full regeneration - ```bash - git clone - cd scientific-gallery-generator - ``` - -2. **Configure sources** - ```bash - config.yaml # Edit config.yaml to point to your plot directories - ``` - -3. **Generate gallery** - - * Barebones (after installing dependencies yourself) - - ```bash - python generate_gallery.py - ``` - - * Apptainer / Singularity - - ```bash - apptainer run -B /web,/work,/ceph gallery.sif - ``` - -4. **Serve locally** (optional) - ```bash - python -m http.server 8000 -d /path/to/web/directory - ``` - ## πŸ“Έ Screenshots @@ -102,7 +72,9 @@ paths: ### Metadata Files -Create `metadata.yaml` files in your source directories: +Create `metadata.yaml` files in your source directories. The fields are all arbitrary and rendered using +yaml object interpretation (dict, list...). You can have different metadata.yaml files in each folder, +with lower-level fields overriding the parent values. Great for labelling specific experiments. ```yaml # metadata.yaml diff --git a/gallery/tui.py b/gallery/tui.py index 4bd1e6f..2d98555 100644 --- a/gallery/tui.py +++ b/gallery/tui.py @@ -8,7 +8,6 @@ Key Textual concepts used here: 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 @@ -28,7 +27,7 @@ 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 textual.widgets import Button, Collapsible, Footer, Header, Input, Label, RichLog, Static from gallery.config import ConfigManager, ensure_user_config, get_active_config_path @@ -95,41 +94,77 @@ class GalleryTUI(App): border: tall $error; } - /* Sources table */ - #sources-table { - height: 8; + /* 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; + } - /* Add-source inline form (hidden by default) */ - #add-source-form { - display: none; + /* Generate section β€” no extra top margin; Collapsible already has bottom margin */ + #generate-section { height: auto; - border: tall $accent; - padding: 1; + margin-top: 0; margin-bottom: 1; } - #add-source-form.visible { - display: block; - } - #add-source-form Label { + #generate-hint { + height: auto; color: $text-muted; - margin-bottom: 0; - } - #add-source-form Input { + padding: 0 1; margin-bottom: 1; } - #add-source-form Horizontal { - height: auto; - align: right middle; + #generate-section.ran #generate-hint { + display: none; } - #add-source-form Button { - margin-left: 1; + /* 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 pane */ #generate-log { - height: 12; + height: 1; + margin-top: 0; } /* Bottom action bar β€” docked so it is always visible regardless of @@ -166,12 +201,12 @@ class GalleryTUI(App): # depends on it. dirty: reactive[bool] = reactive(False) - def __init__(self, config_path: Path | None = None): + 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) - # Local copy of sources so we can diff on save - self._sources: list[dict] = [] + self._next_row_id: int = 0 + self._log_lines: int = 0 # ------------------------------------------------------------------ # compose() β€” builds the widget tree declaratively. @@ -184,6 +219,18 @@ class GalleryTUI(App): # 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): @@ -213,28 +260,11 @@ class GalleryTUI(App): 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): - # 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) + 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"): @@ -249,15 +279,32 @@ class GalleryTUI(App): # 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 _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.""" @@ -272,19 +319,16 @@ class GalleryTUI(App): # Validate required fields on load self._validate_required() - # Populate sources table - self._sources = list(self.mgr.list_sources()) - self._refresh_sources_table() + # 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 _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: @@ -348,13 +392,13 @@ class GalleryTUI(App): self.notify(f"Could not save {config_key}: {exc}", severity="error") return - # Sync sources: remove all then re-add from in-memory list. + # 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._sources: + 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") @@ -364,65 +408,42 @@ class GalleryTUI(App): # notify() shows a transient toast message at the bottom of the screen self.notify("Config saved.", severity="information") - # -- Add source form ------------------------------------------------- + # -- Source row add/remove ------------------------------------------- - @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() + @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 - # -- 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']}'.") + @on(Button.Pressed, ".source-remove-btn") + def _remove_source_row(self, event: Button.Pressed) -> None: + """Remove the row whose βˆ’ button was pressed.""" + event.button.parent.remove() + self.dirty = True # -- Generate -------------------------------------------------------- - @on(Button.Pressed, "#generate-btn") - def _start_generate(self) -> None: + 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() - 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._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. @@ -431,8 +452,17 @@ class GalleryTUI(App): @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"] + 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, @@ -446,10 +476,12 @@ class GalleryTUI(App): # 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) + self.call_from_thread(self._append_log, 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) + 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, f"[bold red]Error: {exc}[/bold red]") + self.call_from_thread(log.write, str(exc)) + self.call_from_thread(set_status, "Generate βœ— error", "error") diff --git a/pyproject.toml b/pyproject.toml index 54a457e..75167d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gallery" -version = "0.1.1" +version = "0.1.2" description = "Scientific Gallery Generator - Create responsive HTML galleries from plot collections" readme = "README.md" requires-python = ">=3.8" diff --git a/uv.lock b/uv.lock index ffabb61..0037677 100644 --- a/uv.lock +++ b/uv.lock @@ -295,7 +295,7 @@ wheels = [ [[package]] name = "gallery" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "argcomplete" },