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

glitter.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

Almost 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&#95;SOURCE&#95;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&#95;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&#95;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 / mechanismArgsReturnNotes
"realize"2 (standard)voidThe one signal that fits glitter's default shape
"render"2intNon-void return; always returns 1
"resize"4voidWidth/height as direct int args
tick callback3 (not a signal, gtk_widget_add_tick_callback)intFrame-clock API, not g_signal_connect_data; returns 1 to keep ticking
"motion" (on a controller)4voidx/y as direct double args
"key-pressed" (on a controller, attached to root)5intNon-void; returns 0 to propagate
"key-released" (same controller)5voidSame args as key-pressed, void return
"pressed"/"released" (on a GtkGestureClick)5voidn_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&#95;gl/ plasma.clj and examples/glitter&#95;gl/gl&#95;area&#95;smoke.clj both just leave the warnings on.