diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f9477378..fd2f7d12 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -52,7 +52,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let surfaceId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) { - tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false) + tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: false) } notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1d193c2b..d7e5ef0f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1528,6 +1528,21 @@ final class GhosttySurfaceScrollView: NSView { private var lastSentRow: Int? private var isActive = true private var focusWorkItem: DispatchWorkItem? +#if DEBUG + private static var flashCounts: [UUID: Int] = [:] + + static func flashCount(for surfaceId: UUID) -> Int { + flashCounts[surfaceId, default: 0] + } + + static func resetFlashCounts() { + flashCounts.removeAll() + } + + private static func recordFlash(for surfaceId: UUID) { + flashCounts[surfaceId, default: 0] += 1 + } +#endif init(surfaceView: GhosttyNSView) { self.surfaceView = surfaceView @@ -1712,6 +1727,11 @@ final class GhosttySurfaceScrollView: NSView { func triggerFlash() { DispatchQueue.main.async { [weak self] in guard let self else { return } +#if DEBUG + if let surfaceId = self.surfaceView.terminalSurface?.id { + Self.recordFlash(for: surfaceId) + } +#endif self.updateFlashPath() self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 diff --git a/Sources/Splits/TerminalSplitTreeView.swift b/Sources/Splits/TerminalSplitTreeView.swift index adf1d40d..99d66bb0 100644 --- a/Sources/Splits/TerminalSplitTreeView.swift +++ b/Sources/Splits/TerminalSplitTreeView.swift @@ -4,6 +4,7 @@ struct TerminalSplitTreeView: View { @ObservedObject var tab: Tab let isTabActive: Bool @State private var config = GhosttyConfig.load() + @EnvironmentObject var notificationStore: TerminalNotificationStore var body: some View { let appearance = SplitAppearance( @@ -20,6 +21,8 @@ struct TerminalSplitTreeView: View { isTabActive: isTabActive, focusedSurfaceId: tab.focusedSurfaceId, appearance: appearance, + tabId: tab.id, + notificationStore: notificationStore, onFocus: { tab.focusSurface($0) }, onTriggerFlash: { tab.triggerDebugFlash(surfaceId: $0) }, onResize: { tab.updateSplitRatio(node: $0, ratio: $1) }, @@ -44,6 +47,8 @@ fileprivate struct TerminalSplitSubtreeView: View { let isTabActive: Bool let focusedSurfaceId: UUID? let appearance: SplitAppearance + let tabId: UUID + let notificationStore: TerminalNotificationStore let onFocus: (UUID) -> Void let onTriggerFlash: (UUID) -> Void let onResize: (SplitTree.Node, Double) -> Void @@ -53,7 +58,7 @@ fileprivate struct TerminalSplitSubtreeView: View { switch node { case .leaf(let surface): let isFocused = isTabActive && focusedSurfaceId == surface.id - ZStack { + ZStack(alignment: .topLeading) { GhosttyTerminalView( terminalSurface: surface, isActive: isFocused, @@ -62,6 +67,15 @@ fileprivate struct TerminalSplitSubtreeView: View { ) .background(Color.clear) + if notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surface.id) { + Circle() + .stroke(Color(nsColor: .systemBlue), lineWidth: 2.5) + .frame(width: 14, height: 14) + .shadow(color: Color(nsColor: .systemBlue).opacity(0.35), radius: 2) + .padding(6) + .allowsHitTesting(false) + } + if isSplit && !isFocused && appearance.unfocusedOverlayOpacity > 0 { Rectangle() .fill(appearance.unfocusedOverlayColor) @@ -92,6 +106,8 @@ fileprivate struct TerminalSplitSubtreeView: View { isTabActive: isTabActive, focusedSurfaceId: focusedSurfaceId, appearance: appearance, + tabId: tabId, + notificationStore: notificationStore, onFocus: onFocus, onTriggerFlash: onTriggerFlash, onResize: onResize, @@ -106,6 +122,8 @@ fileprivate struct TerminalSplitSubtreeView: View { isTabActive: isTabActive, focusedSurfaceId: focusedSurfaceId, appearance: appearance, + tabId: tabId, + notificationStore: notificationStore, onFocus: onFocus, onTriggerFlash: onTriggerFlash, onResize: onResize, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 63a9e148..086251a2 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -7,7 +7,12 @@ class Tab: Identifiable, ObservableObject { @Published var title: String @Published var currentDirectory: String @Published var splitTree: SplitTree - @Published var focusedSurfaceId: UUID? + @Published var focusedSurfaceId: UUID? { + didSet { + guard let focusedSurfaceId else { return } + AppDelegate.shared?.tabManager?.rememberFocusedSurface(tabId: id, surfaceId: focusedSurfaceId) + } + } @Published var surfaceDirectories: [UUID: String] = [:] var splitViewSize: CGSize = .zero @@ -33,7 +38,7 @@ class Tab: Identifiable, ObservableObject { return nil } - func focusSurface(_ id: UUID, shouldFlash: Bool = true) { + func focusSurface(_ id: UUID) { let wasFocused = focusedSurfaceId == id focusedSurfaceId = id let isSelectedTab = AppDelegate.shared?.tabManager?.selectedTabId == self.id @@ -44,9 +49,6 @@ class Tab: Identifiable, ObservableObject { guard isSelectedTab && isAppFocused else { return } guard let notificationStore = AppDelegate.shared?.notificationStore else { return } if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) { - if shouldFlash { - triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false) - } notificationStore.markRead(forTabId: self.id, surfaceId: id) return } @@ -64,17 +66,26 @@ class Tab: Identifiable, ObservableObject { currentDirectory = trimmed } - func triggerNotificationFocusFlash(surfaceId: UUID, requiresSplit: Bool = false) { - triggerPanelFlash(surfaceId: surfaceId, requiresSplit: requiresSplit) + func triggerNotificationFocusFlash( + surfaceId: UUID, + requiresSplit: Bool = false, + shouldFocus: Bool = true + ) { + triggerPanelFlash(surfaceId: surfaceId, requiresSplit: requiresSplit, shouldFocus: shouldFocus) } func triggerDebugFlash(surfaceId: UUID) { - triggerPanelFlash(surfaceId: surfaceId, requiresSplit: false) + triggerPanelFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: true) } - private func triggerPanelFlash(surfaceId: UUID, requiresSplit: Bool) { + private func triggerPanelFlash(surfaceId: UUID, requiresSplit: Bool, shouldFocus: Bool) { guard let surface = surface(for: surfaceId) else { return } - focusSurface(surfaceId) + if shouldFocus { + if focusedSurfaceId != surfaceId { + focusSurface(surfaceId) + } + surface.hostedView.ensureFocus(for: self.id, surfaceId: surfaceId) + } if requiresSplit && !splitTree.isSplit { return } @@ -249,6 +260,10 @@ class TabManager: ObservableObject { didSet { guard selectedTabId != oldValue else { return } let previousTabId = oldValue + if let previousTabId, + let previousSurfaceId = focusedSurfaceId(for: previousTabId) { + lastFocusedSurfaceByTab[previousTabId] = previousSurfaceId + } DispatchQueue.main.async { [weak self] in self?.focusSelectedTabSurface(previousTabId: previousTabId) self?.updateWindowTitleForSelectedTab() @@ -260,6 +275,7 @@ class TabManager: ObservableObject { } private var observers: [NSObjectProtocol] = [] private var suppressFocusFlash = false + private var lastFocusedSurfaceByTab: [UUID: UUID] = [:] init() { addTab() @@ -415,6 +431,10 @@ class TabManager: ObservableObject { tabs.first(where: { $0.id == tabId })?.focusedSurfaceId } + func rememberFocusedSurface(tabId: UUID, surfaceId: UUID) { + lastFocusedSurfaceByTab[tabId] = surfaceId + } + func applyWindowBackgroundForSelectedTab() { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), @@ -424,8 +444,13 @@ class TabManager: ObservableObject { private func focusSelectedTabSurface(previousTabId: UUID?) { guard let selectedTabId, - let tab = tabs.first(where: { $0.id == selectedTabId }), - let surface = tab.focusedSurface else { return } + let tab = tabs.first(where: { $0.id == selectedTabId }) else { return } + if let restoredSurfaceId = lastFocusedSurfaceByTab[selectedTabId], + tab.surface(for: restoredSurfaceId) != nil, + tab.focusedSurfaceId != restoredSurfaceId { + tab.focusedSurfaceId = restoredSurfaceId + } + guard let surface = tab.focusedSurface else { return } let previousSurface = previousTabId.flatMap { id in tabs.first(where: { $0.id == id })?.focusedSurface } @@ -436,14 +461,11 @@ class TabManager: ObservableObject { private func markFocusedPanelReadIfActive(tabId: UUID) { let shouldSuppressFlash = suppressFocusFlash suppressFocusFlash = false + guard !shouldSuppressFlash else { return } guard AppFocusState.isAppFocused() else { return } guard let surfaceId = focusedSurfaceId(for: tabId) else { return } guard let notificationStore = AppDelegate.shared?.notificationStore else { return } guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } - if !shouldSuppressFlash, - let tab = tabs.first(where: { $0.id == tabId }) { - tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false) - } notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } @@ -501,14 +523,18 @@ class TabManager: ObservableObject { } if let surfaceId { - focusSurface(tabId: tabId, surfaceId: surfaceId, shouldFlash: !suppressFlash) + if !suppressFlash { + focusSurface(tabId: tabId, surfaceId: surfaceId) + } else if let tab = tabs.first(where: { $0.id == tabId }) { + tab.focusedSurfaceId = surfaceId + } } } func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) { let wasSelected = selectedTabId == tabId suppressFocusFlash = true - focusTab(tabId, surfaceId: surfaceId) + focusTab(tabId) if wasSelected { suppressFocusFlash = false } @@ -521,13 +547,14 @@ class TabManager: ObservableObject { tab.surface(for: targetSurfaceId) != nil else { return } guard let notificationStore = AppDelegate.shared?.notificationStore else { return } guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetSurfaceId) else { return } - tab.triggerNotificationFocusFlash(surfaceId: targetSurfaceId, requiresSplit: false) + tab.triggerNotificationFocusFlash(surfaceId: targetSurfaceId, requiresSplit: false, shouldFocus: true) + notificationStore.markRead(forTabId: tabId, surfaceId: targetSurfaceId) } } - func focusSurface(tabId: UUID, surfaceId: UUID, shouldFlash: Bool = true) { + func focusSurface(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } - tab.focusSurface(surfaceId, shouldFlash: shouldFlash) + tab.focusSurface(surfaceId) } func selectNextTab() { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 96c878a3..52faecec 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -194,6 +194,17 @@ class TerminalController { case "simulate_app_active": return simulateAppDidBecomeActive() +#if DEBUG + case "focus_notification": + return focusFromNotification(args) + + case "flash_count": + return flashCount(args) + + case "reset_flash_counts": + return resetFlashCounts() +#endif + case "help": return helpText() @@ -203,7 +214,7 @@ class TerminalController { } private func helpText() -> String { - return """ + var text = """ Available commands: ping - Check if server is running list_tabs - List all tabs with IDs @@ -226,6 +237,15 @@ class TerminalController { simulate_app_active - Trigger app active handler help - Show this help """ +#if DEBUG + text += """ + + focus_notification [surface|idx] - Focus via notification flow + flash_count - Read flash count for a surface + reset_flash_counts - Reset flash counters + """ +#endif + return text } private func listTabs() -> String { @@ -420,6 +440,60 @@ class TerminalController { return "OK" } +#if DEBUG + private func focusFromNotification(_ args: String) -> String { + guard let tabManager else { return "ERROR: TabManager not available" } + let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) + let tabArg = parts.first ?? "" + let surfaceArg = parts.count > 1 ? parts[1] : "" + + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else { + result = "ERROR: Tab not found" + return + } + let surfaceId = surfaceArg.isEmpty ? nil : resolveSurfaceId(from: surfaceArg, tab: tab) + if !surfaceArg.isEmpty && surfaceId == nil { + result = "ERROR: Surface not found" + return + } + tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId) + } + return result + } + + private func flashCount(_ args: String) -> String { + guard let tabManager else { return "ERROR: TabManager not available" } + let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" } + + var result = "ERROR: Surface not found" + DispatchQueue.main.sync { + guard let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + result = "ERROR: No tab selected" + return + } + guard let surfaceId = resolveSurfaceId(from: trimmed, tab: tab) else { + result = "ERROR: Surface not found" + return + } + let count = GhosttySurfaceScrollView.flashCount(for: surfaceId) + result = "OK \(count)" + } + return result + } + + private func resetFlashCounts() -> String { + DispatchQueue.main.sync { + GhosttySurfaceScrollView.resetFlashCounts() + } + return "OK" + } +#endif + private func parseSplitDirection(_ value: String) -> SplitTree.NewDirection? { switch value.lowercased() { case "left", "l": diff --git a/tests/cmux.py b/tests/cmux.py index f606aea6..c4d5f39d 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -303,6 +303,29 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def focus_notification(self, tab: str | int, surface: str | int | None = None) -> None: + """Focus tab/surface using the notification flow.""" + if surface is None: + command = f"focus_notification {tab}" + else: + command = f"focus_notification {tab} {surface}" + response = self._send_command(command) + if not response.startswith("OK"): + raise cmuxError(response) + + def flash_count(self, surface: str | int) -> int: + """Get flash count for a surface by ID or index.""" + response = self._send_command(f"flash_count {surface}") + if response.startswith("OK "): + return int(response.split(" ", 1)[1]) + raise cmuxError(response) + + def reset_flash_counts(self) -> None: + """Reset flash counters.""" + response = self._send_command("reset_flash_counts") + if not response.startswith("OK"): + raise cmuxError(response) + def main(): """CLI interface for cmux""" diff --git a/tests/test_notifications.py b/tests/test_notifications.py index f5e8c090..8b23bb19 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -195,6 +195,114 @@ def test_mark_read_on_tab_switch(client: cmux) -> TestResult: return result +def test_no_flash_on_tab_switch(client: cmux) -> TestResult: + result = TestResult("No Flash On Tab Switch") + try: + client.clear_notifications() + client.reset_flash_counts() + + tab1 = client.current_tab() + surfaces = client.list_surfaces() + focused = next((s for s in surfaces if s[2]), None) + if focused is None: + result.failure("Unable to identify focused surface") + return result + + client.set_app_focus(False) + client.notify("tabswitchflash") + time.sleep(0.1) + + client.new_tab() + time.sleep(0.1) + + client.set_app_focus(True) + client.select_tab(tab1) + time.sleep(0.2) + + count = client.flash_count(focused[1]) + if count != 0: + result.failure(f"Expected flash count 0, got {count}") + else: + result.success("No flash triggered on tab switch") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def test_focus_on_notification_click(client: cmux) -> TestResult: + result = TestResult("Focus On Notification Click") + try: + client.clear_notifications() + client.reset_flash_counts() + + surfaces = ensure_two_surfaces(client) + focused = next((s for s in surfaces if s[2]), None) + other = next((s for s in surfaces if not s[2]), None) + if focused is None or other is None: + result.failure("Unable to identify focused and unfocused surfaces") + return result + + client.set_app_focus(False) + client.notify_surface(other[0], "notifyfocus") + time.sleep(0.1) + + client.set_app_focus(True) + tab_id = client.current_tab() + client.focus_notification(tab_id, other[0]) + time.sleep(0.2) + + surfaces = client.list_surfaces() + target = next((s for s in surfaces if s[1] == other[1]), None) + if target is None or not target[2]: + result.failure("Expected notification surface to be focused") + return result + + count = client.flash_count(other[1]) + if count < 1: + result.failure(f"Expected flash count >= 1, got {count}") + else: + result.success("Notification click focuses and flashes panel") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + +def test_restore_focus_on_tab_switch(client: cmux) -> TestResult: + result = TestResult("Restore Focus On Tab Switch") + try: + client.clear_notifications() + client.set_app_focus(True) + + surfaces = ensure_two_surfaces(client) + focused = next((s for s in surfaces if s[2]), None) + other = next((s for s in surfaces if not s[2]), None) + if focused is None or other is None: + result.failure("Unable to identify focused and unfocused surfaces") + return result + + client.focus_surface(other[0]) + time.sleep(0.1) + + tab1 = client.current_tab() + client.new_tab() + time.sleep(0.1) + + client.select_tab(tab1) + time.sleep(0.2) + + surfaces = client.list_surfaces() + target = next((s for s in surfaces if s[1] == other[1]), None) + if target is None: + result.failure("Unable to find previously focused surface") + elif not target[2]: + result.failure("Expected previously focused surface to be focused after tab switch") + else: + result.success("Restored last focused surface after tab switch") + except Exception as e: + result.failure(f"Exception: {e}") + return result + + def run_tests() -> int: results = [] with cmux() as client: @@ -204,6 +312,9 @@ def run_tests() -> int: results.append(test_mark_read_on_focus_change(client)) results.append(test_mark_read_on_app_active(client)) results.append(test_mark_read_on_tab_switch(client)) + results.append(test_no_flash_on_tab_switch(client)) + results.append(test_focus_on_notification_click(client)) + results.append(test_restore_focus_on_tab_switch(client)) client.set_app_focus(None) client.clear_notifications()