Architecture
What glitter-uikit is (and isn't)
glitter.core (ported from replicant.core) owns the entire reconciler โ the diff algorithm that decides what changed between two hiccup trees and what to do about it โ plus the IRender/IMemory protocols in glitter.protocols that it drives. Neither namespace knows anything about AppKit, or even that AppKit exists.
glitter-uikit supplies the other half: real AppKit views (glitter-uikit.widget), the IRender/IMemory implementation that wires them into the reconciler (glitter-uikit.appkit), and the app loop that gets an NSApplication running in the first place (glitter-uikit.app). This is a whole alternative renderer โ the AppKit counterpart of glitter.gtk โ chosen at mount!'s call site by which library an app requires, not a widget registered into some shared component registry inside glitter.core itself.
flowchart TD state["state atom"] -->|swap!| watch["add-watch fires"] watch --> ongui["glitter-uikit.app/on-gui<br/>(marshal to the AppKit main thread if needed)"] ongui --> view["(view @state)"] view -->|new hiccup| reconcile["glitter.core/reconcile(renderer, root-el, new-hiccup, prev-vdom)"] reconcile -->|"diffs new hiccup against prev-vdom, issues the<br/>minimal set of protocol calls to bring the<br/>live tree in sync"| protocols["glitter.protocols/IRender + IMemory<br/>(glitter-uikit.appkit implements this for real AppKit views)"]
One reify, not two composed pieces
reify, not two composed piecesglitter-uikit.appkit/renderer implements IRender and IMemory in a single reify form, rather than composing two separately-built pieces. This is deliberate, not incidental: the code's own comment on the point reads
;; IMemory, folded into the SAME reify form rather than composed via
;; metadata: :extend-via-metadata is verified broken under Jolt (see
;; glitter's porting-and-attribution.md). Keyed off the el atom, which is
;; already a stable Clojure identity, rather than the raw view pointer.
The verification itself lives in glitter's own porting notes, not re-derived here: glitter.protocols declares both protocols with :extend-via-metadata true (mirroring Replicant's own replicant.protocols), and Replicant's test helper (replicant.mutation-log) actually relies on that โ composing IRender plus a logging concern via with-meta. Requiring that helper under Jolt and calling its renderer throws No method create-element in replicant.protocols/IRender. :extend-via-metadata simply doesn't dispatch under Jolt; reify does. Both glitter.gtk and glitter-uikit.appkit implement IRender+IMemory together in one reify because of that finding, not by parallel taste.
The el atom: a tracking atom, not a raw pointer
create-element and create-text-node don't hand the reconciler a raw AppKit view pointer. They return a Clojure atom:
{:tag <hiccup tag keyword>
:view <AppKit view pointer>
:children [<child el atom> ...]
:handlers {<event keyword> <handler fn>}}
(mount! builds the same shape by hand for the root element, tagging it :window over the caller-supplied NSWindow pointer.)
There are two separate reasons this is an atom holding a small map, not the pointer itself.
The first is shared with glitter.gtk: glitter.core's reconciler wants an opaque, stable identity per live-tree node to hold across renders โ IMemory's remember/recall key off it, and keyed-list diffing needs something to compare across two renders that isn't just "the same pointer happened to come back." An atom is already a stable Clojure identity, so remember/recall can use it as a map key without any question of whether a Jolt FFI pointer hashes or compares correctly.
The second is AppKit-specific, and it's about registry cleanup, not identity. An NSControl has exactly one target/action slot, and the shared ObjC method implementations that receive its callback โ fire-cb/change-cb in glitter-uikit.widget โ are typed [:pointer :pointer :pointer] :void: (fn [_self _cmd sender] ...). The IMP gets a raw sender pointer and nothing else โ no Clojure closure can travel across that boundary. So dispatch has to go through global, process-wide Clojure atoms keyed by raw view pointer (glitter-uikit.widget/actions, /changes, /alignments), owned end-to-end by glitter-uikit.appkit.
That's a real hazard GTK doesn't share. GTK's per-widget signal connections live on the GObject itself (g_signal_connect_data returns a connection id, g_signal_handler_disconnect uses it) โ there's no external Clojure-side table for a freed widget to leave stale. AppKit's port has exactly that table, and AppKit reuses freed pointer addresses. An un-scrubbed entry in actions/changes/alignments isn't just a leak: a brand-new, entirely unrelated view can allocate at the same address a removed one used to occupy and silently inherit its stale handler. glitter-uikit.appkit/forget-subtree! exists to close that window โ every IRender method that detaches a subtree (remove-child, replace-child's displaced node, remove-all-children) calls it first, and it walks (:children @el) recursively, calling glitter-uikit.widget/forget-view! on every descendant's pointer before that memory can be reused. That walk is only possible because the el atom carries :children โ a raw pointer alone couldn't be walked at all.
:handlers on the el atom itself is a bookkeeping mirror, not a second source of truth: set-event-handler/remove-event-handler write it (swap! el assoc-in [:handlers event] f, swap! el update :handlers dissoc event), but nothing in this codebase reads it back except a test asserting it starts as {}. The live dispatch tables are glitter-uikit.widget/actions and /changes.
mount!'s wiring
mount!'s wiring(defn mount!
[window view state-atom]
(let [r (renderer)
root-el (atom {:tag :window
:view window
:children []
:handlers {}})
vdom (atom nil)
render! (fn [state]
(reset! vdom (:vdom (core/reconcile r root-el (view state) @vdom
{:aliases (alias/get-registered-aliases)}))))]
(render! @state-atom)
(add-watch state-atom ::render (fn [_ _ _ state] (app/on-gui (fn [] (render! state)))))
nil))
The root element is the NSWindow pointer itself, tagged :window โ an NSWindow is a single-child container in this port (glitter-uikit.widget's :window spec has :container :window, and append-child!'s :window branch pins the mounted view to window-content), so the view function's return value becomes the window's one content child, not a replacement for the window.
Every re-render goes through glitter-uikit.app/on-gui, never called directly โ a swap! on state-atom can originate from any thread (an nREPL eval's worker thread, a future), and on-gui is what makes routing that safely onto the AppKit main thread possible. See app-loop-and-threading.md.
Registered aliases (glitter.alias/get-registered-aliases) are merged into every reconcile call automatically via {:aliases ...}, so an app never has to thread its alias registry through by hand.
The event model: data, not a closure wired once
This is the port's central adaptation, and it's worth naming precisely what changed. glitter-uikit.widget was forked from glimmer-uikit, whose Reagent-style model wires target/action once, at widget creation, via connect-signals! โ reading from the widget's own source confirms this: glimmer_uikit/widget.clj has
(when-let [h (:on-click props)] (swap! actions assoc widget h))
called from create!, once, and the stored handler h is a plain closure the caller passed as :on-click. If the click behavior needs to change, the caller re-mounts or re-derefs a reactive cell the closure already closes over โ the target/action wiring itself never changes.
glitter's hiccup carries handlers as data instead: [:button {:on {:click [[:action/inc]]}}]. glitter.core's diff calls IRender/set-event-handler again whenever that data changes between renders โ not just once at creation โ because the action tuples for the same event on the same element can differ across two renders without the event key itself changing. glitter-uikit.appkit's set-event-handler has to actually do something on every one of those calls:
(set-event-handler [_ el event handler _opt]
(let [view (ptr el)
tag (:tag @el)
f (dispatcher el tag event handler)]
(cond
(contains? action-events event)
(do (u/control-target! view w/invoker)
(u/control-action! view (u/sel "fire:"))
(swap! w/actions assoc-in [view event] f))
(= :change event)
(do (u/control-delegate! view w/invoker)
(swap! w/changes assoc view f))
:else nil)
(swap! el assoc-in [:handlers event] f))
nil)
action-events is #{:click :toggled :activate} โ these route through NSControl's target/action slot, redirected to the single shared GlitterTarget instance (w/invoker) and its fire: selector. :change routes through the NSTextField delegate instead (controlTextDidChange:), also on w/invoker. Either way, the actual handler function f lands in a pointer-keyed registry (glitter-uikit.widget/actions or /changes) โ the same structural requirement described above: fire-cb/change-cb receive only the sender pointer, so dispatch has to be a global lookup by that pointer. The el atom's :handlers map gets the same write purely for bookkeeping, as noted above.
dispatcher wraps the caller's handler (a glitter.core-supplied fn of one event map) as the one-arg fn the ObjC callback actually invokes:
(fn [sender]
(let [value-fn (@signal-value [tag event])]
(handler (cond-> {:glitter/node el
:glitter/appkit-view sender}
value-fn (assoc :glitter/value (value-fn sender))))))
:glitter/node is the key glitter.core's own build-event-map reads on Jolt to recover the acting element, since there's no DOM event.target to fall back on. signal-value is a small table of [tag event] -> (fn [view] value) extractors (e.g. [:entry :change] reads control-string) โ every entry re-reads the view's own current property rather than trusting a value the callback happened to carry, which is safe here because AppKit updates a control's property before invoking its action/delegate, mirroring the identical choice in glitter.gtk.
remove-event-handler and clear-target-if-unused! are the other half: removing the last action handler for a view drops its entry from w/actions and clears the control's target back to null via control-target!. Note this clears only target, not the action selector itself (still fire:) โ harmless, since AppKit has nothing to send the action message to once target is null.
The :ctor finding: props always arrive empty
:ctor finding: props always arrive emptyglitter.core's create-node calls IRender/create-element with only an optional XML-namespace hint, never the real hiccup props:
;; glitter/core.clj โ create-node's actual call
(r/create-element renderer tag-name (when ns {:ns ns}))
ns is non-nil only for SVG/foreignObject hiccup, which no AppKit widget in this project ever produces โ so for glitter-uikit, options is always nil at this call site. glitter-uikit.appkit's create-element reflects that directly:
(create-element [_ tag-name options]
(let [tag (keyword tag-name)
view (w/create! tag (or options {}))]
...))
(or options {}) means w/create! โ and therefore a spec's :ctor, and the :apply call create! makes right after constructing the view โ is always invoked with {} through the real reconciler path. The real prop values arrive afterward, one key at a time, through a different path entirely: glitter.core's set-attributes calls set-attr per key (run! over the new attrs map), which reaches IRender/set-attribute once per attribute. glitter-uikit.appkit's implementation forwards each call as a single-key partial map:
(set-attribute [_ el a v _opt]
(w/apply-props! (:tag @el) (ptr el) {(keyword a) v})
nil)
The consequence for anyone writing a widget spec: a :ctor that branches on a prop value is dead code along the real reconciler path โ the widget's actual observable state depends entirely on whether :apply independently handles that same key once set-attribute delivers it. button-spec's :ctor reads (:label p), but p is always {} there; the button's real label comes from :apply's (when (contains? p :label) (u/control-title! w (:label p))), invoked later through apply-props! once glitter.core sends the real :label attribute through. Every spec in glitter-uikit.widget follows that shape โ :ctor builds a bare, presentable view; :apply is what any real prop value actually reaches.