Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Macterm/Views/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,16 @@ private struct WindowStyler: NSViewRepresentable {
}

func windowDidBecomeKey(_ notification: Notification) {
if let window = notification.object as? NSWindow {
WindowAppearance.syncKeyStatus(window: window)
}
swiftuiDelegate?.windowDidBecomeKey?(notification)
}

func windowDidResignKey(_ notification: Notification) {
if let window = notification.object as? NSWindow {
WindowAppearance.syncKeyStatus(window: window)
}
swiftuiDelegate?.windowDidResignKey?(notification)
}

Expand Down
103 changes: 98 additions & 5 deletions Macterm/Views/WindowAppearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@ extension NSView {
}
}

// MARK: - Color helpers (for the inactive-glass tint)

extension NSColor {
/// Perceptual luminance in 0...1, computed in sRGB. Returns 0 for colors
/// that can't be converted to an RGB space (e.g. pattern colors).
var luminance: CGFloat {
guard let rgb = usingColorSpace(.sRGB) else { return 0 }
return 0.2126 * rgb.redComponent + 0.7152 * rgb.greenComponent + 0.0722 * rgb.blueComponent
}

var isLightColor: Bool { luminance > 0.5 }

/// Returns a copy with its HSB saturation multiplied by `factor` (clamped
/// to 0...1). Used to make the inactive-window overlay read as a desaturated
/// version of the terminal background, matching Ghostty.
func adjustingSaturation(by factor: CGFloat) -> NSColor {
guard let hsb = usingColorSpace(.sRGB) else { return self }
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
hsb.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
return NSColor(hue: h, saturation: min(max(s * factor, 0), 1), brightness: b, alpha: a)
}
}

// MARK: - Private CGS blur SPI

/// `CGSSetWindowBackgroundBlurRadius` is a private CoreGraphics API that
Expand Down Expand Up @@ -49,18 +72,43 @@ func setWindowBackgroundBlur(_ window: NSWindow, radius: Int) {
// MARK: - Liquid glass background

/// A container that hosts a macOS 26 `NSGlassEffectView` (the real liquid
/// glass material). Mirrors Ghostty's `TerminalGlassView`
/// (`TerminalViewContainer.swift`), minus its inactive-window tint overlay —
/// Macterm keeps the window's appearance identical whether or not it's key.
/// glass material) plus an inactive-window tint overlay. Modeled on Ghostty's
/// `TerminalGlassView` (`TerminalViewContainer.swift`), with one deliberate
/// divergence noted below.
///
/// `NSGlassEffectView` desaturates itself when its window is not key — native
/// liquid-glass behavior with no API to opt out of (the macOS 26 SDK header
/// exposes only `tintColor`/`style`/`cornerRadius`). The overlay fades a
/// saturation-boosted tint of the background *in* as the window resigns key
/// and back *out* when it regains key. This isn't decoration: with no overlay
/// the raw system dimming reads as the window becoming markedly more
/// translucent on unfocus; the fade-in tint tames that. Focused, the tint is
/// at alpha 0, so the focused glass matches the user's chosen opacity exactly
/// (a *constant* tint would over-darken the focused window past that opacity).
///
/// Divergence from Ghostty: the unfocused tint alpha is scaled by the window
/// opacity (`tint.opacity * backgroundOpacity`), not the raw `tint.opacity`
/// Ghostty uses. Macterm exposes a full-range opacity slider, and an unscaled
/// tint would jump a very-translucent window to a near-opaque unfocused state,
/// ignoring the slider. Scaling keeps the inactive appearance proportional to
/// the setting.
///
/// Macterm inserts this below the window's content view, filling the whole
/// window — including the region under the titlebar (via a negative top inset
/// equal to the content view's top safe-area inset) — so the glass reads as
/// one continuous surface behind the sidebar, titlebar, and terminal.
@available(macOS 26.0, *)
final class MactermGlassView: NSView {
private let glassEffectView = NSGlassEffectView()
private let tintOverlay = NSView()
private var topConstraint: NSLayoutConstraint!

/// The window opacity the glass is currently configured for. The inactive
/// tint is scaled by this so the unfocused window honors the user's opacity
/// slider instead of jumping to a fixed tint — a deliberate divergence from
/// Ghostty, which uses the raw tint opacity regardless of the setting.
private var backgroundOpacity: CGFloat = 1

init(topOffset: CGFloat) {
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
Expand All @@ -74,6 +122,19 @@ final class MactermGlassView: NSView {
glassEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
glassEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
])

// The inactive tint sits above the glass and fades in when the window
// resigns key, masking the system's inactive-glass desaturation.
tintOverlay.translatesAutoresizingMaskIntoConstraints = false
tintOverlay.wantsLayer = true
tintOverlay.alphaValue = 0
addSubview(tintOverlay, positioned: .above, relativeTo: glassEffectView)
NSLayoutConstraint.activate([
tintOverlay.topAnchor.constraint(equalTo: glassEffectView.topAnchor),
tintOverlay.leadingAnchor.constraint(equalTo: glassEffectView.leadingAnchor),
tintOverlay.bottomAnchor.constraint(equalTo: glassEffectView.bottomAnchor),
tintOverlay.trailingAnchor.constraint(equalTo: glassEffectView.trailingAnchor),
])
}

@available(*, unavailable)
Expand All @@ -85,16 +146,37 @@ final class MactermGlassView: NSView {
style: NSGlassEffectView.Style,
backgroundColor: NSColor,
backgroundOpacity: Double,
cornerRadius: CGFloat?
cornerRadius: CGFloat?,
isKeyWindow: Bool
) {
glassEffectView.style = style
glassEffectView.tintColor = backgroundColor.withAlphaComponent(backgroundOpacity)
glassEffectView.cornerRadius = cornerRadius ?? 0
self.backgroundOpacity = CGFloat(backgroundOpacity)
updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor)
}

