Patches for Observable Drag Detection / Updates - #12
Conversation
|
Thanks for your consideration! This is supporting part of a Linux port for Lyrebird ( https://realizedsound.mooo.com/josh/Lyrebird ), a synthesis and music composition environment written for Swift. |
There was a problem hiding this comment.
🟢 Approval recommended
The change is narrowly scoped to GTKViewHost.rebuild() and correctly re-establishes Observation tracking on the narrow path to avoid widget-tree teardown during active gestures.
Pull request overview
This PR fixes a GTK4 backend interaction bug where @Observable-driven updates during an in-flight drag caused GTKViewHost.rebuild() to fall back to a full widget-tree rebuild, canceling the active GtkGestureDrag and making drag updates appear to fire only once.
Changes:
- Expands the narrow in-place mutation path in
GTKViewHost.rebuild()to run for@Observable-triggered rebuilds as well (instead of forcing a full rebuild). - When the rebuild was triggered by Observation, wraps the narrow-path
describeBodypass inwithObservationTrackingso the one-shot observation subscription is re-registered without tearing down the widget subtree.
File summaries
| File | Description |
|---|---|
| Sources/Backend/GTK4/Rendering/GTKViewHost.swift | Allows Observation-triggered rebuilds to use the in-place mutation path while re-subscribing to withObservationTracking to keep updates flowing during gestures. |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Makes a drag-to-set control (a Canvas-drawn knob / XY pad bound to @observable model state) track continuously on the GTK4 backend, and keeps controls bound to the same value in sync during the drag. Three coupled fixes: 1. @observable narrow path. A gesture onChanged that mutates @observable state took the full-rebuild path (needed to re-register the one-shot withObservationTracking subscription), which tore down the widget tree — cancelling the in-flight gesture. Run the narrow (in-place) describe pass under withObservationTracking so the subscription re-registers without a full rebuild. 2. Live redraw during the drag. The rebuild scheduled by a state mutation runs at idle priority, which the pointer-motion event stream starves, so a Canvas otherwise only repaints on release. On each drag-update, queue_draw the gesture widget's whole subtree (GTK4 reuses a child's cached render node when only an ancestor is invalidated, so the nested DrawingArea must be marked dirty directly). Canvas closures read the value at paint time, so this repaints the new value with no rebuild. 3. Global interaction deferral + linked redraw. With several controls bound to one model, a sibling/ancestor host that observes the same value rebuilds mid-drag and recreates (detaches) the dragged widget. Add a global interaction depth that every host's scheduleRebuild honours, so a drag freezes all rebuilds; deferred hosts are flushed once on drag-end. To keep linked controls live (their observation is one-shot and its re-registering rebuild is deferred), each drag-update also queue_draws every deferred host's subtree, so Canvas controls bound to the dragged value track together. (Native widgets whose value is pushed on rebuild, e.g. GtkScale, still reconcile on release.) GtkGestureDrag emits drag-begin/drag-end once per sequence, so the begin/endGlobalInteraction bracket pairs. Global state is touched only on the GTK main thread.
During a gesture drag every host defers its rebuild (so a sibling/ancestor rebuild can't recreate and detach the dragged widget). Canvas hosts still track live via a per-drag-update queue_draw, but NATIVE widgets bound to the dragged value (e.g. a TextField's GtkEntry) stayed frozen until drag-end, because their value is pushed to the widget only during the deferred rebuild. Fix: on each drag-update, run the existing narrow (text/color/canvas) in-place mutation path for every deferred host, without the full-rebuild fallback. The narrow path never recreates a widget, so it cannot detach the in-flight gesture; structural changes stay deferred to drag-end (a failed narrow attempt leaves the retained descriptor untouched, so the drag-end rebuild still picks them up). - Extract rebuild()'s narrow-mutation block into tryNarrowMutation(fromObservation:) -> Bool (behavior-preserving; rebuild() now calls it and returns on success). - Add applyDeferredNarrowMutationDuringInteraction(), which runs tryNarrowMutation without falling back to a full rebuild; observationDidFire is left set so the deferred drag-end rebuild still re-registers observation. - redrawDeferredInteractionHosts() now applies the narrow mutation (snapshotting the host set first, since a describe pass can re-register observation that re-inserts into the dict) before the queue_draw.
…updates)
A TextField had no descriptor kind, so it described as an empty .composite —
which gtkCanApplyTextColorHostMutation's .reuse case explicitly rejects. That
poisoned the narrow-mutation path for the ENTIRE host containing a TextField: no
node (not even a sibling Text label) could ride the narrow path, so anything
bound to a value changing during a drag reconciled only at mouse-up.
Add a .textField descriptor kind so a TextField's text is visible in the
descriptor tree and a change applies in place:
- GTK4TextFieldDescriptor { text, placeholder }; .textField kind + props case.
- .textFieldValue update intent; gtkUpdateIntent plans it when only the text
differs (placeholder change falls back to a full rebuild).
- gtkTextFieldValueHook -> gtkSetTextFieldValue: sets the hosted GtkEntry's text
via gtk_swift_editable_set_text, guarded by gtk_widget_is_focus (never clobber
a typing user's caret) and a text-equality check (skip redundant notify::text).
Returns true in the skip cases so the narrow path doesn't fall back and recreate
the entry mid-interaction.
- .textFieldValue added to the narrow-path gate + gtkAllSlotsValid slot check.
- GTK4HostedNodeKind.textField + tag/read + gtkHostedKindForDescriptor mapping;
gtkCollectSupportedHostedWidgets includes it. TextField.gtkCreateWidget marks
the entry; TextField now conforms to GTKDescribable.
Pairs with the per-drag narrow-mutation pass (f21316a): a TextField bound to a
value being dragged (a slider's gain) now tracks live, not just at mouse-up.
…ow path)
A modifier view that wraps one `content` but has `Body == Never` and no
gtkDescribeNode described as an EMPTY `.composite`, which the narrow-mutation gate
rejects — poisoning the ENTIRE host's narrow path (and dropping the wrapped node
from the descriptor tree). So a Text/TextField wrapped in .onChange / .focused /
.onSubmit / .textFieldStyle / … could never narrow-update; the whole host fell to
a full rebuild on every change.
Add protocol GTKContentWrapper { var gtkWrappedContent: any View }, checked in
gtkDescribeView before the empty-composite fallback: a conformer describes its
wrapped content as a composite-with-[child], so the child stays in the descriptor
tree and narrow-applicable (extra composite wrappers don't affect leaf slot
pairing — same as DragGestureView's existing transparent describe).
Conform the 42 Body==Never single-content wrappers (onChange/onSubmit/focused/
lifecycle/style/text/gesture/environment modifiers) — one line each; plus
MonospacedDigitView (harmless: it has a real body, same result). OverlayView gets
an explicit gtkDescribeNode describing both content + overlay.
This is Phase 1 of the narrow-path generalization (see the Lyrebird repo's
spikes/SwiftOpenUIKnob/NARROW_PATH_GENERALIZATION_PLAN.md). Phase 2 (opaque leaf
widgets: Toggle/Stepper/Picker) is separate.
…rrow path
Phase 2 of the narrow-path generalization. An opaque native widget (Toggle,
Stepper, Picker, a filled/stroked Shape, gradient, EmptyView) had no descriptor
kind and described as an empty .composite — which the narrow gate rejects,
poisoning the whole host (so LyrebirdEngineStatusView could never narrow-update
even after Phase 1's transparent-describe).
Add a generic `.opaqueLeaf` kind + GTK4OpaqueLeafDescriptor { signature } and a
GTKOpaqueLeaf { var gtkStateSignature: AnyHashable } protocol, checked in
gtkDescribeView after GTKContentWrapper. An opaque widget now describes as
.opaqueLeaf carrying a hash of its bound state: an unchanged signature .reuses
(passing the gate — not an empty composite), a changed one plans .none (via
gtkUpdateIntent) the gate rejects, forcing a full rebuild. Correct on real state
changes, non-poisoning otherwise. No hosted-kind/slot (it's not narrow-updated).
Conformances: Toggle (isOn+label), Stepper (value+range+step+label), Picker
(selected+options+label), FilledShape/StrokedShape (fill colour), and the static
shapes/gradients/EmptyView (constant signature; a type swap is caught by the
descriptor typeName). Signatures capture exactly the bound state that changes a
widget's appearance so a programmatic change still rebuilds.
Together with Phase 1 (e0c93b7) this lets a full panel of native controls
narrow-update. See NARROW_PATH_GENERALIZATION_PLAN.md in the Lyrebird repo.
`if/else` in a ViewBuilder makes a _ConditionalView; a bare `if` / `if let` makes an Optional<some View>. Both were GTKRenderable-only, so they described as empty `.composite`s — poisoning the host's narrow path. Any view with a conditional (nearly every real panel) therefore never narrow-updated, even after Phases 1 and 2 fixed the modifiers and opaque widgets. Conform both to GTKContentWrapper, describing the ACTIVE branch (Optional.none → EmptyView). A branch/optional flip changes the child type and is caught as a structural change (rebuild); a stable condition — as during a drag — reuses, so the host stays narrow-applicable. Completes Patch F: a full native panel (LyrebirdEngineStatusView: buttons, Picker/Stepper/Toggle, shapes, conditional sections, a bound TextField) now narrow-updates in place, so the TextField tracks a live drag.
During a drag, when the narrow path is about to be rejected, log the offending plan nodes (action + descriptor kind + typeName) so we can see which view type still poisons a host instead of guessing. Env-gated + deduped. gtkCollectNonNarrowReasons mirrors gtkCanApplyTextColorHostMutation's accept set.
Log the whole tryNarrowMutation outcome during a drag (gate reject / slots-invalid / hook-failed / APPLIED) plus a per-pass deferred-host count, so we can tell whether the interaction pass runs on the host at all, and if the gate passes whether the mutation actually applies. Separate dedupe keys. Still env-gated.
gtkCaptureSupportedNativeSlots assigns NO slots when the supported-descriptor count != supported-widget count, which later surfaces as [narrow-slots-invalid]. Log the two counts + their kinds under SWIFTOPENUI_NARROW_DEBUG so we can see which leaf is unbalanced.
A Button with a custom label (Button { } label: { HStack { Text… } }) renders
the label view tree as child widgets — its Text/Canvas leaves get marked and
collected during slot capture — but Button described itself as a CHILDLESS
.button leaf. So the supported-widget count exceeded the supported-descriptor
count, gtkCaptureSupportedNativeSlots bailed (no slots assigned), and every later
narrow update in the host saw a nil slot ([narrow-slots-invalid]).
Describe the label's children when the label is not a native Text (a Text label
becomes the GtkButton's own label — no separate hosted widget — so it stays a
childless leaf). Rendering and describing the same label tree now yield matching
leaves, so slot capture pairs them and the host's narrow path works. This was the
last blocker for a full native panel narrow-updating during a drag.
Strip the SWIFTOPENUI_NARROW_DEBUG logging (gtkNarrowDebugEnabled, gtkCollectNonNarrowReasons, the tryNarrowMutation/redraw/slot-capture log points, dedup statics) now that the full-panel narrow path is validated on display. The functional Patch F changes (transparent-describe wrappers, opaque-leaf descriptors, conditional/optional describe, Button custom-label describe) remain.
More views that described as empty .composite and poisoned a host's narrow path, conformed with the batch-1 principle (describe what is rendered inline so descriptor/widget leaves balance): - GTKContentWrapper (single inline content): AnyView (wrapped), the modal modifiers (Sheet/ItemSheet/Alert/Popover/FullScreenCover/ConfirmationDialog — content is the inline base), DropDestinationView, GridCellSpanView, and the container wrappers List/Grid/Section/DisclosureGroup. - MultiChildView: LazyVStack/LazyHStack/LazyVGrid/LazyHGrid (one child per data item, like ForEach). - GTKOpaqueLeaf (native leaf widgets, no marked inner widgets): SecureField, TextEditor, DatePicker, ProgressView, Link, Label (native gtk_label), Image (source signature). Worst case for any of these is the pre-existing safe fallback (full rebuild), so this is purely additive. Still deferred (need per-view care; none in current Lyrebird UIs): GeometryReader/ScrollViewReader (closure content), Menu + toolbars (popup/toolbar surfaces), TabView/OutlineGroup/ViewThatFits, _ViewModifierContent.
Summary
On the GTK4 backend, a drag whose onChanged handler writes to @observable state fires only once per press — the motion after the first drag-update is lost — so a "drag to set a value" control (knob, slider, XY pad) backed by an @observable model can't track the drag.
Root cause
GTKViewHost.rebuild() has a narrow mutation path that applies text / color / canvas changes in place (gtkCanApplyTextColorHostMutation accepts .canvasContent), preserving the widget tree — and with it any in-flight GtkGestureDrag. But that path is guarded by !fromObservation: an @Observable-driven change always falls through to a full rebuild, which tears down and recreates the subtree, cancelling the active gesture. The guard exists because the narrow path did not re-run body under withObservationTracking, which would leave the one-shot subscription dead after the first change.
Fix
Let @Observable-driven changes use the narrow path too, and re-register the subscription there: when fromObservation, run the narrow path's describe pass under withObservationTracking, so re-reading the observed properties re-subscribes — the same re-registration the full rebuild gets from buildBodyWithTracking, without the teardown. Non-narrow-applicable changes still fall through to the full rebuild. @State behaviour is unchanged.
Validation
Ubuntu 24.04 (arm64), GTK4, Swift 6.3.3. An @Observable-backed Canvas knob with .onDrag: before — one step per press; after — tracks continuously, and subsequent @observable changes still redraw (subscription stays live). A @State knob worked before and still does.
Scope
One function, GTKViewHost.rebuild(). No API change. The narrow-path guard drops !fromObservation, and the describe call is wrapped in withObservationTracking on the fromObservation branch, mirroring buildBodyWithTracking.