The :gl-area widget layer
glitter-gl.gtk registers exactly one hiccup tag, :gl-area, into glitter's widget registry (glitter.widget/register-widget!). This page covers its mechanics: how its handlers actually get wired (a real, live-found correction to the original port design), and the GTK4 signal shapes it has to handle that don't fit glitter's uniform void(widget,data) path.
[:gl-area {:version [3 2] :depth-buffer true :hexpand true :vexpand true
:on-realize (fn [area] ...)
:on-render (fn [area] ...)
:on-resize (fn [area w h] ...)
:on-tick (fn [area] ...)
:on-motion (fn [area x y] ...)
:on-key (fn [area keyval pressed?] ...)
:on-button (fn [area btn pressed? x y] ...)}]
Why :apply, not :connect: a corrected design
:apply, not :connect: a corrected designglitter.widget/register-widget!'s own docstring describes a :connect key on a widget spec: "the optional :connect runs at create! time after the generic :on-* wiring, for widgets whose signals don't fit the uniform void(widget,data) shape (e.g. a GtkGLArea's realize/render/resize)." :gl-area is that docstring's own motivating example, so the original port design (see the design spec's glitter-gl.gtk section) naturally called for registering :gl-area with a :connect closure that wires realize/render/resize/tick once, at construction.
This does not work, and the reason is specific to how glitter.core actually drives widget creation. :connect genuinely runs (the bug isn't in :connect itself), but it never sees any of :gl-area's real props, so it has nothing to wire.
Tracing why
glitter.core/create-node is the only place IRender/create-element is ever called, and it passes almost nothing:
;; glitter.core/create-node
(let [tag-name (hiccup/tag-name headers)
ns (get-ns headers)
node (r/create-element renderer tag-name (when ns {:ns ns}))
[attrs mounting-attrs] (get-mounting-attrs headers)
_ (set-attributes renderer node (or mounting-attrs attrs))
...]
...)
The options argument create-element receives is (when ns {:ns ns}), an XML-namespace hint for SVG-style tags, nil for everything else. Never the hiccup element's own attribute map. glitter.gtk's create-element passes that same near-empty map straight through to glitter.widget/create!:
;; glitter.gtk
(create-element [_ tag-name options]
(let [tag (keyword tag-name)
widget (w/create! tag (or options {}))]
...))
;; glitter.widget
(defn create! [tag props]
(let [props (with-orientation tag props)
s (spec-for! tag)
widget ((:ctor s) props)]
((:apply s) widget props)
(apply-widget-props! widget props)
(connect-signals! tag widget props)
(when-let [connect (:connect s)] (connect widget props)) ; <- props here
widget))
:connect's own props argument is exactly the options value threaded all the way from create-node: at most {:ns "..."}, never :on-realize/:on-render/etc. A :connect closure registered for :gl-area runs, once, with an empty (or near-empty) prop map, finds none of the handler keys it's looking for, and does nothing. Silently: no exception, no warning, just a GtkGLArea that never realizes.
The real props arrive through a separate path, right after create-element returns: create-node's own (set-attributes renderer node (or mounting-attrs attrs)) call. set-attributes iterates the attribute map and calls set-attr once per key, which (for any key that isn't :style/:classes/:on) calls set-attr-val, which ultimately routes through glitter.widget/apply-props! to the widget spec's :apply closure. :apply runs once per prop key, both at construction and on every re-render. glitter.gtk's set-attribute (the only caller of apply-props! outside create! itself) always passes a single-key map, {(keyword a) v}, never the full prop map; apply-props!'s own docstring confirms this is exactly the shape it's designed for ("safe to call with a single-key partial map"). This is the exact same path :scale's :apply already relies on to re-range its min/max/step on every render (its :ctor doesn't see real props either), found live while tracing this, and cited directly as precedent in gtk.clj's own CORRECTION comment.
So: :apply is the only widget-spec closure that ever sees a hiccup element's real prop values, at any point in its lifecycle. :connect runs at the right time (once, at construction) but with the wrong data (never the real props): it's not a usable extension point for wiring event handlers under glitter's actual reconcile flow, regardless of what its docstring promises. This was found live via Task 17's smoke: the first version of :gl-area, wired through :connect exactly as the design spec prescribed, passed every unit test (none of which mount a real widget tree) and then never fired :on-realize when actually run against GTK.
The fix: wire from :apply, guarded idempotent
(defonce ^:private wired (atom {}))
(defn- wire-once!
"True the FIRST time `event` is seen for `area`; false (and no side
effect) on any repeat call."
[area event]
(let [seen (get @wired area #{})]
(when-not (contains? seen event)
(swap! wired update area (fnil conj #{}) event)
true)))
(defn- gl-area-apply! [area props]
(let [{:keys [on-realize on-render ...]} props]
(when (and on-realize (wire-once! area :on-realize))
(connect! area "realize" ...))
...))
:apply can run several times over a :gl-area's life, once per prop key, both at construction and on every re-render (never with the whole prop map in one call), but each signal must only ever be connected once; reconnecting "realize" on every render would pile up duplicate callbacks, each firing independently. wire-once! gates every branch on [area event], so gl-area-apply! is safe to call as many times as :apply actually is, while each underlying g_signal_connect_data (or gtk_widget_add_tick_callback) call happens exactly once per widget per event.
Write-once contract for callers, distinct from :apply's general per-key mechanism above: the general mechanism (:apply runs once per prop key, both at construction and on every re-render) is what lets most widgets (:scale's min/max/step, for instance) pick up fresh values on every render. :gl-area specifically does NOT get that benefit for its event-handler props, because wire-once! only reacts to the FIRST arrival of each event key: once :on-realize/:on-render/:on-resize/:on-tick/ :on-motion/:on-key/:on-button has been wired once for a given :gl-area widget, every subsequent :apply call for that same event key is a guarded no-op; the closure connected on first arrival keeps running for the widget's entire life, even if a later render supplies a different closure for the same key. Concretely: if a caller (e.g. glitter-gl.app/reactive-area) is invoked again for what's meant to be the same :gl-area mount point, expecting its new opts/closures to replace the old ones, they will not; the original closures keep running. Callers must build a :gl-area prop map once, at a stable point, and reuse the same result across renders.
Known v1 limitation, found during review, not fixed: wired is keyed by the raw GtkGLArea pointer (a plain machine address) with no release path when a widget is destroyed. If GTK/GLib ever reuses a freed :gl-area's address for a brand-new widget, the new widget would silently inherit the old one's wired entries and never get its handlers connected: no exception, just a GL area that never realizes. glitter.gtk's own memory atom sidesteps this exact trap by keying off a tracking atom's Clojure identity instead of a raw pointer; that pattern isn't available here without changing glitter.widget's :apply contract to pass a stable identity alongside the raw widget pointer, which is out of scope for this fix. Not currently a live risk (every call site in this project mounts one :gl-area for the app's lifetime), but revisit if a future task introduces dynamic :gl-area mount/unmount.
What this means for extending :gl-area, or registering a different custom widget with non-standard signals (here or in glitter itself): wire from :apply, guarded idempotent, never from :connect.
Signal shapes that don't fit the uniform void(widget,data) path
void(widget,data) pathAlmost every GTK signal glitter's own set-event-handler connects is void(widget, user_data). :gl-area needs several shapes that don't match, wired directly by glitter-gl.gtk itself (it does not go through glitter.widget/signals/signal-value at all; every :gl-area handler is application-supplied, not routed through glitter's action-dispatch system; see scene-and-app.md for why).
"render": non-void return. GtkGLArea's "render" signal is gboolean render(GtkGLArea*, GdkGLContext*, gpointer). glitter-gl's foreign-callable declares it as [:pointer :pointer] :void, 2 args (the extra GdkGLContext* is simply not read, a harmless simplification inherited unchanged from glimmer-gl's own file), but with an :int return, not :void:
(ffi/foreign-callable (fn [a _] (on-render a) 1)
[:pointer :pointer] :int :collect-safe)
Always returning 1 (TRUE) tells GTK the draw call is handled and nothing further needs to run.
"resize": extra int arguments. GtkGLArea's "resize" signal carries the new width/height directly as signal arguments: void resize(GtkGLArea*, gint width, gint height, gpointer): 4 parameters, not 2:
(ffi/foreign-callable (fn [a w h _] (on-resize a w h))
[:pointer :int :int :pointer] :void :collect-safe)
"realize" is the one signal here that IS the standard shape: void realize(GtkGLArea*, gpointer), wired exactly like any other glitter signal.
on-tick isn't a GTK signal at all. There is no "tick"/"frame" signal on GtkGLArea; frame-synced callbacks go through a completely separate GTK API, gtk_widget_add_tick_callback, which registers directly against the widget's GdkFrameClock rather than via g_signal_connect_data:
(gtk-widget-add-tick-callback area
(let [cb (ffi/foreign-callable
(fn [a _clock _data] (on-tick a) (queue-render a) 1)
[:pointer :pointer :pointer] :int :collect-safe)]
(w/retain-callable! cb) cb)
ffi/null ffi/null)
The callback signature is gboolean callback(GtkWidget*, GdkFrameClock*, gpointer); returning 1 (GDK_SOURCE_CONTINUE, aliased through gboolean's ABI) keeps the tick firing on every subsequent frame, 0 would cancel it. on-tick always queues a render immediately after, so an app using it gets a continuous render loop for free without having to call queue-render itself.
on-motion layers a GtkEventControllerMotion onto the area, since GtkGLArea itself has no pointer-motion signal; controllers are GTK4's mechanism for attaching extra input handling to any widget:
(let [ctl (gtk-event-controller-motion-new)]
(gtk-widget-add-controller area ctl)
(connect! ctl "motion"
(ffi/foreign-callable (fn [_ x y _] (on-motion area (double x) (double y)))
[:pointer :double :double :pointer] :void :collect-safe)))
"motion"'s real signature is void motion(GtkEventControllerMotion*, gdouble x, gdouble y, gpointer): the two gdouble coordinates arrive as direct signal arguments, same shape class as "resize"'s extra ints but with :double instead of :int.
on-key needs a controller on the root window, not the GLArea itself. GtkGLArea can't hold keyboard focus (gtk_widget_grab_focus returns FALSE even with :can-focus set), so a GtkEventControllerKey attached directly to the area never receives key events. The fix wires a small self-arming handler onto :gl-area's OWN "realize" signal (a second, independent "realize" connection, alongside whatever the caller's own :on-realize does) that looks up the root window via gtk_widget_get_root (which only resolves once the widget is actually realized and attached to a window) and attaches the key controller there, once:
(let [armed? (atom false)
arm (ffi/foreign-callable
(fn [_area _]
(when-not @armed?
(reset! armed? true)
(let [root (gtk-widget-get-root area)
ctl (gtk-event-controller-key-new)]
(when-not (ffi/null? root)
(gtk-widget-add-controller root ctl)
(connect! ctl "key-pressed" ...)
(connect! ctl "key-released" ...)))))
[:pointer :pointer] :void :collect-safe)]
(connect! area "realize" arm))
"key-pressed"'s real signature is gboolean key_pressed (GtkEventControllerKey*, guint keyval, guint keycode, GdkModifierType state, gpointer): 4 args, non-void return (a third distinct callable shape in this file, alongside "render"'s 2-arg/non-void and "resize"'s 4-arg/void):
(ffi/foreign-callable (fn [_ kv _kc _st _] (on-key area (int kv) true) 0)
[:pointer :uint :uint :uint :pointer] :int :collect-safe)
Returning 0 (GDK_EVENT_PROPAGATE) lets the key event continue past this handler to any other consumer (e.g. GTK's own focus-navigation keys). "key-released" carries the identical 4 arguments but IS void-returning; the pressed/released pair is asymmetric in GTK4's own API, not a glitter-gl inconsistency.
on-button layers a GtkGestureClick, GTK4's gesture-based mouse button API, the same controller-attachment pattern as on-motion:
(let [g (gtk-gesture-click-new)]
(gtk-widget-add-controller area g)
(connect! g "pressed"
(ffi/foreign-callable (fn [_ _n x y _] (on-button area 1 true (double x) (double y)))
[:pointer :int :double :double :pointer] :void :collect-safe))
(connect! g "released" ...))
"pressed"/"released"'s real signature is void pressed (GtkGestureClick*, gint n_press, gdouble x, gdouble y, gpointer): 5 args, void return; the n_press (click-count) argument is read but discarded, and on-button is always called reporting button 1 (no button-index disambiguation in this version).
Device pixels vs. logical points: a Retina-only trap
"resize" reports the framebuffer size in device pixels, correct for gl/gl-viewport, which needs real framebuffer pixels: on a 2x Retina display, a 900x600 logical window resizes to 1800x1200. "motion"'s (x, y) arguments, by contrast, arrive in logical points, the same units GTK reports widget/window sizes in everywhere else: on that same 900-wide area, a live :on-motion trace never exceeds roughly 900, not 1800.
Anything that unprojects a pointer position using the viewport dimensions captured from "resize" is silently wrong by the display's scale factor, and the bug is invisible on a non-Retina display, where the scale factor is 1 and the two units happen to coincide. Found live during this project's picking.clj arc: a standalone probe ((gtk-gl-area "resize") against gtk_widget_get_width/gtk_widget_get_height against a live :on-motion trace, all on the same window) confirmed the split directly, after a first version of the example placed its marker nowhere near the actual pointer on a Retina machine.
The fix is to source width and height from glx/widget-width/ glx/widget-height (gtk.clj:116-122, thin wrappers over gtk_widget_get_width/gtk_widget_get_height, both logical) for anything unprojecting a screen pixel, and to reserve a "resize"-populated viewport atom for gl/gl-viewport and any aspect-ratio computation, which is scale-invariant either way and doesn't care which unit it's given. gtk.clj's own docstrings for these two functions just say "in pixels (GTK4)"; "logical" is this guide's own term for the distinction drawn above, between "resize"'s device pixels and gtk_widget_get_width/ gtk_widget_get_height's GTK4-defined widget-space pixels, not additional wording gtk.clj itself uses. examples/glitter_gl/picking.clj is the first, and so far only, consumer of pointer input anywhere in this project, and follows this pattern.
Summary: every non-standard shape in this file
| Signal / mechanism | Args | Return | Notes |
|---|---|---|---|
"realize" | 2 (standard) | void | The one signal that fits glitter's default shape |
"render" | 2 | int | Non-void return; always returns 1 |
"resize" | 4 | void | Width/height as direct int args |
| tick callback | 3 (not a signal, gtk_widget_add_tick_callback) | int | Frame-clock API, not g_signal_connect_data; returns 1 to keep ticking |
"motion" (on a controller) | 4 | void | x/y as direct double args |
"key-pressed" (on a controller, attached to root) | 5 | int | Non-void; returns 0 to propagate |
"key-released" (same controller) | 5 | void | Same args as key-pressed, void return |
"pressed"/"released" (on a GtkGestureClick) | 5 | void | n_press + x/y as direct args |
Every one of these is wired with its own literal foreign-callable call inside gl-area-apply!; jolt.ffi/foreign-callable's argtypes/ rettype must be compile-time literals (the same constraint glitter's own :switch/:list-box signal generalization hit and documented in its own docs/guide/gtk-widget-layer.md), so there is no data-driven table here either; adding a ninth :gl-area handler with yet another shape means adding its own literal branch by hand.
A cosmetic side effect: dev-time hiccup warnings on every render
:gl-area's :on-realize/:on-render/:on-resize/:on-tick props all have names starting with "on", the exact pattern glitter's ported Replicant hiccup validation (glitter.asserts/assert-no-event-attribute) flags as a likely :on {} mistake, printing "Set event listeners in the :on map... Instead of :on-realize set :on {:realize ,,,}" once per prop key, per render. The warning does not affect anything: create-node's set-attributes call still routes the value through set-attr-val to r/set-attribute โ glitter.widget/apply-props! โ gl-area-apply! exactly as described above, regardless of the console noise. It's a false positive specific to this widget's non-standard prop shape, not a sign anything is broken.
There is no way to exempt :gl-area's specific props from this check without modifying glitter.core/glitter.asserts themselves (out of scope for glitter-gl).
(glitter.env/configure! :glitter/asserts? false) looks like the obvious escape hatch, but it does NOT work when called from an app's own -main, verified live, not just read. glitter.assert's enter-node/ assert are macros whose (when (assert? ) ...) gate runs at MACROEXPANSION time: once, when glitter.core itself is compiled, not per-call at runtime. glitter.core gets compiled the moment any namespace :requires glitter.app/glitter.gtk (both pull it in transitively), which happens as part of processing the ns form itself, before any of that file's own code, -main included, ever runs. So by the time -main calls configure!, glitter.core's macros have already expanded with asserts baked in; the call is a no-op. Confirmed with a two-line probe: (configure! ...) (require '[glitter.core]) then checking glitter.assert/assert? reports false when configure! runs first, but calling it in the opposite order (the only order a single namespace's -main can actually achieve, since its own ns form's :require already ran before -main exists to call anything) leaves assert? at its default true; the check simply never sees the config change in time.
Making configure! actually take effect needs a genuinely separate bootstrap namespace: one that requires only glitter.env, calls configure! first, and only then dynamically (require 'the-app-ns), so so glitter.core's compilation happens after the config change, not before. glitter-gl doesn't ship one (the added entry-point complexity wasn't judged worth it for a cosmetic wart); examples/glitter_gl/ plasma.clj and examples/glitter_gl/gl_area_smoke.clj both just leave the warnings on.