func updateTopInset(_ offset: CGFloat) {
topConstraint.constant = offset
}

func updateKeyStatus(_ isKeyWindow: Bool, backgroundColor: NSColor) {
let tint = tintProperties(for: backgroundColor)
tintOverlay.layer?.backgroundColor = tint.color.cgColor
// Scale by the window opacity so the inactive tint stays within the
// translucency the user chose — otherwise an unfocused window reads as
// near-opaque regardless of the opacity slider.
tintOverlay.alphaValue = isKeyWindow ? 0 : tint.opacity * backgroundOpacity
}
Comment on lines +163 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file, then inspect the relevant section and nearby call sites.
ast-grep outline Macterm/Views/WindowAppearance.swift --view expanded || true
printf '\n--- lines 120-210 ---\n'
sed -n '120,210p' Macterm/Views/WindowAppearance.swift
printf '\n--- call sites of updateKeyStatus ---\n'
rg -n "updateKeyStatus\(" Macterm/Views/WindowAppearance.swift Macterm -g '*.swift'

Repository: thdxg/macterm

Length of output: 6453


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the implementation and any surrounding animation helpers.
sed -n '1,220p' Macterm/Views/WindowAppearance.swift

Repository: thdxg/macterm

Length of output: 10588


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- file outline ---\n'
ast-grep outline Macterm/Views/WindowAppearance.swift --view expanded || true

printf '\n--- relevant lines ---\n'
nl -ba Macterm/Views/WindowAppearance.swift | sed -n '140,190p'

printf '\n--- call sites ---\n'
rg -n "updateKeyStatus\(" Macterm/Views/WindowAppearance.swift Macterm -g '*.swift' || true

Repository: thdxg/macterm

Length of output: 242


🌐 Web query:

AppKit NSView alphaValue animator animation context documentation

💡 Result:

To animate an NSView's alphaValue in AppKit, you must use the view's animator proxy [1][2] within an NSAnimationContext [3]. Crucially, the view must be layer-backed for this animation to function [4][5][6]. Key Requirements and Usage 1. Layer-Backing: You must enable layer-backing for the view (or its superview if appropriate) by setting its wantsLayer property to true [5][6]. Without this, alphaValue changes will typically occur instantly rather than animating [5]. 2. Animator Proxy: Use the animator method to obtain a proxy object [1][2]. Any property changes made to this proxy are automatically animated if they are animatable [1]. 3. Animation Context: Wrap the property change in an NSAnimationContext to control parameters such as duration and timing functions [3][7]. Example (Swift) NSAnimationContext.runAnimationGroup({ context in context.duration = 1.0 // Set animation duration myView.animator.alphaValue = 0.0 // Animate to transparent }) { // Optional: Actions to perform after animation completes myView.isHidden = true } When executing these animations, ensure the view's wantsLayer property is set beforehand [6]: myView.wantsLayer = true // Then proceed with the animation block as shown above. Behavioral Notes - The NSAnimatablePropertyContainer protocol defines this proxy-based animation behavior [1][2]. - If you set a new value while an animation is in progress, the system will animate from the current interpolated value to the new target [1][2]. - You can nest NSAnimationContexts to apply different durations or configurations to specific groups of animations [3].

Citations:


Animate the key-status opacity change. alphaValue is assigned directly here, so the inactive tint snaps instead of fading. Wrap this in NSAnimationContext and set tintOverlay.animator().alphaValue for key changes; keep the initial configure path direct.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Macterm/Views/WindowAppearance.swift` around lines 163 - 170, Update
updateKeyStatus so key-status changes animate tintOverlay opacity through
NSAnimationContext and tintOverlay.animator().alphaValue, while preserving a
direct alphaValue assignment during the initial configuration path.


/// A saturation-boosted tint + opacity for the inactive overlay, lifted
/// from Ghostty's `tintProperties`.
private func tintProperties(for color: NSColor) -> (color: NSColor, opacity: CGFloat) {
let isLight = color.isLightColor
let vibrant = color.adjustingSaturation(by: 1.2)
let overlayOpacity: CGFloat = isLight ? 0.35 : 0.85
return (vibrant, overlayOpacity)
}
}

// MARK: - Window styling
Expand Down Expand Up @@ -169,6 +251,16 @@ enum WindowAppearance {
syncToolbar(window: window)
}

/// Update the inactive-glass tint when the window gains/loses key status.
/// Cheap no-op unless the glass view is currently installed.
static func syncKeyStatus(window: NSWindow) {
guard glassSupported else { return }
if #available(macOS 26.0, *) {
guard let glass = existingGlass(in: window) else { return }
glass.updateKeyStatus(window.isKeyWindow, backgroundColor: GhosttyApp.shared.backgroundColor)
}
}

/// Lock the toolbar to icon-only rendering. SwiftUI's NavigationSplitView
/// toolbar doesn't survive the label display modes: picking "Icon and
/// Text" from the toolbar's context menu makes AppKit fold the system
Expand Down Expand Up @@ -219,7 +311,8 @@ enum WindowAppearance {
style: officialGlassStyle(Preferences.shared.windowGlassStyle),
backgroundColor: backgroundColor,
backgroundOpacity: opacity,
cornerRadius: windowCornerRadius(window)
cornerRadius: windowCornerRadius(window),
isKeyWindow: window.isKeyWindow
)
}

Expand Down
Loading