Improve layout for adding sources
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
# Gallery: Scientific Plot Gallery Generator
|
||||
# Gallery: Scientific Plot Organizer
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user