1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
|
# ggaze — Implementation Plan
The engineering execution layer between the design docs and the first line of
code. The design is locked in via the decisions log in [PLAN.md](PLAN.md);
this page says *how* we build it, in what order, and what "done" means.
## Guiding principles
1. **Every milestone ends runnable** (per [roadmap.md](roadmap.md)) — and every
commit within a milestone compiles + `meson test` passes.
2. **Plain-C modules first, GTK shell thin.** Build and unit-test the logic
modules before (or alongside) the UI that consumes them, so coverage is
natural rather than retrofit.
3. **Conventions are build-time enforced**, not just documented: a `clang-format`
CI gate + an ASan/UBSan build lane are worth more than a doc page.
4. **Optional features are off by default in CI** (`gegl`, `jxl`, `avif`,
`heif`), with one CI lane that builds `gegl=enabled` so it never silently
bit-rots.
## Two test tracks (mandatory)
ggaze has **two complementary test tracks**. Both run under `meson test`.
### Unit tests — `tests/test_<module>.c`
- Target the **plain-C modules** (`detect`, `navigator`, `thumbnail`,
`trash`, `mover`, `opener`, `runner`, `enhancer`, `info`, `texturecache`,
`clipboard` helpers). No GTK display needed.
- GLib `GTest` framework; per-module `a(ss)`/path/EXIF fixtures.
- **Coverage gate ≥80%** on these modules (gcov/lcov), flipped to *fail* at
M10, *warn* before.
- Each module lands its test file in the same milestone as the module.
### Integration tests — `tests/integration/test_<flow>.c`
- Exercise **multiple modules together** through the real public API, with a
real temp directory and real files (no mocks of `GFile`/`GFileMonitor`). They
verify the *contracts* between modules the unit tests can't reach.
- Use a lightweight **offscreen GTK harness** (`gtk_test_init` +
`GtkWindow` offscreen via `GdkSurface`/`gtk_widget_realize` under
`GDK_DEBUG=no-grabs`) where a widget is needed; prefer pure-GLib harnesses
where one isn't.
- No coverage gate (they cross module boundaries), but they must be green in CI.
- Land at the milestone that first makes the flow possible, and grow with it.
#### Planned integration suites (mapped to milestones)
| Suite | Lands at | Verifies |
|-------|----------|----------|
| `test_open_and_show.c` | M1 | CLI file arg → window → viewer has a non-null `GdkTexture` of the right size + upright orientation. |
| `test_walk_folder.c` | M2 | Folder arg → navigator listing; `h`/`l` action changes current; `GFileMonitor` add/delete propagates to navigator; wrap at ends. |
| `test_responsive_nav.c` | M3 | Rapid `next` ×10: only the last `GdkTexture` is shown (last-write-wins invariant), UI thread not blocked (measured via a main-loop timer). |
| `test_progressive_jpeg.c` | M6 | A progressive JPEG fires the partial-texture callback at increasing resolution before the final. |
| `test_grid_cull.c` | M7 | Grid view shows N cells; `d` bins one into `./Trash`, cell dims; `u` restores; counter reflects remaining; `Enter`→large on the right cell. |
| `test_move_undo.c` | M8 | Mark 3 → `m`→dest2 → files gone from folder, present in dest; `u` moves back; collision suffixing. |
| `test_runner_rescan.c` | M8 | `!` runs a script that writes a file into the dir; on exit the navigator rescans and the new file appears; injection-guard filename is single-quoted. |
| `test_enhance_flow.c` | M9 (gated) | `a`→preset applies a preview (texture differs from raw); `s` writes `-enhanced.<ext>` with EXIF Orientation=1 and original byte-identical; navigate-away dirty prompt Save/Discard/Cancel. |
| `test_clipboard_copy.c` | M8 | `Ctrl+c` with no marks → `image/png` on `GdkClipboard`; with marks → `text/uri-list`; paste back into a fake target. |
| `test_full_lifecycle.c` | M10 | The elevator-pitch session scripted: open → walk → `i` → `d` ×k → mark → `m`→dest → `e`→program (use `true`) → `!`→script → quit. End-to-end smoke. |
### Test infrastructure (built in Phase 0)
- `tests/meson.build` wires `unit` and `integration` subdirs as separate
`meson test` suites (`-t suite:unit`, `-t suite:integration`) so they can be
run selectively in CI.
- `tests/helpers/` — shared helpers: temp-dir factory, fixture locator,
offscreen-window builder, fake `GdkClipboard` target, main-loop drain with
timeout (prevents a hung test from blocking CI).
- `tests/fixtures/` — curated images per format + a rotated-EXIF JPEG +
progressive JPEG + RAW+JPEG pair + an injection-hostile filename (`;rm -rf /`).
- CI runs: unit always; integration on the minimal lane; the `gegl` lane adds
the gated suites; ASan lane runs both.
---
## Phase 0 — Bootstrap (prerequisite to M0)
One commit. Lays the build/test/convention groundwork before any feature.
**Files**
- `meson.build` (root) — project `org.buetow.ggaze`, C11,
`default_options: warning_level=2`, extra `-Wextra`, ninja. Declare
`dependency('gtk4')`, `glib`, `gio`. Feature options `gegl`, `jxl`, `avif`,
`heif` (each `auto`).
- `src/`, `tests/` (+ `tests/integration/`, `tests/helpers/`, `tests/fixtures/`),
`data/`, `po/`, `build-aux/` dirs.
- `.clang-format` matching [coding-conventions.md](coding-conventions.md);
`.editorconfig` mirroring it. CI `clang-format --dry-run` gate.
- `meson.build` test infra: GLib `g_test_init`; `-Db_coverage=true` support; a
`coverage` target; separate `unit`/`integration` suites.
- CI matrix: `{minimal, gegl}` × `{x86_64}`, ASan/UBSan lane on minimal;
coverage upload; gate set to *warn* until M10.
- `LICENSE`.
- **Top-level `README.md` for humans** — project pitch (the elevator
pitch from PLAN.md), a screenshot placeholder, install/run quick-start
(`ggaze ~/Downloads/Camera/IMG_0001.jpg`), keybindings cheat-sheet table
(the one from ui-and-interactions.md), build/test commands, a "status:
planning → skeleton" line, and links into `docs/`. The audience is a
Fedora user who finds the repo and wants to know what it is and how to
run it — not a contributor-only wall of links.
- **Top-level `AGENTS.md` for agents** — the machine-facing entry point: how
to build/test (`meson setup`, `meson test -t suite:unit` / `suite:integration`,
coverage target), the mandatory two test tracks and their locations,
convention enforcement (`clang-format`, header guards, `type_new`/
`type_delete`), where the design lives (`docs/` index), the module map, the
"optional features are off in CI" rule, the single-`GCancellable`/last-
write-wins invariant, and a pointer to load the `agent-task-management` +
`c-best-practices` + `solid-principles` + `beyond-solid-principles` skills.
This is the file an agent in a fresh context reads first.
**Acceptance:** `meson setup build && ninja -C build && meson test -C build`
green (empty suites); CI builds the matrix; `clang-format --dry-run` clean.
---
## M0 — Skeleton (app + empty window)
**Deliverables**
- `src/main.c` — GApplication with `G_APPLICATION_HANDLES_OPEN`; local-options
for `--version`/`--help`.
- `src/app.c/.h` — owns `GtkApplication`, single-instance (decision #32);
`open` handler: file → parent dir + that file current; folder → grid.
- `src/window.c/.h` — `GgazeWindow : GtkApplicationWindow` (`G_DEFINE_TYPE`);
empty `GtkStack` (`grid`, `large` children); `AdwHeaderBar` (decision #29).
- `data/org.buetow.ggaze.desktop` stub (`image/*` handler).
- `data/org.buetow.ggaze.gschema.xml` — **all keys now** with defaults so
modules read them as they land.
**Tests**
- Unit: `test_app.c` — `--version`/`--help`/unknown arg.
- Smoke: `test_window.c` — offscreen window, stack has two children.
**Acceptance:** `ggaze IMG_0001.jpg` opens an empty window; `--version` works.
---
## M1 — Show one image (zoom/pan)
**Deliverables**
- `src/loader/loader.c/.h` — `loader_load(GFile*, GCancellable*, GError**) →
GdkTexture*`; static backend dispatch; only `pixbuf` registered here.
- `src/loader/detect.c/.h` — `detect_format(head, len) → GgazeFormat`;
magic-byte sniff. Unit-testable.
- `src/loader/backends/pixbuf.c` — GdkPixbuf →
`gdk_pixbuf_apply_embedded_orientation` (decision #26) → `GdkTexture`.
- `src/viewer.c/.h` — `GgazeViewer : GtkWidget` (custom, decision #31): zoom,
pan, fit, cursor-centered zoom, pan clamp, `viewer_set_texture`; scroll
follows `scroll-behavior` (`zoom` / `pan-when-zoomed` / `navigate`).
- `src/window.c` — wire `open` → load → `viewer_set_texture`; large view.
**Tests**
- Unit: `test_detect.c`, `test_loader_pixbuf.c` (incl. rotated-EXIF fixture).
- Integration: `test_open_and_show.c`.
**Acceptance:** one image shows upright; zoom/pan works; detect+loader ≥80%.
---
## M2 — Walk the directory
**Deliverables**
- `src/navigator.c/.h` — plain-C; dir listing, MIME filter, sort (name default;
time/size stubs), current index, mark set, rescan; hide RAW sidecars by
default (decision #33). `GFileMonitor` debounced 250 ms (decision AA);
nearest-fallback on current removal.
- `src/window.c` — `h`/`l`/`←`/`→` → nav → load → viewer; header subtitle
`n / total · filename`; single `GCancellable` (architecture invariant);
`GtkDropTarget` for file/folder.
- `src/shortcuts.c/.h` — `GtkShortcut`+`GtkShortcutController`; actions on the
window/app; one table all milestones add to.
**Tests**
- Unit: `test_navigator.c` (the big one — filter, sort, wrap, marks, rescan,
nearest-fallback). Target ~90%.
- Integration: `test_walk_folder.c`.
**Acceptance:** `ggaze dir/` lists; `h`/`l` walks with wrap; drop reloads;
external `touch` appears in ~250 ms; counter updates.
---
## M3 — Responsive + prefetch
**Deliverables**
- `loader_load_async` + `_finish` via `GTask`; keep a sync worker for tests.
- `src/texturecache.c/.h` — bounded LRU (cap 4) of `GFile → GdkTexture`;
prefetch next+prev on current change.
- Window enforces: one `GCancellable`, drop results whose path ≠ current.
**Tests**
- Unit: `test_texturecache.c` (LRU eviction, cap; `gdk_memory_texture_new`
1×1 so no display).
- Integration: `test_responsive_nav.c` (last-write-wins + non-blocking UI).
**Acceptance:** rapid `jjjjjj` never blocks; visible texture matches current;
bounded memory.
---
## M4 — Fullscreen + slideshow + info
**Deliverables**
- Fullscreen (`f`, auto-hide header); `Esc` contextual back
(marks → fs → large → grid → quit).
- Slideshow (`S`), configurable delay; pause on manual key.
- `src/info.c/.h` — plain-C EXIF gather via `libexif`; rendered as viewer
overlay.
**Tests**
- Unit: `test_info.c` (EXIF extraction + orientation tag).
**Acceptance:** `f`/`S`/`i` work; EXIF shows; `Esc` chain correct.
---
## M5 — Modern formats
**Deliverables**
- `src/loader/backends/jxl.c`, `avif.c`, `heif.c` behind meson features;
register into the dispatcher.
- Animated GIF/WebP via `GdkPixbufAnimation` → `GdkPaintable`.
**Tests**
- Extend `test_detect` + `test_loader_*` per backend, feature-gated.
**Acceptance:** JXL/AVIF/HEIF open when built; minimal build still green.
---
## M6 — Progressive low-res preview
**Deliverables**
- `src/loader/backends/jpeg.c` (libjpeg-turbo, optional; supersedes pixbuf for
JPEG when enabled) — two-phase load emitting partial `GdkTexture`.
- Generalize `Loader` with a `progress_cb(GdkTexture *partial)`.
- Viewer accepts progressive replacement.
**Tests**
- Unit: `test_loader_jpeg.c`.
- Integration: `test_progressive_jpeg.c`.
**Acceptance:** 40 MP JPEG shows a coarse frame <50 ms, refines to full.
---
## M7 — Thumbnail cache + grid view
**Deliverables**
- `src/thumbnail.c/.h` — freedesktop TMS `~/.cache/thumbnails/{normal,large}`
+ custom bucket for 64–512 (decision T); mtime verify; thread-safe worker;
`thumbnail_get_async`.
- `src/gridview.c/.h` — `GgazeGrid` over navigator `GListModel`; lazy cell
decode; `+`/`-` resize → `thumbnail-size`; reflow; mark badges; dim
trashed/deleted; `Enter`/double-click → large; cursor sync both ways.
- `src/trash.c/.h` — `./Trash` bin (lazy, collision suffix), restore-last,
permanent delete.
- Window: `d`/`D`/`u`; `d` advances; `D` on **>1 marked** asks a confirm dialog; counter = remaining; `t` toggle.
**Tests**
- Unit: `test_thumbnail.c`, `test_trash.c`.
- Integration: `test_grid_cull.c`.
**Acceptance:** `ggaze dir/` → grid; thumbnails load async; `Enter`→large;
`d` dims+advances; `u` restores; `+`/`-` resizes and persists.
---
## M8 — Selection, move, open-external & scripts, clipboard, prefs
**Deliverables**
- `src/mover.c/.h` — `destinations` `a(ss)`; `mover_move` (`g_file_move` +
suffix); undo move-back; acts on marks-or-current.
- `src/opener.c/.h` — `editors` `a(ss)`; `%f` expand; detached `GSubprocess`;
acts on original file (decision #38).
- `src/runner.c/.h` — `scripts` `a(ss)`; `/bin/sh -c`, single-quoted `%f`/`%d`
(decision S); `wait_async`; rescan + toast on done.
- `src/clipboard.c/.h` — copies the **displayed** image (modified if a
preview is active, else original) as `image/png` (decode in `GTask`) /
marked files as `text/uri-list`; union provider for one file (decision V).
`Ctrl+Shift+c` (later) copies the original/path.
- Reusable popover (`ggaze_popup`) for move/open/scripts/(enhance later):
`(hotkey, label)` rows + key controller firing on digit/letter.
- Unified one-level undo `u` (decision P).
- Mark UI: `v`/`V`/`Ctrl+a`/`Esc`; header subtitle shows `N marked`; grid
check-badges + large-view indicator.
- Preferences dialog (`,`): `AdwPreferencesWindow` editing ordered `a(ss)`
lists + sort/background/scroll/slideshow/hide-trashed.
**Tests**
- Unit: `test_mover.c`, `test_opener.c` (`true`/`false` commands, weird
filenames), `test_runner.c` (injection guard, exit status), `test_clipboard.c`.
- Integration: `test_move_undo.c`, `test_runner_rescan.c`, `test_clipboard_copy.c`.
**Acceptance:** full culling workflow works keyboard-only.
---
## M9 — GEGL quick-enhance, crop/straighten/rotate, compare (optional)
**Deliverables**
- `meson` `gegl` feature; `src/enhancer.c/.h` plain-C.
- `enhancer_get_presets` (built-in programmatic / user `gegl-graph` text,
decision #34); `enhancer_apply` in `GTask`; `enhancer_export` →
`<stem>-enhanced.<ext>` same dir, collision `-1`, EXIF Orientation=1 on
export (decision #26); defaults to the original format (JPEG quality 95);
a format/quality chooser and a lossless `jpegtran`/`exiftool` path are later.
- Viewer: preset active → import → `GeglBuffer` → apply → `GdkTexture`; not
during scrub (decision #34); "enhanced" badge.
- Compose order load→enhance→rotate→straighten→crop→export (decision #35).
Crop (`c`), straighten (`R`), rotate 90 (`[`/`]`) stack on the preview graph.
- Dirty flag: navigate/`d`/`D`/`m`/quit with dirty → Save/Discard/Cancel
(decisions #34/#18); `s` clears; re-press/Esc discards directly.
- Hold-`Space` compare (decision #23/#24).
- ICC decode/export via GEGL/babl (closes open question G).
- "GEGL not built in" toast when off.
**Tests**
- Unit: `test_enhancer.c` (gated): each preset dims + non-zero; export file
written + orientation=1 + original untouched; rotate-then-crop compose.
- Integration: `test_enhance_flow.c`.
**Acceptance:** `a` preview; `s` copy; `c`/`R`/`[`/`]`; hold-`Space`; dirty
prompt; minimal build toasts cleanly.
---
## M10 — Polish & packaging
**Deliverables**
- AppStream metainfo, app icons (symbolic + full).
- Fedora RPM spec; optional Flatpak manifest.
- `ggaze(1)` man page (stub in M0, finalized here).
- Window geometry persistence (GSettings `window-geometry`).
- **Coverage gate → fail** at <80% on plain-C modules.
- **Quality audit:** `auditing-code-quality` skill (C-adapted:
c-best-practices + find-code-bugs + solid-principles + beyond-solid-principles),
triage via `agent-task-management`, fix all HIGH/MEDIUM.
- **Keyboard-completeness audit:** visible hotkeys, mnemonics, `?` overlay,
full dialog traversal.
- Empty-`./Trash` menu action.
- Integration: `test_full_lifecycle.c` (the elevator-pitch session scripted).
**Acceptance:** RPM builds/installs/registers; man page; coverage gate green;
audit findings resolved; lifecycle integration green.
---
## Cross-cutting
- **Audit cadence:** run the C-adapted `auditing-code-quality` skill at each
M-boundary (decision #40); findings → `agent-task-management` tasks.
- **Conventions:** `clang-format` CI gate + header-guard/`type_new`-`type_delete`
pairing checks where feasible.
- **Fixtures** grow per milestone; one known-good + one known-bad per format.
- **Dependency risk:** pin libadwaita to a Fedora target; validate each
`gegl:op` exists at M9 via a `gegl_operations` introspection test.
- **Memory:** bounded texture LRU from M3; ASan/UBSan lane in CI.
- **Memory-leak profiling (mandatory, per feature):** C has no GC, so **after
every major feature milestone completes** (M1, M2, M3, M7, M8, M9, M10)
run a dedicated leak-profiling pass before starting the next feature:
- Build with `-Db_sanitize=address` and `G_DEBUG=gc-friendly`;
- Run the full `meson test` suite under ASan (unit **and** integration) and
assert zero leak reports for the plain-C modules (`navigator`, `loader`,
`detect`, `thumbnail`, `trash`, `mover`, `opener`, `runner`, `enhancer`,
`info`, `texturecache`, `clipboard`) — every `type_new` must have a matching
`type_delete` and every `GTask`/`GSubprocess`/`GFileMonitor`/`GdkTexture`
must be unreffed;
- Run a scripted session (the elevator-pitch workflow from PLAN.md) under
ASan — open a folder, walk, `d`/`u`, mark, `m`, `e`, `!`, `a`, `s`, quit —
and assert no leak at exit;
- `valgrind --leak-check=full --error-exitcode=1` on a representative subset
where ASan is unavailable (e.g. the gegl lane), as a cross-check;
- Any leak found is a **blocker** for progressing to the next milestone — fix
it, re-run, then move on. Each leak pass is its own tracked task
(`+leakcheck`) that depends on the feature milestone it follows.
Rationale: deferring leak hunting to M10 means chasing leaks across the
whole codebase at once; catching them right after each feature keeps the
cost local and keeps the `_new`/`_delete` discipline honest as the tree grows.
## Suggested execution order
Phase 0 → M0 → M1 → M2 → M3 → **M7** → M4 → M5 → M6 → M8 → M9 → M10.
Pulling **M7 before M4/M5**: the grid is the core differentiator and depends
only on navigator + thumbnail + trash — the highest-value plain-C modules.
Getting them + tests in early maximizes coverage return and de-risks the most
visible feature. Fullscreen/slideshow/modern-formats are polish relative to
"flip through a folder and cull."
|