summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-12 17:30:26 +0300
committerPaul Buetow <paul@buetow.org>2026-07-12 17:30:26 +0300
commit547b810eea19b0c72731e30ca3e2e339593c1833 (patch)
treee19fc9308d2f93ffd1d558ec705effc6d9ffb752
parent1770b12d5d95476310c27ccf39a91b5ba4a3f119 (diff)
skeleton: GtkApplication, window, GSettings schema, desktop, tests (ft0)
M0 (task ft0): the app shell — no feature views yet (those are M1 large, M7 grid). - src/main.c: g_application_run + --version via handle-local-options (--help is GApplication's own); exits before activate so --version works headless. - src/app.{c,h}: GgazeApp : AdwApplication : GtkApplication (libadwaita styling, decision #29), single-instance (decision #32), G_APPLICATION_HANDLES_OPEN; activate + open reuse the active window (replace-on-new-open); ggaze_window_open remembers the GFile. - src/window.{c,h}: GgazeWindow : GtkApplicationWindow with AdwHeaderBar and a GtkStack (children 'grid'/'large', default 'grid'); dispose clears the file. - data/org.buetow.ggaze.desktop (image/* MIME, validated) and data/org.buetow.ggaze.gschema.xml (all keys + defaults, --strict clean). - meson: libggaze static lib shared by the executable and tests; install to bindir/applications/glib-2.0 schemas; gnome.post_install compiles schemas + updates desktop db. - tests: unit test_app (--version/--help/unknown via subprocess), integration test_window (offscreen stack checks + open-titles), bootstrap. 3/3 green. - docs/tech-stack.md: window-geometry shape corrected to (width,height, fullscreen,maximized) to match the schema.
-rw-r--r--data/.gitkeep0
-rw-r--r--data/meson.build15
-rw-r--r--data/org.buetow.ggaze.desktop13
-rw-r--r--data/org.buetow.ggaze.gschema.xml106
-rw-r--r--docs/IMPLEMENTATION.md18
-rw-r--r--docs/tech-stack.md2
-rw-r--r--meson.build18
-rw-r--r--src/app.c74
-rw-r--r--src/app.h28
-rw-r--r--src/main.c51
-rw-r--r--src/meson.build13
-rw-r--r--src/window.c74
-rw-r--r--src/window.h37
-rw-r--r--tests/meson.build26
-rw-r--r--tests/test_app.c80
-rw-r--r--tests/test_window.c80
16 files changed, 623 insertions, 12 deletions
diff --git a/data/.gitkeep b/data/.gitkeep
deleted file mode 100644
index e69de29..0000000
--- a/data/.gitkeep
+++ /dev/null
diff --git a/data/meson.build b/data/meson.build
new file mode 100644
index 0000000..c12b619
--- /dev/null
+++ b/data/meson.build
@@ -0,0 +1,15 @@
+# ggaze data: .desktop entry + GSettings schema. Full AppStream/icon install
+# lands in M10; this wires the basics so `meson install` lays out the app and
+# compiles the schema. gnome.post_install also refreshes the desktop database.
+
+install_data('org.buetow.ggaze.desktop',
+ install_dir : join_paths(get_option('datadir'), 'applications'))
+
+install_data('org.buetow.ggaze.gschema.xml',
+ install_dir : join_paths(get_option('datadir'), 'glib-2.0', 'schemas'))
+
+gnome = import('gnome')
+gnome.post_install(
+ glib_compile_schemas : true,
+ update_desktop_database : true,
+) \ No newline at end of file
diff --git a/data/org.buetow.ggaze.desktop b/data/org.buetow.ggaze.desktop
new file mode 100644
index 0000000..c5c31d0
--- /dev/null
+++ b/data/org.buetow.ggaze.desktop
@@ -0,0 +1,13 @@
+[Desktop Entry]
+Type=Application
+Name=ggaze
+GenericName=Image Viewer
+Comment=Fast native image viewer for culling camera shoots
+# M0 stub; full MIME/keywords/AppStream wiring lands in M10.
+Exec=ggaze %f
+Icon=ggaze
+Terminal=false
+StartupNotify=true
+Categories=Graphics;Viewer;Photography;
+MimeType=image/png;image/jpeg;image/gif;image/webp;image/tiff;image/avif;image/heif;image/heic;image/jxl;
+Keywords=image;viewer;photo;camera;culling; \ No newline at end of file
diff --git a/data/org.buetow.ggaze.gschema.xml b/data/org.buetow.ggaze.gschema.xml
new file mode 100644
index 0000000..70ddfa4
--- /dev/null
+++ b/data/org.buetow.ggaze.gschema.xml
@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ggaze GSettings schema — org.buetow.ggaze.
+
+ ALL keys are declared up front (M0) with sensible defaults so later modules
+ just read them. See docs/tech-stack.md "Settings keys" for the intended
+ semantics; consumption lands with the matching milestones.
+
+ `window-geometry` is (iiib) = (width, height, fullscreen-as-int-0/1,
+ maximized). The width/height/maximized mapping is finalized in M10; the
+ shape is fixed here so the schema is stable.
+-->
+<schemalist>
+ <enum id="org.buetow.ggaze.sort">
+ <value nick="name" value="0"/>
+ <value nick="capture-time" value="1"/>
+ <value nick="size" value="2"/>
+ </enum>
+
+ <enum id="org.buetow.ggaze.background">
+ <value nick="black" value="0"/>
+ <value nick="dark" value="1"/>
+ <value nick="grey" value="2"/>
+ <value nick="checker" value="3"/>
+ </enum>
+
+ <enum id="org.buetow.ggaze.scroll-behavior">
+ <value nick="zoom" value="0"/>
+ <value nick="pan-when-zoomed" value="1"/>
+ <value nick="navigate" value="2"/>
+ </enum>
+
+ <schema id="org.buetow.ggaze" path="/org/buetow/ggaze/">
+ <key name="sort" enum="org.buetow.ggaze.sort">
+ <default>'name'</default>
+ <summary>Sort order</summary>
+ <description>Sort the folder by name, EXIF capture time, or size.</description>
+ </key>
+
+ <key name="wrap" type="b">
+ <default>true</default>
+ <summary>Wrap at folder ends</summary>
+ <description>Wrap prev/next at the first/last image.</description>
+ </key>
+
+ <key name="background" enum="org.buetow.ggaze.background">
+ <default>'dark'</default>
+ <summary>Viewer background</summary>
+ <description>Background behind the image in large view.</description>
+ </key>
+
+ <key name="scroll-behavior" enum="org.buetow.ggaze.scroll-behavior">
+ <default>'zoom'</default>
+ <summary>Scroll behavior</summary>
+ <description>What the mouse scroll wheel does in large view.</description>
+ </key>
+
+ <key name="slideshow-delay" type="d">
+ <default>3.0</default>
+ <summary>Slideshow delay</summary>
+ <description>Seconds between slides in slideshow mode.</description>
+ </key>
+
+ <key name="thumbnail-size" type="i">
+ <default>128</default>
+ <summary>Grid thumbnail size</summary>
+ <description>Pixel size of grid thumbnails (resizable via +/-).</description>
+ </key>
+
+ <key name="hide-trashed" type="b">
+ <default>false</default>
+ <summary>Hide trashed items</summary>
+ <description>Hide ./Trash/deleted items from the grid entirely.</description>
+ </key>
+
+ <key name="window-geometry" type="(iiib)">
+ <default>(800, 600, 0, false)</default>
+ <summary>Window geometry</summary>
+ <description>(width, height, fullscreen, maximized) restored on launch.</description>
+ </key>
+
+ <key name="destinations" type="a(ss)">
+ <default>[]</default>
+ <summary>Move destinations</summary>
+ <description>Ordered (name, path) pairs for the `m` move popup; order = hotkey order.</description>
+ </key>
+
+ <key name="editors" type="a(ss)">
+ <default>[]</default>
+ <summary>External editors</summary>
+ <description>Ordered (name, command) pairs for the `e` open-in popup; %f = current file.</description>
+ </key>
+
+ <key name="scripts" type="a(ss)">
+ <default>[]</default>
+ <summary>Shell scripts</summary>
+ <description>Ordered (name, command) pairs for the `!` run-script popup; %f/%d expanded, run via /bin/sh -c.</description>
+ </key>
+
+ <key name="enhance-presets" type="a(ss)">
+ <default>[]</default>
+ <summary>Enhance presets</summary>
+ <description>Ordered (name, gegl-graph) pairs for the `a` enhance popup (GEGL only).</description>
+ </key>
+ </schema>
+</schemalist> \ No newline at end of file
diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md
index 359511a..c2be2c3 100644
--- a/docs/IMPLEMENTATION.md
+++ b/docs/IMPLEMENTATION.md
@@ -144,7 +144,8 @@ green (empty suites); CI builds the matrix; `clang-format --dry-run` clean.
- `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`.
+ 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**
@@ -253,7 +254,7 @@ bounded memory.
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; counter = remaining; `t` toggle.
+- 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`.
@@ -273,11 +274,15 @@ bounded memory.
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` — `image/png` (decode in `GTask`) / `text/uri-list`;
- union provider for one file (decision V).
+- `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.
@@ -296,8 +301,9 @@ bounded memory.
- `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).
+ `<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).
diff --git a/docs/tech-stack.md b/docs/tech-stack.md
index f8b54b3..7dc1191 100644
--- a/docs/tech-stack.md
+++ b/docs/tech-stack.md
@@ -70,7 +70,7 @@ it. Treat as a later milestone, but design the loader signature for it now.
- `slideshow-delay` — double (seconds)
- `thumbnail-size` — int: grid thumbnail pixel size (resizable via `+`/`-`)
- `hide-trashed` — bool
-- `window-geometry` — `(iiib)` width, height, maximized; persisted, restored on launch
+- `window-geometry` — `(iiib)` (width, height, fullscreen, maximized); persisted, restored on launch
- `destinations` — `a(ss)`: ordered array of `(name, path)` pairs for the
`m` move popup. List order determines auto-assigned hotkeys (`1`, `2`, …).
Edited via the Preferences dialog (`,`) or `gsettings`.
diff --git a/meson.build b/meson.build
index 4c67730..e21b70c 100644
--- a/meson.build
+++ b/meson.build
@@ -43,9 +43,23 @@ ggaze_conf_h = configure_file(
configuration : conf,
)
-# Include path: source root (generated header lands in the build root, which
-# meson adds automatically for targets that list ggaze_conf_h as a source).
+# Include paths: source root (generated header) + src/ (module headers).
inc = include_directories('.')
+src_inc = include_directories('src')
+
+# --- ggaze library: the app + window (shared between the executable and
+# tests so the GApplication/GtkApplicationWindow code is exercised without
+# duplicating sources). Plain-C modules will join this library as they
+# land (navigator, trash, mover, ...).
+ggaze_lib = static_library('ggaze',
+ files('src/app.c', 'src/window.c'),
+ include_directories : [inc, src_inc],
+ dependencies : [gtk4_dep, glib_dep, gio_dep, adwaita_dep],
+ install : false,
+)
+
+ggaze_deps = [gtk4_dep, glib_dep, gio_dep, adwaita_dep]
subdir('src')
+subdir('data')
subdir('tests') \ No newline at end of file
diff --git a/src/app.c b/src/app.c
new file mode 100644
index 0000000..587feb7
--- /dev/null
+++ b/src/app.c
@@ -0,0 +1,74 @@
+/*:*
+ * ggaze — application object
+ *
+ * Implements GgazeApp (GgazeApp : AdwApplication : GtkApplication). Wires the
+ * GApplication `open` signal (file or folder) and the default `activate`
+ * (no-arg) path to a GgazeWindow, reusing the active window for the
+ * single-instance "replace on new open" behaviour (decision #32). Real
+ * file-vs-folder resolution is the navigator's job in M2; M0 just remembers
+ * the path and titles the window. See docs/architecture.md.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "app.h"
+#include "window.h"
+#include "ggaze-config.h"
+
+#include <glib.h>
+
+struct _GgazeApp {
+ AdwApplication parent_instance;
+};
+
+G_DEFINE_TYPE(GgazeApp, ggaze_app, ADW_TYPE_APPLICATION)
+
+static GgazeWindow *
+_ensure_window(GgazeApp *p_app) {
+ GtkWindow *p_active =
+ gtk_application_get_active_window(GTK_APPLICATION(p_app));
+ if (p_active != NULL && GGAZE_IS_WINDOW(p_active)) {
+ return (GgazeWindow *)p_active;
+ }
+ return (ggaze_window_new(p_app));
+}
+
+static void
+ggaze_app_activate(GApplication *p_app) {
+ GgazeWindow *p_win = _ensure_window(GGAZE_APP(p_app));
+ gtk_window_present(GTK_WINDOW(p_win));
+}
+
+static void
+ggaze_app_open(GApplication *p_app, GFile **p_files, gint n_files,
+ const gchar *c_hint) {
+ (void)c_hint;
+ GgazeApp *p_app_self = GGAZE_APP(p_app);
+ GgazeWindow *p_win = _ensure_window(p_app_self);
+ /* open-question Z / decision #27: one file opens it; many files open the
+ * first file's folder with the first current (navigator wiring in M2). */
+ if (n_files > 0 && p_files != NULL) {
+ ggaze_window_open(p_win, p_files[0]);
+ }
+ gtk_window_present(GTK_WINDOW(p_win));
+}
+
+static void
+ggaze_app_init(GgazeApp *p_app) {
+ (void)p_app;
+}
+
+static void
+ggaze_app_class_init(GgazeAppClass *p_klass) {
+ GApplicationClass *p_app_class = G_APPLICATION_CLASS(p_klass);
+ p_app_class->activate = ggaze_app_activate;
+ p_app_class->open = ggaze_app_open;
+}
+
+GgazeApp *
+ggaze_app_new(void) {
+ return (
+ GGAZE_APP(g_object_new(GGAZE_TYPE_APP, "application-id", GGAZE_APP_ID,
+ "flags", G_APPLICATION_HANDLES_OPEN, NULL)));
+} \ No newline at end of file
diff --git a/src/app.h b/src/app.h
new file mode 100644
index 0000000..7b40ff6
--- /dev/null
+++ b/src/app.h
@@ -0,0 +1,28 @@
+#ifndef GGAZE_APP_H
+#define GGAZE_APP_H
+
+/*:*
+ * ggaze — application object
+ *
+ * GgazeApp is the GApplication/single-instance entry point. It owns the
+ * GtkApplication, registers actions, and routes the GApplication `open`
+ * signal (a file OR a directory) to the window. See docs/architecture.md
+ * "Responsibilities / app".
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include <adwaita.h>
+
+G_BEGIN_DECLS
+
+#define GGAZE_TYPE_APP (ggaze_app_get_type())
+G_DECLARE_FINAL_TYPE(GgazeApp, ggaze_app, GGAZE, APP, AdwApplication)
+
+/* Construct a new single-instance ggaze application. */
+GgazeApp *ggaze_app_new(void);
+
+G_END_DECLS
+
+#endif /* GGAZE_APP_H */ \ No newline at end of file
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..69cf84d
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,51 @@
+/*:*
+ * ggaze — entry point
+ *
+ * Builds the GgazeApp, registers the `--version` option, and hands control to
+ * g_application_run. `--help` is provided by GApplication itself; `--version`
+ * is handled in handle-local-options and exits before any window is created
+ * (so it works headless, which the unit test relies on). See docs/roadmap.md
+ * M0.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "app.h"
+#include "ggaze-config.h"
+
+#include <glib.h>
+#include <gtk/gtk.h>
+
+static const GOptionEntry GGAZE_OPTIONS[] = {
+ {"version", '\0', G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, NULL,
+ "Show version information and exit", NULL},
+ {NULL, '\0', G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, NULL, NULL, NULL},
+};
+
+static gint
+on_local_options(GApplication *p_app, GVariantDict *p_opts, gpointer p_data) {
+ (void)p_app;
+ (void)p_data;
+ gboolean b_version = FALSE;
+ if (g_variant_dict_lookup(p_opts, "version", "b", &b_version) && b_version) {
+ g_print("ggaze %s\n", GGAZE_VERSION);
+ return (0); /* exit success, before activate */
+ }
+ return (-1); /* continue normal startup */
+}
+
+int
+main(int i_argc, char **c_argv) {
+ g_set_application_name("ggaze");
+
+ GgazeApp *p_app = ggaze_app_new();
+ g_application_add_main_option_entries(G_APPLICATION(p_app), GGAZE_OPTIONS);
+ g_signal_connect(p_app, "handle-local-options", G_CALLBACK(on_local_options),
+ NULL);
+
+ int i_status = g_application_run(G_APPLICATION(p_app), i_argc, c_argv);
+
+ g_object_unref(p_app);
+ return (i_status);
+} \ No newline at end of file
diff --git a/src/meson.build b/src/meson.build
index a46993e..187c79d 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -1,3 +1,10 @@
-# ggaze production sources. Populated from M0 onward (see docs/roadmap.md and
-# docs/architecture.md for the module map). Phase 0 ships no sources yet —
-# only the build/test/CI/convention harness, so `ninja` has no C targets here. \ No newline at end of file
+# ggaze executable. The app/window code lives in libggae (see meson.build); the
+# executable only adds the entry point and the --version CLI option.
+
+ggaze_exe = executable('ggaze',
+ 'main.c',
+ include_directories : [inc, src_inc],
+ dependencies : ggaze_deps,
+ link_with : ggaze_lib,
+ install : true,
+) \ No newline at end of file
diff --git a/src/window.c b/src/window.c
new file mode 100644
index 0000000..049be4e
--- /dev/null
+++ b/src/window.c
@@ -0,0 +1,74 @@
+/*:*
+ * ggaze — main window
+ *
+ * Implements GgazeWindow. Builds an AdwHeaderBar + a GtkStack with two named
+ * placeholder children ("grid", "large"); the real views arrive in M1 (large)
+ * and M7 (grid). Tracks the current GFile for later milestones.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "window.h"
+
+#include <adwaita.h>
+#include <glib.h>
+
+struct _GgazeWindow {
+ GtkApplicationWindow parent_instance;
+ GFile *p_file; /* current file/folder, remembered for later use */
+ GtkWidget *p_stack; /* GtkStack: grid/large placeholder children */
+};
+
+G_DEFINE_TYPE(GgazeWindow, ggaze_window, GTK_TYPE_APPLICATION_WINDOW)
+
+static void
+ggaze_window_dispose(GObject *p_obj) {
+ GgazeWindow *p_win = GGAZE_WINDOW(p_obj);
+ g_clear_object(&p_win->p_file);
+ G_OBJECT_CLASS(ggaze_window_parent_class)->dispose(p_obj);
+}
+
+static void
+ggaze_window_class_init(GgazeWindowClass *p_klass) {
+ GObjectClass *p_obj_class = G_OBJECT_CLASS(p_klass);
+ p_obj_class->dispose = ggaze_window_dispose;
+}
+
+static void
+ggaze_window_init(GgazeWindow *p_win) {
+ /* Header bar (libadwaita, decision #29). */
+ GtkWidget *p_header = adw_header_bar_new();
+ gtk_window_set_titlebar(GTK_WINDOW(p_win), p_header);
+
+ /* Two-view stack. Children are placeholders for M1 (large) and M7 (grid). */
+ p_win->p_stack = gtk_stack_new();
+ gtk_stack_set_transition_type(GTK_STACK(p_win->p_stack),
+ GTK_STACK_TRANSITION_TYPE_CROSSFADE);
+ gtk_window_set_child(GTK_WINDOW(p_win), p_win->p_stack);
+
+ GtkWidget *p_grid = gtk_label_new("grid");
+ gtk_widget_add_css_class(p_grid, "dim-label");
+ GtkWidget *p_large = gtk_label_new("large");
+ gtk_widget_add_css_class(p_large, "dim-label");
+ gtk_stack_add_named(GTK_STACK(p_win->p_stack), p_grid, "grid");
+ gtk_stack_add_named(GTK_STACK(p_win->p_stack), p_large, "large");
+ gtk_stack_set_visible_child_name(GTK_STACK(p_win->p_stack), "grid");
+}
+
+GgazeWindow *
+ggaze_window_new(GgazeApp *p_app) {
+ return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, "application", p_app,
+ "default-width", 800, "default-height",
+ 600, NULL)));
+}
+
+void
+ggaze_window_open(GgazeWindow *p_win, GFile *p_file) {
+ g_return_if_fail(GGAZE_IS_WINDOW(p_win));
+ g_return_if_fail(G_IS_FILE(p_file));
+ g_set_object(&p_win->p_file, p_file);
+ char *c_name = g_file_get_basename(p_file);
+ gtk_window_set_title(GTK_WINDOW(p_win), c_name);
+ g_free(c_name);
+} \ No newline at end of file
diff --git a/src/window.h b/src/window.h
new file mode 100644
index 0000000..aeddf64
--- /dev/null
+++ b/src/window.h
@@ -0,0 +1,37 @@
+#ifndef GGAZE_WINDOW_H
+#define GGAZE_WINDOW_H
+
+/*:*
+ * ggaze — main window
+ *
+ * GgazeWindow : GtkApplicationWindow owns the layout: an AdwHeaderBar and a
+ * GtkStack with two placeholder children (`grid`, `large`). M0 keeps the
+ * stack empty of real views; gridview (M7) and viewer (M1) populate it. The
+ * window remembers the current GFile so later milestones can build on it.
+ * See docs/architecture.md "Responsibilities / window".
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "app.h"
+
+#include <gtk/gtk.h>
+
+G_BEGIN_DECLS
+
+#define GGAZE_TYPE_WINDOW (ggaze_window_get_type())
+G_DECLARE_FINAL_TYPE(GgazeWindow, ggaze_window, GGAZE, WINDOW,
+ GtkApplicationWindow)
+
+/* Construct a new window attached to p_app. */
+GgazeWindow *ggaze_window_new(GgazeApp *p_app);
+
+/* Remember p_file as the current file (or folder) and title the window with
+ * its basename. The GFile reference is held until replaced or the window is
+ * destroyed. Real file-vs-folder handling lands in M2. */
+void ggaze_window_open(GgazeWindow *p_win, GFile *p_file);
+
+G_END_DECLS
+
+#endif /* GGAZE_WINDOW_H */ \ No newline at end of file
diff --git a/tests/meson.build b/tests/meson.build
index c0ec9fb..4c02ccd 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -10,6 +10,7 @@
# meson test -C build
# ninja -C build coverage (needs lcov + genhtml)
+# --- unit track ---
test_bootstrap = executable(
'test_bootstrap',
['test_bootstrap.c', ggaze_conf_h],
@@ -19,4 +20,29 @@ test_bootstrap = executable(
)
test('bootstrap', test_bootstrap, suite : 'unit')
+# test_app runs the ggaze binary as a subprocess; meson passes its path.
+test_app = executable(
+ 'test_app',
+ ['test_app.c', ggaze_conf_h],
+ include_directories : inc,
+ dependencies : [glib_dep, gio_dep],
+ install : false,
+)
+test('app', test_app,
+ suite : 'unit',
+ args : [ggaze_exe.full_path()],
+ depends : ggaze_exe,
+)
+
+# --- integration track (needs a display; CI uses xvfb-run) ---
+test_window = executable(
+ 'test_window',
+ ['test_window.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : ggaze_deps,
+ link_with : ggaze_lib,
+ install : false,
+)
+test('window', test_window, suite : 'integration')
+
subdir('integration') \ No newline at end of file
diff --git a/tests/test_app.c b/tests/test_app.c
new file mode 100644
index 0000000..b1438a1
--- /dev/null
+++ b/tests/test_app.c
@@ -0,0 +1,80 @@
+/*:*
+ * ggaze — app CLI unit test
+ *
+ * Exercises the ggaze binary as a subprocess (so --help/--version, which call
+ * exit() inside g_application_run, do not terminate the test process itself).
+ * Verifies: `--version` prints "ggaze" and exits 0; `--help` exits 0; an
+ * unknown flag exits non-zero. No display is needed — all three exit before
+ * activate. The ggaze binary path is passed as argv[1] by meson.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "ggaze-config.h"
+
+#include <glib.h>
+#include <gio/gio.h>
+
+static const char *GGAZE_BIN;
+
+/* Run GGAZE_BIN with c_arg, return the exit status (0 on success). If
+ * c_out is non-NULL, receive the combined stdout+stderr. */
+static gint
+run_one(const char *c_arg, char **c_out) {
+ GError *p_err = NULL;
+ GSubprocess *p_sub = g_subprocess_new(G_SUBPROCESS_FLAGS_STDOUT_PIPE |
+ G_SUBPROCESS_FLAGS_STDERR_MERGE,
+ &p_err, GGAZE_BIN, c_arg, NULL);
+ g_assert_no_error(p_err);
+
+ char *c_stdout = NULL;
+ g_subprocess_communicate_utf8(p_sub, NULL, NULL, &c_stdout, NULL, &p_err);
+ g_assert_no_error(p_err);
+
+ g_assert_true(g_subprocess_get_if_exited(p_sub));
+ gint i_status = g_subprocess_get_exit_status(p_sub);
+
+ if (c_out != NULL) {
+ *c_out = c_stdout;
+ } else {
+ g_free(c_stdout);
+ }
+ g_object_unref(p_sub);
+ return (i_status);
+}
+
+static void
+test_version(void) {
+ char *c_out = NULL;
+ gint i_status = run_one("--version", &c_out);
+ g_assert_cmpint(i_status, ==, 0);
+ g_assert_nonnull(c_out);
+ g_assert_nonnull(g_strstr_len(c_out, -1, "ggaze"));
+ g_free(c_out);
+}
+
+static void
+test_help(void) {
+ /* GApplication provides --help itself; it prints and exits 0. */
+ gint i_status = run_one("--help", NULL);
+ g_assert_cmpint(i_status, ==, 0);
+}
+
+static void
+test_unknown_flag(void) {
+ gint i_status = run_one("--definitely-not-a-ggaze-flag", NULL);
+ g_assert_cmpint(i_status, !=, 0);
+}
+
+int
+main(int i_argc, char **c_argv) {
+ g_assert_cmpint(i_argc, >=, 2);
+ GGAZE_BIN = c_argv[1];
+
+ g_test_init(&i_argc, &c_argv, NULL);
+ g_test_add_func("/app/version", test_version);
+ g_test_add_func("/app/help", test_help);
+ g_test_add_func("/app/unknown_flag", test_unknown_flag);
+ return (g_test_run());
+} \ No newline at end of file
diff --git a/tests/test_window.c b/tests/test_window.c
new file mode 100644
index 0000000..0570397
--- /dev/null
+++ b/tests/test_window.c
@@ -0,0 +1,80 @@
+/*:*
+ * ggaze — window smoke test (integration)
+ *
+ * Constructs a GgazeWindow offscreen and asserts the two-view stack exists
+ * (children "grid" and "large", default visible = "grid"), and that
+ * ggaze_window_open titles the window with the file's basename.
+ *
+ * The window is built with g_object_new() (no "application" property): the
+ * stack/header are constructed in ggaze_window_init, independent of the app
+ * association. Setting GtkWindow:application requires the GApplication
+ * ::startup signal to have fired (it does in production via activate/open;
+ * not in this isolated smoke test). Needs a display, so this lives in the
+ * `integration` suite (CI runs it under xvfb); skipped cleanly when no
+ * display is available.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "window.h"
+
+#include <gio/gio.h>
+#include <glib.h>
+#include <gtk/gtk.h>
+
+static GgazeWindow *
+new_window(void) {
+ return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, NULL)));
+}
+
+static void
+test_stack_has_two_views(void) {
+ GgazeWindow *p_win = new_window();
+ g_assert_nonnull(p_win);
+
+ GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win));
+ g_assert_nonnull(p_child);
+ g_assert_true(GTK_IS_STACK(p_child));
+
+ GtkStack *p_stack = GTK_STACK(p_child);
+ GListModel *p_pages = G_LIST_MODEL(gtk_stack_get_pages(p_stack));
+ g_assert_cmpint(g_list_model_get_n_items(p_pages), ==, 2);
+ g_assert_nonnull(gtk_stack_get_child_by_name(p_stack, "grid"));
+ g_assert_nonnull(gtk_stack_get_child_by_name(p_stack, "large"));
+ g_assert_cmpstr(gtk_stack_get_visible_child_name(p_stack), ==, "grid");
+
+ g_object_unref(p_win);
+}
+
+static void
+test_open_titles_window(void) {
+ GgazeWindow *p_win = new_window();
+
+ GFile *p_file = g_file_new_for_path("/tmp/IMG_0001.jpg");
+ ggaze_window_open(p_win, p_file);
+
+ g_assert_cmpstr(gtk_window_get_title(GTK_WINDOW(p_win)), ==, "IMG_0001.jpg");
+
+ g_object_unref(p_file);
+ g_object_unref(p_win);
+}
+
+int
+main(int i_argc, char **c_argv) {
+ g_test_init(&i_argc, &c_argv, NULL);
+
+ /* Tolerate host GTK WARNINGs (e.g. an unknown gtk-modules key delivered
+ * by the live GNOME session via XSettings) that g_test_init makes fatal.
+ * Keep CRITICALs fatal — those are real bugs. */
+ g_log_set_always_fatal(G_LOG_LEVEL_ERROR | G_LOG_LEVEL_CRITICAL);
+
+ if (!gtk_init_check()) {
+ g_test_skip("no display available (run under xvfb)");
+ return (g_test_run());
+ }
+
+ g_test_add_func("/window/stack_has_two_views", test_stack_has_two_views);
+ g_test_add_func("/window/open_titles_window", test_open_titles_window);
+ return (g_test_run());
+} \ No newline at end of file