From 9c9f68399eb58cf29615d8aee29fec59645ec604 Mon Sep 17 00:00:00 2001 From: Soham Sharma Date: Fri, 24 Apr 2026 09:14:41 +0800 Subject: [PATCH] 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,