Improve layout for adding sources

This commit is contained in:
Kylian Schmidt
2026-05-08 16:32:28 +02:00
parent ace9646a25
commit 73916268a2
4 changed files with 166 additions and 162 deletions
+10 -38
View File
@@ -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 <repository-url>
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
+154 -122
View File
@@ -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")
+1 -1
View File
@@ -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"
Generated
+1 -1
View File
@@ -295,7 +295,7 @@ wheels = [
[[package]]
name = "gallery"
version = "0.1.1"
version = "0.1.2"
source = { editable = "." }
dependencies = [
{ name = "argcomplete" },