glitter-gl is glimmer-gl's geometry/matrix/shader/GL library, ported onto glitter instead of glimmer. Most of it (vectors, matrices, meshes, the shader DSL, raw GL bindings, the renderer) has no dependency on either UI library at all and ports across unchanged. The part that does (a :gl-area widget so a GL pane can live inside a glitter hiccup tree) needed real adaptation, and hit a real, live-found correction along the way. This guide covers that adaptation.
A .clj (Jolt/Chez Scheme host, not JVM) library, split into two halves:
glitter-gl.gtk/.scene/.app, the glitter-specific layer: a :gl-area widget, a declarative scene graph, and a reactive mount point built around glitter's single state atom.(require '[glitter.app :as app]
'[glitter.core :as core]
'[glitter.gtk :as gtk]
'[glitter-gl.gtk]) ; registers :gl-area
(defn view [_state]
[:gl-area {:version [3 2] :hexpand true :vexpand true
:on-realize (fn [area] ...)
:on-render (fn [area] ...)
:on-resize (fn [area w h] ...)}])
(app/run (fn [window] (gtk/mount! window view (atom {}))))
Every :gl-area prop key with an :on-* handler wires into a raw GTK4 signal (or, for :on-tick, a frame-clock callback) the first time it's seen; :on-render's closure is free to read whatever state it needs on each call, exactly like any other glitter view function.
examples.md: the ten namespaces under examples/glitter_gl/, split by what each is for: check.clj and gl_area_smoke.clj exist to fail when a regression lands (the only two wired into bb smokes), plasma_shader.clj exists to be composed and read (the shader-composition worked example), and plasma.clj, ripple.clj, orbit.clj, knot.clj, gears.clj, textured.clj and picking.clj exist to be watched: rotating-shape, shader, texture and pointer-input demos with no assertions of their own. Also covers the six touchpoints (namespace, deps.edn alias, bb.edn task, bb.edn demos:examples row, scripts/demo_manifest.edn group entry, smokes entry) a new example needs so it doesn't go silently unexercised.architecture.md: how thin the seam to glitter actually is: of 25 files under src/glitter_gl/, only gtk.clj has a literal :require on glitter.*, and scene.clj has none at all. Traces, against the real source, why a :gl-area keeps redrawing (queue-render has exactly one call site in the whole project: gtk.clj's gl-area-apply! tick wrapper, which reactive-area always causes to be installed, and a bare state swap! alone never triggers a repaint), plus why :gl-area bypasses glitter's uniform signal table entirely and where GL-plumbing state deliberately breaks glitter's one-atom/action-dispatch discipline.porting-and-attribution.md: the three sourcing buckets NOTICE.md tracks (22 verbatim glimmer-gl files, 3 adapted core files plus demo material, and what's genuinely new), and the two-hop lineage back through glimmer-gl to thi.ng/geom. Explains the Standard Verbatim Port Procedure's sed-diff check that makes "verbatim port" a checkable claim rather than an assertion, the formatting-pass exemption that lets project-wide clojure-lsp format/clean-ns touch those 22 files without violating it, and the one documented live-found correction (:gl-area's :apply-vs- :connect fix) told from the provenance angle.geometry-and-shaders.md: orientation, not reference, over the 22 verbatim-ported namespaces: the three groups (14 pure geometry/math, 4 mesh, 4 GL plumbing), and the two design decisions (column-major matrices, shaders as mergeable data) a new reader would otherwise have to reconstruct by hand. Traces where a mesh becomes GL data and finds a real surprise along the way: glmesh.clj's documented mesh β GL pipeline has no caller anywhere but its own test; the shipped renderer hand-rolls the actual upload via mesh/->floats and eleven raw gl.clj calls instead.gl-area-widget-layer.md: the :gl-area widget's mechanics in full: the :apply-vs-:connect correction, traced through create-node/set-attributes to the exact reason a :connect closure never sees a real prop map under glitter's reconciler. Catalogs every GTK4 signal shape that doesn't fit glitter's uniform void(widget,data) path: "render"'s non-void return, "resize"'s extra int arguments, on-tick's frame-clock API that isn't a signal at all, and the controllers on-motion/on-key/ on-button layer onto the widget or its root window. Also documents a live-found Retina-only trap: "resize" reports device pixels while on-motion reports logical points, and unprojecting a pointer position with the wrong one is silently wrong by the display's scale factor.scene-and-app.md: glitter-gl.scene's mini-hiccup dialect, and why it's not glitter's hiccup: [fn args...] is a first-class component invocation here, a real trap in both directions the page states plainly with a live example (plasma.clj's once-broken shape-button calls). Explains why plan is a plain function with no reactive-cell tracking, the write-once handler contract gtk.clj's wired atom enforces, and gives reactive-area an honest status note: unit-tested, and now exercised live end to end by examples/glitter_gl/orbit.clj, plus the per-tick full-view-recompute cost that demo pays.testing-and-tasks.md: the unit suite (jolt -M:test, 178 tests / 559 assertions) and the live-GTK smoke plus headless check bb smokes runs, plus offscreen_test.clj's real render-to-texture round trip and its designed-to-skip behavior on a display-less machine. Documents the jolt -M:<alias> vs jolt <task> exit-code trap (reproduced across v0.6.3 and v0.7.23, and confirmed fixed on jolt main at v0.7.27-22, though no tagged release carries the fix yet) and the quality-tooling surface (bb lint, bb lsp:*, the FFI-aware clj-kondo hook, bb verify vs. the stricter git pre-commit hook).limitations.md: every known v1 gap, each with the reason it was left rather than fixed: "render"'s 2-arg foreign-callable declaration against GTK4's real 3-argument signal (harmless, traced through the calling convention, not just asserted), the dev-time hiccup warning that has no application-level silencer, and why :scale is deliberately not registered here.CONTRIBUTING.md (repo root): how to build, test, and submit changes, plus the ten numbered invariants this project does not regress (the :gl-area correction is invariant #2 there, in summary form).NOTICE.md (repo root): the file-by-file attribution ledger and porting summary (verbatim / adapted / new buckets).docs/guide/ for the reconcile β IRender/IMemory architecture and the GTK widget layer this project's :gl-area plugs into.glitter-gl reads as one project but is really two: a geometry/matrix/ mesh/shader/GL library that knows nothing about any UI toolkit, and a thin glitter-integration layer bolted on top of it. The README and CONTRIBUTING.md both describe this as "glitter-gl.gtk/.scene/.app are the glitter-specific layer", true as a grouping, but the actual :require graph is sharper than that phrasing suggests, worth stating precisely because it's the whole reason the split is load-bearing rather than cosmetic:
src/glitter_gl/, exactly one, glitter_gl/gtk.clj, has a literal :require on a glitter.* namespace (glitter.ffi, glitter.widget).app.clj never requires glitter.* directly. It only reaches glitter transitively, through requiring glitter-gl.gtk (for gtk/widget-width/gtk/widget-height, used to normalize pointer coordinates in :on-motion).scene.clj requires glitter-gl.matrix and nothing else. It has no dependency on glitter, direct or transitive. plan, expand, and flatten all operate on a plain state value and a plain scene-fn; nothing in the code ties either to glitter's atom or its reconciler. It's grouped with "the glitter-specific layer" because its intended caller (app.clj) hands it glitter's state, not because its own code requires glitter to exist.What this buys: the 22 files from vector.clj through renderer.clj (and, in practice, scene.clj too) are usable from any Jolt program with an OpenGL context (a different windowing toolkit, a headless renderer, a completely different app architecture) without pulling in GTK4 or glitter at all. The dependency surface toward glitter is one file wide.
What it costs: that one file's job is proportionally harder. Because scene.clj/app.clj deliberately don't import glitter.core, they can't lean on glitter's reconciler to do any dependency-tracking work for them: glimmer-gl's originals could, via glimmer.ratom/reaction, and were ported here almost unchanged everywhere else. Here they had to be independently redesigned around "call this fresh every frame, plain function, no tracking" instead, a real design decision, not a mechanical rename. See "Where state lives" below and scene-and-app.md for the full reasoning.
flowchart TD
subgraph pure["Pure library: no glitter dependency, usable from any Jolt + OpenGL program"]
geom["Geometry and math (14 files)<br/>vector, vec2, matrix, quaternion<br/>aabb, rect, circle, line, plane<br/>triangle, sphere, polygon<br/>bezier, intersect"]
mesh["Mesh model (4 files)<br/>mesh, glmesh<br/>primitives, polyhedra"]
glplumb["GL plumbing (4 files)<br/>shader, gl<br/>offscreen, renderer"]
scene["scene.clj<br/>scene tree to render plan<br/>(requires glitter-gl.matrix only)"]
end
subgraph bridge["glitter integration"]
gtk["gtk.clj<br/>registers the :gl-area widget<br/>handlers wire from :apply,<br/>never :connect"]
app["app.clj<br/>reactive-area builds the<br/>:gl-area prop map bound to<br/>glitter's state atom"]
end
glitter["glitter<br/>glitter.ffi, glitter.widget"]
geom --> mesh
mesh --> glplumb
geom --> scene
glplumb --> app
scene --> app
gtk --> app
glitter -.->|"required by gtk.clj alone,<br/>1 of 25 files in src/glitter_gl"| gtk
The boxes inside "Pure library" are the geometry/matrix/mesh/shader/GL layer covered above. The two under "glitter integration" are separate concerns, and the arrows between them repay a close read, because even this grouping is coarser than the real :require graph:
gtk.clj depends on nothing else in glitter-gl. Its entire :require list is glitter.ffi, glitter.widget, and jolt.ffi. It is the :gl-area widget and nothing more, which is why the only arrow reaching it comes from glitter rather than from the layers above.scene.clj depends on exactly one namespace, glitter-gl.matrix. That is why it sits inside the pure half here despite being grouped under "the glitter-specific layer" in prose elsewhere.app.clj is the single file that ties the two halves together. It requires gl, matrix, renderer, scene, and gtk, and reaches glitter itself only transitively, through gtk.clj.The mechanism that keeps a :gl-area redrawing is not what a reader familiar with glitter's own model would expect. In glitter's normal world, a swap!/reset! on the state atom is the thing that causes a new frame: glitter.gtk/mount!'s state-atom watcher fires, view runs again, and the reconciler patches the live widget tree. It would be reasonable to assume :gl-area works the same way, with a state change propagating through to a fresh render. It mostly doesn't, and tracing why is the point of this section.
flowchart TD clock["GdkFrameClock ticks<br/>(gtk_widget_add_tick_callback)"] --> tickcb["gtk.clj's tick wrapper:<br/>(on-tick a) then (queue-render a)"] tickcb -->|"gtk_gl_area_queue_render"| schedule["GTK schedules the 'render' signal"] schedule --> onrender[":on-render fires<br/>(reactive-area's installed closure)"] onrender -->|"(deref state)"| current["current state, read fresh"] current --> plan["glitter-gl.scene/plan<br/>= (flatten (expand (scene-fn state)))"] plan --> draw["glitter-gl.renderer/draw!<br/>issues the GL calls"] swap["a click handler, :on-motion,<br/>:on-key, :on-button, an nREPL swap!"] -->|"mutates the SAME state atom"| current
Tracing this against the actual source (gtk.clj, app.clj, scene.clj, renderer.clj):
queue-render (the function that asks GTK to fire "render" again) has exactly one call site in this entire project, and it is not application code: it's baked into the tick callback gl-area-apply! installs, in gtk.clj: (fn [a _clock _data] (on-tick a) (queue-render a) 1)
No example, demo, or library function calls gtk/queue-render anywhere else (confirmed by grepping src/, examples/, and test/ for queue-render; the only hits are this definition and this one call site). So the only thing that keeps "render" firing on an ongoing basis is this tick-driven loop.
reactive-area always installs that tick callback, whether or not the caller asked for one. app.clj's default: :on-tick
(or (:on-tick opts) (fn [_area]))
is a no-op function, but still a function, and gl-area-apply!'s guard is (and on-tick (wire-once! area :on-tick)). A non-nil value is truthy regardless of what it does, so reactive-area unconditionally wires the auto-queue-render tick callback into every :gl-area it builds. Practical consequence: any :gl-area mounted through reactive-area redraws continuously at the frame clock's rate, by construction, whether or not the app supplies its own :on-tick.
state mutation, on its own, does not cause a redraw. mount!'s watcher still fires and still reconciles the whole hiccup tree on any swap!/reset!, exactly as it does for ordinary widgets, but for the :gl-area element specifically, that reconcile pass re-applies the same already-wired handler closures through :apply, and invariant #9's wire-once! guard makes every one of those re-applications a silent no-op (see gl-area-widget-layer.md). Nothing in that path calls queue-render. What makes a state change visible on screen is not causal: it's that :on-render's closure derefs state fresh every time it happens to run (driven by the frame clock, per point 2), so whatever the state was by the next tick is what gets drawn. A :gl-area wired manually without :on-tick (nothing in this project does this, but nothing prevents it) would only repaint on GTK's own default invalidation (realize, resize, window expose), never in response to a state change alone.:on-render actually runs, app.clj's installed closure does the rest: (scene/plan current scene-fn) compiles the scene hiccup to a render plan (camera, lights, world-transformed items; see scene.clj's flatten/walk), builds a perspective-camera and an optional shadow-light frustum from it, and calls renderer/draw!, which runs the two-pass shadow-mapped Blinn-Phong pipeline (depth-only pass from the light, then the lit pass sampling that depth texture) and issues the actual gl-draw-arrays calls.The shipped plasma demo (examples/glitter_gl/plasma.clj) confirms point 2 independently: it wires :gl-area directly rather than through reactive-area, and its own on-tick (a real one, advancing a clock atom) is what drives its rotation; nothing in the demo calls queue-render by hand either, because gl-area-apply!'s wrapper does it after every on-tick invocation regardless of which layer wired the handler.
:gl-area is not an ordinary glitter widgetEvery other widget glitter ships wires its :on-* props through glitter.widget's own signal machinery (signals/signal-value/ connect-signals!), a shared, data-driven table mapping hiccup prop keys to GTK signal names, with an optional value-fn for value-bearing signals. gtk.clj never touches any of that: its :require pulls in only glitter.ffi and glitter.widget (for retain-callable! and register-widget!), and every :gl-area signal is connected through a private local connect! helper calling g/g-signal-connect-data directly, with a literal foreign-callable shape hand-written per signal (confirmed by reading gtk.clj end to end: signal-value, signal-name, and connect-signals! appear nowhere in the file).
This isn't an oversight; it's forced by the shapes involved. GtkGLArea's signals don't fit glitter's uniform void(widget, user_data) assumption: "render" returns a gboolean, "resize" carries width/height as direct signal arguments, on-tick isn't a GTK signal at all (it's a separate frame-clock API), and on-key/on-motion/on-button need controllers layered onto the widget or its root window rather than a plain signal connect. A data-driven table tuned for the common void(widget,data) case can't express these without becoming a special case for every row anyway, so :gl-area's handlers are application-supplied closures, wired directly by glitter_gl.gtk, and connected from the widget spec's :apply closure rather than :connect, a design correction found live, not the original plan. The full mechanics, including the exact create-node code path that proves :connect never sees real props, and every non-standard signal shape :gl-area has to handle, are in gl-area-widget-layer.md. This page only needs the headline: :gl-area is a deliberate, load-bearing exception to how every other glitter widget wires its events, not an inconsistency to clean up.
Glitter's own UI chrome (buttons, sliders, checkbuttons, anything a user's mouse or keyboard drives) never touches the state atom directly. It's one atom, one pure view function, and every mutation flows through core/set-dispatch! as data: a click produces [[:action/toggle-paused]], and one global dispatch function is the only code in the app allowed to swap!. This buys the property that "what happened" (an event) and "what changed" (the swap) are always separated by an inspectable, replayable data structure.
The GL layer breaks that discipline on purpose, in exactly the places glitter's own model would be actively wrong for it. reactive-area's :on-tick/:on-motion/:on-key/:on-button closures swap!/reset! the shared state atom directly (no action tuples, no dispatch function in the loop), and :on-render derefs state fresh on every GTK-driven call rather than reading a value handed to it. The reason is the frame rate mismatch traced above: this is state that can legitimately change 60 times a second (a camera angle following the pointer, a clock advancing every tick), driven by GTK's frame clock rather than by discrete user actions. Routing that through swap!-then-full-hiccup-recompute-then-diff on every single frame would be paying the reconciler's cost for state a :gl-area element doesn't even re-render through in the first place (see the previous section: state changes don't drive :on-render at all; the frame clock does). Demo code follows the identical split: examples/glitter_gl/plasma.clj's on-realize/on-render/on-resize/on-tick read and write plain atoms directly, while its control-panel (buttons, sliders, a checkbutton) dispatches ordinary [[:action/foo ...]] tuples exactly like any other glitter UI. Full treatment, including why this mirrors an existing pattern in glitter's own dispatch system rather than inventing a new one from nothing: scene-and-app.md.
Everything runnable lives under examples/glitter_gl/: ten namespaces, nine of them with their own jolt -M:<alias>/bb <name> entry point, one (plasma_shader.clj) that exists only to be required by plasma.clj. They split cleanly along one axis: two exist to fail when a regression lands (check.clj, gl_area_smoke.clj), one exists to be composed and read (plasma_shader.clj), and seven exist to be watched (plasma.clj, ripple.clj, orbit.clj, knot.clj, gears.clj, textured.clj, picking.clj). Only the first two are part of the project's actual regression coverage; say so plainly rather than letting a reader assume a demo is tested because it runs.
check.clj: headless, and the one that pins the widest surfacejolt -M:check # or: bb check
Needs no GL context and no display, so it's the fastest thing in the project to run, and the only example safe to call from a machine with no windowing system at all. It asserts three unrelated things in one process:
plasma-shader/shader-spec compiles to GLSL with the declarations the composition promised: a_pos attribute, u_time uniform, the palette and plasma prelude functions. If a future shader-module edit drops a uniform or forgets to thread a prelude function through merge-specs, this is what catches it, headlessly, before anyone opens the demo and notices the picture looks wrong.mesh/->floats produces a well-formed vertex buffer (positive vertex count, buffer length exactly count * stride) for each of the three primitive shapes the demo can switch between (cube, sphere, tetra).:gl-area and :scale are both present in glitter.widget/specs after requiring glitter-gl.gtk.That third assertion is a load-order check, not an ownership check, worth stating precisely because it's easy to misread. :gl-area is registered by this library; :scale is not (invariant #6 in CONTRIBUTING.md; glitter already ships a richer, first-party :scale, and glitter-gl deliberately doesn't shadow it). check.clj's own docstring says as much: :scale "comes from glitter's own native widget, not from glitter-gl.gtk." So if :scale is missing when this runs, the bug is that glitter itself never loaded, not that glitter-gl broke something it owns.
Measured, run just now:
shader: vs 1327 chars, fs 1403 chars
geometry:
cube faces=6 tris=12 verts=36 floats=216 stride=6
sphere faces=504 tris=952 verts=2856 floats=17136 stride=6
tetra faces=4 tris=4 verts=12 floats=72 stride=6
widgets registered: true
check: ok
What it pins: the shader-composition contract and the mesh-buffer contract, both without touching GTK at all. This is the one example a CI runner could execute today with no display, once CI is wired (see testing-and-tasks.md).
gl_area_smoke.clj: live GTK, and the one that pins the correctionjolt -M:gl-area-smoke # or: bb gl-area-smoke
Needs a real display: it opens an actual GtkGLArea under glitter's real reconciler (not a fake renderer) and drives it through :on-realize/:on-render/:on-resize, then reads back a results atom the handlers populated and exits non-zero if any of :realized?, :rendered?, :resized? didn't land, or :error is non-nil. Confirmed by running it here, with a display available:
:results {:realized? true, :rendered? true, :resized? [640 480], :error nil}
What it pins: this is the one example that exercises invariant #2, :gl-area's handlers wiring through the widget spec's :apply closure rather than glitter.widget's :connect hook. That correction was found because the original :connect-based design silently never fired under glitter's real reconciler; a fake in-memory renderer (the kind test/glitter_gl/*_test.clj uses) can't reproduce that failure, because it never routes props through the real apply-props! path at all. This is why the smoke exists as a separate live-GTK example rather than a unit test; see the smoke's own docstring, and gl-area-widget-layer.md for the full mechanics.
It's CI-safe in the sense that bb.edn's smokes task runs it via jolt -M:gl-area-smoke (the exit-code-propagating alias form, not the task form; see testing-and-tasks.md), but CI isn't actually wired for this project yet, so today it's a local gate a contributor runs by hand before opening a PR.
plasma_shader.clj: composed, not runThis one has no -main, no deps.edn alias, no bb.edn task. It's the shared shader spec: four data maps (base, plasma-module, stripes-module, main-module) combined with a single call:
(def shader-spec
(sh/merge-specs base plasma-module stripes-module main-module))
This demonstrates glitter-gl.shader's composition model rather than anything glitter-gl-specific. base supplies the vertex stage and framing uniforms; plasma-module contributes a domain-warped, 4-octave plasma field plus an Inigo Quilez cosine palette (both as GLSL text in :prelude, since the shader DSL's data IR only compiles main() bodies, not free functions); stripes-module contributes an independent animated stripe pattern; main-module blends the two by u_mix and applies Blinn-Phong-style lighting to the result. Drop a module from the merge, or add a third, and the visual changes accordingly. That's the point being demonstrated.
What it pins: nothing on its own. It's exercised, not asserted, by check.clj's shader-compile checks (does the merged spec still emit a_pos, u_time, palette(, plasma() and rendered, not verified, by plasma.clj. If you're adding a new shader module to this codebase, this file plus check.clj's three assertions are the pattern to copy: one adds a data map to the merge, the other adds a str/includes? line asserting the thing that map was supposed to contribute is actually in the generated source.
plasma.clj: the one to watch, not the one to trustjolt -M:plasma # or: bb plasma
A rotating cube/sphere/tetrahedron lit by the composed plasma+stripes shader, with a glitter-native control panel: shape buttons, five sliders (speed, zoom, scale, warp, blend), a smooth-shading checkbutton, a pause/resume 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.
Be honest about what this one is not: it is not part of bb.edn's smokes task, it makes no assertions, and nothing fails when its picture regresses except a human noticing it looks wrong. Per the organising principle for this whole guide (an example that exists only to be pretty is worth less than one that fails when a regression lands), this is the "pretty" one. (It does support a GLITTER_GL_DEMO_QUIT_MS env var, mirroring the original's GLIMMER_GL_DEMO_QUIT_MS, that auto-closes the window after N ms, useful for confirming it launches without hanging, but that's a liveness check, not a correctness one, and it isn't wired into any automated task today.)
What it is worth reading for is invariant #4, in the clearest form anywhere in this codebase.
:gl-area directly instead of going through reactive-areaglitter-gl.app/reactive-area exists precisely to keep GL plumbing out of application code: build a scene as data, hand it to reactive-area, get back a ready-made :gl-area prop map. plasma.clj doesn't use it. It wires on-realize/on-render/on-resize/on-tick by hand, the same way gl_area_smoke.clj does.
The reason is provenance, not oversight: plasma.clj's own docstring says it's "ported from gl-demo.core... converting its reactive-cell control panel... into glitter's single state atom + data-driven action dispatch," while "the GL render-loop plumbing (on-realize/on-render/on-resize/on-tick) is otherwise unchanged". Direct wiring is how the upstream glimmer-gl demo already worked, and the port kept that shape rather than routing it through the newer reactive-area layer built later in this project's history.
The honest cost of that choice: until this arc, reactive-area had direct unit coverage (app_test.clj checks its returned prop map's shape and defaults) but no example that mounted it end to end against a real :gl-area and actually rendered through it. See orbit.clj below for the example that closes that gap, scene-and-app.md's "What reactive-area actually is" section for the full "honest status" note, and limitations.md for this and the project's other known gaps.
plasma.clj is the clearest illustration of invariant #4 (reactive-area's own handlers read/write state directly rather than dispatching, the same principle plasma.clj follows even though it bypasses reactive-area itself), because both halves of the split sit right next to each other in one file:
on-tick doesn't dispatch anything; it just mutates: (defn on-tick [_area]
(when-not (:paused @state)
(swap! clock + (* (double (:speed @state)) frame-dt))))
clock, viewport, and gl-state are all defonce atoms outside the reconciled view entirely, updated 60 times a second by GTK's frame clock, exactly the kind of state invariant #4 says has no business round-tripping through swap!-then-re-render.
[:scale {:min lo :max hi :step step :value value :digits 2 :hexpand true
:on {:value-changed [[:effect/assoc-in [key] [:glitter/value]]]}}]
and the checkbutton/pause controls go through registered actions (:action/toggle-smooth, :action/toggle-paused) that expand to the same :effect/assoc-in primitive, dispatched through glitter.nexus exactly the way todo.clj/crud.clj do in glitter's own demo set.
Put side by side, the contrast answers a question a new contributor will otherwise have to reconstruct from first principles: which category does my new piece of state belong to? If it changes on every frame and nothing in the UI needs to react to a click causing it, it's a plain atom. If a person clicks or drags something, it's an action.
ripple.clj: the shader DSL with no geometry to speak ofjolt -M:ripple # or: bb ripple
A single primitives/quad scaled to fill clip space directly, no MVP, no camera, no lighting, no material system, textured by a fragment shader composed through glitter-gl.shader/merge-specs from two small modules: a drifting concentric-ripple field, and a wave-to-color mapping with a soft corner vignette. on-tick advances a time uniform; on-render draws.
The claim this one makes is about the library's two independent halves. plasma.clj already shows a lit solid whose surface comes from a composable shader, so a second lit-solid-plus-shader demo would say nothing new. ripple.clj has no geometry to speak of, and demonstrates that the shader-spec DSL is useful entirely on its own, with no mesh half of the library involved at all.
Like plasma.clj, this is watched, not trusted: it makes no assertions and is not part of bb smokes. bb.edn's own comment on the smokes task explains why: what makes an example usable as a smoke is that it raises or exits non-zero when something is wrong, not that it calls System/exit. ripple.clj wraps program construction in (try ... (catch Throwable _ nil)), so a broken shader just prints and the process still exits 0; folding it into smokes would look like a gate that cannot fail.
orbit.clj: the first live reactive-area demojolt -M:orbit # or: bb orbit
Six distinct solids drawn from the library's own shapes (primitives/cuboid, primitives/sphere, primitives/tetrahedron, and polyhedra's octahedron/icosahedron/dodecahedron) orbit above a lit, shadowed ground plane, each at its own radius, phase, and speed. Unlike every other example, it's mounted through glitter-gl.app/reactive-area rather than direct :gl-area wiring, and its orbit phase lives in glitter's own shared state atom rather than a private clock atom, so that scene-fn is a genuine function of state.
That choice is the whole point of the example, not incidental: reactive-area had direct unit coverage but had never before been mounted against a real :gl-area. orbit.clj closes that gap; see limitations.md for the before-and-after, and scene-and-app.md for the real cost this example pays, by design, for driving glitter's watched state atom on every tick, which plasma.clj and ripple.clj avoid.
| preview | what it shows |
|---|---|
![]() | orbit: six differently-shaped, differently-colored solids circling a shadowed ground plane at independent speeds, most also spinning on their own axis. |
Like plasma.clj and ripple.clj, watched not trusted: no assertions, not part of bb smokes, for the same reason.
knot.clj: geometry the library does not shipjolt -M:knot # or: bb knot
A (2,3) trefoil torus knot, swept as a tube and rendered as a single rotating lit solid: 2400 quads generated from scratch by a pure function (torus-knot-faces) from three radii (major R, winding amplitude r, tube radius tube-r) and two counts (samples, sides), handed straight to mesh/mesh. Everything downstream of the generator, upload, shader, draw, is the exact single-mesh plumbing plasma.clj already established, wired directly to :gl-area rather than through reactive-area (one mesh, no scene composition, nothing for reactive-area to coordinate).
The point is what primitives.clj and polyhedra.clj are not: a starting set, not the boundary of what mesh/mesh can build. No other example generates its own geometry; every other one draws a shape the library already ships as a constructor.
| preview | what it shows |
|---|---|
![]() | knot: a rotating, smooth-shaded trefoil torus knot, its tube visibly self-crossing the way a genuine (2,3) knot does rather than closing into a plain ring. |
Like the other two, watched not trusted: no assertions, not part of bb smokes.
gears.clj: polygon/tessellate's first consumer outside its own test suitejolt -M:gears # or: bb gears
Three cog outlines, flat-shaded, spinning in a camera-less 2D scene. polygon/cog (added to glitter-gl.polygon in this arc, ported from thi.ng/geom's thi.ng.geom.polygon/cog; see NOTICE.md) builds a toothed outline per cog, and polygon/tessellate ear-clips that outline directly into a triangle list. Every other example draws geometry glitter-gl.mesh already tessellates for it (primitives.clj/ polyhedra.clj shapes go through mesh/->floats); mesh.clj never requires glitter-gl.polygon at all, and has its own unrelated tessellate-face over Vec3 meshes. Before this file, polygon/tessellate's only caller anywhere was its own test suite; this is the first thing in the project to hand it a shape for real.
Each cog's triangle buffer is built once, at namespace load; on-render never rebuilds geometry, it only rotates the three buffers, at different signed speeds so adjacent cogs counter-rotate, via a per-cog model matrix, the same way knot.clj spins its tube by matrix alone. m/ortho stands in for m/perspective: there's no camera, just a flat orthographic frame wide enough to hold all three cogs side by side.
| preview | what it shows |
|---|---|
![]() | gears: three cog outlines of different radii and tooth counts, spinning at different signed rates so the two outer cogs turn one way and the middle one turns the other. |
Like the others above, watched not trusted: no assertions, not part of bb smokes.
textured.clj: the first shader spec to sample a texturejolt -M:textured # or: bb textured
A rotating cube wearing a procedurally generated checkerboard: no image file, no binary asset in the repo. checkerboard-ptr writes an RGBA byte buffer straight into foreign memory at realize time. p/cuboid supplies the mesh; since mesh/->floats only ever emits position and normal, this file builds its own interleaved position+UV buffer by hand (cube-uv-floats), matching UV corners to p/cuboid's own per-face winding so every face shows the full checkerboard once.
This is the first consumer of glitter-gl.gl's texture FFI (gl-gen-textures/gl-bind-texture/gl-tex-image-2d/ gl-tex-parameter-i/gl-active-texture) outside renderer.clj's internal shadow-map path and the test suite (offscreen_test.clj exercises the same fns headlessly), and the first shader spec in the project to declare a :sampler2D uniform. Worth stating plainly, since "first user of X" invites the assumption that X was broken: no library gap was found here. shader.clj's set-uniform! already had a working :sampler2D branch, uploading the texture unit index via glUniform1i; it had simply never had a caller before this file.
The one uniform that's easy to get wrong, :u_texture, must be set to the texture unit index (0), never the GL texture id gl-gen-textures returned; on-render sets it correctly on every frame, the same "set every declared uniform explicitly" discipline ripple.clj's ns docstring describes.
| preview | what it shows |
|---|---|
![]() | textured: a cube rotating on two axes, its faces showing a crisp white-and-orange checkerboard baked at runtime rather than loaded from a file. |
Like the others above, watched not trusted: no assertions, not part of bb smokes.
picking.clj: the first example to react to the pointerjolt -M:picking # or: bb picking
A ground plane and a back wall, with a small sphere marker drawn at wherever the pointer's world-space ray hits one of them: amber for the ground, magenta for the wall, so the switch is visible without reading coordinates. Every other example draws geometry that never reacts to input; this is the first to use pointer events at all, and the first consumer of glitter-gl.intersect (specifically its ray-plane function) outside its own namespace and test suite.
on-motion only stores the latest pointer position; the actual raycast happens in on-render, every frame, re-reading whatever pointer-pos currently holds, so a fast pointer doesn't queue extra work per event. Unprojecting a screen pixel into a world-space ray needs the real perspective divide: glitter-gl.matrix/transform-point is documented as an affine-only transform (it assumes a constant w row, true for model/view matrices but not for a projection matrix), so this file supplies its own unproject, doing the full four-component transform including the divide by w. It re-derives the divide-by-w half of thi.ng/geom's unproject-point (src/thi/ng/geom/matrix.cljc): the port that produced matrix.clj never carried that function over, so there was nothing to call. See NOTICE.md for the full attribution.
A real widget-layer trap, found live during this arc: GtkGLArea's resize signal reports the framebuffer size in device pixels (2x on a Retina display), while :on-motion reports logical points. Unprojecting a pointer position using the viewport dimensions captured from resize is silently wrong by the display's scale factor on a Retina display, and invisible on a non-Retina one, where the two units happen to coincide. picking.clj avoids it by sourcing width and height from glx/widget-width/glx/widget-height (both logical) rather than from its own @viewport atom (kept for gl/gl-viewport, which does want device pixels). See gl-area-widget-layer.md for the full mechanics.
| preview | what it shows |
|---|---|
![]() | picking: a marker tracking the pointer across a ground plane and a back wall, switching color at the boundary where the ray leaves one plane and hits the other. |
Like the others above, watched not trusted: no assertions, not part of bb smokes.
A forced or content-triggered bb record would destroy this GIF, not reproduce it. picking is pointer-driven, and scripts/demo_manifest.edn's steering is keyboard-only, so an actual re-capture of this take would show the scene at rest, with no marker at all, silently overwriting the committed GIF with an empty one. That risk is not standing today: docs/demos/ledger.edn's picking entry already matches this file's current content hash, so an ordinary bb record --only picking (or --dry-run) reports it up to date and skips it (1 items, 0 to capture, 1 up to date, verified). The hazard becomes real only if picking.clj changes, invalidating that hash, or if --force is passed. Under either condition: the committed GIF was captured outside the normal recording pipeline, by synthesizing pointer motion with Quartz CGEventPost while taking a screenshot per frame; reach for that approach instead of bb record.
Six touchpoints; skip one and the example is invisible to something:
examples/glitter_gl/.deps.edn alias, so jolt -M:<name> works without babashka: :gl-area-smoke {:extra-paths ["examples"] :main-opts ["-m" "glitter-gl.gl-area-smoke"]}
bb.edn task, so bb <name> works and it shows up in bb info: gl-area-smoke {:doc "Live-GTK smoke: :gl-area construct/realize/render/resize"
:task (shell "jolt" "-M:gl-area-smoke")}
bb.edn demos:examples row, so bb record's gallery pipeline knows the example exists: {:id "orbit"
:group "orbit"
:desc "six distinct solids orbiting above a ground plane, lit and shadowed, mounted via reactive-area"
:src "examples/glitter_gl/orbit.clj"
:run "jolt -M:orbit"}
scripts/demo_manifest.edn group entry, matching the row's :group key, so the recorded GIF gets its own gallery heading: {:key "orbit" :title "the orbit demo"}
bb.edn's smokes task so bb smokes actually runs it: smokes {:doc "Run every live-GTK smoke and headless check in sequence; stops at the first failure"
:task (do (shell "jolt" "-M:gl-area-smoke")
(shell "jolt" "-M:check"))}
Skipping this step doesn't break the new example; it just means nobody running bb smokes (or a future CI job built on it) ever exercises it, the same silent gap plasma.clj has today by design.
A smoke's own -main must exit non-zero on failure and be invoked via jolt -M:<alias>, never the bare jolt <task> shorthand; see testing-and-tasks.md for why the task form can't be trusted to fail a build at all.
The 22 namespaces this page orients you in (glitter_gl/vector.clj through glitter_gl/renderer.clj) are not authored in this repository. They are verbatim namespace-rename ports of glimmer-gl, which itself ports thi.ng/geom. Nobody should change their behavior here; a real behavioral change belongs in its own reviewed commit against upstream, or in its own commit here with a NOTICE.md entry (see porting-and-attribution.md for exactly what that requires).
Because of that, this is not API reference documentation. The reference is thi.ng/geom's own docs and source, and glimmer-gl's (unmodified) copy of them. What this page gives you instead is orientation: how the 22 files group, where the data changes shape as it crosses from plain Clojure values to a GPU buffer, and the two design decisions (column-major matrices, shaders as data) that a reader new to this codebase would otherwise have to reconstruct by reading all 22 files. For "where this code came from and what changed on the way," see porting-and-attribution.md, which this page links back to.
14 pure geometry/math namespaces: vector, vec2, matrix, quaternion, aabb, rect, circle, line, plane, triangle, sphere, polygon, bezier, intersect. Vec3/Vec2 arithmetic, 4Γ4 matrices, quaternion rotation, and one record type per geometric primitive (axis-aligned box, rectangle, circle, line segment, plane, triangle, sphere, polygon, BΓ©zier/Catmull-Rom curve), plus intersect's ray tests for picking. All plain functions over defrecord/deftype values: no protocols, no mutation. Two of these (aabb, rect) carry a small, genuinely interesting scar from the host: their extent field is stored as sz, not size, because in Jolt a record field literally named size is shadowed by record introspection (.-size would return the field count, not the value). The public accessor is still size; only the storage field is renamed. That's a Jolt constraint neither thi.ng/geom nor glimmer-gl had to work around.
4 mesh namespaces: mesh, glmesh, primitives, polyhedra. mesh is the composable data model (a mesh is a sequence of faces of Vec3 vertices) plus the ops that transform one: translate, scale, tessellate, subdivide, compute normals. primitives and polyhedra are constructors: cuboid, tetrahedron, plane, UV sphere; octahedron, icosahedron, dodecahedron. glmesh is where a mesh stops being pure data and becomes a GL buffer spec, the boundary the next section traces in detail.
GL plumbing: shader, gl, offscreen, renderer. shader is the shader-spec-as-data DSL (its own section below). gl is the raw FFI layer: every glGetString/glBufferData/glUniform* call the rest of the library needs, and nothing else (its own section below too). offscreen solves a narrower problem: every other GL entry point needs a realized GtkGLArea to have a current context, which means GL code can normally only run inside a live GTK app. offscreen asks GDK directly for a context bound to the display rather than a surface (gdk_display_create_gl_context, GTK 4.6+), so the unit suite can exercise real GL calls (real shader compilation, real buffer uploads) from a headless test runner with no window at all. renderer is the one file in this group that isn't infrastructure: it's a complete two-pass shadow-mapped Blinn-Phong renderer (depth pass from the light, then a lit pass sampling that depth texture), built entirely out of the other three.
This is the part upstream's docs can't tell you, because it's specific to how this codebase wires its own layers together. thi.ng/geom documents the mesh model and the GL layer separately, not the path data actually takes between them in glitter-gl's shipped renderer.
Plain data. A mesh is (defrecord Mesh [faces]) in mesh.clj: a vector of faces, each face a vector of glitter-gl.vector/Vec3 records wound counter-clockwise so the face normal points outward. A constructor like primitives/cuboid builds one directly:
(mesh/mesh
[[c d h g] ;; east (+X)
[a b f e] ;; west (-X)
[f g h e] ;; north (+Y)
[a d c b] ;; south (-Y)
[b c g f] ;; front (+Z)
[d a e h]])) ;; back (-Z)
six quad faces, each just four Vec3 corners; nothing GL-shaped about it yet.
Tessellation and normals. mesh/triangles fans every face down to triangles via mesh/tessellate-face (a triangle is itself; a quad splits into two triangles across a diagonal; a larger n-gon fans around v/centroid). mesh/->floats then computes a normal per triangle-corner: mesh/face-normal (the same flat normal for all three corners of a triangle) in :flat mode, or mesh/vertex-normals (every triangle touching a corner contributes its face normal, summed and renormalized) in :smooth mode. It then interleaves position and normal into one flat sequence of doubles:
{:data data
:count (* 3 (count tris))
:stride 6}
six doubles per vertex (x y z nx ny nz), three vertices per triangle, no index buffer, no sharing between triangles even where corners coincide.
What actually reaches GL, and the pipeline that doesn't. glmesh.clj documents itself as the mesh β GL pipeline: as-gl-buffer-spec compiles a mesh into a buffer spec of separate attribute buffers ({:attribs {:position {...} :normal {...}} :num-vertices N :mode gl/GL-TRIANGLES}), make-buffers-in-spec uploads each attribute to its own VBO, make-vertex-array binds them into a VAO against a compiled shader's attribute locations, and draw-with-shader issues the actual draw call. It's real, pure where it can be (as-gl-buffer-spec needs no GL context at all; that's exactly why glmesh_test.clj can exercise it headlessly), and verbatim-ported from thi.ng's gl.glmesh + gl.core.
But it is not the path this project's own renderer or demo take. Grep the whole tree for callers of make-buffers-in-spec, make-vertex-array, or draw-with-shader outside glmesh.clj itself, and the only hit is glmesh_test.clj, which, per its own comment, exercises just the context-free half. renderer.clj's private upload-mesh instead calls mesh/->floats directly (the interleaved single-buffer shape above, not glmesh's separate-attribute one) and hand-rolls the upload with eleven raw glitter-gl.gl calls in total (VAO/VBO generation, bind/unbind, and attrib-array enables among them), but the three that matter for the vertex layout are: one gl-buffer-data to copy the whole interleaved blob into a single VBO, then two gl-vertex-attrib-pointer calls describing that one buffer to the GPU as two attributes: location 0 (a_pos) reads 3 floats at byte offset 0, location 1 (a_normal) reads 3 floats at byte offset 12, both at a 24-byte (6-float) stride. gl/write-floats marshals the Clojure double sequence into a native float* via jolt's FFI immediately beforehand, and is freed right after gl-buffer-data copies it. The mesh is cached by value in the render state's :meshes map, keyed on the Mesh record itself, so equal geometry (every column in a scene, say) uploads once and every instance replays the same VAO.
The upshot for a reader: glmesh.clj's pipeline is real, tested, and the more general of the two: a future caller who wants separate-attribute buffers or draw-with-shader's composed draw call has it available. But if you're tracing what jolt -M:plasma or jolt -M:check actually uploads to the GPU today, follow mesh/->floats β renderer.clj's upload-mesh, not glmesh.clj. Either way the draw call at the end is gl-draw-arrays; this codebase never binds glDrawElements or an index buffer at all (see the gl.clj section below), so every triangle's three vertices are always emitted in full, whichever pipeline built the buffer.
matrix.clj's Matrix44 stores its sixteen fields in column-major order (each contiguous group of four is one column) because that is the layout glUniformMatrix4fv expects on the wire, and ->vec / shader/set-uniform!'s :mat4 case hand that layout straight through with the transpose flag set to GL-FALSE ("don't transpose, this is already what you want"). transform-point's docstring states the consequence directly: component r of the transformed point is Ξ£_c m{c}{r}Β·p_c, and the translation column (m30 m31 m32) is added last: translation lives in the last group of four values, not scattered across the last position of each group. translation's own constructor makes the same point by construction:
(defn translation ^Matrix44 [^double tx ^double ty ^double tz]
(Matrix44. 1 0 0 0 0 1 0 0 0 0 1 0 tx ty tz 1))
If someone "fixed" this to row-major (reordering the sixteen fields so each group of four reads as a row instead of a column), two things would break, not one. First, every matrix already built by translation/ scaling/rotate-x/rotate-y/rotate-z/perspective/ortho/ look-at encodes its translation and axis vectors as column groups; reinterpreting the same sixteen numbers as rows moves the translation out of the last-four-values position into scattered single entries across all four rows, which is a different matrix, not a transposed view of the same one. Second, even if every constructor were rewritten to match, shader/set-uniform! still uploads with GL-FALSE (no transpose), so unless that flag also flipped to GL-TRUE everywhere a :mat4 uniform is set, the GPU would receive data in the opposite layout from what the shader's mat4 type expects, silently transposing every matrix multiply on the GPU side. mul's cofactor pattern (the madd/msub macros) is written to match the current column-major layout too, so a row-major rewrite would need every arithmetic function in the file re-derived, not just the storage order relabeled. This is inherited unmodified from thi.ng/geom, which made the same column-major choice for the same reason.
shader.clj doesn't hold GLSL strings. A shader is a plain map declaring its interface (uniforms, attributes, varyings, fragment outputs, an optional GLSL :prelude of helper functions, and the :version string), with :vs-main/:fs-main bodies as vectors of statements built from small expression nodes ([:* a b], [:. x :xyz], [:vec3 :a_pos 1.0], and so on; compile-expr/compile-stmt document every node form). sources is the only function that turns any of this into an actual GLSL string, and it needs no GL context to run: you can call it from a REPL or a test and read the generated shader source directly. program is the one function in the file that does need a context: it compiles and links the generated GLSL and returns the spec enriched with the program id and each uniform/attribute's real GL location.
Because a shader spec is just a map, composing shaders is just map composition. merge-specs combines several spec fragments: later :uniforms/:attribs/:varying/:fs-out entries win on key conflicts, :vs-main/:fs-main statement vectors concatenate in argument order, :prelude strings concatenate. examples/glitter_gl/ plasma_shader.clj is the real worked example this project ships: a shared vertex-stage-and-framing base map, a plasma-module (domain-warped plasma via a GLSL helper in :prelude, its own u_scale/u_warp uniforms), a stripes-module (animated stripes, its own u_stripes uniform), and a main-module that blends the two effects by u_mix and applies simple diffuse lighting, composed as
(def shader-spec
(sh/merge-specs base plasma-module stripes-module main-module))
Drop stripes-module from that call, or write a fourth module and add it, and the composed shader changes shape with no edits to the other three maps; each module owns only the uniforms and statements its own effect needs. This is the intended reading of the shaders-as-data model: reusable GLSL logic as merge-able data fragments, GLSL text generated once at the very end.
[type default] pair is documentation, not an uploadA uniform declaration can be a bare type ({:u_time :float}) or a [type default] pair ({:u_freq [:float 12.0]}), and reading the second form as "the value this uniform starts at if nothing sets it explicitly" is the natural assumption. It's wrong, and worth stating precisely because nothing about compiling or linking the shader reveals that it's wrong.
Verified directly against the source, not restated from a plan: program's private located-uniforms (shader.clj:256-262) is the only place :default is ever written. It destructures the pair, compiles the uniform's real GL location, and stores :default alongside :loc/:type in the returned {name {:loc :type :default}} map:
;; shader.clj
(defn- located-uniforms [prog uniforms]
(into {} (map (fn [[id t]]
(let [[type default] (if (sequential? t) t [t])]
[id {:loc (gl/gl-get-uniform-location prog (name id))
:type type
:default default}]))
uniforms)))
set-uniform! (shader.clj:291-313), the only function that ever uploads a value with glUniform*f/glUniformMatrix4fv, reads :loc and :type from that same map to dispatch the upload, but never reads :default. set-uniforms! (shader.clj:315-319) is just set-uniform! looped over whatever {name value} map the caller passes it. Grep the file for every reference to :default, and located-uniforms's write is the only one; nothing downstream ever reads it back.
The consequence: a uniform your spec declares but your on-render never includes in the map passed to set-uniforms! sits at GLSL's own zero-initialized default (0.0, vec3(0), and so on) on the GPU, regardless of what the spec's [type default] pair says. There is no error at compile time or at render time: the shader compiles and links cleanly, because leaving a declared uniform unset is always legal GLSL.
This cost examples/glitter_gl/ripple.clj a real debugging session, not a hypothetical one: its first version's on-render set :u_time and :u_resolution (the two uniforms that visibly needed a per-frame value) but left :u_freq, :u_speed, :u_deep, and :u_bright out of the set-uniforms! call, on the reasonable-looking assumption that their [type default] pairs already covered them. With u_freq/ u_speed at zero, the ripple field's sin(d * 0 - t * 0) collapsed to a constant 0 for every pixel, every frame; with u_deep/u_bright at vec3(0), the color mix output pure black regardless of that constant. The window opened, the shader compiled and linked, nothing threw, and the picture was a flat black rectangle. plasma.clj never hits this because its on-render already sets every uniform its shader declares, every frame, including the ones that read like fixed constants; ripple.clj and knot.clj now follow the same rule, and both say why in their own ns docstrings.
The rule: set every uniform your spec declares, on every render call, even the ones that look like they should only need setting once. If a value is genuinely constant, define it as a module-level def and reference that same def from both the spec's [type default] pair and the set-uniforms! call, so the two can't silently drift apart the way two separate literals could.
gl.clj binds, and doesn'tgl.clj is a minimal FFI surface, not a general OpenGL binding: it exists to compile shaders and fill buffers/VAOs/uniforms, and it stops there. Its defcfn declarations cover context/state (glClear, glViewport, glEnable/glDisable, glBlendFunc, glScissor), buffer and vertex-array objects, shader/program compilation and linking, uniform upload (glUniform1f/2f/3f/4f, the vector-array forms, glUniformMatrix4fv), vertex attributes, face culling, textures, and framebuffers (the render-to-texture path renderer.clj's shadow pass uses). That's the whole surface every other file in this project calls.
What it deliberately leaves out, verifiably: there is no glDrawElements binding anywhere in the file, so this codebase has no index-buffer draw path at all: every draw call in renderer.clj and glmesh.clj is a non-indexed glDrawArrays, matching the never-deduplicated triangle-soup shape mesh/->floats produces (see above). There's no compute-shader or debug-callback binding either.
One block is worth calling out on its own, because it doesn't fit "the slice needed to compile shaders and fill buffers" and is easy to miss reading top-to-bottom: a self-contained transform-feedback + geometry-shader + texture-buffer + query-object section, with its own header comment explaining the intended use (GPU-side stream compaction: a vertex+geometry program that conditionally emits survivors into a buffer via transform feedback, a query object reporting how many, the result read back through a texture buffer, chosen specifically because the target macOS GL version has no compute shaders or SSBOs to do the same job more directly). Grepping the tree for its bindings (gl-begin-transform-feedback, gl-tex-buffer, gl-begin-query, and neighbors) turns up no caller anywhere in src/, examples/, or test/ outside gl.clj itself; this is capability the port carries forward, verbatim, not code exercised by anything glitter-gl currently ships.
porting-and-attribution.md: where every one of these 22 files came from, the Standard Verbatim Port Procedure that keeps them that way, and what a real change to one of them would require.architecture.md: how this geometry/GL layer and the glitter-integration layer (gtk/scene/app) fit together as one repository, and how thin the seam between them actually is.:gl-area widget layerglitter-gl.gtk registers exactly one hiccup tag, :gl-area, into glitter's widget registry (glitter.widget/register-widget!). This page covers its mechanics: how its handlers actually get wired (a real, live-found correction to the original port design), and the GTK4 signal shapes it has to handle that don't fit glitter's uniform void(widget,data) path.
[:gl-area {:version [3 2] :depth-buffer true :hexpand true :vexpand true
:on-realize (fn [area] ...)
:on-render (fn [area] ...)
:on-resize (fn [area w h] ...)
:on-tick (fn [area] ...)
:on-motion (fn [area x y] ...)
:on-key (fn [area keyval pressed?] ...)
:on-button (fn [area btn pressed? x y] ...)}]
:apply, not :connect: a corrected designglitter.widget/register-widget!'s own docstring describes a :connect key on a widget spec: "the optional :connect runs at create! time after the generic :on-* wiring, for widgets whose signals don't fit the uniform void(widget,data) shape (e.g. a GtkGLArea's realize/render/resize)." :gl-area is that docstring's own motivating example, so the original port design (see the design spec's glitter-gl.gtk section) naturally called for registering :gl-area with a :connect closure that wires realize/render/resize/tick once, at construction.
This does not work, and the reason is specific to how glitter.core actually drives widget creation. :connect genuinely runs (the bug isn't in :connect itself), but it never sees any of :gl-area's real props, so it has nothing to wire.
glitter.core/create-node is the only place IRender/create-element is ever called, and it passes almost nothing:
;; glitter.core/create-node
(let [tag-name (hiccup/tag-name headers)
ns (get-ns headers)
node (r/create-element renderer tag-name (when ns {:ns ns}))
[attrs mounting-attrs] (get-mounting-attrs headers)
_ (set-attributes renderer node (or mounting-attrs attrs))
...]
...)
The options argument create-element receives is (when ns {:ns ns}), an XML-namespace hint for SVG-style tags, nil for everything else. Never the hiccup element's own attribute map. glitter.gtk's create-element passes that same near-empty map straight through to glitter.widget/create!:
;; glitter.gtk
(create-element [_ tag-name options]
(let [tag (keyword tag-name)
widget (w/create! tag (or options {}))]
...))
;; glitter.widget
(defn create! [tag props]
(let [props (with-orientation tag props)
s (spec-for! tag)
widget ((:ctor s) props)]
((:apply s) widget props)
(apply-widget-props! widget props)
(connect-signals! tag widget props)
(when-let [connect (:connect s)] (connect widget props)) ; <- props here
widget))
:connect's own props argument is exactly the options value threaded all the way from create-node: at most {:ns "..."}, never :on-realize/:on-render/etc. A :connect closure registered for :gl-area runs, once, with an empty (or near-empty) prop map, finds none of the handler keys it's looking for, and does nothing. Silently: no exception, no warning, just a GtkGLArea that never realizes.
The real props arrive through a separate path, right after create-element returns: create-node's own (set-attributes renderer node (or mounting-attrs attrs)) call. set-attributes iterates the attribute map and calls set-attr once per key, which (for any key that isn't :style/:classes/:on) calls set-attr-val, which ultimately routes through glitter.widget/apply-props! to the widget spec's :apply closure. :apply runs once per prop key, both at construction and on every re-render. glitter.gtk's set-attribute (the only caller of apply-props! outside create! itself) always passes a single-key map, {(keyword a) v}, never the full prop map; apply-props!'s own docstring confirms this is exactly the shape it's designed for ("safe to call with a single-key partial map"). This is the exact same path :scale's :apply already relies on to re-range its min/max/step on every render (its :ctor doesn't see real props either), found live while tracing this, and cited directly as precedent in gtk.clj's own CORRECTION comment.
So: :apply is the only widget-spec closure that ever sees a hiccup element's real prop values, at any point in its lifecycle. :connect runs at the right time (once, at construction) but with the wrong data (never the real props): it's not a usable extension point for wiring event handlers under glitter's actual reconcile flow, regardless of what its docstring promises. This was found live via Task 17's smoke: the first version of :gl-area, wired through :connect exactly as the design spec prescribed, passed every unit test (none of which mount a real widget tree) and then never fired :on-realize when actually run against GTK.
:apply, guarded idempotent(defonce ^:private wired (atom {}))
(defn- wire-once!
"True the FIRST time `event` is seen for `area`; false (and no side
effect) on any repeat call."
[area event]
(let [seen (get @wired area #{})]
(when-not (contains? seen event)
(swap! wired update area (fnil conj #{}) event)
true)))
(defn- gl-area-apply! [area props]
(let [{:keys [on-realize on-render ...]} props]
(when (and on-realize (wire-once! area :on-realize))
(connect! area "realize" ...))
...))
:apply can run several times over a :gl-area's life, once per prop key, both at construction and on every re-render (never with the whole prop map in one call), but each signal must only ever be connected once; reconnecting "realize" on every render would pile up duplicate callbacks, each firing independently. wire-once! gates every branch on [area event], so gl-area-apply! is safe to call as many times as :apply actually is, while each underlying g_signal_connect_data (or gtk_widget_add_tick_callback) call happens exactly once per widget per event.
Write-once contract for callers, distinct from :apply's general per-key mechanism above: the general mechanism (:apply runs once per prop key, both at construction and on every re-render) is what lets most widgets (:scale's min/max/step, for instance) pick up fresh values on every render. :gl-area specifically does NOT get that benefit for its event-handler props, because wire-once! only reacts to the FIRST arrival of each event key: once :on-realize/:on-render/:on-resize/:on-tick/ :on-motion/:on-key/:on-button has been wired once for a given :gl-area widget, every subsequent :apply call for that same event key is a guarded no-op; the closure connected on first arrival keeps running for the widget's entire life, even if a later render supplies a different closure for the same key. Concretely: if a caller (e.g. glitter-gl.app/reactive-area) is invoked again for what's meant to be the same :gl-area mount point, expecting its new opts/closures to replace the old ones, they will not; the original closures keep running. Callers must build a :gl-area prop map once, at a stable point, and reuse the same result across renders.
Known v1 limitation, found during review, not fixed: wired is keyed by the raw GtkGLArea pointer (a plain machine address) with no release path when a widget is destroyed. If GTK/GLib ever reuses a freed :gl-area's address for a brand-new widget, the new widget would silently inherit the old one's wired entries and never get its handlers connected: no exception, just a GL area that never realizes. glitter.gtk's own memory atom sidesteps this exact trap by keying off a tracking atom's Clojure identity instead of a raw pointer; that pattern isn't available here without changing glitter.widget's :apply contract to pass a stable identity alongside the raw widget pointer, which is out of scope for this fix. Not currently a live risk (every call site in this project mounts one :gl-area for the app's lifetime), but revisit if a future task introduces dynamic :gl-area mount/unmount.
What this means for extending :gl-area, or registering a different custom widget with non-standard signals (here or in glitter itself): wire from :apply, guarded idempotent, never from :connect.
void(widget,data) pathAlmost every GTK signal glitter's own set-event-handler connects is void(widget, user_data). :gl-area needs several shapes that don't match, wired directly by glitter-gl.gtk itself (it does not go through glitter.widget/signals/signal-value at all; every :gl-area handler is application-supplied, not routed through glitter's action-dispatch system; see scene-and-app.md for why).
"render": non-void return. GtkGLArea's "render" signal is gboolean render(GtkGLArea*, GdkGLContext*, gpointer). glitter-gl's foreign-callable declares it as [:pointer :pointer] :void, 2 args (the extra GdkGLContext* is simply not read, a harmless simplification inherited unchanged from glimmer-gl's own file), but with an :int return, not :void:
(ffi/foreign-callable (fn [a _] (on-render a) 1)
[:pointer :pointer] :int :collect-safe)
Always returning 1 (TRUE) tells GTK the draw call is handled and nothing further needs to run.
"resize": extra int arguments. GtkGLArea's "resize" signal carries the new width/height directly as signal arguments: void resize(GtkGLArea*, gint width, gint height, gpointer): 4 parameters, not 2:
(ffi/foreign-callable (fn [a w h _] (on-resize a w h))
[:pointer :int :int :pointer] :void :collect-safe)
"realize" is the one signal here that IS the standard shape: void realize(GtkGLArea*, gpointer), wired exactly like any other glitter signal.
on-tick isn't a GTK signal at all. There is no "tick"/"frame" signal on GtkGLArea; frame-synced callbacks go through a completely separate GTK API, gtk_widget_add_tick_callback, which registers directly against the widget's GdkFrameClock rather than via g_signal_connect_data:
(gtk-widget-add-tick-callback area
(let [cb (ffi/foreign-callable
(fn [a _clock _data] (on-tick a) (queue-render a) 1)
[:pointer :pointer :pointer] :int :collect-safe)]
(w/retain-callable! cb) cb)
ffi/null ffi/null)
The callback signature is gboolean callback(GtkWidget*, GdkFrameClock*, gpointer); returning 1 (GDK_SOURCE_CONTINUE, aliased through gboolean's ABI) keeps the tick firing on every subsequent frame, 0 would cancel it. on-tick always queues a render immediately after, so an app using it gets a continuous render loop for free without having to call queue-render itself.
on-motion layers a GtkEventControllerMotion onto the area, since GtkGLArea itself has no pointer-motion signal; controllers are GTK4's mechanism for attaching extra input handling to any widget:
(let [ctl (gtk-event-controller-motion-new)]
(gtk-widget-add-controller area ctl)
(connect! ctl "motion"
(ffi/foreign-callable (fn [_ x y _] (on-motion area (double x) (double y)))
[:pointer :double :double :pointer] :void :collect-safe)))
"motion"'s real signature is void motion(GtkEventControllerMotion*, gdouble x, gdouble y, gpointer): the two gdouble coordinates arrive as direct signal arguments, same shape class as "resize"'s extra ints but with :double instead of :int.
on-key needs a controller on the root window, not the GLArea itself. GtkGLArea can't hold keyboard focus (gtk_widget_grab_focus returns FALSE even with :can-focus set), so a GtkEventControllerKey attached directly to the area never receives key events. The fix wires a small self-arming handler onto :gl-area's OWN "realize" signal (a second, independent "realize" connection, alongside whatever the caller's own :on-realize does) that looks up the root window via gtk_widget_get_root (which only resolves once the widget is actually realized and attached to a window) and attaches the key controller there, once:
(let [armed? (atom false)
arm (ffi/foreign-callable
(fn [_area _]
(when-not @armed?
(reset! armed? true)
(let [root (gtk-widget-get-root area)
ctl (gtk-event-controller-key-new)]
(when-not (ffi/null? root)
(gtk-widget-add-controller root ctl)
(connect! ctl "key-pressed" ...)
(connect! ctl "key-released" ...)))))
[:pointer :pointer] :void :collect-safe)]
(connect! area "realize" arm))
"key-pressed"'s real signature is gboolean key_pressed (GtkEventControllerKey*, guint keyval, guint keycode, GdkModifierType state, gpointer): 4 args, non-void return (a third distinct callable shape in this file, alongside "render"'s 2-arg/non-void and "resize"'s 4-arg/void):
(ffi/foreign-callable (fn [_ kv _kc _st _] (on-key area (int kv) true) 0)
[:pointer :uint :uint :uint :pointer] :int :collect-safe)
Returning 0 (GDK_EVENT_PROPAGATE) lets the key event continue past this handler to any other consumer (e.g. GTK's own focus-navigation keys). "key-released" carries the identical 4 arguments but IS void-returning; the pressed/released pair is asymmetric in GTK4's own API, not a glitter-gl inconsistency.
on-button layers a GtkGestureClick, GTK4's gesture-based mouse button API, the same controller-attachment pattern as on-motion:
(let [g (gtk-gesture-click-new)]
(gtk-widget-add-controller area g)
(connect! g "pressed"
(ffi/foreign-callable (fn [_ _n x y _] (on-button area 1 true (double x) (double y)))
[:pointer :int :double :double :pointer] :void :collect-safe))
(connect! g "released" ...))
"pressed"/"released"'s real signature is void pressed (GtkGestureClick*, gint n_press, gdouble x, gdouble y, gpointer): 5 args, void return; the n_press (click-count) argument is read but discarded, and on-button is always called reporting button 1 (no button-index disambiguation in this version).
"resize" reports the framebuffer size in device pixels, correct for gl/gl-viewport, which needs real framebuffer pixels: on a 2x Retina display, a 900x600 logical window resizes to 1800x1200. "motion"'s (x, y) arguments, by contrast, arrive in logical points, the same units GTK reports widget/window sizes in everywhere else: on that same 900-wide area, a live :on-motion trace never exceeds roughly 900, not 1800.
Anything that unprojects a pointer position using the viewport dimensions captured from "resize" is silently wrong by the display's scale factor, and the bug is invisible on a non-Retina display, where the scale factor is 1 and the two units happen to coincide. Found live during this project's picking.clj arc: a standalone probe ((gtk-gl-area "resize") against gtk_widget_get_width/gtk_widget_get_height against a live :on-motion trace, all on the same window) confirmed the split directly, after a first version of the example placed its marker nowhere near the actual pointer on a Retina machine.
The fix is to source width and height from glx/widget-width/ glx/widget-height (gtk.clj:116-122, thin wrappers over gtk_widget_get_width/gtk_widget_get_height, both logical) for anything unprojecting a screen pixel, and to reserve a "resize"-populated viewport atom for gl/gl-viewport and any aspect-ratio computation, which is scale-invariant either way and doesn't care which unit it's given. gtk.clj's own docstrings for these two functions just say "in pixels (GTK4)"; "logical" is this guide's own term for the distinction drawn above, between "resize"'s device pixels and gtk_widget_get_width/ gtk_widget_get_height's GTK4-defined widget-space pixels, not additional wording gtk.clj itself uses. examples/glitter_gl/picking.clj is the first, and so far only, consumer of pointer input anywhere in this project, and follows this pattern.
| Signal / mechanism | Args | Return | Notes |
|---|---|---|---|
"realize" | 2 (standard) | void | The one signal that fits glitter's default shape |
"render" | 2 | int | Non-void return; always returns 1 |
"resize" | 4 | void | Width/height as direct int args |
| tick callback | 3 (not a signal, gtk_widget_add_tick_callback) | int | Frame-clock API, not g_signal_connect_data; returns 1 to keep ticking |
"motion" (on a controller) | 4 | void | x/y as direct double args |
"key-pressed" (on a controller, attached to root) | 5 | int | Non-void; returns 0 to propagate |
"key-released" (same controller) | 5 | void | Same args as key-pressed, void return |
"pressed"/"released" (on a GtkGestureClick) | 5 | void | n_press + x/y as direct args |
Every one of these is wired with its own literal foreign-callable call inside gl-area-apply!; jolt.ffi/foreign-callable's argtypes/ rettype must be compile-time literals (the same constraint glitter's own :switch/:list-box signal generalization hit and documented in its own docs/guide/gtk-widget-layer.md), so there is no data-driven table here either; adding a ninth :gl-area handler with yet another shape means adding its own literal branch by hand.
:gl-area's :on-realize/:on-render/:on-resize/:on-tick props all have names starting with "on", the exact pattern glitter's ported Replicant hiccup validation (glitter.asserts/assert-no-event-attribute) flags as a likely :on {} mistake, printing "Set event listeners in the :on map... Instead of :on-realize set :on {:realize ,,,}" once per prop key, per render. The warning does not affect anything: create-node's set-attributes call still routes the value through set-attr-val to r/set-attribute β glitter.widget/apply-props! β gl-area-apply! exactly as described above, regardless of the console noise. It's a false positive specific to this widget's non-standard prop shape, not a sign anything is broken.
There is no way to exempt :gl-area's specific props from this check without modifying glitter.core/glitter.asserts themselves (out of scope for glitter-gl).
(glitter.env/configure! :glitter/asserts? false) looks like the obvious escape hatch, but it does NOT work when called from an app's own -main, verified live, not just read. glitter.assert's enter-node/ assert are macros whose (when (assert? ) ...) gate runs at MACROEXPANSION time: once, when glitter.core itself is compiled, not per-call at runtime. glitter.core gets compiled the moment any namespace :requires glitter.app/glitter.gtk (both pull it in transitively), which happens as part of processing the ns form itself, before any of that file's own code, -main included, ever runs. So by the time -main calls configure!, glitter.core's macros have already expanded with asserts baked in; the call is a no-op. Confirmed with a two-line probe: (configure! ...) (require '[glitter.core]) then checking glitter.assert/assert? reports false when configure! runs first, but calling it in the opposite order (the only order a single namespace's -main can actually achieve, since its own ns form's :require already ran before -main exists to call anything) leaves assert? at its default true; the check simply never sees the config change in time.
Making configure! actually take effect needs a genuinely separate bootstrap namespace: one that requires only glitter.env, calls configure! first, and only then dynamically (require 'the-app-ns), so so glitter.core's compilation happens after the config change, not before. glitter-gl doesn't ship one (the added entry-point complexity wasn't judged worth it for a cosmetic wart); examples/glitter_gl/ plasma.clj and examples/glitter_gl/gl_area_smoke.clj both just leave the warnings on.
Each of these is a deliberate scope decision, not an oversight. If you're tempted to "just fix" one of these opportunistically, read the rationale first: each has a reason the fix (or the workaround) was left for a later round, not a reason it's impossible.
reactive-area now has a live demoglitter-gl.app/reactive-area is real, adapted code with direct unit coverage: app_test.clj asserts it returns a :gl-area prop map with all four standard handler keys present as functions, that its defaults match the documented ones ([3 2] version, depth buffer on, hexpand/vexpand true), and that explicit opts override them. Until this arc, that was all the coverage it had: grepping the tree for reactive-area outside its own definition and test file returned nothing, and the shipped plasma demo (examples/glitter_gl/plasma.clj) wired :gl-area directly instead, matching its own upstream source (the demo's docstring describes itself as ported from gl-demo.core, with "the GL render-loop plumbing ... otherwise unchanged").
examples/glitter_gl/orbit.clj closes that gap: six solids (primitives/cuboid/sphere/tetrahedron plus polyhedra's octahedron/icosahedron/dodecahedron) orbiting a lit, shadowed ground plane, mounted through reactive-area and driven by glitter's own shared state atom, the first thing in this project that mounts reactive-area against a real :gl-area and renders a frame through it. Recorded and committed; see examples.md for the gallery entry.
What orbit.clj found, stated plainly: no defect. reactive-area mounted and rendered correctly on the first version that actually ran: no crash, no black window, no stalled scene, multiple distinct solids visible and lit, shadows tracking each solid's position across frames. That's a real result, not a non-result: an integration path that had never been exercised end to end turned out to work as designed the first time it was tried live. See scene-and-app.md for the one real cost this example did surface: driving glitter's watched state atom from :on-tick recomputes the whole view every frame, not just the GL render loop, worth knowing before reaching for reactive-area in a busier reconciled tree than a single :gl-area pane.
What this does not close: orbit.clj is one scene, exercising one shape of reactive-area usage (built from the library's own primitives and polyhedra, one light, :materials fully overriding the renderer's default palette). It doesn't exercise :fog, :shadow-bias, :depth-spec/:lit-spec, :on-motion, :on-key, or :on-button through reactive-area's own opts, all of which reactive-area accepts and none of which any reactive-area-mounted example drives. Treat :fog, :shadow-bias, and :depth-spec/:lit-spec as still resting on unit coverage alone.
:on-motion is no longer wholly unexercised, though, just not through reactive-area. examples/glitter_gl/picking.clj drives it live, wired directly onto :gl-area the way plasma.clj/ ripple.clj/gears.clj/knot.clj/textured.clj wire their own handlers, not through reactive-area. picking.clj wires only :on-motion beyond the standard realize/render/resize/tick set; it has no :on-button prop at all. :on-button and :on-key therefore remain completely unexercised, by any example, through either wiring path; a future example driving either would still be closing new ground.
One item here is not merely untested, it is wrong. Verified against source: app.clj:186 always writes :materials (:materials opts) into the render context, so the key is always present, with value nil when a caller omits it. renderer.clj:190 reads it back as (get ctx :materials material-colors); get's default fires only on an ABSENT key, so it never fires here, and materials stays nil. renderer.clj:238's per-mesh lookup, (get materials material [0.5 0.5 0.5]), then falls through for every mesh, rendering everything mid-grey instead of the renderer's palette. Every sibling opt (:bg, :ambient, :shadow-bias, :fog, at app.clj:166-170) uses or and does fall back correctly, and app.clj:108's own docstring promises :materials "defaults to the renderer's": this is an oversight, not intent. orbit.clj supplies :materials explicitly, which is exactly why this arc did not surface it. This is a defect in the ported renderer path, not a deliberate limitation like the rest of this page; left rather than fixed because src/ is frozen verbatim-ported code and a behavior change needs its own arc with a test.
"render"'s foreign-callable declares two arguments; GTK4 passes threeVerified directly against the source, not restated from a plan. From gtk.clj's gl-area-apply! (the block wiring :on-render):
(when (and on-render (wire-once! area :on-render))
(connect! area "render"
(ffi/foreign-callable (fn [a _] (on-render a) 1)
[:pointer :pointer] :int :collect-safe)))
The foreign-callable call declares its argument-type vector as [:pointer :pointer] (two arguments) and the function value receives exactly two positional parameters, a (the widget) and _ (discarded). GTK4's real "render" signal is gboolean render(GtkGLArea*, GdkGLContext*, gpointer): three arguments, the middle one a GdkGLContext* this callback never reads. Inherited unmodified from glimmer-gl's own file; see gl-area-widget-layer.md for the same finding in the context of :gl-area's other non-standard signal shapes ("resize"'s extra int arguments, on-tick's frame-clock callback that isn't a signal at all).
Why this is harmless, not merely "hasn't broken yet." The declared type list only shapes this one callback's own entry stub; GTK's signal-emission code is what decides how many arguments to pass, and it passes exactly what the real "render" signal always passes (three) regardless of what the callback declares it wants. On the C calling conventions this project's native targets use, the caller places arguments into fixed positions (registers, then the stack) before the call happens, and the callee reads however many of those positions its own declared signature asks for. A callback that reads only the first two never touches whatever position the third argument landed in: nothing is misread and nothing shifts, because the widget pointer is still argument 1 and the discarded user-data pointer is still argument 2 on both sides. The only real cost is informational: this callback could read the GdkGLContext* (to assert the expected context is current, say) and currently doesn't.
:gl-area's dev-time hiccup warning cannot be silenced from application code:gl-area's :on-realize/:on-render/:on-resize/:on-tick/etc. prop keys all start with the two characters "on", exactly the pattern glitter.core's ported-from-Replicant hiccup validation flags as a probable :on {} mistake, printing a warning once per flagged key, per render. The warning is purely cosmetic: glitter.widget/apply-props! still receives and applies the value correctly regardless (see gl-area-widget-layer.md), but for a GL scene re-rendering every frame, that's a lot of console noise, and there is no way to exempt one widget's non-event on-* props from the check.
glitter.env/configure!, or (configure! :glitter/asserts? false), looks like the obvious escape hatch, and it genuinely works, but only if it runs before glitter.core is first required anywhere in the process. No application namespace's own -main can arrange that on its own.
Why: glitter.assert's enter-node/assert are macros, and the (when (assert? ) ...) gate inside them runs at MACROEXPANSION time: once, when glitter.core itself is compiled, not per-call at runtime. Any namespace that :requires glitter.app/glitter.gtk (both pull in glitter.core transitively) triggers that compilation while processing its own ns form, before a single line of that file's own code, -main included, ever runs. By the time -main calls configure!, glitter.core's macros have already expanded with asserts baked in.
Verified live, not just read off the source and assumed: a two-line probe, (configure! ...) followed by (require '[glitter.core]), confirms the ordering directly. Calling configure! first and only then requiring glitter.core leaves glitter.assert/assert? reporting false afterward, exactly as asked. Calling them in the only order a single namespace's own -main can actually achieve (its own ns form's :require of glitter.app/glitter.gtk has already run before -main exists to call anything) leaves assert? at its default true: the config change never gets a chance to land before the macros have already expanded.
Why left as-is: the only way to make configure! actually take effect is a genuinely separate bootstrap namespace: one that requires only glitter.env, calls configure! first, and only then dynamically requires the real application namespace, so glitter.core's compilation happens after the config change instead of before. Neither plasma.clj nor gl_area_smoke.clj ships one; the added complexity of a second entry-point file per app wasn't judged worth it for a cosmetic wart. Both examples' own comments near -main acknowledge the same tradeoff. Full mechanics: gl-area-widget-layer.md.
:scale is deliberately not registeredglimmer-gl.gtk ships its own :scale widget. glitter-gl.gtk does not port it. That is deliberate (invariant #6 in CONTRIBUTING.md): glitter already has a richer, first-party native :scale (min/max/step/value/digits/ draw-value, with :on-value-changed already wired through glitter's own standard signals table), and porting glimmer-gl's version alongside it would silently conflict with, or shadow, the one glitter already provides.
This is a scope decision, not a gap, but it belongs on this page because a reader porting glimmer-gl application code will go looking for glitter-gl.gtk's :scale and not find it. If that's you: :scale is registered by glitter itself, not by this library, and it already covers what glimmer-gl's version does, plus more.
examples/glitter_gl/check.clj asserts :scale is registered (by glitter, not by glitter-gl) as a load-order sanity check that this project doesn't accidentally shadow it in some future change.
gl-area-widget-layer.md: the :gl-area widget in depth, including the "render"/"resize"/on-tick signal shapes and the full dev-time-warning trace.scene-and-app.md: reactive-area's design and its "honest status" note.examples.md: why the shipped plasma demo wires :gl-area directly rather than through reactive-area.CONTRIBUTING.md (repo root): the ten numbered invariants this project does not regress.glitter-gl's source falls into three sourcing buckets. NOTICE.md (repo root) is the authoritative, maintained ledger; this page explains what the buckets mean, walks through the procedure that keeps the largest bucket trustworthy, and summarizes the one real correction found along the way. If this page and NOTICE.md ever disagree, NOTICE.md wins; see "Keeping NOTICE.md current" below.
For what these files actually do (the three groups, where a mesh's data changes shape on its way to the GPU, why the matrices are column-major, shaders-as-data), see geometry-and-shaders.md, which this page links back to.
The entire geometry/matrix/mesh/shader/GL layer is a namespace-rename port, and nothing else, of the equivalent files in glimmer-gl. Per NOTICE.md's own porting ledger:
vector.clj, vec2.clj, matrix.clj, quaternion.clj, aabb.clj, rect.clj, circle.clj, line.clj, plane.clj, triangle.clj, sphere.clj, polygon.clj, bezier.clj, intersect.clj (14 pure geometry/math files); mesh.clj, glmesh.clj, primitives.clj, polyhedra.clj (4 mesh files); shader.clj, gl.clj, offscreen.clj, renderer.clj (4 GL-plumbing files). 14 + 4 + 4 = 22, plus every corresponding test file except renderer.clj's (glimmer-gl ships no test for renderer.clj; renderer_test.clj here is new; see Bucket 3).
gtk.clj, scene.clj, app.clj carry glimmer-gl's design forward but are not mechanical renames: each was redesigned against glitter's own architecture (its state-atom model has no reactive cells; its widget props arrive through :apply, not :connect). geometry-and-shaders.md and scene-and-app.md cover what each does; this page's job is only the provenance.
Two more files in examples/glitter_gl/ sit in the same bucket for a different reason: they're ports of a different upstream, the glimmer-gl-app jolt example's gl_demo/*.clj, not glimmer-gl itself. plasma_shader.clj and check.clj are near-verbatim; plasma.clj (from gl_demo/core.clj) is a real adaptation, rewriting its reactive-cell control panel as glitter's state-atom-plus-action-dispatch pattern while keeping the GL render loop's direct state-atom read/write.
Not present in glimmer-gl at all: examples/glitter_gl/gl_area_smoke.clj (the live-GTK smoke exercising :gl-area construct/realize/render/resize), test/glitter_gl/renderer_test.clj, and test/glitter_gl/app_test.clj. glimmer-gl ships no test for either renderer.clj or app.clj, even though renderer.clj's own source is a verbatim port (Bucket 1).
NOTICE.md also separately tracks a fourth kind of material: tooling config (.clj-kondo/hooks/jolt_ffi.clj, .clj-kondo/config.edn, .lsp/config.edn, bb.edn task bodies, scripts/check_positional_args.clj) adapted from glitter itself. Same author, same org, no license file, no attribution obligation, so NOTICE.md lists it for provenance only, not because it needs a grant. It isn't one of the three source-code buckets above and isn't repeated here in detail; see NOTICE.md's own entry.
Chasing where a geometry function actually came from takes two hops, not one: glitter-gl ports from glimmer-gl, and glimmer-gl ports from thi.ng/geom (Karsten Schmidt, Apache License 2.0). NOTICE.md's own attribution paragraph names the specific thi.ng/geom modules this project's Bucket 1 derives from: the matrix arithmetic, cofactor inversion, and constructor formulas (thi.ng.geom.matrix); the mesh model, tessellation, and primitive vertex/face definitions (thi.ng.geom.{basicmesh,utils,cuboid, tetrahedron,sphere,plane}); and the shader-spec model (thi.ng.geom.gl.shaders). A reader who wants the original design rationale for any of Bucket 1's 22 files (not just what changed in transit) has to go past glimmer-gl to thi.ng/geom itself; glimmer-gl's own copies are themselves unmodified ports and don't add commentary of their own.
Bucket 1's 22 files are large (gl.clj alone is over 400 lines) and "trust me, I only renamed the namespace" isn't a claim a reviewer can verify by reading a diff of glitter-gl against nothing. The procedure used at port time was a mechanical check rather than a manual review: take the glimmer-gl source file, apply the namespace-rename substitution mechanically (;; sketch, not from the source):
;; illustrative only, not a script that lives in this repo
(sed 's/glimmer-gl/glitter-gl/g' path/to/glimmer-gl/src/glimmer_gl/mesh.clj)
and diff the result against the same substitution applied a second way (e.g. via a different tool, or reapplied to the file as it landed in this repo). If the two diffs agree, the only difference between the glimmer-gl source and the glitter-gl file is the namespace rename; nothing else moved. This is what makes "verbatim port" a checkable claim about a specific commit rather than an assertion to take on faith: it catches a smuggled logic change the same way a checksum catches a corrupted download, independent of how carefully anyone proofread the diff by eye.
This is invariant #1's substance in CONTRIBUTING.md, and the single most likely thing for a future contributor to get wrong, in either direction.
The Standard Verbatim Port Procedure above was a point-in-time verification, run once, at the moment each of the 22 files was ported. It was never a promise to keep those files byte-identical to that sed output forever. A project-wide clojure-lsp format / clean-ns pass, run across the whole codebase, Bucket 1 included, to keep the git pre-commit hook's format --dry gate meaningful, changes whitespace and :require ordering only. It cannot change logic: that's what the tool does by construction, not a property this project has to verify per-file the way the sed-diff check verifies a hand-edited port. Running it across the 22 verbatim-port files does not violate "do not improve them," because reformatting isn't improving: there's no behavioral decision being made, and the same source expression compiles to the same code before and after.
Getting this wrong runs in both directions:
clean-ns" won't go looking for it.This project isn't the first to hit this tension: glitter's own docs/guide/testing-and-tasks.md records an identical reversal for its own Replicant-ported files, for the same reason.
A genuine behavioral change to one of Bucket 1's 22 files (not a rename, not a reformat) is not something the port procedure, or the formatting exemption, cover at all. It requires:
NOTICE.md entry recording what changed and why, in the same commit as the code change, not as a follow-up.CONTRIBUTING.md's own "Licensing" section states the same requirement from the contributor's side: moving code between the verbatim/adapted/new buckets, or introducing a new upstream source, means updating both NOTICE.md and this page in the same PR.
Bucket 2's gtk.clj carries the project's one significant live-found correction: :gl-area's realize/render/resize/tick/motion/key/button handlers wire from the widget spec's :apply closure, guarded idempotent per [area event], not from glitter.widget's :connect hook, even though :connect's own docstring names a GtkGLArea's realize/render/resize as its motivating example. Under glitter's actual reconcile flow, :connect never sees an element's real hiccup props, so a handler wired through it silently never fires; :apply is called once per prop key, both at construction and on every re-render, and does see them. This isn't a rename-vs-behavior question: gtk.clj was never a Bucket 1 verbatim file, and the original adapted design (before the correction) called for :connect in the first place. Full mechanics, including the exact create-node/set-attributes code path traced to prove :connect doesn't work here: gl-area-widget-layer.md.
NOTICE.md currentAny new ported/adapted file, or any new deviation in an already-ported file, gets a line added to NOTICE.md in the same commit as the code change, not as a follow-up. NOTICE.md is what a downstream consumer or license auditor actually reads; this page is context for a contributor trying to understand the shape of that ledger, not a substitute for it. Where the two disagree, NOTICE.md wins.
geometry-and-shaders.md: what these files do, grouped by the three-way split (pure geometry/math, mesh, GL plumbing), including the mesh β GL data-shape trace.gl-area-widget-layer.md: the full :apply-vs-:connect correction referenced above.CONTRIBUTING.md (repo root): the ten numbered invariants, including invariant #1's summary of this page.NOTICE.md (repo root): the authoritative, file-by-file ledger.glitter-gl.scene and glitter-gl.app are the two files that let a GL pane be authored declaratively (a hiccup-shaped scene tree in, a compiled render plan out) instead of hand-writing GL calls per frame. Both are adapted from glimmer-gl, not verbatim ports, and the adaptation is where four of CONTRIBUTING.md's ten invariants (#3, #4, #5, #9) live. This page is the detailed version of what those invariants state in summary.
glitter-gl.scene's mini-hiccup dialect is not glitter's hiccupA scene tree looks like glitter hiccup (vectors, keyword tags, a props map) and mostly behaves like it: [:group {:transform m} & children] threads a world matrix, [:mesh {...}] and [:light {...}] are leaves, [:camera {...}] is collected once. But glitter-gl.scene/expand recognizes a second kind of vector head that glitter's own hiccup does not:
;; scene.clj: expand
(fn? head) (expand (apply head (rest node)))
[my-component args...], a vector whose first element is a function value, not a keyword, is a component invocation. expand calls it, takes whatever hiccup it returns, and recursively expands that. This is exactly Reagent's/glimmer's component convention, deliberately carried over into this one corner of glitter-gl even though the rest of the project follows glitter's stricter rule. scene_test.clj pins the behavior directly:
(deftest component-invocation-expands-to-native-hiccup
(let [box (fn [material] [:mesh {:geom ::cube
:material material}])
items (:items (scene/flatten (scene/expand [box :stone])))]
(is (= [::cube] (map :geom items)))
(is (= [:stone] (map :material items)))))
and nested-components-compose confirms it recurses: a component returning a tree that itself contains more [fn args...] invocations expands all the way down to native nodes, with group transforms still threading correctly to the leaf mesh.
Say both directions, because both are real traps:
[my-fn args...], always call (my-fn args...) directly and splice the result") into a scene tree will not write broken code (calling plainly and splicing the result still works fine here too, since expand-children walks and expands whatever it's handed). But they will write needlessly constrained code: scene trees were built to support the [component args...] shape as a first-class authoring style (see nested-components-compose) and avoiding it means giving up the one place in this codebase where that Reagent-style ergonomic is actually available and intended.glitter.hiccup/hiccup? requires a literal keyword in position 0; a vector whose head is a function value fails that check entirely and is treated as an opaque child value, stringified via str rather than expanded or rejected. No exception. The failure is silent: the UI renders literal text like [#object[my_ns$shape_button 0x1234 "..."] "Cube"] where a button should be. This happened for real in examples/glitter_gl/plasma.clj's control-panel, whose shape-button calls were originally bracket-wrapped.The reason the two dialects can afford to disagree is that they're consumed by different code entirely: scene hiccup is compiled by glitter-gl.scene/flatten into a render plan for glitter-gl.renderer, and never touches glitter.core/reconcile. Mixing the two mental models is the mistake, not either model on its own.
glimmer-gl's originals wrap the compiled scene plan in a glimmer.ratom/reaction, so a scene only recomputes when a cell it actually dereferenced changes, dependency-tracked, like the rest of glimmer's reactive model. glitter has no equivalent machinery: there is no per-node dependency tracking anywhere in glitter.core. Its state-atom watcher (glitter.gtk/mount!) already recomputes the whole state -> hiccup view on every change, unconditionally. Given that, building a scene the same dependency-tracked way glimmer.ratom does would be solving a problem glitter's own model doesn't have, so plan doesn't try:
;; scene.clj: docstring elided, code otherwise verbatim
(defn plan
...
[state scene-fn]
(flatten (expand (scene-fn state))))
No reaction, no deref-tracking, no cache. It's a plain function: call it, get a plan, done. scene_test.clj's plan-reflects-the-current-state-on-each-call and plan-reflects-the-latest-of-many-state-values both confirm this by calling plan twice with two different state maps and asserting the second call reflects the second map. "Recomputes when state changes" here just means "call it again with the new state," the same top-down re-render model glitter itself uses for view. Frame this as matching the host's model deliberately, not as a missing feature relative to glimmer-gl: a reaction wrapper here would add bookkeeping for a short-circuit glitter's own reconciler never offers the caller anyway.
reactive-area's own docstring states the contract plainly for :on-tick: "optional app animation policy; runs each frame before render. Mutate state-driving cells here." Concretely, app.clj's reactive-area installs thin wrapper closures for :on-tick/:on-motion/:on-key/:on-button that each delegate straight to the caller-supplied opts function:
;; app.clj
:on-tick
(or (:on-tick opts) (fn [_area]))
:on-motion
(when-let [m (:on-motion opts)]
(fn [area x y]
(let [w (double (max (long (gtk/widget-width area)) 1))
h (double (max (long (gtk/widget-height area)) 1))]
(m (- (/ (* 2.0 (double x)) w) 1.0)
(- (/ (* 2.0 (double y)) h) 1.0)))))
reactive-area itself never touches the shared state atom except to deref it inside :on-render. The contract, and the reason this page exists, is that the app's own :on-tick/:on-motion/:on-key/ :on-button functions are expected to swap!/reset! that same state atom directly, not to return [[:action/foo ...]] tuples the way glitter's :on {:click [...]} handlers do. :on-render then derefs state fresh on every GTK-driven call, so whatever those handlers changed is picked up on the next frame with no dispatch step in between.
Why the deliberate departure from glitter's one-atom/pure-view/ action-dispatch model: that model's entire value is separating "what happened" from "what changed" behind an inspectable, replayable data structure, worth paying for on a button click, worth nothing on a value that can legitimately change 60 times a second (a camera following the pointer, a clock advancing every tick). Routing per-frame motion deltas through swap! β full hiccup recompute β diff β dispatch would spend the reconciler's cost on state a :gl-area element doesn't even re-render through when it changes (see architecture.md's "How a frame actually happens": state changes don't drive :on-render at all; the frame clock does).
This isn't glitter-gl inventing an undisciplined escape hatch from nothing, either. glitter's own dispatch system already has a documented precedent for values that can't be expressed as static action-tuple data: a value-bearing GTK signal (a slider's "value-changed", an entry's "changed") carries its live value in the dispatched event map under :glitter/value, read back as (get-in event [:glitter/dom-event :glitter/value]), precisely because the action tuple itself is fixed at the moment view runs, before a value that only exists once the user acts can be known. That pattern still dispatches through the normal *dispatch* path; only the value travels outside the static tuple. reactive-area's handlers go one step further and skip dispatch entirely, which is the right call specifically because GL-plumbing state changes far more often, and far less discretely, than a slider release.
Demo code mirrors the same split. examples/glitter_gl/plasma.clj's on-realize/on-render/on-resize/on-tick read and write plain atoms (clock, viewport, gl-state) directly, while its control-panel (ordinary buttons, sliders, a checkbutton) dispatches [[:effect/assoc-in ...]]/[[:action/toggle-paused]] tuples exactly like any other glitter view.
Invariant #4 says reactive-area's handlers read and write the shared state atom directly, and that's real: :on-tick's own docstring contract is to "mutate state-driving cells here." What that contract doesn't spell out on its own is that which atom a caller chooses to tick from :on-tick has a cost, and the cost differs depending on the answer.
glitter.gtk/mount!, the function every example's -main calls to start the reconciler except check.clj (headless, never mounts) and plasma_shader.clj (no -main at all), installs a watcher on whichever state atom it's handed:
;; glitter/src/glitter/gtk.clj:409
(add-watch state-atom ::render (fn [_ _ _ state] (app/on-gui (fn [] (render! state)))))
Any swap!/reset! on that specific atom fires this watcher, which re-runs view and reconciles the whole hiccup tree, whether or not anything outside the GL pane actually depends on what changed. examples/glitter_gl/orbit.clj advances orbit phase from :on-tick with (swap! state update :t + frame-dt) on exactly that atom: orbit.clj calls (gtk/mount! window view state) and passes the same state into reactive-area, deliberately, so that scene-fn is a genuine function of state rather than a closure over a private mutable, a deliberate choice for this example. Every tick therefore drives a full view β reconcile pass, not just the GL render loop.
plasma.clj, ripple.clj, knot.clj, gears.clj and textured.clj don't pay this cost, and not by accident: all five advance a private clock atom from their own :on-tick, outside glitter's state entirely. glitter.gtk/mount! never watches clock, so nothing outside the render loop reruns when it changes. picking.clj doesn't pay it either, for a related but distinct reason: it has no clock atom at all, and its own :on-tick is a deliberate no-op (see examples.md); :on-motion resets a private pointer-pos atom instead, which mount! never watches either.
Neither choice is wrong. orbit.clj is the honest exercise of reactive-area, whose own docstring says it's "driven by glitter's shared top-level state atom" by design; a demo that routed around that to dodge the cost would prove nothing about the thing it exists to prove. But the cost is real and worth naming for anyone reaching for reactive-area in a busier reconciled tree than a single :gl-area pane: the recorded frames and the live run showed no visible stutter, but that's a single-widget view, not a claim the same approach scales once view has real work to do outside the GL pane. See limitations.md for the rest of what orbit.clj did, and didn't, verify.
Every other glitter widget's :apply closure re-applies on every render: that's how :scale's min/max/step pick up new values each time view returns a different range. :gl-area is a deliberate exception for its event-handler props, and the guard that makes it one lives in gtk.clj:
;; gtk.clj
(defonce ^:private wired (atom {}))
(defn- wire-once!
"True the FIRST time `event` is seen for `area`; false (and no side effect)
on any repeat call. See the correction note above :gl-area's :apply for why
this guard is necessary."
[area event]
(let [seen (get @wired area #{})]
(when-not (contains? seen event)
(swap! wired update area (fnil conj #{}) event)
true)))
Verified against the source, not recalled: wired is a single atom holding a map from area (the raw GtkGLArea pointer) to a set of event keywords already connected for that widget. wire-once! reads that per-area set (seen), and only proceeds (updating the set and returning true) when event is not already a member. Every call site in gl-area-apply! gates on this exact function, e.g. (when (and on-realize (wire-once! area :on-realize)) ...). So while the literal map key is area alone (with event living inside that entry's set), the effective guard is keyed on the pair: a second :apply call for the same [area event] combination finds event already in seen, wire-once! returns nil, and the when guarding the connect!/gtk-widget-add-tick-callback call short-circuits: no new signal connection, no side effect, silently.
What this means in practice: the closure connected on an event's first arrival is the one that runs for the widget's entire life. If a caller invokes reactive-area again (say, on every render, expecting a fresh set of opts/closures to replace the stale ones), the new closures are simply discarded; the original ones keep firing. There is no exception, no log line, nothing observably different about the call that failed to take effect. This differs from the general :apply mechanism (every prop key re-applies on every render, which is what lets :scale re-range live) specifically for :gl-area's seven event-handler props. :version/:depth-buffer are NOT gated this way and do re-apply each render, since they're plain value-setting FFI calls with no signal-connection cost to guard against.
The practical rule: build a :gl-area prop map, directly or via glitter-gl.app/reactive-area, exactly once, at a stable call site, and reuse that same result across every render. Never call reactive-area fresh inside a view function expecting each render's new closures to take over; they will not. Full mechanics, including the :connect-vs-:apply correction this guard exists to work around: gl-area-widget-layer.md.
reactive-area actually is: an honest status notereactive-area is real, adapted (not verbatim-ported) code with direct unit coverage: app_test.clj asserts it returns a :gl-area prop map with all four standard handler keys present as functions, that its defaults match the documented ones ([3 2] version, depth buffer on, hexpand/vexpand true), and that explicit opts override them. It also unit-tests keyval->kw's GDK-keyval-to-movement-keyword mapping in isolation.
What changed this arc: examples/glitter_gl/orbit.clj now mounts reactive-area against a real :gl-area and renders a frame through it: six solids orbiting a lit, shadowed ground plane, driven by glitter's own shared state atom. It's the only example built specifically to exercise reactive-area; every other example (plasma.clj, ripple.clj, knot.clj, gears.clj, textured.clj, picking.clj) wires :gl-area directly instead, matching plasma.clj's own upstream source: its docstring describes itself as ported from gl-demo.core, with "the GL render-loop plumbing (on-realize/on-render/on-resize/on-tick) ... otherwise unchanged". orbit.clj mounted and rendered correctly on the first version that actually ran: no crash, no black window, no stalled scene. reactive-area is consequently verified at the unit level (its prop map shape, its defaults) AND, for the one scene shape orbit.clj builds, at the integration level: a real render loop actually driving a real :gl-area through it. It doesn't exercise every opt reactive-area accepts (:fog, :shadow-bias, :depth-spec/:lit-spec, :on-motion, :on-key, :on-button), and it pays a real cost for ticking glitter's watched state atom that a busier reconciled tree would need to weigh (see "Ticking glitter's state atom..." above). See limitations.md for this project's other known gaps.
glitter-gl has two layers of verification that catch different classes of bug: a headless unit suite, and bb smokes, a live-GTK smoke that opens a real window, immediately followed by a second headless check. Both matter for the same reason glitter's own guide gives: this project's one real bug (:gl-area's handlers silently never firing under the :connect hook glitter's own docstring recommends) was "obviously correct" against the headless suite and wrong only when actually run against live GTK. See examples.md for what each of the two bb smokes steps individually pins.
jolt -M:test / bb testtest/glitter_gl/test_runner.clj is the entry point (deps.edn's :test alias points -m at it). It requires 24 test namespaces, one per src/glitter_gl file except gtk.clj (which needs a live widget registry and is exercised by the smoke instead, not a headless test), runs clojure.test against all of them, and calls (System/exit code) directly on failure rather than any resolve-guarded exit path.
Run it:
jolt -M:test # or: bb test
Measured just now:
Ran 178 tests. 559 assertions passed, 0 failures, 0 errors.
----
tests: 178 assertions: 559 passed / 0 failed
That's 178 deftest forms and 559 individual assertions. Don't multiply one by the other; a single deftest built on clojure.test/are can contribute many assertions under one test count. If this number drifts in your own run, re-measure with bb test 2>&1 | tail -3 rather than trusting this page: it's a snapshot, not a promise.
offscreen_test.clj: the suite is not purely in-memoryAlmost everything else in the suite runs against pure data (vectors, matrices, mesh buffers, generated GLSL strings) with no GPU involved at all. offscreen_test.clj is the exception: it asks GDK for a context bound to the display rather than to a window surface (gdk_display_create_gl_context, GTK 4.6+), compiles a real shader program against it, renders one triangle into an RGBA32F texture, and reads the texel back: a real render-to-texture round trip, not a mock of one. The test asserts on exact float values ([0.25 0.5 0.75 1.0]), which only works because those specific numbers are exact in binary32; that's a sharper assertion than anything relying on interpolation tolerance would give.
Measured on this machine, from the same bb test run above:
offscreen GL 4 . 1 Apple M1 Pro
What it means when this is the test that fails on a new machine: not necessarily that glitter-gl broke. off/ensure-current! is designed to degrade to a printed skip; off/ensure-current!'s own usage comment in offscreen.clj shows the pattern ((if-let [err (:error ...)] (println "no offscreen GL:" err) ...)), and offscreen_test.clj's own docstring states it plainly: it "Skips (with a printed reason) when no display is available", calling a CI box without one a legitimate environment, not a failure. A genuine CI runner with no windowing system, or a GL driver stuck below 3.2 core, is exactly the case this test is built to tolerate rather than fail on. If you see a skip printed here, that's the offscreen path degrading correctly, not the library breaking; a :fail/:error assertion actually firing is the real signal to chase.
bb smokes: the live-GTK smoke and headless checkbb smokes # jolt -M:gl-area-smoke, then jolt -M:check
bb smokes runs gl-area-smoke (a live :gl-area mounted through the real reconciler) and check (headless shader/geometry/registration sanity) in sequence, and stops at the first failure. bb.edn's smokes task is a plain (do (shell "jolt" "-M:gl-area-smoke") (shell "jolt" "-M:check")), and babashka's task runner aborts a task body on the first non-zero-exit shell call, so there's no extra control flow making that happen; it falls out of shell's default behavior.
It's a local gate, not a CI one, for one direct reason: gl-area-smoke opens a real GTK window, and this project has no CI wired up to give it a display (see "CI status" below). Run it yourself before opening a PR; see examples.md for what each of the two checks it runs individually pins.
jolt -M:<alias> vs jolt <task> exit-code trapThis is the single most important operational fact on this page, because getting it wrong doesn't look wrong: a suite that fails silently in CI is worse than no CI at all, since it reports green.
A deps.edn :tasks entry does not propagate its child process's exit status. jolt test (the task shorthand) runs the suite, prints failures to stdout, and still exits 0. jolt -M:test (the -M:<alias> form) correctly exits non-zero on failure. bb.edn's tasks already use the alias form throughout (bb test, bb check, bb smokes, etc. all shell to jolt -M:<alias>, never the bare task name), so this is a trap for anyone bypassing bb and driving jolt directly, not a live bug in this repo's own tasks.
This was originally verified against jolt v0.6.3 (in glitter, where the finding was first made), then reverified against v0.7.23-10-gc50a3717 rather than carried forward on the older claim: a minimal deps.edn with a task shelling to a process that exits 7:
$ jolt fail # task form
EXIT(task form)=0
$ jolt -M:fail # alias form
EXIT(alias form)=7
This is fixed on jolt main. Re-running the same probe under v0.7.27-22-g502008db gives EXIT(task form)=7: jolt now exits with the command's status for a string task body. The fix sits under [Unreleased] in jolt's own CHANGELOG and no tagged release contains it yet, so anyone on v0.7.27 or earlier still hits the swallow. Keep using -M:<alias> until the fix ships in a release. Always use -M:<alias> (or a bb.edn task, which already does) to gate a build or a commit. The bare task form is fine for interactive use where a human is watching stdout directly, and nowhere else.
bb lint / lint:strict clj-kondo (report | propagate exit code)
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 verify pre-commit-shaped gate: lint (report) + test (must pass)
bb check:positional-args / :strict fns with 3+ positional args (report | gate)
bb nrepl [port] jolt nREPL server (default 7888)
bb lint/lint:strict, every bb lsp:* task, and bb verify all need clj-kondo and clojure-lsp on PATH. Without them the unit suite and every demo/smoke still run fine; you just lose the fast local lint/format/clean-ns loop, including the git pre-commit hook below, which depends on both binaries too. bb check:positional-args/:strict and bb nrepl need neither: the first is a plain babashka script (scripts/check_positional_args.clj), the second only needs jolt.
bb check:positional-args flags any function with 3 or more genuinely positional arguments (a leading 1-2 "subject" args before a {:keys [...]} map are allowed), scanning src/glitter_gl only. Its exceptions set is pre-populated with every finding whose only appearance is inside one of the 22 glimmer-gl verbatim-port files: refactoring a ported file's signature to a kwargs map would be a real behavioral change, which invariant #1 reserves for its own reviewed commit, so those are permanent exceptions, not TODOs. The two genuinely-adapted-layer findings (glitter-gl.gtk/connect!, glitter-gl.scene/walk) are deliberately not in that set; they stay flagged as legitimately refactorable.
.clj-kondo/hooks/jolt_ffi.clj: making FFI bindings visible to lintjolt.ffi/defcfn binds a C symbol to a Clojure var. glitter-gl.gl and glitter-gl.gtk are built almost entirely out of calls like this:
(ffi/defcfn gtk-box-new "gtk_box_new" [:int :int] :pointer)
clj-kondo cannot see through a macro it doesn't know, so without a hook every defcfn-bound name reports Unresolved symbol at its definition and Unresolved var at every call site, enough noise across a FFI-heavy library to make the linter useless as a signal. The hook rewrites each defcfn form into an equivalent defn of the same name and arity (derived from the declared C argument-type vector), with a body that's a literal of the declared C return type. That buys clj-kondo three things it couldn't otherwise infer: the var exists, its arity (so passing the wrong argument count, exactly the FFI mistake that would otherwise surface only as a native crash, is now a lint error instead), and a plausible return type for downstream type-checking.
One deliberate deviation worth knowing if you ever port this hook elsewhere: :pointer maps to a number, not nil. glitter-gl.gl and glitter-gl.gtk's own docstrings describe pointers as "plain machine addresses (jolt numbers)," and the codebase leans on that directly: glitter.genum/glitter.widget call zero?/arithmetic straight on :pointer-typed return values. Mapping :pointer to nil (the choice the hook this one adapts from makes, for raylib's opaque-handle pointers) would trip a spurious type-mismatch ("Expected: number, received: nil") against code that's already correct.
This is also why .clj-kondo/hooks/jolt_ffi.clj itself is tracked in git while .clj-kondo/.cache/ is not (see .gitignore): the hook is configuration this project depends on to make bb lint usable at all; the cache is disposable, regenerated analysis output.
bb verify vs the git hook: they check different thingsbb verify bundles a lint report plus jolt -M:test (which must pass) into one command, a convenient manual gate to run before committing. It does not check formatting. The installed git pre-commit hook (bb hooks:install) is a separate, stricter, automatic gate that runs on every git commit: lint errors-only, then clojure-lsp format --dry, then clojure-lsp clean-ns --dry (bb hooks:install:full adds the full unit suite as a fourth step). Concretely: a clean bb verify run tells you the tests pass and lint has nothing new to report; it tells you nothing about whether your file is formatted the way clojure-lsp format wants it. If you've drifted on formatting, bb verify stays green while the commit itself gets rejected by the hook.
This gap is real enough that contributors hit it in practice; it isn't a hypothetical corner case. If a commit is rejected on step 2 or 3 of the hook after bb verify passed clean, that's this gap, not a bug in either tool: run bb lsp:format (or bb lsp:clean-ns) and re-commit.
bb hooks:install / :install:full / :uninstallbb hooks:install writes an executable .git/hooks/pre-commit via spit. It is not tracked in the repo, so each clone opts in with its own bb hooks:install run. The FAST hook (bb.edn's own doc string calls it "~2s") runs clj-kondo errors-only, clojure-lsp format --dry, and clojure-lsp clean-ns --dry. bb hooks:install:full adds a fourth step, the complete jolt -M:test suite, measured on this machine just now at roughly 5.6 seconds wall time (time bb test β 5.586 total), so budget single-digit seconds more per commit with the full hook installed versus the fast one. bb hooks:uninstall deletes the hook file and is idempotent: it reports "no pre-commit hook found" rather than erroring if run twice. git commit --no-verify skips the hook for one commit, useful for a genuine emergency, not for routing around a failure the hook caught correctly.
Not yet wired, deliberately. This is a scope decision, not an oversight, pending the repo going public and free CI runners applying to it. Until then, bb verify plus bb smokes (run by a human, locally, before opening a PR) is the whole gate. CONTRIBUTING.md's "Before you open a PR" section lists the exact four commands.