diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 0dc68f02..87c694f0 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -8,6 +8,11 @@ import Sharing @MainActor @Observable final class WorktreeTerminalState { + struct SurfaceActivity: Equatable { + let isVisible: Bool + let isFocused: Bool + } + let tabManager: TerminalTabManager private let runtime: GhosttyRuntime private let worktree: Worktree @@ -202,19 +207,23 @@ final class WorktreeTerminalState { surface.insertText(text, replacementRange: NSRange(location: 0, length: 0)) } - func syncFocus(windowIsKey: Bool) { + func syncFocus(windowIsKey: Bool, windowIsVisible: Bool) { let selectedTabId = tabManager.selectedTabId var surfaceToFocus: GhosttySurfaceView? for (tabId, tree) in trees { let focusedId = focusedSurfaceIdByTab[tabId] - // Occlusion: only selected tab is visible, regardless of window focus let isSelectedTab = (tabId == selectedTabId) for surface in tree.leaves() { - surface.setOcclusion(isSelectedTab) - // Focus: requires both selected tab AND window key - let isFocused = windowIsKey && isSelectedTab && surface.id == focusedId - surface.focusDidChange(isFocused) - if isFocused { + let activity = Self.surfaceActivity( + isSelectedTab: isSelectedTab, + windowIsVisible: windowIsVisible, + windowIsKey: windowIsKey, + focusedSurfaceID: focusedId, + surfaceID: surface.id + ) + surface.setOcclusion(activity.isVisible) + surface.focusDidChange(activity.isFocused) + if activity.isFocused { surfaceToFocus = surface } } @@ -224,6 +233,18 @@ final class WorktreeTerminalState { } } + static func surfaceActivity( + isSelectedTab: Bool, + windowIsVisible: Bool, + windowIsKey: Bool, + focusedSurfaceID: UUID?, + surfaceID: UUID + ) -> SurfaceActivity { + let isVisible = isSelectedTab && windowIsVisible + let isFocused = isVisible && windowIsKey && focusedSurfaceID == surfaceID + return SurfaceActivity(isVisible: isVisible, isFocused: isFocused) + } + @discardableResult func focusSurface(id: UUID) -> Bool { guard let tabId = tabId(containing: id), diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabContentStack.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabContentStack.swift index 3161aa0b..3663cc9e 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabContentStack.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabContentStack.swift @@ -16,16 +16,13 @@ struct TerminalTabContentStack: View { } var body: some View { - ZStack { - ForEach(tabs) { tab in - content(tab.id) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .opacity(tab.id == selectedTabId ? 1 : 0) - .zIndex(tab.id == selectedTabId ? 1 : 0) - .allowsHitTesting(tab.id == selectedTabId) - .accessibilityHidden(tab.id != selectedTabId) - } + if let selectedTabID = Self.selectedTabID(in: tabs, selectedTabId: selectedTabId) { + content(selectedTabID) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + static func selectedTabID(in tabs: [TerminalTabItem], selectedTabId: TerminalTabID) -> TerminalTabID? { + tabs.contains { $0.id == selectedTabId } ? selectedTabId : nil } } diff --git a/supacode/Features/Terminal/Views/WindowFocusObserverView.swift b/supacode/Features/Terminal/Views/WindowFocusObserverView.swift index 4a01deee..3e21b448 100644 --- a/supacode/Features/Terminal/Views/WindowFocusObserverView.swift +++ b/supacode/Features/Terminal/Views/WindowFocusObserverView.swift @@ -1,49 +1,55 @@ import AppKit import SwiftUI +struct WindowActivityState: Equatable { + let isKeyWindow: Bool + let isVisible: Bool + + static let inactive = Self(isKeyWindow: false, isVisible: false) +} + struct WindowFocusObserverView: NSViewRepresentable { - let onWindowKeyChanged: (Bool) -> Void + let onWindowActivityChanged: (WindowActivityState) -> Void func makeNSView(context: Context) -> WindowFocusObserverNSView { let view = WindowFocusObserverNSView() - view.onWindowKeyChanged = onWindowKeyChanged + view.onWindowActivityChanged = onWindowActivityChanged return view } func updateNSView(_ nsView: WindowFocusObserverNSView, context: Context) { - nsView.onWindowKeyChanged = onWindowKeyChanged - nsView.notifyCurrentState() + nsView.onWindowActivityChanged = onWindowActivityChanged } } final class WindowFocusObserverNSView: NSView { - var onWindowKeyChanged: (Bool) -> Void = { _ in } + var onWindowActivityChanged: (WindowActivityState) -> Void = { _ in } private var observers: [NSObjectProtocol] = [] private weak var observedWindow: NSWindow? + private var lastEmittedActivity: WindowActivityState? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() updateObservers() } - func notifyCurrentState() { - onWindowKeyChanged(windowIsEffectivelyKey) - } - - private var windowIsEffectivelyKey: Bool { - guard let window else { return false } - return window.isKeyWindow && window.occlusionState.contains(.visible) + private var activityState: WindowActivityState { + guard let window else { return .inactive } + return WindowActivityState( + isKeyWindow: window.isKeyWindow, + isVisible: window.occlusionState.contains(.visible) + ) } private func updateObservers() { if observedWindow === window { - notifyCurrentState() + emitActivityIfNeeded() return } clearObservers() observedWindow = window guard let window else { - onWindowKeyChanged(false) + emitActivityIfNeeded(force: true) return } let center = NotificationCenter.default @@ -53,8 +59,7 @@ final class WindowFocusObserverNSView: NSView { object: window, queue: .main ) { [weak self] _ in - guard let self else { return } - self.onWindowKeyChanged(self.windowIsEffectivelyKey) + self?.emitActivityIfNeeded() }) observers.append( center.addObserver( @@ -62,7 +67,7 @@ final class WindowFocusObserverNSView: NSView { object: window, queue: .main ) { [weak self] _ in - self?.onWindowKeyChanged(false) + self?.emitActivityIfNeeded() }) observers.append( center.addObserver( @@ -70,10 +75,18 @@ final class WindowFocusObserverNSView: NSView { object: window, queue: .main ) { [weak self] _ in - guard let self else { return } - self.onWindowKeyChanged(self.windowIsEffectivelyKey) + self?.emitActivityIfNeeded() }) - onWindowKeyChanged(windowIsEffectivelyKey) + emitActivityIfNeeded(force: true) + } + + private func emitActivityIfNeeded(force: Bool = false) { + let activity = activityState + if !force, activity == lastEmittedActivity { + return + } + lastEmittedActivity = activity + onWindowActivityChanged(activity) } private func clearObservers() { diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 2e032635..30871245 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -7,6 +7,7 @@ struct WorktreeTerminalTabsView: View { let shouldRunSetupScript: Bool let forceAutoFocus: Bool let createTab: () -> Void + @State private var windowActivity = WindowActivityState.inactive var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } @@ -45,8 +46,9 @@ struct WorktreeTerminalTabsView: View { } } .background( - WindowFocusObserverView { isKey in - state.syncFocus(windowIsKey: isKey) + WindowFocusObserverView { activity in + windowActivity = activity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } ) .onAppear { @@ -54,12 +56,15 @@ struct WorktreeTerminalTabsView: View { if shouldAutoFocusTerminal { state.focusSelectedTab() } + let activity = resolvedWindowActivity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } .onChange(of: state.tabManager.selectedTabId) { _, _ in if shouldAutoFocusTerminal { state.focusSelectedTab() } - state.syncFocus(windowIsKey: NSApp.keyWindow?.isKeyWindow ?? false) + let activity = resolvedWindowActivity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } } @@ -70,4 +75,14 @@ struct WorktreeTerminalTabsView: View { guard let responder = NSApp.keyWindow?.firstResponder else { return true } return !(responder is NSTableView) && !(responder is NSOutlineView) } + + private var resolvedWindowActivity: WindowActivityState { + if let keyWindow = NSApp.keyWindow { + return WindowActivityState( + isKeyWindow: keyWindow.isKeyWindow, + isVisible: keyWindow.occlusionState.contains(.visible) + ) + } + return windowActivity + } } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 23a245e0..0a9ee7a6 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -29,6 +29,8 @@ final class GhosttySurfaceView: NSView, Identifiable { private var keyTextAccumulator: [String]? private var cellSize: CGSize = .zero private var lastScrollbar: ScrollbarState? + private var lastOcclusion: Bool? + private var lastSurfaceFocus: Bool? private var eventMonitor: Any? private var notificationObservers: [NSObjectProtocol] = [] private var prevPressureStage: Int = 0 @@ -160,6 +162,8 @@ final class GhosttySurfaceView: NSView, Identifiable { ghostty_surface_free(surface) self.surface = nil bridge.surface = nil + lastOcclusion = nil + lastSurfaceFocus = nil } } @@ -640,6 +644,8 @@ final class GhosttySurfaceView: NSView, Identifiable { config.context = context surface = ghostty_surface_new(app, &config) bridge.surface = surface + lastOcclusion = nil + lastSurfaceFocus = nil updateSurfaceSize() } @@ -661,11 +667,19 @@ final class GhosttySurfaceView: NSView, Identifiable { func setOcclusion(_ visible: Bool) { guard let surface else { return } + if lastOcclusion == visible { + return + } + lastOcclusion = visible ghostty_surface_set_occlusion(surface, visible) } private func setSurfaceFocus(_ focused: Bool) { guard let surface else { return } + if lastSurfaceFocus == focused { + return + } + lastSurfaceFocus = focused ghostty_surface_set_focus(surface, focused) } diff --git a/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift b/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift index 2be04da4..1ff4b710 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -8,6 +8,6 @@ struct GhosttyTerminalView: NSViewRepresentable { } func updateNSView(_ view: GhosttySurfaceScrollView, context: Context) { - view.updateSurfaceSize() + _ = view } } diff --git a/supacodeTests/TerminalRenderingPolicyTests.swift b/supacodeTests/TerminalRenderingPolicyTests.swift new file mode 100644 index 00000000..6e0514cb --- /dev/null +++ b/supacodeTests/TerminalRenderingPolicyTests.swift @@ -0,0 +1,97 @@ +import SwiftUI +import Testing + +@testable import supacode + +@MainActor +struct TerminalRenderingPolicyTests { + @Test func surfaceActivityForSelectedVisibleFocusedSurfaceIsFocused() { + let focusedID = UUID() + let activity = WorktreeTerminalState.surfaceActivity( + isSelectedTab: true, + windowIsVisible: true, + windowIsKey: true, + focusedSurfaceID: focusedID, + surfaceID: focusedID + ) + #expect(activity.isVisible) + #expect(activity.isFocused) + } + + @Test func surfaceActivityForSelectedVisibleUnfocusedSurfaceIsNotFocused() { + let activity = WorktreeTerminalState.surfaceActivity( + isSelectedTab: true, + windowIsVisible: true, + windowIsKey: true, + focusedSurfaceID: UUID(), + surfaceID: UUID() + ) + #expect(activity.isVisible) + #expect(!activity.isFocused) + } + + @Test func surfaceActivityForSelectedTabInBackgroundWindowIsVisibleButNotFocused() { + let surfaceID = UUID() + let activity = WorktreeTerminalState.surfaceActivity( + isSelectedTab: true, + windowIsVisible: true, + windowIsKey: false, + focusedSurfaceID: surfaceID, + surfaceID: surfaceID + ) + #expect(activity.isVisible) + #expect(!activity.isFocused) + } + + @Test func surfaceActivityForOccludedWindowIsHiddenAndUnfocused() { + let surfaceID = UUID() + let activity = WorktreeTerminalState.surfaceActivity( + isSelectedTab: true, + windowIsVisible: false, + windowIsKey: true, + focusedSurfaceID: surfaceID, + surfaceID: surfaceID + ) + #expect(!activity.isVisible) + #expect(!activity.isFocused) + } + + @Test func surfaceActivityForUnselectedTabIsHiddenAndUnfocused() { + let surfaceID = UUID() + let activity = WorktreeTerminalState.surfaceActivity( + isSelectedTab: false, + windowIsVisible: true, + windowIsKey: true, + focusedSurfaceID: surfaceID, + surfaceID: surfaceID + ) + #expect(!activity.isVisible) + #expect(!activity.isFocused) + } + + @Test func tabContentStackReturnsSelectedTabWhenItExists() { + let selected = TerminalTabID() + let tabs = [ + TerminalTabItem(title: "one", icon: nil), + TerminalTabItem(id: selected, title: "two", icon: nil), + ] + let selectedTab = TerminalTabContentStack.selectedTabID( + in: tabs, + selectedTabId: selected + ) + #expect(selectedTab == selected) + } + + @Test func tabContentStackReturnsNilWhenSelectionDoesNotExist() { + let selected = TerminalTabID() + let tabs = [ + TerminalTabItem(title: "one", icon: nil), + TerminalTabItem(title: "two", icon: nil), + ] + let selectedTab = TerminalTabContentStack.selectedTabID( + in: tabs, + selectedTabId: selected + ) + #expect(selectedTab == nil) + } +}