From 9c9f68399eb58cf29615d8aee29fec59645ec604 Mon Sep 17 00:00:00 2001 From: Soham Sharma Date: Fri, 24 Apr 2026 09:14:41 +0800 Subject: [PATCH 1/2] Terminal opacity fix --- .../xcshareddata/swiftpm/Package.resolved | 8 +- DynamicIsland/Localizable.xcstrings | 9 +++ .../components/Notch/NotchTerminalView.swift | 40 ++++++++-- .../components/Settings/SettingsView.swift | 6 +- DynamicIsland/managers/TerminalManager.swift | 79 ++++++++++++++++--- DynamicIsland/sizing/matters.swift | 40 ++++++++++ 6 files changed, 161 insertions(+), 21 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fed7c00..d6b56aa 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/migueldeicaza/SwiftTerm", "state" : { - "revision" : "b1262db5b6bea699a8260a8c66999436c508ca56", - "version" : "1.11.2" + "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", + "version" : "1.13.0" } }, { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 1687df1..2d2e3bb 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -5619,6 +5619,9 @@ } } } + }, + "Amazon Music" : { + }, "Animation" : { "extractionState" : "stale", @@ -41010,6 +41013,9 @@ } } } + }, + "Terminal opacity only affects the terminal backdrop; text stays fully opaque. Blur uses the system material behind the window." : { + }, "Text" : { "comment" : "Name of a clipboard item type that contains plain text.", @@ -43754,6 +43760,9 @@ } } } + }, + "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek." : { + }, "Uses the frosted blur treatment while glass mode is enabled." : { "comment" : "A description of the frosted blur treatment when glass mode is enabled. | A description of the frosted blur treatment when glass mode is enabled. | merge:ko:alt='Glass 모드가 활성화되어 있는 동안에는 Frosted 블러 효과가 적용됩니다.'", diff --git a/DynamicIsland/components/Notch/NotchTerminalView.swift b/DynamicIsland/components/Notch/NotchTerminalView.swift index a3f4336..4fa99f9 100644 --- a/DynamicIsland/components/Notch/NotchTerminalView.swift +++ b/DynamicIsland/components/Notch/NotchTerminalView.swift @@ -86,9 +86,39 @@ struct NotchTerminalView: View { @ObservedObject var terminalManager = TerminalManager.shared @EnvironmentObject var vm: DynamicIslandViewModel @Default(.enableTerminalFeature) var enableTerminalFeature + @Default(.cornerRadiusScaling) var cornerRadiusScaling + @Default(.enableMinimalisticUI) var enableMinimalisticUI @State private var suppressionToken = UUID() @State private var isSuppressing = false + /// Top corners stay mild; bottom follows shell − padding (see `notchTerminalBottomCornerRadii`). + private static let terminalClipTopCornerRadius: CGFloat = 6 + + private var currentScreenName: String { + vm.screen ?? DynamicIslandViewCoordinator.shared.selectedScreen + } + + private var isDynamicIslandMode: Bool { + shouldUseDynamicIslandMode(for: currentScreenName) + } + + private var terminalClipShape: UnevenRoundedRectangle { + let innerBottom = notchTerminalBottomCornerRadii( + isDynamicIslandMode: isDynamicIslandMode, + notchState: vm.notchState, + cornerRadiusScaling: cornerRadiusScaling, + enableMinimalisticUI: enableMinimalisticUI, + closedNotchHeight: vm.closedNotchSize.height + ).innerBottom + return UnevenRoundedRectangle( + topLeadingRadius: Self.terminalClipTopCornerRadius, + bottomLeadingRadius: innerBottom, + bottomTrailingRadius: innerBottom, + topTrailingRadius: Self.terminalClipTopCornerRadius, + style: .continuous + ) + } + var body: some View { VStack(spacing: 0) { if enableTerminalFeature { @@ -121,15 +151,15 @@ struct NotchTerminalView: View { .padding(.bottom, 4) Divider() - .padding(.horizontal, 8) + .padding(.horizontal, notchTerminalContentEdgePadding.horizontal) // Terminal content — the containerView is stable across // notch close/open cycles; updateNSView handles restart. TerminalNSViewRepresentable(terminalManager: terminalManager) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - .padding(.horizontal, 8) - .padding(.bottom, 8) - .padding(.top, 4) + .clipShape(terminalClipShape) + .padding(.horizontal, notchTerminalContentEdgePadding.horizontal) + .padding(.bottom, notchTerminalContentEdgePadding.bottom) + .padding(.top, notchTerminalContentEdgePadding.top) .onHover { hovering in updateSuppression(for: hovering) } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 73c2559..6cb4300 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -897,7 +897,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .terminal, title: "Enable terminal", keywords: ["terminal", "guake", "shell"], highlightID: SettingsTab.terminal.highlightID(for: "Enable terminal")), SettingsSearchEntry(tab: .terminal, title: "Shell path", keywords: ["shell", "zsh", "bash", "terminal"], highlightID: SettingsTab.terminal.highlightID(for: "Shell path")), SettingsSearchEntry(tab: .terminal, title: "Font size", keywords: ["terminal", "font", "text size"], highlightID: SettingsTab.terminal.highlightID(for: "Font size")), - SettingsSearchEntry(tab: .terminal, title: "Terminal opacity", keywords: ["terminal", "opacity", "transparency"], highlightID: SettingsTab.terminal.highlightID(for: "Terminal opacity")), + SettingsSearchEntry(tab: .terminal, title: "Terminal opacity", keywords: ["terminal", "opacity", "transparency", "blur", "background"], highlightID: SettingsTab.terminal.highlightID(for: "Terminal opacity")), SettingsSearchEntry(tab: .terminal, title: "Maximum height", keywords: ["terminal", "height", "size"], highlightID: SettingsTab.terminal.highlightID(for: "Maximum height")), SettingsSearchEntry(tab: .terminal, title: "Background color", keywords: ["terminal", "background", "color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Background color")), SettingsSearchEntry(tab: .terminal, title: "Foreground color", keywords: ["terminal", "foreground", "text color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Foreground color")), @@ -7862,6 +7862,10 @@ struct TerminalSettings: View { .settingsHighlight(id: highlightID("Maximum height")) } header: { Text("Appearance") + } footer: { + Text("Terminal opacity only affects the terminal backdrop; text stays fully opaque. Blur uses the system material behind the window.") + .font(.caption) + .foregroundStyle(.secondary) } // MARK: Colors diff --git a/DynamicIsland/managers/TerminalManager.swift b/DynamicIsland/managers/TerminalManager.swift index 4ec7224..1b047d7 100644 --- a/DynamicIsland/managers/TerminalManager.swift +++ b/DynamicIsland/managers/TerminalManager.swift @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +import AppKit import Foundation import SwiftUI import SwiftTerm @@ -89,12 +90,24 @@ class TerminalManager: ObservableObject { let v = StableTerminalContainerView(frame: .zero) v.autoresizingMask = [.width, .height] v.wantsLayer = true + v.layer?.backgroundColor = NSColor.clear.cgColor return v }() /// The actual terminal view (child of `containerView`). private(set) var terminalView: LocalProcessTerminalView? + /// Frosted blur of desktop/content behind the terminal; always below `terminalView`. + private lazy var terminalBackgroundEffectView: NSVisualEffectView = { + let v = NSVisualEffectView() + // Minimal frosted look with very light blur/vibrancy. + v.material = .underWindowBackground + v.blendingMode = .behindWindow + v.state = .active + v.autoresizingMask = [.width, .height] + return v + }() + private init() {} // MARK: - Lifecycle @@ -107,7 +120,7 @@ class TerminalManager: ObservableObject { existing.processDelegate = delegate // Force a redraw — the view may have been off-screen (notch closed) // and needs to re-render the terminal buffer. - existing.needsDisplay = true + synchronizeTerminalTranslucencyPresentation(to: existing) return } @@ -119,6 +132,9 @@ class TerminalManager: ObservableObject { ? containerView.bounds : CGRect(x: 0, y: 0, width: 400, height: 300) + // Replace only the terminal view - keep the blur underlay between restarts. + terminalView?.removeFromSuperview() + let view = LocalProcessTerminalView(frame: initialFrame) view.autoresizingMask = [.width, .height] @@ -127,14 +143,16 @@ class TerminalManager: ObservableObject { view.processDelegate = delegate - // Mount inside the stable container - containerView.subviews.forEach { $0.removeFromSuperview() } + if terminalBackgroundEffectView.superview == nil { + containerView.addSubview(terminalBackgroundEffectView) + } containerView.addSubview(view) terminalView = view // If the container already has a valid size, snap the child to it. let containerSize = containerView.bounds.size if containerSize.width >= 10, containerSize.height >= 10 { + terminalBackgroundEffectView.frame = containerView.bounds view.frame = containerView.bounds } } @@ -196,6 +214,40 @@ class TerminalManager: ObservableObject { return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } + /// Background color with opacity slider applied to alpha only (glyphs stay fully opaque). + private func resolvedTerminalBackgroundNSColor( + baseColor: NSColor? = nil, + opacitySlider: CGFloat? = nil + ) -> NSColor { + let raw = baseColor ?? NSColor(Defaults[.terminalBackgroundColor]) + let rgb = raw.usingColorSpace(.deviceRGB) ?? raw.usingColorSpace(.sRGB) ?? raw + let opacity = opacitySlider ?? CGFloat(Defaults[.terminalOpacity]) + let alpha = CGFloat(rgb.cgColor.alpha) * opacity + return rgb.withAlphaComponent(alpha) + } + + /// Composites the terminal buffer over `terminalBackgroundEffectView` without dimming glyphs. + /// SwiftTerm’s `TerminalView.isOpaque` is read-only; translucency relies on alpha in `nativeBackgroundColor`. + private func applyTerminalBackgroundAppearance(to view: LocalProcessTerminalView) { + view.layer?.opacity = 1 + view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor() + } + + /// Upstream `TerminalView.setupOptions()` assigns `layer.backgroundColor` from `nativeBackgroundColor`, which + /// prevents the `NSVisualEffectView` under the terminal from showing through. Re-clear after our color updates. + /// Default-cell alpha and attribute-cache invalidation on native color changes may still need a SwiftTerm PR + private func applyTerminalLayerTranslucencyHacks(to view: LocalProcessTerminalView) { + view.layer?.backgroundColor = NSColor.clear.cgColor + view.layer?.isOpaque = false + } + + /// Best-effort redraw. `TerminalView.installColors(_:)` could clear SwiftTerm color caches but needs 16 + /// `SwiftTerm.Color` values; `Terminal.ansiColors` is not public, so we do not duplicate a palette here. + private func synchronizeTerminalTranslucencyPresentation(to view: LocalProcessTerminalView) { + applyTerminalLayerTranslucencyHacks(to: view) + view.setNeedsDisplay(view.bounds) + } + // MARK: - Settings Application /// Applies all persisted settings to a terminal view at creation time. @@ -205,11 +257,8 @@ class TerminalManager: ObservableObject { let fontFamily = Defaults[.terminalFontFamily] view.font = resolveFont(family: fontFamily, size: fontSize) - // Opacity - view.layer?.opacity = Float(Defaults[.terminalOpacity]) - - // Colors - view.nativeBackgroundColor = NSColor(Defaults[.terminalBackgroundColor]) + // Colors (background opacity is separate from terminal opacity slider — see `applyTerminalBackgroundAppearance`) + applyTerminalBackgroundAppearance(to: view) view.nativeForegroundColor = NSColor(Defaults[.terminalForegroundColor]) view.caretColor = NSColor(Defaults[.terminalCursorColor]) @@ -219,6 +268,7 @@ class TerminalManager: ObservableObject { view.getTerminal().setCursorStyle(cursorStyle.swiftTermStyle) // Keep drawing the selected cursor style when TerminalView.hasFocus is false; SwiftTerm + // otherwise uses an unfocused/outline caret while tracksFocus is true. view.caretViewTracksFocus = false // Scrollback @@ -232,6 +282,8 @@ class TerminalManager: ObservableObject { // Rendering view.useBrightColors = Defaults[.terminalBoldAsBright] + + synchronizeTerminalTranslucencyPresentation(to: view) } /// Updates font size on the live terminal view. @@ -248,10 +300,12 @@ class TerminalManager: ObservableObject { view.font = resolveFont(family: family, size: fontSize) } - /// Updates opacity on the live terminal view. + /// Updates background opacity on the live terminal view (glyphs stay fully opaque). func applyOpacity(_ opacity: Double) { guard let view = terminalView else { return } - view.layer?.opacity = Float(opacity) + view.layer?.opacity = 1 + view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(opacitySlider: CGFloat(opacity)) + synchronizeTerminalTranslucencyPresentation(to: view) } /// Updates cursor style on the live terminal view. @@ -289,13 +343,16 @@ class TerminalManager: ObservableObject { /// Updates background color on the live terminal view. func applyBackgroundColor(_ color: SwiftUI.Color) { guard let view = terminalView else { return } - view.nativeBackgroundColor = NSColor(color) + view.layer?.opacity = 1 + view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(baseColor: NSColor(color)) + synchronizeTerminalTranslucencyPresentation(to: view) } /// Updates foreground color on the live terminal view. func applyForegroundColor(_ color: SwiftUI.Color) { guard let view = terminalView else { return } view.nativeForegroundColor = NSColor(color) + synchronizeTerminalTranslucencyPresentation(to: view) } /// Updates cursor color on the live terminal view. diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index b9418b2..5017138 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -154,6 +154,46 @@ var minimalisticOpenNotchSize: CGSize { let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) let minimalisticCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed) +// MARK: - Terminal tab clip (notch surface) + +/// Padding on the terminal block inside the notch. Inner corner radius = outer shell radius on that edge, minus the matching edge padding. +let notchTerminalContentEdgePadding: (top: CGFloat, horizontal: CGFloat, bottom: CGFloat) = (4, 8, 8) + +/// Bottom radii for the shell (outer) and the terminal ``clipShape`` (inner), per design: inner = outer shell bottom radius − `notchTerminalContentEdgePadding.bottom`. +func notchTerminalBottomCornerRadii( + isDynamicIslandMode: Bool, + notchState: NotchState, + cornerRadiusScaling: Bool, + enableMinimalisticUI: Bool, + closedNotchHeight: CGFloat +) -> (outerBottom: CGFloat, innerBottom: CGFloat) { + let p = notchTerminalContentEdgePadding.bottom + if isDynamicIslandMode { + let outer: CGFloat + if notchState == .open { + outer = enableMinimalisticUI + ? minimalisticCornerRadiusInsets.opened.top + : dynamicIslandPillCornerRadiusInsets.opened + } else { + outer = max(closedNotchHeight / 2, dynamicIslandPillCornerRadiusInsets.closed.standard) + } + return (outer, max(0, outer - p)) + } + let active: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = { + if enableMinimalisticUI { + return (opened: minimalisticCornerRadiusInsets.opened, closed: cornerRadiusInsets.closed) + } + return cornerRadiusInsets + }() + let outerBottom: CGFloat + if notchState == .open && cornerRadiusScaling { + outerBottom = active.opened.bottom + } else { + outerBottom = active.closed.bottom + } + return (outerBottom, max(0, outerBottom - p)) +} + func statsAdjustedNotchSize( from baseSize: CGSize, isStatsTabActive: Bool, From f39785f9205afbb35c76fb7a68bb1f8bd562189a Mon Sep 17 00:00:00 2001 From: Soham Sharma Date: Fri, 24 Apr 2026 16:31:45 +0800 Subject: [PATCH 2/2] 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 }