From 9e95ae9a2f0c867cf6cf326f85c14eb2799d8c3b Mon Sep 17 00:00:00 2001 From: AlexCat315 <99124972+AlexCat315@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:07:16 +0800 Subject: [PATCH] Add viewport render gate and TextField improvements --- .../EditorApp/Panels/ViewportPanel.swift | 17 +- Editor/Sources/EditorApp/main.swift | 5 +- .../Editing/EditorViewportRenderGate.swift | 70 +++++++++ Editor/Sources/EditorCore/EditorCore.swift | 53 ++++++- .../EditorCore/State/EditorReducer.swift | 4 + .../EditorCore/State/EditorState.swift | 8 + .../EditorCore/State/EditorStore.swift | 6 + .../EditorViewportRenderGateTests.swift | 147 ++++++++++++++++++ Engine/Sources/SceneRuntime/Animation.swift | 4 +- Engine/Sources/SceneRuntime/RenderScene.swift | 2 +- GuavaUI/Sources/GuavaUIApp/AppConfig.swift | 17 ++ GuavaUI/Sources/GuavaUIApp/AppRuntime.swift | 8 +- .../GuavaUICompose/Primitives/TextField.swift | 5 + .../Primitives/TextFieldEditing.swift | 30 +++- .../Primitives/TextFieldInputController.swift | 1 + .../GuavaUIRuntime/SDL3PlatformHost.swift | 3 + .../GuavaUIComposeTests/TextFieldTests.swift | 54 +++++++ 17 files changed, 420 insertions(+), 14 deletions(-) create mode 100644 Editor/Sources/EditorCore/Editing/EditorViewportRenderGate.swift create mode 100644 Editor/Tests/EditorCoreTests/EditorViewportRenderGateTests.swift diff --git a/Editor/Sources/EditorApp/Panels/ViewportPanel.swift b/Editor/Sources/EditorApp/Panels/ViewportPanel.swift index e2116c96..104051b2 100644 --- a/Editor/Sources/EditorApp/Panels/ViewportPanel.swift +++ b/Editor/Sources/EditorApp/Panels/ViewportPanel.swift @@ -29,6 +29,7 @@ struct ViewportPanel: View { let shadowsEnabled = store.viewportShadowsEnabled let renderScalePercent = store.viewportRenderScalePercent let interactionDownscaleEnabled = store.viewportInteractionDownscaleEnabled + let realtimeEnabled = store.viewportRealtimeEnabled let playbackState = store.playbackState // 推送 gizmo 控制器所需的快照(摄像机 / 视口矩形 / 实体世界坐标)。 @@ -74,6 +75,7 @@ struct ViewportPanel: View { shadowsEnabled: shadowsEnabled, renderScalePercent: renderScalePercent, interactionDownscaleEnabled: interactionDownscaleEnabled, + realtimeEnabled: realtimeEnabled, playbackState: playbackState, onSelectGizmoMode: { mode in if gizmoMode != mode { @@ -97,6 +99,9 @@ struct ViewportPanel: View { onToggleInteractionDownscale: { app.setViewportInteractionDownscaleEnabled(!interactionDownscaleEnabled) }, + onToggleRealtime: { + app.setViewportRealtimeEnabled(!realtimeEnabled) + }, onPlay: { app.applyPlaybackState(.playing) }, onPause: { app.applyPlaybackState(.paused) }, onStop: { app.applyPlaybackState(.stopped) }) @@ -1210,6 +1215,7 @@ private struct ViewportInfoBar: View { let shadowsEnabled: Bool let renderScalePercent: Int let interactionDownscaleEnabled: Bool + let realtimeEnabled: Bool let playbackState: PlaybackState let onSelectGizmoMode: (EditorGizmoMode) -> Void let onSelectGizmoSpace: (EditorGizmoSpace) -> Void @@ -1217,6 +1223,7 @@ private struct ViewportInfoBar: View { let onToggleShadows: () -> Void let onSelectRenderScale: (Int) -> Void let onToggleInteractionDownscale: () -> Void + let onToggleRealtime: () -> Void let onPlay: () -> Void let onPause: () -> Void let onStop: () -> Void @@ -1272,8 +1279,10 @@ private struct ViewportInfoBar: View { RenderScaleSelector(percent: renderScalePercent, interactionDownscaleEnabled: interactionDownscaleEnabled, + realtimeEnabled: realtimeEnabled, onSelect: onSelectRenderScale, - onToggleInteractionDownscale: onToggleInteractionDownscale) + onToggleInteractionDownscale: onToggleInteractionDownscale, + onToggleRealtime: onToggleRealtime) Divider() .frame(width: 1, height: 16) @@ -1450,8 +1459,10 @@ private struct GizmoAxisChip: View { private struct RenderScaleSelector: View { let percent: Int let interactionDownscaleEnabled: Bool + let realtimeEnabled: Bool let onSelect: (Int) -> Void let onToggleInteractionDownscale: () -> Void + let onToggleRealtime: () -> Void @State private var isPresented: Bool = false private static let presets = [50, 75, 100, 150, 200] @@ -1486,6 +1497,10 @@ private struct RenderScaleSelector: View { title: L("Downscale while navigating"), isSelected: interactionDownscaleEnabled, action: { onToggleInteractionDownscale() }))) + entries.append(.item(MenuItem(id: "renderscale-realtime", + title: L("Realtime"), + isSelected: realtimeEnabled, + action: { onToggleRealtime() }))) return entries } } diff --git a/Editor/Sources/EditorApp/main.swift b/Editor/Sources/EditorApp/main.swift index e8c62a49..e6c63816 100644 --- a/Editor/Sources/EditorApp/main.swift +++ b/Editor/Sources/EditorApp/main.swift @@ -64,7 +64,10 @@ private func runLegacyEditor(launchOptions: EditorAppLaunchOptions) throws { // instead of an off-palette dark blue. clearColor: GPUColor(r: 0x1E / 255, g: 0x1F / 255, b: 0x22 / 255, a: 1), backendConfig: launchOptions.backendConfig, - titleBarStyle: .hiddenInset), + titleBarStyle: .hiddenInset, + // 编辑器走事件驱动:空闲时 UI 与 3D 都不渲染(场景按需 + // 渲染见 EditorViewportRenderGate),输入延迟与 GPU 负载解耦。 + frameDrivePolicy: .eventDriven), backend: backend, events: events, onTick: { dt in diff --git a/Editor/Sources/EditorCore/Editing/EditorViewportRenderGate.swift b/Editor/Sources/EditorCore/Editing/EditorViewportRenderGate.swift new file mode 100644 index 00000000..3ade8c5f --- /dev/null +++ b/Editor/Sources/EditorCore/Editing/EditorViewportRenderGate.swift @@ -0,0 +1,70 @@ +import RenderBackend +import SceneRuntime + +/// Decides whether the engine should render the viewport this tick. +/// +/// The signature samples the values that feed `RenderPacket` — a closed set: +/// if none changed, re-rendering would produce an identical image, so the +/// tick skips the GPU entirely (the editor stays on the last published +/// texture). Camera and palettes are compared by value, scene content by the +/// `RuntimeWorld` mutation revision, so there is no "remember to mark dirty" +/// discipline. A false-dirty merely degrades to today's continuous +/// rendering; a missed invalidation is caught by the heartbeat and by the +/// realtime toggle escape hatch. +public struct EditorViewportRenderGate { + public struct Signature: Equatable { + public var sceneRevision: UInt64 + public var camera: RenderCamera + public var drawableSize: RenderDrawableSize + public var settingsGeneration: UInt64 + public var jointPalettes: JointPaletteMap + + public init(sceneRevision: UInt64, + camera: RenderCamera, + drawableSize: RenderDrawableSize, + settingsGeneration: UInt64, + jointPalettes: JointPaletteMap) { + self.sceneRevision = sceneRevision + self.camera = camera + self.drawableSize = drawableSize + self.settingsGeneration = settingsGeneration + self.jointPalettes = jointPalettes + } + } + + /// Missed-invalidation safety net: render at least this often while the + /// editor ticks, so a forgotten dirty path shows up as a 1 Hz refresh + /// instead of a frozen viewport. + public static let heartbeatInterval: Double = 1.0 + /// Frames rendered after the last change. Covers in-flight completion + /// handlers and double buffering. + public static let convergenceFrames = 2 + /// TAA needs several frames after the last change for its history to + /// converge; stopping earlier would freeze a half-resolved image. + public static let temporalConvergenceFrames = 8 + + private var lastSignature: Signature? + private var lastRenderTime: Double = -.infinity + private var pendingFrames = 0 + + public init() {} + + public mutating func shouldRender(signature: Signature, + forceContinuous: Bool, + hasViewportInput: Bool, + temporalEffectsActive: Bool, + now: Double) -> Bool { + if signature != lastSignature || forceContinuous || hasViewportInput { + pendingFrames = temporalEffectsActive + ? Self.temporalConvergenceFrames + : Self.convergenceFrames + } + let render = pendingFrames > 0 || now - lastRenderTime >= Self.heartbeatInterval + if render { + lastSignature = signature + lastRenderTime = now + if pendingFrames > 0 { pendingFrames -= 1 } + } + return render + } +} diff --git a/Editor/Sources/EditorCore/EditorCore.swift b/Editor/Sources/EditorCore/EditorCore.swift index 34d84b8e..56bb4892 100644 --- a/Editor/Sources/EditorCore/EditorCore.swift +++ b/Editor/Sources/EditorCore/EditorCore.swift @@ -44,6 +44,9 @@ public final class EditorApplication: @unchecked Sendable { private var pendingViewportEvents: [InputEvent] = [] private var _viewportDrawableSize: RenderDrawableSize = .init(width: 1280, height: 720) private var lastViewportSurfaceState = ViewportSurfaceState() + private var renderGate = EditorViewportRenderGate() + private var renderSettingsGeneration: UInt64 = 0 + private var lastQueuedRenderSettings = RenderSettings() private var openSettingsWindowHandler: (() -> Void)? private var displayInvalidationHandler: (() -> Void)? private var vsyncModeHandler: ((EditorVSyncMode) -> Void)? @@ -163,7 +166,7 @@ public final class EditorApplication: @unchecked Sendable { engine.start(renderSurface: nil, enableViewportSurface: true) // 默认启用离屏渲染,让引擎渲染到一个 viewport 纹理交给编辑器显示。 // 不开启 viewportResolve 时 UI 会一直停在 "Waiting for first render packet"。 - engine.queueRenderSettings(makeViewportRenderSettings( + queueTrackedRenderSettings(makeViewportRenderSettings( shadowsEnabled: store.state.viewportShadowsEnabled, shadingMode: store.state.viewportShadingMode)) store.dispatch(.setConnected(true)) @@ -171,19 +174,38 @@ public final class EditorApplication: @unchecked Sendable { } public func tick(deltaTime: Double) { + // Under the event-driven frame policy the loop can sleep for seconds; + // the wake-up tick must not step simulation/animation by the whole gap. + let deltaTime = min(max(deltaTime, 0), 0.25) let didUpdateFrameTiming = recordFrameTiming(deltaTime) store.dispatch(.tickFrame(store.state.frameIndex &+ 1)) let inputEvents = pendingViewportEvents pendingViewportEvents.removeAll(keepingCapacity: true) inputState.process(inputEvents) scene.tickScene(deltaTime: deltaTime) + let drawableSize = effectiveViewportDrawableSize() + let jointPalettes = scene.currentJointPaletteMap() + let state = store.state + let renderViewport = renderGate.shouldRender( + signature: EditorViewportRenderGate.Signature( + sceneRevision: scene.revision, + camera: scene.currentRenderCamera(), + drawableSize: drawableSize, + settingsGeneration: renderSettingsGeneration, + jointPalettes: jointPalettes + ), + forceContinuous: state.viewportRealtimeEnabled || state.playbackState == .playing, + hasViewportInput: !inputEvents.isEmpty, + temporalEffectsActive: lastQueuedRenderSettings.enableTAA, + now: monotonicNow() + ) engine.tick( deltaTime: deltaTime, inputEvents: inputEvents, - drawableSize: effectiveViewportDrawableSize(), - shouldRender: store.state.shouldRender, + drawableSize: drawableSize, + shouldRender: state.shouldRender && renderViewport, renderSceneOverride: scene.currentRenderScene(), - jointPaletteOverride: scene.currentJointPaletteMap() + jointPaletteOverride: jointPalettes ) let surface = engine.currentViewportSurfaceState() @@ -248,6 +270,17 @@ public final class EditorApplication: @unchecked Sendable { : "Viewport interaction downscale disabled") } + public func setViewportRealtimeEnabled(_ enabled: Bool) { + guard store.state.viewportRealtimeEnabled != enabled else { return } + store.dispatch(.setViewportRealtime(enabled)) + logConsole(enabled ? "Viewport realtime rendering enabled" + : "Viewport renders on demand") + } + + private func monotonicNow() -> Double { + Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 + } + public func setViewportRenderCompletionHandler(_ handler: (@Sendable (ViewportSurfaceState) -> Void)?) { engine.setRenderCompletionHandler { completion in handler?(completion.viewportSurfaceState) @@ -517,6 +550,14 @@ public final class EditorApplication: @unchecked Sendable { } public func queueViewportRenderSettings(_ settings: RenderSettings) { + queueTrackedRenderSettings(settings) + } + + /// Single funnel for render-settings changes: bumps the generation the + /// viewport render gate folds into its dirty signature. + private func queueTrackedRenderSettings(_ settings: RenderSettings) { + renderSettingsGeneration &+= 1 + lastQueuedRenderSettings = settings engine.queueRenderSettings(settings) } @@ -524,7 +565,7 @@ public final class EditorApplication: @unchecked Sendable { if store.state.viewportShadowsEnabled != enabled { store.dispatch(.setViewportShadowsEnabled(enabled)) } - engine.queueRenderSettings(makeViewportRenderSettings( + queueTrackedRenderSettings(makeViewportRenderSettings( shadowsEnabled: enabled, shadingMode: store.state.viewportShadingMode)) logConsole(enabled ? "Viewport shadows enabled" : "Viewport shadows disabled") @@ -536,7 +577,7 @@ public final class EditorApplication: @unchecked Sendable { if store.state.viewportShadingMode != mode { store.dispatch(.setViewportShadingMode(mode)) } - engine.queueRenderSettings(makeViewportRenderSettings( + queueTrackedRenderSettings(makeViewportRenderSettings( shadowsEnabled: store.state.viewportShadowsEnabled, shadingMode: mode)) } diff --git a/Editor/Sources/EditorCore/State/EditorReducer.swift b/Editor/Sources/EditorCore/State/EditorReducer.swift index 71dad934..d8e52a4e 100644 --- a/Editor/Sources/EditorCore/State/EditorReducer.swift +++ b/Editor/Sources/EditorCore/State/EditorReducer.swift @@ -23,6 +23,7 @@ public enum EditorAction: Sendable { case setViewportShadowsEnabled(Bool) case setViewportRenderScalePercent(Int) case setViewportInteractionDownscale(Bool) + case setViewportRealtime(Bool) case setTranslateSnapEnabled(Bool) case setRotateSnapEnabled(Bool) case setScaleSnapEnabled(Bool) @@ -126,6 +127,9 @@ public enum EditorReducer { case let .setViewportInteractionDownscale(enabled): state.viewportInteractionDownscaleEnabled = enabled + case let .setViewportRealtime(enabled): + state.viewportRealtimeEnabled = enabled + case let .setTranslateSnapEnabled(enabled): state.translateSnapEnabled = enabled diff --git a/Editor/Sources/EditorCore/State/EditorState.swift b/Editor/Sources/EditorCore/State/EditorState.swift index b359a28c..1d1d99eb 100644 --- a/Editor/Sources/EditorCore/State/EditorState.swift +++ b/Editor/Sources/EditorCore/State/EditorState.swift @@ -256,6 +256,9 @@ public struct EditorState: Codable, Sendable { public var viewportRenderScalePercent: Int /// Temporarily halve the render resolution during camera / gizmo drags. public var viewportInteractionDownscaleEnabled: Bool + /// Escape hatch for on-demand viewport rendering: render every tick even + /// when no packet input changed (Unreal's per-viewport "Realtime"). + public var viewportRealtimeEnabled: Bool public var translateSnapEnabled: Bool public var rotateSnapEnabled: Bool public var scaleSnapEnabled: Bool @@ -301,6 +304,7 @@ public struct EditorState: Codable, Sendable { viewportShadowDebugMode: EditorViewportShadowDebugMode = .off, viewportRenderScalePercent: Int = 100, viewportInteractionDownscaleEnabled: Bool = false, + viewportRealtimeEnabled: Bool = false, translateSnapEnabled: Bool = false, rotateSnapEnabled: Bool = false, scaleSnapEnabled: Bool = false, @@ -346,6 +350,7 @@ public struct EditorState: Codable, Sendable { self.viewportShadowDebugMode = viewportShadowDebugMode self.viewportRenderScalePercent = Self.sanitizedRenderScalePercent(viewportRenderScalePercent) self.viewportInteractionDownscaleEnabled = viewportInteractionDownscaleEnabled + self.viewportRealtimeEnabled = viewportRealtimeEnabled self.translateSnapEnabled = translateSnapEnabled self.rotateSnapEnabled = rotateSnapEnabled self.scaleSnapEnabled = scaleSnapEnabled @@ -409,6 +414,7 @@ public struct EditorState: Codable, Sendable { case viewportShadowDebugMode case viewportRenderScalePercent case viewportInteractionDownscaleEnabled + case viewportRealtimeEnabled case translateSnapEnabled case rotateSnapEnabled case scaleSnapEnabled @@ -473,6 +479,7 @@ public struct EditorState: Codable, Sendable { viewportShadowDebugMode: try c.decodeIfPresent(EditorViewportShadowDebugMode.self, forKey: .viewportShadowDebugMode) ?? .off, viewportRenderScalePercent: try c.decodeIfPresent(Int.self, forKey: .viewportRenderScalePercent) ?? 100, viewportInteractionDownscaleEnabled: try c.decodeIfPresent(Bool.self, forKey: .viewportInteractionDownscaleEnabled) ?? false, + viewportRealtimeEnabled: try c.decodeIfPresent(Bool.self, forKey: .viewportRealtimeEnabled) ?? false, translateSnapEnabled: try c.decodeIfPresent(Bool.self, forKey: .translateSnapEnabled) ?? false, rotateSnapEnabled: try c.decodeIfPresent(Bool.self, forKey: .rotateSnapEnabled) ?? false, scaleSnapEnabled: try c.decodeIfPresent(Bool.self, forKey: .scaleSnapEnabled) ?? false, @@ -519,6 +526,7 @@ public struct EditorState: Codable, Sendable { try c.encode(viewportShadowDebugMode, forKey: .viewportShadowDebugMode) try c.encode(viewportRenderScalePercent, forKey: .viewportRenderScalePercent) try c.encode(viewportInteractionDownscaleEnabled, forKey: .viewportInteractionDownscaleEnabled) + try c.encode(viewportRealtimeEnabled, forKey: .viewportRealtimeEnabled) try c.encode(translateSnapEnabled, forKey: .translateSnapEnabled) try c.encode(rotateSnapEnabled, forKey: .rotateSnapEnabled) try c.encode(scaleSnapEnabled, forKey: .scaleSnapEnabled) diff --git a/Editor/Sources/EditorCore/State/EditorStore.swift b/Editor/Sources/EditorCore/State/EditorStore.swift index 09688aa6..5542edcf 100644 --- a/Editor/Sources/EditorCore/State/EditorStore.swift +++ b/Editor/Sources/EditorCore/State/EditorStore.swift @@ -36,6 +36,7 @@ public final class EditorStore: @unchecked Sendable { case viewportShadowsEnabled case viewportRenderScalePercent case viewportInteractionDownscaleEnabled + case viewportRealtimeEnabled case translateSnapEnabled case rotateSnapEnabled case scaleSnapEnabled @@ -165,6 +166,8 @@ public final class EditorStore: @unchecked Sendable { mark(.viewportInteractionDownscaleEnabled, old.viewportInteractionDownscaleEnabled, new.viewportInteractionDownscaleEnabled) + case .setViewportRealtime: + mark(.viewportRealtimeEnabled, old.viewportRealtimeEnabled, new.viewportRealtimeEnabled) case .setTranslateSnapEnabled: mark(.translateSnapEnabled, old.translateSnapEnabled, new.translateSnapEnabled) case .setRotateSnapEnabled: @@ -274,6 +277,9 @@ extension EditorStore { public var viewportInteractionDownscaleEnabled: Bool { read(.viewportInteractionDownscaleEnabled, storage.viewportInteractionDownscaleEnabled) } + public var viewportRealtimeEnabled: Bool { + read(.viewportRealtimeEnabled, storage.viewportRealtimeEnabled) + } public var translateSnapEnabled: Bool { read(.translateSnapEnabled, storage.translateSnapEnabled) } public var rotateSnapEnabled: Bool { read(.rotateSnapEnabled, storage.rotateSnapEnabled) } public var scaleSnapEnabled: Bool { read(.scaleSnapEnabled, storage.scaleSnapEnabled) } diff --git a/Editor/Tests/EditorCoreTests/EditorViewportRenderGateTests.swift b/Editor/Tests/EditorCoreTests/EditorViewportRenderGateTests.swift new file mode 100644 index 00000000..ecb8504c --- /dev/null +++ b/Editor/Tests/EditorCoreTests/EditorViewportRenderGateTests.swift @@ -0,0 +1,147 @@ +import EditorCore +import Foundation +import RenderBackend +import SceneRuntime +import SIMDCompat +import Testing + +@Suite("EditorViewportRenderGate") +struct EditorViewportRenderGateTests { + private func signature(revision: UInt64 = 1, + eyeX: Float = 0, + width: UInt32 = 1000, + settings: UInt64 = 0, + paletteCount: Int = 0) -> EditorViewportRenderGate.Signature { + var palettes: [EntityID: JointPalette] = [:] + for index in 0..(eyeX, 2, 5)), + drawableSize: RenderDrawableSize(width: width, height: 800), + settingsGeneration: settings, + jointPalettes: JointPaletteMap(palettes: palettes) + ) + } + + private func decide(_ gate: inout EditorViewportRenderGate, + _ sig: EditorViewportRenderGate.Signature, + force: Bool = false, + input: Bool = false, + taa: Bool = false, + now: Double) -> Bool { + gate.shouldRender(signature: sig, + forceContinuous: force, + hasViewportInput: input, + temporalEffectsActive: taa, + now: now) + } + + /// Steps the gate through enough clean ticks to drain the post-dirty + /// convergence frames, then returns the next clean decision. + private func settled(_ gate: inout EditorViewportRenderGate, + _ sig: EditorViewportRenderGate.Signature, + at time: Double) -> Bool { + for _ in 0.. public var target: SIMD3 public var up: SIMD3 diff --git a/GuavaUI/Sources/GuavaUIApp/AppConfig.swift b/GuavaUI/Sources/GuavaUIApp/AppConfig.swift index ce031bfd..a0becb90 100644 --- a/GuavaUI/Sources/GuavaUIApp/AppConfig.swift +++ b/GuavaUI/Sources/GuavaUIApp/AppConfig.swift @@ -7,6 +7,19 @@ public enum AppWindowTitleBarStyle: Sendable, Equatable { case hiddenInset } +/// How the main window schedules frames after a successful present. +public enum AppFrameDrivePolicy: Sendable, Equatable { + /// Request the next frame unconditionally — the window re-renders at + /// display rate forever. Right for games whose content animates every + /// frame (e.g. a `ViewportHost` compositing an engine texture that is + /// re-rendered in place without its surface state changing). + case continuous + /// Render only when something asks for a frame: input events, recompose, + /// `markRenderDirty`, animations, or an explicit `requestDisplay()` + /// (e.g. an engine render-completion handler). Idle windows do no work. + case eventDriven +} + /// 启动一个 GuavaUI 应用窗口时所需的最小参数。所有字段都有默认值, /// 调用方通常只需要传 `title` 加 root view。 public struct AppConfig: Sendable { @@ -29,6 +42,8 @@ public struct AppConfig: Sendable { public var titleBarStyle: AppWindowTitleBarStyle /// Optional UI frame-rate cap. `nil` preserves the event-driven default. public var targetFrameRate: Double? + /// Frame scheduling after present. `.continuous` keeps legacy behavior. + public var frameDrivePolicy: AppFrameDrivePolicy /// DevTools 配置。`nil` 关闭。默认从 `GUAVA_DEVTOOLS=1` env var 读取, /// 这样 release 构建不会意外开启服务端。 public var devTools: DevToolsConfig? @@ -42,6 +57,7 @@ public struct AppConfig: Sendable { msaaSampleCount: UInt32 = 4, titleBarStyle: AppWindowTitleBarStyle = .standard, targetFrameRate: Double? = nil, + frameDrivePolicy: AppFrameDrivePolicy = .continuous, devTools: DevToolsConfig? = nil) { self.title = title self.primaryFontName = primaryFontName @@ -56,6 +72,7 @@ public struct AppConfig: Sendable { } else { self.targetFrameRate = nil } + self.frameDrivePolicy = frameDrivePolicy self.devTools = devTools ?? DevToolsConfig.fromEnvironment(appTitle: title) } } diff --git a/GuavaUI/Sources/GuavaUIApp/AppRuntime.swift b/GuavaUI/Sources/GuavaUIApp/AppRuntime.swift index d268188c..68b0517a 100644 --- a/GuavaUI/Sources/GuavaUIApp/AppRuntime.swift +++ b/GuavaUI/Sources/GuavaUIApp/AppRuntime.swift @@ -510,7 +510,13 @@ public final class AppRuntime { let buffer = try encoder.finish() backend.submit(buffer) surface.present() - host.requestDisplay() + if config.frameDrivePolicy == .continuous { + // Legacy behavior: keep re-rendering at display rate. Under + // `.eventDriven` the loop's needsDisplay machinery (input, + // recompose, markRenderDirty, animations, requestDisplay) + // schedules the next frame instead. + host.requestDisplay() + } let presentEnd = TimingTrace.now() if Self.fpsLogEnabled { diff --git a/GuavaUI/Sources/GuavaUICompose/Primitives/TextField.swift b/GuavaUI/Sources/GuavaUICompose/Primitives/TextField.swift index 27a8df10..9178a173 100644 --- a/GuavaUI/Sources/GuavaUICompose/Primitives/TextField.swift +++ b/GuavaUI/Sources/GuavaUICompose/Primitives/TextField.swift @@ -199,6 +199,8 @@ public struct TextField: View { state.cursorIndex = text.wrappedValue.count node.attachments["__textfield_state"] = state } + state.hostNode = node + normalizeIndices(state) let snapshot = self updateInteractionHandlers(for: node, state: state) @@ -310,6 +312,9 @@ public struct TextField: View { // MARK: - Editing func handleKey(_ event: KeyEvent, state: FieldState, node: Node) -> Bool { + // The bound text may have been rewritten since the last interaction; + // re-anchor stale indices before any String.index arithmetic. + normalizeIndices(state) let mods = event.modifiers let shift = !mods.isDisjoint(with: .shift) let primaryModifier = !mods.isDisjoint(with: .gui) || !mods.isDisjoint(with: .ctrl) diff --git a/GuavaUI/Sources/GuavaUICompose/Primitives/TextFieldEditing.swift b/GuavaUI/Sources/GuavaUICompose/Primitives/TextFieldEditing.swift index 7e6dc485..434cbde7 100644 --- a/GuavaUI/Sources/GuavaUICompose/Primitives/TextFieldEditing.swift +++ b/GuavaUI/Sources/GuavaUICompose/Primitives/TextFieldEditing.swift @@ -8,6 +8,10 @@ extension TextField { /// Per-instance editing state. Lives on the captured closures so it /// persists across redraws without recompose. final class FieldState { + /// Surface node owning this state. Caret/selection changes happen + /// outside recompose, so they must invalidate the node's cached layer + /// themselves (`LayerAwareNodeRenderer` replays clean layers verbatim). + weak var hostNode: Node? /// Cursor index measured in `Character` units from the start of `text`. var cursorIndex: Int = 0 /// Selection anchor in `Character` units; `nil` means no selection. @@ -49,11 +53,29 @@ extension TextField { var isComposing: Bool { !compositionText.isEmpty } } + /// Clamp persisted indices into the current text. `FieldState` outlives + /// recompose while the bound text can be rewritten underneath it (entity + /// switch, formatter, programmatic set); a stale `cursorIndex` past the + /// new end must never reach `String.index(_:offsetBy:)` math. + func normalizeIndices(_ state: FieldState) { + let count = text.wrappedValue.count + state.cursorIndex = clamp(state.cursorIndex, 0, count) + if let anchor = state.selectionAnchor { + let bounded = clamp(anchor, 0, count) + state.selectionAnchor = bounded == state.cursorIndex ? nil : bounded + } + } + /// Returns the active selection range as a half-open `[low, high)` in - /// `Character` units, or nil when there is no selection. + /// `Character` units, or nil when there is no selection. Bounds are + /// clamped to the current text so stale state can't index out of range. func selectionRange(_ state: FieldState) -> Range? { guard let anchor = state.selectionAnchor, anchor != state.cursorIndex else { return nil } - return min(anchor, state.cursorIndex)..) -> String { @@ -157,7 +179,11 @@ extension TextField { recordCaretActivity(state) } + /// Every caret/selection mutation funnels through here: reset the blink + /// phase and invalidate the field's cached render layer so the change is + /// visible on the very next frame (frames may be event-driven). func recordCaretActivity(_ state: FieldState) { state.lastCaretActivity = TimingTrace.now() + state.hostNode?.markRenderDirty(reason: .styleSet(field: "textFieldCaret")) } } \ No newline at end of file diff --git a/GuavaUI/Sources/GuavaUICompose/Primitives/TextFieldInputController.swift b/GuavaUI/Sources/GuavaUICompose/Primitives/TextFieldInputController.swift index 018286f7..23d2af38 100644 --- a/GuavaUI/Sources/GuavaUICompose/Primitives/TextFieldInputController.swift +++ b/GuavaUI/Sources/GuavaUICompose/Primitives/TextFieldInputController.swift @@ -64,6 +64,7 @@ extension TextField { state.selectionAnchor = state.cursorIndex } state.cursorIndex = target + textField.recordCaretActivity(state) return .handled } registry.setHover(node) { phase in diff --git a/GuavaUI/Sources/GuavaUIRuntime/SDL3PlatformHost.swift b/GuavaUI/Sources/GuavaUIRuntime/SDL3PlatformHost.swift index 1889b40d..172d4ce8 100644 --- a/GuavaUI/Sources/GuavaUIRuntime/SDL3PlatformHost.swift +++ b/GuavaUI/Sources/GuavaUIRuntime/SDL3PlatformHost.swift @@ -705,6 +705,9 @@ public final class SDL3PlatformHost: PlatformHost { session.lastTextCursorAnimationTick = now session.needsDisplay = true + // The caret blink phase is computed inside the field's draw closure; + // invalidate its cached layer or the composite replays the old slice. + session.focusChain.focused?.markRenderDirty(reason: .styleSet(field: "textFieldCaretBlink")) } } diff --git a/GuavaUI/Tests/GuavaUIComposeTests/TextFieldTests.swift b/GuavaUI/Tests/GuavaUIComposeTests/TextFieldTests.swift index 3a2f209f..0afbc29e 100644 --- a/GuavaUI/Tests/GuavaUIComposeTests/TextFieldTests.swift +++ b/GuavaUI/Tests/GuavaUIComposeTests/TextFieldTests.swift @@ -792,4 +792,58 @@ struct TextFieldTests: GuavaUIComposeSerializedSuite { _ = h.key!(cmdBackspace, .target) #expect(rig.store.value == "") } } + + @Test("Caret movement invalidates the field's render so cached layers re-record") + func caretMoveMarksRenderDirty() { GlobalTestLock.locked { + let rig = makeRig() + rig.store.value = "hello" + rig.graph.install(root: TextField(text: makeBinding(rig.store))) + + let node = fieldNode(in: rig.tree.root) + let h = rig.registry.handlers(for: node) + + // Drain install-time dirt: a caret-only change must re-dirty the tree + // by itself (frames can be event-driven and layers cache their slices). + rig.tree.flush() + #expect(!rig.tree.hasRenderUpdates) + + let left = KeyEvent(scancode: 80, keycode: 0, modifiers: [], isRepeat: false) + let handled = h.key!(left, .target) + #expect(handled == .handled) + #expect(rig.tree.hasRenderUpdates) + + // Selection drag (pointer motion) must invalidate the same way. + rig.tree.flush() + #expect(!rig.tree.hasRenderUpdates) + let state = node.attachments["__textfield_state"] as? TextField.FieldState + state?.isDragging = true + let motion = MouseMotionEvent(x: 40, y: 10, deltaX: 4, deltaY: 0) + _ = h.motion!(motion, .target) + #expect(rig.tree.hasRenderUpdates) + } } + + @Test("Stale indices from external text rewrites are clamped, not crashed on") + func staleIndicesAreClamped() { GlobalTestLock.locked { + let rig = makeRig() + rig.store.value = "hello world" + rig.graph.install(root: TextField(text: makeBinding(rig.store))) + + let node = fieldNode(in: rig.tree.root) + let h = rig.registry.handlers(for: node) + + // Cursor seeds at the end (11). Shrink the bound text behind the + // field's back — entity switch / formatter rewrite in the editor. + rig.store.value = "hi" + let backspace = KeyEvent(scancode: 42, keycode: 0, modifiers: [], isRepeat: false) + _ = h.key!(backspace, .target) + #expect(rig.store.value == "h") + + // Stale selection: select all of "h", externally empty the text, then + // type — the dead range must collapse instead of indexing past end. + let cmdA = KeyEvent(scancode: Scancode.a, keycode: 0, modifiers: [.lgui], isRepeat: false) + _ = h.key!(cmdA, .target) + rig.store.value = "" + _ = h.text!("x", .target) + #expect(rig.store.value == "x") + } } }