mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
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.
This commit is contained in:
parent
9c9f68399e
commit
f39785f920
5 changed files with 128 additions and 14 deletions
|
|
@ -507,7 +507,7 @@ struct ContentView: View {
|
||||||
// When sneak peek finishes, check if user is still hovering and open notch if needed
|
// When sneak peek finishes, check if user is still hovering and open notch if needed
|
||||||
if !sneakPeekShowing {
|
if !sneakPeekShowing {
|
||||||
runAfter(0.2) {
|
runAfter(0.2) {
|
||||||
if isHovering && vm.notchState == .closed {
|
if isHovering && vm.notchState == .closed && !coordinator.isHoverOpenSuppressed {
|
||||||
openNotch()
|
openNotch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1715,6 +1715,7 @@ struct ContentView: View {
|
||||||
guard let vm, let lockScreenManager else { return }
|
guard let vm, let lockScreenManager else { return }
|
||||||
guard !lockScreenManager.isLocked else { return }
|
guard !lockScreenManager.isLocked else { return }
|
||||||
guard vm.notchState == .closed else { return }
|
guard vm.notchState == .closed else { return }
|
||||||
|
guard !self.coordinator.isHoverOpenSuppressed else { return }
|
||||||
guard self.isHovering else { return }
|
guard self.isHovering else { return }
|
||||||
guard !self.handleClosedMusicWaveformTapIfNeeded() else { return }
|
guard !self.handleClosedMusicWaveformTapIfNeeded() else { return }
|
||||||
if Defaults[.enableHaptics] {
|
if Defaults[.enableHaptics] {
|
||||||
|
|
@ -1766,6 +1767,10 @@ struct ContentView: View {
|
||||||
stickyTerminalClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak vm] _ in
|
stickyTerminalClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak vm] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let vm, vm.notchState == .open else { return }
|
guard let vm, vm.notchState == .open else { return }
|
||||||
|
let clickLocation = NSEvent.mouseLocation
|
||||||
|
if self.isPointInsideNotchWindow(clickLocation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
vm.close()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1818,7 +1823,8 @@ struct ContentView: View {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
guard self.vm.notchState == .closed,
|
guard self.vm.notchState == .closed,
|
||||||
self.isHovering,
|
self.isHovering,
|
||||||
!self.isSneakPeekVisibleOnCurrentScreen else { return }
|
!self.isSneakPeekVisibleOnCurrentScreen,
|
||||||
|
!self.coordinator.isHoverOpenSuppressed else { return }
|
||||||
|
|
||||||
if shouldFocusTimerTab {
|
if shouldFocusTimerTab {
|
||||||
withAnimation(.smooth) {
|
withAnimation(.smooth) {
|
||||||
|
|
@ -1843,14 +1849,27 @@ struct ContentView: View {
|
||||||
} else if self.vm.notchState == .open
|
} else if self.vm.notchState == .open
|
||||||
&& Defaults[.terminalStickyMode]
|
&& Defaults[.terminalStickyMode]
|
||||||
&& self.coordinator.currentView == .terminal {
|
&& self.coordinator.currentView == .terminal {
|
||||||
// Terminal sticky mode kept the notch open — install a
|
// Re-sync monitor state through one code path to avoid
|
||||||
// global click monitor so a click outside closes it.
|
// monitor lifecycle races between hover and state updates.
|
||||||
self.installStickyTerminalClickMonitor()
|
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
|
// Helper function to check if any popovers are active
|
||||||
private func hasAnyActivePopovers() -> Bool {
|
private func hasAnyActivePopovers() -> Bool {
|
||||||
|
|
|
||||||
|
|
@ -1046,13 +1046,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
closeNotchWorkItem = nil
|
closeNotchWorkItem = nil
|
||||||
vm.open()
|
vm.open()
|
||||||
coordinator.currentView = .terminal
|
coordinator.currentView = .terminal
|
||||||
|
TerminalManager.shared.refreshTerminalAppearanceIfNeeded()
|
||||||
|
TerminalManager.shared.focusTerminalIfPossible()
|
||||||
|
TerminalManager.shared.refreshTerminalAppearanceIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
if coordinator.currentView == .terminal {
|
if coordinator.currentView == .terminal {
|
||||||
|
coordinator.suppressHoverOpen()
|
||||||
|
TerminalManager.shared.resignTerminalFirstResponderIfNeeded()
|
||||||
vm.close()
|
vm.close()
|
||||||
} else {
|
} else {
|
||||||
closeNotchWorkItem?.cancel()
|
closeNotchWorkItem?.cancel()
|
||||||
closeNotchWorkItem = nil
|
closeNotchWorkItem = nil
|
||||||
coordinator.currentView = .terminal
|
coordinator.currentView = .terminal
|
||||||
|
TerminalManager.shared.refreshTerminalAppearanceIfNeeded()
|
||||||
|
TerminalManager.shared.focusTerminalIfPossible()
|
||||||
|
TerminalManager.shared.refreshTerminalAppearanceIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ struct ExpandedItem {
|
||||||
class DynamicIslandViewCoordinator: ObservableObject {
|
class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
static let shared = DynamicIslandViewCoordinator()
|
static let shared = DynamicIslandViewCoordinator()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var hoverOpenSuppressedUntil: Date = .distantPast
|
||||||
|
|
||||||
private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .terminal, .extensionExperience]
|
private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .terminal, .extensionExperience]
|
||||||
|
|
||||||
|
|
@ -246,6 +247,14 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
enforceMinimumNotchWidth()
|
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) {
|
private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) {
|
||||||
guard oldValue != newValue else { return }
|
guard oldValue != newValue else { return }
|
||||||
if newValue == .stats && Defaults[.enableStatsFeature] {
|
if newValue == .stats && Defaults[.enableStatsFeature] {
|
||||||
|
|
|
||||||
|
|
@ -157,9 +157,14 @@ struct NotchTerminalView: View {
|
||||||
// notch close/open cycles; updateNSView handles restart.
|
// notch close/open cycles; updateNSView handles restart.
|
||||||
TerminalNSViewRepresentable(terminalManager: terminalManager)
|
TerminalNSViewRepresentable(terminalManager: terminalManager)
|
||||||
.clipShape(terminalClipShape)
|
.clipShape(terminalClipShape)
|
||||||
.padding(.horizontal, notchTerminalContentEdgePadding.horizontal)
|
.padding(
|
||||||
.padding(.bottom, notchTerminalContentEdgePadding.bottom)
|
EdgeInsets(
|
||||||
.padding(.top, notchTerminalContentEdgePadding.top)
|
top: notchTerminalContentEdgePadding.top,
|
||||||
|
leading: notchTerminalContentEdgePadding.horizontal,
|
||||||
|
bottom: notchTerminalContentEdgePadding.bottom,
|
||||||
|
trailing: notchTerminalContentEdgePadding.horizontal
|
||||||
|
)
|
||||||
|
)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
updateSuppression(for: hovering)
|
updateSuppression(for: hovering)
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +189,10 @@ struct NotchTerminalView: View {
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
updateSuppression(for: false)
|
updateSuppression(for: false)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
terminalManager.refreshTerminalAppearanceIfNeeded()
|
||||||
|
terminalManager.focusTerminalIfPossible()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateSuppression(for hovering: Bool) {
|
private func updateSuppression(for hovering: Bool) {
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ final class StableTerminalContainerView: NSView {
|
||||||
for child in subviews {
|
for child in subviews {
|
||||||
child.needsDisplay = true
|
child.needsDisplay = true
|
||||||
}
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
TerminalManager.shared.refreshTerminalAppearanceIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,9 +121,8 @@ class TerminalManager: ObservableObject {
|
||||||
if let existing = terminalView, existing.superview === containerView {
|
if let existing = terminalView, existing.superview === containerView {
|
||||||
// Already mounted — just re-wire the delegate in case the coordinator changed.
|
// Already mounted — just re-wire the delegate in case the coordinator changed.
|
||||||
existing.processDelegate = delegate
|
existing.processDelegate = delegate
|
||||||
// Force a redraw — the view may have been off-screen (notch closed)
|
// Re-apply opacity/translucency after hide/show cycles (e.g. shortcut toggle).
|
||||||
// and needs to re-render the terminal buffer.
|
refreshTerminalOpacityAndTranslucency(for: existing)
|
||||||
synchronizeTerminalTranslucencyPresentation(to: existing)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,6 +157,15 @@ class TerminalManager: ObservableObject {
|
||||||
terminalBackgroundEffectView.frame = containerView.bounds
|
terminalBackgroundEffectView.frame = containerView.bounds
|
||||||
view.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.
|
/// Starts the shell process if not already running.
|
||||||
|
|
@ -248,6 +259,12 @@ class TerminalManager: ObservableObject {
|
||||||
view.setNeedsDisplay(view.bounds)
|
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
|
// MARK: - Settings Application
|
||||||
|
|
||||||
/// Applies all persisted settings to a terminal view at creation time.
|
/// Applies all persisted settings to a terminal view at creation time.
|
||||||
|
|
@ -283,7 +300,7 @@ class TerminalManager: ObservableObject {
|
||||||
// Rendering
|
// Rendering
|
||||||
view.useBrightColors = Defaults[.terminalBoldAsBright]
|
view.useBrightColors = Defaults[.terminalBoldAsBright]
|
||||||
|
|
||||||
synchronizeTerminalTranslucencyPresentation(to: view)
|
refreshTerminalOpacityAndTranslucency(for: view)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates font size on the live terminal view.
|
/// Updates font size on the live terminal view.
|
||||||
|
|
@ -305,7 +322,7 @@ class TerminalManager: ObservableObject {
|
||||||
guard let view = terminalView else { return }
|
guard let view = terminalView else { return }
|
||||||
view.layer?.opacity = 1
|
view.layer?.opacity = 1
|
||||||
view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(opacitySlider: CGFloat(opacity))
|
view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(opacitySlider: CGFloat(opacity))
|
||||||
synchronizeTerminalTranslucencyPresentation(to: view)
|
refreshTerminalOpacityAndTranslucency(for: view)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates cursor style on the live terminal view.
|
/// Updates cursor style on the live terminal view.
|
||||||
|
|
@ -345,7 +362,7 @@ class TerminalManager: ObservableObject {
|
||||||
guard let view = terminalView else { return }
|
guard let view = terminalView else { return }
|
||||||
view.layer?.opacity = 1
|
view.layer?.opacity = 1
|
||||||
view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(baseColor: NSColor(color))
|
view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(baseColor: NSColor(color))
|
||||||
synchronizeTerminalTranslucencyPresentation(to: view)
|
refreshTerminalOpacityAndTranslucency(for: view)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates foreground color on the live terminal view.
|
/// Updates foreground color on the live terminal view.
|
||||||
|
|
@ -355,6 +372,58 @@ class TerminalManager: ObservableObject {
|
||||||
synchronizeTerminalTranslucencyPresentation(to: view)
|
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.
|
/// Updates cursor color on the live terminal view.
|
||||||
func applyCursorColor(_ color: SwiftUI.Color) {
|
func applyCursorColor(_ color: SwiftUI.Color) {
|
||||||
guard let view = terminalView else { return }
|
guard let view = terminalView else { return }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue