Terminal opacity fix

This commit is contained in:
Soham Sharma 2026-04-24 09:14:41 +08:00
commit 9c9f68399e
6 changed files with 161 additions and 21 deletions

View file

@ -105,8 +105,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser", "location" : "https://github.com/apple/swift-argument-parser",
"state" : { "state" : {
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b",
"version" : "1.7.0" "version" : "1.7.1"
} }
}, },
{ {
@ -132,8 +132,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/migueldeicaza/SwiftTerm", "location" : "https://github.com/migueldeicaza/SwiftTerm",
"state" : { "state" : {
"revision" : "b1262db5b6bea699a8260a8c66999436c508ca56", "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43",
"version" : "1.11.2" "version" : "1.13.0"
} }
}, },
{ {

View file

@ -5619,6 +5619,9 @@
} }
} }
} }
},
"Amazon Music" : {
}, },
"Animation" : { "Animation" : {
"extractionState" : "stale", "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" : { "Text" : {
"comment" : "Name of a clipboard item type that contains plain 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." : { "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 블러 효과가 적용됩니다.'", "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 블러 효과가 적용됩니다.'",

View file

@ -86,9 +86,39 @@ struct NotchTerminalView: View {
@ObservedObject var terminalManager = TerminalManager.shared @ObservedObject var terminalManager = TerminalManager.shared
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@Default(.enableTerminalFeature) var enableTerminalFeature @Default(.enableTerminalFeature) var enableTerminalFeature
@Default(.cornerRadiusScaling) var cornerRadiusScaling
@Default(.enableMinimalisticUI) var enableMinimalisticUI
@State private var suppressionToken = UUID() @State private var suppressionToken = UUID()
@State private var isSuppressing = false @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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if enableTerminalFeature { if enableTerminalFeature {
@ -121,15 +151,15 @@ struct NotchTerminalView: View {
.padding(.bottom, 4) .padding(.bottom, 4)
Divider() Divider()
.padding(.horizontal, 8) .padding(.horizontal, notchTerminalContentEdgePadding.horizontal)
// Terminal content — the containerView is stable across // Terminal content — the containerView is stable across
// notch close/open cycles; updateNSView handles restart. // notch close/open cycles; updateNSView handles restart.
TerminalNSViewRepresentable(terminalManager: terminalManager) TerminalNSViewRepresentable(terminalManager: terminalManager)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) .clipShape(terminalClipShape)
.padding(.horizontal, 8) .padding(.horizontal, notchTerminalContentEdgePadding.horizontal)
.padding(.bottom, 8) .padding(.bottom, notchTerminalContentEdgePadding.bottom)
.padding(.top, 4) .padding(.top, notchTerminalContentEdgePadding.top)
.onHover { hovering in .onHover { hovering in
updateSuppression(for: hovering) updateSuppression(for: hovering)
} }

View file

@ -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: "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: "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: "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: "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: "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")), 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")) .settingsHighlight(id: highlightID("Maximum height"))
} header: { } header: {
Text("Appearance") 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 // MARK: Colors

View file

@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import AppKit
import Foundation import Foundation
import SwiftUI import SwiftUI
import SwiftTerm import SwiftTerm
@ -89,12 +90,24 @@ class TerminalManager: ObservableObject {
let v = StableTerminalContainerView(frame: .zero) let v = StableTerminalContainerView(frame: .zero)
v.autoresizingMask = [.width, .height] v.autoresizingMask = [.width, .height]
v.wantsLayer = true v.wantsLayer = true
v.layer?.backgroundColor = NSColor.clear.cgColor
return v return v
}() }()
/// The actual terminal view (child of `containerView`). /// The actual terminal view (child of `containerView`).
private(set) var terminalView: LocalProcessTerminalView? 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() {} private init() {}
// MARK: - Lifecycle // MARK: - Lifecycle
@ -107,7 +120,7 @@ class TerminalManager: ObservableObject {
existing.processDelegate = delegate existing.processDelegate = delegate
// Force a redraw — the view may have been off-screen (notch closed) // Force a redraw — the view may have been off-screen (notch closed)
// and needs to re-render the terminal buffer. // and needs to re-render the terminal buffer.
existing.needsDisplay = true synchronizeTerminalTranslucencyPresentation(to: existing)
return return
} }
@ -119,6 +132,9 @@ class TerminalManager: ObservableObject {
? containerView.bounds ? containerView.bounds
: CGRect(x: 0, y: 0, width: 400, height: 300) : 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) let view = LocalProcessTerminalView(frame: initialFrame)
view.autoresizingMask = [.width, .height] view.autoresizingMask = [.width, .height]
@ -127,14 +143,16 @@ class TerminalManager: ObservableObject {
view.processDelegate = delegate view.processDelegate = delegate
// Mount inside the stable container if terminalBackgroundEffectView.superview == nil {
containerView.subviews.forEach { $0.removeFromSuperview() } containerView.addSubview(terminalBackgroundEffectView)
}
containerView.addSubview(view) containerView.addSubview(view)
terminalView = view terminalView = view
// If the container already has a valid size, snap the child to it. // If the container already has a valid size, snap the child to it.
let containerSize = containerView.bounds.size let containerSize = containerView.bounds.size
if containerSize.width >= 10, containerSize.height >= 10 { if containerSize.width >= 10, containerSize.height >= 10 {
terminalBackgroundEffectView.frame = containerView.bounds
view.frame = containerView.bounds view.frame = containerView.bounds
} }
} }
@ -196,6 +214,40 @@ class TerminalManager: ObservableObject {
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) 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 // 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.
@ -205,11 +257,8 @@ class TerminalManager: ObservableObject {
let fontFamily = Defaults[.terminalFontFamily] let fontFamily = Defaults[.terminalFontFamily]
view.font = resolveFont(family: fontFamily, size: fontSize) view.font = resolveFont(family: fontFamily, size: fontSize)
// Opacity // Colors (background opacity is separate from terminal opacity slider — see `applyTerminalBackgroundAppearance`)
view.layer?.opacity = Float(Defaults[.terminalOpacity]) applyTerminalBackgroundAppearance(to: view)
// Colors
view.nativeBackgroundColor = NSColor(Defaults[.terminalBackgroundColor])
view.nativeForegroundColor = NSColor(Defaults[.terminalForegroundColor]) view.nativeForegroundColor = NSColor(Defaults[.terminalForegroundColor])
view.caretColor = NSColor(Defaults[.terminalCursorColor]) view.caretColor = NSColor(Defaults[.terminalCursorColor])
@ -219,6 +268,7 @@ class TerminalManager: ObservableObject {
view.getTerminal().setCursorStyle(cursorStyle.swiftTermStyle) view.getTerminal().setCursorStyle(cursorStyle.swiftTermStyle)
// Keep drawing the selected cursor style when TerminalView.hasFocus is false; SwiftTerm // 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 view.caretViewTracksFocus = false
// Scrollback // Scrollback
@ -232,6 +282,8 @@ class TerminalManager: ObservableObject {
// Rendering // Rendering
view.useBrightColors = Defaults[.terminalBoldAsBright] view.useBrightColors = Defaults[.terminalBoldAsBright]
synchronizeTerminalTranslucencyPresentation(to: view)
} }
/// Updates font size on the live terminal view. /// Updates font size on the live terminal view.
@ -248,10 +300,12 @@ class TerminalManager: ObservableObject {
view.font = resolveFont(family: family, size: fontSize) 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) { func applyOpacity(_ opacity: Double) {
guard let view = terminalView else { return } 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. /// Updates cursor style on the live terminal view.
@ -289,13 +343,16 @@ class TerminalManager: ObservableObject {
/// Updates background color on the live terminal view. /// Updates background color on the live terminal view.
func applyBackgroundColor(_ color: SwiftUI.Color) { func applyBackgroundColor(_ color: SwiftUI.Color) {
guard let view = terminalView else { return } 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. /// Updates foreground color on the live terminal view.
func applyForegroundColor(_ color: SwiftUI.Color) { func applyForegroundColor(_ color: SwiftUI.Color) {
guard let view = terminalView else { return } guard let view = terminalView else { return }
view.nativeForegroundColor = NSColor(color) view.nativeForegroundColor = NSColor(color)
synchronizeTerminalTranslucencyPresentation(to: view)
} }
/// Updates cursor color on the live terminal view. /// Updates cursor color on the live terminal view.

View file

@ -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 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) 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( func statsAdjustedNotchSize(
from baseSize: CGSize, from baseSize: CGSize,
isStatsTabActive: Bool, isStatsTabActive: Bool,