diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 207816f4..a3ba3a70 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -13592,6 +13592,23 @@ } } } + }, + "tabBar.newTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規タブ" + } + } + } } }, "version": "1.0" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 42e058c1..30ac82bf 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9261,7 +9261,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // inside WindowGlassEffect.apply. let currentThemeBackground = GhosttyBackgroundTheme.currentColor() let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( - sidebarBlendMode: sidebarBlendMode, bgGlassEnabled: bgGlassEnabled, glassEffectAvailable: WindowGlassEffect.isAvailable ) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ce920cd5..9eb55111 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -663,7 +663,12 @@ struct ContentView: View { } private var effectiveTitlebarPadding: CGFloat { + // Inverted card layout with the sidebar visible: the strip owns the + // card's top — the legacy 32pt titlebar band is dead space there. With + // the sidebar hidden the titlebar accessory controls still need it. + if cardInsetAmount > 0 && sidebarState.isVisible { return 0 } if isMinimalMode { + if cardInsetAmount > 0 { return 0 } return isFullScreen ? 0 : -titlebarPadding } return titlebarPadding @@ -728,7 +733,9 @@ struct ContentView: View { } .padding(.top, effectiveTitlebarPadding) .overlay(alignment: .top) { - if !isMinimalMode { + // Card layout with the sidebar visible has no titlebar band (see + // effectiveTitlebarPadding) — the overlay would float over pills. + if !isMinimalMode && !(cardInsetAmount > 0 && sidebarState.isVisible) { // Titlebar overlay is only over terminal content, not the sidebar. customTitlebar } @@ -918,6 +925,15 @@ struct ContentView: View { return dir.isEmpty ? nil : dir } + @Environment(\.accessibilityReduceTransparency) private var accessibilityReduceTransparency + + /// Inverted glass layout: the terminal region floats as a rounded card over + /// the window's glass backdrop, inset from the edges and the sidebar. + private var cardInsetAmount: CGFloat { + WindowGlassEffect.isAvailable && !accessibilityReduceTransparency + ? WindowGlassEffect.contentCardInset : 0 + } + private var contentAndSidebarLayout: AnyView { let layout: AnyView // When matching terminal background, use HStack so both sidebar and terminal @@ -930,6 +946,7 @@ struct ContentView: View { layout = AnyView( ZStack(alignment: .leading) { terminalContentWithSidebarDropOverlay + .padding(cardInsetAmount) .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) if sidebarState.isVisible { sidebarView @@ -944,6 +961,7 @@ struct ContentView: View { sidebarView } terminalContentWithSidebarDropOverlay + .padding(cardInsetAmount) } ) } diff --git a/Sources/GhosttySurfaceScrollView.swift b/Sources/GhosttySurfaceScrollView.swift index 2d931607..1e97ac0a 100644 --- a/Sources/GhosttySurfaceScrollView.swift +++ b/Sources/GhosttySurfaceScrollView.swift @@ -416,6 +416,16 @@ final class GhosttySurfaceScrollView: NSView { super.init(frame: .zero) wantsLayer = true layer?.masksToBounds = true + // Inverted glass layout: each pane is an elevated card over the window + // backdrop. The layer already masks to bounds, so rounding it clips the + // Metal surface to the card shape for free. Same predicate as the card + // insets (ContentView): Reduce Transparency disables the card layout, + // so it must not leave rounded corners on flush content. + if WindowGlassEffect.isAvailable, + !NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency { + layer?.cornerRadius = WindowGlassEffect.contentCardCornerRadius + layer?.cornerCurve = .continuous + } backgroundView.wantsLayer = true let initialTerminalBackground = GhosttyApp.shared.defaultBackgroundColor diff --git a/Sources/GhosttyTerminalSupport.swift b/Sources/GhosttyTerminalSupport.swift index b5e4e577..3327e830 100644 --- a/Sources/GhosttyTerminalSupport.swift +++ b/Sources/GhosttyTerminalSupport.swift @@ -21,25 +21,22 @@ func ghostty_surface_select_cursor_cell_compat(_ surface: ghostty_surface_t) -> #if os(macOS) func cmuxShouldApplyWindowGlass( - sidebarBlendMode _: String, bgGlassEnabled: Bool, - glassEffectAvailable _: Bool, + glassEffectAvailable: Bool, performanceOverride: Bool? = nil ) -> Bool { if let performanceOverride { return performanceOverride } - // Window glass is independent from the sidebar material and blend mode. Native - // NSGlassEffectView vs NSVisualEffectView fallback is chosen in WindowGlassEffect.apply. - return bgGlassEnabled + // Inverted layout: on macOS 26 the glass backdrop is the stock window + // treatment, not an opt-in. Pre-26 keeps the legacy opt-in flag. + return glassEffectAvailable || bgGlassEnabled } func cmuxShouldUseTransparentBackgroundWindow() -> Bool { let defaults = UserDefaults.standard - let sidebarBlendMode = defaults.string(forKey: "sidebarBlendMode") ?? "withinWindow" let bgGlassEnabled = defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false return cmuxShouldApplyWindowGlass( - sidebarBlendMode: sidebarBlendMode, bgGlassEnabled: bgGlassEnabled, glassEffectAvailable: WindowGlassEffect.isAvailable, performanceOverride: ProgramaGlassSettings.startupOverride(for: .window) @@ -47,6 +44,9 @@ func cmuxShouldUseTransparentBackgroundWindow() -> Bool { } func cmuxShouldUseClearWindowBackground(for opacity: Double) -> Bool { + // The glass backdrop samples BEHIND the window, which requires a + // non-opaque window — same compositing the translucent-terminal mode has + // always used (standard frame, no custom masks, so no corner artifacts). cmuxShouldUseTransparentBackgroundWindow() || opacity < 0.999 } diff --git a/Sources/ProgramaGlassSettings.swift b/Sources/ProgramaGlassSettings.swift index 2a5955aa..24877e88 100644 --- a/Sources/ProgramaGlassSettings.swift +++ b/Sources/ProgramaGlassSettings.swift @@ -87,10 +87,13 @@ enum ProgramaGlassSettings { /// backgrounds retain enough tint for readable text while allowing the glass to show through. static func effectiveTerminalBackgroundOpacity( configuredOpacity: Double, - windowGlassEnabled: Bool + windowGlassEnabled _: Bool ) -> Double { - let clampedOpacity = min(1.0, max(0.0, configuredOpacity)) - return windowGlassEnabled ? min(clampedOpacity, 0.82) : clampedOpacity + // Inverted backdrop layout: the terminal pane is an opaque elevated card; + // the glass material lives behind it in the sidebar backdrop. The old + // 0.82 glass clamp forced a translucent terminal, which in turn forced a + // transparent window and killed the backdrop's window-server blur. + min(1.0, max(0.0, configuredOpacity)) } static let sidebarMigrationVersionKey = "sidebarAppearanceDefaultsVersion" diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index b54ab526..8da0a002 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -1035,17 +1035,10 @@ struct SettingsView: View { // calls WorkspaceTabColorSettings.reset() and nils both hex keys, so // anything set while these rows existed is still recoverable. - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), - subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.") - ) { - Toggle("", isOn: $sidebarMatchTerminalBackground) - .labelsHidden() - .toggleStyle(.switch) - .controlSize(.small) - } + // "Match Terminal Background" removed with the inverted glass layout: + // the sidebar sits on the system-appearance glass backdrop by design, + // and the toggle's only remaining effect was forcing terminal-derived + // text contrast that fought the system scheme. SettingsCardDivider() diff --git a/Sources/SidebarQuotaFooter.swift b/Sources/SidebarQuotaFooter.swift index 79bc9abb..869a50f3 100644 --- a/Sources/SidebarQuotaFooter.swift +++ b/Sources/SidebarQuotaFooter.swift @@ -20,8 +20,11 @@ struct SidebarQuotaFooter: View { window: snapshot.sevenDay ) } - .padding(.horizontal, 12) - .padding(.vertical, 6) + // Sidebar spacing grid: bare rows align to the content line (card + // edge 8 + row inset 8), not the card-edge line. + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 8) } else { EmptyView() } diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index 5497022d..be667e2b 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -22,10 +22,10 @@ struct SidebarFooter: View { #if DEBUG SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) #else + // Sidebar spacing grid: 4pt base, edges on 8. SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) + .padding(.horizontal, 8) + .padding(.bottom, 8) #endif } } @@ -487,7 +487,7 @@ private struct SidebarDevFooter: View { private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 4) { SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) if showSidebarDevBuildBanner { Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) @@ -495,9 +495,9 @@ private struct SidebarDevFooter: View { .foregroundColor(.red) } } - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) + // Sidebar spacing grid: 4pt base, edges on 8. + .padding(.horizontal, 8) + .padding(.bottom, 8) } } #endif @@ -517,34 +517,6 @@ enum SidebarTerminalAppearance { } } -/// Applies the terminal-derived scheme to a subtree while the sidebar is matching the -/// terminal background, so every label in it picks contrast from the colour it sits on. -struct SidebarTerminalColorScheme: ViewModifier { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @Environment(\.accessibilityReduceTransparency) private var accessibilityReduceTransparency - @State private var scheme: ColorScheme = SidebarTerminalAppearance.colorScheme() - - /// The native glass sidebar floats over the terminal-colored base plane, so its - /// content must always resolve contrast against the terminal, not the app scheme. - private var followsTerminal: Bool { - matchTerminalBackground || - (WindowGlassEffect.isAvailable && !accessibilityReduceTransparency) - } - - func body(content: Content) -> some View { - Group { - if followsTerminal { - content.environment(\.colorScheme, scheme) - } else { - content - } - } - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - scheme = SidebarTerminalAppearance.colorScheme() - } - } -} - struct SidebarTopScrim: View { let height: CGFloat @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false @@ -1237,13 +1209,13 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { } } -/// Hosts the complete interactive sidebar inside the native glass content host. AppKit does -/// not guarantee the z-order or rendering of controls added as arbitrary glass siblings. -private struct SidebarNativeGlassContentHost: NSViewRepresentable { +/// Hosts the complete interactive sidebar directly on the window's glass backdrop +/// (inverted, Aside-style layout). No inner glass surface: the window contentView +/// glass provides the material, so this host is a transparent AppKit container +/// whose only job is zeroing the window safe area — the sidebar owns its own +/// titlebar-like header row. +private struct SidebarBackdropContentHost: NSViewRepresentable { let content: Content - let tintColor: NSColor? - let cornerRadius: CGFloat - let appearance: NSAppearance? final class Coordinator { let hostingView: NSHostingView @@ -1253,7 +1225,7 @@ private struct SidebarNativeGlassContentHost: NSViewRepresentable hostingView.autoresizingMask = [.width, .height] hostingView.wantsLayer = true hostingView.layer?.backgroundColor = NSColor.clear.cgColor - // The panel owns its own titlebar-like header row; the window safe + // The sidebar owns its own titlebar-like header row; the window safe // area must not add a second inset on top of it. hostingView.safeAreaRegions = [] } @@ -1264,53 +1236,12 @@ private struct SidebarNativeGlassContentHost: NSViewRepresentable } func makeNSView(context: Context) -> NSView { - let hostingView = context.coordinator.hostingView - - #if compiler(>=6.2) - if #available(macOS 26.0, *) { - let glass = NSGlassEffectView(frame: .zero) - glass.autoresizingMask = [.width, .height] - glass.wantsLayer = true - glass.style = .regular - glass.tintColor = tintColor - glass.appearance = appearance - applyCornerMask(to: glass) - hostingView.frame = glass.bounds - glass.contentView = hostingView - return glass - } - #endif - - return hostingView + context.coordinator.hostingView } func updateNSView(_ nsView: NSView, context: Context) { context.coordinator.hostingView.rootView = content - - #if compiler(>=6.2) - if #available(macOS 26.0, *), let glass = nsView as? NSGlassEffectView { - glass.tintColor = tintColor - glass.appearance = appearance - applyCornerMask(to: glass) - } - #endif - } - - #if compiler(>=6.2) - @available(macOS 26.0, *) - private func applyCornerMask(to glass: NSGlassEffectView) { - glass.cornerRadius = cornerRadius - glass.layer?.cornerRadius = cornerRadius - glass.layer?.cornerCurve = .continuous - glass.layer?.maskedCorners = [ - .layerMinXMinYCorner, - .layerMaxXMinYCorner, - .layerMinXMaxYCorner, - .layerMaxXMaxYCorner, - ] - glass.layer?.masksToBounds = cornerRadius > 0 } - #endif } /// Owns the sidebar as a distinct native glass surface above the terminal-colored base plane. @@ -1332,20 +1263,24 @@ struct SidebarSurface: View { var body: some View { Group { if usesLocalNativeGlass { - // One radius, one mask: the glass host owns the corner treatment. - // The sidebar floats over the terminal-colored plane, so its glass and - // content must resolve against the terminal scheme, not the app scheme. - SidebarNativeGlassContentHost( - content: content.environment(\.colorScheme, terminalScheme), - tintColor: resolvedTintColor, - cornerRadius: standaloneCornerRadius, - appearance: NSAppearance(named: terminalScheme == .dark ? .darkAqua : .aqua) - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + // Inverted layout: the sidebar sits directly on the window's glass + // backdrop, which follows the SYSTEM appearance (light sidebar in + // light mode) — only the content card and its pills stay + // terminal-toned. No inner glass, no clip: window-level material. + SidebarBackdropContentHost(content: content) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { + // Fallback path (pre-26 / Reduce Transparency): the backdrop can + // render terminal-colored when Match Terminal Background is on, + // so labels must resolve contrast against the terminal, not the + // system scheme. ZStack { SidebarBackdrop() - content + if matchTerminalBackground { + content.environment(\.colorScheme, terminalScheme) + } else { + content + } } .clipShape(RoundedRectangle(cornerRadius: standaloneCornerRadius, style: .continuous)) } diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index 932657cd..c8551158 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -709,10 +709,10 @@ struct TabItemView: View, Equatable { .padding(.horizontal, 10) .padding(.vertical, 8) .background( - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(backgroundColor) .overlay { - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) } .overlay(alignment: .leading) { @@ -1168,7 +1168,7 @@ struct TabItemView: View, Equatable { } // Default: quiet neutral selection that harmonizes with the glass sidebar // instead of a saturated accent card. - return NSColor.labelColor.withAlphaComponent(0.14) + return NSColor.quaternaryLabelColor } private var backgroundColor: Color { diff --git a/Sources/TerminalController+Debug.swift b/Sources/TerminalController+Debug.swift index 806d897a..0ac18b0c 100644 --- a/Sources/TerminalController+Debug.swift +++ b/Sources/TerminalController+Debug.swift @@ -478,6 +478,47 @@ extension TerminalController { return .ok(payload) } + /// Dumps the key window's AppKit view tree with frames, visibility, and any + /// opaque layer background — chrome-layering bugs (a stray view painting + /// over content) are otherwise invisible to log-based diagnosis. + func v2DebugViewTree() -> V2CallResult { + let lines: [String] = v2MainSync { + guard let window = NSApp.keyWindow ?? NSApp.windows.first(where: { $0.isVisible && $0.contentView != nil }) else { + return [] + } + var out: [String] = [] + out.append( + "WINDOW isOpaque=\(window.isOpaque) bg=\(window.backgroundColor.hexString())@\(String(format: "%.3f", window.backgroundColor.alphaComponent)) " + + "appearance=\(window.effectiveAppearance.name.rawValue)" + ) + func walk(_ view: NSView, depth: Int) { + let frame = view.frame + var line = String(repeating: " ", count: depth) + line += String(describing: type(of: view)).prefix(48) + line += String(format: " (%.0f,%.0f %.0fx%.0f)", frame.origin.x, frame.origin.y, frame.width, frame.height) + if view.isHidden { line += " HIDDEN" } + if let bg = view.layer?.backgroundColor, let color = NSColor(cgColor: bg), color.alphaComponent > 0.01 { + line += " bg=\(color.hexString())@\(String(format: "%.2f", color.alphaComponent))" + } + if view.layer?.cornerRadius ?? 0 > 0 { + line += " r=\(Int(view.layer?.cornerRadius ?? 0))" + } + if let effect = view as? NSVisualEffectView { + line += " material=\(effect.material.rawValue) blend=\(effect.blendingMode.rawValue) " + + "state=\(effect.state.rawValue) alpha=\(String(format: "%.2f", effect.alphaValue)) " + + "emphasized=\(effect.isEmphasized)" + } + out.append(line) + for child in view.subviews { walk(child, depth: depth + 1) } + } + if let root = window.contentView?.superview ?? window.contentView { + walk(root, depth: 0) + } + return out + } + return .ok(["tree": lines]) + } + func v2DebugBonsplitUnderflowCount() -> V2CallResult { let resp = bonsplitUnderflowCount() guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index bb97ca76..b342123a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2069,6 +2069,8 @@ class TerminalController { return v2Result(id: id, self.v2DebugLayout()) case "debug.portal.stats": return v2Result(id: id, self.v2DebugPortalStats()) + case "debug.viewtree": + return v2Result(id: id, self.v2DebugViewTree()) case "debug.bonsplit_underflow.count": return v2Result(id: id, self.v2DebugBonsplitUnderflowCount()) case "debug.bonsplit_underflow.reset": diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index fef7562b..c9b2b476 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -182,6 +182,12 @@ struct VerticalTabsSidebar: View { /// Content clearance inside the glass panel for the traffic lights and the /// always-visible titlebar controls that share the panel's top strip. private let trafficLightPadding: CGFloat = WindowGlassEffect.sidebarHeaderHeight + @Environment(\.accessibilityReduceTransparency) private var accessibilityReduceTransparency + + /// Inverted layout: sidebar rendered directly on the window glass backdrop. + private var usesBackdropSidebar: Bool { + WindowGlassEffect.isAvailable && !accessibilityReduceTransparency + } private let tabRowSpacing: CGFloat = 2 private var isMinimalMode: Bool { @@ -225,6 +231,9 @@ struct VerticalTabsSidebar: View { HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) } .frame(height: trafficLightPadding) + // Flush sidebar (no panel inset): keep the header content on the + // 25pt traffic-light midline the decorations controller targets. + .padding(.top, usesBackdropSidebar ? WindowGlassEffect.sidebarPanelInset : 0) .contentShape(Rectangle()) .background( WindowDragHandleView() @@ -234,9 +243,6 @@ struct VerticalTabsSidebar: View { GeometryReader { proxy in ScrollView { VStack(spacing: 0) { - Spacer() - .frame(height: 6) - // Workspaces are bounded, so prefer a non-lazy stack here. // LazyVStack + drag-state invalidations can recurse through layout. VStack(spacing: tabRowSpacing) { @@ -332,14 +338,16 @@ struct VerticalTabsSidebar: View { .accessibilityIdentifier("Sidebar") ZStack { - SidebarTerminalBasePlane() - .ignoresSafeArea() - - // Maps-style: the panel includes the traffic lights and simply pads - // its content below them; insets stay uniform so the panel radius is - // concentric with the window corner on all four sides. - SidebarSurface(content: sidebarContent) - .padding(WindowGlassEffect.sidebarPanelInset) + if usesBackdropSidebar { + // Inverted (Aside-style): the sidebar sits flush on the window's + // glass backdrop — no base plane, no panel inset. + SidebarSurface(content: sidebarContent) + } else { + SidebarTerminalBasePlane() + .ignoresSafeArea() + SidebarSurface(content: sidebarContent) + .padding(WindowGlassEffect.sidebarPanelInset) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() @@ -396,7 +404,6 @@ struct VerticalTabsSidebar: View { draggedTabId = nil } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .modifier(SidebarTerminalColorScheme()) } private func debugShortSidebarTabId(_ id: UUID?) -> String { diff --git a/Sources/WindowBrowserSlotView.swift b/Sources/WindowBrowserSlotView.swift index 5ec81f3a..5358dc21 100644 --- a/Sources/WindowBrowserSlotView.swift +++ b/Sources/WindowBrowserSlotView.swift @@ -53,6 +53,14 @@ final class WindowBrowserSlotView: NSView { super.init(frame: frameRect) wantsLayer = true layer?.masksToBounds = true + // Inverted glass layout: browser panes are elevated cards like terminal + // panes (GhosttySurfaceScrollView carries the same radius). Same + // predicate as the card insets — see GhosttySurfaceScrollView. + if WindowGlassEffect.isAvailable, + !NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency { + layer?.cornerRadius = WindowGlassEffect.contentCardCornerRadius + layer?.cornerCurve = .continuous + } translatesAutoresizingMaskIntoConstraints = true autoresizingMask = [] diff --git a/Sources/WindowChrome.swift b/Sources/WindowChrome.swift index a54facde..986e5d36 100644 --- a/Sources/WindowChrome.swift +++ b/Sources/WindowChrome.swift @@ -52,193 +52,106 @@ enum WindowGlassEffect { /// Vertical midline of the header row measured from the window top. static var sidebarHeaderCenterFromWindowTop: CGFloat { sidebarPanelInset + sidebarHeaderHeight / 2 } - /// The stock tint (#000000 @ 0.03) is effectively clear, which lets the - /// desktop color wash through every translucent region and corner. Stock - /// resolves to a terminal-toned grounding tint; explicit user tints win. - static func resolvedWindowTint(hex: String, opacity: Double) -> NSColor { + /// Inverted (Aside-style) layout: the window backdrop is the sidebar's + /// material, sampling the desktop and following the system appearance. + /// Stock (#000000 @ 0.03) means untinted system glass; explicit user + /// tints still win. + static func resolvedWindowTint(hex: String, opacity: Double) -> NSColor? { let normalized = hex.trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "#", with: "").uppercased() let isStock = normalized == "000000" && abs(opacity - 0.03) < 0.001 if isStock { - return GhosttyBackgroundTheme.currentColor().withAlphaComponent(0.55) + return nil } return (NSColor(hex: hex) ?? .black).withAlphaComponent(opacity) } - private static var fullScreenObserverKey: UInt8 = 0 - - @available(macOS 26.0, *) - private static func applyWindowCornerMask(to glassView: NSView, rounded: Bool) { - glassView.layer?.cornerRadius = rounded ? windowCornerRadius : 0 - glassView.layer?.cornerCurve = .continuous - glassView.layer?.masksToBounds = rounded - } - - /// The transparent titlebar still hosts a material backdrop that peeks out - /// in the crescent between our large corner mask and the legacy frame shape - /// at the two top corners — a lighter glitchy wedge. Hide the material; the - /// buttons and accessories are separate views and stay visible. - private static func hideTitlebarBackdrop(in window: NSWindow) { - guard let frameView = window.contentView?.superview else { return } - for child in frameView.subviews - where String(describing: type(of: child)).contains("NSTitlebarContainerView") { - for titlebarChild in descendants(of: child) - where titlebarChild is NSVisualEffectView { - titlebarChild.isHidden = true - } - } - } - - private static func descendants(of view: NSView) -> [NSView] { - view.subviews.flatMap { [$0] + descendants(of: $0) } - } - - @available(macOS 26.0, *) - private static func installFullScreenRadiusObservers(for window: NSWindow, glassView: NSView) { - let center = NotificationCenter.default - let tokens: [NSObjectProtocol] = [ - center.addObserver( - forName: NSWindow.willEnterFullScreenNotification, object: window, queue: .main - ) { [weak glassView] _ in - guard let glassView else { return } - applyWindowCornerMask(to: glassView, rounded: false) - }, - center.addObserver( - forName: NSWindow.willExitFullScreenNotification, object: window, queue: .main - ) { [weak glassView] _ in - guard let glassView else { return } - applyWindowCornerMask(to: glassView, rounded: true) - }, - // Shadow must re-derive from content alpha after size/shape changes. - center.addObserver( - forName: NSWindow.didEndLiveResizeNotification, object: window, queue: .main - ) { [weak window] _ in - window?.invalidateShadow() - }, - center.addObserver( - forName: NSWindow.didExitFullScreenNotification, object: window, queue: .main - ) { [weak window] _ in - DispatchQueue.main.async { window?.invalidateShadow() } - }, - ] - objc_setAssociatedObject(window, &fullScreenObserverKey, tokens, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } + /// Corner radius of the elevated content card (terminal/browser panes). + static let contentCardCornerRadius: CGFloat = 12 + /// Radius for floating glass controls: tab pills, icon capsule clusters. + static let controlCornerRadius: CGFloat = 10 + /// Gap between the content card and the window edges / sidebar. + static let contentCardInset: CGFloat = 8 + /// Inverted (Aside-style) backdrop. The window stays a completely standard + /// opaque AppKit window — system corner radius, system shadow, system frame. + /// The sidebar material is an NSVisualEffectView underlay that AppKit rounds + /// to the window shape itself, exactly like every native sidebar. No custom + /// corner mask, no non-opaque window, no contentView replacement: those were + /// the source of every "transparent corner" artifact, so they are gone, not + /// patched. static func apply(to window: NSWindow, tintColor: NSColor? = nil) { guard let originalContentView = window.contentView else { return } - // Check if we already applied glass (avoid re-wrapping) - if let existingGlass = objc_getAssociatedObject(window, &glassViewKey) as? NSView { - // Already applied, just update the tint - updateTint(on: existingGlass, color: tintColor, window: window) + if let existing = objc_getAssociatedObject(window, &glassViewKey) as? NSView { + updateTint(on: existing, color: tintColor, window: window) return } + let bounds = originalContentView.bounds + let backdrop: NSView #if compiler(>=6.2) if #available(macOS 26.0, *) { - applyGlass(to: window, originalContentView: originalContentView, tintColor: tintColor) - return + // Liquid Glass backdrop: samples the desktop through the transparent + // window and blurs it — the mechanism proven to work on macOS 26. + let glass = NSGlassEffectView(frame: bounds) + glass.style = .regular + glass.cornerRadius = 0 + glass.tintColor = tintColor + backdrop = glass + } else { + backdrop = Self.makeVisualEffectBackdrop(frame: bounds) } + #else + backdrop = Self.makeVisualEffectBackdrop(frame: bounds) #endif - applyVisualEffectFallback(to: window, originalContentView: originalContentView, tintColor: tintColor) - } - - #if compiler(>=6.2) - @available(macOS 26.0, *) - private static func applyGlass(to window: NSWindow, originalContentView: NSView, tintColor: NSColor?) { - let glassView = NSGlassEffectView(frame: originalContentView.bounds) - glassView.wantsLayer = true - // Match the modern large window radius (Maps-style); the sidebar panel's - // radius is derived as this minus its inset to stay concentric. Shape via - // a plain layer mask, not NSGlassEffectView.cornerRadius: the glass draws - // a specular rim at its own rounded boundary, which reads as a ghost - // border against dark content. A layer cut has no rim. - glassView.cornerRadius = 0 - // Opaque terminal-colored backing: backdrop sampling bleeds the desktop - // into a rim wherever fills are translucent, which reads as a glitchy - // inconsistent border. In-window glass elements are unaffected — they - // sample window content, not the desktop. - glassView.layer?.backgroundColor = - GhosttyBackgroundTheme.currentColor().withAlphaComponent(1.0).cgColor - applyWindowCornerMask(to: glassView, rounded: !window.styleMask.contains(.fullScreen)) - installFullScreenRadiusObservers(for: window, glassView: glassView) - // The system window backdrop keeps its own smaller-radius shape; between - // it and our larger mask it peeks out as a ghost arc in each corner. - // A clear window lets the shadow and edge follow the masked shape only. - window.isOpaque = false - window.backgroundColor = .clear - // A non-opaque window's shadow only re-derives from content alpha on - // explicit invalidation; without it the old square-ish shadow rings the - // corner crescents and they read as transparent holes. - DispatchQueue.main.async { [weak window] in - guard let window else { return } - hideTitlebarBackdrop(in: window) - window.invalidateShadow() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak window] in - guard let window else { return } - hideTitlebarBackdrop(in: window) - window.invalidateShadow() - } - glassView.tintColor = tintColor - glassView.autoresizingMask = [.width, .height] - - // NSGlassEffectView is a full replacement for the contentView. - objc_setAssociatedObject(window, &originalContentViewKey, originalContentView, .OBJC_ASSOCIATION_RETAIN) - window.contentView = glassView - - // AppKit only guarantees correct control placement when the controls are owned by - // NSGlassEffectView.contentView. Portal-hosted terminal surfaces remain siblings below - // this host, where they continue to provide the source pixels sampled by the effect. - originalContentView.frame = glassView.bounds - originalContentView.autoresizingMask = [.width, .height] - originalContentView.wantsLayer = true - originalContentView.layer?.backgroundColor = NSColor.clear.cgColor - glassView.contentView = originalContentView - - objc_setAssociatedObject(window, &glassViewKey, glassView, .OBJC_ASSOCIATION_RETAIN) - } - #endif - private static func applyVisualEffectFallback(to window: NSWindow, originalContentView: NSView, tintColor: NSColor?) { - let bounds = originalContentView.bounds - let glassView = NSVisualEffectView(frame: bounds) - glassView.blendingMode = .behindWindow - // Favor a lighter fallback so behind-window glass reads more transparent. - glassView.material = .underWindowBackground - glassView.state = .active - glassView.wantsLayer = true - glassView.autoresizingMask = [.width, .height] - - // For the NSVisualEffectView fallback, do NOT replace window.contentView. - // Replacing contentView can break traffic light rendering with - // `.fullSizeContentView` + `titlebarAppearsTransparent`. - glassView.translatesAutoresizingMaskIntoConstraints = false - originalContentView.addSubview(glassView, positioned: .below, relativeTo: nil) + // Never replace window.contentView — that breaks traffic-light rendering + // with `.fullSizeContentView` + `titlebarAppearsTransparent`, and it is + // what forced the old custom-mask architecture. The backdrop also cannot + // be a SUBVIEW of the hosting view: NSHostingView draws pure-SwiftUI + // content into its own layer, and any subview — even positioned .below — + // sits above that drawing, occluding the sidebar. Install it as a theme- + // frame sibling below the contentView instead (the terminal portal's + // proven pattern), pinned to the contentView's geometry. + guard let themeFrame = originalContentView.superview else { return } + backdrop.translatesAutoresizingMaskIntoConstraints = false + themeFrame.addSubview(backdrop, positioned: .below, relativeTo: originalContentView) NSLayoutConstraint.activate([ - glassView.topAnchor.constraint(equalTo: originalContentView.topAnchor), - glassView.bottomAnchor.constraint(equalTo: originalContentView.bottomAnchor), - glassView.leadingAnchor.constraint(equalTo: originalContentView.leadingAnchor), - glassView.trailingAnchor.constraint(equalTo: originalContentView.trailingAnchor) + backdrop.topAnchor.constraint(equalTo: originalContentView.topAnchor), + backdrop.bottomAnchor.constraint(equalTo: originalContentView.bottomAnchor), + backdrop.leadingAnchor.constraint(equalTo: originalContentView.leadingAnchor), + backdrop.trailingAnchor.constraint(equalTo: originalContentView.trailingAnchor) ]) - // Add tint overlay between glass and content - if let tintColor { - let tintOverlay = NSView(frame: bounds) - tintOverlay.translatesAutoresizingMaskIntoConstraints = false - tintOverlay.wantsLayer = true - tintOverlay.layer?.backgroundColor = tintColor.cgColor - glassView.addSubview(tintOverlay) - NSLayoutConstraint.activate([ - tintOverlay.topAnchor.constraint(equalTo: glassView.topAnchor), - tintOverlay.bottomAnchor.constraint(equalTo: glassView.bottomAnchor), - tintOverlay.leadingAnchor.constraint(equalTo: glassView.leadingAnchor), - tintOverlay.trailingAnchor.constraint(equalTo: glassView.trailingAnchor) - ]) - objc_setAssociatedObject(window, &tintOverlayKey, tintOverlay, .OBJC_ASSOCIATION_RETAIN) + if let tintColor, !isGlassEffectView(backdrop) { + installTintOverlay(on: backdrop, color: tintColor, window: window) } - objc_setAssociatedObject(window, &glassViewKey, glassView, .OBJC_ASSOCIATION_RETAIN) + objc_setAssociatedObject(window, &glassViewKey, backdrop, .OBJC_ASSOCIATION_RETAIN) + } + + private static func makeVisualEffectBackdrop(frame: NSRect) -> NSVisualEffectView { + let view = NSVisualEffectView(frame: frame) + view.blendingMode = .behindWindow + view.material = .sidebar + view.state = .active + return view + } + + private static func installTintOverlay(on backdrop: NSView, color: NSColor, window: NSWindow) { + let tintOverlay = NSView(frame: backdrop.bounds) + tintOverlay.translatesAutoresizingMaskIntoConstraints = false + tintOverlay.wantsLayer = true + tintOverlay.layer?.backgroundColor = color.cgColor + backdrop.addSubview(tintOverlay) + NSLayoutConstraint.activate([ + tintOverlay.topAnchor.constraint(equalTo: backdrop.topAnchor), + tintOverlay.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor), + tintOverlay.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor), + tintOverlay.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor) + ]) + objc_setAssociatedObject(window, &tintOverlayKey, tintOverlay, .OBJC_ASSOCIATION_RETAIN) } /// Update the tint color on an existing glass effect @@ -247,47 +160,25 @@ enum WindowGlassEffect { updateTint(on: glassView, color: color, window: window) } - private static func updateTint(on glassView: NSView, color: NSColor?, window: NSWindow) { + private static func updateTint(on backdrop: NSView, color: NSColor?, window: NSWindow) { #if compiler(>=6.2) - if #available(macOS 26.0, *), let glass = glassView as? NSGlassEffectView { + if #available(macOS 26.0, *), let glass = backdrop as? NSGlassEffectView { glass.tintColor = color - // Keep the opaque backing in step with the terminal theme. - glass.layer?.backgroundColor = - GhosttyBackgroundTheme.currentColor().withAlphaComponent(1.0).cgColor return } #endif - // For NSVisualEffectView fallback, update the tint overlay if let tintOverlay = objc_getAssociatedObject(window, &tintOverlayKey) as? NSView { tintOverlay.layer?.backgroundColor = color?.cgColor + } else if let color { + installTintOverlay(on: backdrop, color: color, window: window) } } static func remove(from window: NSWindow) { - guard let glassView = objc_getAssociatedObject(window, &glassViewKey) as? NSView else { + guard let backdrop = objc_getAssociatedObject(window, &glassViewKey) as? NSView else { return } - - if isGlassEffectView(glassView) { - if let originalContentView = objc_getAssociatedObject(window, &originalContentViewKey) as? NSView { - #if compiler(>=6.2) - if #available(macOS 26.0, *), let glass = glassView as? NSGlassEffectView { - glass.contentView = nil - } - #endif - originalContentView.removeFromSuperview() - originalContentView.autoresizingMask = [.width, .height] - originalContentView.frame = glassView.bounds - window.contentView = originalContentView - } - } else { - glassView.removeFromSuperview() - } - - if let tokens = objc_getAssociatedObject(window, &fullScreenObserverKey) as? [NSObjectProtocol] { - for token in tokens { NotificationCenter.default.removeObserver(token) } - } - objc_setAssociatedObject(window, &fullScreenObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + backdrop.removeFromSuperview() objc_setAssociatedObject(window, &glassViewKey, nil, .OBJC_ASSOCIATION_RETAIN) objc_setAssociatedObject(window, &originalContentViewKey, nil, .OBJC_ASSOCIATION_RETAIN) objc_setAssociatedObject(window, &tintOverlayKey, nil, .OBJC_ASSOCIATION_RETAIN) diff --git a/Sources/WindowDecorationsController.swift b/Sources/WindowDecorationsController.swift index 216c4b34..15e4ca04 100644 --- a/Sources/WindowDecorationsController.swift +++ b/Sources/WindowDecorationsController.swift @@ -33,6 +33,16 @@ final class WindowDecorationsController { // Titlebar layout resets button positions on resize and fullscreen churn. observers.append(center.addObserver(forName: NSWindow.didEndLiveResizeNotification, object: nil, queue: .main, using: handler)) observers.append(center.addObserver(forName: NSWindow.didExitFullScreenNotification, object: nil, queue: .main, using: handler)) + // Live resize relayouts the titlebar per frame and snaps the buttons back + // to the stock corner until the drag ends; re-seat them synchronously on + // every resize tick so they hold position through the whole drag. + observers.append(center.addObserver(forName: NSWindow.didResizeNotification, object: nil, queue: .main) { [weak self] notification in + guard let self, let window = notification.object as? NSWindow else { return } + guard window.inLiveResize else { return } + let hidden = self.shouldHideTrafficLights(for: window) + let offset = hidden ? NSPoint.zero : self.trafficLightOffset(for: window) + self.applyTrafficLightOffsetNow(on: window, offset: offset) + }) } private func attachToExistingWindows() { diff --git a/Sources/WindowPaneChromePortal.swift b/Sources/WindowPaneChromePortal.swift index 49c003ce..cb7f2c17 100644 --- a/Sources/WindowPaneChromePortal.swift +++ b/Sources/WindowPaneChromePortal.swift @@ -105,31 +105,39 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr scheduleSettledSynchronize() } - /// Workspace-level controls follow the focused visible pane, stacked down the - /// right edge like Maps' control pills: new-tab capsule first, splits below. + /// Workspace-level controls follow the focused visible pane, sharing its tab + /// strip: pills on the left, both capsules side by side on the right, all + /// centered on the strip's midline. The pane's tab bar reserves trailing + /// space so pills never run under the controls. private func updateClusters() { // Anchors can move to another window (workspace drag-out); their stale // descriptors must not steer this window's controls. let inWindow = descriptors.values.filter { $0.anchorView?.window === window } let visible = inWindow.filter(\.isVisible) - guard let active = visible.first(where: { $0.isFocused }) ?? visible.first else { + guard let active = visible.first(where: { $0.isFocused }) ?? visible.first, + let anchor = active.anchorView else { newTabCluster.isHidden = true splitCluster.isHidden = true + for bar in bars.values { bar.trailingReservedWidth = 0 } return } let barHeight: CGFloat = 28 - var y = hostView.bounds.maxY - barHeight - 5 + // Chrome spacing grid: 4pt base, edges on 8. + let gap: CGFloat = 8 + let strip = hostView.convert(anchor.bounds, from: anchor) + let y = strip.midY - barHeight / 2 newTabCluster.setActions([active.onNewTab, active.onNewBrowserTab]) newTabCluster.isHidden = false ensureAboveBars(newTabCluster) + let newTabX = strip.maxX - gap - newTabCluster.preferredWidth newTabCluster.frame = NSRect( - x: hostView.bounds.maxX - newTabCluster.preferredWidth - 8, + x: newTabX, y: y, width: newTabCluster.preferredWidth, height: barHeight ) - y -= barHeight + 7 + var reserved = gap + newTabCluster.preferredWidth // Cap workspace splits at a 2x2-equivalent depth; deeper trees degenerate // into slivers. Checked here (live pane count) rather than at publish time, @@ -150,14 +158,19 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr splitCluster.isHidden = false ensureAboveBars(splitCluster) splitCluster.frame = NSRect( - x: hostView.bounds.maxX - splitCluster.preferredWidth - 8, + x: newTabX - gap - splitCluster.preferredWidth, y: y, width: splitCluster.preferredWidth, height: barHeight ) + reserved += gap + splitCluster.preferredWidth } else { splitCluster.isHidden = true } + + for (paneID, bar) in bars { + bar.trailingReservedWidth = paneID == active.paneID ? reserved : 0 + } } /// Re-adding an already-parented subview removes and re-inserts it, which @@ -303,10 +316,13 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr // Called from every descriptor update and geometry pass; a full-window // recursive search each time is the dominant cost, so cache the host and // re-find only when it leaves the window. + // The terminal portal installs its host in the theme frame (the + // contentView's superview), so the search must start there. + let searchRoot = window.contentView?.superview ?? window.contentView let terminalHost: WindowTerminalHostView if let cached = cachedTerminalHost, cached.window === window { terminalHost = cached - } else if let found = findTerminalHost(in: window.contentView) { + } else if let found = findTerminalHost(in: searchRoot) { cachedTerminalHost = found terminalHost = found } else { @@ -386,6 +402,12 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr /// Coalesces ancestor-resize storms (divider drags, collapse animations) into /// one geometry pass per runloop turn. private func scheduleSynchronizeAll() { + // Live resize: a one-turn lag reads as chrome smearing behind the + // window edge — sync immediately (same trade the terminal portal makes). + if window?.inLiveResize == true { + synchronizeAll() + return + } guard !syncAllScheduled else { return } syncAllScheduled = true DispatchQueue.main.async { [weak self] in @@ -493,6 +515,25 @@ private final class NativePaneTabBarView: NSView { private let documentView = FlippedDocumentView(frame: .zero) private var pillViews: [TabID: NativeGlassTabPillView] = [:] private var descriptor: BonsplitPaneChromeDescriptor? + /// Safari-style "+" after the last pill; scrolls with the tabs. Bare glyph, + /// no capsule — it's an affordance, not a peer of the tab pills. + private let newTabButton: NSButton = { + let title = String(localized: "tabBar.newTab", defaultValue: "New Tab") + let button = NSButton(frame: .zero) + button.image = NSImage(systemSymbolName: "plus", accessibilityDescription: title) + button.isBordered = false + button.contentTintColor = .secondaryLabelColor + button.toolTip = title + button.setAccessibilityLabel(title) + return button + }() + private var newTabAction: (() -> Void)? + + /// Space kept clear at the trailing edge for the workspace control capsules + /// that share this strip. + var trailingReservedWidth: CGFloat = 0 { + didSet { if oldValue != trailingReservedWidth { needsLayout = true } } + } override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -509,13 +550,20 @@ private final class NativePaneTabBarView: NSView { scrollView.verticalScrollElasticity = .none scrollView.documentView = documentView addSubview(scrollView) + newTabButton.target = self + newTabButton.action = #selector(newTabPressed) + documentView.addSubview(newTabButton) } + @objc private func newTabPressed() { newTabAction?() } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layout() { super.layout() - scrollView.frame = bounds.insetBy(dx: 8, dy: 5) + var scrollFrame = bounds.insetBy(dx: 8, dy: 8) + scrollFrame.size.width = max(0, scrollFrame.width - trailingReservedWidth) + scrollView.frame = scrollFrame layoutPills() // Early zero-sized layout passes can leave the clip view scrolled to a // negative vertical origin, which parks the whole tab row outside the @@ -549,6 +597,7 @@ private final class NativePaneTabBarView: NSView { dragState: { [weak descriptor] active in descriptor?.onDragStateChanged(tab.id, active) } ) } + newTabAction = descriptor.onNewTab // Title changes arrive outside AppKit's layout cadence; needsLayout // reruns layoutPills() in the next pass (widths track intrinsic size). needsLayout = true @@ -556,16 +605,18 @@ private final class NativePaneTabBarView: NSView { private func layoutPills() { guard let descriptor else { return } - let gap: CGFloat = 7 + let gap: CGFloat = 8 let minPillWidth: CGFloat = 78 let leadingInset = max(0, descriptor.leadingInset) let height = max(28, scrollView.contentSize.height) let pills = descriptor.tabs.compactMap { pillViews[$0.id] } + let plusWidth: CGFloat = 28 // Natural width per pill; only compress (which is what introduces - // truncation) once the row genuinely runs out of space. + // truncation) once the row genuinely runs out of space. The "+" always + // keeps its seat at the end of the row. var widths = pills.map { min(220, max(minPillWidth, $0.preferredWidth)) } - let available = scrollView.contentSize.width - leadingInset + let available = scrollView.contentSize.width - leadingInset - (gap + plusWidth) let naturalTotal = widths.reduce(0, +) + gap * CGFloat(max(0, widths.count - 1)) if naturalTotal > available, !widths.isEmpty { let evenWidth = (available - gap * CGFloat(widths.count - 1)) / CGFloat(widths.count) @@ -579,6 +630,8 @@ private final class NativePaneTabBarView: NSView { pill.layoutSubtreeIfNeeded() x += width + gap } + newTabButton.frame = NSRect(x: x, y: 0, width: plusWidth, height: height) + x += plusWidth + gap documentView.frame = NSRect(x: 0, y: 0, width: max(x, scrollView.contentSize.width), height: height) } } @@ -604,7 +657,9 @@ private final class GlassIconClusterView: NSView { super.init(frame: .zero) #if compiler(>=6.2) glass.style = .regular - glass.cornerRadius = 14 + glass.cornerRadius = WindowGlassEffect.controlCornerRadius + // Same surface tone as the selected pill (see applySurfaceState). + glass.tintColor = NSColor.labelColor.withAlphaComponent(0.12) glass.contentView = container #endif addSubview(glass) @@ -623,6 +678,9 @@ private final class GlassIconClusterView: NSView { } actions = Array(repeating: {}, count: symbols.count) defaultTooltips = symbols.map(\.tooltip) + // Full-presence like the selected pill; the shared tint above keeps the + // strip's three capsule surfaces in one material family. + alphaValue = 1.0 } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -685,7 +743,7 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource { super.init(frame: frameRect) #if compiler(>=6.2) glass.style = .regular - glass.cornerRadius = 14 + glass.cornerRadius = WindowGlassEffect.controlCornerRadius glass.contentView = control #endif addSubview(glass) @@ -734,15 +792,17 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource { /// Accent fills fight the native material; tint with the text primary. private func applySurfaceState() { #if compiler(>=6.2) + // One shared surface tone across the strip: selected pills and the + // control capsules use labelColor@0.12; quiet pills stay untinted. if isSelected { - glass.tintColor = NSColor.labelColor.withAlphaComponent(0.16) + glass.tintColor = NSColor.labelColor.withAlphaComponent(0.12) } else if isHovered { - glass.tintColor = NSColor.labelColor.withAlphaComponent(0.08) + glass.tintColor = NSColor.labelColor.withAlphaComponent(0.06) } else { glass.tintColor = nil } #endif - alphaValue = isSelected ? 1.0 : (isHovered ? 0.94 : 0.82) + alphaValue = isSelected ? 1.0 : (isHovered ? 0.94 : 0.85) } func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { @@ -862,7 +922,11 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS override var focusRingMaskBounds: NSRect { bounds } override func drawFocusRingMask() { - NSBezierPath(roundedRect: bounds, xRadius: 14, yRadius: 14).fill() + NSBezierPath( + roundedRect: bounds, + xRadius: WindowGlassEffect.controlCornerRadius, + yRadius: WindowGlassEffect.controlCornerRadius + ).fill() } override func keyDown(with event: NSEvent) { diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 81577e70..7357cf91 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -241,12 +241,18 @@ struct WorkspaceContentView: View { @AppStorage(ProgramaGlassSettings.overlaysEnabledKey) private var overlayLiquidGlassEnabled = false @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceTransparency) private var accessibilityReduceTransparency @EnvironmentObject var notificationStore: TerminalNotificationStore private var isMinimalMode: Bool { WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal } + /// Inverted glass layout: content floats as an inset card (see ContentView). + private var usesCardLayout: Bool { + WindowGlassEffect.isAvailable && !accessibilityReduceTransparency + } + static func panelVisibleInUI( isWorkspaceVisible: Bool, isSelectedInPane: Bool, @@ -389,13 +395,22 @@ struct WorkspaceContentView: View { } Group { - if isMinimalMode && !isFullScreen { + if isMinimalMode && !isFullScreen && !usesCardLayout { bonsplitView .ignoresSafeArea(.container, edges: .top) } else { + // Card layout: the inset card owns its geometry — expanding + // through the top safe area would push the card past the + // window edge. bonsplitView } } + // The card's visible body is bonsplit's pane background; clip it to the + // card shape (portal-hosted surfaces above carry the same radius). + .clipShape(RoundedRectangle( + cornerRadius: usesCardLayout ? WindowGlassEffect.contentCardCornerRadius : 0, + style: .continuous + )) .background( WindowAccessor(dedupeByWindow: false) { window in if #available(macOS 26.0, *) { diff --git a/programaTests/GhosttyConfigTests.swift b/programaTests/GhosttyConfigTests.swift index de719d43..a2349748 100644 --- a/programaTests/GhosttyConfigTests.swift +++ b/programaTests/GhosttyConfigTests.swift @@ -696,58 +696,51 @@ final class WindowTransparencyDecisionTests: XCTestCase { private let sidebarBlendModeKey = "sidebarBlendMode" private let bgGlassEnabledKey = "bgGlassEnabled" - func testTranslucentOpacityForcesClearWindowBackgroundOutsideSidebarBlendModePath() { + func testWindowTransparencyFollowsGlassAvailabilityAndTerminalOpacity() { withTemporaryWindowBackgroundDefaults { let defaults = UserDefaults.standard defaults.set("withinWindow", forKey: sidebarBlendModeKey) defaults.set(false, forKey: bgGlassEnabledKey) - XCTAssertFalse(cmuxShouldUseTransparentBackgroundWindow()) + if WindowGlassEffect.isAvailable { + // Inverted layout: the glass backdrop is stock, and it samples + // behind the window — transparency is on regardless of opacity. + XCTAssertTrue(cmuxShouldUseTransparentBackgroundWindow()) + XCTAssertTrue(cmuxShouldUseClearWindowBackground(for: 1.0)) + } else { + // Pre-26: opt-in off means only a translucent terminal clears it. + XCTAssertFalse(cmuxShouldUseTransparentBackgroundWindow()) + XCTAssertFalse(cmuxShouldUseClearWindowBackground(for: 1.0)) + } XCTAssertTrue(cmuxShouldUseClearWindowBackground(for: 0.80)) - XCTAssertFalse(cmuxShouldUseClearWindowBackground(for: 1.0)) } } - func testGlassEnabledDecisionIsIndependentOfSidebarBlendAndImplementationAvailability() { + func testGlassIsStockWhenAvailableAndOptInOtherwise() { + // Legacy opt-in still decides when the native glass is unavailable. XCTAssertTrue( - cmuxShouldApplyWindowGlass( - sidebarBlendMode: "behindWindow", - bgGlassEnabled: true, - glassEffectAvailable: false - ) + cmuxShouldApplyWindowGlass(bgGlassEnabled: true, glassEffectAvailable: false) ) - XCTAssertTrue( - cmuxShouldApplyWindowGlass( - sidebarBlendMode: "behindWindow", - bgGlassEnabled: true, - glassEffectAvailable: true - ) + XCTAssertFalse( + cmuxShouldApplyWindowGlass(bgGlassEnabled: false, glassEffectAvailable: false) ) + // Inverted layout: glass is the stock treatment whenever available. XCTAssertTrue( - cmuxShouldApplyWindowGlass( - sidebarBlendMode: "withinWindow", - bgGlassEnabled: true, - glassEffectAvailable: true - ) + cmuxShouldApplyWindowGlass(bgGlassEnabled: false, glassEffectAvailable: true) ) - XCTAssertFalse( - cmuxShouldApplyWindowGlass( - sidebarBlendMode: "withinWindow", - bgGlassEnabled: false, - glassEffectAvailable: true - ) + XCTAssertTrue( + cmuxShouldApplyWindowGlass(bgGlassEnabled: true, glassEffectAvailable: true) ) + // The startup performance override wins in both directions. XCTAssertTrue( cmuxShouldApplyWindowGlass( - sidebarBlendMode: "withinWindow", bgGlassEnabled: false, - glassEffectAvailable: true, + glassEffectAvailable: false, performanceOverride: true ) ) XCTAssertFalse( cmuxShouldApplyWindowGlass( - sidebarBlendMode: "behindWindow", bgGlassEnabled: true, glassEffectAvailable: true, performanceOverride: false @@ -794,13 +787,15 @@ final class WindowTransparencyDecisionTests: XCTestCase { ) } - func testTerminalBackgroundOpacityCapsOpaqueFillWhenWindowGlassIsEnabled() { + func testTerminalBackgroundOpacityStaysOpaqueWithWindowGlass() { + // Inverted layout: panes are opaque elevated cards — window glass no + // longer clamps an opaque terminal fill. XCTAssertEqual( ProgramaGlassSettings.effectiveTerminalBackgroundOpacity( configuredOpacity: 1.0, windowGlassEnabled: true ), - 0.82, + 1.0, accuracy: 0.0001 ) } diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index f4452cb8..09e669f6 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -29,7 +29,7 @@ private struct ShortcutHintSizingTestLabel: View { @MainActor final class WindowGlassEffectTests: XCTestCase { - func testRemoveRestoresOriginalContentHierarchy() { + func testBackdropInstallsBelowContentViewAndRemovesCleanly() throws { _ = NSApplication.shared let originalContentView = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 200)) @@ -41,20 +41,30 @@ final class WindowGlassEffectTests: XCTestCase { ) window.contentView = originalContentView + // Track exactly what apply() adds — the theme frame carries its own + // system furniture (titlebar material) that a type check would match. + let themeFrame = try XCTUnwrap(originalContentView.superview) + let preexisting = Set(themeFrame.subviews.map(ObjectIdentifier.init)) + WindowGlassEffect.apply(to: window, tintColor: .systemBlue) - if WindowGlassEffect.isAvailable { - XCTAssertFalse(window.contentView === originalContentView) - XCTAssertTrue(WindowGlassEffect.hostedContentView(in: window.contentView!) === originalContentView) - } else { - XCTAssertTrue(window.contentView === originalContentView) - XCTAssertTrue(originalContentView.subviews.contains(where: { $0 is NSVisualEffectView })) - } + // Inverted layout: the contentView is never replaced; the backdrop is a + // theme-frame sibling BELOW it. + XCTAssertTrue(window.contentView === originalContentView) + let added = themeFrame.subviews.filter { !preexisting.contains(ObjectIdentifier($0)) } + XCTAssertEqual(added.count, 1) + let backdrop = try XCTUnwrap(added.first) + XCTAssertTrue( + WindowGlassEffect.isGlassEffectView(backdrop) || backdrop is NSVisualEffectView + ) + let backdropIndex = try XCTUnwrap(themeFrame.subviews.firstIndex(of: backdrop)) + let contentIndex = try XCTUnwrap(themeFrame.subviews.firstIndex(of: originalContentView)) + XCTAssertLessThan(backdropIndex, contentIndex) WindowGlassEffect.remove(from: window) XCTAssertTrue(window.contentView === originalContentView) - XCTAssertFalse(originalContentView.subviews.contains(where: { $0 is NSVisualEffectView })) + XCTAssertFalse(themeFrame.subviews.contains(where: { $0 === backdrop })) } func testNativePaneChromePillsOwnAppKitControlsAboveTerminalPortal() throws { diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitNodeView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitNodeView.swift index ea098c97..1cce1aaa 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitNodeView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/SplitNodeView.swift @@ -70,6 +70,10 @@ struct SinglePaneWrapper: NSViewRepresentable ) let hostingController = NSHostingController(rootView: paneView) hostingController.view.translatesAutoresizingMaskIntoConstraints = false + // This hosting view lives inside the pane, not under the titlebar; the + // window's titlebar safe area must not inset the tab strip (it read as + // ~24pt of dead space above the tabs in full-size-content windows). + hostingController.safeAreaRegions = [] let containerView = PaneDragContainerView() containerView.wantsLayer = true diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarGlassSurfaces.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarGlassSurfaces.swift index 926e09ed..e3986b33 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarGlassSurfaces.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/TabBarGlassSurfaces.swift @@ -2,8 +2,9 @@ import AppKit import SwiftUI enum TabBarGlassStyling { - static let verticalInset: CGFloat = 4 - static let barHeight: CGFloat = TabBarMetrics.tabHeight + (verticalInset * 2) + // Chrome spacing grid: 8pt above and below the 28pt pill row. + static let verticalInset: CGFloat = 8 + static let barHeight: CGFloat = 28 + (verticalInset * 2) static var isAvailable: Bool { #if compiler(>=6.2)