summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-12 17:00:43 +0300
committerPaul Buetow <paul@buetow.org>2026-07-12 17:00:43 +0300
commit78fa24a665002c01f0ea7ded8791c7e6af470305 (patch)
treeaaa5625337ff4cc97f40f855405c9091b3b3496a
Add ggaze implementation plan (docs/)
-rw-r--r--docs/PLAN.md230
-rw-r--r--docs/README.md55
-rw-r--r--docs/architecture.md242
-rw-r--r--docs/coding-conventions.md127
-rw-r--r--docs/gegl.md150
-rw-r--r--docs/goals-and-scope.md102
-rw-r--r--docs/open-questions.md238
-rw-r--r--docs/roadmap.md120
-rw-r--r--docs/tech-stack.md109
-rw-r--r--docs/ui-and-interactions.md390
10 files changed, 1763 insertions, 0 deletions
diff --git a/docs/PLAN.md b/docs/PLAN.md
new file mode 100644
index 0000000..636f009
--- /dev/null
+++ b/docs/PLAN.md
@@ -0,0 +1,230 @@
+# ggaze — Project Plan (living document)
+
+**GNOME Gaze** — a small, fast, native image viewer for Fedora
+Linux, written in C with GTK4. Its job: quickly preview a folder of pictures
+downloaded from a camera, cull the rejects, move on.
+
+This file is the running tracker. The detailed design lives in the sibling
+docs; this page keeps the overview, the decisions log, and the status of each
+milestone in one place. Update it as we go.
+
+---
+
+## Elevator pitch
+
+`ggaze ~/Downloads/Camera/IMG_0001.jpg` opens the folder as a thumbnail grid,
+`Enter` drops into the large view, `h`/`l` (or `←`/`→`) scrubs through the shoot, `i` shows
+EXIF, `d` bins a reject into `./Trash` (undoable), `D` deletes it outright, `v` marks
+keepers, `m` then `1` ships them to "irregular ninja", `e` opens a keeper in GIMP,
+`!` runs `usbimport` to pull new shots, `a` previews a quick GEGL auto-fix, `Esc` returns to the grid, `q` quits.
+No library, no database, no sidecars.
+
+## Two views
+
+- **Grid** — thumbnail overview of the whole folder, keyboard-navigable;
+ thumbnails resize with `+`/`-` and the grid reflows to fit.
+- **Large** — single picture with zoom/pan, fullscreen, slideshow.
+
+Switch with `Enter` (grid→large) and `Esc`/`Backspace` (large→grid), or `t` to
+toggle. The cursor stays in sync across the switch.
+
+## Opening files & folders
+
+- `ggaze file.jpg` opens the file (large view, parent folder as navigator);
+ `ggaze dir/` opens the folder in the grid.
+- Drag-and-drop a file or folder onto the window to open it.
+- `o` opens a file/folder dialog.
+
+## Folder monitoring
+
+- `GFileMonitor` watches the current dir; external adds/deletes/moves
+ refresh the grid live (debounced). A removed current file falls back to the
+ nearest; `r` still does a manual reload.
+
+## Deletion model
+
+- `d` → move to `<folder>/.Trash/` (local, recoverable, undoable via `u`).
+- `D` → permanent delete (unlink, no undo).
+- `./Trash` lives with the shoot; empty it via the menu or from a shell.
+- Trashed/deleted items stay listed but dimmed (hide toggle in settings).
+
+## Moving & marks
+
+- `v` / `V` / `Ctrl+a` mark pictures; marks persist across views.
+- `m` → popup of **configured** destinations, each with an auto-assigned
+ hotkey (`1`, `2`, …). Press the hotkey to move the marked set there.
+- Destinations = ordered `a(ss)` list of name → path pairs in GSettings.
+- `u` undoes the last `d` or `m`.
+
+## External programs
+
+- `e` → popup of configured programs (auto-assigned hotkeys), launches the
+ current image in the chosen one (detached `GSubprocess`, `%f` = path).
+- `editors` = ordered `a(ss)` list of name → command pairs in GSettings.
+
+## Shell scripts
+
+- `!` → popup of configured scripts (auto-assigned hotkeys), runs the chosen
+ one **asynchronously** via `/bin/sh -c` (`%f` = image, `%d` = folder).
+- On exit, ggaze rescans the directory (e.g. `usbimport` adds files).
+- `scripts` = ordered `a(ss)` list of name → command pairs in GSettings.
+
+## Quick enhance (GEGL, optional)
+
+- `a` → popup of enhance presets (auto-assigned hotkeys); applies a GEGL graph
+ as a **non-destructive live preview**; press again / `Esc` to turn off.
+- `s` / menu *Save enhanced copy…* writes `<name>-enhanced.<ext>`; original
+ untouched. **No auto-save** — navigating away from an un-exported preview
+ prompts Save/Discard/Cancel.
+- `enhance-presets` = ordered `a(ss)` list of `(name, gegl-graph)` pairs.
+- GEGL is an optional meson feature; without it, ggaze is a plain fast viewer.
+ GEGL also brings ICC color management. See [gegl.md](gegl.md).
+
+## Crop, straighten & rotate (GEGL, optional)
+
+- `c` crop (adjustable rect, aspect presets), `R` straighten (horizon drag /
+ angle nudge), and `[`/`]` rotate 90° CCW/CW — non-destructive, same preview
+ graph + `s` to export a copy.
+- Hold `Space` to compare original vs modified (before/after); release returns
+ to the modified preview.
+- Large view only; GEGL required.
+
+## Copy to clipboard
+
+- `Ctrl+c` / menu *Copy*: no marks → current image as PNG pixels; marks →
+ marked files as `text/uri-list`. Paste into Katogram/GIMP/file managers
+ (like gthumb).
+
+## Reachability (keyboard + GUI)
+
+Every GUI element has a hotkey, and it's shown on the element (menu items,
+tooltips, popup entries). Conversely, every action is also reachable by
+mouse — header-bar buttons for the common ones, the `F10` menu for the rest.
+`?` lists all shortcuts. Neither keyboard nor mouse is a fallback.
+
+## Quality & testing
+
+- ≥80% unit-test coverage on plain-C modules (gcov/lcov), gated in CI.
+- Run the `auditing-code-quality` skill at each milestone boundary and before
+ release — for C: `c-best-practices` + `find-code-bugs` + `solid-principles`
+ + `beyond-solid-principles`, findings tracked as tasks via
+ `agent-task-management`. Fix all HIGH/MEDIUM before release.
+
+---
+
+## Tech at a glance
+
+| Concern | Choice |
+|--------------|---------------------------------|
+| Language | C11 |
+| UI | GTK4 + libadwaita (decided) |
+| Async/objects| GLib / GObject / GTask |
+| Config | GSettings (`org.buetow.ggaze`) |
+| Build | Meson + Ninja |
+| Decode | GdkPixbuf fallback + libjxl/libavif/libheif (feature options) |
+| Image proc. | GEGL + babl (optional, feature-gated) — enhance, ICC, export copy |
+| Thumbnails | freedesktop TMS, `~/.cache/thumbnails/` |
+| Packaging | Fedora RPM + AppStream (Flatpak later) |
+| Testing | `meson test` + gcov/lcov; ≥80% on plain-C modules |
+| Quality audit | `auditing-code-quality` skill at milestones (C: c-best-practices + find-code-bugs + SOLID + beyond-SOLID) |
+
+See [tech-stack.md](tech-stack.md).
+
+---
+
+## Milestones
+
+| M | Title | Status | Notes |
+|-----|----------------------------|----------|-------|
+| M0 | Skeleton (app + empty window) | not started | Meson, GtkApplication, file/folder arg, --version/--help |
+| M1 | Show one image (zoom/pan) | not started | custom viewer widget, GdkPixbuf backend, EXIF orientation on load |
+| M2 | Walk the directory | not started | navigator, `h`/`l` prev/next, folder arg + drag-drop open, `GFileMonitor` auto-refresh, wrap, header counter |
+| M3 | Responsive + prefetch | not started | GTask decode, 2–3 slot LRU, cancel-in-flight |
+| M4 | Fullscreen + slideshow + info | not started | `f`, `s`, `i` EXIF overlay |
+| M5 | Modern formats | not started | JXL/AVIF/HEIF backends, animated GIF/WebP |
+| M6 | Progressive low-res preview | not started | libjpeg-turbo early low-res scan |
+| M7 | Thumbnail cache + grid view | not started | TMS cache + `gridview`, dim trashed items |
+| M8 | Selection, move, open-external & scripts | not started | marks, `m`/`e`/`!` popups, `mover`/`opener`/`runner`, `Ctrl+c` clipboard, destinations+editors+scripts `a(ss)`, prefs |
+| M9 | GEGL quick-enhance, crop/straighten/rotate (optional) | not started | `enhancer`, `a`/`c`/`R`/`[`/`]` tools, hold-`Space` compare, non-destructive preview, `s` save copy (no auto-save, prompt on navigate), ICC via GEGL |
+| M10 | Polish & packaging | not started | AppStream, RPM, man page, settings, keyboard-completeness audit, ≥80% coverage gate |
+
+Later / maybe: configurable keybindings, recursive walking, RAW embedded
+preview, burst grouping, RAW+JPEG pair hiding, GEGL transforms/artistic.
+
+See [roadmap.md](roadmap.md).
+
+---
+
+## Decisions log
+
+Decisions made during planning. Newest first.
+
+| # | Date | Decision | Rationale |
+|---|------------|--------------------------------------------------------------------------|-----------------------------------|
+| 28 | 2026-07-12 | Folder monitoring via `GFileMonitor` (GIO): external adds/deletes/moves refresh the grid live (debounced); removed current file falls back to nearest. | New shots from usbimport/etc. appear without manual reload. |
+| 29 | 2026-07-12 | **UI toolkit: libadwaita** (was A). GNOME-native header bar/dark viewer/system theme; no theming overrides. | Native Fedora look per the gthumb-but-KISS direction. |
+| 30 | 2026-07-12 | **App ID `org.buetow.ggaze`** (was B). | Matches buetow.org domain. |
+| 31 | 2026-07-12 | **Custom viewer widget** (was L), not `GtkPicture`. | Cursor-centered zoom, pan clamp, hold-`Space` compare, tool overlays. |
+| 32 | 2026-07-12 | **Single instance** (was E); new `open`/drop replaces current folder+image. | Standard GNOME behavior; no window sprawl. |
+| 33 | 2026-07-12 | **Camera specifics** (was K): burst grouping deferred to "later"; hide RAW sidecars by default (toggle to reveal); default sort = filename (EXIF capture-time as a menu option); import folder = just a path. | KISS first; culling-friendly grid; filename ≈ shot order. |
+| 34 | 2026-07-12 | **GEGL integration** (was U): optional meson feature; built-in presets programmatic, user presets as `gegl-graph` text; apply only when settled (not during scrub); "enhanced" badge; export `<stem>-enhanced.<ext>` same dir, collision suffix `-1`; dirty-prompt fires on `d`/`D`/`m`; no gegl-gtk. | Keeps core fast; KISS preset UI; explicit save. |
+| 35 | 2026-07-12 | **GEGL compose order** (was W): load → enhance(color) → rotate → straighten → crop → export; straighten auto-crop default on. | Crop the final framed image; remove rotated corners. |
+| 36 | 2026-07-12 | **Enhance presets** (was X+#4): one active color preset (replace); crop/straighten/rotate stack on top; "reset preview" clears all; combine color presets via one `gegl-graph` entry; curves via `gegl:contrast-curve`; no fine-adjust nudging in v1. | Predictable + KISS; combinable via graph text. |
+| 37 | 2026-07-12 | **Milestone Leans locked**: C GdkPixbuf-first; G color via GEGL/sRGB-else; H scroll=zoom + `pan-when-zoomed` mode; M GSettings `a(ss)` destinations; N move+suffix; O 1-9,0,a-z (cap 36); P one-level undo; Q marks path-based/survive re-sort/clear on trash; R raw cmd+%f+GSubprocess; S /bin/sh -c single-quote+rescan; T 64-512px ±32px custom bucket; V PNG+uri-list union provider; Y EXIF normalize-to-identity, export tag=1; Z folder→grid/file→parent/many→first; AA 250ms debounce nearest; F flat default; J RPM+AppStream first. | Working defaults confirmed at each milestone. |
+| 40 | 2026-07-12 | Run the `auditing-code-quality` skill at each milestone + before release (C-adapted: `c-best-practices` + `find-code-bugs` + `solid-principles` + `beyond-solid-principles`, tracked via `agent-task-management`); fix all HIGH/MEDIUM findings. | Structured, well-factored project; catch defects + design smells early. |
+| 39 | 2026-07-12 | **≥80% unit-test coverage** on plain-C modules (navigator/detect/thumbnail/mover/opener/runner/enhancer/trash/settings) via gcov/lcov with a CI coverage gate; GTK widgets get smoke tests. | Quality floor; refactor safely. |
+| 38 | 2026-07-12 | **Gaps folded in**: mark count in header; window-geometry persistence; CLI `--version`/`--help` (+`--sort`/`--view` later); bulk `D` confirm >1; `scroll-behavior` = zoom/pan-when-zoomed/navigate; `Ctrl+c` copies the *displayed* image; export same ext + JPEG q95 (lossless later); `e` opens the *original* file; go-to-# skipped for v1. | Plan now complete before implementation. |
+| 27 | 2026-07-12 | Open a folder arg (`ggaze dir/` → grid) and accept drag-and-drop of a file/folder onto the window; `o` dialog allows folders too. | Match gthumb flexibility; open anything from CLI, file manager, or drag. |
+| 26 | 2026-07-12 | Honor EXIF Orientation on load (upright display); manual rotate/straighten compose on top; export resets the tag to normal. | Portrait/tilted camera shots display correctly without manual fix. |
+| 25 | 2026-07-12 | Expand GEGL enhance presets: brightness, contrast, saturation, warm/cool, white balance, shadows/highlights, levels, clarity + artistic (B&W/sepia/vignette/softglow); strength tunable via `enhance-presets` gegl-graph text (no slider UI). | Cover the common quick fixes as one-shot presets; keep KISS. |
+| 24 | 2026-07-12 | Compare moved to `Space` (hold); zoom-fit folded into `0` (toggle fit/100%); `\` freed. | `Space` is the comfortable hold-to-compare key; one zoom toggle key. |
+| 23 | 2026-07-12 | Hold `Space` to flash the original image; release to return to the modified preview (before/after compare) to decide whether to `s` save. | Judge edits before saving; no accidental keeps. |
+| 22 | 2026-07-12 | Add 90° rotation (`[`/`]`, `gegl:rotate-on-center`) as a one-shot non-destructive GEGL transform; `s` exports a copy. | Quick orientation fix; original untouched. |
+| 21 | 2026-07-12 | Add crop (`c`, `gegl:crop`) and straighten (`R`, `gegl:rotate`) as non-destructive interactive GEGL tools; `s` exports a copy. | Level horizons and frame shots without leaving ggaze; original untouched. |
+| 20 | 2026-07-12 | `Ctrl+c` copies to clipboard: current image as PNG pixels, or marked files as `text/uri-list` (paste into Katogram/GIMP/etc. like gthumb). | Quick hand-off of an image/selection to other apps. |
+| 19 | 2026-07-12 | Layout & design reminiscent of gthumb (header bar, thumbnail grid, full-window viewer) but KISS: no folder sidebar, no catalogs/tags, no status-bar clutter, no batch/edit toolbars. | Familiar GNOME image-app feel without the weight. |
+| 18 | 2026-07-12 | No auto-save of image changes. `s` saves an enhanced copy manually; navigating away (or quitting) from an un-exported enhance preview prompts Save/Discard/Cancel. Slideshow moved to `S` to free `s` for save. | Originals never silently modified; explicit consent. |
+| 17 | 2026-07-12 | Plan GEGL (optional, feature-gated) for quick non-destructive enhance (`a` popup of presets) + export copy (`s`); also brings ICC color mgmt and format save. | Judge/fix keepers in-app without a full editor; original never modified. |
+| 16 | 2026-07-12 | Grid thumbnails are resizable (`+`/`-`); grid auto-reflows to fit; size persisted in `thumbnail-size` GSettings. | Overview at a glance vs. detail, ad hoc. |
+| 15 | 2026-07-12 | `!` runs configurable shell scripts asynchronously via `/bin/sh -c` (`%f`/`%d`), rescan dir on exit; `scripts` `a(ss)` settings. | Run usbimport etc. from within ggaze; pick up new files. |
+| 14 | 2026-07-12 | `e` opens the current image in a configurable external program via a popup (auto-assigned hotkeys); `editors` `a(ss)` settings; detached GSubprocess launch. | Hand off to GIMP/identify/etc. without leaving ggaze. |
+| 13 | 2026-07-12 | UI is self-documenting: hotkeys shown on elements, tooltips, `?` overlay, badges/counters/toasts narrate state. | Discoverable without a manual. |
+| 12 | 2026-07-12 | Every action is also reachable through the GUI (button/menu), not only by hotkey. | Keyboard and mouse are equally first-class. |
+| 11 | 2026-07-12 | Hotkeys are shown on the elements themselves (menu items, tooltips, popup entries). | Discoverability; no hidden keys. |
+| 10 | 2026-07-12 | Every GUI element has a hotkey / is keyboard-reachable; full mnemonics. | Fully keyboard-driven, no mouse needed. |
+| 9 | 2026-07-12 | `m` moves marked pictures to a configured destination via a popup with auto-assigned hotkeys; destinations user-configurable; multi-select via marks. | Fast triage of camera dumps into named folders. |
+| 8 | 2026-07-12 | Follow the c-best-practices skill; conventions pinned in coding-conventions.md. | Consistent C style across the project. |
+| 7 | 2026-07-12 | vi-style nav (`h`/`l` prev/next) plus cursor keys (`←`/`→`); `j`/`k` pan when zoomed. | vi users + cursor fallback. |
+| 6 | 2026-07-12 | `D` permanently deletes; no undo. | Fast path for obvious garbage. |
+| 5 | 2026-07-12 | `d` moves to a local `./Trash` folder, not the system trash; undoable. | Trash travels with the shoot; easy to inspect/empty. |
+| 4 | 2026-07-12 | Two views: thumbnail grid + large single-picture, in one window. | Overview + detail, both keyboard-driven. |
+| 3 | 2026-07-12 | Aim = quickly preview camera downloads and cull them. | Narrows scope to a culling viewer. |
+| 2 | 2026-07-12 | Stack: C + GTK4, native Fedora/GNOME look. | User requirement. |
+| 1 | 2026-07-12 | Planning docs only first; no implementation yet. | Get the design straight first. |
+
+Most open questions are now **decided** (see decisions #29–#38); remaining
+working Leans and per-milestone details live in
+[open-questions.md](open-questions.md).
+
+---
+
+## Documents index
+
+- [README.md](README.md) — overview + working assumptions
+- [goals-and-scope.md](goals-and-scope.md) — goals, non-goals, target workflow
+- [architecture.md](architecture.md) — modules, data flow, concurrency
+- [ui-and-interactions.md](ui-and-interactions.md) — views, keybindings, gestures, grid
+- [tech-stack.md](tech-stack.md) — libraries, decode backends, deps
+- [coding-conventions.md](coding-conventions.md) — C style (c-best-practices skill)
+- [gegl.md](gegl.md) — GEGL quick-enhance & image-processing plan
+- [roadmap.md](roadmap.md) — milestone detail
+- [open-questions.md](open-questions.md) — undecided items
+- `PLAN.md` — this file (tracker)
+
+## How to use this file
+
+- Flip milestone **Status** as work starts/finishes.
+- Add a row to the **Decisions log** whenever something is settled (and move
+ the matching item out of `open-questions.md`).
+- Keep the elevator pitch and tables in sync with the detail docs; if they
+ disagree, the detail docs are authoritative and this file gets updated. \ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..b124b12
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,55 @@
+# ggaze — GNOME Gaze
+
+A small, fast, native image viewer for Fedora Linux, written in C with GTK4.
+
+The aim is concrete: **quickly preview a folder of pictures downloaded from a
+camera.** You pull a shoot off the camera into a directory, fire up ggaze on
+any file, and flip through the lot fast — glance at EXIF to tell near-identical
+frames apart, trash the obvious rejects, move on. It is a culling viewer, not
+a library manager.
+
+The emphasis is on **quick**: fast startup, instant first-frame display,
+keyboard-driven navigation through a directory of images, and a UI that gets
+out of the way. Think of it as a modern, GNOME-native take on the classic
+`feh` / `nsxiv` / `qiv` lineage — minimal chrome, fast decode, no library
+management bloat.
+
+Layout-wise it nods to **gthumb** (header bar, thumbnail grid, full-window
+viewer) but stays KISS — no sidebar, no catalogs, no toolbars.
+
+This folder contains **planning only**. No implementation yet.
+
+## Documents
+
+- [goals-and-scope.md](goals-and-scope.md) — what ggaze is and is not
+- [architecture.md](architecture.md) — module layout and data flow
+- [ui-and-interactions.md](ui-and-interactions.md) — window layout, keybindings, gestures
+- [tech-stack.md](tech-stack.md) — libraries, build system, dependencies
+- [coding-conventions.md](coding-conventions.md) — C style (follows the c-best-practices skill)
+- [gegl.md](gegl.md) — GEGL quick-enhance & image-processing plan
+- [roadmap.md](roadmap.md) — milestones from skeleton to polish
+- [open-questions.md](open-questions.md) — decisions still to be made
+
+## One-line summary
+
+`ggaze ~/Downloads/Camera/IMG_0001.jpg` — opens instantly in a thumbnail
+grid of the folder, `Enter` into the large view, walk the shoot with `h`/`l` (or `←`/`→`),
+check EXIF with `i`, `d` to bin a reject into `./Trash`, `D` to delete it
+outright, `q` to leave.
+
+## Command-line
+
+- `ggaze [FILE|FOLDER]` — open a file (large view) or a folder (grid).
+- `--version`, `--help`.
+- `--sort=name|time|size`, `--view=grid|large` (convenience/scripting; later).
+
+## Working assumptions (correct me)
+
+- Desktop app, single main window with **two views**: a thumbnail grid
+ (overview of the folder) and a large single-picture view.
+- Rejected pictures go into a `./Trash` folder beside the images (local,
+ recoverable), not the system trash. `D` deletes permanently.
+- Opens a file **or** a folder (CLI arg or drag-and-drop); `ggaze dir/` → grid.
+- Primary platform: Fedora Linux / GNOME. Other GTK4 platforms are a bonus.
+- App ID / GApplication: `org.buetow.ggaze` (placeholder).
+- Build system: Meson (GNOME/Fedora convention). \ No newline at end of file
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..b747199
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,242 @@
+# Architecture
+
+Layered: a thin GTK shell over a small set of focused C modules. Each module
+has one job and a narrow interface; the UI never calls decoders directly.
+
+## Module sketch
+
+```
+ggaze
+├── main.c # entry point, CLI arg parsing, GtkApplication setup
+├── app/ # GApplication, GActions (open, quit, prefs), single-instance
+├── window.{c,h} # GgazeWindow : GtkApplicationWindow — owns the layout, switches grid/large
+├── viewer.{c,h} # GgazeViewer : GtkWidget — large single-image canvas, zoom/pan, displays a GdkTexture
+├── gridview.{c,h} # GgazeGrid : GtkGridView/FlowLayout — thumbnail overview of the folder
+├── trash.{c,h} # ./Trash folder management + permanent delete; restore/undo
+├── mover.{c,h} # configurable move destinations; move marked set into a dir (undoable)
+├── opener.{c,h} # configurable external programs; launch current image (GSubprocess)
+├── runner.{c,h} # configurable shell scripts; async run via /bin/sh -c, rescan on done
+├── enhancer.{c,h} # (optional) GEGL quick-enhance presets; non-destructive apply + export copy
+├── clipboard.{c,h} # copy image (PNG) or file URIs to GdkClipboard (no state, helpers)
+├── loader/
+│ ├── loader.{c,h} # async load API: load(path, cancellable, ready_cb)
+│ ├── detect.{c,h} # sniff format from contents (magic), not extension
+│ └── backends/ # one file per format family, behind a backend struct
+│ ├── pixbuf.c # fallback via GdkPixbuf (PNG/JPEG/GIF/WebP)
+│ ├── jxl.c # libjxl
+│ ├── avif.c # libavif
+│ └── heif.c # libheif
+├── navigator.{c,h} # directory listing, sort, filter, prev/next, wrap, recurse(opt)
+├── thumbnail.{c,h} # freedesktop thumbnail cache (normal/large), shared/mutex
+├── settings.{c,h} # GSettings schema wrapper
+└── shortcuts.{c,h} # keybinding → GAction map (configurable later)
+```
+
+## Responsibilities
+
+- **app** — owns the `GtkApplication`, registers actions, handles the `open`
+ signal (files **or a directory** → window), single-instance behavior. A
+ directory arg opens the folder in the grid; a file arg opens its parent
+ folder with that file current.
+- **window** — owns the two view modes (**grid** and **large**) in a
+ `GtkStack`, the header bar, and the info overlay. Routes actions to
+ navigator/loader/viewer/gridview; manages fullscreen state. Keeps the
+ navigator cursor in sync so switching grid↔large preserves position. Tracks
+ the enhance "dirty" flag and gates navigation on it (prompt
+ Save/Discard/Cancel when an un-exported enhance preview is active). Hosts
+ interactive tool overlays (crop, straighten) in large view. Has a
+ `GtkDropTarget` accepting dropped files/folders (open them).
+- **viewer** — the *large* view. Pure display widget. Takes a `GdkTexture`
+ (or `GtkSnapshot` paintable). Owns zoom level, pan offset, fit mode. Draws
+ via GTK4 render nodes. Holds both the raw and GEGL-processed textures;
+ `Space` swaps to the raw (compare) while held. Emits "needs-next" when nearing
+ the end of a preloaded set.
+- **gridview** — the *thumbnail* view. A `GtkGridView` (or `GtkFlowBox`)
+ backed by a `GListModel` of the navigator's files, each cell rendered from
+ the `thumbnail` cache. Thumbnail size is adjustable (`+`/`-`); cells reflow
+ to fit the window. Size comes from GSettings `thumbnail-size`. Selection
+ follows the navigator cursor. Double-click / `Enter` switches to large view
+ on the selected item.
+- **loader** — runs decode in a `GTask` thread, returns a `GdkTexture` on the
+ main thread. Format detection by content sniffing. **Applies EXIF
+ Orientation** so the texture is upright (GdkPixbuf path:
+ `gdk_pixbuf_apply_embedded_orientation`; other backends read the EXIF tag and
+ rotate/flip). Backend selected at build time via meson `feature` options.
+- **navigator** — given a starting file, lists the parent directory, filters
+ to image MIME types, sorts (name/time/size), exposes `current/prev/next`.
+ Also owns the **mark set** (multi-select): `navigator_toggle_mark`,
+ `navigator_mark_range`, `navigator_mark_all`, `navigator_clear_marks`,
+ `navigator_get_marks` (returns a `GList` of `GFile*`). Emits a `changed`
+ signal on sort/filter/trash/move so grid + large stay in sync. Watches the
+ directory with `GFileMonitor` and emits `changed` on external
+ adds/deletes/moves (debounced); if the current file is removed, falls back
+ to the nearest. Owns no GTK state; testable standalone.
+- **trash** — moves a file to `<dir>/.Trash/` (creating it lazily), preserving
+ relative path uniqueness (suffix `-1`, `-2`… on collision). `D` calls
+ `g_file_delete` instead. Tracks the last trashed item for `u` undo/restore.
+ Never touches the system trash.
+- **mover** — owns the configured destination list (loaded from settings) and
+ performs `g_file_move` for a set of `GFile*` into a chosen destination, with
+ collision suffixing. Records the last move (paths + dest) so `u` can move
+ them back. Exposes `mover_get_dests` (ordered, for the popup + hotkey
+ assignment) and `mover_move(GList *paths, MoverDest *dest, GError **)`.
+- **opener** — owns the configured external-program list (loaded from
+ settings). Expands `%f` (and later `%F`) in the command and launches it
+ detached via `GSubprocess` (`g_subprocess_new`). Exposes
+ `opener_get_progs` (ordered, for the popup + hotkey assignment) and
+ `opener_launch(GFile *file, OpenerProg *prog, GError **)`. Owns no GTK
+ state; the window owns the popup.
+- **runner** — owns the configured shell-script list (loaded from settings).
+ Expands `%f` (current image) and `%d` (current folder) in the command and
+ runs it **asynchronously** via `/bin/sh -c` (`GSubprocess` with
+ `g_subprocess_wait_async`); substituted paths are single-quoted to prevent
+ shell injection. Exposes `runner_get_scripts` (ordered, for the popup +
+ hotkey assignment) and `runner_run(GFile *file, GFile *dir,
+ RunnerScript *script, GAsyncReadyCallback on_done, GError **)`. On
+ completion the window calls `navigator_rescan()` (scripts may mutate the
+ folder) and shows a toast with the exit status. Owns no GTK state.
+- **enhancer** *(optional, if GEGL is enabled)* — owns the enhance-preset list
+ (loaded from settings). Builds a GEGL op graph for a preset and applies it
+ to a `GeglBuffer` in a `GTask` thread: `enhancer_get_presets`,
+ `enhancer_apply(GeglBuffer *in, EnhancerPreset *, GError **) → GeglBuffer*`,
+ `enhancer_export(GeglBuffer *in, EnhancerPreset *, GFile *out, GError **)`.
+ The window imports the decoded image into a `GeglBuffer` when a preset is
+ active and renders the result back to a `GdkTexture`. The crop/straighten/
+ rotate tools add `gegl:crop`/`gegl:rotate`/`gegl:rotate-on-center` to the same
+ graph via the enhancer. GEGL also backs color-managed decode/export (ICC).
+ Owns no GTK state.
+- **clipboard** — stateless helpers that put content on the `GdkClipboard`:
+ `clipboard_copy_image(GdkClipboard *clip, GFile *file, GCancellable *,
+ GError **)` decodes the image in a `GTask` thread and sets a
+ `GdkContentProvider` for `image/png`; `clipboard_copy_uris(GdkClipboard
+ *clip, GList *files)` sets `text/uri-list` (+ `text/plain`). Single image →
+ pixels; marks → URIs. (Optionally union both providers so one file offers
+ PNG + URI.)
+- **thumbnail** — reads/writes `~/.cache/thumbnails/` per the freedesktop
+ Thumbnail Managing Standard; shared so multiple windows don't re-decode.
+ Also feeds the gridview cells.
+- **settings** — wraps a `GSettings` schema: sort order, wrap, background
+ colour, scroll behavior (zoom vs navigate), slideshow delay,
+ `thumbnail-size` (grid thumbnail pixel size), hide-trashed toggle,
+ `destinations` — an ordered `a(ss)` array of `(name, path)` pairs
+ used by the move popup, and `editors` — an ordered `a(ss)` array of
+ `(name, command)` pairs used by the `e` open-in popup (`%f` = current path),
+ and `scripts` — an ordered `a(ss)` array of `(name, command)` pairs used by
+ the `!` run-script popup (`%f` = current path, `%d` = current folder; run
+ via `/bin/sh -c`), and `enhance-presets` — an ordered `a(ss)` array of
+ `(name, gegl-graph)` pairs for the `a` enhance popup (GEGL only). List
+ order = hotkey order (`1`, `2`, …).
+
+## Data flow (next image, large view)
+
+```
+key 'l' → window action "next"
+ → navigator.next() → path2
+ → loader.load(path2, cancellable) [thread]
+ → GdkTexture ready [main thread]
+ → viewer.set_texture(texture)
+ → thumbnail.ensure(path2) [background]
+```
+
+## Data flow (grid ↔ large)
+
+```
+grid Enter / double-click → window.set_view(LARGE)
+ → navigator.set_current(selected_path)
+ → viewer shows that image
+large Esc / Backspace → window.set_view(GRID)
+ → gridview scrolls cursor into view, focused
+```
+
+## Data flow (trash / delete)
+
+```
+key 'd' → trash.bin(path) → mv path → <dir>/.Trash/<name> (undoable)
+key 'D' → trash.delete(path) → unlink(path) (not undoable)
+ → navigator.remove(path) → emits 'changed'
+ → gridview drops cell / dims it; large view advances to next
+```
+
+## Data flow (move)
+
+```
+key 'm' → window shows move popup (GtkPopover)
+ → mover_get_dests() → [ {"irregular ninja", ~/…}, {"alt …", …}, … ]
+ → popup assigns hotkeys 1..9,0,a.. by list order
+key '2' → mover_move(marked_paths, dests[1], &err)
+ → g_file_move each (rename/copy+delete), collision-suffix
+ → navigator.remove(each) → emits 'changed'
+ → grid drops cells; large advances; counter updates
+ → mover records move for 'u' undo
+no marks? → move acts on navigator.current instead
+```
+
+## Data flow (open in external program)
+
+```
+key 'e' → window shows open-in popup (GtkPopover)
+ → opener_get_progs() → [ {"GIMP", "gimp %f"}, {"identify", …}, … ]
+ → popup assigns hotkeys 1..9,0,a.. by list order
+key '2' → opener_launch(current_path, progs[1], &err)
+ → expand %f → argv; g_subprocess_new (detached)
+ → toast on failure; ggaze stays responsive, image stays open
+```
+
+## Data flow (run shell script)
+
+```
+key '!' → window shows scripts popup (GtkPopover)
+ → runner_get_scripts() → [ {"usbimport", "~/scripts/usbimport %d"}, … ]
+ → popup assigns hotkeys 1..9,0,a.. by list order
+key '1' → runner_run(current_path, dir, scripts[0], on_done, &err)
+ → expand %f/%d (single-quoted) → /bin/sh -c "<cmd>"
+ → g_subprocess_wait_async; ggaze stays responsive; toast: "running…"
+on done → navigator_rescan() (scripts may add/remove files)
+ → toast: "usbimport finished (exit 0)" or error
+```
+
+## Data flow (quick enhance, GEGL)
+
+```
+key 'a' → window shows enhance popup (GtkPopover)
+ → enhancer_get_presets() → [ {"Auto-fix", "stretch-contrast|color-enhance"}, … ]
+ → popup assigns hotkeys 1..9,0,a.. by list order
+key '1' → import decoded image → GeglBuffer
+ → enhancer_apply(buf, presets[0], &err) [GTask thread]
+ → GeglBuffer out → render to GdkTexture → viewer (non-destructive)
+ → toggle off on second press / Esc
+key 's' → enhancer_export(buf, presets[0], out_file, &err)
+ → writes IMG_0001-enhanced.<ext> via GEGL saver; original untouched
+ → clears the dirty flag for this image
+navigate with dirty preview → prompt: Save (export) / Discard / Cancel
+GEGL disabled? → 'a' shows "GEGL not built in" toast
+```
+
+## Data flow (copy to clipboard)
+
+```
+Ctrl+c → marks? clipboard_copy_uris(clip, marked_files) [text/uri-list]
+ → no marks? clipboard_copy_image(clip, current, cancellable, &err)
+ → decode in GTask → GdkPixbuf/Texture → PNG content provider
+ → gdk_clipboard_set_content (main thread)
+ → toast: "Copied image" / "Copied N files"
+```
+
+Prefetch: when `navigator.current` changes, schedule `loader.load` for the
+*next* and *previous* paths into a small (2–3 slot) texture cache so navigation
+feels instant.
+
+## Concurrency model
+
+- Only the main thread touches GTK widgets.
+- Decode happens in `GTask` worker threads (one at a time per load, with a
+ `GCancellable` so a rapid `jjjj` cancels stale work).
+- Thumbnail I/O on a low-priority thread or `GThreadPool`.
+- A bounded LRU of decoded `GdkTexture`s (e.g. 4) to bound memory on large
+ folders / huge images.
+
+## Threading / cancellation invariant
+
+At most one *active* load per window. Issuing a new load cancels the previous
+cancellable and drops its result. The viewer only ever shows a texture whose
+path matches `navigator.current`. \ No newline at end of file
diff --git a/docs/coding-conventions.md b/docs/coding-conventions.md
new file mode 100644
index 0000000..e1d1d44
--- /dev/null
+++ b/docs/coding-conventions.md
@@ -0,0 +1,127 @@
+# Coding Conventions
+
+ggaze follows the **c-best-practices** skill
+(`~/.agents/skills/c-best-practices/SKILL.md`). That skill and its references
+are authoritative; this page summarizes the conventions as they apply to
+ggaze so contributors don't have to context-switch. When in doubt, the skill
+wins.
+
+## Source style
+
+- **Indentation**: 3 spaces, no tabs.
+- **Line length**: max 80 chars.
+- **Braces**: K&R — opening brace on the same line as the statement/condition.
+- **Pointer asterisk**: on the variable, not the type — `Token *p_token`, not
+ `Token* p_token`.
+- **Returns**: parenthesized — `return (p_token);`. Return type on its own
+ line above the function name.
+- **Comments**: `/* ... */` for blocks, `//` for single lines; put notes on
+ their own line, not trailing.
+
+## Naming
+
+| Category | Convention | ggaze examples |
+|-------------------|-----------------------------|------------------------------------------|
+| Types | PascalCase | `GgazeWindow`, `GgazeViewer`, `GgazeGrid`, `Navigator`, `Loader`, `Thumbnail` |
+| Functions | `module_action` (snake_case)| `navigator_new`, `navigator_next`, `viewer_set_texture`, `grid_get_selected`, `trash_bin`, `mover_move`, `opener_launch`, `runner_run`, `enhancer_apply`, `clipboard_copy_image` |
+| Variables | `prefix_name` (type prefix) | `p_nav`, `p_texture`, `i_count`, `c_path`, `u_idx`, `b_wrapped` |
+| Macros/constants | UPPER_SNAKE_CASE | `GGAZE_APP_ID`, `GGAZE_PREFETCH_N`, `NO_DEFAULT` |
+| Enum values | `MODULE_PREFIX_NAME` | `GGAZE_SORT_NAME`, `GGAZE_SORT_TIME` |
+| Callbacks | `name_cb` | `loader_ready_cb`, `navigator_changed_cb` |
+| Static/private fns| `_prefix_name` | `_viewer_clamp_pan`, `_loader_pick_backend` |
+
+Variable prefixes: `p_` pointer, `i_` int, `c_` char/string, `u_` unsigned,
+`b_` bool. Use them consistently.
+
+## Module layout (one module per file pair)
+
+Every module is a `foo.h` + `foo.c` pair named after its main type. Related
+types (element, iterator, state) live in the same pair, not split out.
+
+```
+navigator.h / navigator.c → Navigator (+ NavigatorIterator if needed)
+viewer.h / viewer.c → GgazeViewer
+gridview.h / gridview.c → GgazeGrid
+loader.h / loader.c → Loader (+ per-backend structs under loader/)
+trash.h / trash.c → Trash
+mover.h / mover.c → Mover (+ MoverDest)
+opener.h / opener.c → Opener (+ OpenerProg)
+runner.h / runner.c → Runner (+ RunnerScript)
+enhancer.h / enhancer.c → Enhancer (+ EnhancerPreset) [GEGL, optional]
+clipboard.h / clipboard.c → helpers (no type) — like settings
+thumbnail.h / thumbnail.c → Thumbnail
+settings.h / settings.c → wraps GSettings (no custom type, just helpers)
+```
+
+### Lifecycle: `_new` / `_delete`
+
+Every concrete type gets `Type *type_new(...)` and `void type_delete(Type *p)`.
+Pair every `_new` with a `_delete`; never leave allocation unbalanced.
+
+```c
+Navigator*
+navigator_new(GFile *p_dir, GgazeSort sort);
+
+void
+navigator_delete(Navigator *p_nav);
+```
+
+For types passed as `void*` to generic callbacks (e.g. `g_list_free_full`),
+provide `void type_delete_cb(void *p_void)`.
+
+### Iterators
+
+When a module exposes traversal, follow
+`TypeIterator *typeiterator_new(Container *p)`,
+`void typeiterator_delete(TypeIterator *p)`,
+`void *typeiterator_next(TypeIterator *p)`,
+`_Bool typeiterator_has_next(TypeIterator *p)`.
+The iterator type lives in the same file pair as its container.
+
+### Accessors
+
+`module_get_field(obj)`, `module_set_field(obj, val)` — macros for trivial
+access, functions for non-trivial logic. Keep accessor macros side-effect-free.
+
+## Headers
+
+- **Header guards** UPPERCASE, derived from filename:
+ `#ifndef NAVIGATOR_H` / `#define NAVIGATOR_H`.
+- **Header order** inside a header: guard, includes, macros, enums, structs,
+ `new`/`delete`, then the rest.
+- **`.c` include order**: own header first, then system, then project headers.
+ Optional forward declarations between own header and the rest.
+
+## Errors
+
+- **Fatal** errors via the project's `ERROR(...)` (abort with a message).
+- **Recoverable** errors via a documented return: a `RETCODE`, a `gboolean`,
+ or a `GError **` where GLib conventions apply (loader, file I/O).
+- Decoders return `NULL` + set `GError **` on failure; the window shows a
+ placeholder and advances — never crash on a bad file.
+
+## Globals
+
+Minimal. Any global is named UPPER or `_prefix` and lives in the module that
+owns it. No module reaches into another's globals — go through accessors.
+
+## GObject / GTK notes
+
+GTK/GObject types (`GgazeWindow`, `GgazeViewer`, `GgazeGrid`) use `G_DEFINE_TYPE`
+and GObject conventions (constructed/dispose) for the GObject side; the
+`_new`/`_delete` skill pattern still applies to the plain-C modules
+(`Navigator`, `Loader`, `Trash`, `Mover`, `Opener`, `Runner`, `Enhancer`, `Thumbnail`) that have no GObject parent.
+Keep the two worlds clean: plain-C modules own no GtkWidget and are unit-testable
+without a display.
+
+## Testing
+
+Each plain-C module pair ships a `tests/test_<module>.c` using GLib's `GTest`
+framework (or a tiny harness), run via `meson test`. Aim for **≥80% line
+coverage** (gcov/lcov: `meson setup -Db_coverage=true && meson test && ninja -C
+build coverage`), gated in CI. GObject/GTK widgets get smoke tests only; the
+logic lives in the plain-C modules so it's testable without a display.
+Beyond tests, run the `auditing-code-quality` skill (C-adapted:
+`c-best-practices` + `find-code-bugs` + `solid-principles` +
+`beyond-solid-principles`) at milestone boundaries and track findings via
+`agent-task-management`. \ No newline at end of file
diff --git a/docs/gegl.md b/docs/gegl.md
new file mode 100644
index 0000000..0fe54d5
--- /dev/null
+++ b/docs/gegl.md
@@ -0,0 +1,150 @@
+# GEGL Integration
+
+GEGL (Generic Graphics Library) — the data-flow, floating-point,
+non-destructive image-processing framework behind GIMP, GNOME Photos, imgflo,
+and iconographer. You chain *operations* (ops) into a graph; GEGL renders it.
+Full op list: <https://gegl.org/operations/>.
+
+ggaze uses GEGL for **quick, non-destructive photo enhance** plus a few bonus
+capabilities. It is an **optional** dependency (meson `feat