diff --git a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift index c434590..2bd529f 100644 --- a/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift +++ b/Sources/Backend/GTK4/Rendering/GTK4DescriptorTree.swift @@ -24,6 +24,15 @@ public enum GTK4DescriptorKind: Equatable { case searchable 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 @@ -84,6 +93,23 @@ 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 +} + +/// 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 @@ -226,6 +252,8 @@ public enum GTK4DescriptorProps: Equatable { case rotation(GTK4RotationDescriptor) case scale(GTK4ScaleDescriptor) case text(GTK4TextDescriptor) + case textField(GTK4TextFieldDescriptor) + case opaqueLeaf(GTK4OpaqueLeafDescriptor) case color(GTK4ColorDescriptor) case frame(GTK4FrameDescriptor) case foregroundColor(GTK4ColorDescriptor) @@ -382,6 +410,7 @@ public enum GTK4DescriptorUpdateIntent: Equatable { case sliderConfiguration case sliderValue case textContent + case textFieldValue case vStackLayout case zStackLayout case widgetPropertyUpdate @@ -490,6 +519,29 @@ 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 } +} + +/// 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] = [] } @@ -524,6 +576,27 @@ 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)] + ) + } + // 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, @@ -689,6 +762,16 @@ 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 { + 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 +882,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 +920,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 +997,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 +1119,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 +1137,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 +1157,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 +1178,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 +1206,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 +1275,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 +1310,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 +1380,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 1555055..56d2fce 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) @@ -436,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 { @@ -2893,6 +2920,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 +2960,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 +3022,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 @@ -7444,3 +7556,307 @@ 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)] + ) + } +} + +// 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") } } + +// 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() + } + } +} + +// 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)) } +} diff --git a/Sources/Backend/GTK4/Rendering/GTKViewHost.swift b/Sources/Backend/GTK4/Rendering/GTKViewHost.swift index 2a7f15c..ddc489a 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,60 @@ 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() { + // 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) + } + } + public func beginInteractiveUpdate() { lock.lock() defer { lock.unlock() } @@ -221,6 +283,104 @@ 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 + } else if debugInteraction { + narrowLog("[narrow-hook-failed] gtkApplyHookMutation returned failure") + } + } + } + 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 @@ -236,48 +396,12 @@ 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, - let oldRetained = lastRetainedDescriptor, - let oldExecutor = retainedExecutor { - - let previousEnv = getCurrentEnvironment() - installRebuildEnvironment() - let 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.