From f4bc8fb53904d876b02a48bbf7bdb5077e691b3a Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Mon, 7 Sep 2026 12:54:53 -0700 Subject: [PATCH 01/12] GTK4: continuous drag for Canvas value controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Backend/GTK4/Rendering/GTKRenderer.swift | 85 ++++++++++++++++ .../Backend/GTK4/Rendering/GTKViewHost.swift | 99 +++++++++++++++++-- 2 files changed, 174 insertions(+), 10 deletions(-) diff --git a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift index 15550555..32b99df2 100644 --- a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift +++ b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift @@ -2893,6 +2893,25 @@ private class GTKDragState { var startX: Double = 0 var startY: Double = 0 var dragStarted = false + /// Owning view host, captured at widget creation, used to bracket the drag + /// in an interactive-update deferral so a mid-drag rebuild cannot recreate + /// (and detach) the gesture's widget. Weak — the host outlives the gesture. + weak var host: GTKViewHost? +} + +/// Queue a redraw of `widget` and every descendant. +/// +/// GTK4 caches each widget's render node and reuses a child's node when only an +/// ancestor is invalidated, so `gtk_widget_queue_draw` on a container does not +/// re-run a nested `GtkDrawingArea`'s draw func. Walking the subtree marks every +/// widget dirty, which is what a live Canvas redraw during a drag needs. +func gtkQueueDrawSubtree(_ widget: UnsafeMutablePointer) { + gtk_widget_queue_draw(widget) + var child = gtk_widget_get_first_child(widget) + while let c = child { + gtkQueueDrawSubtree(c) + child = gtk_widget_get_next_sibling(c) + } } extension DragGestureView: GTKRenderable, GTKDescribable { @@ -2914,6 +2933,51 @@ extension DragGestureView: GTKRenderable, GTKDescribable { let gesture = gtk_gesture_drag_new()! let dragState = GTKDragState() + dragState.host = GTKViewHost.getCurrentRebuilding() + + // Bracket the whole drag sequence in the host's interactive-update + // deferral. A state-mutating onChanged schedules a rebuild that, when + // sibling controls are bound to the same model, is not narrow-applicable + // and recreates this gesture's widget mid-drag — detaching GtkGestureDrag + // so the sequence ends after one tick. Deferring rebuilds until drag-end + // keeps the widget (and the in-flight gesture) alive; the live redraw + // still happens via gtkQueueDrawSubtree in drag-update, and the single + // deferred rebuild on drag-end reconciles final state and re-registers + // observation. GtkGestureDrag emits drag-begin and drag-end exactly once + // per sequence, so these begin/end calls pair. Connected before the user + // handlers; GTK invokes multiple handlers for a signal in order. + let bracketState = dragState + _ = bracketState // host no longer needed for the bracket; kept for the trace + let bracketBeginBox = Unmanaged.passRetained(DoubleDoubleClosureBox { _, _ in + GTKViewHost.beginGlobalInteraction() + }).toOpaque() + g_signal_connect_data( + gpointer(gesture), + "drag-begin", + unsafeBitCast({ (_: gpointer?, x: gdouble, y: gdouble, userData: gpointer?) in + Unmanaged.fromOpaque(userData!).takeUnretainedValue().closure(x, y) + } as @convention(c) (gpointer?, gdouble, gdouble, gpointer?) -> Void, to: GCallback.self), + bracketBeginBox, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + Unmanaged.fromOpaque(userData!).release() + }, + GConnectFlags(rawValue: 0) + ) + let bracketEndBox = Unmanaged.passRetained(DoubleDoubleClosureBox { _, _ in + GTKViewHost.endGlobalInteraction() + }).toOpaque() + g_signal_connect_data( + gpointer(gesture), + "drag-end", + unsafeBitCast({ (_: gpointer?, offsetX: gdouble, offsetY: gdouble, userData: gpointer?) in + Unmanaged.fromOpaque(userData!).takeUnretainedValue().closure(offsetX, offsetY) + } as @convention(c) (gpointer?, gdouble, gdouble, gpointer?) -> Void, to: GCallback.self), + bracketEndBox, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + Unmanaged.fromOpaque(userData!).release() + }, + GConnectFlags(rawValue: 0) + ) if let onChanged = onChanged { let boundOnChanged = bindActionToCurrentEnvironment(onChanged) @@ -2931,6 +2995,27 @@ extension DragGestureView: GTKRenderable, GTKDescribable { translation: (width: offsetX, height: offsetY) ) boundOnChanged(value) + // Live redraw during the drag. The rebuild that a state-mutating + // onChanged schedules runs at default idle priority, which the + // stream of pointer-motion events starves, so a Canvas otherwise + // only repaints on release. The drawing-area draw func re-invokes + // the stored draw closure, which reads the bound value at paint + // time, so forcing a redraw here repaints the new value + // immediately — no rebuild required. + // + // The gesture's widget is typically a container (e.g. a Canvas + // wrapped by `.frame`); GTK4 reuses a child's cached render node + // when only an ancestor is invalidated, so a bare + // `queue_draw(widget)` would not re-run a nested GtkDrawingArea's + // draw func. Walk the subtree so the Canvas itself is marked + // dirty and actually repaints. + gtkQueueDrawSubtree(widget) + // Repaint every control bound to the value being dragged (linked + // knob/XY sharing a parameter), not just the dragged one, so they + // track live. Their rebuilds are deferred during the drag, so this + // drives the redraw directly; the Canvas closures read the value + // at paint time. + GTKViewHost.redrawDeferredInteractionHosts() }).toOpaque() // drag-begin: record start position diff --git a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift index 2a7f15c7..82222170 100644 --- a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift +++ b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift @@ -125,9 +125,17 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { let currentAnimation = getCurrentAnimation() defer { lock.unlock() } guard isContainerAlive else { return } - // Defer rebuild while interactive (e.g. slider drag) - if interactiveUpdateDepth > 0 { + // Defer rebuild while interactive: either THIS host is mid-interaction + // (interactiveUpdateDepth, e.g. a native slider drag) or ANY host is + // (globalInteractionDepth). A gesture drag freezes EVERY host's rebuilds, + // because a sibling/ancestor host that observes the same model would + // otherwise rebuild mid-drag and recreate — detaching — the dragged + // gesture's widget. Deferred hosts are collected and flushed on end. + if interactiveUpdateDepth > 0 || GTKViewHost.globalInteractionDepth > 0 { rebuildDeferredDuringInteraction = true + if GTKViewHost.globalInteractionDepth > 0 { + GTKViewHost.deferredDuringGlobalInteraction[ObjectIdentifier(self)] = self + } return } if let currentAnimation { @@ -143,6 +151,50 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { }, retained.toOpaque()) } + // MARK: - Global interaction deferral + + /// While > 0, EVERY host defers its rebuilds (see `scheduleRebuild`). A + /// gesture drag brackets itself with `begin/endGlobalInteraction` so that no + /// host — not the dragged control's, nor a sibling/ancestor that observes the + /// same model — rebuilds mid-drag and recreates (and thereby detaches) the + /// dragged gesture's widget. Accessed only on the GTK main thread, so no lock. + static var globalInteractionDepth: Int = 0 + + /// Hosts that deferred a rebuild during the current global interaction, + /// flushed exactly once when the interaction ends. Strong refs are fine — + /// entries live only for the duration of a drag. + static var deferredDuringGlobalInteraction: [ObjectIdentifier: GTKViewHost] = [:] + + /// Enter a global interaction (drag begin). Balanced by `endGlobalInteraction`. + static func beginGlobalInteraction() { + globalInteractionDepth += 1 + } + + /// Leave a global interaction (drag end). When the last one ends, flush every + /// host that deferred a rebuild during it, so the whole UI reconciles once. + static func endGlobalInteraction() { + guard globalInteractionDepth > 0 else { return } + globalInteractionDepth -= 1 + guard globalInteractionDepth == 0 else { return } + let hosts = deferredDuringGlobalInteraction + deferredDuringGlobalInteraction = [:] + for host in hosts.values { + host.scheduleRebuild() + } + } + + /// Repaint every host that has deferred a rebuild during the current global + /// interaction, without rebuilding. Called on each drag-update so controls + /// bound to the value being dragged (a linked knob and XY pad, say) track it + /// live: their Canvas draw closures read the shared value at paint time, so a + /// queue_draw reflects the new value even though the value-change observation + /// is one-shot and its re-registering rebuild is deferred until drag-end. + static func redrawDeferredInteractionHosts() { + for host in deferredDuringGlobalInteraction.values where host.isContainerAlive { + gtkQueueDrawSubtree(host.container) + } + } + public func beginInteractiveUpdate() { lock.lock() defer { lock.unlock() } @@ -236,19 +288,46 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { observationDidFire = false lock.unlock() - // --- Narrow mutation path: try text/color in-place update --- - // Skipped when withObservationTracking's onChange fired — the narrow - // path returns without re-running body under withObservationTracking, - // which would leave @Observable subscriptions dead after the first - // change. Fall through to the full rebuild so observation re-registers. - if !fromObservation, - let describeBody = describeBody, + // --- Narrow mutation path: try text/color/canvas in-place update --- + // Applied for both @State- and @Observable-driven changes. A full + // rebuild tears down the widget tree, which cancels any in-flight + // gesture (e.g. a drag on a Canvas knob); the narrow path mutates in + // place and preserves it. For an @Observable change the describe pass + // below is run under `withObservationTracking`, so re-reading the + // observed properties re-registers the one-shot subscription — the same + // re-registration the full rebuild gets from `buildBodyWithTracking`, + // without the teardown. If the change is not narrow-applicable we fall + // through to the full rebuild, which re-registers observation as before. + if let describeBody = describeBody, let oldRetained = lastRetainedDescriptor, let oldExecutor = retainedExecutor { let previousEnv = getCurrentEnvironment() installRebuildEnvironment() - let described = gtkDescribeCapturingCanvasPayloads(describeBody) + let described: (descriptor: GTK4DescriptorNode, canvasPayloads: [GTK4CanvasPayload]) + if fromObservation { + #if canImport(Observation) + if #available(macOS 14.0, iOS 17.0, *) { + var captured: (descriptor: GTK4DescriptorNode, canvasPayloads: [GTK4CanvasPayload])! + withObservationTracking { + captured = gtkDescribeCapturingCanvasPayloads(describeBody) + } onChange: { [weak self] in + guard let self else { return } + self.lock.lock() + self.observationDidFire = true + self.lock.unlock() + self.scheduleRebuild() + } + described = captured + } else { + described = gtkDescribeCapturingCanvasPayloads(describeBody) + } + #else + described = gtkDescribeCapturingCanvasPayloads(describeBody) + #endif + } else { + described = gtkDescribeCapturingCanvasPayloads(describeBody) + } setCurrentEnvironment(previousEnv) let newIdentified = gtkIdentifyDescriptorTree(described.descriptor) From f21316a95126a153bdf3364b05b3a295237968d5 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 11:53:21 -0700 Subject: [PATCH 02/12] GTK4: narrow in-place widget updates during a drag 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. --- .../Backend/GTK4/Rendering/GTKViewHost.swift | 183 +++++++++++------- 1 file changed, 113 insertions(+), 70 deletions(-) diff --git a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift index 82222170..69ee1feb 100644 --- a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift +++ b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift @@ -190,7 +190,17 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { /// queue_draw reflects the new value even though the value-change observation /// is one-shot and its re-registering rebuild is deferred until drag-end. static func redrawDeferredInteractionHosts() { - for host in deferredDuringGlobalInteraction.values where host.isContainerAlive { + // Snapshot the values first: a host's narrow-mutation describe pass can + // re-register observation whose onChange re-inserts into the dict, so we + // must not iterate the live dictionary while mutating it. + let hosts = Array(deferredDuringGlobalInteraction.values) + for host in hosts where host.isContainerAlive { + // Push narrow (text/color/canvas) in-place updates so NATIVE widgets + // bound to the dragged value — a TextField's GtkEntry, say — track + // live too, not just Canvas hosts. The narrow path never recreates a + // widget, so it can't detach the in-flight gesture; structural + // changes stay deferred to drag-end. + host.applyDeferredNarrowMutationDuringInteraction() gtkQueueDrawSubtree(host.container) } } @@ -273,6 +283,102 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { return result } + /// Attempt an in-place text/color/canvas mutation for the current body, + /// preserving the widget tree (and any in-flight gesture) instead of a full + /// teardown-rebuild. Returns `true` if the plan was narrow-applicable and + /// applied — the caller should then skip the full rebuild — or `false` if a + /// structural rebuild is still required. + /// + /// Applied for both @State- and @Observable-driven changes. For an + /// @Observable change the describe pass runs under `withObservationTracking`, + /// so re-reading the observed properties re-registers the one-shot + /// subscription — the same re-registration the full rebuild gets from + /// `buildBodyWithTracking`, without the teardown. + /// + /// Must be called with `lock` NOT held (it runs the describe/plan/apply pass + /// lock-free, matching the original inline placement after `lock.unlock()`). + func tryNarrowMutation(fromObservation: Bool) -> Bool { + guard let describeBody = describeBody, + let oldRetained = lastRetainedDescriptor, + let oldExecutor = retainedExecutor else { + return false + } + + let previousEnv = getCurrentEnvironment() + installRebuildEnvironment() + let described: (descriptor: GTK4DescriptorNode, canvasPayloads: [GTK4CanvasPayload]) + if fromObservation { + #if canImport(Observation) + if #available(macOS 14.0, iOS 17.0, *) { + var captured: (descriptor: GTK4DescriptorNode, canvasPayloads: [GTK4CanvasPayload])! + withObservationTracking { + captured = gtkDescribeCapturingCanvasPayloads(describeBody) + } onChange: { [weak self] in + guard let self else { return } + self.lock.lock() + self.observationDidFire = true + self.lock.unlock() + self.scheduleRebuild() + } + described = captured + } else { + described = gtkDescribeCapturingCanvasPayloads(describeBody) + } + #else + described = gtkDescribeCapturingCanvasPayloads(describeBody) + #endif + } else { + described = gtkDescribeCapturingCanvasPayloads(describeBody) + } + setCurrentEnvironment(previousEnv) + + let newIdentified = gtkIdentifyDescriptorTree(described.descriptor) + let canvasPayloads = gtkCanvasPayloadsByIdentity( + descriptorRoot: newIdentified, + payloads: described.canvasPayloads + ) + let plan = gtkPlanDescriptorTree(old: oldRetained, new: newIdentified) + + if gtkCanApplyTextColorHostMutation(plan: plan) { + let action = gtkExecuteDescriptorPlan( + old: oldExecutor, + plan: plan, + canvasPayloadsByIdentity: canvasPayloads + ) + + // Verify all slots are still valid before mutating + let allSlotsValid = gtkAllSlotsValid(action: action) + if allSlotsValid { + let result = gtkApplyHookMutation(action: action) + if gtkHookMutationSucceeded(result) { + // Success — update retained state, skip full rebuild + lastRetainedDescriptor = gtkRetainDescriptorTree(newIdentified) + retainedExecutor = action.resultingNode + return true + } + } + } + return false + } + + /// Apply a narrow in-place mutation for a host whose rebuild is deferred by an + /// active drag, WITHOUT falling back to a full rebuild. Lets native widgets + /// bound to the value being dragged (e.g. a `TextField`'s `GtkEntry`) track + /// live during the drag, the same way Canvas hosts track via redraw — the + /// narrow path never recreates widgets, so it cannot detach the gesture. Any + /// structural change stays deferred to drag-end (where the full rebuild runs, + /// picking it up because a failed narrow attempt leaves the retained + /// descriptor untouched). Called from `redrawDeferredInteractionHosts` per + /// drag-update. `observationDidFire` is intentionally NOT cleared here — the + /// deferred drag-end rebuild still needs it to re-register observation. + func applyDeferredNarrowMutationDuringInteraction() { + lock.lock() + guard isContainerAlive else { lock.unlock(); return } + let fromObservation = observationDidFire + lock.unlock() + _ = tryNarrowMutation(fromObservation: fromObservation) + } + func rebuild() { lock.lock() scheduled = false @@ -288,75 +394,12 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { observationDidFire = false lock.unlock() - // --- Narrow mutation path: try text/color/canvas in-place update --- - // Applied for both @State- and @Observable-driven changes. A full - // rebuild tears down the widget tree, which cancels any in-flight - // gesture (e.g. a drag on a Canvas knob); the narrow path mutates in - // place and preserves it. For an @Observable change the describe pass - // below is run under `withObservationTracking`, so re-reading the - // observed properties re-registers the one-shot subscription — the same - // re-registration the full rebuild gets from `buildBodyWithTracking`, - // without the teardown. If the change is not narrow-applicable we fall - // through to the full rebuild, which re-registers observation as before. - if let describeBody = describeBody, - let oldRetained = lastRetainedDescriptor, - let oldExecutor = retainedExecutor { - - let previousEnv = getCurrentEnvironment() - installRebuildEnvironment() - let described: (descriptor: GTK4DescriptorNode, canvasPayloads: [GTK4CanvasPayload]) - if fromObservation { - #if canImport(Observation) - if #available(macOS 14.0, iOS 17.0, *) { - var captured: (descriptor: GTK4DescriptorNode, canvasPayloads: [GTK4CanvasPayload])! - withObservationTracking { - captured = gtkDescribeCapturingCanvasPayloads(describeBody) - } onChange: { [weak self] in - guard let self else { return } - self.lock.lock() - self.observationDidFire = true - self.lock.unlock() - self.scheduleRebuild() - } - described = captured - } else { - described = gtkDescribeCapturingCanvasPayloads(describeBody) - } - #else - described = gtkDescribeCapturingCanvasPayloads(describeBody) - #endif - } else { - described = gtkDescribeCapturingCanvasPayloads(describeBody) - } - setCurrentEnvironment(previousEnv) - - let newIdentified = gtkIdentifyDescriptorTree(described.descriptor) - let canvasPayloads = gtkCanvasPayloadsByIdentity( - descriptorRoot: newIdentified, - payloads: described.canvasPayloads - ) - let plan = gtkPlanDescriptorTree(old: oldRetained, new: newIdentified) - - if gtkCanApplyTextColorHostMutation(plan: plan) { - let action = gtkExecuteDescriptorPlan( - old: oldExecutor, - plan: plan, - canvasPayloadsByIdentity: canvasPayloads - ) - - // Verify all slots are still valid before mutating - let allSlotsValid = gtkAllSlotsValid(action: action) - if allSlotsValid { - let result = gtkApplyHookMutation(action: action) - if gtkHookMutationSucceeded(result) { - // Success — update retained state, skip full rebuild - lastRetainedDescriptor = gtkRetainDescriptorTree(newIdentified) - retainedExecutor = action.resultingNode - return - } - } - } - // Fall through to full rebuild + // Narrow mutation path: try an in-place text/color/canvas update that + // preserves the widget tree (and any in-flight gesture) instead of a + // teardown-rebuild. If it fully applies, skip the full rebuild; otherwise + // fall through, which re-registers observation as before. + if tryNarrowMutation(fromObservation: fromObservation) { + return } // Phase 7: skip body evaluation if no storage was mutated since last render. From 6c3e8e57fb33234301739a1d4c7743c25d64b072 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 13:34:43 -0700 Subject: [PATCH 03/12] GTK4: TextField as a first-class descriptor node (live in-place text updates) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../GTK4/Rendering/GTK4DescriptorTree.swift | 74 ++++++++++++++++++- .../Backend/GTK4/Rendering/GTKRenderer.swift | 14 +++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift index c434590b..973a9e82 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift @@ -24,6 +24,7 @@ public enum GTK4DescriptorKind: Equatable { case searchable case font case text + case textField case color case frame case foregroundColor @@ -84,6 +85,16 @@ public struct GTK4TextDescriptor: Equatable { public let content: String } +/// A `TextField`'s current text + placeholder. Making the text visible in the +/// descriptor tree means a changed value plans a `.textFieldValue` update the +/// narrow path applies in place (`gtk_swift_editable_set_text` on the hosted +/// GtkEntry) instead of a silent-reuse that never updates, or an empty +/// `.composite` that poisons the whole host's narrow path. +public struct GTK4TextFieldDescriptor: Equatable { + public let text: String + public let placeholder: String +} + public struct GTK4ColorDescriptor: Equatable { public let red: Double public let green: Double @@ -226,6 +237,7 @@ public enum GTK4DescriptorProps: Equatable { case rotation(GTK4RotationDescriptor) case scale(GTK4ScaleDescriptor) case text(GTK4TextDescriptor) + case textField(GTK4TextFieldDescriptor) case color(GTK4ColorDescriptor) case frame(GTK4FrameDescriptor) case foregroundColor(GTK4ColorDescriptor) @@ -382,6 +394,7 @@ public enum GTK4DescriptorUpdateIntent: Equatable { case sliderConfiguration case sliderValue case textContent + case textFieldValue case vStackLayout case zStackLayout case widgetPropertyUpdate @@ -689,6 +702,15 @@ private func gtkUpdateIntent(old: GTK4DescriptorNode, return oldSlider.range == newSlider.range && oldSlider.step == newSlider.step ? .sliderValue : .sliderConfiguration case .text: return .textContent + case .textField: + guard case let .textField(oldTF) = old.props, + case let .textField(newTF) = new.props else { + return .none + } + // Only a text change rides the narrow path; a placeholder change is rare + // and left to a full rebuild (returns .none → reuse, no narrow update). + return oldTF.text != newTF.text && oldTF.placeholder == newTF.placeholder + ? .textFieldValue : .none case .vStack: return .vStackLayout case .zStack: return .zStackLayout case .animated: return .animatedTiming @@ -799,6 +821,7 @@ public func gtkCanApplyTextColorHostMutation(plan: GTK4DescriptorPlan) -> Bool { guard plan.updateIntent == .textContent || plan.updateIntent == .colorFill || plan.updateIntent == .canvasContent || plan.updateIntent == .sliderValue + || plan.updateIntent == .textFieldValue || plan.updateIntent == .paddingLayout else { // .widgetPropertyUpdate is deliberately NOT here: `.widgetProperty` // applies in-place to the content's (often already-hosted) widget @@ -836,6 +859,8 @@ private func gtkUpdateHook(action: GTK4ExecutorAction, return gtkCanvasContentHook(action: action, performMutation: performMutation) case .sliderValue: return gtkSliderValueHook(action: action, performMutation: performMutation) + case .textFieldValue: + return gtkTextFieldValueHook(action: action, performMutation: performMutation) case .paddingLayout: return gtkPaddingLayoutHook(action: action, performMutation: performMutation) case .animatedTiming, .backgroundColor, .borderStyle, .fontStyle, .frameLayout, .foregroundColor, @@ -911,6 +936,21 @@ private func gtkSliderValueHook(action: GTK4ExecutorAction, mutationSucceeded: mutationSucceeded) } +private func gtkTextFieldValueHook(action: GTK4ExecutorAction, + performMutation: Bool) -> GTK4HookResult { + var mutationSucceeded = true + if performMutation, + case let .textField(tfDesc) = action.currentDescriptor.props, + let slotID = action.resultingNode.nativeSlotID ?? action.previousNode?.nativeSlotID { + mutationSucceeded = gtkSetTextFieldValue(slotID: slotID, text: tfDesc.text) + } else if performMutation { + mutationSucceeded = false + } + return gtkUpdatedHookResult(action: action, intent: .textFieldValue, + performMutation: performMutation, + mutationSucceeded: mutationSucceeded) +} + private func gtkPaddingLayoutHook(action: GTK4ExecutorAction, performMutation: Bool) -> GTK4HookResult { var mutationSucceeded = true @@ -1018,6 +1058,7 @@ public func gtkColorDescriptor(_ color: Color) -> GTK4ColorDescriptor { /// Kinds of hosted native widgets that support in-place mutation. public enum GTK4HostedNodeKind: String { case text + case textField case color case canvas case slider @@ -1035,6 +1076,8 @@ public func gtkMarkHostedNodeKind(_ widget: UnsafeMutablePointer, switch kind { case .text: g_object_set_data(gobject, gtkHostedKindKey, UnsafeMutableRawPointer(mutating: gtkHostedKindTextPtr)) + case .textField: + g_object_set_data(gobject, gtkHostedKindKey, UnsafeMutableRawPointer(mutating: gtkHostedKindTextFieldPtr)) case .color: g_object_set_data(gobject, gtkHostedKindKey, UnsafeMutableRawPointer(mutating: gtkHostedKindColorPtr)) case .canvas: @@ -1053,6 +1096,7 @@ public func gtkHostedNodeKind(of widget: UnsafeMutablePointer) -> GTK let gobject = UnsafeMutableRawPointer(widget).assumingMemoryBound(to: GObject.self) guard let raw = g_object_get_data(gobject, gtkHostedKindKey) else { return .unknown } if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindTextPtr) { return .text } + if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindTextFieldPtr) { return .textField } if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindColorPtr) { return .color } if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindCanvasPtr) { return .canvas } if raw == UnsafeMutableRawPointer(mutating: gtkHostedKindSliderPtr) { return .slider } @@ -1073,6 +1117,12 @@ private let gtkHostedKindColorPtr: UnsafePointer = { return UnsafePointer(p) }() +private let gtkHostedKindTextFieldPtr: UnsafePointer = { + let p = UnsafeMutablePointer.allocate(capacity: 1) + p.pointee = 6 + return UnsafePointer(p) +}() + private let gtkHostedKindCanvasPtr: UnsafePointer = { let p = UnsafeMutablePointer.allocate(capacity: 1) p.pointee = 5 @@ -1095,6 +1145,7 @@ private let gtkHostedKindPaddingPtr: UnsafePointer = { public func gtkHostedKindForDescriptor(_ kind: GTK4DescriptorKind) -> GTK4HostedNodeKind? { switch kind { case .text: return .text + case .textField: return .textField case .color: return .color case .canvas: return .canvas case .slider: return .slider @@ -1163,7 +1214,7 @@ private func gtkCollectSupportedHostedWidgets( into result: inout [UnsafeMutablePointer] ) { let kind = gtkHostedNodeKind(of: widget) - if kind == .text || kind == .color || kind == .canvas || kind == .slider || kind == .padding { + if kind == .text || kind == .textField || kind == .color || kind == .canvas || kind == .slider || kind == .padding { result.append(widget) } var child = gtk_widget_get_first_child(widget) @@ -1198,6 +1249,7 @@ public func gtkAllSlotsValid(action: GTK4ExecutorAction) -> Bool { if action.updateIntent == .textContent || action.updateIntent == .colorFill || action.updateIntent == .canvasContent || action.updateIntent == .sliderValue + || action.updateIntent == .textFieldValue || action.updateIntent == .paddingLayout { guard let slotID = action.resultingNode.nativeSlotID ?? action.previousNode?.nativeSlotID, let widget = gtkWidgetFromSlotID(slotID), @@ -1267,6 +1319,26 @@ public func gtkSetSliderValue(slotID: Int, value: Double) -> Bool { return true } +/// Set the text of a hosted GtkEntry (TextField) in place. +/// +/// Skips while the widget is focused so a programmatic set can't clobber the +/// caret/selection of a user who is typing — the binding still holds the value, +/// and the field reconciles on the next full rebuild after blur. Skips when the +/// text already matches (also avoids a redundant `notify::text` → binding +/// round-trip). Returns `true` in the skip cases too: the narrow path has +/// nothing to do, and must not fall back to a full rebuild that would recreate +/// the entry mid-interaction. +public func gtkSetTextFieldValue(slotID: Int, text: String) -> Bool { + guard let widget = gtkWidgetFromSlotID(slotID) else { return false } + guard gtk_swift_is_widget(widget) != 0 else { return false } + if gtk_widget_is_focus(widget) != 0 { return true } + if let cStr = gtk_editable_get_text(OpaquePointer(widget)) { + if String(cString: cStr) == text { return true } + } + gtk_swift_editable_set_text(widget, text) + return true +} + private let gtkCanvasDrawBoxKey = "gtk-swift-canvas-draw-box" public func gtkSetCanvasContent(slotID: Int, payload: GTK4CanvasPayload) -> Bool { diff --git a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift index 32b99df2..06e0dc24 100644 --- a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift +++ b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift @@ -225,10 +225,22 @@ extension Divider: GTKRenderable, GTKDescribable { } } -extension TextField: GTKRenderable { +extension TextField: GTKRenderable, GTKDescribable { + public func gtkDescribeNode() -> GTK4DescriptorNode { + // A real descriptor node (not an empty `.composite`, which would poison + // the host's narrow-mutation path) so a text change can be applied in + // place via `.textFieldValue` — letting the field track a live value + // (e.g. bound to a slider being dragged) instead of only at mouse-up. + GTK4DescriptorNode( + kind: .textField, typeName: "TextField", + props: .textField(GTK4TextFieldDescriptor( + text: text.wrappedValue, placeholder: title))) + } + public func gtkCreateWidget() -> OpaquePointer { let entry = gtk_entry_new()! gtk_widget_set_hexpand(entry, 1) + gtkMarkHostedNodeKind(entry, kind: .textField) let entryPtr = UnsafeMutableRawPointer(entry).assumingMemoryBound(to: GtkEntry.self) let bufferPtr = gtk_entry_get_buffer(entryPtr) gtk_entry_buffer_set_text(bufferPtr, text.wrappedValue, -1) From e0c93b7cdf237cfa99c8999d144c0054e21c6bcd Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 14:10:57 -0700 Subject: [PATCH 04/12] GTK4: transparent-describe for single-content wrapper modifiers (narrow path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../GTK4/Rendering/GTK4DescriptorTree.swift | 24 +++ .../Backend/GTK4/Rendering/GTKRenderer.swift | 148 ++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift index 973a9e82..8a7d48fc 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift @@ -503,6 +503,19 @@ public protocol GTKDescribable { func gtkDescribeNode() -> GTK4DescriptorNode } +/// A transparent single-`content` wrapper view (a styling / gesture / lifecycle +/// modifier) whose only visible structure is the view it wraps. Conforming lets +/// `gtkDescribeView` describe the wrapped content rather than falling through to +/// an EMPTY `.composite` — which the narrow-mutation gate rejects +/// (`gtkCanApplyTextColorHostMutation`), poisoning the whole host's narrow path +/// and dropping the wrapped node from the descriptor tree entirely. With this, +/// a `Text` / `TextField` / slider wrapped in `.onChange` / `.monospacedDigit` / +/// `.focused` / … stays narrow-applicable, so its value change updates in place +/// instead of forcing a full-window rebuild. +public protocol GTKContentWrapper { + var gtkWrappedContent: any View { get } +} + private final class GTK4CanvasPayloadCollector { var payloads: [GTK4CanvasPayload] = [] } @@ -537,6 +550,17 @@ public func gtkDescribeView(_ view: V) -> GTK4DescriptorNode { if let describable = view as? GTKDescribable { return describable.gtkDescribeNode() } + // Transparent single-content wrapper (styling / gesture / lifecycle modifier): + // describe the wrapped content so it stays in the descriptor tree and remains + // narrow-applicable, instead of collapsing to an empty `.composite` that the + // narrow gate rejects (which would poison the whole host's narrow path). + if let wrapper = view as? GTKContentWrapper { + return GTK4DescriptorNode( + kind: .composite, + typeName: String(describing: type(of: view)), + children: [gtkDescribeAnyView(wrapper.gtkWrappedContent)] + ) + } if let multi = view as? MultiChildView { return GTK4DescriptorNode( kind: .composite, diff --git a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift index 06e0dc24..dd3c433b 100644 --- a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift +++ b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift @@ -7541,3 +7541,151 @@ extension ViewThatFits: GTKRenderable { return opaqueFromWidget(stack) } } + +// MARK: - Transparent content-wrapper describe conformances +// +// These styling / gesture / lifecycle / environment modifiers wrap a single +// `content` view and render it directly. Conforming to GTKContentWrapper makes +// gtkDescribeView describe the wrapped content instead of collapsing to an empty +// `.composite` (which the narrow-mutation gate rejects, poisoning the host). + +extension OnChangeView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnChangeTwoArgView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnSubmitView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnAppearView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnDisappearView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FocusedView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FocusedEqualsView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FocusedValueView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension MonospacedDigitView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension MultilineTextAlignmentView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension TextFieldStyleModifier: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ButtonStyleModifier: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ToggleStyleModifier: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LabelsHiddenView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LineLimitView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension TruncationModeView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LineSpacingView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension BoldView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ItalicView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FontWeightView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension UnderlineView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension StrikethroughView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension TextCaseView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension CornerRadiusView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ClippedView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ClipShapeView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ShadowView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension BlurView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension AspectRatioView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension PositionView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LayoutPriorityView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension FixedSizeView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension HelpView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension IdView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension TagView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension KeyboardShortcutView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension HiddenView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension ContextMenuView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension LongPressGestureView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension OnExitCommandView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension EnvironmentModifierView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension EnvironmentObjectModifierView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} +extension EnvironmentObservableModifierView: GTKContentWrapper { + public var gtkWrappedContent: any View { content } +} + +// OverlayView has two view children (content + overlay); describe both so a +// change in either stays narrow-applicable. +extension OverlayView: GTKDescribable { + public func gtkDescribeNode() -> GTK4DescriptorNode { + GTK4DescriptorNode( + kind: .composite, typeName: "OverlayView", + children: [gtkDescribeAnyView(content), gtkDescribeAnyView(overlay)] + ) + } +} From eb6a081831c57fefe8e3bbdf858efc50d77db110 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 14:19:25 -0700 Subject: [PATCH 05/12] GTK4: opaque-leaf descriptors so native widgets stop poisoning the narrow path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../GTK4/Rendering/GTK4DescriptorTree.swift | 37 +++++++++++++ .../Backend/GTK4/Rendering/GTKRenderer.swift | 53 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift index 8a7d48fc..2bd529f4 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift @@ -25,6 +25,14 @@ public enum GTK4DescriptorKind: Equatable { case font case text case textField + /// An opaque native widget whose state the narrow path does not model (Toggle, + /// Stepper, Picker, a filled Shape, …). It carries an `AnyHashable` state + /// signature so the diff can tell whether it changed: an unchanged signature + /// `.reuse`s (passing the narrow gate — it is NOT an empty `.composite`), a + /// changed one plans an `.update`/`.none` the gate rejects, forcing a full + /// rebuild. This keeps such widgets from poisoning a host's narrow path while + /// staying correct on a real state change. + case opaqueLeaf case color case frame case foregroundColor @@ -95,6 +103,13 @@ public struct GTK4TextFieldDescriptor: Equatable { public let placeholder: String } +/// State signature of an opaque native widget (see `.opaqueLeaf`). Two describe +/// as equal iff their signatures are equal, so an unchanged widget reuses and a +/// changed one forces a rebuild. `AnyHashable` is `Equatable`, so this is too. +public struct GTK4OpaqueLeafDescriptor: Equatable { + public let signature: AnyHashable +} + public struct GTK4ColorDescriptor: Equatable { public let red: Double public let green: Double @@ -238,6 +253,7 @@ public enum GTK4DescriptorProps: Equatable { case scale(GTK4ScaleDescriptor) case text(GTK4TextDescriptor) case textField(GTK4TextFieldDescriptor) + case opaqueLeaf(GTK4OpaqueLeafDescriptor) case color(GTK4ColorDescriptor) case frame(GTK4FrameDescriptor) case foregroundColor(GTK4ColorDescriptor) @@ -516,6 +532,16 @@ public protocol GTKContentWrapper { var gtkWrappedContent: any View { get } } +/// An opaque native-widget view (Toggle, Stepper, Picker, a filled Shape, …) that +/// the narrow path cannot update in place. Conforming makes it describe as a +/// `.opaqueLeaf` carrying `gtkStateSignature` instead of an empty `.composite`, so +/// it no longer poisons the host's narrow path — an unchanged signature reuses; a +/// changed one forces a full rebuild. The signature MUST include every bound value +/// that affects the widget's appearance, or a programmatic change goes stale. +public protocol GTKOpaqueLeaf { + var gtkStateSignature: AnyHashable { get } +} + private final class GTK4CanvasPayloadCollector { var payloads: [GTK4CanvasPayload] = [] } @@ -561,6 +587,16 @@ public func gtkDescribeView(_ view: V) -> GTK4DescriptorNode { children: [gtkDescribeAnyView(wrapper.gtkWrappedContent)] ) } + // Opaque native leaf widget: describe as `.opaqueLeaf` carrying its state + // signature (not an empty `.composite`), so it doesn't poison the host's + // narrow path but still forces a rebuild when its state changes. + if let leaf = view as? GTKOpaqueLeaf { + return GTK4DescriptorNode( + kind: .opaqueLeaf, + typeName: String(describing: type(of: view)), + props: .opaqueLeaf(GTK4OpaqueLeafDescriptor(signature: leaf.gtkStateSignature)) + ) + } if let multi = view as? MultiChildView { return GTK4DescriptorNode( kind: .composite, @@ -726,6 +762,7 @@ private func gtkUpdateIntent(old: GTK4DescriptorNode, return oldSlider.range == newSlider.range && oldSlider.step == newSlider.step ? .sliderValue : .sliderConfiguration case .text: return .textContent + case .opaqueLeaf: return .none // any state-signature change → full rebuild case .textField: guard case let .textField(oldTF) = old.props, case let .textField(newTF) = new.props else { diff --git a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift index dd3c433b..73136b4e 100644 --- a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift +++ b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift @@ -7689,3 +7689,56 @@ extension OverlayView: GTKDescribable { ) } } + +// MARK: - Opaque-leaf state-signature conformances +// +// Native widgets the narrow path can't update in place. Describing them as +// `.opaqueLeaf(signature)` (not an empty `.composite`) stops them poisoning a +// host's narrow path; the signature captures the bound state that affects +// appearance, so an unchanged widget reuses and a changed one forces a rebuild. +// Each signature must include EVERY value that changes the widget's look. + +extension Toggle: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(label), AnyHashable(isOn.wrappedValue)]) + } +} + +extension Stepper: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(label), AnyHashable(value.wrappedValue), + AnyHashable(range.lowerBound), AnyHashable(range.upperBound), + AnyHashable(step)]) + } +} + +extension Picker: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(label), AnyHashable(selected), AnyHashable(options)]) + } +} + +extension FilledShape: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(color.red), AnyHashable(color.green), + AnyHashable(color.blue), AnyHashable(color.alpha)]) + } +} + +extension StrokedShape: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(color.red), AnyHashable(color.green), + AnyHashable(color.blue), AnyHashable(color.alpha)]) + } +} + +// Static shapes / gradients / empty — a constant signature (they don't change; +// a structural swap is caught by the descriptor typeName, not the signature). +extension Circle: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("Circle") } } +extension Rectangle: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("Rectangle") } } +extension Ellipse: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("Ellipse") } } +extension Capsule: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("Capsule") } } +extension RoundedRectangle: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable(cornerRadius) } } +extension LinearGradient: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("LinearGradient") } } +extension RadialGradient: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("RadialGradient") } } +extension EmptyView: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("EmptyView") } } From 617afa65c9f9572e0b6c485c78d1bb359e3d31f6 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 14:24:31 -0700 Subject: [PATCH 06/12] GTK4: transparent-describe for _ConditionalView and Optional views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `if/else` in a ViewBuilder makes a _ConditionalView; a bare `if` / `if let` makes an Optional. 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. --- .../Backend/GTK4/Rendering/GTKRenderer.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift index 73136b4e..1dac82e0 100644 --- a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift +++ b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift @@ -7742,3 +7742,30 @@ extension RoundedRectangle: GTKOpaqueLeaf { public var gtkStateSignature: AnyHas extension LinearGradient: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("LinearGradient") } } extension RadialGradient: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("RadialGradient") } } extension EmptyView: GTKOpaqueLeaf { public var gtkStateSignature: AnyHashable { AnyHashable("EmptyView") } } + +// MARK: - Conditional / optional transparent describe +// +// `if/else` in a ViewBuilder produces `_ConditionalView`; a bare `if` (incl. +// `if let`) produces `Optional`. Both are GTKRenderable-only, so they +// described as empty `.composite`s that poison the host's narrow path. Describe +// the ACTIVE branch transparently instead. A branch/optional FLIP changes the +// described child's type — caught as a structural change (rebuild) — but a stable +// condition (as during a drag) reuses, keeping the host narrow-applicable. + +extension _ConditionalView: GTKContentWrapper { + public var gtkWrappedContent: any View { + switch self { + case .trueContent(let view): return view + case .falseContent(let view): return view + } + } +} + +extension Optional: GTKContentWrapper where Wrapped: View { + public var gtkWrappedContent: any View { + switch self { + case .some(let view): return view + case .none: return EmptyView() + } + } +} From 47eeb23da5a78521596a0c9545a57d6868506172 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 14:28:51 -0700 Subject: [PATCH 07/12] GTK4: narrow-reject diagnostics (SWIFTOPENUI_NARROW_DEBUG) 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. --- .../GTK4/Rendering/GTK4DescriptorTree.swift | 29 +++++++++++++++++++ .../Backend/GTK4/Rendering/GTKViewHost.swift | 20 +++++++++++++ 2 files changed, 49 insertions(+) diff --git a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift index 2bd529f4..62b0feed 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift @@ -869,6 +869,35 @@ public func gtkHookMutationSucceeded(_ result: GTK4HookResult) -> Bool { /// Opaque composites (Body = Never, no describable conformance) with no /// described children are rejected — their child content is not captured /// in the descriptor, so we can't prove nothing changed inside. +/// Debug (SWIFTOPENUI_NARROW_DEBUG): true when narrow-path rejection diagnostics +/// should be logged. Cheap `getenv` at first access. +public let gtkNarrowDebugEnabled: Bool = + ProcessInfo.processInfo.environment["SWIFTOPENUI_NARROW_DEBUG"] != nil + +/// Debug: collect the nodes that make `gtkCanApplyTextColorHostMutation` reject a +/// plan — i.e. why a host fell to a full rebuild instead of a narrow update. Each +/// entry names the plan action, the descriptor kind, and its `typeName`. +public func gtkCollectNonNarrowReasons(_ plan: GTK4DescriptorPlan, into out: inout [String]) { + switch plan.kind { + case .create, .replace: + out.append("\(plan.kind) \(plan.newDescriptor.kind) '\(plan.newDescriptor.typeName)'") + return + case .reuse: + if plan.newDescriptor.kind == .composite && plan.children.isEmpty { + out.append("reuse EMPTY .composite '\(plan.newDescriptor.typeName)'") + return + } + case .update: + switch plan.updateIntent { + case .textContent, .colorFill, .canvasContent, .sliderValue, .textFieldValue, .paddingLayout: + break + default: + out.append("update intent=\(plan.updateIntent) \(plan.newDescriptor.kind) '\(plan.newDescriptor.typeName)'") + } + } + for child in plan.children { gtkCollectNonNarrowReasons(child, into: &out) } +} + public func gtkCanApplyTextColorHostMutation(plan: GTK4DescriptorPlan) -> Bool { switch plan.kind { case .create, .replace: diff --git a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift index 69ee1feb..135c4324 100644 --- a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift +++ b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift @@ -160,6 +160,10 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { /// dragged gesture's widget. Accessed only on the GTK main thread, so no lock. static var globalInteractionDepth: Int = 0 + /// Last narrow-rejection diagnostic line printed (SWIFTOPENUI_NARROW_DEBUG), + /// to dedupe a held drag's repeated logs. Main-thread only. + static var lastNarrowRejectLog: String = "" + /// Hosts that deferred a rebuild during the current global interaction, /// flushed exactly once when the interaction ends. Strong refs are fine — /// entries live only for the duration of a drag. @@ -339,6 +343,22 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { ) let plan = gtkPlanDescriptorTree(old: oldRetained, new: newIdentified) + // Diagnostics (SWIFTOPENUI_NARROW_DEBUG): during a drag, if the narrow path + // is about to be rejected, log the offending node(s) — the reason a host + // falls to a full rebuild instead of updating in place. Deduped so a held + // drag doesn't flood stderr. + if gtkNarrowDebugEnabled, + GTKViewHost.globalInteractionDepth > 0, + !gtkCanApplyTextColorHostMutation(plan: plan) { + var reasons: [String] = [] + gtkCollectNonNarrowReasons(plan, into: &reasons) + let line = "[narrow-reject] " + reasons.prefix(8).joined(separator: " | ") + if line != GTKViewHost.lastNarrowRejectLog { + GTKViewHost.lastNarrowRejectLog = line + FileHandle.standardError.write(Data((line + "\n").utf8)) + } + } + if gtkCanApplyTextColorHostMutation(plan: plan) { let action = gtkExecuteDescriptorPlan( old: oldExecutor, From daf145e162116c65bb5291aebff0bc38cb62c584 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 15:22:36 -0700 Subject: [PATCH 08/12] GTK4: broaden narrow-path diagnostics (pass count + full outcome) 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. --- .../Backend/GTK4/Rendering/GTKViewHost.swift | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift index 135c4324..55843559 100644 --- a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift +++ b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift @@ -160,9 +160,12 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { /// dragged gesture's widget. Accessed only on the GTK main thread, so no lock. static var globalInteractionDepth: Int = 0 - /// Last narrow-rejection diagnostic line printed (SWIFTOPENUI_NARROW_DEBUG), + /// Last narrow-rejection/outcome diagnostic line printed (SWIFTOPENUI_NARROW_DEBUG), /// to dedupe a held drag's repeated logs. Main-thread only. static var lastNarrowRejectLog: String = "" + /// Separate dedupe for the per-pass "deferred hosts = N" line so it doesn't + /// alternate with the outcome lines and re-print every motion event. + static var lastNarrowPassLog: String = "" /// Hosts that deferred a rebuild during the current global interaction, /// flushed exactly once when the interaction ends. Strong refs are fine — @@ -198,6 +201,13 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { // re-register observation whose onChange re-inserts into the dict, so we // must not iterate the live dictionary while mutating it. let hosts = Array(deferredDuringGlobalInteraction.values) + if gtkNarrowDebugEnabled { + let line = "[narrow-pass] deferred hosts = \(hosts.count)" + if line != lastNarrowPassLog { + lastNarrowPassLog = line + FileHandle.standardError.write(Data((line + "\n").utf8)) + } + } for host in hosts where host.isContainerAlive { // Push narrow (text/color/canvas) in-place updates so NATIVE widgets // bound to the dragged value — a TextField's GtkEntry, say — track @@ -343,23 +353,25 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { ) let plan = gtkPlanDescriptorTree(old: oldRetained, new: newIdentified) - // Diagnostics (SWIFTOPENUI_NARROW_DEBUG): during a drag, if the narrow path - // is about to be rejected, log the offending node(s) — the reason a host - // falls to a full rebuild instead of updating in place. Deduped so a held - // drag doesn't flood stderr. - if gtkNarrowDebugEnabled, - GTKViewHost.globalInteractionDepth > 0, - !gtkCanApplyTextColorHostMutation(plan: plan) { - var reasons: [String] = [] - gtkCollectNonNarrowReasons(plan, into: &reasons) - let line = "[narrow-reject] " + reasons.prefix(8).joined(separator: " | ") + // Diagnostics (SWIFTOPENUI_NARROW_DEBUG): log the full narrow-path outcome + // during a drag — whether the gate rejected (and why), or passed but the + // mutation failed at slot-validation / hook time, or applied. Deduped. + let debugInteraction = gtkNarrowDebugEnabled && GTKViewHost.globalInteractionDepth > 0 + func narrowLog(_ line: String) { if line != GTKViewHost.lastNarrowRejectLog { GTKViewHost.lastNarrowRejectLog = line FileHandle.standardError.write(Data((line + "\n").utf8)) } } - if gtkCanApplyTextColorHostMutation(plan: plan) { + let canApply = gtkCanApplyTextColorHostMutation(plan: plan) + if debugInteraction, !canApply { + var reasons: [String] = [] + gtkCollectNonNarrowReasons(plan, into: &reasons) + narrowLog("[narrow-reject] " + reasons.prefix(8).joined(separator: " | ")) + } + + if canApply { let action = gtkExecuteDescriptorPlan( old: oldExecutor, plan: plan, @@ -368,13 +380,17 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { // Verify all slots are still valid before mutating let allSlotsValid = gtkAllSlotsValid(action: action) + if !allSlotsValid, debugInteraction { narrowLog("[narrow-slots-invalid] plan accepted but a slot was nil/dead") } if allSlotsValid { let result = gtkApplyHookMutation(action: action) if gtkHookMutationSucceeded(result) { + if debugInteraction { narrowLog("[narrow-APPLIED] in-place update") } // Success — update retained state, skip full rebuild lastRetainedDescriptor = gtkRetainDescriptorTree(newIdentified) retainedExecutor = action.resultingNode return true + } else if debugInteraction { + narrowLog("[narrow-hook-failed] gtkApplyHookMutation returned failure") } } } From a9d8b7073766b29c6466ad78d8b04df2f9042f8d Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 15:35:11 -0700 Subject: [PATCH 09/12] GTK4: log supported-slot count mismatch (narrow slot-capture bail) 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. --- Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift index 62b0feed..a401bf7e 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift @@ -1271,6 +1271,15 @@ public func gtkCaptureSupportedNativeSlots( gtkCollectSupportedHostedWidgets(from: widgetRoot, into: &supportedWidgets) guard supportedDescriptors.count == supportedWidgets.count else { + // Diagnostics: this bail (no slots assigned → later narrow updates see + // nil slots) is a leading cause of `[narrow-slots-invalid]`. Log the + // mismatch: which descriptor leaf-kinds vs which widget hosted-kinds. + if gtkNarrowDebugEnabled { + let dk = supportedDescriptors.map { "\($0.kind)" }.joined(separator: ",") + let wk = supportedWidgets.map { "\(gtkHostedNodeKind(of: $0))" }.joined(separator: ",") + FileHandle.standardError.write(Data( + "[slot-mismatch] desc=\(supportedDescriptors.count)[\(dk)] widgets=\(supportedWidgets.count)[\(wk)]\n".utf8)) + } return executorRoot } From f60b6c524d7d6ce53bb21dcc68a71bbda3e4a5ac Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 15:46:49 -0700 Subject: [PATCH 10/12] GTK4: Button describes its custom label's children (slot-count balance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Backend/GTK4/Rendering/GTKRenderer.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift index 1dac82e0..f36099bc 100644 --- a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift +++ b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift @@ -448,7 +448,22 @@ extension Button: GTKRenderable, GTKDescribable { // dedicated kind prevents the narrow-mutation guard from rejecting // the entire tree when a Button appears alongside mutable nodes // (Canvas, Text, Slider, etc.). - GTK4DescriptorNode(kind: .button, typeName: "Button") + // + // A `Text` label renders as the GtkButton's OWN native label (see + // gtkCreateWidget: `gtk_button_new_with_label`) — no separate hosted + // widget — so describe a childless leaf. A CUSTOM label view is rendered + // as child widgets (`gtkRenderView(label)`), whose hosted leaves + // (Text/Canvas/…) are collected during slot capture; they must appear in + // the descriptor too, or the descriptor/widget leaf counts mismatch and + // `gtkCaptureSupportedNativeSlots` bails (assigning no slots → later + // narrow updates see nil slots). + if label is Text { + return GTK4DescriptorNode(kind: .button, typeName: "Button") + } + return GTK4DescriptorNode( + kind: .button, typeName: "Button", + children: [gtkDescribeView(label)] + ) } public func gtkCreateWidget() -> OpaquePointer { From 9631e87302f96c3acce8bf3d7f84d86ffdd36c03 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 16:41:38 -0700 Subject: [PATCH 11/12] GTK4: remove narrow-path diagnostics (Patch F validated) 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. --- .../GTK4/Rendering/GTK4DescriptorTree.swift | 38 ------------------- .../Backend/GTK4/Rendering/GTKViewHost.swift | 36 +----------------- 2 files changed, 1 insertion(+), 73 deletions(-) diff --git a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift index a401bf7e..2bd529f4 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift @@ -869,35 +869,6 @@ public func gtkHookMutationSucceeded(_ result: GTK4HookResult) -> Bool { /// Opaque composites (Body = Never, no describable conformance) with no /// described children are rejected — their child content is not captured /// in the descriptor, so we can't prove nothing changed inside. -/// Debug (SWIFTOPENUI_NARROW_DEBUG): true when narrow-path rejection diagnostics -/// should be logged. Cheap `getenv` at first access. -public let gtkNarrowDebugEnabled: Bool = - ProcessInfo.processInfo.environment["SWIFTOPENUI_NARROW_DEBUG"] != nil - -/// Debug: collect the nodes that make `gtkCanApplyTextColorHostMutation` reject a -/// plan — i.e. why a host fell to a full rebuild instead of a narrow update. Each -/// entry names the plan action, the descriptor kind, and its `typeName`. -public func gtkCollectNonNarrowReasons(_ plan: GTK4DescriptorPlan, into out: inout [String]) { - switch plan.kind { - case .create, .replace: - out.append("\(plan.kind) \(plan.newDescriptor.kind) '\(plan.newDescriptor.typeName)'") - return - case .reuse: - if plan.newDescriptor.kind == .composite && plan.children.isEmpty { - out.append("reuse EMPTY .composite '\(plan.newDescriptor.typeName)'") - return - } - case .update: - switch plan.updateIntent { - case .textContent, .colorFill, .canvasContent, .sliderValue, .textFieldValue, .paddingLayout: - break - default: - out.append("update intent=\(plan.updateIntent) \(plan.newDescriptor.kind) '\(plan.newDescriptor.typeName)'") - } - } - for child in plan.children { gtkCollectNonNarrowReasons(child, into: &out) } -} - public func gtkCanApplyTextColorHostMutation(plan: GTK4DescriptorPlan) -> Bool { switch plan.kind { case .create, .replace: @@ -1271,15 +1242,6 @@ public func gtkCaptureSupportedNativeSlots( gtkCollectSupportedHostedWidgets(from: widgetRoot, into: &supportedWidgets) guard supportedDescriptors.count == supportedWidgets.count else { - // Diagnostics: this bail (no slots assigned → later narrow updates see - // nil slots) is a leading cause of `[narrow-slots-invalid]`. Log the - // mismatch: which descriptor leaf-kinds vs which widget hosted-kinds. - if gtkNarrowDebugEnabled { - let dk = supportedDescriptors.map { "\($0.kind)" }.joined(separator: ",") - let wk = supportedWidgets.map { "\(gtkHostedNodeKind(of: $0))" }.joined(separator: ",") - FileHandle.standardError.write(Data( - "[slot-mismatch] desc=\(supportedDescriptors.count)[\(dk)] widgets=\(supportedWidgets.count)[\(wk)]\n".utf8)) - } return executorRoot } diff --git a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift index 55843559..ddc489a7 100644 --- a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift +++ b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift @@ -160,13 +160,6 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { /// dragged gesture's widget. Accessed only on the GTK main thread, so no lock. static var globalInteractionDepth: Int = 0 - /// Last narrow-rejection/outcome diagnostic line printed (SWIFTOPENUI_NARROW_DEBUG), - /// to dedupe a held drag's repeated logs. Main-thread only. - static var lastNarrowRejectLog: String = "" - /// Separate dedupe for the per-pass "deferred hosts = N" line so it doesn't - /// alternate with the outcome lines and re-print every motion event. - static var lastNarrowPassLog: String = "" - /// Hosts that deferred a rebuild during the current global interaction, /// flushed exactly once when the interaction ends. Strong refs are fine — /// entries live only for the duration of a drag. @@ -201,13 +194,6 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { // re-register observation whose onChange re-inserts into the dict, so we // must not iterate the live dictionary while mutating it. let hosts = Array(deferredDuringGlobalInteraction.values) - if gtkNarrowDebugEnabled { - let line = "[narrow-pass] deferred hosts = \(hosts.count)" - if line != lastNarrowPassLog { - lastNarrowPassLog = line - FileHandle.standardError.write(Data((line + "\n").utf8)) - } - } for host in hosts where host.isContainerAlive { // Push narrow (text/color/canvas) in-place updates so NATIVE widgets // bound to the dragged value — a TextField's GtkEntry, say — track @@ -353,25 +339,7 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { ) let plan = gtkPlanDescriptorTree(old: oldRetained, new: newIdentified) - // Diagnostics (SWIFTOPENUI_NARROW_DEBUG): log the full narrow-path outcome - // during a drag — whether the gate rejected (and why), or passed but the - // mutation failed at slot-validation / hook time, or applied. Deduped. - let debugInteraction = gtkNarrowDebugEnabled && GTKViewHost.globalInteractionDepth > 0 - func narrowLog(_ line: String) { - if line != GTKViewHost.lastNarrowRejectLog { - GTKViewHost.lastNarrowRejectLog = line - FileHandle.standardError.write(Data((line + "\n").utf8)) - } - } - - let canApply = gtkCanApplyTextColorHostMutation(plan: plan) - if debugInteraction, !canApply { - var reasons: [String] = [] - gtkCollectNonNarrowReasons(plan, into: &reasons) - narrowLog("[narrow-reject] " + reasons.prefix(8).joined(separator: " | ")) - } - - if canApply { + if gtkCanApplyTextColorHostMutation(plan: plan) { let action = gtkExecuteDescriptorPlan( old: oldExecutor, plan: plan, @@ -380,11 +348,9 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { // Verify all slots are still valid before mutating let allSlotsValid = gtkAllSlotsValid(action: action) - if !allSlotsValid, debugInteraction { narrowLog("[narrow-slots-invalid] plan accepted but a slot was nil/dead") } if allSlotsValid { let result = gtkApplyHookMutation(action: action) if gtkHookMutationSucceeded(result) { - if debugInteraction { narrowLog("[narrow-APPLIED] in-place update") } // Success — update retained state, skip full rebuild lastRetainedDescriptor = gtkRetainDescriptorTree(newIdentified) retainedExecutor = action.resultingNode From 810fdb498c3204444334a8916783bdb67c9eded9 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Tue, 8 Sep 2026 18:59:57 -0700 Subject: [PATCH 12/12] GTK4: broaden narrow-path coverage (batch 2 of Patch F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Backend/GTK4/Rendering/GTKRenderer.swift | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift index f36099bc..56d2fce8 100644 --- a/Sources/Backend/GTK4/Rendering/GTKRenderer.swift +++ b/Sources/Backend/GTK4/Rendering/GTKRenderer.swift @@ -7784,3 +7784,79 @@ extension Optional: GTKContentWrapper where Wrapped: View { } } } + +// MARK: - Broader narrow-path coverage (Patch F, batch 2) +// +// More views that described as empty `.composite`s and poisoned a host's narrow +// path. Same principle as batch 1: describe what gtkCreateWidget renders inline, +// so descriptor and widget leaves stay balanced for slot capture. + +// Type-erased single view. +extension AnyView: GTKContentWrapper { + public var gtkWrappedContent: any View { wrapped } +} + +// Single-content wrappers whose `content` is the inline base view (the modal / +// drop / grid-cell chrome is auxiliary and rendered elsewhere). +extension DropDestinationView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension GridCellSpanView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension FullScreenCoverView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension PopoverView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension SheetModifierView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension ItemSheetModifierView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension AlertModifierView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension ConfirmationDialogView: GTKContentWrapper { public var gtkWrappedContent: any View { content } } + +// Container wrappers that render `content` inline as their body. +extension List: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension Grid: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension DisclosureGroup: GTKContentWrapper { public var gtkWrappedContent: any View { content } } +extension Section: GTKContentWrapper { public var gtkWrappedContent: any View { content } } + +// Lazy stacks/grids render one child per data item — expose them as children so +// each item's leaves participate in the narrow path (like ForEach). +extension LazyVStack: MultiChildView { + public var children: [any View] { items.map { contentBuilder($0) as any View } } +} +extension LazyHStack: MultiChildView { + public var children: [any View] { items.map { contentBuilder($0) as any View } } +} +extension LazyVGrid: MultiChildView { + public var children: [any View] { items.map { contentBuilder($0) as any View } } +} +extension LazyHGrid: MultiChildView { + public var children: [any View] { items.map { contentBuilder($0) as any View } } +} + +// Opaque native leaf widgets (native labels/entries — no marked inner widgets). +extension SecureField: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(placeholder), AnyHashable(text.wrappedValue)]) + } +} +extension TextEditor: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { AnyHashable(text.wrappedValue) } +} +extension DatePicker: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(title), AnyHashable(selection?.wrappedValue)]) + } +} +extension ProgressView: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(value), AnyHashable(total), AnyHashable(title)]) + } +} +extension Link: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(title), AnyHashable(destination)]) + } +} +extension Label: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { + AnyHashable([AnyHashable(title), AnyHashable(systemImage), AnyHashable(imagePath)]) + } +} +extension Image: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { AnyHashable(String(describing: source)) } +}