From f39785f9205afbb35c76fb7a68bb1f8bd562189a Mon Sep 17 00:00:00 2001 From: Soham Sharma Date: Fri, 24 Apr 2026 16:31:45 +0800 Subject: [PATCH] Fix terminal notch lifecycle so open/close, hover, outside-click, focus, and opacity stay consistent. fix: stabilize terminal notch open/close, focus, and opacity Improve terminal tab behavior in notch by preventing hover re-open races, keeping outside-click close logic accurate, and reliably restoring terminal opacity/translucency after remounts. Also ensure terminal receives focus on open and resigns cleanly on close so keyboard input and close/open cycles behave consistently. --- DynamicIsland/ContentView.swift | 29 +++++-- DynamicIsland/DynamicIslandApp.swift | 8 ++ .../DynamicIslandViewCoordinator.swift | 9 +++ .../components/Notch/NotchTerminalView.swift | 15 +++- DynamicIsland/managers/TerminalManager.swift | 81 +++++++++++++++++-- 5 files changed, 128 insertions(+), 14 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 444f3e3..0d413f8 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -507,7 +507,7 @@ struct ContentView: View { // When sneak peek finishes, check if user is still hovering and open notch if needed if !sneakPeekShowing { runAfter(0.2) { - if isHovering && vm.notchState == .closed { + if isHovering && vm.notchState == .closed && !coordinator.isHoverOpenSuppressed { openNotch() } } @@ -1715,6 +1715,7 @@ struct ContentView: View { guard let vm, let lockScreenManager else { return } guard !lockScreenManager.isLocked else { return } guard vm.notchState == .closed else { return } + guard !self.coordinator.isHoverOpenSuppressed else { return } guard self.isHovering else { return } guard !self.handleClosedMusicWaveformTapIfNeeded() else { return } if Defaults[.enableHaptics] { @@ -1766,6 +1767,10 @@ struct ContentView: View { stickyTerminalClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak vm] _ in Task { @MainActor in guard let vm, vm.notchState == .open else { return } + let clickLocation = NSEvent.mouseLocation + if self.isPointInsideNotchWindow(clickLocation) { + return + } vm.close() } } @@ -1818,7 +1823,8 @@ struct ContentView: View { await MainActor.run { guard self.vm.notchState == .closed, self.isHovering, - !self.isSneakPeekVisibleOnCurrentScreen else { return } + !self.isSneakPeekVisibleOnCurrentScreen, + !self.coordinator.isHoverOpenSuppressed else { return } if shouldFocusTimerTab { withAnimation(.smooth) { @@ -1843,14 +1849,27 @@ struct ContentView: View { } else if self.vm.notchState == .open && Defaults[.terminalStickyMode] && self.coordinator.currentView == .terminal { - // Terminal sticky mode kept the notch open — install a - // global click monitor so a click outside closes it. - self.installStickyTerminalClickMonitor() + // Re-sync monitor state through one code path to avoid + // monitor lifecycle races between hover and state updates. + self.syncStickyTerminalOutsideClickMonitor() } } } } } + + private func isPointInsideNotchWindow(_ point: CGPoint) -> Bool { + if let appDelegate = AppDelegate.shared { + if Defaults[.showOnAllDisplays] { + return appDelegate.windows.values.contains(where: { $0.frame.contains(point) }) + } + if let window = appDelegate.window { + return window.frame.contains(point) + } + } + + return NSApp.windows.contains(where: { $0.frame.contains(point) }) + } // Helper function to check if any popovers are active private func hasAnyActivePopovers() -> Bool { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 902d5e6..8650f79 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -1046,13 +1046,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { closeNotchWorkItem = nil vm.open() coordinator.currentView = .terminal + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + TerminalManager.shared.focusTerminalIfPossible() + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() } else { if coordinator.currentView == .terminal { + coordinator.suppressHoverOpen() + TerminalManager.shared.resignTerminalFirstResponderIfNeeded() vm.close() } else { closeNotchWorkItem?.cancel() closeNotchWorkItem = nil coordinator.currentView = .terminal + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + TerminalManager.shared.focusTerminalIfPossible() + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() } } } diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index c6ed432..be70f58 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -102,6 +102,7 @@ struct ExpandedItem { class DynamicIslandViewCoordinator: ObservableObject { static let shared = DynamicIslandViewCoordinator() private var cancellables = Set() + private var hoverOpenSuppressedUntil: Date = .distantPast private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .terminal, .extensionExperience] @@ -246,6 +247,14 @@ class DynamicIslandViewCoordinator: ObservableObject { enforceMinimumNotchWidth() } + var isHoverOpenSuppressed: Bool { + Date() < hoverOpenSuppressedUntil + } + + func suppressHoverOpen(for duration: TimeInterval = 0.35) { + hoverOpenSuppressedUntil = Date().addingTimeInterval(max(0, duration)) + } + private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { guard oldValue != newValue else { return } if newValue == .stats && Defaults[.enableStatsFeature] { diff --git a/DynamicIsland/components/Notch/NotchTerminalView.swift b/DynamicIsland/components/Notch/NotchTerminalView.swift index 4fa99f9..2db4e1f 100644 --- a/DynamicIsland/components/Notch/NotchTerminalView.swift +++ b/DynamicIsland/components/Notch/NotchTerminalView.swift @@ -157,9 +157,14 @@ struct NotchTerminalView: View { // notch close/open cycles; updateNSView handles restart. TerminalNSViewRepresentable(terminalManager: terminalManager) .clipShape(terminalClipShape) - .padding(.horizontal, notchTerminalContentEdgePadding.horizontal) - .padding(.bottom, notchTerminalContentEdgePadding.bottom) - .padding(.top, notchTerminalContentEdgePadding.top) + .padding( + EdgeInsets( + top: notchTerminalContentEdgePadding.top, + leading: notchTerminalContentEdgePadding.horizontal, + bottom: notchTerminalContentEdgePadding.bottom, + trailing: notchTerminalContentEdgePadding.horizontal + ) + ) .onHover { hovering in updateSuppression(for: hovering) } @@ -184,6 +189,10 @@ struct NotchTerminalView: View { .onDisappear { updateSuppression(for: false) } + .onAppear { + terminalManager.refreshTerminalAppearanceIfNeeded() + terminalManager.focusTerminalIfPossible() + } } private func updateSuppression(for hovering: Bool) { diff --git a/DynamicIsland/managers/TerminalManager.swift b/DynamicIsland/managers/TerminalManager.swift index 1b047d7..3275f0e 100644 --- a/DynamicIsland/managers/TerminalManager.swift +++ b/DynamicIsland/managers/TerminalManager.swift @@ -60,6 +60,9 @@ final class StableTerminalContainerView: NSView { for child in subviews { child.needsDisplay = true } + Task { @MainActor in + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + } } } @@ -118,9 +121,8 @@ class TerminalManager: ObservableObject { if let existing = terminalView, existing.superview === containerView { // Already mounted — just re-wire the delegate in case the coordinator changed. existing.processDelegate = delegate - // Force a redraw — the view may have been off-screen (notch closed) - // and needs to re-render the terminal buffer. - synchronizeTerminalTranslucencyPresentation(to: existing) + // Re-apply opacity/translucency after hide/show cycles (e.g. shortcut toggle). + refreshTerminalOpacityAndTranslucency(for: existing) return } @@ -155,6 +157,15 @@ class TerminalManager: ObservableObject { terminalBackgroundEffectView.frame = containerView.bounds view.frame = containerView.bounds } + + // Apply translucency immediately and again on the next run loop tick. + // The deferred pass handles AppKit/SwiftUI mount timing where layer + // changes can otherwise be lost on first open. + refreshTerminalOpacityAndTranslucency(for: view) + DispatchQueue.main.async { [weak self, weak view] in + guard let self, let view, self.terminalView === view else { return } + self.refreshTerminalOpacityAndTranslucency(for: view) + } } /// Starts the shell process if not already running. @@ -248,6 +259,12 @@ class TerminalManager: ObservableObject { view.setNeedsDisplay(view.bounds) } + /// Re-applies backdrop opacity + translucency hacks after remount/reopen paths. + private func refreshTerminalOpacityAndTranslucency(for view: LocalProcessTerminalView) { + applyTerminalBackgroundAppearance(to: view) + synchronizeTerminalTranslucencyPresentation(to: view) + } + // MARK: - Settings Application /// Applies all persisted settings to a terminal view at creation time. @@ -283,7 +300,7 @@ class TerminalManager: ObservableObject { // Rendering view.useBrightColors = Defaults[.terminalBoldAsBright] - synchronizeTerminalTranslucencyPresentation(to: view) + refreshTerminalOpacityAndTranslucency(for: view) } /// Updates font size on the live terminal view. @@ -305,7 +322,7 @@ class TerminalManager: ObservableObject { guard let view = terminalView else { return } view.layer?.opacity = 1 view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(opacitySlider: CGFloat(opacity)) - synchronizeTerminalTranslucencyPresentation(to: view) + refreshTerminalOpacityAndTranslucency(for: view) } /// Updates cursor style on the live terminal view. @@ -345,7 +362,7 @@ class TerminalManager: ObservableObject { guard let view = terminalView else { return } view.layer?.opacity = 1 view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(baseColor: NSColor(color)) - synchronizeTerminalTranslucencyPresentation(to: view) + refreshTerminalOpacityAndTranslucency(for: view) } /// Updates foreground color on the live terminal view. @@ -355,6 +372,58 @@ class TerminalManager: ObservableObject { synchronizeTerminalTranslucencyPresentation(to: view) } + /// Called by UI lifecycle hooks after tab re-appearance to keep opacity stable. + func refreshTerminalAppearanceIfNeeded() { + guard let view = terminalView else { return } + refreshTerminalOpacityAndTranslucency(for: view) + } + + /// Makes terminal view first responder when terminal tab opens. + /// Retries a few times to cover mount/animation timing so typing works immediately. + func focusTerminalIfPossible() { + focusTerminalIfPossible(attemptsRemaining: 3) + } + + private func focusTerminalIfPossible(attemptsRemaining: Int) { + guard let view = terminalView else { return } + guard let window = containerView.window ?? view.window else { + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.focusTerminalIfPossible(attemptsRemaining: attemptsRemaining - 1) + } + return + } + + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + if !window.isKeyWindow { + window.makeKeyAndOrderFront(nil) + } + + guard window.firstResponder !== view else { return } + if !window.makeFirstResponder(view), attemptsRemaining > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.focusTerminalIfPossible(attemptsRemaining: attemptsRemaining - 1) + } + } + } + + /// Resigns terminal focus before closing notch to avoid stale responder state. + func resignTerminalFirstResponderIfNeeded() { + guard let view = terminalView else { return } + guard let window = containerView.window ?? view.window else { return } + guard window.firstResponder === view else { return } + _ = window.makeFirstResponder(nil) + } + + /// True when the terminal view is currently first responder. + func isTerminalFirstResponder() -> Bool { + guard let view = terminalView else { return false } + guard let window = containerView.window ?? view.window else { return false } + return window.firstResponder === view + } + /// Updates cursor color on the live terminal view. func applyCursorColor(_ color: SwiftUI.Color) { guard let view = terminalView else { return }