summaryrefslogtreecommitdiff
path: root/src/viewer.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/viewer.c')
-rw-r--r--src/viewer.c382
1 files changed, 382 insertions, 0 deletions
diff --git a/src/viewer.c b/src/viewer.c
new file mode 100644
index 0000000..e38d415
--- /dev/null
+++ b/src/viewer.c
@@ -0,0 +1,382 @@
+/*:*
+ * ggaze — large single-image viewer
+ *
+ * Custom GtkWidget (decision #31). Draws the GdkTexture scaled into the widget
+ * with letterboxing, zoom (fit / 100% / in / out), cursor-centered zoom,
+ * drag-to-pan with clamping, and a dark background. M1: synchronous single
+ * image. Compare-before/after (hold-Space) and tool overlays land in M9.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "viewer.h"
+
+#include <glib.h>
+#include <graphene.h>
+
+#define GGAZE_ZOOM_FACTOR 1.25
+#define GGAZE_ZOOM_MIN 0.02
+#define GGAZE_ZOOM_MAX 64.0
+#define GGAZE_PAN_STEP 24.0
+
+struct _GgazeViewer {
+ GtkWidget parent_instance;
+ GdkTexture *p_texture;
+ gboolean b_fit; /* TRUE = fit-to-window; FALSE = use d_zoom */
+ gdouble d_zoom; /* 1.0 = 100% (used when !b_fit) */
+ gdouble d_pan_x; /* offset from centred, in widget px */
+ gdouble d_pan_y;
+ gdouble d_drag_start_pan_x;
+ gdouble d_drag_start_pan_y;
+};
+
+G_DEFINE_TYPE(GgazeViewer, ggaze_viewer, GTK_TYPE_WIDGET)
+
+/* --- geometry --------------------------------------------------------------
+ */
+
+static int
+_tex_w(GgazeViewer *p_v) {
+ return (p_v->p_texture != NULL) ? gdk_texture_get_width(p_v->p_texture) : 0;
+}
+
+static int
+_tex_h(GgazeViewer *p_v) {
+ return (p_v->p_texture != NULL) ? gdk_texture_get_height(p_v->p_texture) : 0;
+}
+
+/* Display scale + clamped top-left for the current state. Also writes the
+ * clamped pan back so stored state matches what is drawn (no "dead zone" when
+ * a new drag begins from a clamped edge). */
+static void
+_compute_geom(GgazeViewer *p_v, int i_w, int i_h, gdouble *p_scale,
+ gdouble *p_x, gdouble *p_y, gdouble *p_dw, gdouble *p_dh) {
+ int i_tw = _tex_w(p_v);
+ int i_th = _tex_h(p_v);
+
+ gdouble s;
+ if (p_v->b_fit) {
+ if (i_tw <= 0 || i_th <= 0 || i_w <= 0 || i_h <= 0) {
+ s = 1.0;
+ } else {
+ s = MIN((gdouble)i_w / i_tw, (gdouble)i_h / i_th);
+ }
+ } else {
+ s = p_v->d_zoom;
+ }
+
+ gdouble dw = (gdouble)i_tw * s;
+ gdouble dh = (gdouble)i_th * s;
+ gdouble x = ((gdouble)i_w - dw) / 2.0 + p_v->d_pan_x;
+ gdouble y = ((gdouble)i_h - dh) / 2.0 + p_v->d_pan_y;
+
+ /* Clamp so the image can't drift off-screen. */
+ gdouble cx =
+ CLAMP(x, MIN(0.0, (gdouble)i_w - dw), MAX(0.0, (gdouble)i_w - dw));
+ gdouble cy =
+ CLAMP(y, MIN(0.0, (gdouble)i_h - dh), MAX(0.0, (gdouble)i_h - dh));
+ /* Write clamped pan back so the stored state tracks the drawn position. */
+ p_v->d_pan_x = cx - ((gdouble)i_w - dw) / 2.0;
+ p_v->d_pan_y = cy - ((gdouble)i_h - dh) / 2.0;
+
+ if (p_scale != NULL) {
+ *p_scale = s;
+ }
+ if (p_x != NULL) {
+ *p_x = cx;
+ }
+ if (p_y != NULL) {
+ *p_y = cy;
+ }
+ if (p_dw != NULL) {
+ *p_dw = dw;
+ }
+ if (p_dh != NULL) {
+ *p_dh = dh;
+ }
+}
+
+static gdouble
+_current_scale(GgazeViewer *p_v) {
+ if (p_v->b_fit) {
+ gdouble s = 1.0;
+ _compute_geom(p_v, gtk_widget_get_width(GTK_WIDGET(p_v)),
+ gtk_widget_get_height(GTK_WIDGET(p_v)), &s, NULL, NULL,
+ NULL, NULL);
+ return (s);
+ }
+ return (p_v->d_zoom);
+}
+
+/* Zoom around widget point (d_cx, d_cy), keeping that point over the same
+ * image pixel. _compute_geom clamps on the next draw. */
+static void
+_zoom_at(GgazeViewer *p_v, gdouble d_cx, gdouble d_cy, gdouble d_new_zoom) {
+ if (p_v->p_texture == NULL) {
+ return;
+ }
+ d_new_zoom = CLAMP(d_new_zoom, GGAZE_ZOOM_MIN, GGAZE_ZOOM_MAX);
+
+ int i_w = gtk_widget_get_width(GTK_WIDGET(p_v));
+ int i_h = gtk_widget_get_height(GTK_WIDGET(p_v));
+ gdouble s_old;
+ gdouble x_old, y_old;
+ _compute_geom(p_v, i_w, i_h, &s_old, &x_old, &y_old, NULL, NULL);
+
+ /* Image-space pixel under the cursor before zoom. */
+ gdouble img_x = (s_old > 0.0) ? (d_cx - x_old) / s_old : 0.0;
+ gdouble img_y = (s_old > 0.0) ? (d_cy - y_old) / s_old : 0.0;
+
+ p_v->b_fit = FALSE;
+ p_v->d_zoom = d_new_zoom;
+
+ gdouble s_new = d_new_zoom;
+ gdouble want_x = d_cx - img_x * s_new;
+ gdouble want_y = d_cy - img_y * s_new;
+ p_v->d_pan_x = want_x - ((gdouble)i_w - (gdouble)_tex_w(p_v) * s_new) / 2.0;
+ p_v->d_pan_y = want_y - ((gdouble)i_h - (gdouble)_tex_h(p_v) * s_new) / 2.0;
+
+ gtk_widget_queue_draw(GTK_WIDGET(p_v));
+}
+
+/* --- GtkWidget vfuncs ----------------------------------------------------- */
+
+static void
+ggaze_viewer_measure(GtkWidget *p_widget, GtkOrientation o, int i_for_size,
+ int *p_min, int *p_nat, int *p_min_bl, int *p_nat_bl) {
+ (void)p_widget;
+ (void)o;
+ (void)i_for_size;
+ /* Fit-to-window fills the allocation via hexpand/vexpand; the viewer's
+ * own natural size is small so a large image doesn't grow the window. */
+ *p_min = 1;
+ *p_nat = 1;
+ *p_min_bl = -1;
+ *p_nat_bl = -1;
+}
+
+static void
+ggaze_viewer_snapshot(GtkWidget *p_widget, GtkSnapshot *p_snap) {
+ GgazeViewer *p_v = GGAZE_VIEWER(p_widget);
+ int i_w = gtk_widget_get_width(p_widget);
+ int i_h = gtk_widget_get_height(p_widget);
+
+ /* Dark background (configurable via settings in M10). */
+ static const GdkRGBA BG = {0.07f, 0.07f, 0.07f, 1.0f};
+ graphene_rect_t bg_rect =
+ GRAPHENE_RECT_INIT(0.f, 0.f, (float)i_w, (float)i_h);
+ gtk_snapshot_append_color(p_snap, &BG, &bg_rect);
+
+ if (p_v->p_texture == NULL) {
+ return;
+ }
+
+ gdouble x, y, dw, dh;
+ _compute_geom(p_v, i_w, i_h, NULL, &x, &y, &dw, &dh);
+ if (dw <= 0.0 || dh <= 0.0) {
+ return;
+ }
+ graphene_rect_t rect =
+ GRAPHENE_RECT_INIT((float)x, (float)y, (float)dw, (float)dh);
+ gtk_snapshot_append_texture(p_snap, p_v->p_texture, &rect);
+}
+
+static void
+ggaze_viewer_dispose(GObject *p_obj) {
+ GgazeViewer *p_v = GGAZE_VIEWER(p_obj);
+ g_clear_object(&p_v->p_texture);
+ G_OBJECT_CLASS(ggaze_viewer_parent_class)->dispose(p_obj);
+}
+
+static void
+ggaze_viewer_class_init(GgazeViewerClass *p_klass) {
+ GtkWidgetClass *p_wc = GTK_WIDGET_CLASS(p_klass);
+ GObjectClass *p_oc = G_OBJECT_CLASS(p_klass);
+ p_wc->measure = ggaze_viewer_measure;
+ p_wc->snapshot = ggaze_viewer_snapshot;
+ p_oc->dispose = ggaze_viewer_dispose;
+ gtk_widget_class_set_css_name(p_wc, "ggazeviewer");
+}
+
+/* --- controllers ---------------------------------------------------------- */
+
+static void
+drag_begin_cb(GtkGestureDrag *p_gesture, gdouble d_x, gdouble d_y,
+ gpointer p_data) {
+ GgazeViewer *p_v = GGAZE_VIEWER(p_data);
+ (void)p_gesture;
+ (void)d_x;
+ (void)d_y;
+ p_v->d_drag_start_pan_x = p_v->d_pan_x;
+ p_v->d_drag_start_pan_y = p_v->d_pan_y;
+}
+
+static void
+drag_update_cb(GtkGestureDrag *p_gesture, gdouble d_dx, gdouble d_dy,
+ gpointer p_data) {
+ GgazeViewer *p_v = GGAZE_VIEWER(p_data);
+ (void)p_gesture;
+ p_v->d_pan_x = p_v->d_drag_start_pan_x + d_dx;
+ p_v->d_pan_y = p_v->d_drag_start_pan_y + d_dy;
+ gtk_widget_queue_draw(GTK_WIDGET(p_v));
+}
+
+static gboolean
+scroll_cb(GtkEventControllerScroll *p_scroll, gdouble d_dx, gdouble d_dy,
+ gpointer p_data) {
+ GgazeViewer *p_v = GGAZE_VIEWER(p_data);
+ if (p_v->p_texture == NULL) {
+ return (FALSE);
+ }
+ (void)d_dx;
+
+ gdouble d_cx = (gdouble)gtk_widget_get_width(GTK_WIDGET(p_v)) / 2.0;
+ gdouble d_cy = (gdouble)gtk_widget_get_height(GTK_WIDGET(p_v)) / 2.0;
+ GdkEvent *p_event =
+ gtk_event_controller_get_current_event(GTK_EVENT_CONTROLLER(p_scroll));
+ if (p_event != NULL) {
+ gdk_event_get_position(p_event, &d_cx, &d_cy);
+ }
+
+ gdouble d_factor =
+ (d_dy < 0.0) ? GGAZE_ZOOM_FACTOR : 1.0 / GGAZE_ZOOM_FACTOR;
+ _zoom_at(p_v, d_cx, d_cy, _current_scale(p_v) * d_factor);
+ return (TRUE);
+}
+
+static gboolean
+key_cb(GtkEventControllerKey *p_key, guint u_keyval, guint u_keycode,
+ GdkModifierType e_state, gpointer p_data) {
+ GgazeViewer *p_v = GGAZE_VIEWER(p_data);
+ (void)p_key;
+ (void)u_keycode;
+ (void)e_state;
+ gboolean b_handled = TRUE;
+
+ switch (u_keyval) {
+ case GDK_KEY_plus:
+ case GDK_KEY_equal:
+ ggaze_viewer_zoom_in(p_v);
+ break;
+ case GDK_KEY_minus:
+ case GDK_KEY_underscore:
+ ggaze_viewer_zoom_out(p_v);
+ break;
+ case GDK_KEY_0:
+ ggaze_viewer_toggle_fit_100(p_v);
+ break;
+ case GDK_KEY_j:
+ ggaze_viewer_pan(p_v, 0.0, GGAZE_PAN_STEP);
+ break;
+ case GDK_KEY_k:
+ ggaze_viewer_pan(p_v, 0.0, -GGAZE_PAN_STEP);
+ break;
+ case GDK_KEY_H:
+ ggaze_viewer_pan(p_v, -GGAZE_PAN_STEP, 0.0);
+ break;
+ case GDK_KEY_L:
+ ggaze_viewer_pan(p_v, GGAZE_PAN_STEP, 0.0);
+ break;
+ default:
+ b_handled = FALSE;
+ break;
+ }
+ return (b_handled);
+}
+
+static void
+ggaze_viewer_init(GgazeViewer *p_v) {
+ p_v->p_texture = NULL;
+ p_v->b_fit = TRUE;
+ p_v->d_zoom = 1.0;
+ p_v->d_pan_x = 0.0;
+ p_v->d_pan_y = 0.0;
+
+ gtk_widget_set_focusable(GTK_WIDGET(p_v), TRUE);
+
+ GtkGesture *p_drag = gtk_gesture_drag_new();
+ gtk_widget_add_controller(GTK_WIDGET(p_v), GTK_EVENT_CONTROLLER(p_drag));
+ g_signal_connect(p_drag, "drag-begin", G_CALLBACK(drag_begin_cb), p_v);
+ g_signal_connect(p_drag, "drag-update", G_CALLBACK(drag_update_cb), p_v);
+
+ GtkEventController *p_scroll =
+ gtk_event_controller_scroll_new(GTK_EVENT_CONTROLLER_SCROLL_VERTICAL);
+ gtk_widget_add_controller(GTK_WIDGET(p_v), p_scroll);
+ g_signal_connect(p_scroll, "scroll", G_CALLBACK(scroll_cb), p_v);
+
+ GtkEventController *p_key = gtk_event_controller_key_new();
+ gtk_widget_add_controller(GTK_WIDGET(p_v), p_key);
+ g_signal_connect(p_key, "key-pressed", G_CALLBACK(key_cb), p_v);
+}
+
+/* --- public API ----------------------------------------------------------- */
+
+GtkWidget *
+ggaze_viewer_new(void) {
+ return (GTK_WIDGET(g_object_new(GGAZE_TYPE_VIEWER, NULL)));
+}
+
+void
+ggaze_viewer_set_texture(GgazeViewer *p_viewer, GdkTexture *p_texture) {
+ g_return_if_fail(GGAZE_IS_VIEWER(p_viewer));
+ g_set_object(&p_viewer->p_texture, p_texture);
+ p_viewer->b_fit = TRUE;
+ p_viewer->d_zoom = 1.0;
+ p_viewer->d_pan_x = 0.0;
+ p_viewer->d_pan_y = 0.0;
+ gtk_widget_queue_draw(GTK_WIDGET(p_viewer));
+}
+
+GdkTexture *
+ggaze_viewer_get_texture(GgazeViewer *p_viewer) {
+ g_return_val_if_fail(GGAZE_IS_VIEWER(p_viewer), NULL);
+ return (p_viewer->p_texture); /* (transfer none) */
+}
+
+void
+ggaze_viewer_zoom_in(GgazeViewer *p_viewer) {
+ g_return_if_fail(GGAZE_IS_VIEWER(p_viewer));
+ _zoom_at(p_viewer, (gdouble)gtk_widget_get_width(GTK_WIDGET(p_viewer)) / 2.0,
+ (gdouble)gtk_widget_get_height(GTK_WIDGET(p_viewer)) / 2.0,
+ _current_scale(p_viewer) * GGAZE_ZOOM_FACTOR);
+}
+
+void
+ggaze_viewer_zoom_out(GgazeViewer *p_viewer) {
+ g_return_if_fail(GGAZE_IS_VIEWER(p_viewer));
+ _zoom_at(p_viewer, (gdouble)gtk_widget_get_width(GTK_WIDGET(p_viewer)) / 2.0,
+ (gdouble)gtk_widget_get_height(GTK_WIDGET(p_viewer)) / 2.0,
+ _current_scale(p_viewer) / GGAZE_ZOOM_FACTOR);
+}
+
+void
+ggaze_viewer_toggle_fit_100(GgazeViewer *p_viewer) {
+ g_return_if_fail(GGAZE_IS_VIEWER(p_viewer));
+ if (p_viewer->b_fit) {
+ p_viewer->b_fit = FALSE;
+ p_viewer->d_zoom = 1.0;
+ p_viewer->d_pan_x = 0.0;
+ p_viewer->d_pan_y = 0.0;
+ } else {
+ p_viewer->b_fit = TRUE;
+ }
+ gtk_widget_queue_draw(GTK_WIDGET(p_viewer));
+}
+
+void
+ggaze_viewer_fit(GgazeViewer *p_viewer) {
+ g_return_if_fail(GGAZE_IS_VIEWER(p_viewer));
+ p_viewer->b_fit = TRUE;
+ p_viewer->d_pan_x = 0.0;
+ p_viewer->d_pan_y = 0.0;
+ gtk_widget_queue_draw(GTK_WIDGET(p_viewer));
+}
+
+void
+ggaze_viewer_pan(GgazeViewer *p_viewer, gdouble d_dx, gdouble d_dy) {
+ g_return_if_fail(GGAZE_IS_VIEWER(p_viewer));
+ p_viewer->d_pan_x += d_dx;
+ p_viewer->d_pan_y += d_dy;
+ gtk_widget_queue_draw(GTK_WIDGET(p_viewer));
+} \ No newline at end of file