Skip to content

Patches for Observable Drag Detection / Updates - #12

Open
joshpar wants to merge 12 commits into
codelynx:developfrom
joshpar:develop
Open

Patches for Observable Drag Detection / Updates#12
joshpar wants to merge 12 commits into
codelynx:developfrom
joshpar:develop

Conversation

@joshpar

@joshpar joshpar commented Aug 22, 2026

Copy link
Copy Markdown

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.

Copilot AI lite review requested due to automatic review settings August 22, 2026 15:49
@joshpar joshpar changed the title Patches for Lyrebird UI Linux work Patches for Observable Drag Detection / Updates Aug 22, 2026
@joshpar

joshpar commented Aug 22, 2026

Copy link
Copy Markdown
Author

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 describeBody pass in withObservationTracking so 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants