From f4bc8fb53904d876b02a48bbf7bdb5077e691b3a Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Mon, 7 Sep 2026 12:54:53 -0700 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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)) } +} From ecbd1da2dea30d17bda522ef3f107641e7ef014e Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Wed, 9 Sep 2026 07:28:11 -0700 Subject: [PATCH 13/21] GTK4: imperative standalone-window API (GTK4Backend.openStandaloneWindow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a runtime "open a top-level window hosting this view" entry point outside the App/Scene tree, the imperative counterpart to the declarative Window scene. This is what host abstractions like Lyrebird's LyrebirdWindow need to bring back "Open Window" buttons on Linux (parity with the macOS NSWindow path). - shim.h: gtk_swift_get_default_gtk_application() — the default GApplication cast to GtkApplication (mirrors the existing gtk_swift_get_active_window helper), so a window opened at runtime parents to the GtkApplication started by run(_:). - GTK4StandaloneWindow.swift: GTK4Backend.openStandaloneWindow(title:width:height: onClose:content:) renders the view via gtkRenderView, presents a GtkApplicationWindow, and returns a GTK4StandaloneWindowHandle (present/close/ isOpen). A single destroy-signal path clears the handle and fires onClose once, covering both close() and the WM close button. Returns nil if no GtkApplication is running. Co-Authored-By: Claude Opus 4.8 --- Sources/Backend/GTK4/CGTK/shim.h | 13 ++ .../GTK4/Rendering/GTK4StandaloneWindow.swift | 112 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift diff --git a/Sources/Backend/GTK4/CGTK/shim.h b/Sources/Backend/GTK4/CGTK/shim.h index c2d47d6b..d32556c9 100644 --- a/Sources/Backend/GTK4/CGTK/shim.h +++ b/Sources/Backend/GTK4/CGTK/shim.h @@ -1191,3 +1191,16 @@ gtk_swift_get_active_window(void) { if (!app || !GTK_IS_APPLICATION(app)) return NULL; return gtk_application_get_active_window(GTK_APPLICATION(app)); } + +/// Return the process default GApplication cast to GtkApplication, or NULL if +/// there is no default application or it is not a GtkApplication. Used to +/// parent an imperatively-opened standalone window (via +/// GTK4Backend.openStandaloneWindow) to the GtkApplication started by +/// GTK4Backend.run(_:), so a window can be opened at runtime from outside the +/// App/Scene tree. +static inline GtkApplication * +gtk_swift_get_default_gtk_application(void) { + GApplication *app = g_application_get_default(); + if (!app || !GTK_IS_APPLICATION(app)) return NULL; + return GTK_APPLICATION(app); +} diff --git a/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift b/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift new file mode 100644 index 00000000..1bd8406c --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift @@ -0,0 +1,112 @@ +import CGTK +import CGTKBridge +import SwiftOpenUI +import Foundation + +/// Handle to a standalone GTK window opened imperatively via +/// ``GTK4Backend/openStandaloneWindow(title:width:height:onClose:content:)``. +/// +/// The caller retains the handle to keep control of the window's lifetime. The +/// window is owned by GTK; ``close()`` destroys it, and a user close (WM close +/// button) is reported through the `onClose` callback and flips ``isOpen`` to +/// `false`. This is the GTK4 analogue of the AppKit `NSWindow` + delegate that +/// backs `LyrebirdWindow` on macOS. +public final class GTK4StandaloneWindowHandle { + + /// Live GTK window pointer, or `nil` once the window has been closed + /// (either via ``close()`` or by the user). Cleared by the destroy signal, + /// so it is a single source of truth for "is this window still open". + fileprivate var winPtr: UnsafeMutablePointer? + + fileprivate init(winPtr: UnsafeMutablePointer) { + self.winPtr = winPtr + } + + /// Whether the window is still open. + public var isOpen: Bool { winPtr != nil } + + /// Bring the window to the front (no-op once closed). + public func present() { + guard let win: UnsafeMutablePointer = winPtr else { return } + gtk_window_present(win) + } + + /// Close and destroy the window. The destroy signal clears ``winPtr`` and + /// fires the `onClose` callback, so calling this more than once is safe. + public func close() { + guard let win: UnsafeMutablePointer = winPtr else { return } + gtk_window_destroy(win) + } +} + +extension GTK4Backend { + + /// Open a standalone top-level GTK window hosting `content`, parented to the + /// running `GtkApplication` started by ``run(_:)``. + /// + /// This is the imperative counterpart to the declarative `Window` /scene + /// path: it opens a window at runtime from outside the App/Scene tree, which + /// is what a host like `LyrebirdWindow`'s "Open Window" buttons need. + /// + /// - Important: A `GtkApplication` must already be running (i.e. you are + /// inside a ``run(_:)`` main loop). If there is no default application, + /// this returns `nil`. + /// + /// - Parameters: + /// - title: Window title-bar text. + /// - width: Initial window width in points. + /// - height: Initial window height in points. + /// - onClose: Invoked (once) when the window is closed, whether by + /// ``GTK4StandaloneWindowHandle/close()`` or by the user. Runs on the + /// GTK main thread. + /// - content: The SwiftOpenUI view to host. + /// - Returns: A handle for controlling/closing the window, or `nil` if no + /// `GtkApplication` is running. + public static func openStandaloneWindow( + title: String, + width: Int, + height: Int, + onClose: (() -> Void)? = nil, + @ViewBuilder content: () -> Content + ) -> GTK4StandaloneWindowHandle? { + guard let appPtr: UnsafeMutablePointer = gtk_swift_get_default_gtk_application() else { + return nil + } + + guard let rawWindow = gtk_application_window_new(appPtr) else { return nil } + let winPtr: UnsafeMutablePointer = windowPointer(rawWindow) + gtk_window_set_title(winPtr, title) + gtk_window_set_default_size(winPtr, gint(width), gint(height)) + + let contentWidget: UnsafeMutablePointer = widgetFromOpaque(gtkRenderView(content())) + gtkConfigureRootContentToFillWindow(contentWidget) + gtk_window_set_child(winPtr, contentWidget) + + let handle: GTK4StandaloneWindowHandle = GTK4StandaloneWindowHandle(winPtr: winPtr) + + // Single teardown path: the destroy signal clears the handle and fires + // onClose exactly once, covering both close() and the WM close button. + // The handle is captured weakly so dropping the caller's reference does + // not keep it alive; onClose is captured strongly so it still fires. + let box: ClosureBox = ClosureBox { [weak handle] in + handle?.winPtr = nil + onClose?() + } + let userData: UnsafeMutableRawPointer = Unmanaged.passRetained(box).toOpaque() + g_signal_connect_data( + gpointer(winPtr), "destroy", + unsafeBitCast({ (_: gpointer?, ud: gpointer?) in + guard let ud else { return } + Unmanaged.fromOpaque(ud).takeUnretainedValue().closure() + } as @convention(c) (gpointer?, gpointer?) -> Void, to: GCallback.self), + userData, + { (data: gpointer?, _: UnsafeMutablePointer?) in + if let data { Unmanaged.fromOpaque(data).release() } + }, + GConnectFlags(rawValue: 0) + ) + + gtk_window_present(winPtr) + return handle + } +} From bc246c995124e3c1c39b661435e35b592928920a Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Wed, 9 Sep 2026 08:14:48 -0700 Subject: [PATCH 14/21] GTK4: fix Linux build breaks in the batch-2 narrow-path coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broadened narrow-path coverage (810fdb4) never compiled on Linux; three breaks: - GTKViewHost: an orphaned `else if debugInteraction { narrowLog(...) }` debug branch survived the removal of the SWIFTOPENUI_NARROW_DEBUG lever — both symbols are undefined. Drop the dead branch. - DateComponents (SwiftOpenUI's own no-Foundation value type) was Equatable but not Hashable, so DatePicker's GTKOpaqueLeaf state signature (AnyHashable(selection?.wrappedValue)) failed to type-check. Conform it to Hashable (synthesized from its three Int fields). Verified: `swift build --product BackendGTK4` is clean in a GTK4 Linux container. Co-Authored-By: Claude Opus 4.8 --- Sources/Backend/GTK4/Rendering/GTKViewHost.swift | 2 -- Sources/SwiftOpenUI/Views/DatePicker.swift | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift index ddc489a7..69ee1feb 100644 --- a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift +++ b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift @@ -355,8 +355,6 @@ public class GTKViewHost: AnyViewHost, DependencyTrackingHost { lastRetainedDescriptor = gtkRetainDescriptorTree(newIdentified) retainedExecutor = action.resultingNode return true - } else if debugInteraction { - narrowLog("[narrow-hook-failed] gtkApplyHookMutation returned failure") } } } diff --git a/Sources/SwiftOpenUI/Views/DatePicker.swift b/Sources/SwiftOpenUI/Views/DatePicker.swift index 57f23066..3b0d20d7 100644 --- a/Sources/SwiftOpenUI/Views/DatePicker.swift +++ b/Sources/SwiftOpenUI/Views/DatePicker.swift @@ -1,7 +1,11 @@ import Foundation /// A simple date value type for DatePicker (no Foundation dependency). -public struct DateComponents: Equatable { +/// +/// `Hashable` (synthesized from the three `Int` fields) so it can back a +/// `GTKOpaqueLeaf` state signature — unlike Foundation's `DateComponents`, +/// which is not `Hashable` on swift-corelibs-foundation. +public struct DateComponents: Hashable { public var year: Int public var month: Int public var day: Int From 004bfc7bb9435515fe5879e8f286510bbcdd7557 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Wed, 9 Sep 2026 09:06:02 -0700 Subject: [PATCH 15/21] GTK4: process-wide pointer tracking (GTK4PointerTracking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a window-local pointer source so a host can receive normalized pointer motion/button events from the windows GTK4Backend opens — the GTK4 counterpart to a global pointer monitor, backing Lyrebird's Linux MouseTracker (feeding the Mouse.X/Y/Click generators). - GTK4PointerTracking: a process-wide sink (install(onMove:onButton:)); coords normalized to the window, y flipped to bottom-origin (screen-up), button = primary state. - gtkAttachPointerTracking(to:): attaches a GtkEventControllerMotion (motion → normalized x/y via the widget's live allocation) and a window-level GtkGestureClick (pressed/released → button) that forward to the sink. Handlers read the installed sink at event time, so install timing is not sensitive. - Wired into WindowGroup + Window scene rendering and openStandaloneWindow, so every opened window forwards pointer events. Verified: `swift build --product BackendGTK4` clean in a GTK4 Linux container. Co-Authored-By: Claude Opus 4.8 --- .../Backend/GTK4/Rendering/GTK4Backend.swift | 2 + .../GTK4/Rendering/GTK4PointerTracking.swift | 93 +++++++++++++++++++ .../GTK4/Rendering/GTK4StandaloneWindow.swift | 1 + 3 files changed, 96 insertions(+) create mode 100644 Sources/Backend/GTK4/Rendering/GTK4PointerTracking.swift diff --git a/Sources/Backend/GTK4/Rendering/GTK4Backend.swift b/Sources/Backend/GTK4/Rendering/GTK4Backend.swift index c85e2239..5a1457ed 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4Backend.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4Backend.swift @@ -162,6 +162,7 @@ extension WindowGroup: GTKWindowRenderable { gtkSetupMenuBarIfNeeded(winPtr: winWidget, contentWidget: contentWidget, windowID: Int(bitPattern: winPtr)) gtkAttachKeyboardShortcutController(to: winWidget) gtkAttachWindowActivationHandler(to: winWidget) + gtkAttachPointerTracking(to: winWidget) gtk_window_present(winPtr) } } @@ -673,6 +674,7 @@ extension Window: GTKWindowRenderable { gtkSetupMenuBarIfNeeded(winPtr: winWidget, contentWidget: contentWidget, windowID: Int(bitPattern: winPtr)) gtkAttachKeyboardShortcutController(to: winWidget) gtkAttachWindowActivationHandler(to: winWidget) + gtkAttachPointerTracking(to: winWidget) gtk_window_present(winPtr) // Track the live window so repeated openWindow(id:) refocuses diff --git a/Sources/Backend/GTK4/Rendering/GTK4PointerTracking.swift b/Sources/Backend/GTK4/Rendering/GTK4PointerTracking.swift new file mode 100644 index 00000000..6752dbde --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/GTK4PointerTracking.swift @@ -0,0 +1,93 @@ +import CGTK +import CGTKBridge +import Foundation + +/// Process-wide sink for pointer motion/button events from the windows +/// `GTK4Backend` opens. A host (e.g. Lyrebird's Linux `MouseTracker`) installs +/// handlers here; the backend attaches a `GtkEventControllerMotion` and a +/// `GtkGestureClick` to each top-level window and forwards normalized updates. +/// +/// This is the GTK4 counterpart to a global pointer monitor (AppKit's +/// `NSEvent.addGlobalMonitorForEvents`), scoped window-local: it reports while +/// the pointer is over one of the app's windows. Coordinates are normalized to +/// the window: `x` 0…1 left→right, `y` 0…1 bottom→top (y flipped from GTK's +/// top-left origin so it matches screen-up conventions). `pressed` is the +/// primary-button state. +/// +/// Handlers run on the GTK main thread. +public enum GTK4PointerTracking { + + /// Normalized (x, y) move handler. Set via ``install(onMove:onButton:)``. + nonisolated(unsafe) static var onMove: ((Double, Double) -> Void)? + + /// Primary-button state handler (`true` = pressed). + nonisolated(unsafe) static var onButton: ((Bool) -> Void)? + + /// Install the pointer sink. Existing windows created before this call are + /// not retrofitted, but the app's main `WindowGroup` window attaches its + /// controllers at creation and forwards to whatever is installed here, so + /// installing before or after `run(_:)` both work. + /// + /// - Parameters: + /// - onMove: Called with normalized `(x, y)` on pointer motion. + /// - onButton: Called with the primary-button state on press/release. + public static func install( + onMove: @escaping (Double, Double) -> Void, + onButton: @escaping (Bool) -> Void + ) { + self.onMove = onMove + self.onButton = onButton + } +} + +/// Attach motion + click controllers to `widget` (a top-level window) that +/// forward to ``GTK4PointerTracking``. Called by the window-creating paths. +func gtkAttachPointerTracking(to widget: UnsafeMutablePointer) { + // Motion: coordinates are in the controlled widget's space; normalize by its + // current allocation. The widget pointer rides as user_data so the handler + // can read the live size. + guard let motion = gtk_event_controller_motion_new() else { return } + g_signal_connect_data( + gpointer(motion), "motion", + unsafeBitCast({ (_: OpaquePointer?, x: Double, y: Double, ud: gpointer?) in + guard let ud else { return } + let w: UnsafeMutablePointer = ud.assumingMemoryBound(to: GtkWidget.self) + let width: Double = Double(gtk_widget_get_width(w)) + let height: Double = Double(gtk_widget_get_height(w)) + guard width > 0, height > 0 else { return } + let nx: Double = min(max(x / width, 0), 1) + // Flip Y so 1 = top (GTK's origin is top-left). + let ny: Double = min(max(1 - y / height, 0), 1) + GTK4PointerTracking.onMove?(nx, ny) + } as @convention(c) (OpaquePointer?, Double, Double, gpointer?) -> Void, + to: GCallback.self), + gpointer(widget), nil, + GConnectFlags(rawValue: 0) + ) + gtk_widget_add_controller(widget, motion) + + // Primary-button state. A window-level GtkGestureClick in the default + // (bubble) phase sees presses not consumed by an interactive child — enough + // for a baseline Mouse.Click; motion (x/y) is the fully-covered case. + guard let click = gtk_gesture_click_new() else { return } + gtk_swift_gesture_single_set_button(click, 1) // primary button + g_signal_connect_data( + gpointer(click), "pressed", + unsafeBitCast({ (_: gpointer?, _: gint, _: Double, _: Double, _: gpointer?) in + GTK4PointerTracking.onButton?(true) + } as @convention(c) (gpointer?, gint, Double, Double, gpointer?) -> Void, + to: GCallback.self), + nil, nil, + GConnectFlags(rawValue: 0) + ) + g_signal_connect_data( + gpointer(click), "released", + unsafeBitCast({ (_: gpointer?, _: gint, _: Double, _: Double, _: gpointer?) in + GTK4PointerTracking.onButton?(false) + } as @convention(c) (gpointer?, gint, Double, Double, gpointer?) -> Void, + to: GCallback.self), + nil, nil, + GConnectFlags(rawValue: 0) + ) + gtk_swift_add_gesture(widget, click) +} diff --git a/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift b/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift index 1bd8406c..f230ffc6 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4StandaloneWindow.swift @@ -81,6 +81,7 @@ extension GTK4Backend { let contentWidget: UnsafeMutablePointer = widgetFromOpaque(gtkRenderView(content())) gtkConfigureRootContentToFillWindow(contentWidget) gtk_window_set_child(winPtr, contentWidget) + gtkAttachPointerTracking(to: widgetPointer(winPtr)) let handle: GTK4StandaloneWindowHandle = GTK4StandaloneWindowHandle(winPtr: winPtr) From 29fd1f179f7cc67264f93ac7abc40b28ce4ad072 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Thu, 10 Sep 2026 13:05:53 -0700 Subject: [PATCH 16/21] GTK4: CodeEditor view backed by GtkSourceView 5 (syntax highlighting + gutter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `CodeEditor` primitive view for source editing: on the GTK4 backend it is a GtkSourceView configured for Swift — syntax highlighting, a line-number gutter, monospace, 4-space soft tabs, auto-indent, current-line highlight — with a two-way text binding via the buffer's "changed" signal (same shape as TextEditor). - CGtkSource: a new system-library module (pkgConfig "gtksourceview-5", apt libgtksourceview-5-dev) exposing an OpaquePointer-only shim so gtksourceview's C types never cross into the gtk4-only CGTK module. - SwiftOpenUI: `CodeEditor(text:)` primitive view. - BackendGTK4: `CodeEditor: GTKRenderable` rendering the source view in a scrolled window. Verified: `swift build --product BackendGTK4` links clean in a GTK4 Linux container with libgtksourceview-5-dev installed. Co-Authored-By: Claude Opus 4.8 --- Package.swift | 11 ++- .../Backend/GTK4/CGtkSource/module.modulemap | 5 ++ Sources/Backend/GTK4/CGtkSource/shim.h | 76 +++++++++++++++++++ .../GTK4/Rendering/CodeEditorGTK.swift | 62 +++++++++++++++ Sources/SwiftOpenUI/Views/CodeEditor.swift | 15 ++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 Sources/Backend/GTK4/CGtkSource/module.modulemap create mode 100644 Sources/Backend/GTK4/CGtkSource/shim.h create mode 100644 Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift create mode 100644 Sources/SwiftOpenUI/Views/CodeEditor.swift diff --git a/Package.swift b/Package.swift index ccfdcb5e..c51d5e7b 100644 --- a/Package.swift +++ b/Package.swift @@ -64,6 +64,15 @@ targets += [ pkgConfig: "gtk4", providers: [.apt(["libgtk-4-dev"])] ), + // GtkSourceView 5 — the code-editor widget behind SwiftOpenUI's `CodeEditor` + // (syntax highlighting / gutter). Its own system-library module so the + // gtksourceview include path + link stay off the gtk4-only CGTK module. + .systemLibrary( + name: "CGtkSource", + path: "Sources/Backend/GTK4/CGtkSource", + pkgConfig: "gtksourceview-5", + providers: [.apt(["libgtksourceview-5-dev"])] + ), .target( name: "CGTKBridge", dependencies: ["CGTK"], @@ -71,7 +80,7 @@ targets += [ ), .target( name: "BackendGTK4", - dependencies: ["SwiftOpenUI", "CGTK", "CGTKBridge", "SwiftOpenUISymbols"], + dependencies: ["SwiftOpenUI", "CGTK", "CGtkSource", "CGTKBridge", "SwiftOpenUISymbols"], path: "Sources/Backend/GTK4/Rendering", linkerSettings: [ // FontConfig is used by the process-local font registration diff --git a/Sources/Backend/GTK4/CGtkSource/module.modulemap b/Sources/Backend/GTK4/CGtkSource/module.modulemap new file mode 100644 index 00000000..1d4b7d65 --- /dev/null +++ b/Sources/Backend/GTK4/CGtkSource/module.modulemap @@ -0,0 +1,5 @@ +module CGtkSource [system] { + header "shim.h" + link "gtksourceview-5" + export * +} diff --git a/Sources/Backend/GTK4/CGtkSource/shim.h b/Sources/Backend/GTK4/CGtkSource/shim.h new file mode 100644 index 00000000..2eb6880d --- /dev/null +++ b/Sources/Backend/GTK4/CGtkSource/shim.h @@ -0,0 +1,76 @@ +// CGtkSource — a thin, OpaquePointer-only shim over GtkSourceView 5, kept in its +// own system-library module (pkgConfig "gtksourceview-5") so the gtksourceview +// include path + link don't have to be forced onto the gtk4-only CGTK module. +// +// Every function takes/returns `void*` (an opaque GtkWidget* / GtkTextBuffer*), +// so the Swift caller treats the handles as `OpaquePointer`/`gpointer` and never +// imports GtkSourceView's C types alongside CGTK's — avoiding cross-module +// GtkWidget/GtkTextBuffer redefinitions. Signal wiring stays on the CGTK side +// (the buffer is just a gpointer to g_signal_connect_data). +#ifndef LYREBIRD_CGTKSOURCE_SHIM_H +#define LYREBIRD_CGTKSOURCE_SHIM_H + +#include + +// Create a code editor view configured for Swift: syntax highlighting, a +// line-number gutter, a monospace font, 4-space soft tabs, and auto-indent. +// Returns the GtkSourceView as an opaque GtkWidget*. +static inline void * +gtk_swift_source_view_new(void) { + GtkSourceView *view = GTK_SOURCE_VIEW(gtk_source_view_new()); + gtk_source_view_set_show_line_numbers(view, TRUE); + gtk_source_view_set_auto_indent(view, TRUE); + gtk_source_view_set_highlight_current_line(view, TRUE); + gtk_source_view_set_tab_width(view, 4); + gtk_source_view_set_insert_spaces_instead_of_tabs(view, TRUE); + + // Monospace is a GtkTextView property in GtkSourceView 5 (there is no + // gtk_source_view_set_monospace). + GtkTextView *tv = GTK_TEXT_VIEW(view); + gtk_text_view_set_monospace(tv, TRUE); + + GtkSourceBuffer *buf = + GTK_SOURCE_BUFFER(gtk_text_view_get_buffer(tv)); + GtkSourceLanguageManager *lm = gtk_source_language_manager_get_default(); + GtkSourceLanguage *lang = + gtk_source_language_manager_get_language(lm, "swift"); + if (lang) { + gtk_source_buffer_set_language(buf, lang); + gtk_source_buffer_set_highlight_syntax(buf, TRUE); + } + return (void *)view; +} + +// The view's GtkSourceBuffer as an opaque GtkTextBuffer* (it is a subclass, so +// the base gtk_text_buffer_* API applies). +static inline void * +gtk_swift_source_view_get_buffer(void *view) { + return (void *)gtk_text_view_get_buffer(GTK_TEXT_VIEW(view)); +} + +static inline void +gtk_swift_source_buffer_set_text(void *buffer, const char *text, int length) { + gtk_text_buffer_set_text((GtkTextBuffer *)buffer, text, length); +} + +// Full buffer contents. Caller must g_free the returned string. +static inline char * +gtk_swift_source_buffer_get_text(void *buffer) { + GtkTextIter start, end; + gtk_text_buffer_get_bounds((GtkTextBuffer *)buffer, &start, &end); + return gtk_text_buffer_get_text((GtkTextBuffer *)buffer, &start, &end, FALSE); +} + +// Apply a named style scheme (e.g. "Adwaita-dark", "classic") when present. +static inline void +gtk_swift_source_buffer_set_style_scheme(void *buffer, const char *scheme_id) { + GtkSourceStyleSchemeManager *sm = + gtk_source_style_scheme_manager_get_default(); + GtkSourceStyleScheme *scheme = + gtk_source_style_scheme_manager_get_scheme(sm, scheme_id); + if (scheme) { + gtk_source_buffer_set_style_scheme((GtkSourceBuffer *)buffer, scheme); + } +} + +#endif /* LYREBIRD_CGTKSOURCE_SHIM_H */ diff --git a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift new file mode 100644 index 00000000..ad6c6bfb --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift @@ -0,0 +1,62 @@ +import CGTK +import CGtkSource +import CGTKBridge +import SwiftOpenUI + +// GTK4 rendering for `CodeEditor`: a GtkSourceView (Swift highlighting + +// line-number gutter, from the CGtkSource shim) inside a scrolled window, with a +// two-way text binding wired through the buffer's "changed" signal — the same +// shape as the plain `TextEditor` render, but the widget is created via the +// OpaquePointer-only CGtkSource shim so gtksourceview's C types never cross into +// this file. +extension CodeEditor: GTKRenderable { + public func gtkCreateWidget() -> OpaquePointer { + let viewRaw: UnsafeMutableRawPointer = gtk_swift_source_view_new() + let view: UnsafeMutablePointer = viewRaw.assumingMemoryBound(to: GtkWidget.self) + let bufferRaw: UnsafeMutableRawPointer = gtk_swift_source_view_get_buffer(viewRaw) + + // Best-effort dark scheme; ignored when the scheme id isn't installed. + "Adwaita-dark".withCString { (id: UnsafePointer) in + gtk_swift_source_buffer_set_style_scheme(bufferRaw, id) + } + + let current: String = text.wrappedValue + if !current.isEmpty { + current.withCString { (c: UnsafePointer) in + gtk_swift_source_buffer_set_text(bufferRaw, c, Int32(current.utf8.count)) + } + } + + let binding: Binding = text + let box: UnsafeMutableRawPointer = Unmanaged.passRetained(StringClosureBox { (newText: String) in + if newText != binding.wrappedValue { + binding.wrappedValue = newText + } + }).toOpaque() + g_signal_connect_data( + gpointer(bufferRaw), + "changed", + unsafeBitCast({ (bufferPtr: gpointer?, userData: gpointer?) in + guard let userData, let bufferPtr else { return } + let box = Unmanaged.fromOpaque(userData).takeUnretainedValue() + guard let cStr: UnsafeMutablePointer = gtk_swift_source_buffer_get_text(bufferPtr) else { return } + let result: String = String(cString: cStr) + g_free(UnsafeMutableRawPointer(cStr)) + box.closure(result) + } as @convention(c) (gpointer?, gpointer?) -> Void, to: GCallback.self), + box, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + if let userData { Unmanaged.fromOpaque(userData).release() } + }, + GConnectFlags(rawValue: 0) + ) + + let scrolled = gtk_scrolled_window_new()! + gtk_scrolled_window_set_policy(OpaquePointer(scrolled), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC) + gtk_scrolled_window_set_child(OpaquePointer(scrolled), view) + gtk_widget_set_vexpand(scrolled, 1) + gtk_widget_set_hexpand(scrolled, 1) + + return opaqueFromWidget(scrolled) + } +} diff --git a/Sources/SwiftOpenUI/Views/CodeEditor.swift b/Sources/SwiftOpenUI/Views/CodeEditor.swift new file mode 100644 index 00000000..9831886f --- /dev/null +++ b/Sources/SwiftOpenUI/Views/CodeEditor.swift @@ -0,0 +1,15 @@ +/// A multi-line source-code editor with syntax highlighting and a line-number +/// gutter. On the GTK4 backend this is a `GtkSourceView` configured for Swift; +/// other backends may fall back to a plain text editor. +public struct CodeEditor: View { + public typealias Body = Never + + public let text: Binding + + /// Create a code editor bound to `text`. The initial language is Swift. + public init(text: Binding) { + self.text = text + } + + public var body: Never { fatalError("CodeEditor is a primitive view") } +} From 78d6ff3eafe6e40b47703b50aa407e836e6c90a2 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Thu, 10 Sep 2026 13:34:40 -0700 Subject: [PATCH 17/21] GTK4: CodeEditor exposes its selection (for line-level evaluation) Add an optional `selection: Binding` to CodeEditor, mirrored out from the GtkSourceView on every "mark-set" (caret/selection move) via a new gtk_swift_source_buffer_get_selected_text shim (empty when nothing is selected). Lets a host evaluate just the selected lines, like the macOS editor. Verified: BackendGTK4 links clean in a GTK4 Linux container. Co-Authored-By: Claude Opus 4.8 --- Sources/Backend/GTK4/CGtkSource/shim.h | 12 +++++++++ .../GTK4/Rendering/CodeEditorGTK.swift | 27 +++++++++++++++++++ Sources/SwiftOpenUI/Views/CodeEditor.swift | 12 ++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Sources/Backend/GTK4/CGtkSource/shim.h b/Sources/Backend/GTK4/CGtkSource/shim.h index 2eb6880d..0317bcff 100644 --- a/Sources/Backend/GTK4/CGtkSource/shim.h +++ b/Sources/Backend/GTK4/CGtkSource/shim.h @@ -61,6 +61,18 @@ gtk_swift_source_buffer_get_text(void *buffer) { return gtk_text_buffer_get_text((GtkTextBuffer *)buffer, &start, &end, FALSE); } +// The currently selected text, or an empty string when nothing is selected. +// Caller must g_free the returned string. +static inline char * +gtk_swift_source_buffer_get_selected_text(void *buffer) { + GtkTextBuffer *b = (GtkTextBuffer *)buffer; + GtkTextIter start, end; + if (!gtk_text_buffer_get_selection_bounds(b, &start, &end)) { + return g_strdup(""); + } + return gtk_text_buffer_get_text(b, &start, &end, FALSE); +} + // Apply a named style scheme (e.g. "Adwaita-dark", "classic") when present. static inline void gtk_swift_source_buffer_set_style_scheme(void *buffer, const char *scheme_id) { diff --git a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift index ad6c6bfb..c5a4cebd 100644 --- a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift +++ b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift @@ -51,6 +51,33 @@ extension CodeEditor: GTKRenderable { GConnectFlags(rawValue: 0) ) + // Mirror the selection out (for line-level evaluation). "mark-set" fires + // whenever the caret or selection bound moves. + if let selectionBinding: Binding = selection { + let selBox: UnsafeMutableRawPointer = Unmanaged.passRetained(StringClosureBox { (sel: String) in + if sel != selectionBinding.wrappedValue { + selectionBinding.wrappedValue = sel + } + }).toOpaque() + g_signal_connect_data( + gpointer(bufferRaw), + "mark-set", + unsafeBitCast({ (bufferPtr: gpointer?, _: gpointer?, _: gpointer?, userData: gpointer?) in + guard let userData, let bufferPtr else { return } + let box = Unmanaged.fromOpaque(userData).takeUnretainedValue() + guard let cStr: UnsafeMutablePointer = gtk_swift_source_buffer_get_selected_text(bufferPtr) else { return } + let result: String = String(cString: cStr) + g_free(UnsafeMutableRawPointer(cStr)) + box.closure(result) + } as @convention(c) (gpointer?, gpointer?, gpointer?, gpointer?) -> Void, to: GCallback.self), + selBox, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + if let userData { Unmanaged.fromOpaque(userData).release() } + }, + GConnectFlags(rawValue: 0) + ) + } + let scrolled = gtk_scrolled_window_new()! gtk_scrolled_window_set_policy(OpaquePointer(scrolled), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC) gtk_scrolled_window_set_child(OpaquePointer(scrolled), view) diff --git a/Sources/SwiftOpenUI/Views/CodeEditor.swift b/Sources/SwiftOpenUI/Views/CodeEditor.swift index 9831886f..bdf81d6a 100644 --- a/Sources/SwiftOpenUI/Views/CodeEditor.swift +++ b/Sources/SwiftOpenUI/Views/CodeEditor.swift @@ -6,9 +6,19 @@ public struct CodeEditor: View { public let text: Binding + /// Optional one-way mirror of the editor's current selection (empty when + /// nothing is selected). Updated as the caret/selection moves — lets a host + /// evaluate just the selected lines. + public let selection: Binding? + /// Create a code editor bound to `text`. The initial language is Swift. - public init(text: Binding) { + /// + /// - Parameters: + /// - text: Two-way binding to the full document. + /// - selection: Optional binding updated with the current selection. + public init(text: Binding, selection: Binding? = nil) { self.text = text + self.selection = selection } public var body: Never { fatalError("CodeEditor is a primitive view") } From 62a74bde0de0de807721fbb92d6234ffef353afd Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Thu, 10 Sep 2026 14:36:44 -0700 Subject: [PATCH 18/21] GTK4: CodeEditor reuses its widget across rebuilds (GTKOpaqueLeaf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeEditor was GTKRenderable-only, so every host rebuild recreated the GtkSourceView — an unrelated @Observable change (a scope poll timer, a run flag) flickered the editor and reset the caret, and each keystroke (text binding → rebuild) snapped the cursor to the end, making editing/selection impossible. Conform CodeEditor to GTKOpaqueLeaf with a CONSTANT signature so the narrow path always reuses the existing widget instead of recreating it. The GtkSourceView now persists across rebuilds with its live buffer, caret, and selection intact. An external programmatic text change is not pushed back into the widget — correct for a scratch editor the user solely authors. Verified: BackendGTK4 links clean in a GTK4 Linux container. Co-Authored-By: Claude Opus 4.8 --- Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift index c5a4cebd..0f3d698d 100644 --- a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift +++ b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift @@ -9,6 +9,18 @@ import SwiftOpenUI // shape as the plain `TextEditor` render, but the widget is created via the // OpaquePointer-only CGtkSource shim so gtksourceview's C types never cross into // this file. +// A code editor holds live, user-owned state (the buffer text, caret, and +// selection) that must survive host rebuilds. Describe it as an opaque leaf with +// a CONSTANT signature so the narrow path always REUSES the existing +// GtkSourceView instead of recreating it: an unrelated `@Observable` change (the +// scope's poll timer, the run flag) that rebuilds the host no longer flickers the +// editor or snaps the caret to the end on every keystroke. The trade-off — an +// external, programmatic change to the bound text is not pushed back into the +// widget — is exactly right for a scratch editor the user is the sole author of. +extension CodeEditor: GTKOpaqueLeaf { + public var gtkStateSignature: AnyHashable { AnyHashable("SwiftOpenUI.CodeEditor") } +} + extension CodeEditor: GTKRenderable { public func gtkCreateWidget() -> OpaquePointer { let viewRaw: UnsafeMutableRawPointer = gtk_swift_source_view_new() From a392026a5ac8838693bc0f261f38f86ae1bcca29 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Thu, 10 Sep 2026 15:10:08 -0700 Subject: [PATCH 19/21] GTK4: CodeEditor Ctrl+Space completion popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional async `completionProvider` to CodeEditor. Ctrl+Space in the editor gathers the buffer text + caret (line, column) via new CGtkSource shims, calls the provider off the main thread, and shows the results in a GtkPopover list anchored at the caret; activating a row inserts its text at the cursor. - CGtkSource shims: cursor line/col, insert-at-cursor (replacing any selection), and the caret rectangle for popover placement. - CodeEditor: `completionProvider: (@Sendable (String, Int, Int) async -> [CodeCompletionItem])?` + the CodeCompletionItem type. - CodeEditorCompletionGTK: a controller wiring a Ctrl+Space key controller → async provider → GtkPopover + GtkListBox → insert on row-activate. Verified: BackendGTK4 links clean in a GTK4 Linux container. Co-Authored-By: Claude Opus 4.8 --- Sources/Backend/GTK4/CGtkSource/shim.h | 39 +++++ .../Rendering/CodeEditorCompletionGTK.swift | 158 ++++++++++++++++++ .../GTK4/Rendering/CodeEditorGTK.swift | 8 + Sources/SwiftOpenUI/Views/CodeEditor.swift | 26 ++- 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift diff --git a/Sources/Backend/GTK4/CGtkSource/shim.h b/Sources/Backend/GTK4/CGtkSource/shim.h index 0317bcff..6b2c6d23 100644 --- a/Sources/Backend/GTK4/CGtkSource/shim.h +++ b/Sources/Backend/GTK4/CGtkSource/shim.h @@ -73,6 +73,45 @@ gtk_swift_source_buffer_get_selected_text(void *buffer) { return gtk_text_buffer_get_text(b, &start, &end, FALSE); } +// Cursor position as 0-based (line, column-in-characters). +static inline void +gtk_swift_source_buffer_get_cursor_line_col(void *buffer, int *line, int *col) { + GtkTextBuffer *b = (GtkTextBuffer *)buffer; + GtkTextMark *insert = gtk_text_buffer_get_insert(b); + GtkTextIter it; + gtk_text_buffer_get_iter_at_mark(b, &it, insert); + *line = gtk_text_iter_get_line(&it); + *col = gtk_text_iter_get_line_offset(&it); +} + +// Insert `text` at the cursor (replacing any selection). +static inline void +gtk_swift_source_buffer_insert_at_cursor(void *buffer, const char *text) { + GtkTextBuffer *b = (GtkTextBuffer *)buffer; + gtk_text_buffer_begin_user_action(b); + if (gtk_text_buffer_get_has_selection(b)) { + gtk_text_buffer_delete_selection(b, FALSE, TRUE); + } + gtk_text_buffer_insert_at_cursor(b, text, -1); + gtk_text_buffer_end_user_action(b); +} + +// The caret's rectangle in widget coordinates (for anchoring a popover). +static inline void +gtk_swift_source_view_get_cursor_rect(void *view, int *x, int *y, int *w, int *h) { + GtkTextView *tv = GTK_TEXT_VIEW(view); + GtkTextBuffer *b = gtk_text_view_get_buffer(tv); + GtkTextMark *insert = gtk_text_buffer_get_insert(b); + GtkTextIter it; + gtk_text_buffer_get_iter_at_mark(b, &it, insert); + GdkRectangle loc; + gtk_text_view_get_iter_location(tv, &it, &loc); + int bx = 0, by = 0; + gtk_text_view_buffer_to_window_coords(tv, GTK_TEXT_WINDOW_WIDGET, + loc.x, loc.y, &bx, &by); + *x = bx; *y = by; *w = loc.width; *h = loc.height; +} + // Apply a named style scheme (e.g. "Adwaita-dark", "classic") when present. static inline void gtk_swift_source_buffer_set_style_scheme(void *buffer, const char *scheme_id) { diff --git a/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift b/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift new file mode 100644 index 00000000..73985de5 --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift @@ -0,0 +1,158 @@ +import CGTK +import CGtkSource +import CGTKBridge +import SwiftOpenUI +import Foundation + +// Ctrl+Space completion for the GtkSourceView `CodeEditor`. A key controller on +// the view triggers the host's async `completionProvider`; the result is applied +// on the main thread. (This step inserts the first candidate; a picker popover is +// layered on next.) +// +// `@unchecked Sendable`: main-thread-confined — the raw GTK pointers are only +// touched inside `@MainActor` methods; the Task hops back to the main actor before +// applying anything. +final class CodeEditorCompletionController: @unchecked Sendable { + let viewRaw: UnsafeMutableRawPointer + let bufferRaw: UnsafeMutableRawPointer + let provider: @Sendable (String, Int, Int) async -> [CodeCompletionItem] + + private var items: [CodeCompletionItem] = [] + private var popover: UnsafeMutablePointer? + + init( + viewRaw: UnsafeMutableRawPointer, + bufferRaw: UnsafeMutableRawPointer, + provider: @escaping @Sendable (String, Int, Int) async -> [CodeCompletionItem] + ) { + self.viewRaw = viewRaw + self.bufferRaw = bufferRaw + self.provider = provider + } + + /// Gather buffer text + caret (on the GTK main thread), ask the provider off + /// the main thread, then apply the result back on the main actor. + @MainActor + func trigger() { + guard let cStr: UnsafeMutablePointer = gtk_swift_source_buffer_get_text(bufferRaw) else { return } + let text: String = String(cString: cStr) + g_free(UnsafeMutableRawPointer(cStr)) + var line: Int32 = 0 + var col: Int32 = 0 + gtk_swift_source_buffer_get_cursor_line_col(bufferRaw, &line, &col) + let l: Int = Int(line) + let c: Int = Int(col) + Task { [self] in + let result: [CodeCompletionItem] = await provider(text, l, c) + await MainActor.run { self.present(result) } + } + } + + /// Show `items` in a popover at the caret. Clicking (or activating) a row + /// inserts its text. + @MainActor + private func present(_ candidates: [CodeCompletionItem]) { + dismiss() + guard !candidates.isEmpty else { return } + items = candidates + let view: UnsafeMutablePointer = viewRaw.assumingMemoryBound(to: GtkWidget.self) + + guard let popover = gtk_popover_new() else { return } + + guard let listBox = gtk_list_box_new() else { return } + let listBoxOp: OpaquePointer = OpaquePointer(listBox) + gtk_list_box_set_selection_mode(listBoxOp, GTK_SELECTION_SINGLE) + for candidate: CodeCompletionItem in candidates { + let label: UnsafeMutablePointer = candidate.label.withCString { (c: UnsafePointer) in + gtk_label_new(c) + } + gtk_widget_set_halign(label, GTK_ALIGN_START) + let row = gtk_list_box_row_new()! + gtk_list_box_row_set_child( + UnsafeMutableRawPointer(row).assumingMemoryBound(to: GtkListBoxRow.self), + label + ) + gtk_list_box_append(listBoxOp, row) + } + + let scrolled = gtk_scrolled_window_new()! + let scrolledOp: OpaquePointer = OpaquePointer(scrolled) + gtk_scrolled_window_set_policy(scrolledOp, GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC) + gtk_scrolled_window_set_child(scrolledOp, listBox) + gtk_widget_set_size_request(scrolled, 280, min(220, 26 * Int32(candidates.count))) + + gtk_swift_popover_set_child(popover, scrolled) + gtk_widget_set_parent(popover, view) + + var rx: Int32 = 0, ry: Int32 = 0, rw: Int32 = 0, rh: Int32 = 0 + gtk_swift_source_view_get_cursor_rect(viewRaw, &rx, &ry, &rw, &rh) + gtk_swift_popover_set_pointing_to(popover, rx, ry, rw, rh) + + // Insert on row activation. + let activateBox: UnsafeMutableRawPointer = Unmanaged.passRetained(IntClosureBox { [weak self] (index: Int) in + self?.insert(at: index) + }).toOpaque() + g_signal_connect_data( + gpointer(listBox), + "row-activated", + unsafeBitCast({ (_: gpointer?, rowPtr: gpointer?, userData: gpointer?) in + guard let userData, let rowPtr else { return } + let row = rowPtr.assumingMemoryBound(to: GtkListBoxRow.self) + let index: Int = Int(gtk_list_box_row_get_index(row)) + Unmanaged.fromOpaque(userData).takeUnretainedValue().closure(index) + } as @convention(c) (gpointer?, gpointer?, gpointer?) -> Void, to: GCallback.self), + activateBox, + { (data: gpointer?, _: UnsafeMutablePointer?) in + if let data { Unmanaged.fromOpaque(data).release() } + }, + GConnectFlags(rawValue: 0) + ) + + gtk_swift_popover_popup(popover) + self.popover = popover + } + + @MainActor + private func insert(at index: Int) { + guard index >= 0, index < items.count else { dismiss(); return } + items[index].insertText.withCString { (c: UnsafePointer) in + gtk_swift_source_buffer_insert_at_cursor(bufferRaw, c) + } + dismiss() + } + + @MainActor + private func dismiss() { + if let pop: UnsafeMutablePointer = popover { + gtk_swift_popover_popdown(pop) + gtk_widget_unparent(pop) + popover = nil + } + } +} + +/// Add a Ctrl+Space key controller to `view` (a GtkSourceView) that drives +/// `controller.trigger()`. +func gtkAttachCodeEditorCompletion(view: UnsafeMutableRawPointer, controller: CodeEditorCompletionController) { + let box: UnsafeMutableRawPointer = Unmanaged.passRetained(controller).toOpaque() + guard let keyController = gtk_event_controller_key_new() else { return } + g_signal_connect_data( + gpointer(keyController), + "key-pressed", + unsafeBitCast({ (_: OpaquePointer?, keyval: guint, _: guint, state: guint, userData: gpointer?) -> gboolean in + guard let userData else { return 0 } + // Ctrl+Space: GDK_KEY_space = 0x20, GDK_CONTROL_MASK = 1<<2 = 4. + let isCtrl: Bool = (state & 4) != 0 + guard isCtrl, keyval == 0x20 else { return 0 } + let ctrl = Unmanaged.fromOpaque(userData).takeUnretainedValue() + MainActor.assumeIsolated { ctrl.trigger() } + return 1 // consumed + } as @convention(c) (OpaquePointer?, guint, guint, guint, gpointer?) -> gboolean, to: GCallback.self), + box, + { (userData: gpointer?, _: UnsafeMutablePointer?) in + if let userData { Unmanaged.fromOpaque(userData).release() } + }, + GConnectFlags(rawValue: 0) + ) + gtk_widget_add_controller(view.assumingMemoryBound(to: GtkWidget.self), keyController) +} diff --git a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift index 0f3d698d..795bce15 100644 --- a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift +++ b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift @@ -90,6 +90,14 @@ extension CodeEditor: GTKRenderable { ) } + // Ctrl+Space completion, when a provider was supplied. + if let provider = completionProvider { + let completion: CodeEditorCompletionController = CodeEditorCompletionController( + viewRaw: viewRaw, bufferRaw: bufferRaw, provider: provider + ) + gtkAttachCodeEditorCompletion(view: viewRaw, controller: completion) + } + let scrolled = gtk_scrolled_window_new()! gtk_scrolled_window_set_policy(OpaquePointer(scrolled), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC) gtk_scrolled_window_set_child(OpaquePointer(scrolled), view) diff --git a/Sources/SwiftOpenUI/Views/CodeEditor.swift b/Sources/SwiftOpenUI/Views/CodeEditor.swift index bdf81d6a..08d27b8a 100644 --- a/Sources/SwiftOpenUI/Views/CodeEditor.swift +++ b/Sources/SwiftOpenUI/Views/CodeEditor.swift @@ -1,3 +1,16 @@ +/// One completion candidate offered in the editor's Ctrl+Space popover. +public struct CodeCompletionItem: Sendable { + /// Text shown in the popover row. + public let label: String + /// Text inserted at the caret when the row is chosen. + public let insertText: String + + public init(label: String, insertText: String) { + self.label = label + self.insertText = insertText + } +} + /// A multi-line source-code editor with syntax highlighting and a line-number /// gutter. On the GTK4 backend this is a `GtkSourceView` configured for Swift; /// other backends may fall back to a plain text editor. @@ -11,14 +24,25 @@ public struct CodeEditor: View { /// evaluate just the selected lines. public let selection: Binding? + /// Optional Ctrl+Space completion source. Given the full buffer text and the + /// 0-based caret (line, column), it returns candidates asynchronously; the + /// editor shows them in a popover at the caret and inserts the chosen one. + public let completionProvider: (@Sendable (String, Int, Int) async -> [CodeCompletionItem])? + /// Create a code editor bound to `text`. The initial language is Swift. /// /// - Parameters: /// - text: Two-way binding to the full document. /// - selection: Optional binding updated with the current selection. - public init(text: Binding, selection: Binding? = nil) { + /// - completionProvider: Optional Ctrl+Space completion source. + public init( + text: Binding, + selection: Binding? = nil, + completionProvider: (@Sendable (String, Int, Int) async -> [CodeCompletionItem])? = nil + ) { self.text = text self.selection = selection + self.completionProvider = completionProvider } public var body: Never { fatalError("CodeEditor is a primitive view") } From 6d3475a0a4578e65e143faea7c5aed86943c7052 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Thu, 10 Sep 2026 15:35:00 -0700 Subject: [PATCH 20/21] GTK4: filter completions by typed prefix; replace prefix on insert Ctrl+Space fed the raw sourcekit-lsp candidate set straight into the popover. sourcekit returns every in-scope symbol (Glibc macros included) and expects the client to narrow by what was typed, so typing 'Osc' surfaced 'R_MICROMIPS_CALL_HI16' etc. and inserting a row appended the full name onto the prefix ('OscOscSin'). - CodeEditorCompletionController captures the identifier word before the caret at trigger time and filters candidates (starts-with, then contains, alphabetised); an empty prefix (member access after '.') passes through unchanged since sourcekit has already scoped it. - Insert now replaces that prefix via a new gtk_swift_source_buffer_replace_prefix_at_cursor shim (walks left over identifier chars, deletes, inserts) so 'Osc' + OscSin = 'OscSin'. Co-Authored-By: Claude Opus 4.8 --- Sources/Backend/GTK4/CGtkSource/shim.h | 29 +++++++++ .../Rendering/CodeEditorCompletionGTK.swift | 59 +++++++++++++++++-- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/Sources/Backend/GTK4/CGtkSource/shim.h b/Sources/Backend/GTK4/CGtkSource/shim.h index 6b2c6d23..1a27ec86 100644 --- a/Sources/Backend/GTK4/CGtkSource/shim.h +++ b/Sources/Backend/GTK4/CGtkSource/shim.h @@ -96,6 +96,35 @@ gtk_swift_source_buffer_insert_at_cursor(void *buffer, const char *text) { gtk_text_buffer_end_user_action(b); } +// Replace the identifier word immediately before the cursor with `text`, then +// leave the caret after the inserted text. Used when applying a completion so +// the already-typed prefix (`Osc` when picking `OscSin`) is not duplicated. When +// there is no identifier prefix before the caret (e.g. just after a `.`), this +// inserts without deleting anything. Any active selection is replaced first. +static inline void +gtk_swift_source_buffer_replace_prefix_at_cursor(void *buffer, const char *text) { + GtkTextBuffer *b = (GtkTextBuffer *)buffer; + gtk_text_buffer_begin_user_action(b); + if (gtk_text_buffer_get_has_selection(b)) { + gtk_text_buffer_delete_selection(b, FALSE, TRUE); + } + GtkTextMark *insert = gtk_text_buffer_get_insert(b); + GtkTextIter end; + gtk_text_buffer_get_iter_at_mark(b, &end, insert); + GtkTextIter start = end; + // Walk left over identifier characters (letters, digits, underscore). + while (gtk_text_iter_backward_char(&start)) { + gunichar ch = gtk_text_iter_get_char(&start); + if (!(g_unichar_isalnum(ch) || ch == '_')) { + gtk_text_iter_forward_char(&start); + break; + } + } + gtk_text_buffer_delete(b, &start, &end); + gtk_text_buffer_insert_at_cursor(b, text, -1); + gtk_text_buffer_end_user_action(b); +} + // The caret's rectangle in widget coordinates (for anchoring a popover). static inline void gtk_swift_source_view_get_cursor_rect(void *view, int *x, int *y, int *w, int *h) { diff --git a/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift b/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift index 73985de5..55d5fa0b 100644 --- a/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift +++ b/Sources/Backend/GTK4/Rendering/CodeEditorCompletionGTK.swift @@ -19,6 +19,11 @@ final class CodeEditorCompletionController: @unchecked Sendable { private var items: [CodeCompletionItem] = [] private var popover: UnsafeMutablePointer? + /// The identifier word to the left of the caret at the moment Ctrl+Space was + /// pressed. Used to narrow the (large, unfiltered) sourcekit-lsp candidate set + /// client-side — sourcekit returns every in-scope symbol and expects the + /// editor to filter by what was typed. + private var prefix: String = "" init( viewRaw: UnsafeMutableRawPointer, @@ -42,19 +47,37 @@ final class CodeEditorCompletionController: @unchecked Sendable { gtk_swift_source_buffer_get_cursor_line_col(bufferRaw, &line, &col) let l: Int = Int(line) let c: Int = Int(col) + prefix = Self.identifierPrefix(text: text, line: l, column: c) Task { [self] in let result: [CodeCompletionItem] = await provider(text, l, c) await MainActor.run { self.present(result) } } } + /// The identifier word (letters / digits / `_`) ending at the caret on + /// `line` / `column` (both 0-based). Empty when the char before the caret is + /// not an identifier char (e.g. just after `.` or a space). + private static func identifierPrefix(text: String, line: Int, column: Int) -> String { + let lines: [Substring] = text.split(separator: "\n", omittingEmptySubsequences: false) + guard line >= 0, line < lines.count else { return "" } + let chars: [Character] = Array(lines[line]) + let end: Int = min(max(column, 0), chars.count) + var start: Int = end + while start > 0 { + let ch: Character = chars[start - 1] + if ch.isLetter || ch.isNumber || ch == "_" { start -= 1 } else { break } + } + return String(chars[start.. = viewRaw.assumingMemoryBound(to: GtkWidget.self) guard let popover = gtk_popover_new() else { return } @@ -62,7 +85,7 @@ final class CodeEditorCompletionController: @unchecked Sendable { guard let listBox = gtk_list_box_new() else { return } let listBoxOp: OpaquePointer = OpaquePointer(listBox) gtk_list_box_set_selection_mode(listBoxOp, GTK_SELECTION_SINGLE) - for candidate: CodeCompletionItem in candidates { + for candidate: CodeCompletionItem in filtered { let label: UnsafeMutablePointer = candidate.label.withCString { (c: UnsafePointer) in gtk_label_new(c) } @@ -79,7 +102,7 @@ final class CodeEditorCompletionController: @unchecked Sendable { let scrolledOp: OpaquePointer = OpaquePointer(scrolled) gtk_scrolled_window_set_policy(scrolledOp, GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC) gtk_scrolled_window_set_child(scrolledOp, listBox) - gtk_widget_set_size_request(scrolled, 280, min(220, 26 * Int32(candidates.count))) + gtk_widget_set_size_request(scrolled, 280, min(220, 26 * Int32(filtered.count))) gtk_swift_popover_set_child(popover, scrolled) gtk_widget_set_parent(popover, view) @@ -115,12 +138,38 @@ final class CodeEditorCompletionController: @unchecked Sendable { @MainActor private func insert(at index: Int) { guard index >= 0, index < items.count else { dismiss(); return } + // Replace the already-typed prefix (rather than append) so picking + // `OscSin` after typing `Osc` yields `OscSin`, not `OscOscSin`. items[index].insertText.withCString { (c: UnsafePointer) in - gtk_swift_source_buffer_insert_at_cursor(bufferRaw, c) + gtk_swift_source_buffer_replace_prefix_at_cursor(bufferRaw, c) } dismiss() } + /// Narrow the raw sourcekit-lsp candidate set to those matching the typed + /// `prefix` (case-insensitive), preferring a `label`/`insertText` that starts + /// with the prefix, then a contains-match, each ordered alphabetically. An + /// empty prefix (member access after `.`) passes the set through unchanged — + /// sourcekit has already scoped it. + private static func filter(_ candidates: [CodeCompletionItem], prefix: String) -> [CodeCompletionItem] { + guard !prefix.isEmpty else { return candidates } + let lp: String = prefix.lowercased() + var starts: [CodeCompletionItem] = [] + var contains: [CodeCompletionItem] = [] + for item: CodeCompletionItem in candidates { + let label: String = item.label.lowercased() + let insert: String = item.insertText.lowercased() + if label.hasPrefix(lp) || insert.hasPrefix(lp) { + starts.append(item) + } else if label.contains(lp) { + contains.append(item) + } + } + starts.sort { $0.label.lowercased() < $1.label.lowercased() } + contains.sort { $0.label.lowercased() < $1.label.lowercased() } + return starts + contains + } + @MainActor private func dismiss() { if let pop: UnsafeMutablePointer = popover { From 9cf942382a7b21ee7c6653533aa046ed5a554412 Mon Sep 17 00:00:00 2001 From: Joshua Parmenter Date: Thu, 10 Sep 2026 18:13:29 -0700 Subject: [PATCH 21/21] GTK4: native Open/Save file dialogs + CodeEditor imperative setText Adds the pieces a host needs for document save/load in a CodeEditor: - GTK4FileDialog.open/save: ergonomic async native file pickers built on new gtk_swift_present_open_dialog / _save_dialog shims (self-contained GtkFileDialog wrappers that deliver the chosen path, or nil on cancel, to a Swift callback on the GTK main thread; parented to the active window). Distinct from the existing low-level gtk_swift_file_dialog_* building blocks used by the menubar/drop-target code. - CodeEditorController: an imperative handle whose setText() replaces the editor's whole buffer. The editor is a reused opaque widget (so typing never resets the caret), which by design does not mirror an external text change back into the widget; a controller gives a host that ability for file loads. The buffer's own change signal then flows the new text back out through the text binding. Co-Authored-By: Claude Opus 4.8 --- Sources/Backend/GTK4/CGTK/shim.h | 74 +++++++++++++++++++ .../GTK4/Rendering/CodeEditorGTK.swift | 11 +++ .../GTK4/Rendering/GTK4FileDialog.swift | 68 +++++++++++++++++ Sources/SwiftOpenUI/Views/CodeEditor.swift | 35 +++++++++ 4 files changed, 188 insertions(+) create mode 100644 Sources/Backend/GTK4/Rendering/GTK4FileDialog.swift diff --git a/Sources/Backend/GTK4/CGTK/shim.h b/Sources/Backend/GTK4/CGTK/shim.h index d32556c9..055b9801 100644 --- a/Sources/Backend/GTK4/CGTK/shim.h +++ b/Sources/Backend/GTK4/CGTK/shim.h @@ -1204,3 +1204,77 @@ gtk_swift_get_default_gtk_application(void) { if (!app || !GTK_IS_APPLICATION(app)) return NULL; return GTK_APPLICATION(app); } + +// --- Native file dialog (GtkFileDialog, GTK 4.10+) --- +// +// Async open/save wrappers for the live-coding scratchpad's Open/Save buttons. +// The chosen path (or NULL on cancel/error) is delivered to a Swift callback +// via an opaque user pointer; the dialog is parented to the active window (see +// gtk_swift_get_active_window) so GTK 4.14 doesn't emit realization criticals. +typedef void (*LyrebirdFileDialogCB)(const char *path, void *user); + +typedef struct { + LyrebirdFileDialogCB cb; + void *user; +} LyrebirdFileDialogCtx; + +static void +lyrebird_fd_open_done(GObject *src, GAsyncResult *res, gpointer data) { + LyrebirdFileDialogCtx *ctx = (LyrebirdFileDialogCtx *)data; + GFile *file = gtk_file_dialog_open_finish(GTK_FILE_DIALOG(src), res, NULL); + if (file) { + char *p = g_file_get_path(file); + ctx->cb(p, ctx->user); + g_free(p); + g_object_unref(file); + } else { + ctx->cb(NULL, ctx->user); + } + g_free(ctx); +} + +static void +lyrebird_fd_save_done(GObject *src, GAsyncResult *res, gpointer data) { + LyrebirdFileDialogCtx *ctx = (LyrebirdFileDialogCtx *)data; + GFile *file = gtk_file_dialog_save_finish(GTK_FILE_DIALOG(src), res, NULL); + if (file) { + char *p = g_file_get_path(file); + ctx->cb(p, ctx->user); + g_free(p); + g_object_unref(file); + } else { + ctx->cb(NULL, ctx->user); + } + g_free(ctx); +} + +/// Present a native Open dialog. `cb(path, user)` is invoked on the GTK main +/// thread with the chosen path, or NULL if cancelled. +static inline void +gtk_swift_present_open_dialog(const char *title, void *user, LyrebirdFileDialogCB cb) { + GtkFileDialog *dlg = gtk_file_dialog_new(); + if (title) gtk_file_dialog_set_title(dlg, title); + LyrebirdFileDialogCtx *ctx = g_new0(LyrebirdFileDialogCtx, 1); + ctx->cb = cb; + ctx->user = user; + gtk_file_dialog_open(dlg, gtk_swift_get_active_window(), NULL, + lyrebird_fd_open_done, ctx); + g_object_unref(dlg); +} + +/// Present a native Save dialog, pre-filling `suggested_name` (may be NULL). +/// `cb(path, user)` is invoked on the GTK main thread with the chosen path, or +/// NULL if cancelled. +static inline void +gtk_swift_present_save_dialog(const char *title, const char *suggested_name, + void *user, LyrebirdFileDialogCB cb) { + GtkFileDialog *dlg = gtk_file_dialog_new(); + if (title) gtk_file_dialog_set_title(dlg, title); + if (suggested_name) gtk_file_dialog_set_initial_name(dlg, suggested_name); + LyrebirdFileDialogCtx *ctx = g_new0(LyrebirdFileDialogCtx, 1); + ctx->cb = cb; + ctx->user = user; + gtk_file_dialog_save(dlg, gtk_swift_get_active_window(), NULL, + lyrebird_fd_save_done, ctx); + g_object_unref(dlg); +} diff --git a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift index 795bce15..78003d7c 100644 --- a/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift +++ b/Sources/Backend/GTK4/Rendering/CodeEditorGTK.swift @@ -90,6 +90,17 @@ extension CodeEditor: GTKRenderable { ) } + // Imperative text replacement (file loads): push straight into the + // buffer. The buffer's "changed" signal above then flows the new text + // back out through the `text` binding, so the host's state stays in sync. + if let controller: CodeEditorController = controller { + controller._applyText = { (newText: String) in + newText.withCString { (c: UnsafePointer) in + gtk_swift_source_buffer_set_text(bufferRaw, c, Int32(newText.utf8.count)) + } + } + } + // Ctrl+Space completion, when a provider was supplied. if let provider = completionProvider { let completion: CodeEditorCompletionController = CodeEditorCompletionController( diff --git a/Sources/Backend/GTK4/Rendering/GTK4FileDialog.swift b/Sources/Backend/GTK4/Rendering/GTK4FileDialog.swift new file mode 100644 index 00000000..efaa31dd --- /dev/null +++ b/Sources/Backend/GTK4/Rendering/GTK4FileDialog.swift @@ -0,0 +1,68 @@ +import CGTK +import Foundation + +/// Native Open / Save file dialogs (`GtkFileDialog`), presented imperatively +/// from a button action — the same imperative style as +/// ``GTK4Backend/openStandaloneWindow(title:width:height:onClose:content:)`` and +/// `GTK4PointerTracking`. +/// +/// Both calls are non-blocking: the dialog runs on the GTK main loop and the +/// completion fires later on that same thread with the chosen path, or `nil` +/// when the user cancels. The dialog is parented to the active window by the +/// shim. Call from the UI (GTK main) thread. +public enum GTK4FileDialog { + + /// Present an Open dialog. `completion(path)` runs on the GTK main thread + /// with the chosen path, or `nil` if cancelled. + public static func open(title: String? = nil, _ completion: @escaping (String?) -> Void) { + present(save: false, title: title, suggestedName: nil, completion: completion) + } + + /// Present a Save dialog pre-filled with `suggestedName`. `completion(path)` + /// runs on the GTK main thread with the chosen path, or `nil` if cancelled. + public static func save(title: String? = nil, suggestedName: String, _ completion: @escaping (String?) -> Void) { + present(save: true, title: title, suggestedName: suggestedName, completion: completion) + } + + /// Retains the escaping completion across the C async boundary. + private final class Box { + let completion: (String?) -> Void + init(_ completion: @escaping (String?) -> Void) { self.completion = completion } + } + + // Captures nothing, so it is a valid @convention(c) function pointer. Unpacks + // the retained box, converts the C path (NULL → nil), and invokes it once. + private static let trampoline: @convention(c) (UnsafePointer?, UnsafeMutableRawPointer?) -> Void = { + (path: UnsafePointer?, user: UnsafeMutableRawPointer?) in + guard let user: UnsafeMutableRawPointer = user else { return } + let box: Box = Unmanaged.fromOpaque(user).takeRetainedValue() + let result: String? = path.map { (c: UnsafePointer) in String(cString: c) } + box.completion(result) + } + + private static func present( + save: Bool, + title: String?, + suggestedName: String?, + completion: @escaping (String?) -> Void + ) { + let user: UnsafeMutableRawPointer = Unmanaged.passRetained(Box(completion)).toOpaque() + + func withTitle(_ body: (UnsafePointer?) -> T) -> T { + if let title: String = title { return title.withCString(body) } + return body(nil) + } + + if save { + withTitle { (titlePtr: UnsafePointer?) in + (suggestedName ?? "untitled.swift").withCString { (namePtr: UnsafePointer) in + gtk_swift_present_save_dialog(titlePtr, namePtr, user, trampoline) + } + } + } else { + withTitle { (titlePtr: UnsafePointer?) in + gtk_swift_present_open_dialog(titlePtr, user, trampoline) + } + } + } +} diff --git a/Sources/SwiftOpenUI/Views/CodeEditor.swift b/Sources/SwiftOpenUI/Views/CodeEditor.swift index 08d27b8a..309c60cb 100644 --- a/Sources/SwiftOpenUI/Views/CodeEditor.swift +++ b/Sources/SwiftOpenUI/Views/CodeEditor.swift @@ -1,3 +1,31 @@ +/// An imperative handle to a live `CodeEditor` widget, for pushing text into it +/// from outside the SwiftUI-style data flow. +/// +/// The GTK `CodeEditor` is an opaque, reused widget (so typing never flickers or +/// resets the caret), which means a plain change to the `text` binding is *not* +/// mirrored back into the widget. That is the right default for a document the +/// user is editing, but a host that loads a file needs to replace the whole +/// buffer. Passing a controller gives it that ability: ``setText(_:)`` writes +/// the buffer directly, and the widget's own change signal then flows the new +/// text back out through the `text` binding. +/// +/// `@unchecked Sendable`: the backend stores a UI-thread-only apply closure into +/// it on widget creation; ``setText(_:)`` must be called on the UI (GTK main) +/// thread, exactly like the rest of the Linux UI. +public final class CodeEditorController: @unchecked Sendable { + /// Set by the backend when the editor widget is created. Pushes `text` into + /// the live buffer on the UI thread. `nil` until the widget exists. + public var _applyText: ((String) -> Void)? + + public init() {} + + /// Replace the editor's entire contents with `text`. No-op before the widget + /// is created. Call on the UI (GTK main) thread. + public func setText(_ text: String) { + _applyText?(text) + } +} + /// One completion candidate offered in the editor's Ctrl+Space popover. public struct CodeCompletionItem: Sendable { /// Text shown in the popover row. @@ -29,19 +57,26 @@ public struct CodeEditor: View { /// editor shows them in a popover at the caret and inserts the chosen one. public let completionProvider: (@Sendable (String, Int, Int) async -> [CodeCompletionItem])? + /// Optional imperative handle for pushing text into the live widget (e.g. + /// loading a file). See ``CodeEditorController``. + public let controller: CodeEditorController? + /// Create a code editor bound to `text`. The initial language is Swift. /// /// - Parameters: /// - text: Two-way binding to the full document. /// - selection: Optional binding updated with the current selection. + /// - controller: Optional handle for imperative text replacement (loads). /// - completionProvider: Optional Ctrl+Space completion source. public init( text: Binding, selection: Binding? = nil, + controller: CodeEditorController? = nil, completionProvider: (@Sendable (String, Int, Int) async -> [CodeCompletionItem])? = nil ) { self.text = text self.selection = selection + self.controller = controller self.completionProvider = completionProvider }