The AppKit widget layer
glitter-uikit.widget maps hiccup tags to AppKit view constructors and prop appliers; glitter-uikit.appkit drives it from the IRender/IMemory protocols. This page covers the mechanics and the specific AppKit API traps this port hit โ each is a real, live-verified behavior this codebase measured rather than assumed.
The widget registry
(def specs
(atom {:window (window-spec)
:box (box-spec)
:button (button-spec)
:label (label-spec)
:entry (entry-spec)
:checkbutton (checkbutton-spec)
:separator (separator-spec)
:frame (frame-spec)
:scrolled (scrolled-spec)}))
Nine tags, and :hbox/:vbox are not among them. They are sugar, resolved by a separate two-step mechanism rather than by their own specs: aliases maps both onto :box, normalize-tag applies that map, and spec-for looks the normalized tag up โ while with-orientation injects the implied :orientation prop so a bare [:hbox โฆ] lays out horizontally without the caller saying so.
(def ^:private aliases {:hbox :box :vbox :box})
(defn- normalize-tag [tag] (get aliases tag tag))
(defn- spec-for [tag] (@specs (normalize-tag tag)))
Each spec is {:ctor (fn [props] view) :apply (fn [view props]) :container kw}. :container determines how children attach: :box (ordered append/remove/reorder via NSStackView), :window/:frame/:scrolled (single child), or :none (leaf). Concrete view mapping:
| Tag | AppKit view | Container |
|---|---|---|
:window | NSWindow | Single child (pinned to content view) |
:box, :hbox, :vbox | NSStackView | Ordered (horizontal or vertical) |
:button | NSButton (push style) | Leaf |
:label | NSTextField (label style) | Leaf |
:entry | NSTextField (editable, bordered) | Leaf |
:checkbutton | NSButton (switch style) | Leaf |
:separator | NSBox separator (horizontal only) | Leaf |
:frame | NSBox (titled) | Single child |
:scrolled | NSScrollView | Single child (document view) |
create! builds a view end-to-end: construct via the widget spec's :ctor, then apply props via its :apply closure โ :apply is the prop applier, not a separate step that runs after props are applied. create! does no signal wiring at all: unlike glimmer-uikit's original, event lifecycle belongs entirely to glitter-uikit.appkit, which calls IRender/set-event-handler whenever handler data changes between renders โ not on every render, and not for glitter.gtk either, which calls its equivalent under the same "data changed" condition โ see "Where AppKit is simpler" below.
Where AppKit is simpler than GTK
insert-before is single-branch
glitter.gtk's insert-before branches on whether a child is already tracked in the parent's children list, because gtk_box_insert_child_after asserts its child is unparented (gtk_widget_get_parent(child) == NULL) and throws a GTK-CRITICAL + silently no-ops when called on an already-parented child. The real GTK API for repositioning is gtk_box_reorder_child_after.
AppKit's insertArrangedSubview:atIndex: handles both uniformly โ it automatically MOVES an already-arranged subview if you pass one of the stack's own children, so no GTK-style parented/unparented branch is needed. Measured live:
;; Starting state: [A B C]
;; Insert C at index 0
;; Result: [C A B]
;; Count: unchanged
So glitter-uikit.widget/insert-child-after! is a single code path serving both a fresh insert and a keyed move โ but it is not identical to DOM's insertBefore. insertBefore takes a reference node; AppKit's call takes an index, and that index is remove-then-insert internally, interpreted against the post-removal array:
;; Starting state: [A B C D]
;; Insert A at index 3 (A is already arranged, at index 0)
;; Result: [B C D A] -- NOT "insert before whatever is now at index 3"
A fresh insert or a backward move (the child already sits at or after the target sibling) is unaffected, because nothing before the sibling shifted. A forward move (the child currently sits before the sibling) needs the un-incremented sibling index rather than (inc i), or the child lands one slot too far right โ a real bug this port shipped and fixed during final review; see insert-child-after!'s docstring in widget.clj for the full measured detail.
No suppression set needed
glitter.widget carries a suppressing set because GTK's programmatic setters (like gtk_editable_set_text, gtk_check_button_set_active) synchronously re-emit their own signal, which would feed a re-render back into app dispatch.
AppKit does NOT fire action or delegate callbacks for programmatic setState:/setStringValue:/etc., so there is nothing to suppress. This absence is intentional โ do not add a suppression set. The property is asserted live by examples/glitter_uikit/keyed_smoke.clj's programmatic-active-does-not-dispatch test, which sets a checkbutton's state programmatically and verifies no action callback fires.
Where AppKit needs more care than GTK
NSNotFound is NSIntegerMax, not -1, and raises uncaught exceptions
NSStackView's children are indexed via arrangedSubviews array access. indexOfObject: returns NSNotFound (which is NSIntegerMax โ 9223372036854775807 โ not -1 or NSUIntegerMax) when the view is not found. Feeding NSNotFound + 1 to insertArrangedSubview:atIndex: raises an uncatchable NSException that aborts the process. A Clojure catch :default does not intercept it, because Objective-C exceptions do not unwind into Scheme.
Solution: Every index read goes through arranged-index, which checks for NSNotFound/negative results and returns nil for "absent" โ it throws nothing. Every caller (insert-child-after!, replace-child!) is written to handle that nil, falling through to a safe branch (append) instead of ever reaching the arithmetic that would produce a process-aborting index. Verified live โ an index access that misses silently would cascade into worse bugs downstream, so this is load-bearing.
removeArrangedSubview: leaves a plain subview
NSStackView's removeArrangedSubview: removes a view from the stack's arranged list but does NOT remove it from the view hierarchy โ it becomes a plain, unmounted subview still parented to the stack, consuming memory and potentially interfering with sibling layout. A second call to removeFromSuperview is needed to fully detach it.
Solution: remove-child! calls removeFromSuperview after removeArrangedSubview: to complete the detach. Verified live against the actual view hierarchy.
Pointer-keyed registries and cleanup
glitter-uikit.widget maintains several registries (:actions, :changes, :alignments) keyed by view pointer. Views passed to remove-child! or replaced must be explicitly cleaned up via forget-view!, or the registries will grow unbounded and accumulate stale handlers that, because AppKit reuses freed addresses, can be inherited by new views landing on dead views' addresses.
Solution: remove-child! calls forget-view! before removing the view. If cleanup is skipped, a re-created view can silently inherit handlers from a previous view that occupied the same memory.
Model adaptations vs. real defect fixes
The port made three changes that are deliberate model adaptations, not fixes for bugs in glimmer-uikit โ each is required because glitter's architecture differs from glimmer's own, not because the original code was wrong for glimmer:
- Event lifecycle ownership split. Upstream (glimmer-uikit) connects target/action once at mount and lets handlers close over a reactive cell โ correct for glimmer's own Reagent-style model. glitter calls
IRender/set-event-handlerwhenever handler data changes between renders, not just on mount/unmount, and two writers ofsetTarget:/setAction:would fight โ soconnect-signals!was removed rather than adapted, and event wiring moved entirely toglitter-uikit.appkit'sIRender/set-event-handler. GlitterTargetclass registration. Renamed from upstream's"GlimmerTarget"(registered process-wide viaobjc_allocateClassPair) so the two classes are distinguishable if glitter and glitter-uikit ever ran in the same process โ not a bug fix, just a name collision avoided.- Prop filtering on
some?. Upstream'sapply-props!filters on truthiness; here it filters onsome?so an explicitfalse(:active false,:sensitive false) still reaches the view. Needed because glitter's ownapply-props!makes the samesome?choice for the same reason (deviation #3 from Replicant) โ matching the caller's contract, not repairing upstream.
Three further changes ARE real defect fixes โ bugs that would misbehave regardless of which reconciler drives them:
replace-child!captures position before removing. Upstream didremoveArrangedSubview:thenaddArrangedSubview:, which always inserts at the END of the stack โ replacing a non-final child silently relocated it there. This is the identical defect glitter fixed on the GTK side (see glitter's NOTICE.md). Fix: capture the old child's index before removing it, then insert the new child at that same position.insert-child-after!added, then its forward-move index fixed. Absent upstream entirely โ glimmer's reconciler never needs it, but glitter.core'sinsert-beforedoes. The first version carried a real bug of its own:insertArrangedSubview:atIndex:is remove-then-insert internally with a POST-removal index, and the added code incremented the sibling's PRE-removal index unconditionally, so a forward keyed move (the moved child currently sits before its target sibling) landed one slot too far right. Fixed during this arc's final review โ see "insert-before is single-branch" above andinsert-child-after!'s docstring inwidget.clj.arranged-indexadded, and every index read routed through it. Upstream calledstack-index-of!directly and did(inc i)on the result โ a process abort waiting to happen whenever the sibling was absent (NSNotFoundisNSIntegerMax, and feedingNSNotFound + 1toinsertArrangedSubview:atIndex:aborts the process uncatchably). Fix:arranged-indexreturnsnilfor "absent" instead, and every caller handles it.forget-view!added, andremove-child!calls it. Upstream'sactions/changes/alignmentsregistries were never cleaned โ an unbounded leak, and a stale-handler hazard: because AppKit reuses freed addresses, a newly allocated view could land on a dead view's address and inherit its handler.
For full provenance (which file ported from where, every documented deviation), see NOTICE.md.