diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6ec2f062..eaff7ebe 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15,6 +15,48 @@ private enum ProgramaThemeNotifications { static let reloadConfig = Notification.Name("com.darkroom.programa.themes.reload-config") } +/// Association key for retaining `MainWindowToolbarDelegate` on its window -- +/// `NSToolbar.delegate` is weak, so without this the delegate is deallocated +/// immediately and the toolbar silently loses its item provider. +private var mainWindowToolbarDelegateAssociationKey: UInt8 = 0 + +/// Supplies a single invisible spacer item so the main window's otherwise-empty +/// unified toolbar reports a non-zero height, growing the titlebar so AppKit +/// re-centers the traffic lights on `WindowGlassEffect.sidebarHeaderCenterFromWindowTop`. +/// See the call site in `configureMainWindow` for why this is a toolbar item and +/// not an empty toolbar or a `.top` titlebar accessory (both measured as no-ops). +private final class MainWindowToolbarDelegate: NSObject, NSToolbarDelegate { + static let spacerItemIdentifier = NSToolbarItem.Identifier("programa.titlebarSpacer") + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [Self.spacerItemIdentifier] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [Self.spacerItemIdentifier] + } + + func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + guard itemIdentifier == Self.spacerItemIdentifier else { return nil } + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + let spacer = NSView() + spacer.translatesAutoresizingMaskIntoConstraints = false + spacer.widthAnchor.constraint(equalToConstant: 1).isActive = true + spacer.heightAnchor.constraint( + equalToConstant: WindowGlassEffect.mainWindowTitlebarSpacerHeight + ).isActive = true + item.view = spacer + item.isEnabled = false + item.isBordered = false + item.visibilityPriority = .user + return item + } +} + func isCommandPaletteFocusStealingTerminalOrBrowserResponder(_ responder: NSResponder) -> Bool { if responder is GhosttyNSView || responder is WKWebView { return true @@ -9237,6 +9279,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser window.isMovable = false window.styleMask.insert(.fullSizeContentView) + if WindowGlassEffect.isAvailable, window.toolbar == nil { + // A `.top`-attribute NSTitlebarAccessoryViewController and an empty + // NSToolbar were both measured to have no effect on titlebar height + // (confirmed against the AppKit header: `.top` only replaces what's + // drawn within the existing titlebar area, it doesn't grow it; an + // empty toolbar reports zero height). Give the toolbar a single + // invisible spacer item -- the same mechanism sidebar apps (Mail, + // Notes, Finder) use -- so AppKit re-centers the traffic lights on + // WindowGlassEffect.sidebarHeaderCenterFromWindowTop. Guarded on + // `window.toolbar == nil` because this function runs on every + // WindowAccessor update, not just once per window. + let toolbar = NSToolbar(identifier: "programa.main.titlebar") + let toolbarDelegate = MainWindowToolbarDelegate() + objc_setAssociatedObject( + window, + &mainWindowToolbarDelegateAssociationKey, + toolbarDelegate, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + toolbar.delegate = toolbarDelegate + toolbar.allowsUserCustomization = false + toolbar.autosavesConfiguration = false + toolbar.displayMode = .iconOnly + window.toolbar = toolbar + window.toolbarStyle = .unified + window.titlebarSeparatorStyle = .none + } + // Keep content below the titlebar so drags on Bonsplit's tab bar don't // get interpreted as window drags. let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9eb55111..29d150b4 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -947,6 +947,12 @@ struct ContentView: View { ZStack(alignment: .leading) { terminalContentWithSidebarDropOverlay .padding(cardInsetAmount) + // Dead chrome surface: the gap ring around the content card + // (window edges, and the top gap when card-layout+sidebar-visible + // has no customTitlebar). Background sits behind the real + // terminal content, which only occupies the inset interior, so + // AppKit hit-testing falls through to this handle in the ring. + .background(WindowDragHandleView()) .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) if sidebarState.isVisible { sidebarView @@ -962,6 +968,8 @@ struct ContentView: View { } terminalContentWithSidebarDropOverlay .padding(cardInsetAmount) + // See comment in the useWithinWindow branch above. + .background(WindowDragHandleView()) } ) } diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index 4d44db78..867ce665 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -639,6 +639,22 @@ class GhosttyApp { } private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { + // The card layout rounds the terminal card to a corner concentric with + // the window (18pt); ghostty's stock window-padding (2) lets glyphs sit + // inside that curve at the card's bottom corners. Loaded BEFORE the + // user's config files so any user-set window-padding still wins. + // Matches the card layout's own gate: glass available AND Reduce + // Transparency off -- without the rounded card there is no curve to + // clear, so don't spend viewport on padding. + if WindowGlassEffect.isAvailable, + !NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency { + loadInlineGhosttyConfig( + "window-padding-x = 8\nwindow-padding-y = 8", + into: config, + prefix: "programa-card-padding", + logLabel: "card layout padding default" + ) + } ghostty_config_load_default_files(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index be667e2b..ad40c40d 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -1334,8 +1334,15 @@ struct TitlebarLeadingInsetReader: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { DispatchQueue.main.async { guard let window = nsView.window else { return } - // Start past the traffic lights - var leading: CGFloat = 78 + // Start past the traffic lights. Prefer the real zoom-button frame -- + // the unified-toolbar titlebar indents the buttons (zoom right edge + // measured 78.75pt), so a fixed 78 would graze it. The 9pt gap after + // the button matches the Tahoe traffic-light pitch rhythm. + var leading: CGFloat = 88 + if let zoom = window.standardWindowButton(.zoomButton), zoom.superview != nil { + let frameInWindow = zoom.convert(zoom.bounds, to: nil) + leading = max(leading, frameInWindow.maxX + 9) + } // Add width of all left-aligned titlebar accessories for accessory in window.titlebarAccessoryViewControllers where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index fad40e40..d9adba27 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -330,10 +330,19 @@ struct VerticalTabsSidebar: View { .background(Color.clear) .modifier(ClearScrollBackground()) } - SidebarFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - .frame(maxWidth: .infinity, alignment: .leading) - SidebarQuotaFooter() - .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 0) { + SidebarFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + .frame(maxWidth: .infinity, alignment: .leading) + SidebarQuotaFooter() + .frame(maxWidth: .infinity, alignment: .leading) + } + // Clearance from the window's bottom-left corner curve so the last + // footer row doesn't ride the radius. + .padding(.bottom, 6) + // Empty footer space (below/around the help button, feedback row, and + // quota meters) drags the window; the buttons/rows above keep their own + // clicks via the sibling hit-test walk in windowDragHandleShouldCaptureHit. + .background(WindowDragHandleView()) } .accessibilityIdentifier("Sidebar") diff --git a/Sources/WindowChrome.swift b/Sources/WindowChrome.swift index 986e5d36..052056f2 100644 --- a/Sources/WindowChrome.swift +++ b/Sources/WindowChrome.swift @@ -48,9 +48,26 @@ enum WindowGlassEffect { /// Concentric with the window corner at the panel inset. static var sidebarPanelCornerRadius: CGFloat { windowCornerRadius - sidebarPanelInset } /// Height of the sidebar header row shared by the traffic lights and controls. - static let sidebarHeaderHeight: CGFloat = 38 + /// On the glass path this is calibrated to the system traffic-light center + /// under `mainWindowTitlebarSpacerHeight` -- see that constant's doc comment. + /// Off the glass path no spacer toolbar is installed, so the pre-calibration + /// height still applies. + static var sidebarHeaderHeight: CGFloat { isAvailable ? 40 : 38 } /// Vertical midline of the header row measured from the window top. static var sidebarHeaderCenterFromWindowTop: CGFloat { sidebarPanelInset + sidebarHeaderHeight / 2 } + /// Height of the invisible spacer item given to the main window's unified + /// toolbar (`AppDelegate.configureMainWindow` / `MainWindowToolbarDelegate`) + /// so AppKit computes a taller titlebar and re-centers the traffic lights on + /// `sidebarHeaderCenterFromWindowTop`. An empty `NSToolbar` (zero items) and + /// a `.top`-attribute `NSTitlebarAccessoryViewController` were both measured + /// to have no effect on titlebar height -- only a real, sized toolbar item + /// grows it. That item also painted a persistent vertical divider next to + /// the traffic lights until `NSToolbarItem.isBordered = false` was set, + /// which suppresses it with no other visible chrome. Calibrated empirically + /// against the measured system center (measured: spacer height 20 -> + /// traffic-light center 25.75pt, matching the 25pt design target within + /// 0.75pt), not derived from a formula. + static let mainWindowTitlebarSpacerHeight: CGFloat = 20 /// Inverted (Aside-style) layout: the window backdrop is the sidebar's /// material, sampling the desktop and following the system appearance. @@ -67,7 +84,10 @@ enum WindowGlassEffect { } /// Corner radius of the elevated content card (terminal/browser panes). - static let contentCardCornerRadius: CGFloat = 12 + /// Concentric with the window corner at the card inset, same rule as + /// `sidebarPanelCornerRadius` -- a fixed smaller value makes the card's + /// curve visibly diverge from the window's inside the corner gap. + static var contentCardCornerRadius: CGFloat { windowCornerRadius - contentCardInset } /// 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. diff --git a/Sources/WindowSwizzles.swift b/Sources/WindowSwizzles.swift index 09c1dd27..470975d0 100644 --- a/Sources/WindowSwizzles.swift +++ b/Sources/WindowSwizzles.swift @@ -266,6 +266,42 @@ extension NSWindow { programaFirstResponderGuardContextWindowNumber = previousContextWindowNumber } + // The card's dead top-edge sliver (the padding-ring WindowDragHandleView + // mount in ContentView.swift doesn't fully cover it -- AppKit's hit-test + // bottoms out at the bare content host there) has no native drag: + // isMovable=false and isMovableByWindowBackground=false disable AppKit's + // own titlebar drag globally. Handle it here for genuine unclaimed + // background only -- real controls (traffic lights, titlebar accessory + // buttons) and real content (terminal surfaces, bonsplit's tab bar, which + // are portal-hosted outside contentView's subtree by design) keep their + // own gestures -- and folder-icon drag suppression keeps priority over + // this (checked first, matching the guard below). + if event.type == .leftMouseDown, + !shouldSuppressWindowMoveForFolderDrag(window: self, event: event), + let hitView = programaFirstResponderGuardHitViewContext, + Self.programaIsTitlebarBackgroundDragTarget(hitView, in: self) { + #if DEBUG + dlog( + "titlebar.chromeDrag start clickCount=\(event.clickCount) " + + "hit=\(type(of: hitView))" + ) + #endif + if event.clickCount >= 2 { + let action = performStandardTitlebarDoubleClick(window: self) + #if DEBUG + dlog("titlebar.chromeDrag doubleClick action=\(String(describing: action))") + #endif + } else { + withTemporaryWindowMovableEnabled(window: self) { + self.performDrag(with: event) + } + #if DEBUG + dlog("titlebar.chromeDrag dragComplete nowMovable=\(self.isMovable)") + #endif + } + return + } + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), let contentView = self.contentView else { #if DEBUG @@ -653,6 +689,57 @@ extension NSWindow { return programaTopHitViewForEvent(in: window, event: event) } + /// True when `hitView` is exactly `window.contentView` (its root SwiftUI host) -- + /// what AppKit's hit-test falls back to when nothing more specific (no SwiftUI + /// view, no WindowDragHandleView mount) claims the point, observed for the top + /// gap ring where the padding-ring mount's own frame doesn't extend. + /// + /// Deliberately NOT "walk up and see if we ever pass through contentView": + /// portal-hosted content (GhosttyNSView terminal surfaces, browser WKWebViews) + /// is mounted as a theme-frame sibling outside contentView's subtree by design + /// (see WindowTerminalHostView / the terminal find layering contract in + /// CLAUDE.md), so that walk classified live terminal content as dead chrome -- + /// caught by the card-center negative test, which must never regress. + private static func programaIsTitlebarChromeHit(_ hitView: NSView, contentView: NSView) -> Bool { + hitView === contentView + } + + private static func programaIsControlOrControlDescendant(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if candidate is NSControl { + return true + } + current = candidate.superview + } + return false + } + + /// True when `hitView` is owned by a `TitlebarControlsAccessoryViewController` + /// (the help/notifications/new-tab cluster) installed on `window`, so its own + /// gestures (including non-`NSControl` hover chrome inside it) are preserved. + private static func programaIsWithinTitlebarControlsAccessory(_ hitView: NSView, in window: NSWindow) -> Bool { + for controller in window.titlebarAccessoryViewControllers { + guard controller is TitlebarControlsAccessoryViewController else { continue } + if hitView === controller.view || hitView.isDescendant(of: controller.view) { + return true + } + } + return false + } + + /// True when a `leftMouseDown` hit on `hitView` should start a titlebar-style + /// window drag (or, on double-click, the standard zoom): unclaimed background + /// (see `programaIsTitlebarChromeHit`), and not a real control or the titlebar + /// controls accessory cluster. + private static func programaIsTitlebarBackgroundDragTarget(_ hitView: NSView, in window: NSWindow) -> Bool { + guard let contentView = window.contentView else { return false } + guard programaIsTitlebarChromeHit(hitView, contentView: contentView) else { return false } + if programaIsControlOrControlDescendant(hitView) { return false } + if programaIsWithinTitlebarControlsAccessory(hitView, in: window) { return false } + return true + } + private static func programaHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { #if DEBUG if let override = programaFirstResponderGuardHitViewOverride {