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
13 changes: 13 additions & 0 deletions Cotabby/UI/MenuBarPopoverDismisser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,39 @@ final class MenuBarPopoverDismisser: ObservableObject {
/// real backing window without affecting layout.
struct MenuBarPopoverDismisserBinder: NSViewRepresentable {
let dismisser: MenuBarPopoverDismisser
let onWindowBind: (NSWindow) -> Void

init(
dismisser: MenuBarPopoverDismisser,
onWindowBind: @escaping (NSWindow) -> Void = { _ in }
) {
self.dismisser = dismisser
self.onWindowBind = onWindowBind
}

func makeNSView(context: Context) -> WindowBindingView {
let view = WindowBindingView()
view.dismisser = dismisser
view.onWindowBind = onWindowBind
return view
}

func updateNSView(_ nsView: WindowBindingView, context: Context) {
nsView.dismisser = dismisser
nsView.onWindowBind = onWindowBind
}

final class WindowBindingView: NSView {
weak var dismisser: MenuBarPopoverDismisser?
var onWindowBind: ((NSWindow) -> Void)?

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
// `window` is nil while the popover is being torn down. Skipping the update in that
// case keeps a stale reference from outliving the actual popover instance.
if let window {
dismisser?.hostWindow = window
onWindowBind?(window)
}
}
}
Expand Down
68 changes: 53 additions & 15 deletions Cotabby/UI/MenuBarView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI

/// File overview:
Expand Down Expand Up @@ -46,7 +47,12 @@ struct MenuBarView: View {
runtimeModel.refreshAvailableModels()
}
)
.background(MenuBarPopoverDismisserBinder(dismisser: popoverDismisser))
.background(
MenuBarPopoverDismisserBinder(
dismisser: popoverDismisser,
onWindowBind: configureMenuBarWindowIfNeeded
)
)
.onAppear {
permissionManager.refresh()
runtimeModel.refreshAvailableModels()
Expand Down Expand Up @@ -92,6 +98,12 @@ struct MenuBarView: View {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
}

private func configureMenuBarWindowIfNeeded(_ window: NSWindow) {
if #available(macOS 26.0, *) {
MenuBarWindowChromeConfigurator.configure(window)
}
}

// MARK: - Quick controls

/// Session-level preferences that users reach for mid-work: engine choice,
Expand Down Expand Up @@ -468,11 +480,6 @@ struct MenuBarView: View {
/// `MenuBarExtra`. Keeping this as a dedicated modifier gives the UI a narrow boundary for one
/// platform-specific presentation rule without mixing availability checks into the main view body.
private struct MenuBarWindowBackgroundModifier: ViewModifier {
/// Corner radius of the macOS 26 popover window, measured against the system chrome. The opaque
/// fill is clipped to this so it reaches the rounded edge of the non-opaque window. It is an
/// intentional coupling to a system measurement: if a future macOS changes the popover shape,
/// this is the single value to update (too small leaves a transparent sliver that re-detaches
/// the shadow; too large is harmlessly clipped by the window mask).
private static let macOS26PopoverCornerRadius: CGFloat = 16

@ViewBuilder
Expand All @@ -485,15 +492,21 @@ private struct MenuBarWindowBackgroundModifier: ViewModifier {
// is why #492 (translucent material) recurred as #646 even after #566 swapped the
// material for an opaque color, which the system still re-routed through the backdrop.
//
// Draw the fill as ordinary content instead. A plain `.background` renders as a normal
// opaque layer rather than the glass backdrop, and we clip it to the native popover
// shape (see `macOS26PopoverCornerRadius`) so it covers the whole non-opaque window up
// to the rounded edge. The popup then reads as one solid rounded panel that the system
// shadow can hug, with no desktop bleed-through.
content.background {
RoundedRectangle(cornerRadius: Self.macOS26PopoverCornerRadius, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor))
}
// Draw the panel as ordinary SwiftUI content and make the native host window clear in
// `MenuBarWindowChromeConfigurator`. On macOS 26 the host adds its own rounded frame
// outside the content; keeping both surfaces visible is what creates the double
// outline. By owning the one visible rounded surface here, the menu has a single border
// regardless of how much padding the system host reserves around it.
content
.background {
RoundedRectangle(cornerRadius: Self.macOS26PopoverCornerRadius, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor))
.shadow(color: .black.opacity(0.28), radius: 18, x: 0, y: 8)
}
.overlay {
RoundedRectangle(cornerRadius: Self.macOS26PopoverCornerRadius, style: .continuous)
.stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1)
}
} else if #available(macOS 15.0, *) {
// MenuBarExtra's `.window` style already gives us native rounded window chrome. Place
// the fill at the hosting window instead of this view's local bounds so it reaches the
Expand All @@ -506,3 +519,28 @@ private struct MenuBarWindowBackgroundModifier: ViewModifier {
}
}
}

/// Configures the AppKit window behind `MenuBarExtra(.window)` on macOS 26.
///
/// SwiftUI owns the menu contents, but the double-outline regression lives one layer above SwiftUI:
/// macOS 26 gives the menu popover a larger non-opaque host window than the SwiftUI root view. A
/// normal SwiftUI background can stop at that root view and read as a second rounded panel. This
/// helper clears the actual `NSWindow` and its content view so the SwiftUI panel remains the only
/// visible rounded shape.
@available(macOS 26.0, *)
private enum MenuBarWindowChromeConfigurator {
static func configure(_ window: NSWindow) {
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = false

for backingView in [window.contentView, window.contentView?.superview].compactMap({ $0 }) {
backingView.wantsLayer = true
backingView.layer?.backgroundColor = NSColor.clear.cgColor
backingView.layer?.borderWidth = 0
backingView.layer?.shadowOpacity = 0
backingView.layer?.masksToBounds = false
}
Comment on lines +537 to +543

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Modifying contentView?.superview (NSThemeFrame) is fragile internal API

window.contentView?.superview resolves to AppKit's private NSThemeFrame. While compactMap safely handles a nil superview, setting wantsLayer, borderWidth, shadowOpacity, and masksToBounds directly on that internal view relies on an undocumented, stable view-hierarchy assumption. A future macOS 26 point release could introduce an intermediate wrapper between NSWindow and its contentView, silently bypassing these property writes and re-exposing the double-outline. Consider logging a warning when contentView?.superview is the class name you expect so regressions surface early rather than silently.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

window.invalidateShadow()
}
}
Comment on lines +544 to +546

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 window.invalidateShadow() is a no-op here because window.hasShadow was set to false on the line above — there is no native Cocoa shadow left for the window manager to redraw. The shadow is owned entirely by SwiftUI's .shadow() modifier on the fill rectangle. Remove or replace with an explanatory comment to avoid implying it has an effect.

Suggested change
window.invalidateShadow()
}
}
// Note: invalidateShadow() is intentionally omitted here — hasShadow is false so the
// native window manager shadow is disabled, and the visible shadow is owned by the
// SwiftUI .shadow() modifier on the fill rectangle.
}
}

Fix in Codex Fix in Claude Code

Loading