glitter is a Replicant-style GTK4 renderer for Jolt. Its sibling, glimmer, already applies Reagent's model (ratoms, automatic dependency tracking, component-local state) to GTK4; glitter deliberately applies a different model; Replicant's single application- state atom, pure state -> hiccup view function, top-down re-render, and data-driven action-dispatch handlers. This guide covers how that model was adapted to a native, retained-mode, C-ABI toolkit that has no DOM underneath it.
A .clj (Jolt/Chez Scheme host, not JVM) library:
(require '[glitter.app :as app]
'[glitter.core :as core]
'[glitter.gtk :as gtk])
(defonce state (atom {:count 0}))
(defn view [{:keys [count]}]
[:box {:spacing 12}
[:label {:label (str "Count: " count)}]
[:button {:label "+ 1" :on {:click [[:action/inc]]}}]])
(core/set-dispatch!
(fn [_event actions]
(doseq [[kind] actions]
(case kind :action/inc (swap! state update :count inc) nil))))
(app/run (fn [window] (gtk/mount! window view state)))
Every subsequent swap! on state triggers a full re-render of view; glitter.core's reconciler diffs the new hiccup against the previous vdom and issues the minimal set of IRender/IMemory calls needed to bring the live GTK widget tree in sync.
examples.md: the six interactive demos (four of them 7GUIs tasks) and the twenty-six live-GTK smokes: what each one runs, and what it pins. counter.clj is the 20-line version of the whole model and the fastest way in.architecture.md: the reconcile โ IRender/ IMemory flow, mount!'s state-atom watcher, why elements are tracking atoms rather than raw GTK pointers.porting-and-attribution.md: the four sourcing buckets (ported from Replicant / forked from glimmer / new to glitter / ported from nexus; see nexus.md), and every documented deviation from the pure Replicant port.nexus.md: glitter.nexus, a port of nexus's data-driven action/effect/placeholder dispatch engine. It covers:swap! is allowed), placeholders (resolving event data into action data), actions/expansions (pure functions of state that decide what should happen), and interceptors (the before-*/after-* mechanism the engine itself runs on).:glitter/value and :nexus/on-error โ clojure.tools.logging, and why they live in each demo rather than in the ported files.flights.clj's pure-effects-only Flight Booker, versus crud.clj's and todo.clj's action-expansion retrofits.:entries/:chronology accumulation tree ((pr-str @log), no viewer yet), plus the t/parse-date leniency finding that makes Flight Booker's date validation actually work.gtk-widget-layer.md: the hiccup-tag โ widget registry and the signal connect/disconnect lifecycle. The long one: a per-widget record of how each of the 43 supported tags was added and what it taught. Grouped by what it covers:insert-before's reorder-vs-insert branch and replace-child!'s prev-sibling capture; :scrolled's hidden GtkViewport auto-wrap around a non-GtkScrollable child; and a use-after-dispose bug in list-box-reorder-child!/flow-box-reorder-child!, both of which reused a pointer GTK had already disposed while removing the old wrapping row.set-event-handler to generalize. Most widgets reuse the uniform 2-arg-void shape for free. Four did not: :switch (3-arg, non-void return), :list-box (a distinct 3-arg-void shape, later reused for free by :expander/:paned's notify::*), :notebook (4-arg "switch-page": the first signal that must read its own raw argument, because the getter is still stale when it fires), and :scale-button (the first case where the signal name alone can't determine the shape, making dispatch tag-aware).:box. :center-box's three named slots, :paned's two, :overlay's single queryable main slot plus an unenumerable overlay set, :header-bar/:action-bar's hybrid (one named slot plus an ordered pack-start list), and :grid, whose child placement is driven entirely by the child's own props through the :glitter/structural-props mechanism.IRender/set-attribute at all; signal-value had to be keyed by [tag signal] rather than signal name; a ctor/apply audit found four previously-shipped bugs; and, not glitter-specific: bulk GtkEditable text replacement fires "changed" once or twice depending on the buffer's starting state.:spinner, :progress-bar, :image, :level-bar, :revealer, :picture, :inscription, :search-bar), the GtkEditable-delegate reuses (:password-entry, :search-entry, :editable-label), and :menu-button/:popover: the first popup surface here, and the first hiccup relationship that isn't an ordinary append-child!-managed tree child.app-loop-and-threading.md: the GtkApplication bootstrap and cross-thread marshalling that lets a swap! from any thread safely reach the GTK main loop.testing-and-tasks.md (the unit suite, the headless fake-IRender test renderer, the twenty-six automated live-GTK smokes, and the jolt/bb task surfaces that run them.limitations.md) every known v1 gap, and the reasoning behind leaving each one unfixed for now.glitter.core's reconciler and most of the non-GTK-specific namespaces.NOTICE (repo root): the authoritative file-by-file attribution ledger; porting-and-attribution.md explains it, NOTICE is the source of truth for exact commit SHAs and per-file deviation notes.glitter.app is adapted from the non-reactive slice of glimmer.core (post-to-gui, on-gui, run*, run): bootstrapping a GtkApplication and hopping callbacks onto the GTK main thread has nothing to do with which reconciler sits on top, so this code ports cleanly. The one real change from the original: glimmer's :activate handler hardcodes a call into glimmer's own reconciler (mount); glitter's calls a caller-supplied on-activate callback instead, so glitter.app has no dependency on any particular reconciler at all.
(defn run
[on-activate & {:as opts}]
(let [start (fn [] (run* on-activate opts))]
(if-let [hop (resolve 'jolt.host/call-on-main-thread-async)]
(hop start)
(start))))
run* builds a GtkApplication, connects an :activate signal that creates a window, sets its title/size, calls on-activate with the window's widget pointer (so the caller can gtk/mount! its own root content), presents the window, and optionally wires an :auto-quit-ms timeout (used by every automated live-GTK smoke to quit the loop deterministically instead of hanging). g_application_run then blocks running the GTK main loop.
On macOS, g_application_run must run on the process main thread or AppKit aborts when it sets the main menu. run hops onto Jolt's main-thread pump asynchronously via jolt.host/call-on-main-thread-async when that var resolves (true for an nREPL session, whose primordial thread parks there); otherwise it runs inline and blocks until the app quits (a plain jolt run invocation, where the calling thread already is the process main thread).
on-guiWhile the GTK loop runs, g_application_run owns the main thread. Any code that touches a widget from a different thread (an nREPL eval's worker thread, a future) has to defer that work onto the loop, because off-main-thread widget mutation is an AppKit violation on macOS. GTK's mechanism for this is a one-shot g_idle_add source (idle callbacks run on the main thread):
(defn- post-to-gui [work]
(let [slot (atom nil)]
(reset! slot (ffi/foreign-callable
(fn [_data]
(let [cb @slot]
(try (work) (finally (w/release-callable! cb))))
0)
[:pointer] :int :collect-safe))
(w/retain-callable! @slot)
(g/g-idle-add @slot ffi/null)
nil))
Returning 0 (FALSE) tells GLib to run the source once and remove it; the retained callable is released right after running so a long nREPL session doesn't accumulate one retained closure per re-render.
on-gui is the public entry point every render actually goes through (glitter.gtk/mount!'s state-atom watcher calls it, not render! directly):
(defn on-gui [work]
(cond
(not @gui-loop-running?) (work)
(= (Thread/currentThread) @main-thread) (work)
:else (post-to-gui work)))
Two things matter here, both load-bearing:
glitter.test-renderer never start a GtkApplication, so on-gui degrades to a plain synchronous call, no g_idle_add machinery, no dependency on a loop that doesn't exist.glitter.app tracks which thread g_application_run actually runs on (main-thread, set once in run* via (reset! main-thread (Thread/currentThread))). Without this check, every call to on-gui (even one already safely on the GTK main thread, like a click handler's dispatch triggering a swap! whose watcher fires synchronously) would post asynchronously via g_idle_add, deferring the render to the next main-loop iteration. That breaks any caller expecting a synchronous read-back immediately after triggering a state change (examples/glitter/keyed.clj does exactly this: mutate state, then immediately read the live GTK tree back). The thread-identity check is what makes same-thread renders synchronous while still safely marshalling genuinely cross-thread ones.examples/glitter/main_thread_smoke.clj is the automated pin for exactly this: it mutates state from inside a future (a genuinely different thread), then schedules a read-back through on-gui, and asserts which thread view actually ran on, not merely that the label updated. An unmarshalled watcher would still update the label (nothing stops a worker thread from mutating GTK internals under Jolt; it's just an AppKit violation, not a crash), so a text-only assertion would pass with the bug present and prove nothing. The example records (Thread/currentThread) inside view itself and requires it to equal the GTK main thread, and separately asserts the worker thread really was a different thread (so the whole check can't pass vacuously if future ever ran inline).
This gap existed once: glitter.gtk/mount!'s state-atom watcher originally called the reconciler synchronously on whatever thread performed the swap!, bypassing glitter.app's marshalling entirely. Every other example mutates state from inside activate or a signal callback (i.e. already on the GTK main thread), so none of them could ever have caught it. This example is the one the original design spec called for and the implementation arc initially missed; its absence is why the bug went unnoticed until a dedicated review pass.
flowchart TD state["state atom"] -->|swap!| watch["add-watch fires"] watch --> ongui["glitter.app/on-gui<br/>(marshal to GTK main thread if needed)"] ongui --> view["(view @state)"] view -->|new hiccup| reconcile["glitter.core/reconcile(renderer, root-el, new-hiccup, prev-vdom)"] reconcile -->|"diffs new hiccup against prev-vdom, issues the<br/>minimal set of protocol calls to bring the<br/>live tree in sync"| protocols["glitter.protocols/IRender + IMemory<br/>(glitter.gtk implements this for real GTK4;<br/>glitter.test-renderer implements it for<br/>headless tests)"]
glitter.core (ported from replicant.core, see porting-and-attribution.md) is the entire diff algorithm. It knows nothing about GTK: every effect it wants to have on the live tree goes through the IRender/IMemory protocols in glitter.protocols. This is the seam that makes two different backends (glitter.gtk for real widgets, glitter.test-renderer for headless unit tests) possible from the same reconciler.
glimmer/Reagent hiccup recognizes a function value in tag position ([my-component args...]), and calls it, recursively rendering whatever it returns. glitter's hiccup, ported from Replicant, does not: glitter.hiccup/ hiccup? requires a literal keyword in position 0 ((and (vector? sexp) (keyword? (first sexp)))). A vector whose first element is a function value fails that check, so it isn't recognized as an element to expand at all: it falls through to being treated as an opaque child value, which glitter.gtk's create-text-node then stringifies with str. The failure is silent and easy to miss: no exception, just a child that renders as literal text like [#object[my_ns$my_component 0x1234 "..."] arg1 arg2] instead of the intended widget tree.
The fix is to call the helper as an ordinary function, splicing its return value directly into the parent vector: (my-component args...), not [my-component args...]. Since a glitter view is just a pure function returning data, this works exactly like any other Clojure code, no special hiccup convention needed for an in-file layout helper.
Replicant's real reusable-component mechanism is glitter.alias: defalias registers a function under a qualified keyword, and [my-ns/my-component args...] in hiccup (a vector whose first element genuinely is a keyword) is recognized and expanded via that registry (glitter.alias/alias-hiccup? checks qualified-keyword?, not fn?). Reach for defalias when a fragment needs to be referenced by a stable name across files or registered once for reuse throughout an app; use a plain function call for an ordinary same-file layout helper. examples/glitter/aliased.clj exercises the alias path; examples/glitter/todo.clj's stat-card/ task-row are the plain-function-call case (and were the live bug that surfaced this distinction; see CONTRIBUTING.md's invariants list).
mount!: the state-atom watcherglitter.gtk/mount! is Replicant's state-atom.md pattern, adapted from document.body to a GTK root window:
(defn mount!
[window view state-atom]
(let [r (renderer)
root-el (atom {:tag :window :widget window :children [] :handlers {}})
vdom (atom nil)
render! (fn [state]
(reset! vdom (:vdom (core/reconcile r root-el (view state) @vdom
{:aliases (alias/get-registered-aliases)}))))]
(render! @state-atom)
(add-watch state-atom ::render (fn [_ _ _ state] (app/on-gui (fn [] (render! state)))))
nil))
Three things worth noting:
window itself, tagged :window: GTK windows are single-child containers (gtk_window_set_child), so the mounted [:box ...] (or whatever the view returns) becomes window's one child, not window's replacement. glitter.widget/append-child!/ remove-child!/replace-child! all branch on container-kind, and :window routes to gtk_window_set_child.app/on-gui, not called directly. A swap! on state-atom can originate from any thread (an nREPL eval's worker thread, a future): on-gui is what makes that safe. See app-loop-and-threading.md.{:aliases (alias/get-registered-aliases)}: an application never has to pass its alias registry through by hand.GTK4 has no O(1) indexed-child-lookup API: gtk_widget_get_first_child/gtk_widget_get_next_sibling is an O(n) walk from the start of a container's child list. glitter.core's reconciler, however, expects to be able to hold an opaque "el" value per node and use it as a stable identity across renders (for IMemory's remember/recall, for computing keyed-list diffs, and so on).
So glitter.gtk's IRender/create-element and create-text-node don't return the widget pointer directly; they return a Clojure atom:
{:tag <hiccup tag keyword>
:widget <GTK widget pointer>
:children [<child atom> ...]
:handlers {<event keyword> {:id <signal connection id> :cb <retained foreign-callable>}}}
:widget is the actual GTK pointer, retrieved via the private ptr helper whenever a real FFI call needs it.:children is glitter's own ordered bookkeeping of this element's children (as el atoms), maintained by every IRender method that mutates the tree (append-child, remove-child, insert-before, replace-child, remove-all-children). This is what insert-before consults to tell a fresh insertion apart from a keyed reorder; see gtk-widget-layer.md.:handlers tracks each connected GTK signal's connection id and its retained foreign-callable, so set-event-handler/remove-event-handler can cleanly disconnect and release exactly the right one later.:glitter/structural-props (round 11) holds a handful of props a CHILD carries for its PARENT to consume: :grid-column/:grid-row/ :grid-column-span/:grid-row-span (:grid) and :stack-name (:stack): stashed here by set-attribute instead of being routed to the child's own :apply closure, since no widget's :apply has any idea what "which grid cell am I in" would mean for itself. See gtk-widget-layer.md for the full mechanism and why these props must be plain, non-namespaced keywords.:ctor's props argument is always empty: verified live, round 11A fact easy to assume wrong by reading glitter.widget's widget specs in isolation: :ctor never actually receives the real hiccup props at the real call site. glitter.core's create-node calls IRender/create-element with only an optional XML-namespace hint:
;; glitter.core.clj โ create-node's actual call, confirmed by reading it
(r/create-element renderer tag-name (when ns {:ns ns}))
, so glitter.gtk's create-element always invokes a spec's :ctor with {} or {:ns "..."}, never {:label "..."} or anything resembling a real prop. Confirmed live, not assumed: a throwaway probe mounting [:button {:label "x"}] printed options as nil at the real call site, and the button's own label (read back immediately after w/create! ran) was still empty.
The REAL prop values arrive afterward, through a completely different path: glitter.core's set-attributes calls IRender/set-attribute once per key, not as one batched map:
;; glitter.gtk.clj โ set-attribute's actual body
(set-attribute [_ el a v _opt]
(w/apply-props! (:tag @el) (ptr el) {(keyword a) v})
nil)
Two consequences every widget-spec author needs to know:
:ctor closure that branches on a prop ((if (:label p) ...)) is harmless only if :apply also independently re-applies that same prop: the :ctor branch never actually fires through the real reconciler flow, so the observable end state depends entirely on :apply. If :apply doesn't cover it, the prop silently never takes effect, ever. This shipped as a real bug in :checkbutton-spec's :label and :scale-button-spec's :min/:max/:step for 10 rounds before being caught and fixed in round 11; see gtk-widget-layer.md for the full trace.:apply closure that combines multiple keys into one native call, using a hardcoded fallback for whichever key is absent ((or (:min p) 0)), silently clobbers that key's real value whenever a render changes only ONE of the group: because set-attribute delivers exactly one changed key per call, a lone :max change genuinely arrives as {:max 80} alone, with no :min present to read. The fix is reading the widget's OWN current value as the fallback (via a GTK getter) instead of a hardcoded default: also shipped as a real bug in :scale-spec's and :spin-button-spec's :min/:max handling, masked for 10 rounds because every existing smoke's chosen test values happened to match the broken fallback.IMemory (remember/recall, glitter's equivalent of Replicant's per-element scratch storage (used for e.g. stashing a value on mount and reading it back on update) keys off the el atom itself, not the raw pointer) an atom is already a stable Clojure identity, which sidesteps any question of whether Jolt FFI pointers hash/compare correctly as map keys.
A hiccup handler in glitter is data ([:button {:on {:click [[:action/inc]]}}]) dispatched through one global function registered via core/set-dispatch!:
(core/set-dispatch!
(fn [event actions]
(doseq [[kind & args] actions]
(case kind ...))))
glitter.core/get-event-handler wraps the raw action data in a function that, when the underlying GTK signal fires, calls (*dispatch* event-map actions) (event-map is built by build-event-map, which (on the :clj side) reads the acting element out of (:glitter/node e) rather than a DOM event.target (deviation #1 from the pure Replicant port) see porting-and-attribution.md).
Because the handler is just data, it can change between renders (the same button's :on {:click [...]} can carry different action tuples across two renders) without the signal itself needing to change. glitter.core's diff still calls IRender/set-event-handler again whenever that data changes, which is exactly why glitter.gtk has to manage GTK signal connect/disconnect itself instead of connecting once at widget-creation time. Full mechanics: gtk-widget-layer.md.
Everything runnable lives under examples/glitter/; 32 namespaces, in two kinds:
Run any of them with bb <name>, or jolt -M:<name> without babashka. bb info prints the same grouping live.
| preview | bb name | Task | What it demonstrates |
|---|---|---|---|
![]() | counter | โ | The canonical demo. One state atom, a pure state -> hiccup view, handlers as data. The 20-line version of the whole model. |
![]() | todo | โ | A task board: derived counts computed inline (glitter has no reactive-derivation primitive, the view just re-runs), an entry with :change/:activate, checkbutton toggles, list rendering in a frame. |
![]() | crud | CRUD | Live prefix filter, single-selection list box, name/surname fields, Create/Update/Delete. The spec's "separation of domain and presentation logic" is the pure get-people fn, shared by the view and the select-row expansion. |
![]() | flights | Flight Booker | Constraints between widgets and within one. The first glitter.nexus consumer, and the only demo that needs no action expansions at all: every interaction is at most two effects. |
![]() | temperature | Temperature Converter | Two linked numeric fields, each updating the other. Needs exactly one action expansion, because which field is the source depends on which one you just edited. |
![]() | timer | Timer | The first demo whose state advances on its own: a background tick via a demo-local :effect/schedule, plus a Reset button. |
Every preview is a real recording of the demo running, not a mockup. They are committed under docs/demos/, and each thumbnail links to the full-size recording.
Each one is a deliberate contrast with how the same UI would be written in glimmer, the Reagent-style sibling. counter.clj says it directly: in glimmer, local state lives in a component-scoped ratom and a click closure calls swap! itself. Here all state is in one top-level atom, the view is a pure function of it, and click handlers are data dispatched through one global fn, never closures. That difference is the whole point of the project, and it is easier to see in 20 lines of counter.clj than in any prose.
The four 7GUIs demos add a second axis: each names the specific challenge its task is designed to expose, and shows what that challenge looks like under this model. flights.clj and crud.clj are the interesting pair: same engine, and one needs no action expansions while the other's interactions can't be expressed without them.
flights.clj needed its own parse-date. t/parse-date from jolt-lang/time is lenient, not strict: verified live that "27.03.2014x", "not-a-date" and "31.02.2014" all parse without throwing. The demo wraps it in a round-trip check (parse, reformat with the same formatter, reject unless the result matches the trimmed input), which is the only way the spec's "T is coloured red when ill-formatted" requirement actually works.
bb smokes runs all twenty-six in sequence and stops at the first failure. Each is also a standalone bb <name>.
These exist because GTK4 is a live, stateful system with a blocking main loop, and several of this project's fixed bugs were "obviously correct" on paper and wrong when actually run. Each smoke pins the specific behaviour that was once broken.
bb name | Pins |
|---|---|
smoke | Mount a tree and run the loop without an exception escaping |
keyed | A keyed reorder lands in the right GTK order |
replace-child | A replaced child stays at its position, not the end |
aliased | Aliases expand through the real renderer, on mount and update |
main-thread-smoke | An off-main-thread swap! renders on the GTK main thread |
list-box-reorder-smoke | The g_object_ref_sink fix for the keyed-reorder use-after-dispose bug |
ctor-apply-regression-smoke | Four real ctor/apply bugs found in the round-11 audit stay fixed |
bb name | Pins |
|---|---|
scale-smoke | value-changed delivers the right double, with no spurious dispatch |
switch-smoke | state-set (3-arg, non-void return) via a generalized callable |
toggle-level-smoke | :toggle-button's toggled, plus :level-bar re-render |
link-button-smoke | :link-button reuses :on-click; a real click via gtk_widget_activate |
spin-button-list-box-smoke | :spin-button's tag-safe value-fn; :list-box's row-selected |
password-search-entry-smoke | Both reuse :entry's GtkEditable-delegate changed signal |
expander-paned-smoke | notify::* signals reuse the 3-arg-void callable shape |
notebook-scale-button-smoke | switch-page's raw-arg read and mount-time auto-select; :scale-button's tag-aware value-changed |
bb name | Pins |
|---|---|
revealer-center-box-smoke | :revealer's props-driven reveal; :center-box's three named slots |
overlay-flow-box-smoke | :overlay's one-main-plus-N-overlay shape; :flow-box's verified difference from :list-box |
aspect-frame-calendar-smoke | Single-child container reuse; :calendar's refcounted GDateTime round-trip |
header-bar-action-bar-smoke | The hybrid title-plus-pack-start shape, including the show-title-buttons prepend |
menu-button-popover-smoke | :menu-button's popover-as-child relationship; :popover's guarded :visible |
window-handle-stack-smoke | :window-handle's single-child wrap; :stack's name-addressed pages |
drop-down-grid-smoke | :drop-down's GtkStringList selection round-trip; :grid's structural-props cell placement |
bb name | Pins |
|---|---|
class-smoke | :class reaches real GTK CSS classes (add, coexist, remove on diff |
leaf-widgets-smoke | :spinner/:progress-bar/:image construction and re-render |
picture-editable-label-smoke | :picture re-render; :editable-label as a third GtkEditable reuse |
inscription-search-bar-smoke | Display and props only) the round where zero glitter.gtk changes were needed |
Four touchpoints, and skipping any one of them leaves the example invisible to something:
examples/glitter/.deps.edn alias, so jolt -M:<name> works without babashka.bb.edn task, so bb <name> works and it shows up in bb info.bb.edn's smokes list: otherwise bb smokes won't run it and CI-by-hand won't catch a regression in it.A smoke must exit non-zero on failure. Do not gate it on jolt <task>: a deps.edn :tasks entry doesn't propagate its child's exit status, so jolt smoke prints failures and still exits 0. Use jolt -M:<alias> or a bb.edn task. See testing-and-tasks.md for the full rationale and CONTRIBUTING.md for the invariant list.
glitter.widget maps hiccup tags to GTK widget constructors and prop appliers; glitter.gtk drives it from the IRender/IMemory protocols. This page covers the mechanics and the specific GTK4 API traps this project hit; each is a real, live-verified bug this codebase used to have.
(def specs
(atom {:window (window-spec) :box (box-spec) :button (button-spec)
:label (label-spec) :entry (entry-spec) :checkbutton (checkbutton-spec)
:separator (separator-spec) :frame (frame-spec) :scrolled (scrolled-spec)}))
Each spec is {:ctor (fn [props] widget) :apply (fn [widget props]) :container kw}. :container determines how children attach: :box (ordered append/remove/reorder), :window/:frame/:scrolled (single child), or :none (leaf). register-widget! lets extensions (e.g. a hypothetical :gl-area/:scale addition) add new tags without editing this namespace; register-signal! does the same for new :on-* event keys, optionally with a value-fn for value-bearing signals (a slider's "value-changed" delivering the current double, for instance; "changed" on an entry already does this, reading the text via gtk_editable_get_text).
create! builds a widget end-to-end: construct, apply props, apply the universal GtkWidget-level props (apply-widget-props!: margins, halign/ valign, hexpand/vexpand, size requests), connect any direct :on-* props via connect-signals!, then run an optional :connect closure for signals that don't fit the uniform void(widget, user_data) shape. apply-props! re-applies a (possibly partial) prop map to an existing widget on re-render; it's safe to call with a single-key map like {:label "new text"}, because only keys present in the map are touched; absent keys are never reset to a default.
glimmer's connect-signals! connects each signal once, at create! time, and never disconnects: correct for its Reagent-style model, where the handler closure captures a ratom and the closure itself never goes stale.
glitter's handlers are data (see architecture.md), and glitter.core's diff calls IRender/set-event-handler again whenever the handler data changes between renders, not just on mount/unmount. So glitter.gtk's IRender/set-event-handler connects directly (bypassing connect-signals!, which never exposes the connection id a real disconnect needs) and tracks {:id <signal connection id> :cb <retained foreign-callable>} per event on the element atom:
(set-event-handler [_ el event handler _opt]
(when-let [{:keys [id cb]} (get-in @el [:handlers event])]
(g/g-signal-handler-disconnect (ptr el) id)
(w/release-callable! cb)
(swap! el update :handlers dissoc event))
(when-let [signal (w/signal-name (keyword (str "on-" (name event))))]
(let [value-fn (w/signal-value-fn signal)
cb (jolt.ffi/foreign-callable
(fn [src-widget _data]
(when-not (w/suppressing? src-widget)
(handler (cond-> {:glitter/node el :glitter/gtk-widget src-widget}
value-fn (assoc :glitter/value (value-fn src-widget))))))
[:pointer :pointer] :void :collect-safe)
id (g/g-signal-connect-data (ptr el) signal cb jolt.ffi/null jolt.ffi/null g/CONNECT-DEFAULT)]
(w/retain-callable! cb)
(swap! el assoc-in [:handlers event] {:id id :cb cb})))
nil)
Both parts matter: disconnecting the signal alone still leaves the foreign-callable pinned in glitter.widget's retain set forever: an unbounded leak for any handler whose data changes across renders. Both id and cb are tracked so both can be released together.
Reading a value-bearing signal's value back out. The :glitter/value built above travels through glitter.core's build-event-map, which wraps the whole object under :glitter/dom-event before handing it to *dispatch*. So a handler that needs the live value (an entry's :change, say) reads it from the first dispatch argument, not from its own static action data; hiccup :on data is fixed at the moment view runs, so an action tuple can't carry a value that only exists once the user types:
(defn execute-actions [event actions]
(doseq [[kind] actions]
(case kind
:action/set-draft (swap! state assoc :draft
(get-in event [:glitter/dom-event :glitter/value]))
...)))
Verified live (typing "hello" into an entry produces exactly this shape at *dispatch*):
#:glitter{:trigger :glitter.trigger/dom-event
:dom-event #:glitter{:node #object[...] :gtk-widget 41299379472 :value "hello"}
:node #object[...]
:dispatch #object[...]
:js-event #:glitter{:node #object[...] :gtk-widget 41299379472 :value "hello"}}
examples/glitter/todo.clj's :action/set-draft is the live example.
suppressing? guards against a second failure mode: glitter.widget's programmatic setters (set-entry-text!, set-checkbutton-active!) only touch the widget when the new value differs from its current value, and bracket the actual GTK call with a suppressing set so the synchronous signal emission GTK fires during the setter doesn't loop back into a dispatch. Because glitter.gtk connects its own signals directly rather than through connect-signals!, it has to consult this same guard itself: without it, a purely programmatic value change (e.g. re-syncing an entry after an external state update) would fire a real user-facing dispatch that nothing actually triggered.
insert-before: two cases GTK doesn't unifyIRender/insert-before is called for two genuinely different situations, mirroring DOM's own insertBefore auto-move semantics:
DOM's insertBefore handles both uniformly (inserting an already-attached node moves it). GTK does not: gtk_box_insert_child_after asserts its child argument is unparented (gtk_widget_get_parent(child) == NULL) and throws a GTK-CRITICAL + silently no-ops when called on an already-parented child: exactly what a keyed reorder does. The real GTK API for repositioning an existing child is gtk_box_reorder_child_after (already correct for this case, ported verbatim from glimmer's reorder-child!).
So glitter.gtk's insert-before branches on whether child-node is already tracked in the parent's :children:
(insert-before [_ el child-node reference-node]
(let [cs (:children @el)
idx (.indexOf cs reference-node)
prev-sibling (when (pos? idx) (ptr (nth cs (dec idx))))]
(if (some #(= % child-node) cs)
(w/reorder-child! (:tag @el) (ptr el) (ptr child-node) prev-sibling)
(w/insert-child-after! (:tag @el) (ptr el) (ptr child-node) prev-sibling)))
(swap! el update :children
(fn [cs]
(let [without (vec (remove #(= % child-node) cs))
idx (.indexOf without reference-node)]
(into (conj (subvec without 0 idx) child-node) (subvec without idx)))))
nil)
The bookkeeping formula (remove child-node from wherever it currently sits, then re-splice it immediately before reference-node) is correct for both cases with no further branching, because repositioning one child in GTK never changes any other child's tree position; prev-sibling, computed from the pre-removal :children, stays a valid physical anchor regardless of where child-node itself used to sit.
This exact bug class recurs in glitter.test-renderer's fake insert-before too (a keyed move there used to leave a stale duplicate entry in :children instead of relocating it); fixed with the identical remove-then-resplice formula, since the fake renderer's job is to mirror real reconciler bookkeeping even though it never touches actual GTK.
examples/glitter/keyed.clj pins this against the live GTK tree; it reads back the actual widget order via gtk_widget_get_first_child/gtk_widget_get_next_sibling, not glitter's own :children tracking, to prove the real gtk_box_insert_child_after calls landed correctly and not just that the algorithm's decisions were correct in the abstract.
replace-child!: capture the anchor before you remove itglimmer's original :box branch did gtk_box_remove then gtk_box_append, which always inserts at the end of the box. Replacing a non-final child silently relocated it there, desyncing every consumer's positional tracking (verified live during this project's final whole-branch review).
The fix captures the old child's previous sibling before removing it (removal loses that information), then inserts the new child at that same anchor:
(replace-child! [parent-tag parent old-child new-child]
(case (container-kind parent-tag)
:box (let [prev (g/gtk-widget-get-prev-sibling old-child)
prev (when-not (or (nil? prev) (zero? prev)) prev)]
(g/gtk-box-remove parent old-child)
(g/gtk-box-insert-child-after parent new-child (or prev ffi/null)))
...))
gtk_widget_get_prev_sibling returning null/0 means "was already the first child": insert-child-after with a null sibling arg inserts at the front, which is exactly the desired position. examples/glitter/replace_child.clj pins this.
:scale: the first-party value-bearing custom signalEvery widget so far routes :on {:click ...}-style handlers through glitter.widget's pre-registered signals/signal-value tables (:on-click, :on-change, :on-activate, :on-toggled). :scale (a slider, GtkScale/GtkRange) adds a fifth, :on-value-changed -> "value-changed", and is the first widget beyond entry's built-in :change to carry a value through signal-value's value-fn mechanism; concretely exercising the extension path register-signal!'s own docstring describes for third parties, using the exact same registration shape (baked into the initial atom literals rather than a runtime register-signal! call, matching the other four):
(def signals
(atom {:on-click "clicked"
:on-change "changed"
:on-activate "activate"
:on-toggled "toggled"
:on-value-changed "value-changed"}))
(def ^:private signal-value
(atom {"changed" (fn [widget] (g/gtk-editable-get-text widget))
"value-changed" (fn [widget] (g/gtk-range-get-value widget))}))
gtk_scale_new_with_range(orientation, min, max, step) constructs its own internal GtkAdjustment, no separate adjustment binding was needed. The same construct-before-GtkOrientation-is-registered concern box-spec/ separator-spec already handle applies here too (see architecture.md's discussion of enum resolution timing): :ctor builds horizontal (the raw 0; verified against gtk/ gtkenums.h's GtkOrientation ordinal, GTK_ORIENTATION_HORIZONTAL is the first member) and :apply corrects it via ->orientation once the widget exists.
set-scale-value! follows set-entry-text!/set-checkbutton-active!'s established set-compare-suppress shape exactly; set only when the value actually differs, bracketed by the suppressing guard so the reconciler feeding :value back on every render can't loop set_value -> value-changed -> dispatch -> re-render -> set_value:
(defn- set-scale-value! [widget value]
(when (and (some? value) (not= (double value) (g/gtk-range-get-value widget)))
(swap! suppressing conj widget)
(g/gtk-range-set-value widget (double value))
(swap! suppressing disj widget)))
Verified live end-to-end (examples/glitter/scale_smoke.clj, which pins all three): a real drag (simulated via a direct gtk_range_set_value FFI call, bypassing set-scale-value! so the actual "value-changed" signal fires) reaches *dispatch* with the correct double at (get-in event [:glitter/dom-event :glitter/value]); a subsequent state-driven re-render pushes the new value back onto the live widget; and that programmatic push does not trigger a second, spurious dispatch; confirming the suppressing guard works for this widget exactly as it does for entry and checkbutton.
:spinner/:progress-bar/:image: display-only widgets need no signalThree widgets with a genuinely simpler shape than everything above: :spinner (an indeterminate "loading" indicator, one boolean prop :spinning), :progress-bar (:fraction/:text/:show-text), and :image (:icon-name/:file/:pixel-size). None of them have a signal worth wiring; they exist purely to display state the application already tracks elsewhere, so :apply re-applying whatever props are present on every render is the entire implementation:
(defn- spinner-spec []
{:ctor (fn [_] (g/gtk-spinner-new))
:apply (fn [w p] (when (contains? p :spinning) (g/gtk-spinner-set-spinning w (->bool (:spinning p)))))
:container :none})
:image's :ctor picks whichever of :icon-name/:file is present at construction time (falling back to the empty constructor), and :apply re-applies either key on a later render, no explicit "clear the old content" step is needed, because GtkImage's internal storage type (icon name vs. file vs. paintable) switches automatically to whatever setter was called most recently.
Verified live (examples/glitter/leaf_widgets_smoke.clj): all three widgets' construction-time props land correctly, and re-rendering with different values (a different fraction, a different icon name, spinning flipped off) is reflected in real GTK state; read back via gtk_spinner_get_spinning/gtk_progress_bar_get_fraction/ gtk_image_get_icon_name, not glitter's own bookkeeping.
GtkSwitchGtkSwitch looked like a fourth simple leaf widget from its construction API alone (gtk_switch_new, gtk_switch_set_active/get_active: just as trivial as checkbutton's). Its interaction signal is where it stops fitting the pattern. Checked directly against gtk/gtkswitch.c's g_signal_new calls: GtkSwitch has exactly two signals.
"activate": the simple void(widget, user_data) shape every other glitter signal uses, but GTK's own doc comment on it is explicit: "Emitted to animate the switch. Applications should never connect to this signal, but use the [property@Gtk.Switch:active] property." It fires on keyboard activation (Space/Enter) only, not on a mouse click, and using it to detect interaction would silently miss the common case while looking like it worked."state-set": the actual interaction signal, but its C signature is gboolean (*state_set) (GtkSwitch *widget, gboolean state, gpointer user_data): three arguments (not two) and a gboolean return (not void) that GTK uses to decide whether to run its own default handler. glitter.gtk's set-event-handler hardcodes every signal's foreign-callable to [:pointer :pointer] :void; this doesn't fit, and wiring it correctly would mean either generalizing that callable shape (real new architecture, not a small addition) or connecting a differently-shaped callable as a one-off special case for this single widget.The :connect key in a widget spec (see register-widget!'s docstring: "for widgets whose signals don't fit the uniform void(widget,data) shape, e.g. a GtkGLArea's realize/render/resize") looks like an escape hatch, but doesn't actually solve this: :connect runs once at mount with whatever props were current then, the same one-time-connection model connect-signals! already uses for create!'s direct-props path. :switch's handler needs the same reconciler-driven re-wiring set-event-handler/remove-event-handler give every other interactive widget (so a handler closure never goes stale when the action data changes between renders, without the widget's identity changing), and :connect doesn't provide that.
Shipping :switch state-settable-but-non-interactive (readable via :active, but never dispatching back on user toggle) was considered and rejected as a quiet half-measure: it would look identical to :checkbutton in hiccup but silently not round-trip, a worse trap than not shipping it at all. Left as an explicitly open decision at the time, rather than resolved either way.
Resolved two rounds later (see ":switch) generalizing set-event-handler" below. This section is kept as-written rather than rewritten: the investigation and the "not worth a quiet half-measure" judgment call were both correct and are exactly the reasoning that later justified the real architecture work.
:toggle-button: reusing "toggled" for a second GTK4 classGtkToggleButton is the widget :switch isn't: checked its actual signals in gtk/gtktogglebutton.c (the same way :switch's were checked) before assuming anything, and found exactly one, void (*toggled) (GtkToggleButton*): the identical void(widget, user_data) shape already registered as :on-toggled for :checkbutton. No new entry in signals/signal-value was needed at all; glitter.gtk's existing set-event-handler handles :toggle-button exactly the way it already handles :checkbutton, because the lookup is by hiccup event key + registered GTK signal name, not by widget type.
GtkToggleButton and GtkCheckButton are unrelated classes in GTK4 (they shared a type hierarchy pre-GTK4; that relationship was removed), so toggle-button-spec needs its own gtk_toggle_button_set_active/ get_active FFI pair and its own set-toggle-button-active!, but the helper is otherwise a byte-for-byte structural copy of set-checkbutton-active!, same set-compare-suppress shape:
(defn- set-toggle-button-active! [widget active?]
(let [target (->bool active?)]
(when (not= target (g/gtk-toggle-button-get-active widget))
(swap! suppressing conj widget)
(g/gtk-toggle-button-set-active widget target)
(swap! suppressing disj widget))))
Verified live end-to-end (examples/glitter/toggle_level_smoke.clj, same three-part rigor as :scale's smoke): a real click (direct FFI gtk_toggle_button_set_active, bypassing set-toggle-button-active! so the actual "toggled" signal fires) reaches *dispatch* and updates state; a subsequent programmatic reset! pushes the widget back in sync; and that programmatic push does not trigger a second, spurious dispatch. This is what actually proves the reuse works, not just that :checkbutton's original wiring works, which was already known.
:level-bar (a gauge/indicator; :value/:min-value/:max-value/ :inverted) shipped alongside it in the same pass, same display-only shape as :spinner/:progress-bar/:image: no signal, :apply re-applies whatever props are present on every render, min/max set before value so a caller-supplied range is live before the value meant to land inside it (same ordering concern :scale's :apply already has for re-ranging before setting :value). :mode (continuous vs. discrete segments) is out of scope for v1, matching :progress-bar's minimal display-widget scope.
:link-button: a second free signal reuse, plus a real GTK4 timing gotchaGtkLinkButton is the same story as :toggle-button, one level up: gtk/gtklinkbutton.h #includes gtk/gtkbutton.h (the standard GTK header pattern for a parent-class include) confirming GtkLinkButton genuinely extends GtkButton, not just resembles it. It inherits "clicked" for free: :link-button needed no signals/signal-value entry at all, only its own gtk_link_button_new/_with_label/ set_uri/get_uri FFI calls and a link-button-spec that reuses gtk_button_set_label/gtk_widget_set_tooltip_text/ gtk_widget_set_sensitive directly, since those are inherited GtkButton/GtkWidget methods button-spec already calls.
Writing this widget's smoke surfaced a genuine GTK4 timing gotcha, worth pinning precisely because it would silently break any future test (or application code) that tries to simulate a button click programmatically. gtk_widget_activate (the public function that simulates a real Enter/Space key activation) does not synchronously emit "clicked" for a GtkButton. Traced through gtk/gtkbutton.c:
#define ACTIVATE_TIMEOUT 250
...
static void
gtk_real_button_activate (GtkButton *button)
{
...
if (gtk_widget_get_realized (widget) && !priv->activate_timeout)
{
priv->activate_timeout = g_timeout_add_once (ACTIVATE_TIMEOUT, button_activate_timeout, button);
...
}
}
Two traps stacked here, both found live rather than assumed:
"clicked" fires ~250ms later, via a g_timeout_add_once: the press-animation delay. gtk_widget_activate returns before that timeout runs, so checking any dispatched result immediately after calling it reads stale state. examples/glitter/link_button_smoke.clj defers its check via a future + Thread/sleep 400 + app/on-gui, the same cross-thread-marshalling primitives main_thread_smoke.clj already established, applied to a different timing problem (there, marshalling onto the main thread from a worker; here, giving the main thread's own event sources time to run).if (gtk_widget_get_realized (widget) ...). glitter.app/run*'s :activate handler calls on-activate (where gtk/mount! and any smoke-test interaction code runs) before gtk_window_present, so activating a button immediately inside on-activate is a silent no-op: gtk_widget_ activate still returns TRUE (that only means "an activate-signal handler ran," not "clicked will follow"), but the widget isn't realized yet, so the whole timeout-scheduling branch is skipped and "clicked" never fires at all. The smoke defers the activate call itself (not just the check) via the same future, giving the window time to present and realize first.:switch: generalizing set-event-handlerGtkSwitch was surveyed and deliberately not added two widget-additions ago, specifically because its interaction signal, "state-set", doesn't fit the uniform void(widget, user_data) shape every other glitter signal's foreign-callable uses; confirmed against gtk/gtkswitch.c's g_signal_new call: gboolean (*state_set) (GtkSwitch *widget, gboolean state, gpointer user_data), 3 args, a gboolean return GTK uses to decide whether its own default handler should also run.
The natural-looking fix: a data table mapping GTK signal name to its callable shape, looked up at runtime inside set-event-handler, spliced into one generic foreign-callable call:
;; DOES NOT WORK โ kept here as a documented dead end, not a suggestion
(def signal-callable-shape
(atom {"state-set" {:argtypes [:pointer :int :pointer] :rettype :int :return 0}}))
(let [{:keys [argtypes rettype return]} (get @signal-callable-shape signal default-shape)
cb (jolt.ffi/foreign-callable (fn [w & _] ... return) argtypes rettype :collect-safe)]
...)
This compiles the shape of the idea correctly but fails at the foreign-callable call itself. jolt.ffi/foreign-callable (and the __ccallable special form it expands to) is a compile-time construct: argtypes/rettype must be literal at the call site, not a runtime value. Verified live, twice, in throwaway namespaces isolated from this codebase before touching glitter.gtk at all:
;; Literal argtypes/rettype: compiles and runs fine.
(jolt.ffi/foreign-callable (fn [a & _] a) [:pointer :int :pointer] :int :collect-safe)
;; The SAME values, let-bound first: fails to compile.
(let [argtypes [:pointer :int :pointer] rettype :int]
(jolt.ffi/foreign-callable (fn [a b c] a) argtypes rettype :collect-safe))
;; => Unhandled exception: java.lang.IllegalArgumentException:
;; Don't know how to create ISeq from: clojure.lang.Symbol
;; ex-data: {:jolt/error {:type :analysis-error, ...}}
Same error, both times: argtypes/rettype reaching the macro as a symbol (a local binding) rather than a literal vector/keyword breaks the special form's compile-time analysis. A variadic handler function ((fn [a & rest] ...)) works fine on its own (also verified in isolation); it's specifically the runtime-computed argtypes/rettype that can't work, not the handler's arity.
glitter.gtk/set-event-handler branches on the GTK signal name in plain Clojure code, and uses a separate, fully literal foreign-callable call for each distinct shape:
(let [dispatch! (fn [src-widget]
(when-not (w/suppressing? src-widget)
(handler (cond-> {:glitter/node el :glitter/gtk-widget src-widget}
value-fn (assoc :glitter/value (value-fn src-widget))))))
cb (if (= signal "state-set")
(jolt.ffi/foreign-callable
(fn [src-widget _state _data] (dispatch! src-widget) 0)
[:pointer :int :pointer] :int :collect-safe)
(jolt.ffi/foreign-callable
(fn [src-widget _data] (dispatch! src-widget))
[:pointer :pointer] :void :collect-safe))]
...)
The dispatch logic (suppressing-guard + calling handler) is factored into a shared dispatch! closure so it isn't duplicated between branches, but the foreign-callable calls themselves (the part that actually has the compile-time-literal constraint) stay separate and literal. There is no generic extension point for adding a third non-standard shape: it means adding a third literal branch here, by hand. glitter.widget/register-signal! intentionally has no shape parameter for this reason: accepting one and storing it in a table would silently promise a capability set-event-handler can't actually honor.
"state-set"'s value-fn doesn't need the signal's own second argument (the new boolean state) either, despite that value being right there in the callable's parameter list. Verified against gtk_switch_set_active in gtk/gtkswitch.c directly:
if (self->is_active != is_active)
{
self->is_active = is_active; /* set FIRST */
...
g_signal_emit (self, signals[STATE_SET], 0, is_active, &handled); /* emitted AFTER */
self->is_active (what gtk_switch_get_active reads) is updated before "state-set" is emitted, so by the time any handler runs, gtk_switch_get_active already reflects the new value. "state-set"'s signal-value entry is a plain (fn [widget] (g/gtk-switch-get-active widget)), exactly the same shape as :scale's and :change's, with no special argument threading needed:
"state-set" (fn [widget] (g/gtk-switch-get-active widget))
Returning 0/FALSE from the "state-set" callable (the :return-shaped value baked into the branch above) matters for a second reason beyond "satisfy the C ABI": GTK's docs say the signal handler should return TRUE to prevent the default handler from running. Returning FALSE lets that default handler (gtk_switch_set_state, which keeps the switch's internal visual ::state sub-property in sync with ::active) also run; verified live that this doesn't conflict with glitter's own dispatch, since both independently react to the same already-updated ::active value; glitter never needs to call gtk_switch_set_state itself.
set-switch-active! is the usual set-compare-suppress helper, structurally identical to set-checkbutton-active!/set-toggle-button-active!; the suppressing guard works identically for a 3-arg/non-void-return signal as it does for the standard 2-arg-void ones, since suppression is checked inside dispatch!, before the shape-specific branch ever matters.
Verified live end-to-end (examples/glitter/switch_smoke.clj, the full three-part rigor every value-bearing widget's smoke uses): a real interaction (direct FFI gtk_switch_set_active, bypassing set-switch-active! so the actual "state-set" signal fires) reaches *dispatch* with the correct value at (get-in event [:glitter/dom-event :glitter/value]); a subsequent programmatic reset! pushes the widget back in sync; and that programmatic push does not trigger a second, spurious dispatch: the suppressing guard proven to work for this signal shape too, not just the standard ones. jolt -M:test and bb smokes (all prior smokes) were re-run after this change to confirm zero regression in the shared set-event-handler path every other interactive widget depends on, before this shipped.
:revealer: a free single-child container reuse, plus a props-driven widgetGtkRevealer (gtk_revealer_set_child) is a single-child container: the exact same container strategy :frame/:scrolled already established, so append-child!/remove-child!/replace-child! each needed only one more case line, no new logic. No signal either: :reveal-child (bool), :transition-type (a GtkRevealerTransitionType nick: :crossfade, :slide-right, ...; resolved at runtime via glitter.genum, the same mechanism :halign/:valign already use for GtkAlign), and :transition-duration (a plain millisecond uint) are applied in that order; transition settings BEFORE the reveal itself, so the first reveal already uses the caller's transition, not GTK's defaults, mirroring :scale/:level-bar's own "re-range before setting value" ordering concern.
:center-box: a genuinely new container strategy, and a real v1 gapEvery container kind so far (:box (ordered append list), :window/ :frame/:scrolled/:revealer (exactly one child)) fits one of two shapes glitter.gtk's generic child-tracking already assumes. GtkCenterBox doesn't: it has three independently addressable NAMED slots (start/center/end, via gtk_center_box_set_start_widget/ set_center_widget/set_end_widget), not a position in an ordered list.
The container-management functions (center-box-append-child!/center-box-remove-child!/ center-box-replace-child!/center-box-insert-after!) don't track slot occupancy separately: they query it LIVE via the three getters on every call (ptr-null? = empty). center-box-append-child! places a new child in the first empty slot, in start -> center -> end order. center-box-remove-child!/replace-child! find which slot a given child currently occupies (center-box-slot-setter, comparing the child's pointer against each getter's live return) and null it out or overwrite it. None of this needed a single line changed in glitter.gtk; the whole thing lives inside glitter.widget's existing (case (container-kind parent-tag) ...) dispatch points.
A throwaway re-render probe (mount [:center-box [L] [C] [R]], then swap C's hiccup TAG from :label to :button in a re-render, all 3 slots still full) reliably corrupted the R (end) slot, not C: reading it back afterward threw gtk_label_get_text: assertion 'GTK_IS_LABEL (self)' failed. Root-caused by adding debug prints to every container-management function and re-running:
:DEBUG-insert-child-after! :center-box
:DEBUG-remove-child! :center-box
:DEBUG-slot-setter :child <R's pointer> :start <L> :center <C> :end <R's pointer>
insert-child-after! fires FIRST (inserting the new button), THEN remove-child! fires (removing the old label): glitter.core's reconciler handles a same-position, non-keyed TAG mismatch as "insert the new node, then remove the old one", two separate IRender calls, not a single replace-child!. This is the reconciler's general shape for ANY multi-child container, and it works fine for :box, which has genuine transient capacity: a :box can briefly hold 4 widgets (old + new) between the insert and the remove, exactly like glitter.gtk's own :children bookkeeping (updated unconditionally by IRender/insert-before regardless of whether the underlying GTK call did anything) assumes.
GtkCenterBox has no such transient capacity; there is no 4th slot. The FIRST version of center-box-insert-after! (before this fix) reused whichever slot the SIBLING implied was next (sibling = L -> target center), overwriting it directly. gtk_center_box_set_center_widget internally calls gtk_widget_unparent on whatever was there before, and since nothing else in glitter holds a reference to that widget, GTK finalizes it immediately. So the OLD C label was destroyed the moment the NEW button was inserted, while glitter.gtk's :children bookkeeping still (briefly, correctly for :box, WRONGLY for :center-box) believed there were 4 tracked children [L, button, C, R]. The reconciler's NEXT step (reconciling R, the trailing unchanged sibling, by index) reads against that stale, now-4-long bookkeeping and ends up touching the WRONG tracked entry, corrupting R.
This is a genuine structural limitation, not a bug that can be patched purely in center-box-insert-after!. A 3-fixed-slot container literally cannot hold 4 simultaneous occupants the way an ordered list can: there's no slot to stash the new child in while the old one is still "pending removal" from the reconciler's point of view. Documented as a known v1 gap (see glitter.widget/center-box-insert-after!'s docstring and docs/guide/limitations.md): do not swap a slot's hiccup tag while all 3 slots are occupied. Change props instead of tags, or nest a stable wrapper tag (e.g. always render [:box [:label ...]] or [:box [:button ...]] inside the slot) so the type change happens one level down, where :box's own genuine transient capacity handles it correctly; already proven by every existing :box smoke, including keyed.clj.
revealer_center_box_smoke.clj deliberately exercises only the SAFE paths this fix does support: a props-only text update on an already-populated slot (proving unrelated slots stay untouched), and dropping a slot's child entirely (pure removal, no concurrent insert). :list-box, covered next, does NOT share this problem; confirmed separately, live.
:spin-button: generalizing signal-value by tagGtkSpinButton (gtk_spin_button_new_with_range (no separate GtkAdjustment binding needed, same shape as :scale) is a natural value-bearing widget: its "value-changed" signal, confirmed via gtk/gtkspinbutton.c's g_signal_new call, is g_signal_new(..., G_TYPE_NONE, 0)) the plain 2-arg-void shape every widget except :switch/:list-box already uses. No new set-event-handler branch needed.
But "value-changed" is the exact same GTK signal name :scale already registered. glitter.widget/signal-value (the table mapping a value-bearing signal to the (fn [widget] value) that reads it back) was, before this widget, keyed by bare signal name:
;; the OLD shape โ works fine until a SECOND widget type shares a signal name
(def ^:private signal-value
(atom {"changed" (fn [widget] (g/gtk-editable-get-text widget))
"value-changed" (fn [widget] (g/gtk-range-get-value widget)) ; :scale's getter
"state-set" (fn [widget] (g/gtk-switch-get-active widget))}))
:scale's entry reads back via gtk_range_get_value (GtkScale extends GtkRange). :spin-button needs gtk_spin_button_get_value instead: GtkSpinButton is its own, unrelated GTK4 class. Registering :spin-button's value-fn under the bare string "value-changed" would have silently overwritten :scale's entry (or vice versa, depending on which widget's spec happened to load last): both widgets share the one map key, and only one value-fn can occupy it. Found via source-level verification WHILE adding :spin-button, cross-referencing gtk/gtkspinbutton.c's signal name against gtk/gtkrange.c's, before this ever shipped and broke :scale, not caught by any test, since no prior widget had ever shared a signal name with another.
The fix: signal-value is now keyed by [tag gtk-signal-name], a 2-element vector, not a bare string:
(def ^:private signal-value
(atom {[:entry "changed"] (fn [widget] (g/gtk-editable-get-text widget))
[:scale "value-changed"] (fn [widget] (g/gtk-range-get-value widget))
[:spin-button "value-changed"] (fn [widget] (g/gtk-spin-button-get-value widget))
[:switch "state-set"] (fn [widget] (g/gtk-switch-get-active widget))
[:list-box "row-selected"] list-box-selected-index
[:list-box "row-activated"] list-box-selected-index}))
glitter.widget/signal-value-fn and register-signal! both gained a tag parameter to match (register-signal!'s docstring now says why: "more than one widget type can emit the same GTK signal name with a different meaning"), and glitter.gtk/set-event-handler looks the value-fn up via (w/signal-value-fn (:tag @el) signal) instead of just signal. glitter.widget/connect-signals! (the legacy create! direct-props path; see the ns docstring) gained the same tag parameter for consistency, though it isn't exercised by the reconciler- driven render path.
spin_button_list_box_smoke.clj's spin-button assertions are what pin this actually works: the dispatched value comes back correctly through :spin-button's OWN getter, and scale_smoke.clj (already green, re-run as part of every bb smokes pass) confirms :scale's entry is undisturbed.
:list-box: a third callable shape, and two more real bugsGtkListBox's row-interaction signals, "row-selected"/ "row-activated", are confirmed via gtk/gtklistbox.c's g_signal_new calls to be void(GtkListBox*, GtkListBoxRow*, gpointer): 3 args, VOID return. This is a THIRD distinct callable shape, alongside the default 2-arg-void and "state-set"'s 3-arg/non-void-return; set-event-handler gained a third literal foreign-callable branch:
cb (cond
(= signal "state-set")
(jolt.ffi/foreign-callable
(fn [src-widget _state _data] (dispatch! src-widget) 0)
[:pointer :int :pointer] :int :collect-safe)
(#{"row-selected" "row-activated"} signal)
(jolt.ffi/foreign-callable
(fn [src-widget _row _data] (dispatch! src-widget))
[:pointer :pointer :pointer] :void :collect-safe)
:else
(jolt.ffi/foreign-callable
(fn [src-widget _data] (dispatch! src-widget))
[:pointer :pointer] :void :collect-safe))
The row argument itself (_row) is ignored: list-box-selected-index (shared by both signals, in signal-value) re-reads gtk_list_box_get_selected_row -> gtk_list_box_row_get_index AFTER the signal fires, same "re-read the widget's own state" pattern every other value-fn here uses. Verified against gtk/gtklistbox.c's gtk_list_box_select_and_activate_full that a row is selected BEFORE it is activated (activate-single-click defaults to TRUE), so the getter already reflects the right row by the time either handler runs; even for "row-activated", which doesn't carry an explicit selection the way "row-selected" does.
gtk_list_box_remove needs the ROW, not the childgtk_list_box_append/insert auto-wrap a plain child widget in a GtkListBoxRow (confirmed against gtk/gtklistbox.c's bodies), so APPENDING takes the child directly: matching gtk_box_append's shape, and matching gtk_list_box_remove's own doc comment, which reads almost identically to gtk_box_remove's ("the child to remove"). The doc comment is misleading. Reading gtk_list_box_remove's actual C body:
if (!GTK_IS_LIST_BOX_ROW (child))
{
row = g_hash_table_lookup (box->header_hash, child);
if (row != NULL) { ... }
else { g_warning ("Tried to remove non-child %p", child); }
return;
}
row = GTK_LIST_BOX_ROW (child);
Passing the plain child widget (not its row) prints Tried to remove non-child and silently no-ops: found live, via the exact warning text appearing in a re-render smoke's stderr, traced to this function by grepping the GTK source tree for the literal warning string. Fix: list-box-row-of recovers the wrapping row via gtk_widget_get_parent (GTK auto-wraps, so the child's immediate parent IS its row), and list-box-remove-child!/list-box-replace-child!/ list-box-reorder-child! all pass the ROW to gtk_list_box_remove, not the child.
This bug is what made the round's ORIGINAL :list-box v1 scope call ("append/remove/replace correctly, but insert-child-after!/ reorder-child! stay documented no-ops, same as the single-child containers") look reasonable at first. It wasn't: exactly the same "insert new, then remove old" reconciler sequence documented above for :center-box applies to :list-box too, and leaving insert-child-after! a no-op there desyncs glitter.gtk's :children bookkeeping from live GTK state the identical way. Unlike :center-box, though, :list-box has NO structural capacity limit; GtkListBoxRows are just ordinary tree children, so gtk_list_box_insert(box, child, position)'s index-based API (confirmed via its own doc comment that an out-of-range or -1 position clamps to "append") gives list-box-insert-after!/ list-box-reorder-child! everything needed for a REAL fix, not a documented gap:
(defn- list-box-index-after [sibling]
(if (ptr-null? sibling)
0
(inc (g/gtk-list-box-row-get-index (g/gtk-widget-get-parent sibling)))))
(defn- list-box-reorder-child! [parent child sibling]
(when-let [row (list-box-row-of child)]
(swap! suppressing conj parent)
(g/gtk-list-box-remove parent row)
(swap! suppressing disj parent))
(list-box-insert-after! parent child sibling))
list-box-index-after always re-reads sibling's row index at call time: reorder-child! deliberately removes child's OLD row FIRST, THEN computes the target index, so a removal that happens to shift sibling's live index (if child was previously positioned before it) is already reflected before the index is used. list-box-reorder-child! was written but not live-exercised by any smoke in this round (only insert-child-after!'s "insert new, tag-swap" path and pure removal were); treat it as implemented-and-reasoned-through, not verified-under-fire the way the rest of this round's changes are; see docs/guide/limitations.md.
Even after fixing bug 1, a smoke that removed the CURRENTLY SELECTED row still threw gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed, and the app's own dispatch log showed an extra, unexpected :action/select entry. Traced by adding a debug print inside the "row-selected"/"row-activated" callable and re-running: removing the selected row fires "row-selected" a SECOND time, with a NULL row argument: GTK's own deselection notice. Confirmed against gtk/gtklistbox.c's gtk_list_box_select_row_internal, which calls g_signal_emit (box, signals[ROW_SELECTED], 0, row) directly (not conditionally), so removing a row that was selected leaves GTK's internal state needing to announce "nothing is selected now."
This is a real GTK signal, not something glitter.widget's existing suppressing guard (built for GLITTER'S OWN programmatic setters like set-switch-active!) was written to intercept: it's a side effect of a CONTAINER operation (gtk_list_box_remove), not a value-setter. Left unsuppressed, the spurious dispatch reaches the app's *dispatch* fn exactly like a real user deselection would (and because mount!'s watcher runs render! INLINE when already on the GTK main thread (CONTRIBUTING.md invariant #6, app-loop-and-threading.md), and this all happens synchronously inside a GTK signal callback fired from INSIDE an in-progress core/reconcile call, the resulting swap! on the app's state atom triggers a SECOND, NESTED core/reconcile call before the outer one has finished) a reentrant reconcile, which is what actually threw the GTK_IS_WIDGET assertion (touching a widget the outer, still-in-flight reconcile hadn't finished processing yet).
Fix: "row-selected"'s src-widget argument is the list-box ITSELF (per its real C signature), so list-box-remove-child!/ list-box-replace-child!/list-box-reorder-child! all swap! suppressing conj/disj on parent (the list-box widget) around their gtk_list_box_remove call; the exact same suppress-then-mutate shape set-switch-active! and friends already use, just applied to a container operation instead of a value setter:
(defn- list-box-remove-child! [parent child]
(when-let [row (list-box-row-of child)]
(swap! suppressing conj parent)
(g/gtk-list-box-remove parent row)
(swap! suppressing disj parent)))
spin_button_list_box_smoke.clj pins both fixes end-to-end: it selects row1, swaps row1's TAG (:label -> :button, exercising the insert-child-after! fix), then removes row1 entirely (the row selected earlier in the SAME run, exercising the suppressing-guard fix), and asserts the select-dispatch count does NOT increment from that removal. jolt -M:test and bb smokes (all prior smokes, including keyed.clj, which directly exercises the :box branch of the same insert-child-after!/reorder-child! functions this round refactored from when to case) were re-run to confirm zero regression before this shipped.
:password-entry/:search-entry: free GtkEditable reuse, and a signal-value missBoth GtkPasswordEntry and GtkSearchEntry implement GtkEditable via a delegate, confirmed against gtk/gtkpasswordentry.c and gtk/gtksearchentry.c: both call gtk_editable_init_delegate, which internally does
g_signal_connect (delegate, "changed", G_CALLBACK (delegate_changed), editable);
: the delegate helper connects to the INNER widget's "changed" and re-emits it on the OUTER object. So gtk_editable_set_text/ gtk_editable_get_text and glitter.widget's existing set-entry-text! work directly on either pointer, exactly like :entry, and signals (the event-keyword -> GTK-signal-name table) needed no new entry: :on-change -> "changed" already covers both.
signal-value (the [tag signal] -> value-fn table) is a different story. Being keyed by [tag signal] since :spin-button (two widgets ago) means :entry's own [:entry "changed"] registration does not automatically cover :password-entry/:search-entry, even though all three share the identical GTK signal NAME and the identical value-fn body (gtk_editable_get_text). The first version of this round shipped without [:password-entry "changed"], and a live smoke caught it immediately: typing into the password entry dispatched :action/set-pw (confirming the SIGNAL wiring worked), but the dispatched value came back nil every time (confirming the VALUE extraction silently failed);
;; set-event-handler's dispatch!, unchanged:
(handler (cond-> {:glitter/node el :glitter/gtk-widget src-widget}
value-fn (assoc :glitter/value (value-fn src-widget))))
;; value-fn = (w/signal-value-fn :password-entry "changed") = nil,
;; since only [:entry "changed"] existed โ the cond-> clause never fires,
;; :glitter/dom-event never gets a :glitter/value key at all.
Fixed by adding the widget's own entries, even though the value-fn body is identical to :entry's:
[:password-entry "changed"] (fn [widget] (g/gtk-editable-get-text widget))
[:search-entry "changed"] (fn [widget] (g/gtk-editable-get-text widget))
This is exactly the risk :spin-button's [tag signal] re-keying was designed to prevent (a shared signal name silently losing one widget's registration) (but re-keying by tag doesn't mean each widget gets :entry's registration "for free" the way signals (the signal-NAME table) does; it means the OPPOSITE) every widget that wants a value-bearing signal needs its own explicit entry, full stop, even when that entry is a byte-for-byte duplicate of another widget's. Caught by password_search_entry_smoke.clj before it ever shipped, not discovered after the fact.
:search-entry also gets its own genuinely new signal, "search-changed": confirmed via gtk/gtksearchentry.c's g_signal_new call to be G_TYPE_NONE, 0, the plain 2-arg-void shape, no set-event-handler generalization needed. It's debounced by the widget's own internal timer (:search-delay ms after the user stops typing); except when the text is cleared to empty, which reads gtk_search_entry_changed's C body directly:
if (str == NULL || *str == '\0')
{
...
g_clear_handle_id (&entry->delayed_changed_id, g_source_remove);
g_signal_emit (entry, signals[SEARCH_CHANGED], 0); /* immediate */
}
else
{
...
reset_timeout (entry); /* debounced */
}
password_search_entry_smoke.clj uses this documented-in-source special case (clear to "") as its live-interaction trigger for "search-changed": a deterministic, synchronous path, instead of trying to wait out a real GLib timeout inside a smoke test.
:expander/:paned: free signal reuse, and a second structural gapNeither GtkExpander nor GtkPaned has a dedicated interaction signal of its own. Confirmed live, not assumed: gtk/gtkexpander.c has no g_signal_new call at all; gtk/gtkpaned.c's only signals (cycle-child-focus, toggle-handle-focus, move-handle, cycle-handle-focus) are keybinding-navigation actions, not "the user dragged the divider." Real interactivity for both means watching a GObject property-change signal instead ("notify::expanded" for :expander, "notify::position" for :paned) using GLib's standard "notify::<property-name>" detailed-signal syntax with g_signal_connect_data.
A GObject "notify" signal's real C signature is void (*notify) (GObject *gobject, GParamSpec *pspec, gpointer user_data): 3 args, VOID return. That is the exact same shape already generalized for :list-box's "row-selected"/"row-activated" two widget-additions ago (void(GtkListBox*, GtkListBoxRow*, gpointer)): a GParamSpec* is just another :pointer under FFI, indistinguishable in shape from a GtkListBoxRow*. So both new signals slot into the EXISTING literal branch with zero new foreign-callable call sites:
(#{"row-selected" "row-activated" "notify::expanded" "notify::position"} signal)
(jolt.ffi/foreign-callable
(fn [src-widget _pspec-or-row _data] (dispatch! src-widget))
[:pointer :pointer :pointer] :void :collect-safe)
This is the payoff the round-6 :list-box write-up promised: "add a new signal branch here for the next one" turned out to mean, for THIS specific 3-arg-void shape, "add the signal NAME to the existing set," not "write a new branch." expander_paned_smoke.clj's dispatch-count assertions are what actually prove this reuse works end-to-end, not just that it compiles.
:paned: a second named-slot container, with a DIFFERENT verified failure shapeGtkPaned has two independently addressable NAMED slots (start_child/end_child): a simpler sibling to :center-box's three, built the same way (paned-append-child!/paned-slot-setter/ paned-remove-child!/paned-replace-child!/paned-insert-after!, querying occupancy live via the getters). It inherits the same ROOT CAUSE as :center-box's structural v1 gap (see :center-box's section): no transient capacity for a 3rd simultaneous occupant when both slots are full and a same-slot hiccup TAG swap goes through glitter.core's "insert new, then remove old" sequencing.
This gap was applied to :paned from the start this round, informed by the :center-box investigation, but the exact SYMPTOM still needed live verification, not assumption, because GtkPaned only has 2 slots, not 3, and that changes what actually breaks:
sibling = the OTHER, unchanged slot's widget) while both are full lands correctly. The new child overwrites the occupied end slot directly (gtk_paned_set_end_child unparents (and, with nothing else referencing it, GTK finalizes) the old occupant immediately, same mechanism as :center-box), but unlike :center-box there is no THIRD slot after it for the reconciler's stale post-insert bookkeeping to corrupt into. Verified live: no assertion failure, correct final state. expander_paned_smoke.clj exercises exactly this path.sibling = nil, since it's the first child) fails differently: paned-append-child!'s "first empty slot" search finds NEITHER slot empty (both still occupied at insert time) and silently no-ops: the new widget is created but never attached anywhere. The reconciler's subsequent removal of the OLD first-slot child then leaves that slot genuinely EMPTY, holding neither widget. Verified live in a throwaway probe: both gtk_paned_get_start_child and reading back the intended replacement trip GTK_IS_BUTTON assertions afterward, not corruption of an unrelated slot this time, just a silently-failed swap.Either way, the remedy is identical to :center-box's: no same-slot tag swap when both slots are already occupied: change props instead of tags, or nest a stable wrapper tag one level down so the type change happens where :box-shaped reconciliation already handles it correctly. expander_paned_smoke.clj only exercises the safe half (last-slot swap); the first-slot failure mode is documented in paned-insert-after!'s own docstring and docs/guide/limitations.md, not re-tested in the permanent smoke: the same "document, don't ship a test that asserts broken behavior" precedent :center-box's smoke already set.
:aspect-frame/:calendar: a quick win, and a genuinely new value typeGtkAspectFrame (gtk_aspect_frame_set_child) is a single-child container: the exact :frame/:scrolled/:revealer/:expander strategy reused verbatim, one more case line in each of append-child!/remove-child!/replace-child!. Its own construction params (xalign/yalign/ratio/obey-child) are plain floats/bool, confirmed individually re-settable post-construction via their own setters (gtk/gtkaspectframe.h), so unlike :paned's orientation (a GType that may not be registered yet at first widget construction) they carry no chicken-and-egg risk and resolve directly from props at ctor time, with GTK's own documented defaults as fallback.
GtkCalendar is a genuinely new value-bearing leaf widget. "day-selected" is confirmed via gtk/gtkcalendar.c's g_signal_new call to be the plain 2-arg-void shape, but gtk_calendar_get_date returns a GDateTime*, a value type this project has never marshalled before. GLib's GDateTime is refcounted, and the exact ownership rules matter enough that they're worth pinning precisely rather than guessing from general GObject conventions:
/* gtk_calendar_get_date's actual C body โ confirmed by reading it directly */
GDateTime *
gtk_calendar_get_date (GtkCalendar *self)
{
return g_date_time_ref (self->date); /* caller owns a NEW ref */
}
/* gtk_calendar_select_day's signature โ a plain in-param, standard GLib
convention: does NOT take ownership of what's passed in */
void gtk_calendar_select_day (GtkCalendar *calendar, GDateTime *date);
So every call site that reads OR constructs a GDateTime owns a reference it must release:
(defn- calendar-date= [widget]
(let [d (g/gtk-calendar-get-date widget) ; owns a NEW ref
result [(g/g-date-time-get-year d) (g/g-date-time-get-month d)
(g/g-date-time-get-day-of-month d)]]
(g/g-date-time-unref d) ; release it
result))
(defn- set-calendar-date! [widget [year month day :as date]]
(when (and year month day (not= (vec date) (calendar-date= widget)))
(let [gdt (g/g-date-time-new-local year month day 0 0 0.0)] ; owns a NEW ref
(swap! suppressing conj widget)
(g/gtk-calendar-select-day widget gdt) ; does NOT take ownership
(swap! suppressing disj widget)
(g/g-date-time-unref gdt)))) ; release it
Skipping either unref would leak one GDateTime object per render or dispatch: small individually, but unbounded over a long-running app's lifetime. aspect_frame_calendar_smoke.clj's round-trip (a real interaction dispatching a [year month day] value, then a programmatic reset! syncing the widget back with the suppressing guard proven to hold) is what actually exercises this discipline under repeated use, not just confirms it compiles.
:overlay/:flow-box: a third container shape, and a verified difference, not an assumptionGtkOverlay breaks the pattern every multi-child container up to this point has followed. :box is an ordered append-list; :center-box/ :paned are fixed NAMED slots, each independently queryable via its own getter. GtkOverlay has exactly ONE queryable slot (gtk_overlay_get_child, the main content) plus an UNBOUNDED set of floating overlay children with no enumeration getter at all (confirmed: gtk/gtkoverlay.h has no "get overlays" function of any kind). The "query live via getters, never track separately" pattern center-box-slot-setter/paned-slot-setter both rely on simply doesn't have anything to query for the overlay set.
The design: the FIRST hiccup child becomes main content; every subsequent child becomes an overlay, unconditionally:
(defn- overlay-append-child! [parent child]
(if (ptr-null? (g/gtk-overlay-get-child parent))
(g/gtk-overlay-set-child parent child)
(g/gtk-overlay-add-overlay parent child)))
, and overlay-remove-child! decides which branch by checking whether child IS the current main content (the one thing that IS queryable); if not, it's assumed to be a registered overlay (safe, since every overlay-container child glitter ever attaches goes through this same function first). overlay-replace-child!'s overlay branch (remove old, append new) does NOT preserve z-order: GTK has no "insert overlay at position N" API to do better, a deliberate, documented simplification rather than an oversight. The single named slot (main content) inherits the identical structural v1 gap :center-box/:paned have: no same-tag-swap once occupied; overlay-insert-after! only handles the sibling = nil (main slot, still empty) case for real.
Because GTK exposes no overlay-enumeration API, verifying a REMOVAL actually happened can't use the "check occupancy via the specific container's own getter" pattern every earlier container smoke uses. overlay_flow_box_smoke.clj instead reuses the GENERIC widget-tree walk (gtk_widget_get_first_child/get_next_sibling) every structural smoke in this project already has, since overlay children ARE real GTK widget-tree children; just laid out specially by GtkOverlay's own layout manager:
(defn- count-children [w]
(loop [c (g/gtk-widget-get-first-child w) n 0]
(if (or (nil? c) (zero? c)) n (recur (g/gtk-widget-get-next-sibling c) (inc n)))))
:overlay-child-count-on-mount reads 2 (main + one overlay); :overlay-child-count-after-drop reads 1 after removing the overlay; proof the removal reached live GTK state, not just that main stayed correct.
:flow-box: apply the :list-box lessons, but verify, don't assumeGtkFlowBox's append/insert/remove share the identical signature shape GtkListBox's do (confirmed against gtk/gtkflowbox.c), strongly suggesting the same auto-wrap-in-a-child-widget behavior (GtkListBoxRow for :list-box, GtkFlowBoxChild for :flow-box), and gtk_flow_box_insert's body confirms it: a plain child gets wrapped in a fresh GtkFlowBoxChild exactly like gtk_list_box_insert wraps in a fresh GtkListBoxRow.
The temptation, given the structural similarity, is to assume gtk_flow_box_remove has the identical "needs the wrapper, not the plain child" gotcha :list-box needed a fix for two rounds ago. It does not; verified by reading gtk_flow_box_remove's C body directly, not by assuming the lesson transfers between sibling widgets:
/* gtk_flow_box_remove's actual C body โ confirmed by reading it directly */
if (GTK_IS_FLOW_BOX_CHILD (widget))
child = GTK_FLOW_BOX_CHILD (widget);
else
{
child = (GtkFlowBoxChild*) gtk_widget_get_parent (widget);
if (!GTK_IS_FLOW_BOX_CHILD (child))
{
g_warning ("Tried to remove non-child %p", widget);
return;
}
}
Unlike gtk_list_box_remove (which requires the row directly and warns otherwise), gtk_flow_box_remove accepts EITHER the wrapped GtkFlowBoxChild OR the plain inner widget, auto-unwrapping via gtk_widget_get_parent internally when needed. So flow-box-remove-child!/flow-box-replace-child! call gtk_flow_box_remove with the plain child straight away, no list-box-row-of-style recovery helper needed for removal at all. Positional INSERT still needs a sibling's wrapper (to read its index via gtk_flow_box_child_get_index), so flow-box-child-of exists for that one purpose, mirroring list-box-row-of's shape but used only on the insert/reorder side.
"child-activated" is confirmed via g_signal_new to be void(GtkFlowBox*, GtkFlowBoxChild*, gpointer) (the identical 3-arg-void shape already generalized for :list-box/:expander/ :paned, another free reuse with zero new foreign-callable call sites. v1 deliberately wires :on-child-activated with no value-fn: reading back "which child" would need GList traversal via gtk_flow_box_get_selected_children) a genuinely new FFI complexity class (walking a linked list through raw pointers) this project hasn't needed yet, and not worth taking on for a first pass when :on-click/:on-toggled already establish the precedent that a dispatched event without a :glitter/value is a normal, supported shape.
overlay_flow_box_smoke.clj's flow-box assertions swap the MIDDLE child's tag (:label -> :button) while all three positions are occupied and confirm both siblings stay untouched; proving flow-box-insert-after! (built on the verified-safe gtk_flow_box_insert clamping behavior, same as :list-box's) works correctly, without needing :flow-box to inherit any of :center-box/:paned's fixed-slot capacity limits: GtkFlowBoxChilds have per-item capacity, not a bounded slot count, so this class of gap simply doesn't apply here.
:picture/:editable-label: a quick win, a third GtkEditable delegate, and a general GtkEditable findingGtkPicture has no signal at all (confirmed: no g_signal_new call in gtk/gtkpicture.c); purely display-only, the same shape as :spinner/:progress-bar/:image/:level-bar, driven entirely by re-applied props (:content-fit/:can-shrink/:alternative-text). It's a modernized :image: GdkPaintable-based, with real aspect-ratio- aware scaling via :content-fit instead of :image's icon-name/file choice. gtk_picture_new_for_filename/gtk_picture_set_filename take plain strings (not a GFile*), matching :image's existing gtk-image-new-from-file/gtk-image-set-from-file bindings exactly: checked against those before writing the new ones, so no new marshalling convention was introduced.
GtkEditableLabel is a THIRD widget in this project implementing GtkEditable via a delegate (after :password-entry/:search-entry: confirmed against gtk/gtkeditablelabel.c's gtk_editable_init_delegate call), so it reuses set-entry-text!/:entry's "changed" signal NAME for free. It still needs its own [:editable-label "changed"] signal-value entry, exactly like :password-entry did two rounds ago, and this round repeated that EXACT mistake once, live, despite writing a comment in editable-label-spec saying the entry would be "applied proactively this time." It wasn't; the entry was written into the spec's comment and then never actually added to the signal-value atom. Caught by the throwaway probe this section's smoke was distilled from (a typed value came back nil), not assumed safe because the comment said so: the same lesson as the original :password-entry near-miss, now missed twice by two different rounds' authors, which is why it's called out here explicitly rather than trusted to a comment alone.
Click-to-edit itself is GTK's own built-in gesture, no signal wiring needed for entering edit mode by clicking. Programmatic edit-mode control goes through gtk_editable_label_start_editing/stop_editing, wrapped in the usual set-compare-suppress shape:
(defn- set-editable-label-editing! [widget editing?]
(let [target (->bool editing?)]
(when (not= target (g/gtk-editable-label-get-editing widget))
(swap! suppressing conj widget)
(if editing?
(g/gtk-editable-label-start-editing widget)
(g/gtk-editable-label-stop-editing widget 1))
(swap! suppressing disj widget))))
gtk_editable_label_stop_editing's second argument is a commit flag; always 1 here, so leaving edit mode programmatically commits the in-progress text rather than discarding it.
GtkEditable finding: bulk text replacement isn't one atomic emissionInvestigating this widget's "changed" dispatch count surfaced a real, general GtkEditable behavior that applies to every widget built on the delegate in this project (:entry, :password-entry, :search-entry, and now :editable-label alike) not a glitter bug and not specific to any one widget. gtk_editable_set_text's own C body, read directly rather than assumed atomic:
/* gtk_editable_set_text's actual C body โ confirmed by reading it directly */
void
gtk_editable_set_text (GtkEditable *editable, const char *text)
{
...
gtk_editable_delete_text (editable, 0, -1);
gtk_editable_insert_text (editable, text, -1, &pos);
}
Two separate mutations, not one, and only property notify is frozen/ thawed around them (g_object_freeze_notify/thaw_notify), NOT the "changed" signal itself. Whether "changed" fires once or twice depends on the state of the buffer BEFORE the call: gtk/gtktext.c's gtk_text_delete_text has an early return;
/* gtk_text_delete_text's actual C body โ confirmed by reading it directly */
if (start_pos == end_pos)
return;
, so deleting from an ALREADY-EMPTY buffer is a silent no-op (no "changed" emitted), leaving only the insert's own emission: one "changed" total. Replacing NON-EMPTY text fires the delete's emission AND the insert's: two. This only matters when SIMULATING a bulk replace-all-text interaction the way this project's smokes do: calling gtk_editable_set_text directly to bypass glitter's own wrapper and trigger the real signal. A real user typing character-by-character never takes this path at all; that goes through gtk_editable_insert_text directly, once per keystroke, with no matching delete. Every GtkEditable-family smoke in this project (password_search_entry_smoke.clj, picture_editable_label_smoke.clj) starts its interaction target from EMPTY text specifically so its dispatch-count assertion tests the behavior under test, not this incidental doubling: round 7's :password-entry smoke happened to get this right by starting :pw at "", but that was accidental (discovered only while root-causing this round's finding), not a deliberate choice documented at the time.
:notebook/:scale-button: a sixth callable shape, a mount-time surprise, and a tag-aware dispatchGtkNotebook does NOT auto-wrap its children the way :list-box/ :flow-box do: a page's child IS the real widget throughout, and gtk_notebook_page_num(notebook, child) recovers its page index directly (confirmed against gtk/gtknotebook.h), no unwrap step anywhere. gtk_notebook_insert_page's position argument clamps out-of-range values to append (if (position < 0 || position > nchildren) position = nchildren;, confirmed by reading the C body), the same safe-clamping convention :list-box/:flow-box already rely on for their own insert helpers. tab_label is confirmed nullable (g_return_val_if_fail (tab_label == NULL || GTK_IS_WIDGET (tab_label), -1)): GTK auto-generates a default numbered tab when it's NULL. v1 always passes NULL: no per-child hiccup convention for custom tab labels yet, deferred rather than inventing a second widget-per-page shape for a first pass.
"switch-page" is a SIXTH callable shape in this project (4 args, void(GtkNotebook*, GtkWidget* page, guint page_num, gpointer), confirmed against gtk/gtknotebook.c's g_signal_new call), and the FIRST signal here that can't reuse the shared dispatch!/value-fn path every earlier signal (including :scale-button's, below) uses. gtk_notebook_switch_page (the function that EMITS this signal) only READS notebook->cur_page; the actual cur_page = page assignment happens in gtk_notebook_real_switch_page, the signal's OWN DEFAULT CLASS HANDLER, registered G_SIGNAL_RUN_LAST, which runs AFTER user-connected handlers like glitter's. Re-reading gtk_notebook_get_current_page() the way every other signal here re-reads its property would return the STALE previous page; confirmed by reading the C source, then confirmed a second way, empirically, in the throwaway probe this section's smoke was distilled from: capturing the raw dispatched page-num side by side with a same-tick getter read showed the getter really would have lagged by one page. So this branch reads page-num directly from its OWN raw signal argument and builds the dispatched event map inline, bypassing value-fn/dispatch! entirely:
(= signal "switch-page")
(jolt.ffi/foreign-callable
(fn [src-widget _page page-num _data]
(when-not (w/suppressing? src-widget)
(handler {:glitter/node el :glitter/gtk-widget src-widget :glitter/value page-num})))
[:pointer :pointer :uint :pointer] :void :collect-safe)
A second, genuinely surprising real GTK behavior falls out of gtk_notebook_insert_page's own C body (also called internally by gtk_notebook_append_page), read directly while investigating why the throwaway probe's dispatch log had a "switch-page" entry BEFORE any simulated user interaction:
/* gtk_notebook_insert_page's actual C body (tail) โ confirmed by reading it directly */
g_signal_emit (notebook, notebook_signals[PAGE_ADDED], 0, page->child, position);
if (!gtk_notebook_has_current_page (notebook))
{
gtk_notebook_switch_page (notebook, page);
}
Appending the FIRST page to a notebook that has no current page yet auto-selects it, which is exactly what happens when :notebook's initial children are appended during mount. So constructing a [:notebook ...] with initial children genuinely DISPATCHES a "switch-page" action as a side effect of mounting, before the app ever interacts with the widget. This is real GTK behavior, not a glitter bug, but it means a dispatch-count baseline of 0 after mount is WRONG for any app using :notebook with pre-populated pages; notebook_scale_button_smoke.clj's own dispatch-count assertions start from 1, not 0, for exactly this reason. There is no get-nth-page GTK API to read a page's own child widget back out (only gtk_notebook_page_num, which needs a widget reference going IN), so (like :overlay's own documented "no enumerate" gap) this smoke verifies :notebook only via gtk_notebook_get_current_page's int, not by reading page content.
:scale-button: a third widget sharing "value-changed", safely this timegtk_scale_button_new needs min/max/step at construction time, the same shape :scale/:spin-button already establish; the 4th argument (icon names shown at different value ranges) is always jolt.ffi/null; verified live that GTK falls back to its own default icon set rather than erroring on a null icon array.
Its "value-changed" signal shares the exact GTK signal NAME :scale/:spin-button already use (a THIRD widget doing so), but has a genuinely DIFFERENT real C shape: void(GtkScaleButton*, double, gpointer), confirmed against gtk/gtkscalebutton.c's g_signal_new call. This is the FIRST case in this project where the signal NAME alone is insufficient to pick the right callable; set-event-handler's cond has to check the widget's own :tag too:
(and (= signal "value-changed") (= (:tag @el) :scale-button))
(jolt.ffi/foreign-callable
(fn [src-widget _value _data] (dispatch! src-widget))
[:pointer :double :pointer] :void :collect-safe)
Unlike :notebook's "switch-page" above, this one IS safe to re-read via the usual getter-based value-fn; verified, not assumed by analogy, by reading gtk/gtkscalebutton.c's cb_scale_value_changed (the internal callback that emits the button's OWN "value-changed"): it reads gtk_range_get_value from the button's internal slider AFTER that slider's own "value-changed" has already fired, then emits the button's signal. gtk_scale_button_get_value reads that SAME shared GtkAdjustment, so it's already current by the time ANY handler sees the button's own signal; the getter-re-read pattern holds here even though the raw-argument-only pattern was required for :notebook.
notebook_scale_button_smoke.clj's dispatch-count sequence (1 after mount (the notebook's own auto-select), 2 after a real page switch, 3 after a real scale-button drag, 3 again (unchanged) after a programmatic sync-back of both widgets) is what proves both findings end-to-end against live GTK state, not just that the code compiles.
:inscription/:search-bar: two more quick, no-signal winsGtkInscription has no signal at all (confirmed: no g_signal_new in gtk/gtkinscription.c) (a lighter-weight sibling to :label: no markup interpretation, fixed :text-overflow handling (a GtkInscriptionOverflow nick) :clip/:ellipsize-start/ :ellipsize-middle/:ellipsize-end) instead of Pango's ellipsize/wrap options. Purely display-only, same shape as :picture.
GtkSearchBar is also entirely props/display-driven (no g_signal_new in gtk/gtksearchbar.c either): a single-child container, same strategy as :frame/:revealer, controlled by :search-mode/ :show-close-button. Pairs naturally with :search-entry as its child. v1 deliberately does not wire gtk_search_bar_connect_entry/ gtk_search_bar_set_key_capture_widget: both need a raw GtkEditable/GtkWidget pointer glitter has no hiccup-level convention for passing sideways yet, and :search-mode is fully controllable programmatically without them.
Neither widget touched glitter.gtk at all this round: the first round where every new widget is entirely :apply-driven, with zero signal wiring of any kind.
:header-bar/:action-bar: a genuinely new hybrid container shapeEvery multi-child container up to this point has been either a plain ordered list (:box/:list-box/:flow-box/:notebook) or a FIXED set of independently-named slots (:center-box's three,:paned's two, :overlay's one-plus-unbounded-unenumerable). GtkHeaderBar and GtkActionBar are a genuinely different shape again: one named title-widget/center-widget slot, plus an ordered pack-start list; unbounded, like :box's children, but coexisting with a named slot the way :center-box's do.
v1 convention, mirroring :overlay's own "query GTK's own occupancy, don't track position separately" pattern: the FIRST hiccup child becomes the title-widget (:header-bar) or center-widget (:action-bar) if that slot is still empty; every LATER child gets pack_start'd, in order:
(defn- header-bar-append-child! [parent child]
(if (ptr-null? (g/gtk-header-bar-get-title-widget parent))
(g/gtk-header-bar-set-title-widget parent child)
(g/gtk-header-bar-pack-start parent child)))
A real, verified finding drove the decision to stop there and not also wire pack_end. Reading gtk_header_bar_pack's C body directly:
/* gtk_header_bar_pack's actual C body โ confirmed by reading it directly */
if (pack_type == GTK_PACK_START)
gtk_box_append (GTK_BOX (bar->start_box), widget);
else if (pack_type == GTK_PACK_END)
gtk_box_prepend (GTK_BOX (bar->end_box), widget);
pack_start is a safe gtk_box_append: hiccup order lands correctly when the reconciler feeds children one at a time in its normal append sequence. pack_end is a gtk_box_prepend: feeding END children one at a time in hiccup order would silently land them in REVERSE order in the live GTK tree, with no public API to fix the positioning afterward (there is no gtk_header_bar_reorder, and bar->end_box is a private field glitter has no pointer to). GtkActionBar's pack_end carries the identical risk via a DIFFERENT GTK call; confirmed independently, not assumed to carry over just because the widgets look alike:
/* gtk_action_bar_pack_end's actual C body โ confirmed by reading it directly */
gtk_box_insert_child_after (GTK_BOX (action_bar->end_box), child, NULL);
gtk_box_insert_child_after(box, child, NULL) is this project's own established convention for "insert as the FIRST child": a prepend under a different name. Both widgets reverse-accumulate on pack_end; v1 therefore only calls pack_start anywhere in this codebase. gtk_header_bar_pack_end/gtk_action_bar_pack_end are bound to nothing: a future round wiring them must also solve the reversal, not just call the function.
gtk_header_bar_remove/gtk_action_bar_remove each handle EITHER role (title/center-widget or a pack-start child) in one call: confirmed via their C bodies, which branch on the child's actual GTK parent (start_box vs. center_box), so, unlike :overlay's remove, no role check is needed before calling it. *-replace-child! DOES need to check the role first (removal loses that information), same "capture before you mutate" concern every other replace-child! here has.
Known v1 gap, same root cause as :center-box's/:paned's/ :overlay's: if the title/center-widget slot is already occupied and its hiccup TAG gets swapped, *-insert-after!'s sibling nil branch falls through to *-append-child!, which sees the slot still occupied and pack-starts the new widget instead of replacing the title: the reconciler's subsequent removal of the old title then leaves that slot empty with the new widget stranded in the pack-start list. Change props instead of tags, or nest a stable wrapper tag one level down.
Neither widget's pack-start region can be reordered either; a THIRD variant of the same structural reason :overlay can't reorder its overlay children: the region is a real ordered list internally, but it's a PRIVATE GtkBox glitter has no pointer to; only the append-only pack_start functions are public API.
:show-title-buttons shares the SAME listNeither widget exposes an enumeration getter for its pack-start region (same situation :overlay's own smoke is already in), so header_bar_action_bar_smoke.clj identifies the real start_box by the "start" CSS class GTK itself adds internally (confirmed via both gtk_header_bar_init's and gtk_action_bar_init's C bodies), after walking down through each widget's own private wrapper (GtkWindowHandle for :header-bar, GtkRevealer for :action-bar) and its GtkCenterBox: a structural depth (4 levels) confirmed by reading both init functions directly, not guessed. The first attempt at this smoke assumed a flat 3-sibling composite and mis-treated a GtkCenterBox pointer as a GtkButton, caught immediately by a live GTK-CRITICAL rather than a silent wrong answer.
That same investigation surfaced a second, independent real finding: gtk_header_bar_set_show_title_buttons(bar, TRUE) calls create_window_controls(bar), which gtk_box_prepends a native GtkWindowControls widget into bar->start_box; THE SAME pack-start region glitter's own hiccup children live in:
/* create_window_controls's actual C body (tail) โ confirmed by reading it directly */
gtk_box_prepend (GTK_BOX (bar->start_box), controls);
bar->start_window_controls = controls;
Toggling :show-title-buttons true therefore lands a GTK-managed, non-button widget at the FRONT of the pack-start list, shifting glitter's own tracked children back by one position. This is real GTK behavior sharing the same mutable list, not a glitter bug, and not something glitter's own container-management code needs to guard against, since gtk_header_bar_remove/replace only ever act on widgets glitter itself created, never on GTK's own internal controls widget. header_bar_action_bar_smoke.clj asserts the shift explicitly rather than avoiding it: mounts with :show-title-buttons false (so the mount-time read sees only glitter's own two buttons), then toggles it true on re-render and confirms the pack-start count grows by one while glitter's own buttons keep their relative order, now at the tail.
:menu-button/:popover: a popup surface, not a normal tree childEvery container up to this point manages a REAL tree child: something append-child!/remove-child! parents directly into the widget hierarchy glitter's diff walks. :menu-button's relationship to :popover is different: its ONE hiccup child (if present) is expected to be a :popover, attached via gtk_menu_button_set_popover: a popup surface, not a box-shaped child. Confirmed by reading gtk_menu_button_set_popover's C body directly that this is real ownership (gtk_widget_set_parent/unparent), not a passive reference:
/* gtk_menu_button_set_popover's actual C body (relevant lines) โ
confirmed by reading it directly */
if (popover)
{
gtk_widget_set_parent (menu_button->popover, GTK_WIDGET (menu_button));
g_signal_connect_swapped (menu_button->popover, "closed",
G_CALLBACK (menu_deactivate_cb), menu_button);
...
}
:popover itself is an ordinary single-child container (gtk_popover_set_child), same strategy as :frame/:revealer.
Both signals turned out to be free reuses of the plain 2-arg-void shape already generalized in this project: confirmed via gtk/gtkmenubutton.c's and gtk/gtkpopover.c's own g_signal_new calls that "activate" and "closed" are both G_TYPE_NONE, 0. No new foreign-callable branch was needed in glitter.gtk/set-event-handler at all; the first round where every new signal is a free reuse. :on-activate was already a registered signals entry (added speculatively in an earlier round, unused until now); :on-closed is new, but only as a signal NAME entry, not a new shape.
gtk_popover_popdown's underlying gtk_popover_hide vfunc is confirmed (by reading it directly) to emit "closed" SYNCHRONOUSLY as part of the same call (_gtk_widget_set_visible_flag -> gtk_widget_unmap -> g_signal_emit(CLOSED), all in one function body), so :popover's :visible prop drives popup/popdown through the usual suppressing-guard setter, same synchronous-emission shape as every other value-bearing widget here:
(defn- set-popover-visible! [widget visible?]
(let [target (->bool visible?)]
(when (not= target (g/gtk-widget-get-visible widget))
(swap! suppressing conj widget)
(if visible? (g/gtk-popover-popup widget) (g/gtk-popover-popdown widget))
(swap! suppressing disj widget))))
This is a controlled-component contract identical to :notebook's :current-page: :menu-button's own internal click handling opens the popover independently of glitter (confirmed live: set_popover wires its OWN internal "closed" listener, separate from glitter's: GTK happily supports multiple listeners on one signal), so an app is expected to sync its own :visible state from :on-activate (open) and :on-closed (close). menu_button_popover_smoke.clj plays that app's role explicitly and verifies BOTH directions of the suppressing guard: opening/closing programmatically after the real click cycle causes no spurious extra dispatch either way.
gtk_widget_activate on :menu-button, unlike GtkButton's own activation (:link-button's ~250ms press-animation gotcha), does NOT need that delay; confirmed live, not assumed just because both are "button-like": the dispatch fires reliably within a much shorter deferred window than :link-button ever needed. :menu-button is not a GtkButton subclass; its "activate" signal is wired via gtk_widget_class_set_activate_signal, a generic GtkWidget mechanism entirely outside GtkButton's own press-animation state machine.
Every widget spec up to this point was written under an implicit, never-verified assumption: that :ctor's props argument sees the widget's real initial hiccup props, so a construction-time branch like (if (:label p) (gtk-button-new-with-label (:label p)) (gtk-button-new)) does something. Investigating :grid (below) required understanding the ctor/apply prop flow precisely enough to design a new mechanism, and that investigation started with a targeted println probe inside glitter.gtk/create-element, which showed options is always nil or {:ns "..."} at the real call site, never {:label "x"} or anything resembling a hiccup prop. See architecture.md for the full mechanism (create-node's actual call, set-attribute's one-key-per-call shape). This section covers what that finding meant for the widgets already shipped by round 10.
A systematic audit of all 39 pre-round-11 widget specs, cross-checking every :ctor prop reference against :apply's coverage, found four real bugs; all confirmed live, all shipped since as early as round 1, all invisible to every existing smoke because no existing smoke's chosen test values happened to exercise the gap:
:checkbutton-spec's :label was read in :ctor but never applied. (gtk-check-button-new-with-label (:label p)) never actually ran (props are empty at :ctor time), and :apply never called gtk_check_button_set_label either, so a checkbutton's label was silently always empty. Confirmed live: [:checkbutton {:label "x"}] mounted with no visible label at all. The only call site in this project, examples/glitter/todo.clj, never passes :label, so the bug shipped invisibly for 10 rounds. Fixed by adding label handling to :apply: (defn- checkbutton-spec []
{:ctor (fn [_] (g/gtk-check-button-new))
:apply (fn [w p]
(when (contains? p :label) (g/gtk-checkbutton-set-label w (:label p)))
...)
:container :none})
:scale-button-spec's :min/:max/:step had the identical gap: read in :ctor, never covered by :apply at all. GtkScaleButton has no direct set-range call the way GtkRange does; the fix reads the button's own GtkAdjustment via gtk_scale_button_get_adjustment and reconfigures it with gtk_adjustment_configure. The FIRST fix attempt used hardcoded fallbacks ((or (:min p) 0)) inside that call, which turned out to be bug #3's exact shape, caught by re-testing with values (:min 10 :max 20) that didn't happen to match the fallback.:scale-spec's and :spin-button-spec's :min/:max clobbered each other across separate renders. Both :apply closures called one combined native function (gtk_adjustment_configure / equivalent) with a hardcoded fallback for whichever key was absent from the CURRENT set-attribute call, but set-attribute fires once per changed key, never as a batched map, even at initial mount. A render that changes only :max genuinely arrives as {:max 80} alone; a hardcoded (or (:min p) 0) then resets :min back to 0 even though nothing asked for that. Confirmed live via a dedicated probe: mounting [:scale {:min 5 :max 50}] produced an actual live range of (0.0, 50.0), not (5.0, 50.0): the bug fires even on the very first render, since set-attributes still delivers :min and :max as two separate set-attribute calls at create time. The existing scale_smoke.clj (:min 0 :max 100) and spin_button_list_box_smoke.clj (:min 0) both happened to choose 0 as their minimum, which is exactly the broken fallback; an order-dependent coincidence that masked the bug for 10 rounds.Fixed by reading the widget's OWN current adjustment value as the fallback instead of a hardcoded default:
(defn- scale-apply-range! [widget p]
(when (or (contains? p :min) (contains? p :max))
(let [adj (g/gtk-range-get-adjustment widget)]
(g/gtk-adjustment-configure
adj
(double (or (:min p) (g/gtk-adjustment-get-lower adj)))
(double (or (:max p) (g/gtk-adjustment-get-upper adj)))
(g/gtk-adjustment-get-step-increment adj) 0.0 0.0 0.0))))
:spin-button-spec got the identical fix via gtk_spin_button_get_adjustment instead of gtk_range_get_adjustment (GtkSpinButton is not a GtkRange subclass).
window-spec's :width/:height -> gtk_window_set_default_size. Its counterpart getter, gtk_window_get_default_size, uses OUT-PARAMETERS (a genuinely new FFI marshalling class this project hasn't taken on), and the practical severity is much lower than the other three (an initial-sizing-only concern; the -1 fallback GTK itself uses means "natural size," not garbage, so a :width-only re-render doesn't break anything, it just stops applying a previously-set :height on the next unrelated resize). Left as a documented comment above window-spec rather than fixed this round.examples/glitter/ctor_apply_regression_smoke.clj pins bugs 1-3 permanently: it changes :min/:max/:step on SEPARATE re-renders (not together in one hiccup swap) specifically to exercise the one-key-at-a-time call pattern that caused the clobbering; changing both together in one render would never have caught the original bug, since that code path (set-attribute receiving two keys in one call) never exists in the real reconciler at all.
The rule going forward, stated for every future widget-spec author: design for props flowing entirely through :apply, never rely on :ctor seeing anything. If :ctor branches on a prop for a performance or correctness reason (e.g. picking the right gtk_*_new_with_* constructor), that branch is dead code through the real reconciler path; :apply must independently cover the same prop, and if :apply combines multiple keys into one native call, it must read the widget's OWN current values as fallbacks, never hardcoded defaults.
Before designing :grid's structural-props mechanism, the original plan used namespaced keys (:grid/column, :grid/row, :stack/name) matching this project's own Clojure conventions ("keywords over strings for keys", and simply looking more idiomatic). A throwaway probe mounting [:label {:my-plain-prop 42 :grid/column 2}] and printing every key IRender/set-attribute actually received showed only :my-plain-prop ever arrived; :grid/column never reached set-attribute at all, no error, no warning, just silently absent.
Root cause: glitter.core's set-attr and update-attr both guard on (when-not (namespace attr) ...) before calling into IRender (inherited directly from Replicant's own convention that a namespaced hiccup attribute is reserved for framework-internal use, not meant to reach the DOM. glitter's port kept this guard verbatim (it's exactly the mechanism :glitter/remember-style internal keys rely on to stay invisible to IRender/set-attribute), and nothing about it is glitter-specific or fixable at the widget-spec level) the drop happens in glitter.core, upstream of every backend.
This is why :grid-column/:grid-row/:grid-column-span/ :grid-row-span/:stack-name (below) are plain, hyphenated, non-namespaced keywords rather than the more idiomatic :grid/column form: the namespaced form would have compiled, mounted with no error, and simply never worked, for every consumer forever; a worse trap than an unusual naming choice.
:glitter/structural-props: a child's props read by its PARENTEvery container strategy up to this point (ordered append lists, fixed named slots, the popup-surface special case) has one thing in common: the PARENT alone decides where a child goes. :grid breaks that: a GtkGrid cell's position is data the CHILD carries (:grid-column/:grid-row/:grid-column-span/:grid-row-span), and :stack's page name (:stack-name) is the same shape: a child-borne prop the parent needs at attach time.
Two things rule out routing these through the normal :apply path. First, no widget's :apply closure has any way to know it's about to be attached to a :grid versus a :box: :apply only ever sees its OWN widget and its OWN props, never its parent. Second, even if it did, :apply runs on an ALREADY-CONSTRUCTED widget; grid attachment is a call the PARENT makes (gtk_grid_attach) at the moment the child is inserted into the tree, not a property the child widget itself holds.
The mechanism: glitter.gtk/set-attribute and remove-attribute special-case a small structural-child-props set, stashing matching keys on the CHILD's own el atom instead of routing them to glitter.widget/apply-props!:
(def ^:private structural-child-props
#{:grid-column :grid-row :grid-column-span :grid-row-span :stack-name})
(defn- structural-child-prop? [k] (contains? structural-child-props k))
(set-attribute [_ el a v _opt]
(if (structural-child-prop? a)
(swap! el assoc-in [:glitter/structural-props a] v)
(w/apply-props! (:tag @el) (ptr el) {a v}))
nil)
append-child, the fresh-insert branch of insert-before, and replace-child all read (:glitter/structural-props @child-node) off the CHILD and pass it as a new, optional trailing argument into glitter.widget's append-child!/insert-child-after!/ replace-child!; the only place with access to both the parent's container kind and the child's stashed props:
(w/append-child! (:tag @el) (ptr el) (ptr child-node) (:glitter/structural-props @child-node))
glitter.widget's grid-attach! and stack-append-child! are the consumers:
(defn- grid-attach! [parent child structural-props]
(let [{:keys [grid-column grid-row grid-column-span grid-row-span]} structural-props]
(g/gtk-grid-attach parent child
(or grid-column 0) (or grid-row 0)
(or grid-column-span 1) (or grid-row-span 1))))
(defn- stack-append-child! [parent child structural-props]
(if-let [name (:stack-name structural-props)]
(g/gtk-stack-add-named parent child name)
(g/gtk-stack-add-child parent child)))
remove-child! needed no threading at all; both gtk_grid_remove and gtk_stack_remove identify the child by widget pointer alone, with no position/name argument needed for removal.
Known v1 constraint, deliberately not solved this round: structural props are read ONLY when a child is first attached (a fresh mount or a keyed insert); changing an already-attached child's :grid-column/:stack-name on a LATER re-render does not move or rename it, because there is no code path that re-reads :glitter/structural-props for an already-parented child. reorder-child!'s docstring documents this explicitly for both containers: :grid/:stack positions are data-driven/name-addressed, not order-driven, so "reorder" has no meaning for them the way it does for :box. Fine for the common case of a static layout with fixed positions; see limitations.md.
:window-handle: a quick win, and a bug in THIS round's own codeGtkWindowHandle has no signal of its own (confirmed: no g_signal_new in gtk/gtkwindowhandle.c): a single-child CSD drag-handle wrapper, the same container strategy as :frame/ :revealer/:expander/:search-bar. Its first live smoke run caught a real bug, but one shipped by THIS round's own new code, not a pre-existing one: the :window-handle case branch was missing entirely from append-child!/remove-child!/replace-child! in glitter.widget.clj. The widget compiled cleanly and mounted with no exception: gtk_window_handle_set_child simply never ran, so the widget silently had no child at all. Caught immediately by window_handle_stack_smoke.clj reading the child back and getting nothing, before this ever shipped. Fixed by adding the three missing case branches, mirroring every other single-child container already in those functions.
:stack: a third mount-time-auto-dispatch instance, and a real :apply-timing gapGtkStack is a :notebook sibling with no visible tabs of its own: pages are NAME-addressed (:stack-name, via the structural-props mechanism above), not index-addressed the way :notebook's pages are. gtk_stack_add_page's C body auto-selects the first added VISIBLE child as the stack's visible-child (confirmed by reading it directly) a THIRD instance of the exact mount-time-auto-dispatch finding :notebook first surfaced two rounds ago. Mounting a :stack with initial children genuinely dispatches a "notify::visible-child-name" before any real interaction; window_handle_stack_smoke.clj's dispatch-count baseline starts at 1, not 0, for the same reason notebook_scale_button_smoke.clj's does.
A second, genuinely new finding fell out of writing this smoke: :apply runs at create! time, BEFORE the reconciler has appended any children (see the ctor/apply prop-flow section above), so an initial :visible-child-name prop always landed on a completely EMPTY stack. gtk_stack_set_visible_child_name warned Child name not found in GtkStack on every single fresh mount, silently falling back to GTK's own auto-select-first-page behavior instead of the requested page. Fixed the WARNING (not the underlying timing gap) by guarding set-stack-visible-child-name! on gtk_stack_get_child_by_name, proceeding only if the named page already exists:
(defn- set-stack-visible-child-name! [widget name]
(when (and name (g/gtk-stack-get-child-by-name widget name)
(not= name (g/gtk-stack-get-visible-child-name widget)))
(swap! suppressing conj widget)
(g/gtk-stack-set-visible-child-name widget name)
(swap! suppressing disj widget)))
This silences the spurious warning, but leaves a real, documented v1 gap: requesting a NON-default initial page still doesn't take effect at mount, because :apply has no way to defer itself until after children exist. window_handle_stack_smoke.clj's own initial :stack-page is deliberately "a" (the page GTK's own auto-select would land on anyway) specifically to exercise the parts of :stack that DO work correctly (real interaction, suppressing-guard programmatic sync-back) rather than assert on the known gap. See limitations.md.
:drop-down: the first "choose from options" widgetGtkDropDown needed a model (GtkStringList) built incrementally, one string at a time, deliberately sidestepping gtk_drop_down_new_from_strings's raw C-string-array argument, an FFI marshalling class (a char** array) this project has never needed:
(defn- drop-down-build-model! [items]
(let [model (g/gtk-string-list-new jolt.ffi/null)]
(doseq [item items] (g/gtk-string-list-append model item))
model))
Consistent with the round's "design for :apply, never :ctor" rule (above): :ctor always builds an EMPTY GtkStringList (real props are never present at :ctor time regardless, so there's no reason to build anything else there), and :apply's :items key calls gtk_drop_down_set_model to swap in a freshly-built model whenever the item list changes. :selected (a plain int index) follows the usual set-compare-suppress shape every value-bearing widget here uses.
Both of :drop-down's signals turned out to be free reuses, needing zero new glitter.gtk callable-shape code: "notify::selected" is the same 3-arg-void GObject property-change shape already generalized for :list-box/:expander/:paned/:stack (above), and "activate" is the same plain 2-arg-void shape :menu-button already registered. drop_down_grid_smoke.clj's selection round-trip (a real FFI selection change bypassing the wrapper, then a programmatic sync-back) proves both reuses hold for a fourth and fifth widget respectively, not just that they compile.
:grid: the first child-placement containerGtkGrid's attachment API, gtk_grid_attach(grid, child, column, row, width, height), takes the child's cell position as direct arguments at attach time, no separate "set cell" call the way :center-box's named-slot setters work, and no ordered-append semantics the way :box's do. This is what the :glitter/structural-props mechanism (above) exists to serve: grid-attach! reads the child's stashed :grid-column/:grid-row/:grid-column-span/:grid-row-span (each defaulting to 0/0/1/1 when absent) and calls gtk_grid_attach directly. :row-spacing/:column-spacing are the grid's OWN (non-structural) props, applied the normal way through :apply.
gtk_grid_remove takes the child widget pointer directly, needing no position information; remove-child!'s :grid branch is a one-line case addition, no structural-props threading needed at all. gtk_grid_get_child_at(grid, column, row) is the live-GTK verification primitive drop_down_grid_smoke.clj uses to confirm real cell placement, including a column-span cell verified by pointer-equality at BOTH of the two coordinates it's meant to cover; proving the span argument, not just the base column/row, reached GTK correctly.
:grid inherits :glitter/structural-props's v1 constraint above: a child's cell position is fixed at first attach, not reactive to a later re-render's :grid-column/etc changes.
:scrolled wraps a non-GtkScrollable child in a hidden GtkViewport:scrolled shipped in round 1, forked verbatim from glimmer, but examples/glitter/crud.clj (a port of the 7GUIs CRUD task) is the first time any example or smoke in this project has actually given it real content to wrap. Reading a live GTK widget tree back afterward via the usual gtk_widget_get_first_child walk (every smoke's ground-truth technique) initially found :scrolled's child to be some OTHER widget entirely, not the :list-box that was actually mounted inside it.
Root cause, confirmed by reading gtk_scrolled_window_set_child's own doc comment and C body directly: "If child does not implement the GtkScrollable interface, the scrolled window will add child to a GtkViewport instance and then add the viewport as its child widget." GtkListBox does not implement GtkScrollable (confirmed against gtk/gtklistbox.c's G_DEFINE_TYPE_WITH_CODE, which lists no GTK_TYPE_SCROLLABLE interface), so :scrolled's REAL first GTK child is a GtkViewport, and the :list-box is one level further down, as the viewport's own first child:
;; scrolled's :list-box child, read back from live GTK:
(-> scrolled g/gtk-widget-get-first-child ; the auto-inserted GtkViewport
g/gtk-widget-get-first-child) ; the actual :list-box
This is pure GTK behavior, not a glitter concern for ordinary hiccup authors: glitter.gtk's own :children bookkeeping for the :scrolled element never sees the viewport at all (it only ever calls gtk_scrolled_window_set_child once, with the :list-box pointer; GTK manages the viewport-wrapping internally and transparently). It only matters to code that reads the LIVE tree back via raw FFI walks (every live-GTK smoke in this project), which now needs to know to descend one extra level whenever the wrapped child is something non-scrollable like :list-box/:flow-box/:grid. A child that DOES implement GtkScrollable (nothing in this project's widget set does yet) would not get this extra wrapper.
list-box-reorder-child!/flow-box-reorder-child!: a real use-after-dispose bugAlso found while building examples/glitter/crud.clj: renaming a selected person's family name to something that sorts to a DIFFERENT position in the filtered/sorted list triggers a KEYED REORDER: glitter.core's reconciler recognizes the row's :glitter/key as the SAME logical child, just needing a new position, and dispatches to IRender/insert-before โ (since the child is already tracked) list-box-reorder-child!. docs/guide/limitations.md had already flagged this exact path (a same-key reposition, not a tag-swap or a plain append/remove) as "implemented but not previously live-verified". It wasn't safe: the live GTK tree crashed with gtk_list_box_insert: assertion 'GTK_IS_WIDGET (child)' failed plus cascading assertion failures on other widgets still touching the same now-invalid pointer.
Root cause, confirmed by reading GTK source directly rather than assumed: list-box-reorder-child! removes the child's OLD row first (gtk_list_box_remove parent row), then tries to reuse the SAME child pointer for the reinsert. gtk_list_box_remove, once nothing else references the row, disposes it immediately, and GtkListBoxRow's own dispose handler unparents its own child:
/* gtk_list_box_row_dispose's actual C body โ confirmed by reading it
directly */
static void
gtk_list_box_row_dispose (GObject *object)
{
GtkListBoxRowPrivate *priv = ROW_PRIV (GTK_LIST_BOX_ROW (object));
...
g_clear_pointer (&priv->child, gtk_widget_unparent);
...
}
With nothing else in glitter holding an independent reference to that child, gtk_widget_unparent here doesn't just detach it: it drops the child's refcount to zero and finalizes it too. child is a genuinely DANGLING pointer by the time list-box-insert-after! tries to reuse it. flow-box-reorder-child! has the byte-for-byte identical shape, confirmed independently rather than assumed to carry over just because the widgets are siblings: gtk_flow_box_child_dispose has the same g_clear_pointer (&priv->child, gtk_widget_unparent) call.
The fix brackets the remove-then-reinsert with g_object_ref_sink / g_object_unref; the standard GTK C idiom for surviving a reparent gap where the widget would otherwise be briefly, unintentionally ownerless:
(defn- list-box-reorder-child! [parent child sibling]
(let [row (list-box-row-of child)]
(when row
(g/g-object-ref-sink child) ; extra ref โ survives the row's disposal
(swap! suppressing conj parent)
(g/gtk-list-box-remove parent row)
(swap! suppressing disj parent))
(list-box-insert-after! parent child sibling)
(when row
(g/g-object-unref child)))) ; release it โ child is now owned by its NEW row
g_object_ref_sink is safe to call on an ALREADY-sunk, parented widget too (its own semantics: a normal +1 ref if the object isn't floating); it isn't a floating-ref-specific trick, which is why it's the right primitive here even though child was never floating at this call site (it was sunk into its OLD row long ago). Both g-object-ref-sink/g-object-unref were already bound in glitter.ffi (inherited from glimmer's own fork, describing GTK's general floating-ref convention in the ns docstring) but had never actually been called from glitter's own code until this fix; the first real use of either.
examples/glitter/list_box_reorder_smoke.clj pins this permanently: reorders a keyed :list-box and a keyed :flow-box by the SAME keys (no add/remove), and reads the new order back via the usual gtk_widget_get_first_child/get_next_sibling ground-truth walk, not glitter's own bookkeeping: the same discipline examples/glitter/keyed.clj established for :box's own keyed reorder. FAIL-path verified by temporarily reverting the fix: the smoke crashes with the exact assertion failures above and exits 1; restoring the fix returns it to a clean exit 0.
some?, not truthinessapply-props! filters the prop map before handing it to a widget's :apply closure:
(let [applied (into {} (filter (fn [[k v]] (and (not (@signals k)) (some? v)))
(with-orientation tag props)))]
((:apply (spec-for! tag)) widget applied)
(apply-widget-props! widget applied))
some? rather than truthiness is what lets {:active false} or {:sensitive false} actually reach the widget instead of being filtered out alongside genuinely-absent (nil) keys; mirrors glitter.core's own deviation #3 (see porting-and-attribution.md); the two layers have to agree, or a false prop would survive the reconciler's diff only to be silently dropped one layer down.
Setting a prop to a new value (including an explicit false) always works, per the above. But removing a key from the hiccup entirely does not revert the widget to any default: apply-props! only ever receives keys present in the new prop map, so a dropped key is simply never visited again, and the widget keeps its last-applied value. GTK has no generic "unset this property" API the way DOM's removeAttribute does. See limitations.md for the full writeup and why this is left unfixed for v1.
This gap does not apply to :class. :class doesn't go through apply-props!/:apply at all: glitter.core's update-classes diffs the old and new :classes sets directly and calls IRender/remove-class for anything present in the old set but missing from the new one, same as any other keyed collection diff in the reconciler. glitter.gtk wires both add-class and remove-class to real GTK calls:
(add-class [_ el cn] (g/gtk-widget-add-css-class (ptr el) cn) nil)
(remove-class [_ el cn] (g/gtk-widget-remove-css-class (ptr el) cn) nil)
glitter.core's get-classes already normalizes every :class representation (a keyword, a symbol, a string, or a collection of those) down to plain strings before either method is ever called, so cn passes straight through to gtk_widget_add_css_class/ gtk_widget_remove_css_class with no marshalling needed. GTK4 ships built-in classes ("flat", "suggested-action", "destructive-action", "pill", ...) that apply immediately with no app-provided CSS.
Verified live end-to-end (examples/glitter/class_smoke.clj): a built-in class and a custom class both land on mount (confirmed via gtk_widget_has_css_class, not glitter's own bookkeeping); and, crucially, dropping a class from a re-render's :class set actually removes it from the live widget: the class that was correctly not re-applied is the weaker half of the test (any bug that just skipped add-class entirely would still pass that check), so the smoke also asserts a different class is added in the same re-render, proving update-classes' diff calls both add-class and remove-class correctly in one pass, not just one or the other.
:style remains unwired. Unlike :class, there is no GTK equivalent of DOM's element.style.color = ...: an inline, per-element property set outside any stylesheet. GTK4 styling is exclusively class-based, matched against CSS rules loaded through a GtkCssProvider. Synthesizing a real effect from an inline :style map would mean generating a unique class name and CSS rule text per widget and loading it through a provider at render time: genuine design work (provider lifecycle, rule invalidation on every diff, name collision avoidance), not a small FFI addition like :class turned out to be. Hiccup :style props are still accepted and diffed by glitter.core (calling set-style/remove-style), but those two IRender methods remain the no-ops they always were.
Each of these was a deliberate, human-approved scope decision made during the project's final whole-branch review, not an oversight. If you're tempted to "just fix" one of these opportunistically, read the rationale first; each has a reason the mechanical fix was deferred rather than a reason it's impossible.
Setting a prop to a new value (including an explicit false) always works correctly (see gtk-widget-layer.md). Dropping a key from your hiccup entirely does not.
Why: glitter.widget/apply-props! only ever receives the keys present in the new prop map: that's intentional, and what makes it safe to call with a single-key partial map like {:label "new text"} (exactly how glitter.gtk's set-attribute uses it, applying one changed key at a time rather than the whole prop map on every change). A key that's absent from the new map is simply never visited again, so the widget keeps whatever value it last had.
DOM's removeAttribute has an obvious browser meaning (the attribute disappears from the element, and CSS/JS both understand "attribute not present" as a real, distinct state). GTK has no equivalent generic "unset this property, revert to type default" API; a GObject property always has some value, and there's no ABI-level way to ask "what would this widget's :sensitive be if nobody had ever set it."
What a real fix needs: per-widget-type default values designed into glitter.widget's :apply closures (e.g. knowing that a fresh GtkCheckButton's :active defaults to false, so "attribute removed" could mean "re-apply that default"). This is real design work per widget type, not a mechanical patch; out of scope for v1, consistent with this project's existing "no CSS wiring, no animations" v1 boundaries.
glitter.gtk/remove-attribute's implementation makes this explicit rather than silently no-opping without comment:
(remove-attribute [_ el a]
(w/apply-props! (:tag @el) (ptr el) {(keyword a) nil})
nil)
; confirmed live against real GTK state: setting a checkbutton's :active true then "removing" it still leaves gtk_check_button_get_active returning 1.
mount! is one-wayglitter.gtk/mount! registers its add-watch under a fixed key (::render) and returns nil; there is no corresponding unmount!.
Consequences: - Mounting twice against the same state atom silently replaces the first watcher (the second add-watch ::render ... call under the same key overwrites the first), rather than running both. - There's no way to detach a mounted tree and stop it re-rendering, short of (remove-watch state-atom ::render) by hand from outside the API.
Why left as-is: every current example and the intended v1 usage pattern (one root window, mounted once, for the process's life) never needs unmount. Adding a real unmount API (one that also tears down the mounted widget tree, not just the watcher) is real design work (what happens to in-flight renders? does it also need to release every retained foreign-callable under the unmounted subtree?) better done when there's an actual multi-window or hot-reload use case driving the requirements, rather than speculatively.
IMemory never releases:glitter/remember data is held in a process-global map (glitter.gtk's private memory atom) keyed by the element atom, with no eviction when that element is unmounted/removed from the tree.
(defonce ^:private memory (atom {}))
...
proto/IMemory
(remember [_ node data] (swap! memory assoc node data) nil)
(recall [_ node] (get @memory node))
Why this is fine for the intended use, but not for everything: the common IMemory use case is a value stashed on mount and read back on a later update (Replicant's own docs describe this pattern): small, bounded, and the element usually lives for the app's whole lifetime anyway. It is not fine for anything long-lived-relative-to-element- churn or high-cardinality; a keyed list that creates and discards many short-lived rows over a session would leak one map entry per discarded row, forever.
What Replicant's DOM backend does instead: a WeakMap: verified directly against replicant.dom's source ((def ^:no-doc memories (js/WeakMap.))), which lets the JS garbage collector reclaim an entry the moment nothing else references its key DOM node. There is no Jolt/Chez equivalent of a weak-keyed map available to reach for yet, so this gap doesn't currently have a mechanical fix; it's a real platform-capability gap, not a missed line of code.
:center-box cannot safely swap a slot's hiccup tag while all 3 slots are fullGtkCenterBox has exactly three fixed named slots (start/center/ end), no transient capacity for a 4th simultaneous occupant the way :box's ordered list has.
Why this matters: glitter.core's reconciler handles a same-position, non-keyed hiccup TAG mismatch (e.g. a slot's child going from :label to :button) as two separate steps (insert the new node, then remove the old one) relying on the container having room to briefly hold both. GtkCenterBox doesn't; gtk_center_box_set_*_widget unparents (and, since nothing else references it, GTK finalizes) whatever was previously in that slot the instant the new one is set. glitter.gtk's own :children bookkeeping, however, is generic across every container kind and briefly assumes :box-like extra capacity: that mismatch corrupts an UNRELATED third slot, not the one being swapped. Full trace: gtk-widget-layer.md.
What to do instead: change a slot's PROPS, not its TAG (a props-only update never goes through insert/remove at all). If the slot genuinely needs to switch widget types dynamically, nest a stable wrapper tag one level down (e.g. always render [:box [...]] in that slot) so the type change happens where :box's own genuine transient capacity already handles it correctly.
Why left as-is: a real fix would need glitter.gtk's generic :children bookkeeping to become container-kind-aware (know that :center-box has zero spare capacity and defer/reorder its own updates accordingly); a change to shared reconciler-adjacent machinery, not a one-widget patch, and this round's tag-swap scenario is a narrow enough usage pattern (most center-box usage keeps a stable widget type per slot across re-renders) that it wasn't judged worth that scope increase yet.
:paned cannot safely swap a slot's hiccup tag while both slots are fullSame root cause as :center-box's gap above (GtkPaned has exactly two fixed named slots (start/end), no transient capacity for a 3rd simultaneous occupant), but a DIFFERENT, live-verified failure shape, since two slots behave differently from three when the reconciler's "insert new, then remove old" sequencing runs out of room:
:center-box.What to do instead: same remedy as :center-box: change props, not tags, or nest a stable wrapper tag one level down. Full trace, including the live verification of both failure shapes: gtk-widget-layer.md.
Why left as-is: same reasoning as :center-box: a real fix needs glitter.gtk's generic child bookkeeping to become container-kind-aware, not a one-widget patch, and swapping a fixed-slot container's tag dynamically is a narrow usage pattern.
:overlay cannot safely swap its main child's hiccup tag once occupiedSame class of gap as :center-box/:paned: GtkOverlay's one named slot (the main content) has no transient capacity for a second simultaneous occupant, so overlay-insert-after! only handles the sibling = nil, slot still empty case for real. Same remedy: change props, not tags, or nest a stable wrapper tag one level down.
A second, more fundamental gap on top: GTK exposes no API to enumerate current overlay children at all (confirmed: no "get overlays" function in gtk/gtkoverlay.h), so anything beyond "append/remove/replace the main slot correctly, plus append/remove an overlay" is unverifiable by a live smoke, not merely untested. See gtk-widget-layer.md.
:list-box's and :flow-box's reorder-child!This section used to read: "implemented but not live-verified... no smoke in this project currently exercises a genuine reorder for either... treat both as implemented, not verified-under-fire, until a keyed-list reordering smoke covers them." That caveat was correct to have; the live-verification it called for found a REAL bug. examples/glitter/crud.clj (a port of the 7GUIs CRUD task) was the first thing in this project to actually trigger a genuine keyed reorder (renaming a selected person to a family name that sorts differently), and it crashed: list-box-reorder-child!/ flow-box-reorder-child! were reusing a widget pointer that GTK had already disposed as a side effect of removing its old wrapping row: confirmed by reading gtk_list_box_row_dispose's and gtk_flow_box_child_dispose's C bodies directly. Fixed via a g_object_ref_sink/g_object_unref bracket around the remove-then- reinsert in both functions, keeping the child alive across the gap. examples/glitter/list_box_reorder_smoke.clj now pins this permanently: reorders a keyed :list-box and a keyed :flow-box by the same keys and reads the new order back via the live GTK tree, not glitter's own bookkeeping, FAIL-path verified by temporarily reverting the fix. See gtk-widget-layer.md for the full trace.
:flow-box's :on-child-activated dispatches with no valuev1 deliberately does not wire a value-fn for "child-activated": reading back "which child" would need GList traversal via gtk_flow_box_get_selected_children, a new FFI complexity class (walking a linked list through raw pointers) this project hasn't needed elsewhere. :on-click/:on-toggled already establish the precedent that a dispatched event with no :glitter/value is a normal, supported shape: apps that need "which child was activated" must correlate it themselves (e.g. via their own understanding of what's currently rendered in the flow-box) until a real fix lands.
:notebook has no per-child tab-label conventionnotebook-append-child!/notebook-insert-after! always pass NULL as gtk_notebook_append_page/insert_page's tab_label argument: GTK auto-generates a default numbered tab in that case. There is no v1 hiccup convention for supplying a custom tab label per page (that would mean a second widget per child, a shape no other container in this project needs), so this was deliberately deferred rather than invented under time pressure. Apps that need custom tabs must wait for a real fix, or work around it by rendering their own tab-strip alongside a plain :box instead of using :notebook's built-in tabs.
:notebook dispatches a real action as a side effect of mountinggtk_notebook_insert_page's own C body auto-selects the first page added to a notebook that has no current page yet (if (!gtk_notebook_has_current_page (notebook)) { gtk_notebook_switch_page (notebook, page); }, confirmed by reading it directly). Since :notebook's initial children are appended via this exact path during mount, constructing a [:notebook ...] with pre-populated pages genuinely fires "switch-page" (and therefore dispatches whatever action the hiccup wires to it) before the app has done anything. This is real GTK behavior, not a glitter defect, but it means any app tracking a dispatch count (or assuming "no action fires until the user interacts") must account for this one mount-time dispatch specifically for :notebook; no other container in this project behaves this way. See gtk-widget-layer.md for the full trace, including the empirical probe that confirmed it.
GtkEditable text replacement can fire "changed" once or twicegtk_editable_set_text is not a single atomic mutation: its C body calls gtk_editable_delete_text then gtk_editable_insert_text separately, and only property notify (not "changed" itself) is frozen around the pair. Replacing text in an already-empty buffer fires "changed" once (the delete is a no-op, confirmed via gtk/gtktext.c's early return when start_pos == end_pos); replacing non-empty text fires it twice. This affects every widget in this project built on the GtkEditable delegate (:entry, :password-entry, :search-entry, :editable-label) not just one of them, and only matters when a caller bulk-replaces text via gtk_editable_set_text directly (this project's own "bypass the wrapper, trigger the real signal" smoke-testing technique, or any real app code doing a programmatic bulk replace); normal character-by- character typing never hits this path. Every GtkEditable-family smoke in this project starts its interaction target from empty text to avoid asserting on this incidental doubling rather than the behavior under test. See gtk-widget-layer.md for the full trace.
:header-bar/:action-bar cannot safely swap the title/center-widget's hiccup tag while occupiedSame root cause and same class of gap as :center-box's/:paned's/ :overlay's: *-insert-after!'s sibling nil branch falls through to *-append-child!, which sees the title/center-widget slot still occupied and pack-starts the new widget instead of replacing the title: the reconciler's subsequent removal of the old title then leaves that slot empty with the new widget stranded in the pack-start list. Change props instead of tags, or nest a stable wrapper tag one level down.
:header-bar/:action-bar never call pack_endConfirmed by reading gtk_header_bar_pack's and gtk_action_bar_pack_end's C bodies directly: both widgets' pack_end reverse-accumulate (a gtk_box_prepend under one name, a gtk_box_insert_child_after(..., NULL): also a prepend: under the other): feeding END-region children one at a time in hiccup order, as the reconciler naturally does, would silently land them in REVERSE order with no public API to fix afterward. v1 only wires pack_start; gtk_header_bar_pack_end/gtk_action_bar_pack_end are not bound to anything in this codebase. A future round adding end-region support must also solve the reversal (e.g. resyncing the whole region on every mutation), not just call the function. Neither widget's pack-start region can be reordered either, for a genuinely structural reason: it's a real ordered list internally, but a PRIVATE GtkBox glitter has no pointer to: only the append-only pack_start functions are public.
:header-bar's :show-title-buttons shares glitter's own pack-start listgtk_header_bar_set_show_title_buttons(bar, TRUE) prepends a native GtkWindowControls widget into the SAME start_box glitter's own pack-start children live in (confirmed by reading create_window_controls's C body directly). This is real GTK behavior, not a glitter defect, and does not affect glitter's own correctness (gtk_header_bar_remove/replace only ever act on widgets glitter itself created), but any code inspecting the header bar's pack-start region directly (rather than through glitter's own API) needs to account for GTK's own widget potentially occupying the front of that list. See gtk-widget-layer.md for the full trace, including how header_bar_action_bar_smoke.clj asserts the shift explicitly.
:search-bar cannot auto-manage search mode via key capturev1 does not wire gtk_search_bar_connect_entry/ gtk_search_bar_set_key_capture_widget: both need a raw GtkEditable/GtkWidget pointer glitter has no hiccup-level convention for passing sideways yet (every other cross-widget relationship in this project is either a normal tree child or a single named prop, not "here's a pointer to a DIFFERENT widget elsewhere in the tree"). :search-mode remains fully controllable programmatically; only the Ctrl+F/Escape auto-toggle convenience is missing.
:class reaches real GTK CSS classesHiccup :class is diffed by the reconciler (IRender/add-class/ remove-class), and glitter.gtk now wires both to gtk_widget_add_css_class/gtk_widget_remove_css_class: GTK4's actual per-widget styling hook, including its built-in classes ("flat", "suggested-action", "destructive-action", "pill", ...) which apply with zero app-provided CSS. See gtk-widget-layer.md for the mechanics and examples/glitter/class_smoke.clj for the live verification (add, coexist, and (the part that actually proves the diff path works, not just add-class) remove on a re-render).
:grid/:stack: a child's structural props are read only at first attach:grid-column/:grid-row/:grid-column-span/:grid-row-span (read by :grid) and :stack-name (read by :stack) are stashed on a CHILD's own tracking atom via :glitter/structural-props, but only consumed at the moment that child is FRESHLY attached: a fresh mount, or a keyed insert. Changing an already-attached child's :grid-column/:stack-name on a LATER re-render does not move it in the grid or rename its stack page; there is no code path that re-reads :glitter/structural-props for a child already in the tree.
Why left as-is: every other container in this project keys position either by GTK's own live-queryable occupancy (:center-box/:paned) or by plain append order (:box/:list-box): :grid/:stack are the first containers where position is data the CHILD carries, and making that data reactive to later changes would mean detecting a structural-prop diff INSIDE set-attribute and re-invoking gtk_grid_attach/gtk_stack_add_named on an already-parented widget (gtk_grid_attach's own behavior on an already-attached child at a new position isn't something this round verified live). Fine for the common case of a static layout with fixed positions. See gtk-widget-layer.md.
:stack doesn't honor a non-default initial :visible-child-name at mount:apply runs at create! time, BEFORE the reconciler has appended any children to the newly-created widget (see the ctor/apply prop-flow finding in gtk-widget-layer.md), so an initial [:stack {:visible-child-name "b"} ...] always applies :visible-child-name to a completely empty stack. gtk_stack_get_child_by_name guards set-stack-visible-child-name! to avoid the resulting GTK warning, but the requested page still doesn't take effect: GTK's own gtk_stack_add_page auto-selects the FIRST added visible child instead, same as :notebook's mount-time auto-select above. A subsequent re-render's :visible-child-name DOES work correctly (:apply runs after the stack already has children by then); only the very first, initial page selection is affected.
Why left as-is: a real fix needs :apply to defer the :visible-child-name call until after the reconciler has appended this render's children: a change to the create!/apply-props! sequencing shared by every widget spec, not a :stack-local patch. See gtk-widget-layer.md.
:window's :width/:height can clobber each other across single-key re-rendersThe same multi-key-clobbering shape the ctor/apply audit found and fixed in :scale/:spin-button/:scale-button (above) also exists in window-spec: gtk_window_set_default_size(window, width, height) takes both dimensions in one call, and a render changing only :width (or only :height) risks resetting the other back to a hardcoded -1 fallback rather than preserving its last-applied value.
Why NOT fixed alongside the other three this round: the fix pattern used elsewhere (read the widget's CURRENT value as the fallback instead of a hardcoded default) needs gtk_window_get_default_size, whose real signature returns via OUT-PARAMETERS (void gtk_window_get_default_size (GtkWindow*, int *width, int *height)), a genuinely new FFI marshalling class this project hasn't taken on anywhere else. The practical severity is also much lower than the other three: :width/:height are initial-sizing-only concerns (GTK's own -1 fallback means "natural size," not garbage), and no live smoke or app in this project currently sets them across separate single-key re-renders. Documented as a comment above window-spec rather than fixed. See gtk-widget-layer.md.
app.clj's on-gui runs inline when already on the GTK main thread, and every glitter effect dispatches from a GTK signal callback (that thread), so each :effect/assoc-in inside an action-expansion (register-action!) drives its own full, synchronous core/reconcile before the next effect in that expansion runs, not one render for the whole expansion. crud.clj's :action/delete is the concrete example: 4 effects, 4 renders, where the pre-retrofit hand-written version computed the whole transition in one swap! and drove exactly 1.
Why this is fine today, but worth knowing: no demo in this project currently exposes wrong intermediate state from this: crud.clj's own intermediate delete-render still has :selected-id pointing at the just-removed person, but view's selected? derivation happens to keep the Update/Delete buttons insensitive regardless. A future demo built on the action-expansion pattern should keep the possibility in mind. See nexus.md for the full mechanics.
Why left as-is: a coarser effect type that batches an expansion's effects into one render would be new architecture on top of a faithful nexus port, not a mechanical fix: out of scope here.
glitter.nexus.registry's registry is one process-global atomglitter.nexus.registry/!registry is a single top-level atom: two glitter apps sharing one process (e.g. two demos' namespaces loaded into the same REPL) share one set of registered effects/placeholders/ expansions, not one each. Every demo in this project runs as its own process (jolt -M:crud, jolt -M:flights, ...), so this has never mattered in practice, but it's a real constraint on any future multi-app-in-one-process usage.
glitter.nexus.action-log accumulates a full dispatch/expansion/effect tree (see nexus.md), but (pr-str @log) is the only inspection method today; there is no GTK-native viewer window. A deliberate v1 scope decision from the design spec, not an oversight; upstream's own viewer (nexus.inspector) is entangled with dataspex.* rendering protocols that have no glitter/GTK equivalent.
:style, and no animations:style is still diffed (IRender/set-style/remove-style are called) but both remain no-ops. Unlike :class, there's no small FFI addition that fixes this: GTK4 has no equivalent of DOM's inline element.style.color = ...: styling is exclusively class-based, matched against rules loaded through a GtkCssProvider. A real :style prop would need generating a unique class name and CSS rule text per widget, managing that provider's lifecycle, and invalidating/reloading the rule on every diff; genuine design work, not attempted here. on-transition-end still fires its callback immediately and synchronously; there is no animated mount/unmount transition support. Both remain explicit v1 scope boundaries from the design spec, not partially-implemented features.
timer.clj: dragging duration to 0 is a UX dead end, not a crashexamples/glitter/timer.clj's duration :scale allows :min 0, and get-view-state is written to stay safe at :duration 0 (coerced to a double before the percentage division, so it resolves to {:pct 0 :elapsed "0s"} rather than throwing), but the practical result is a permanently frozen 0%/"0s" display with no way to progress, since elapsed is always (min elapsed 0). Not fixed, since "what should a 0-second timer visually do" isn't specified by the 7GUIs task and any answer is a UX judgment call, not a correctness fix.
glitter.nexus is a port of nexus (same author as Replicant), a small, toolkit-agnostic action/effect/ placeholder dispatch engine. It sits directly downstream of core/set-dispatch! (see architecture.md): where glitter.core decides when to call a dispatch fn and what data to hand it, glitter.nexus decides what to do with that data once set-dispatch!'s registered fn receives it. See porting-and-attribution.md's Bucket 4 for the exact file-by-file porting ledger.
Every glitter demo before this arc (counter.clj, todo.clj, crud.clj) hand-wrote one case branch per action kind inside its own execute-actions fn, manually reading (get-in event [:glitter/dom-event :glitter/value]) and swap!-ing the state atom directly. This works, but (per this project's own design spec for the arc) "it's boilerplate that scales linearly with the number of interactive fields, and it puts side-effecting swap! calls in the same function that also has to make domain decisions (should this add a task? should this replace the selected person?)."
nexus solves this generically: actions are plain data ([:effect/assoc-in [:draft] [:glitter/value]]) dispatched through a registry of effect handlers (the ONLY functions allowed to mutate anything), and placeholder resolvers that substitute event-derived values into action data before it's used. Actions that need to make a decision based on current state, not just pass an event value through, register as expansions: pure functions of (state & args) returning more actions/effects. The whole point: the only place a swap! (or any side effect) can happen is inside a registered effect handler; everything else, including "what should happen when this button is clicked," is data a pure function computes.
swap! is allowedAn effect handler is a plain function registered under an action-kind keyword. glitter.nexus.registry/register-effect! stores it under [:nexus/effects effect-k]:
(defn ^{:indent 1} register-effect! [effect-k f]
(swap! !registry assoc-in [:nexus/effects effect-k] f))
Every demo in this project registers exactly one effect, :effect/assoc-in, identical across flights.clj, crud.clj, and todo.clj:
(nxr/register-effect! :effect/assoc-in
(fn [_ system path v] (swap! system assoc-in path v)))
glitter.nexus/execute-effect is what actually calls it: it threads the effect fn through run-interceptors (below), then invokes (apply effect-f (->execute-ctx ctx*) system (next effect)). The first argument (an execute-ctx, ignored by every effect registered in this project so far) gives an effect a way to recursively :dispatch more actions from inside itself, not used by :effect/assoc-in, but part of why the arg list has a leading, usually-unused context arg.
Hiccup :on data is fixed at the moment view runs (see CONTRIBUTING.md invariant #8); it can't carry a value that only exists once the user types. nexus's placeholder mechanism is the generic version of the :glitter/value-in-the-event-map trick every demo already needs: glitter.nexus/interpolate-walk walks an action's data, and any nested vector whose head matches a registered placeholder keyword gets replaced by calling that placeholder's function with dispatch-data (and the placeholder's own trailing args):
(defn ^:no-doc interpolate-walk [placeholders interpolations dispatch-data x]
(if (-> x meta :nexus/skip-interpolation)
x
(let [x' (if (coll? x)
(walk/walk #(interpolate-walk placeholders interpolations dispatch-data %) identity x)
x)]
(if-let [f (when (vector? x')
(get placeholders (first x')))]
(let [resolution (apply f dispatch-data (next x'))]
(swap! interpolations conj {:placeholder x'
:resolution resolution})
resolution)
x'))))
This is why [:effect/assoc-in [:draft] [:glitter/value]] works as hiccup :on data: [:glitter/value] is itself a nested vector headed by a registered placeholder keyword, so before :effect/assoc-in's fn ever runs, interpolate-1 rewrites the whole action to [:effect/assoc-in [:draft] "whatever was typed"]. flights.clj adds a second placeholder, :fmt/nth, for its :drop-down's int-index selection (mirroring nexus's own :fmt/long/:fmt/number convention (see dev/counter/core.cljc and the "Nested placeholders" section of upstream's Readme.md) adapted because :drop-down's value-fn already delivers an int index rather than the raw DOM input string :fmt/long/:fmt/number are built to convert):
:nexus/placeholders
{:glitter/value (fn [event] (get-in event [:glitter/dom-event :glitter/value]))
:fmt/nth (fn [_ coll idx] (nth coll idx))}
used as [:effect/assoc-in [:type] [:fmt/nth [:one-way :roundtrip] [:glitter/value]]]; placeholders nest, so :glitter/value resolves first (innermost), then :fmt/nth indexes into the literal [:one-way :roundtrip] vector with that resolved int.
swap!An action that needs to decide what should happen (not just pass an event value through) registers as an expansion: glitter.nexus.registry/register-action! and register-expansion! are literally the same function body, both writing to [:nexus/expansions action-k]:
(defn ^{:indent 1} register-action! [action-k f]
(swap! !registry assoc-in [:nexus/expansions action-k] f))
(defn ^{:indent 1} register-expansion! [action-k f]
(swap! !registry assoc-in [:nexus/expansions action-k] f))
(Verified by reading glitter.nexus.registry directly; this isn't a glitter deviation, it's how upstream nexus.registry ships too, kept byte-for-byte.) glitter.nexus/dispatch-action checks :nexus/expansions before :nexus/effects when resolving an action kind, so an action-expansion fn takes priority over a same-named effect if both happen to be registered (none of this project's demos register both under the same keyword). crud.clj's :action/select-row is a representative expansion; a pure function of (state & args) that reads current state and returns MORE actions rather than mutating anything itself:
(nxr/register-action! :action/select-row
(fn [state idx]
(if-let [person (nth (vec (get-people state)) idx nil)]
[[:effect/assoc-in [:selected-id] (:id person)]
[:effect/assoc-in [:given-name] (:given-name person)]
[:effect/assoc-in [:family-name] (:family-name person)]]
[])))
One live-verified arity gotcha, found while retrofitting crud.clj (Task 6 of this arc): [[:action/select-row]] (the naive hiccup :on value with no trailing action-tuple data) silently mismatches register-action!'s 2-arg fn (state and idx), and the resulting exception is swallowed into nexus's own :errors accumulator (see try-f/log-error below), not thrown. The fix is [[:action/select-row [:glitter/value]]]: the placeholder resolves idx from the dispatched list-box row index before the action fn runs, giving it the correct 2-arg shape. If an action-expansion silently never fires, check :nexus/on-error's log output before assuming the action itself is buggy.
Every dispatch phase (the outer dispatch, each action, each effect) runs through run-interceptors, which threads state through a stack of before-*/after-* handler pairs, catching exceptions per-step via try-f (so one interceptor's error doesn't kill the whole dispatch):
(defn ^{:indent 1
:no-doc true} run-interceptors [ctx interceptors [before after k]]
(letfn [(invoke [f state phase interceptor]
(->> (select-keys interceptor [:id])
(into (cond-> {:phase phase}
k (assoc k (get ctx k))))
(try-f state f)))]
(loop [state (assoc ctx :queue interceptors :stack ())]
(cond
(:queue state)
(let [interceptor (first (:queue state))
state (-> (update state :queue next)
(update :stack conj interceptor))]
(recur (invoke (get interceptor before) state (or (:phase interceptor) before) interceptor)))
(:stack state)
(let [interceptor (first (:stack state))
state (update state :stack next)]
(recur (invoke (get interceptor after) state after interceptor)))
:else state))))
No demo in this arc registers a custom interceptor: :nexus/interceptors stays empty in flights.clj/crud.clj/todo.clj. The one concrete interceptor in this codebase is glitter.nexus.action-log's get-interceptor (see below), which any consumer can add via install-logger, but none currently do.
glitter.nexus/glitter.nexus.registry stay 100% toolkit-agnostic, faithful to upstream's own separation: nexus.core doesn't know about the DOM any more than it should know about GTK. Two pieces are glitter-specific and deliberately live in each consuming demo, not in the ported files, mirroring how upstream's own dev example, dev/counter/core.cljc, registers its own DOM-specific placeholders (:event.target/value) rather than baking them into nexus.core:
:glitter/value placeholder: (fn [event] (get-in event [:glitter/dom-event :glitter/value])). event here is the dispatch-data argument nexus's dispatch was called with, which (per glitter.core/set-dispatch!'s contract) is the map glitter.core/build-event-map builds ({:glitter/trigger :glitter.trigger/dom-event :glitter/dom-event e ...}); e is the raw event object glitter.gtk/set-event-handler constructs, which stuffs the widget's current value onto it as :glitter/value for any value-bearing signal (see gtk-widget-layer.md and CONTRIBUTING.md invariant #8). This is the SAME value every demo read by hand before this arc; registering it as a nexus placeholder just moves the get-in call out of a hand-written dispatch fn and into data.:nexus/on-error โ clojure.tools.logging: every demo registers (nxr/on-error (fn [_ctx {:keys [err] :as error}] (log/error err "glitter.nexus dispatch error" (dissoc error :err)))). glitter.nexus/ log-error calls this on ANY caught exception anywhere in the dispatch pipeline (a bad effect, a bad expansion, an unresolved effect handler); without it, errors are silently swallowed into ctx :errors and never surface anywhere. This is the reason jolt-lang/logging (a port of clojure.tools.logging) was added to deps.edn in this arc.Both are toolkit-specific choices a Reagent/glimmer app or a nexus.core-only web app wouldn't share, which is exactly why they don't belong in glitter.nexus/glitter.nexus.registry themselves.
flights.clj: pure effects onlyexamples/glitter/flights.clj (the 7GUIs Flight Booker) is the first real consumer of glitter.nexus, and every interaction in it dispatches at most two effects, never an action expansion: every field is a pure :effect/assoc-in plus a registered placeholder, except the "Try again" button, which dispatches two :effect/assoc-in calls back to back. Zero hand-written case-dispatch code either way, and no :nexus/actions/:nexus/expansions registered at all:
[:entry {:text value :hexpand true
:class (if error? ["error"] [])
:on {:change [[:effect/assoc-in [path] [:glitter/value]]]}}]
flights.clj never calls nxr/register-system->state! at all: unlike crud.clj/todo.clj (both call (nxr/register-system->state! deref)), it doesn't need to. glitter.nexus/dispatch's own assert ((when (:nexus/expansions nexus) (assert (or (ifn? (:nexus/system->state nexus)) (ifn? (:nexus/system+dispatch-data->state nexus))) "Either ... must be a function"))) only fires when :nexus/expansions is non-nil, and flights.clj registers no actions/expansions at all, so :nexus/expansions stays nil throughout. Every effect in flights.clj is a direct assoc-in on the raw system atom; nothing reads derived state back through nexus.
crud.clj/todo.clj/temperature.clj/timer.clj: action-expansionscrud.clj and todo.clj were both retrofitted onto glitter.nexus in this arc (replacing a hand-written execute-actions case form); temperature.clj (the 7GUIs Temperature Converter) and timer.clj (the 7GUIs Timer) were both written against glitter.nexus from the start, like flights.clj before them. All four register :nexus/expansions (via register-action!/register-expansion! (see above), the layer flights.clj never needs at all), but for a few different reasons. crud.clj's :action/select-row/:action/create/:action/update/:action/delete and todo.clj's :action/toggle/:action/add-task need to READ current state to decide what should happen. todo.clj's :action/toggle is the simplest expansion in the codebase; reads the row's CURRENT :done value to not it, something a pure :effect/assoc-in literally cannot express since it has no way to read state before writing:
(nxr/register-action! :action/toggle
(fn [state idx]
[[:effect/assoc-in [:tasks idx :done] (not (get-in state [:tasks idx :done]))]]))
temperature.clj's single :action/set-temperature needs an expansion for a different reason: it doesn't read app STATE at all (its fn signature is (fn [_state temps] ...), ignoring the first arg): it branches on which key is present in the DISPATCH DATA (has :celsius come through, or :fahrenheit?) to decide which field is the source and which is derived, something a single bare :effect/assoc-in can't express either, just for a different reason than crud.clj/todo.clj's state-reads. Because glitter.nexus/dispatch's assert (quoted above) fires whenever :nexus/expansions is non-nil at all (regardless of whether the specific expansion that runs actually touches state) temperature.clj still has to call (nxr/register-system->state! deref) even though set-temperature never uses its state argument; omitting it throws Assert failed: Either :nexus/system+dispatch-data->state or :nexus/system->state must be a function on the very first dispatch.
timer.clj's two expansions need :nexus/expansions for a THIRD reason, closer to crud.clj/todo.clj's than temperature.clj's: :action/tick's (fn [state] [[:effect/schedule 100 [[:effect/assoc-in [:last-tick] (:now state)] [:action/tick]]]]) genuinely reads (:now state) (a fresh System/nanoTime reading, supplied by timer.clj's own :nexus/system->state (below)) to give :last-tick a real timestamp instead of nil. (The [:action/tick] re-schedule itself is an unconditional literal, not decided by (:now state): :now's only causal effect here is :last-tick's value, not whether or how often the loop perpetuates.) :action/reset's (fn [_state] [[:effect/assoc-in [:started] [:clock/now]]]) ignores state (same shape as temperature.clj's set-temperature), but the same non-nil-:nexus/expansions assert applies regardless of which specific expansion runs, so timer.clj also has to register a :nexus/system->state function.
timer.clj is the first demo whose :nexus/system->state isn't a bare deref. flights.clj needs none at all; crud.clj/todo.clj/ temperature.clj all register (nxr/register-system->state! deref): a no-op wrapper satisfying the assert above, since none of their expansions need anything beyond the state atom's own stored keys. timer.clj registers (fn [system] (assoc @system :now (System/nanoTime))) instead, augmenting the dereffed atom with a value the atom itself never stores. This augmentation is consumed by :action/tick's own action-expansion; a dispatch-time read; NOT by view (a render-time call). glitter.gtk/mount!'s add-watch re-renders view directly off the raw new value of the watched state atom (src/glitter/gtk.clj), entirely independent of glitter.nexus's dispatch machinery, and unaware :nexus/system->state even exists; since the atom itself never stores a :now key (only :started/:duration/:last-tick are ever written via :effect/assoc-in), an early version of timer.clj's view that tried to read (:now state) directly threw a live NullPointerException on every tick (verified live: 63 occurrences over an 8-second run): swallowed by glitter.nexus's own try-f/on-error handling, so the process didn't crash, but core/reconcile never ran, and the display never advanced past its initial paint. The fix: view reads System/nanoTime directly itself, the same live-fresh-read-at-render-time pattern flights.clj's get-form-state already established for (t/today) (see that fn's own comment): a demo's view computes what it needs fresh, rather than assuming anything nexus computed for a DIFFERENT purpose (dispatch-time action-expansion) will also be there at render time.
All four demos still register :effect/assoc-in and :glitter/value too (crud.clj/todo.clj for the fields that ARE pure passthroughs (the filter field in crud.clj, which dispatches a bare :effect/assoc-in directly via its field-row helper, not an action at all; the draft-text field in todo.clj); temperature.clj has no pure-passthrough field at all (both its :entry fields route through :action/set-temperature), but still registers :effect/assoc-in because set-temperature's own expansion result is built from :effect/assoc-in tuples, and :glitter/value because its demo-local :fmt/number placeholder nests [:glitter/value] inside its own placeholder chain ([:fmt/number [:glitter/value]]); timer.clj's single pure-passthrough interaction is its duration :scale's :on {:value-changed [[:effect/assoc-in [:duration] [:glitter/value]]]}}) a bare :effect/assoc-in/:glitter/value pair, no expansion, dispatched alongside its two action-expansions. The two consumer shapes aren't mutually exclusive within one demo; they're a per-interaction choice, made by whether that interaction needs to read state, or branch on dispatch data, before deciding what effects to run.
Every effect inside an action-expansion (the crud.clj/todo.clj consumer shape above) is dispatched separately, and each one drives its own full, synchronous core/reconcile before the next effect in the same expansion runs, not one render for the whole expansion. This follows from two facts already true elsewhere in this project: app.clj's on-gui runs inline when already on the GTK main thread (see CONTRIBUTING.md invariant #6), and every effect in this codebase dispatches from a GTK signal callback, which already runs on that thread. So there's no batching boundary around an expansion's effects the way one hand-written swap! implicitly gave the pre-retrofit code.
crud.clj's :action/delete is the concrete example: it expands into 4 :effect/assoc-in calls (:people, :selected-id, :given-name, :family-name), so deleting a person drives 4 renders, where the pre-retrofit hand-written version computed the whole transition in one swap! and drove exactly 1. Today this doesn't expose any wrong intermediate state: crud.clj's own intermediate delete-render still has :selected-id pointing at the just-removed person, but view's selected? derivation (which checks whether that id is still present in :people) happens to keep the Update/Delete buttons insensitive regardless. A future demo built on this pattern should keep the possibility in mind: an expansion's effects are N sequential renders, not one atomic transition, and an intermediate render CAN observe partially-applied state.
glitter.nexus.action-log ports nexus's log-accumulation mechanism (the nested :entries/:chronology tree tracking every dispatch, every expanded action, and every executed effect, with per-entry elapsed-time measurements (:dispatch-elapsed, :expansion-elapsed, :effect-elapsed, each a {:ms .. :slow? ..} map from measure-elapsed)) note that :expansion-elapsed is measured from the most-recently-started NESTED item's start time, not the entry's own, inherited verbatim from upstream nexus (inspector.cljc's after-action); not "fixed" here because doing so would be an undocumented divergence from a faithful port (see the code comment above after-action in action_log.clj). It captures, per top-level dispatch: a UUID :id, a tick.core/now timestamp (:dispatched-at), the raw :dispatch-data (and, if present, the :glitter/dom-event under :dom-event), and a nested :actions vector where each action's own :expansions holds the further actions/effects it expanded into; recursively, so a crud.clj-style :action/select-row expanding into three :effect/assoc-in calls shows up as one top-level action entry with three nested expansion entries.
It's wired in via install-logger, which conj's the log's interceptor onto a nexus config map:
(defn install-logger
"Adds this log's interceptor to a nexus config map's :nexus/interceptors."
[nexus log]
(update nexus :nexus/interceptors (fnil conj []) (get-interceptor log)))
No demo in this project currently calls install-logger: only test/glitter/nexus/action_log_test.clj's own unit tests exercise it directly, against small standalone nexus configs. (pr-str @log) is the current, and only, inspection method; there is no viewer. A GTK4- native viewer window (walking @log's :chronology/:entries tree the way a browser-based dataspex panel would) is a natural, separately- scoped follow-up once there's a second reason to build one; upstream's own equivalent (nexus.inspector) is entangled with dataspex.* rendering-protocol implementations that have no glitter/GTK analogue, which is why this port stops at the accumulation mechanism and drops every rendering call site (see porting-and-attribution.md's Bucket 4).
t/parse-date leniency findingflights.clj's date fields need to satisfy the 7GUIs spec's "T is colored red when ill-formatted" requirement, which means detecting malformed date text reliably. The obvious approach, a bare t/parse-date call, does NOT work: verified live under this Jolt port (jolt-lang/time, pulling in juxt/tick transitively; see deps.edn), t/parse-date is LENIENT, not strict. Three separate bad inputs against a "dd.MM.yyyy" formatter, none of which threw:
"27.03.2014x" (trailing garbage) silently parsed to 2014-03-27, ignoring the trailing x."not-a-date" silently parsed to -0001-11-30."31.02.2014" (February 31st, not a real date) silently rolled over to 2014-03-03.Using parse-date naively for the ill-formatted-date check would have shipped a feature that never actually triggers. The fix, also verified live against all three inputs above plus a fourth ("7.3.2014"; wrong digit count for the formatter's 2-digit pattern) and the valid case, is a round-trip wrapper: parse, then re-format the result with the SAME formatter, and reject (nil) unless the re-formatted string exactly matches the trimmed input:
(defn parse-date [s]
(when (string? s)
(let [trimmed (str/trim s)]
(when (seq trimmed)
(try
(let [d (t/parse-date trimmed date-formatter)]
(when (= trimmed (t/format date-formatter d))
d))
(catch Exception _ nil))))))
This is the ONLY date-validation strategy flights.clj uses, no separate regex-based pre-check. get-form-state calls parse-date on both the departure and return fields; a nil result flags that field :invalid?, which drives both the :class "error" CSS styling and the Book button's :sensitive state. This finding is specific to date parsing (a tick/jolt-lang/time library behavior), not to any GTK widget, so it's documented here rather than in gtk-widget-layer.md; this is its one and only write-up in this project's docs.
glitter's source falls into four buckets. NOTICE (repo root) is the authoritative, maintained ledger; this page explains what the buckets mean and summarizes the deviations; if the two ever disagree, NOTICE wins.
Mechanical rename port (replicant.* โ glitter.*, including the :replicant/* keyword namespace) via sed -E 's/\breplicant\b/glitter/g', from Replicant commit 379bb3c1ad4d5d3002c57e67ab647d12f3c2d322 (2026-07-25), Copyright 2023-2025 Christian Johansen, MIT License.
Ported files: glitter.protocols, glitter.hiccup, glitter.hiccup-headers, glitter.console-logger, glitter.errors, glitter.assert, glitter.vdom, glitter.asserts, glitter.core, glitter.alias.
glitter.core (the diff/reconcile algorithm) is the largest and most important of these, and carries three deliberate deviations from a pure mechanical port:
build-event-map's :clj branch reads (:glitter/node e) instead of hardcoding nil. Replicant's #?(:cljs (.-target e) :clj nil) reflects that its :clj branch was never exercised by a live DOM (Replicant targets ClojureScript/the browser), but glitter's events are live, so the acting element has to travel through somehow. glitter.gtk supplies it as :glitter/node on the event map it hands to the dispatched handler.set-dispatch! is new code, not a port. It doesn't exist in replicant.core at all; only *dispatch*, the dynamic var it reads. The function lives in replicant.dom (browser-specific, never ported; see Bucket 3), so glitter added its own one-liner mirroring that original: (defn set-dispatch! [f] (alter-var-root #'*dispatch* (constantly f))).update-attr/set-attributes route on (some? v) instead of truthiness. DOM attributes have a natural "absent" state that coincides with false for most practical purposes (an if-let-style truthiness check works because DOM callers rarely need to distinguish "explicitly false" from "not set"). GTK properties don't share that: a checkbutton's :active false or a widget's :sensitive false is a real, meaningful boolean value that must reach the widget, not get silently treated as "attribute absent, remove it." Verified live against real GTK state during the project's final whole-branch review.glimmer is authored by Dmitri Sotnikov (Yogthos) under the jolt-lang organization: a different author from glitter's, verified from git history on upstream/main (29 commits, 25 as Yogthos and 4 as Dmitri Sotnikov, zero by anyone else). Upstream ships no LICENSE file, so no grant has been made; this bucket records provenance, not a claimed permission. See NOTICE's ## glimmer section for the authoritative statement and its full reasoning:
glitter.ffi: forked from glimmer.ffi, plus 223 new bindings added across the widget rounds. NOTICE's src/glitter/ffi.clj entry is the authoritative, exhaustive list; it is deliberately not repeated here, because this page's own rule is that NOTICE wins when the two disagree, and a hand-maintained second copy is exactly how they come to disagree.What the bindings are for, in roughly the order they arrived:
:scale, :class: GtkRange/GtkScale value and range, CSS-class add/remove/query.:spinner, :progress-bar, :image, later :picture and :inscription.:toggle-button, :level-bar, :link-button, :switch.:revealer, :center-box, :list-box, :paned, :overlay, :flow-box, :header-bar, :action-bar, :grid, :stack.GtkEditable family: :password-entry, :search-entry, :editable-label.:spin-button, :scale-button, :notebook, :drop-down, :calendar (including refcounted GDateTime).:menu-button, :popover, :window-handle.GtkAdjustment accessors plus gtk-checkbutton-set-label, added to fix four previously-shipped bugs. One binding arrived outside any widget round: gtk-widget-get-sensitive, added while verifying examples/glitter/crud.clj. Every prior use of :sensitive only ever set it; that was the first live check of its own read-back.
glitter.widget: forked from glimmer.widget. What was added, and the four places its behaviour deliberately diverges from glimmer's. Added, alongside the shared helpers insert-child-after!, signal-name, signal-value-fn, suppressing?:
:scale: the first-party demonstration of the value-bearing custom-signal path.:spinner, :progress-bar, :image, :level-bar, display-only: no signal wiring, driven entirely by re-applied props.:toggle-button, :link-button: both reuse an existing signals entry verbatim.:switch: the widget that forced glitter.gtk/set-event-handler itself to generalize.:revealer: display-only, and a free single-child-container reuse.:center-box, a genuinely new container strategy: three fixed named slots, not an ordered list.:spin-button: forced signal-value to be keyed by tag as well as signal name.:list-box: a third callable shape, plus two more real bugs found and fixed.Four behavioural deviations from glimmer:
replace-child!'s :box branch captures the old child's previous sibling via gtk_widget_get_prev_sibling and re-inserts with gtk_box_insert_child_after, where glimmer used gtk_box_remove + gtk_box_append. append always lands at the end of the box, silently relocating any non-final child.signal-value is keyed by [tag gtk-signal-name], not by bare signal name: :spin-button and :scale emit the identical "value-changed" signal but need different getters.insert-child-after!/reorder-child! are no longer :box-only no-ops. :list-box needed both genuinely implemented; :center-box gets insert-child-after!, but its reorder-child! stays a structural no-op (three fixed slots have no ordering to change).list-box-remove-child!/list-box-replace-child!/ list-box-reorder-child! suppress on the list-box widget around gtk_list_box_remove; removing the currently-selected row fires a real, synchronous "row-selected(NULL)" signal that would otherwise reach app dispatch. Round 7 adds password-entry-spec/:password-entry + search-entry-spec/:search-entry (both reuse :entry's GtkEditable-delegate "changed" signal name, but each still needed its own signal-value entry under [tag "changed"]; reusing :entry's registration doesn't work once signal-value is keyed by tag; :search-entry also gets its own new :on-search-changed; see gtk-widget-layer.md), set-expander-expanded!/expander-spec/:expander (single-child container, no dedicated signal; :on-expanded watches "notify::expanded" instead), and set-paned-position!/paned-spec/ :paned + paned-append-child!/paned-slot-setter/ paned-remove-child!/paned-replace-child!/paned-insert-after! (a second new named-slot container strategy; 2 slots this time; :on-position-changed watches "notify::position"; both :expander's and :paned's notify::* signals reuse :list-box's generalized 3-arg-void set-event-handler shape for free, and :paned inherits :center-box's structural v1 gap with a verified-DIFFERENT failure shape; see gtk-widget-layer.md).
Round 8 adds aspect-frame-spec/:aspect-frame (single-child container, same strategy as :frame/:scrolled/:revealer/ :expander; quick win, see gtk-widget-layer.md), calendar-date=/set-calendar-date!/calendar-spec/:calendar + the new :on-day-selected signal (this project's first GDateTime- refcounted value type; same anchor as :aspect-frame above), overlay-spec/:overlay + overlay-append-child!/ overlay-remove-child!/overlay-replace-child!/overlay-insert-after! (a THIRD new container strategy; one queryable main slot plus an unbounded, unenumerable overlay set; see gtk-widget-layer.md), and flow-box-spec/:flow-box + flow-box-child-of/ flow-box-index-after/flow-box-insert-after!/flow-box-replace-child!/ flow-box-reorder-child! + the new :on-child-activated signal (a :list-box sibling verified to NOT share gtk_list_box_remove's gotcha; same anchor as :overlay above).
Round 9 adds editable-label-spec/:editable-label + set-editable-label-editing! (a THIRD GtkEditable-delegate reuse, after :password-entry/:search-entry; repeated the exact signal-value-miss near-miss from round 7 despite a comment saying it would be avoided, caught only by live probe testing; investigating it surfaced a general GtkEditable finding, not specific to this widget; see gtk-widget-layer.md), picture-spec/:picture (display-only, no signal; same anchor as :editable-label above), notebook-spec/:notebook + notebook-append-child!/notebook-remove-child!/ notebook-replace-child!/notebook-index-after/ notebook-insert-after!/notebook-reorder-child! + set-notebook-current-page! + the new :on-switch-page signal (a SIXTH generalized set-event-handler callable shape, the first that reads its own raw signal argument instead of a getter, and a verified real mount-time auto-dispatch; see gtk-widget-layer.md), and scale-button-spec/:scale-button + set-scale-button-value! (a THIRD widget sharing :scale's/:spin-button's "value-changed" signal name, the first case needing a TAG-aware, not just signal- name-keyed, set-event-handler dispatch; same anchor as :notebook above).
Round 10 adds inscription-spec/:inscription and search-bar-spec/:search-bar (both entirely display/props-driven, no signal, no glitter.gtk changes needed at all; see gtk-widget-layer.md), header-bar-spec/:header-bar + action-bar-spec/:action-bar + header-bar-append-child!/header-bar-remove-child!/ header-bar-replace-child!/header-bar-insert-after! + action-bar-append-child!/action-bar-remove-child!/ action-bar-replace-child!/action-bar-insert-after! (a genuinely new HYBRID container shape; one named title/center-widget slot plus an ORDERED pack-start list; that surfaced two real findings: both widgets' pack_end silently reverses hiccup order, confirmed independently for each rather than assumed to carry over, so v1 only wires pack_start; and toggling :show-title-buttons prepends GTK's own native window-controls widget into the SAME pack-start region; see gtk-widget-layer.md), and menu-button-spec/:menu-button + popover-spec/:popover + set-popover-visible! + the new :on-closed signal entry (the FIRST popup surface in this project and the first hiccup relationship that isn't a normal append-child!-managed tree child; :menu-button's ONE hiccup child, if present, is attached via gtk_menu_button_set_popover rather than any container-management case branch reused from an existing widget; both :on-activate and :on-closed turned out to be free reuses of the default 2-arg-void callable shape, needing zero glitter.gtk changes; see gtk-widget-layer.md).
Round 11 adds a ctor/apply audit that found and fixed four real, previously-shipped bugs; checkbutton-spec's never-applied :label, and scale-spec/spin-button-spec/scale-button-spec's :min/:max/:step silently clobbering each other across single-key re-renders; see gtk-widget-layer.md; window-handle-spec/:window-handle (single-child, no signal; quick win that also caught a missing-case-branch bug in this round's own new code; see gtk-widget-layer.md); set-stack-visible-child-name!/stack-spec/:stack + the new :on-visible-child-changed signal entry (a THIRD mount-time-auto- dispatch instance, plus a real :apply-timing gap fixed at the warning level only; see gtk-widget-layer.md); set-drop-down-selected!/drop-down-build-model!/drop-down-spec/ :drop-down + the new :on-selected-changed signal entry (the first "choose from options" widget, built on an incrementally-constructed GtkStringList; see gtk-widget-layer.md); and grid-attach!/stack-append-child!/grid-spec/:grid (the first container whose child placement is driven entirely by the child's own hiccup props, via the new :glitter/structural-props mechanism; see gtk-widget-layer.md). append-child!/remove-child!/replace-child!/insert-child-after! each gained new :grid/:stack/:window-handle case branches, and the first three of those four functions gained an optional trailing structural-props argument threaded from glitter.gtk (see Bucket 3 below).
A post-round-11 fix, found while building examples/glitter/crud.clj: list-box-reorder-child!/flow-box-reorder-child! were reusing a widget pointer GTK had already disposed: gtk_list_box_remove/ gtk_flow_box_remove dispose the now-unreferenced wrapping row/ child, and BOTH wrappers' own dispose handlers unparent (and, with nothing else referencing it, finalize) their own child in turn (confirmed by reading gtk_list_box_row_dispose's and gtk_flow_box_child_dispose's C bodies directly). Fixed by bracketing the remove-then-reinsert in both functions with g-object-ref-sink/g-object-unref: the first actual call site for either binding in this codebase (both were already bound, inherited from glimmer's fork, but never previously used). See gtk-widget-layer.md.
See gtk-widget-layer.md for why all of this matters. - glitter.genum: forked from glimmer.genum, unmodified. - glitter.app: adapted from the non-reactive slice of glimmer.core (post-to-gui, on-gui, run*, run). glimmer's own mount/unmount!/ reload!/live-root/make-rerender-watcher are not ported; they're glimmer-reconciler-specific and have no equivalent in glitter's state-atom model.
glitter.env: Jolt/GTK environment detection. Not a port of replicant.env, which concerns ClojureScript compiler presence/optimization (irrelevant to a Jolt/Chez host.glitter.gtk) the IRender/IMemory GTK4 backend and mount!'s state-atom wiring. This is the file that makes glitter glitter rather than a Replicant-with-the-serial-numbers-filed-off; see architecture.md and gtk-widget-layer.md. Round 11 adds structural-child-props/structural-child-prop? and special-cases them in set-attribute/remove-attribute. A namespaced-prop finding (:grid/column-style keys are silently dropped by glitter.core's own set-attr/update-attr guard, upstream of IRender entirely) forced these onto plain, hyphenated keys instead, stashing matches on the CHILD's own el atom under :glitter/structural-props rather than routing them through glitter.widget/apply-props!. append-child, insert-before's fresh-insert branch, and replace-child all thread that stashed map through to glitter.widget's container-management functions as a new optional trailing argument; see gtk-widget-layer.md.glitter.test-renderer: an in-memory fake IRender/IMemory, inspired by Replicant's mutation_log.cljc but separately implemented (a different protocol-composition mechanism; reify, not :extend-via-metadata; was required; see below).reify, never :extend-via-metadataReplicant's own test helper (replicant.mutation-log) composes IRender and a logging concern via :extend-via-metadata true plus with-meta. This was verified broken under Jolt during this project's design phase: requiring replicant.mutation-log and calling its renderer throws No method create-element in replicant.protocols/IRender. Both glitter.gtk/renderer and glitter.test-renderer/renderer instead implement IRender and IMemory directly in a single reify form (reify genuinely dispatches under Jolt where :extend-via-metadata doesn't), and use with-meta only for auxiliary, non-protocol data (the event log and memory atoms in test-renderer).
The following files under src/glitter/ are ported from nexus, commit 5f6c93672f25d2a5b2a91ac3b65a921ecf8826b2, by Christian Johansen, Magnar Sveen, and Teodor Heggelund. MIT License: same terms as the Replicant bucket above (see NOTICE for the full text).
src/glitter/nexus.clj: src/nexus/core.cljc. One deliberate deviation: the three #?(:clj Exception :cljs :default) reader-conditionals collapse to a plain Exception catch (glitter targets Jolt only, no cljs).src/glitter/nexus/registry.clj: src/nexus/registry.cljc. Byte-for-byte, zero deviations.src/glitter/nexus/action_log.clj: a CONCEPT port, not a literal file: nexus's log-accumulation logic now lives inside nexus.inspector.cljc, entangled with dataspex.* rendering-protocol implementations with no glitter/GTK equivalent. This file ports the accumulation mechanism (the same nested :entries/:chronology tree) and drops every dp/*/dataspex call site. Three adaptations: now uses tick.core/now instead of java.util.Date. (jolt.time is already a project dependency); find-event reads :glitter/dom-event directly instead of hunting through dispatch-data's values for a DOM Event instance, since glitter's dispatch-data always IS the event map; measure-elapsed returns a plain {:ms .. :slow? ..} map instead of upstream's rounded Timing record (inspector.cljc's round-tenth): a correct adaptation, not an oversight, since Timing is a dataspex render type with no glitter equivalent, simply never written down until now.Unlike Replicant/glimmer, nexus is a genuinely separate library (not glitter's own reconciler or its widget-layer fork): glitter depends on it conceptually the way an application depends on a dispatch library, which is why this is its own bucket rather than folded into Bucket 1 or 3. See nexus.md for the architecture this enables.
NOTICE currentAny new ported/forked file, or any new deviation in an already-ported file, gets a line added to the relevant bucket in NOTICE in the same commit as the code change, not as a follow-up. That file is what a downstream consumer or license auditor actually reads; this guide page is context for contributors, not a substitute.
glitter has two layers of verification: a headless unit suite against a fake renderer, and twenty-six automated smokes that drive a real GTK4 window and assert on its actual live state. Both matter; several of this project's real bugs (keyed reorder, replace-child!'s position, cross- thread render) were each "obviously correct" against the fake renderer's bookkeeping and only wrong when actually run against live GTK.
jolt test / bb testtest/glitter/test_runner.clj is the entry point (deps.edn's :test alias points -m at it). It calls (System/exit code) directly rather than relying on any resolve-guarded exit path; see limitations.md's note on why that guard doesn't actually work under Jolt.
The suite exercises glitter.core's reconciler against glitter.test-renderer; a fake, in-memory IRender/IMemory (no live GTK display required):
(defn renderer []
(let [log (atom []) memory (atom {})
impl (reify
proto/IRender
(create-element [_ tag-name _options]
(swap! log conj [:create-element tag-name])
(atom {:tag-name tag-name :children []}))
...
proto/IMemory
(remember [_ node data] (swap! memory assoc node data) nil)
(recall [_ node] (get @memory node)))]
(with-meta impl {:log log :memory memory})))
Every element is a plain atom holding {:tag-name <string> :children [...]}; every protocol call appends a pre-formatted tuple to log (e.g. [:create-element "button"], [:append-child "button" :to "box"]). glitter.test-renderer/events returns that accumulated log; reset-events! clears it in place, useful when a test wants to inspect only a second core/reconcile call's mutations in isolation from the mount that preceded it. Tests assert both on the event log (mutation sequence) and on the resulting tree shape directly (:children order), so a test can't pass on a log that happens to look right while the actual tree ends up wrong.
glitter.test-renderer ships in src/, not test/, specifically so applications built on glitter can reuse it for their own headless tests, not just glitter's internal suite.
Run it: jolt test, or jolt -M:test in CI (see the exit-code note below), or bb test.
Twenty-six examples under examples/glitter/ each open a real GTK window, exercise one specific behavior, read back actual GTK state (not glitter's own Clojure-side tracking), and call (System/exit 1) directly on mismatch:
| task | pins | how it verifies |
|---|---|---|
jolt smoke | mount a tree and run the loop without an exception escaping | the loop simply completing is the assertion |
jolt keyed | keyed reorder lands in the right GTK order | walks the live box's children via gtk_widget_get_first_child/get_next_sibling, reads each :label's text back via gtk_label_get_text |
jolt replace-child | a replaced child stays at its position, not the end | same live-walk approach, asserting position survived the swap |
jolt aliased | aliases expand through the real renderer, on mount and update | mounts hiccup using a registered alias, confirms the expanded (not aliased) tag actually reached GTK |
jolt main-thread-smoke | an off-main-thread swap! renders ON the GTK main thread | mutates from inside a future, records which thread view ran on, asserts it's the GTK main thread; see app-loop-and-threading.md |
jolt scale-smoke | :scale's value-changed signal delivers the right double, and a programmatic state sync doesn't cause a spurious second dispatch | a real FFI gtk_range_set_value call simulates a live drag (bypassing set-scale-value!), asserting the dispatched double and the dispatch count both before and after a subsequent programmatic reset!; see gtk-widget-layer.md |
jolt class-smoke | :class reaches real GTK CSS classes: add, coexist, and remove on a re-render diff | reads class membership back via gtk_widget_has_css_class, asserting a built-in class and a custom class both apply on mount, then that dropping one class while adding another in the same re-render calls both add-class and remove-class correctly; see gtk-widget-layer.md |
jolt leaf-widgets-smoke | :spinner/:progress-bar/:image construction + re-render land on real GTK state | reads each widget's actual state back (gtk_spinner_get_spinning/gtk_progress_bar_get_fraction/gtk_image_get_icon_name) on mount and after a re-render with different values; see gtk-widget-layer.md |
jolt toggle-level-smoke | :toggle-button reuses :checkbutton's "toggled" signal correctly for a second GTK4 class; :level-bar construction + re-render | a real FFI gtk_toggle_button_set_active call simulates a click (bypassing set-toggle-button-active!), asserting the dispatched state and dispatch count both before and after a subsequent programmatic reset!; see gtk-widget-layer.md |
jolt link-button-smoke | :link-button reuses :button's "clicked" signal correctly for a class that only extends GtkButton; a real click reaches dispatch | a deferred gtk_widget_activate call (via future/Thread/sleep/app/on-gui, timed past both window-realization and the button's own 250ms press-animation delay) simulates a real Enter/Space activation, asserting the click actually dispatched; see gtk-widget-layer.md |
jolt switch-smoke | :switch's "state-set" signal (3-arg, non-void return: a generalized foreign-callable shape) delivers the correct boolean, and a programmatic state sync doesn't cause a spurious second dispatch | a real FFI gtk_switch_set_active call simulates a live toggle (bypassing set-switch-active!), asserting the dispatched boolean and dispatch count both before and after a subsequent programmatic reset!; see gtk-widget-layer.md |
jolt revealer-center-box-smoke | :revealer's :reveal-child/:transition-type/:transition-duration land on real GTK state; :center-box's 3 named slots survive an append, a props-only update, and a removal | reads gtk_revealer_get_reveal_child/get_transition_type/get_transition_duration and each gtk_center_box_get_*_widget back on mount and after two re-renders (a safe props-only text update, then dropping a slot's child entirely); see gtk-widget-layer.md |
jolt spin-button-list-box-smoke | :spin-button's "value-changed" reads back through the RIGHT getter despite sharing :scale's signal name; :list-box's "row-selected" (a third callable shape) delivers the selected row's index, and a tag-swapped/removed row doesn't corrupt its siblings or spuriously dispatch | a real FFI gtk_spin_button_set_value call and a real gtk_list_box_select_row call simulate live interactions; a subsequent re-render swaps the selected row's TAG then removes it entirely, asserting dispatch counts and sibling row content stay correct throughout; see gtk-widget-layer.md |
jolt password-search-entry-smoke | :password-entry/:search-entry reuse :entry's GtkEditable-delegate "changed" signal NAME but each needed its own signal-value entry; :search-entry's own "search-changed" fires synchronously on a text clear | a real FFI gtk_editable_set_text call types into the password entry and clears the search entry (the documented-in-source path that skips "search-changed"'s normal debounce), asserting both dispatched values and that a subsequent programmatic sync causes no spurious dispatch; see gtk-widget-layer.md |
jolt expander-paned-smoke | :expander's "notify::expanded" and :paned's "notify::position" (both free reuses of :list-box's generalized 3-arg-void callable shape) deliver correctly, and :paned's 2 named slots survive a safe last-slot tag swap | real FFI gtk_expander_set_expanded/gtk_paned_set_position calls simulate live interactions, asserting dispatched values, dispatch counts before/after a subsequent programmatic reset!, and both paned slots' content after the swap; see gtk-widget-layer.md |
jolt aspect-frame-calendar-smoke | :aspect-frame's single-child container reuse lands on real GTK state; :calendar's refcounted GDateTime round-trips through a real interaction and a programmatic sync with no leaks or spurious dispatch | reads gtk_aspect_frame_get_xalign/get_yalign/get_ratio/get_child back on mount; a real FFI gtk_calendar_select_day call simulates a live pick, asserting the dispatched [year month day], dispatch count before/after a subsequent programmatic reset!; see gtk-widget-layer.md |
jolt overlay-flow-box-smoke | :overlay's 1-main+N-overlay shape survives a real removal (verified via a generic widget-tree walk, since GTK exposes no overlay-enumeration API); :flow-box's tag-swapped middle child lands correctly without corrupting either sibling | a re-render drops the overlay child entirely, asserting the actual GTK child count drops by exactly one while main stays untouched; a separate re-render swaps :flow-box's middle child's TAG, asserting both neighbors' content stays correct; see gtk-widget-layer.md |
jolt picture-editable-label-smoke | :picture's display-only GdkPaintable props land on real GTK state; :editable-label (a third GtkEditable-delegate reuse) dispatches its typed value and a programmatic :editing toggle causes no spurious dispatch | reads gtk_picture_get_alternative_text/get_can_shrink/get_content_fit back on mount and after a re-render; a real FFI gtk_editable_set_text call (starting from empty text, sidestepping the general non-empty-bulk-replace double-"changed" finding) simulates typing, asserting the dispatched value and dispatch count before/after a subsequent programmatic edit-mode toggle; see gtk-widget-layer.md |
jolt notebook-scale-button-smoke | :notebook's "switch-page" (a sixth callable shape, reading page-num from its own raw signal argument rather than a stale getter) delivers correctly, including the real mount-time auto-select-page-0 dispatch; :scale-button's "value-changed" (sharing :scale's/:spin-button's signal NAME but needing tag-aware dispatch) delivers the right double | real FFI gtk_notebook_set_current_page/gtk_scale_button_set_value calls simulate live interactions, asserting dispatch counts starting from 1 (not 0) to account for the notebook's own mount-time auto-select, then before/after a subsequent programmatic sync of both widgets; see gtk-widget-layer.md |
jolt inscription-search-bar-smoke | :inscription's display-only text/overflow props land on real GTK state; :search-bar's single-child container reuse and :search-mode/:show-close-button props re-apply correctly on a re-render | reads gtk_inscription_get_text/get_text_overflow and gtk_search_bar_get_search_mode/get_show_close_button back on mount and after a re-render; see gtk-widget-layer.md |
jolt header-bar-action-bar-smoke | :header-bar/:action-bar's hybrid title/center-widget-plus-pack-start shape lands children in the right roles and right order; the real :show-title-buttons native-window-controls-prepend finding is asserted explicitly, not avoided | walks each widget's real internal tree (identified by the "start" CSS class GTK itself adds) to read back pack-start button order on mount, then toggles :show-title-buttons on a re-render and confirms the pack-start count grows by one while glitter's own buttons keep their relative order; see gtk-widget-layer.md |
jolt menu-button-popover-smoke | :menu-button's popover-as-child relationship (gtk_menu_button_set_popover, not a normal tree child) delivers correctly; :popover's :visible suppressing-guarded setter and free-reuse :on-activate/:on-closed signals round-trip through a real click, a real close, and a programmatic sync in both directions with no spurious dispatch | a real gtk_widget_activate click and a real gtk_popover_popdown call simulate live interactions, asserting dispatch counts before/after each and before/after a subsequent programmatic open-then-close of the popover; see gtk-widget-layer.md |
jolt ctor-apply-regression-smoke | four real, previously-shipped bugs stay fixed: :checkbutton's :label, and :scale/:scale-button/:spin-button's :min/:max/:step no longer clobber each other across separate single-key re-renders | reads each widget's live adjustment (gtk_range_get_adjustment/gtk_scale_button_get_adjustment) back on mount, then changes :min-only then :max-only (and :step-only for scale-button) on SEPARATE re-renders; the exact one-key-at-a-time shape that caused the original clobbering; asserting the untouched key survives each change; see gtk-widget-layer.md |
jolt window-handle-stack-smoke | :window-handle's single-child wrap lands correctly (catching this round's own missing-case-branch bug); :stack's mount-time auto-select-first-page dispatch, real page switch, and suppressing-guard sync-back all work | reads gtk_window_handle_get_child/gtk_stack_get_visible_child_name back on mount (dispatch count starts at 1, the stack's own mount-time auto-select); a real FFI gtk_stack_set_visible_child_name call simulates a live switch, asserting the dispatched name and dispatch count before/after a subsequent programmatic reset!; see gtk-widget-layer.md |
jolt drop-down-grid-smoke | :drop-down's GtkStringList-backed selection round-trips through a real interaction and a suppressing-guarded sync-back; :grid's :glitter/structural-props-driven cell placement (including a column-span cell) lands at the right coordinates | reads gtk_drop_down_get_selected and gtk_grid_get_child_at for each cell back on mount (the span cell checked for pointer-equality at BOTH coordinates it covers); a real FFI gtk_drop_down_set_selected call simulates a live pick, asserting the dispatched index and dispatch count before/after a subsequent programmatic reset!; see gtk-widget-layer.md |
jolt list-box-reorder-smoke | list-box-reorder-child!/flow-box-reorder-child!'s g_object_ref_sink/g_object_unref fix stays fixed: a keyed :list-box and a keyed :flow-box both survive a genuine reorder (same keys, new order, no add/remove) without the use-after-dispose crash found while building examples/glitter/crud.clj | reads each container's live children back via gtk_widget_get_first_child/get_next_sibling (the same ground-truth technique keyed.clj uses for :box) before and after a reset! that reorders both containers' keys, asserting the new order landed correctly; FAIL-path verified by temporarily reverting the fix (crashes, exit 1) and restoring it (clean exit 0); see gtk-widget-layer.md |
jolt counter, jolt todo, jolt crud, jolt flights, jolt temperature, and jolt timer are the interactive examples; the full quick-start demo from docs/guide/index.md, a larger task-board demo (ported from glimmer's own todo.clj) exercising derived counts, a value-bearing :change handler, and checkbutton toggles, a port of the 7GUIs CRUD task exercising :list-box single-selection (with an auto-populate design choice beyond the strict spec text), keyed reordering driven by a derived sort order, and :sensitive-gated buttons, a port of the 7GUIs Flight Booker task exercising constraints between and within widgets via glitter.nexus (see nexus.md), a port of the 7GUIs Temperature Converter task exercising two linked :entry fields via a single glitter.nexus action-expansion, and a port of the 7GUIs Timer task exercising a background :effect/schedule tick and a live System/nanoTime read at render time (not through :nexus/system->state; see nexus.md): meant to be run and clicked, not asserted on. jolt todo and jolt crud are also glitter.nexus consumers as of this arc (retrofitted from a hand-written execute-actions case form); jolt counter still dispatches the old way, and jolt flights is the first demo written against glitter.nexus from the start, followed by jolt temperature and jolt timer.
Each smoke's :auto-quit-ms option (see glitter.app/run) quits the GTK loop after a fixed delay so the process exits deterministically instead of hanging, no manual window-close needed to run these in CI.
jolt <task> vs jolt -M:<alias>Verified against jolt v0.6.3: a deps.edn :tasks entry does not propagate its child process's exit status. jolt test (the task form) prints failures to stdout and still exits 0; jolt -M:test (the alias form) correctly exits non-zero. This isn't a glitter-specific quirk to work around; it's how the :tasks wrapper behaves, and it applies to every task in deps.edn, not just test.
Always use -M:<alias> (or a bb.edn task, which already does this) to gate a build. The task shorthand (jolt test, jolt keyed, ...) is fine for interactive use where a human is watching stdout.
bbbb.edn wraps every jolt -M:<alias> invocation as a babashka task, with a grouped bb info cheat-sheet as the discoverability entry point:
bb info # grouped task list โ start here
bb test # jolt -M:test
bb counter # interactive demo
bb todo # interactive task-board demo
bb crud # interactive 7GUIs CRUD demo
bb flights # interactive 7GUIs Flight Booker demo
bb temperature # interactive 7GUIs Temperature Converter demo
bb timer # interactive 7GUIs Timer demo
bb smoke | keyed | replace-child | aliased | main-thread-smoke
bb scale-smoke | class-smoke | leaf-widgets-smoke | toggle-level-smoke
bb link-button-smoke | switch-smoke
bb revealer-center-box-smoke | spin-button-list-box-smoke
bb password-search-entry-smoke | expander-paned-smoke
bb aspect-frame-calendar-smoke | overlay-flow-box-smoke
bb picture-editable-label-smoke | notebook-scale-button-smoke
bb inscription-search-bar-smoke | header-bar-action-bar-smoke | menu-button-popover-smoke
bb ctor-apply-regression-smoke | window-handle-stack-smoke | drop-down-grid-smoke
bb list-box-reorder-smoke
# individual live-GTK smokes
bb smokes # all twenty-six smokes in sequence; stops at first failure
Every bb.edn task shells to jolt -M:<alias> directly (never the jolt <task> shorthand), so bb test and bb smokes are safe to use as a CI gate on their own. bb smokes chains all twenty-six smokes with a plain sequence of shell calls; babashka's task runner aborts on the first non-zero exit, so it naturally stops at the first failure without any extra control flow.
bb lint / lint:strict / lint:errors clj-kondo (report | propagate real exit | errors-only)
bb lsp:format / lsp:format-check clojure-lsp reformat, or dry-run check
bb lsp:clean-ns / lsp:clean-ns-check clojure-lsp ns cleanup, or dry-run check
bb lsp:diagnostics / lsp:check / lsp:fix diagnostics | all dry-run checks | auto-fix
bb check:positional-args / :strict fns with 3+ positional args (report | gate)
bb verify pre-commit gate: lint (report) + test (must pass)
bb hooks:install / :install:full / :uninstall git pre-commit hook (fast | +tests | remove)
Adapted from sibling Jolt/FFI projects by the same author (b12n-adk-clj, a private repo, for the positional-args script; b12n-raylib-jlt for the clj-kondo hook; see NOTICE), not written from scratch, because both needed the same fix for the same underlying problem: jolt.ffi/defcfn is a macro clj-kondo cannot see through.
(ffi/defcfn gtk-box-new "gtk_box_new" [:int :int] :pointer)
Without a hook, clj-kondo has no idea gtk-box-new is a defined var: every one of glitter.ffi's ~90 bindings reports as Unresolved symbol, and every call site through the g/ alias (glitter.widget, glitter.gtk, glitter.app, glitter.genum) reports as Unresolved var. Scoped to just the forked+new source files plus test/examples, that's 75 errors + 83 warnings; enough noise to make the linter worthless as a signal. .clj-kondo/hooks/jolt_ffi.clj fixes this by rewriting each defcfn call into an equivalent defn of the same name, same arity (derived from the declared C argument-type vector), and an inferred return type (derived from the declared C return type); clj-kondo then sees a real var with the right shape and stops flagging it.
One adaptation beyond the b12n-raylib-jlt original: :pointer return values map to a number here, not nil. rljlt's raylib pointers are opaque handles only ever passed to other untyped ffi/* calls, so mapping them to nil cost nothing there. glitter.ffi's own ns docstring states pointers are "plain machine addresses (jolt numbers)", and the codebase relies on this directly: glitter.genum/glitter.widget call zero? on :pointer-typed return values (e.g. checking whether a GEnum lookup or a gtk_widget_get_prev_sibling call returned a null pointer). Mapping :pointer to nil there produced two spurious type-mismatch findings ("Expected: number, received: nil") against code that was already correct.
glitter.alias/defalias needed a second, smaller fix: :lint-as {glitter.alias/defalias clojure.core/defn} in .clj-kondo/config.edn. defalias's shape (name [argvec] body...) is close enough to defn's that telling clj-kondo to analyze it as a defn call resolves both the defined name and the destructured params, with no custom hook needed.
Why bb lint can safely target src test examples directly, no manual file list. The files ported verbatim from Replicant (glitter.core, glitter.alias, etc.; see porting-and-attribution.md) deliberately keep #?(:clj :cljs) reader conditionals in a .clj extension, a mechanical sed rename from Replicant's original .cljc. Standard Clojure tooling (clj-kondo included) restricts reader conditionals to .cljc files, so every one of those forms is a permanent error: [syntax] Reader conditionals are only allowed in .cljc files finding. This is real syntax Jolt itself parses and runs correctly (jolt -M:test proves that far more rigorously than static analysis could); it is simply not the syntax clj-kondo expects from a .clj extension. "syntax"-class findings aren't gated by :linters levels the way ordinary lint warnings are, so the fix is .clj-kondo/config.edn's :output {:exclude-files [...]} (10 regex patterns, one per ported file), the same approach commonly used to exclude source clj-kondo can't parse at all. Scoping at the config level rather than in every task's command line means clj-kondo --lint src test examples (or even an editor's clojure-lsp pass, since clojure-lsp diagnostics runs clj-kondo under the hood and respects the same config) is safe to run unscoped anywhere; a new non-ported file is linted automatically, with nothing to remember to add to a task's argument list.
Why bb lint/bb verify never fail by default, but bb lint:errors does. Even with the ported files excluded, clj-kondo currently reports 2 warnings that are legitimate style opinions, not false positives: a missing-else-branch on a deliberate throw-only guard in glitter.widget/markup-validate-element!, and one genuinely unused private helper in test/glitter/core_test.clj. Neither is worth a config exclusion (that risks hiding a real future instance of either), but neither should permanently block a gate either. clj-kondo's exit code is a severity ladder (0 clean, 2 warnings only, 3 errors present (verified live: an intentionally-broken probe file reproduced errors: 2, warnings: 0 โ exit 3)), so bb lint/bb verify report and always exit 0, bb lint:strict propagates the raw code, and bb lint:errors (used by the git hooks below) fails only when the exit code is exactly 3, treating the 2 known warnings the same as a clean run.
bb hooks:install / :install:full / :uninstallbb hooks:install writes an executable .git/hooks/pre-commit (via spit, not tracked in the repo: each clone opts in with its own bb hooks:install run, adapted from b12n-adk-clj's identical pattern). The FAST hook runs in ~2s, three steps: clj-kondo --lint src test examples gated on bb lint:errors' exit-3-only rule, then clojure-lsp format --dry, then clojure-lsp clean-ns --dry. bb hooks:install:full adds a fourth step, the full jolt -M:test suite (safe to run in a hook: the suite is headless, driven by glitter.test-renderer, no live GTK window needed). bb hooks:uninstall deletes the hook file (idempotent: reports "no pre-commit hook found" on a second run rather than erroring). git commit --no-verify skips the hook for one commit.
Formatting: reversed from "leave it" to "format everything," and why. The codebase originally had real drift against clojure-lsp's default formatting style in a handful of files, found while first wiring these tasks up: clojure-lsp's formatter wraps {:keys [x] :as y}-shaped destructuring across two lines even when the whole form comfortably fits on one:
;; clojure-lsp's default output
(defn reconcile* [{:keys [renderer]
:as impl} el headers vdom index]
...)
, which is not merely a style preference glitter happens to disagree with. Checked directly against replicant.core.cljc in the actual upstream Replicant source: upstream itself writes this exact function signature on one line. Since glitter.core and the other Bucket-1 files are supposed to stay a mechanical, diffable port of Replicant (see porting-and-attribution.md), reformatting them to clojure-lsp's default would simultaneously read worse than the hand-tuned original and reduce future diffability against upstream. No config override was found that suppresses just this rule (:cljfmt {:function-arguments-indentation ...} was tried with both documented values, :standard/:community: neither preserves the inline form). This was originally left as a known, open decision: bb lsp:format/bb lsp:format-check available as on-demand tools, nothing run automatically, neither wired into a git hook.
That decision was revisited and reversed: bb lsp:format has now been run across the entire codebase (bb lsp:format-check is clean), trading away upstream Replicant diffability on the ~4 Bucket-1 files in favor of one uniform style everywhere, including files that had no upstream to diff against in the first place (gtk.clj, test_renderer.clj, and Bucket-2 files like widget.clj/app.clj: the diffability argument never applied to those, so leaving them unformatted alongside the ported files was a broader style inconsistency than the original rationale justified). format --dry is now a step in both pre-commit hooks (see the mechanics above); the natural, cheap follow-up mentioned as an option is done: every commit now gates on staying bb lsp:format-check-clean, so the codebase can't silently drift back out of format again the way it originally did.
Why check:positional-args's exceptions set is empty despite 32 current findings. Running it against glitter's own src/glitter/ finds 32 functions with 3+ positional args, almost entirely in two legitimate categories: glitter.core internals mirroring Replicant's exact upstream signatures (changing them would break porting parity), and glitter.widget's container-management fns (append-child!, reorder-child!, replace-child!, ...) mirroring GTK's own C API argument order. Pre-populating exceptions with all 32 names would defeat the check's purpose; instead it stays non-strict by default (matches b12n-adk-clj's own convention) and a human judges each new finding as it appears, rather than a static list silently absorbing whatever's already there.
A real bug the b12n-adk-clj original script has, caught while adapting it: its file-pattern was "**/*.clj", verified live that babashka.fs/glob's ** requires at least one directory level (so it silently matches files in subdirectories only. glitter's src/glitter/ is flat (17 files, no subdirectories), so the original pattern would have found zero of them. b12n-adk-clj's own src/net/b12n/adk/ is a mix of 18 flat files and 2 nested ones) meaning its own check:positional-args task has likely only ever checked those 2 nested files. glitter's copy uses "{*,**/*}.clj" instead, which matches both flat and nested files (verified: 17 files found in glitter, vs. 0 with the original pattern).