native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #156 from onevcat/fix/canvas-exit-occlusion-attach

fix: defer terminal occlusion apply until surface is attached

authored by

Wei Wang and committed by
GitHub
3b590137 f127b81d

+192 -35
-3
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 145 145 "[CanvasExit] setSelectedWorktreeID previous=\(previousSelectedWorktreeID ?? "nil") " 146 146 + "next=\(id ?? "nil") leavingCanvas=\(leavingCanvas) states=\(states.count)" 147 147 ) 148 - if leavingCanvas, let id, let state = states[id] { 149 - state.refreshSurfaceActivity(reason: "canvas-exit-selected-worktree") 150 - } 151 148 terminalLogger.info("Selected worktree \(id ?? "nil")") 152 149 case .saveLayoutSnapshot: 153 150 terminalLogger.info("[LayoutRestore] received saveLayoutSnapshot command")
-14
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 318 318 applySurfaceActivity() 319 319 } 320 320 321 - func refreshSurfaceActivity(reason: String) { 322 - terminalStateLogger.info( 323 - "[CanvasExit] refreshSurfaceActivity worktree=\(worktree.id) reason=\(reason) " 324 - + "selectedTab=\(tabManager.selectedTabId?.rawValue.uuidString ?? "nil") " 325 - + "windowKey=\(String(describing: lastWindowIsKey)) " 326 - + "windowVisible=\(String(describing: lastWindowIsVisible)) " 327 - + "surfaces=\(surfaces.count) tabs=\(tabManager.tabs.count)" 328 - ) 329 - for surface in surfaces.values { 330 - surface.invalidateOcclusionCache(reason: reason) 331 - } 332 - applySurfaceActivity() 333 - } 334 - 335 321 private func applySurfaceActivity() { 336 322 let selectedTabId = tabManager.selectedTabId 337 323 var surfaceToFocus: GhosttySurfaceView?
+62 -18
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 12 12 private(set) var desired: Bool? 13 13 private var applied: Bool? 14 14 15 + mutating func setDesired(_ visible: Bool) { 16 + desired = visible 17 + } 18 + 15 19 mutating func prepareToApply(_ visible: Bool) -> Bool { 16 20 desired = visible 17 21 guard applied != visible else { return false } ··· 102 106 private let initialInputCString: UnsafeMutablePointer<CChar>? 103 107 private let fontSize: Float32 104 108 private let context: ghostty_surface_context_e 109 + private let skipsSurfaceCreationForTesting: Bool 105 110 private var trackingArea: NSTrackingArea? 106 111 private var lastBackingSize: CGSize = .zero 107 112 private var lastPerformKeyEvent: TimeInterval? ··· 149 154 var onCommittedText: ((String) -> Void)? 150 155 var onMirroredKey: ((MirroredTerminalKey) -> Void)? 151 156 var onFontSizeShortcut: (() -> Void)? 157 + var onOcclusionAppliedForTesting: ((Bool) -> Void)? 158 + var attachmentStateForTesting: (() -> (hasSuperview: Bool, hasWindow: Bool))? 152 159 153 160 private var accessibilityPaneIndexHelp: String? 154 161 ··· 209 216 workingDirectory: URL?, 210 217 initialInput: String? = nil, 211 218 fontSize: Float32? = nil, 212 - context: ghostty_surface_context_e 219 + context: ghostty_surface_context_e, 220 + skipsSurfaceCreationForTesting: Bool = false 213 221 ) { 214 222 self.runtime = runtime 215 223 self.bridge = GhosttySurfaceBridge() 216 224 self.fontSize = fontSize ?? 0 217 225 self.context = context 226 + self.skipsSurfaceCreationForTesting = skipsSurfaceCreationForTesting 218 227 if let workingDirectory { 219 228 let path = Self.normalizedWorkingDirectoryPath( 220 229 workingDirectory.path(percentEncoded: false) ··· 231 240 super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) 232 241 wantsLayer = true 233 242 bridge.surfaceView = self 234 - createSurface() 235 - if let surface { 236 - surfaceRef = runtime.registerSurface(surface) 243 + if !skipsSurfaceCreationForTesting { 244 + createSurface() 245 + if let surface { 246 + surfaceRef = runtime.registerSurface(surface) 247 + } 237 248 } 238 249 registerForDraggedTypes(Array(Self.dropTypes)) 239 250 ··· 983 994 } 984 995 985 996 func setOcclusion(_ visible: Bool) { 986 - guard let surface else { return } 997 + guard let surface else { 998 + guard skipsSurfaceCreationForTesting else { return } 999 + guard isReadyToApplyOcclusion else { 1000 + if occlusionState.desired != visible { 1001 + surfaceLogger.info( 1002 + "[CanvasExit] deferOcclusion surface=\(debugID) desired=\(visible) " 1003 + + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow)" 1004 + ) 1005 + } 1006 + occlusionState.setDesired(visible) 1007 + return 1008 + } 1009 + guard occlusionState.prepareToApply(visible) else { return } 1010 + onOcclusionAppliedForTesting?(visible) 1011 + return 1012 + } 1013 + guard isReadyToApplyOcclusion else { 1014 + if occlusionState.desired != visible { 1015 + surfaceLogger.info( 1016 + "[CanvasExit] deferOcclusion surface=\(debugID) desired=\(visible) " 1017 + + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow)" 1018 + ) 1019 + } 1020 + occlusionState.setDesired(visible) 1021 + return 1022 + } 987 1023 guard occlusionState.prepareToApply(visible) else { return } 1024 + onOcclusionAppliedForTesting?(visible) 988 1025 ghostty_surface_set_occlusion(surface, visible) 989 1026 } 990 1027 991 - func invalidateOcclusionCache(reason: String) { 992 - surfaceLogger.info( 993 - "[CanvasExit] invalidateOcclusionCache surface=\(debugID) " 994 - + "reason=\(reason) desired=\(String(describing: occlusionState.desired)) " 995 - + "attached=\(superview != nil) window=\(window != nil)" 996 - ) 997 - _ = occlusionState.invalidateForAttachmentChange() 998 - } 999 - 1000 1028 private func handleAttachmentChange() { 1001 1029 // Re-parenting can temporarily detach the Metal layer from the visible 1002 1030 // tree and pause Ghostty's renderer. Invalidate the applied cache so the ··· 1004 1032 surfaceLogger.info( 1005 1033 "[CanvasExit] attachmentChange surface=\(debugID) " 1006 1034 + "desired=\(String(describing: occlusionState.desired)) " 1007 - + "attached=\(superview != nil) window=\(window != nil)" 1035 + + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow)" 1008 1036 ) 1009 1037 _ = occlusionState.invalidateForAttachmentChange() 1010 - guard superview != nil else { return } 1038 + guard isReadyToApplyOcclusion else { return } 1011 1039 DispatchQueue.main.async { [weak self] in 1012 1040 self?.reapplyOcclusionIfNeeded() 1013 1041 } 1014 1042 } 1015 1043 1044 + func handleAttachmentChangeForTesting() { 1045 + handleAttachmentChange() 1046 + } 1047 + 1016 1048 private func reapplyOcclusionIfNeeded() { 1017 - guard superview != nil, let desired = occlusionState.desired else { return } 1049 + guard isReadyToApplyOcclusion, let desired = occlusionState.desired else { return } 1018 1050 surfaceLogger.info( 1019 1051 "[CanvasExit] reapplyOcclusion surface=\(debugID) desired=\(desired) " 1020 - + "attached=\(superview != nil) window=\(window != nil)" 1052 + + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow)" 1021 1053 ) 1022 1054 setOcclusion(desired) 1055 + } 1056 + 1057 + private var isReadyToApplyOcclusion: Bool { 1058 + hasAttachedSuperview && hasAttachedWindow 1059 + } 1060 + 1061 + private var hasAttachedSuperview: Bool { 1062 + attachmentStateForTesting?().hasSuperview ?? (superview != nil) 1063 + } 1064 + 1065 + private var hasAttachedWindow: Bool { 1066 + attachmentStateForTesting?().hasWindow ?? (window != nil) 1023 1067 } 1024 1068 1025 1069 private func setSurfaceFocus(_ focused: Bool) {
+130
supacodeTests/GhosttySurfaceViewTests.swift
··· 1 1 import Foundation 2 + import GhosttyKit 2 3 import Testing 3 4 4 5 @testable import supacode ··· 30 31 #expect(desired == nil) 31 32 #expect(firstApply) 32 33 #expect(!secondApply) 34 + } 35 + 36 + @Test func occlusionStateStoresDesiredValueWithoutMarkingItApplied() { 37 + var state = GhosttySurfaceView.OcclusionState() 38 + 39 + state.setDesired(true) 40 + let firstApply = state.prepareToApply(true) 41 + let secondApply = state.prepareToApply(true) 42 + 43 + #expect(firstApply) 44 + #expect(!secondApply) 45 + } 46 + 47 + @Test func occlusionStateUsesLatestDeferredDesiredValue() { 48 + var state = GhosttySurfaceView.OcclusionState() 49 + 50 + state.setDesired(true) 51 + state.setDesired(false) 52 + let applyDeferredValue = state.prepareToApply(false) 53 + let secondApply = state.prepareToApply(false) 54 + 55 + #expect(applyDeferredValue) 56 + #expect(!secondApply) 57 + } 58 + 59 + @Test func occlusionStateAppliesLatestValueAfterAttachmentInvalidation() { 60 + var state = GhosttySurfaceView.OcclusionState() 61 + 62 + let firstApply = state.prepareToApply(true) 63 + let desiredAfterAttachmentChange = state.invalidateForAttachmentChange() 64 + #expect(firstApply) 65 + #expect(desiredAfterAttachmentChange == true) 66 + 67 + state.setDesired(false) 68 + let applyLatestValue = state.prepareToApply(false) 69 + let duplicateApply = state.prepareToApply(false) 70 + 71 + #expect(applyLatestValue) 72 + #expect(!duplicateApply) 73 + } 74 + 75 + @Test func occlusionStateRetainsLatestDesiredValueAcrossMultipleAttachmentChanges() { 76 + var state = GhosttySurfaceView.OcclusionState() 77 + 78 + state.setDesired(true) 79 + let desiredAfterFirstAttachmentChange = state.invalidateForAttachmentChange() 80 + #expect(desiredAfterFirstAttachmentChange == true) 81 + state.setDesired(false) 82 + let desiredAfterSecondAttachmentChange = state.invalidateForAttachmentChange() 83 + #expect(desiredAfterSecondAttachmentChange == false) 84 + 85 + let applyLatestValue = state.prepareToApply(false) 86 + let duplicateApply = state.prepareToApply(false) 87 + 88 + #expect(applyLatestValue) 89 + #expect(!duplicateApply) 90 + } 91 + 92 + @Test func occlusionDoesNotApplyUntilViewHasSuperviewAndWindow() async { 93 + let runtime = GhosttyRuntime() 94 + let surfaceView = GhosttySurfaceView( 95 + runtime: runtime, 96 + workingDirectory: nil, 97 + context: GHOSTTY_SURFACE_CONTEXT_TAB, 98 + skipsSurfaceCreationForTesting: true 99 + ) 100 + var appliedValues: [Bool] = [] 101 + surfaceView.onOcclusionAppliedForTesting = { appliedValues.append($0) } 102 + var attachmentState = (hasSuperview: false, hasWindow: false) 103 + surfaceView.attachmentStateForTesting = { attachmentState } 104 + 105 + surfaceView.setOcclusion(true) 106 + await drainMainQueue() 107 + #expect(appliedValues.isEmpty) 108 + 109 + attachmentState = (hasSuperview: true, hasWindow: false) 110 + surfaceView.handleAttachmentChangeForTesting() 111 + await drainMainQueue() 112 + #expect(appliedValues.isEmpty) 113 + 114 + attachmentState = (hasSuperview: true, hasWindow: true) 115 + surfaceView.handleAttachmentChangeForTesting() 116 + await drainMainQueue() 117 + #expect(appliedValues == [true]) 118 + } 119 + 120 + @Test func occlusionAppliesLatestDeferredValueAfterWindowReattachment() async { 121 + let runtime = GhosttyRuntime() 122 + let surfaceView = GhosttySurfaceView( 123 + runtime: runtime, 124 + workingDirectory: nil, 125 + context: GHOSTTY_SURFACE_CONTEXT_TAB, 126 + skipsSurfaceCreationForTesting: true 127 + ) 128 + var appliedValues: [Bool] = [] 129 + surfaceView.onOcclusionAppliedForTesting = { appliedValues.append($0) } 130 + var attachmentState = (hasSuperview: true, hasWindow: true) 131 + surfaceView.attachmentStateForTesting = { attachmentState } 132 + 133 + surfaceView.handleAttachmentChangeForTesting() 134 + await drainMainQueue() 135 + #expect(appliedValues.isEmpty) 136 + 137 + surfaceView.setOcclusion(true) 138 + await drainMainQueue() 139 + #expect(appliedValues == [true]) 140 + 141 + attachmentState = (hasSuperview: false, hasWindow: false) 142 + surfaceView.handleAttachmentChangeForTesting() 143 + await drainMainQueue() 144 + 145 + surfaceView.setOcclusion(false) 146 + surfaceView.setOcclusion(true) 147 + surfaceView.setOcclusion(false) 148 + await drainMainQueue() 149 + #expect(appliedValues == [true]) 150 + 151 + attachmentState = (hasSuperview: true, hasWindow: true) 152 + surfaceView.handleAttachmentChangeForTesting() 153 + await drainMainQueue() 154 + #expect(appliedValues == [true, false]) 155 + } 156 + 157 + private func drainMainQueue() async { 158 + await withCheckedContinuation { continuation in 159 + DispatchQueue.main.async { 160 + continuation.resume() 161 + } 162 + } 33 163 } 34 164 35 165 @Test func normalizedWorkingDirectoryPathRemovesTrailingSlashForNonRootPath() {