Files
ccs/macos-bar/Sources/CCSBarApp/SettingsWindowController.swift
T
Tam Nhu Tran 70dc53d947 fix(bar): keep menu bar panel on the clicked screen in multi-display setups
The MenuBarExtra panel's default collectionBehavior included
canJoinAllSpaces, so the bar could be visible on every Space and
display at once. The WindowAppearanceForcer bridge now strips
canJoinAllSpaces and applies moveToActiveSpace.

Panel and Settings window placement also keyed off the key-window
screen (NSScreen.main / window.center()) rather than the display where
the status item was clicked. Both now anchor to the screen under the
cursor at click time via the pure BarScreenPicker helper, covered by
new ccs-bar-check geometry assertions.

Closes #1502
Closes #1503
2026-06-10 13:23:47 -04:00

152 lines
7.0 KiB
Swift

import SwiftUI
import AppKit
import CCSBarCore
/// Opens the CCS Bar settings as a standalone AppKit `NSWindow`.
///
/// MECHANISM: a singleton `NSWindowController`-style driver backed by a real
/// `NSWindow` + `NSWindowDelegate`, NOT the SwiftUI `Window` scene. AppKit is the
/// only path that gives deterministic control of the three things a
/// MenuBarExtra-only (no dock icon) app needs:
/// (a) forcing `window.appearance` so the theme flips at the AppKit layer,
/// (b) restoring `.accessory` activation policy on close (drop the dock icon),
/// (c) singleton reuse so a second Settings click focuses the existing window
/// instead of spawning a duplicate.
/// The SwiftUI `Window` scene leaves the app stuck in `.regular` with a lingering
/// dock icon and version-dependent focus behavior, so it is deliberately avoided.
///
/// Crucially, opening this window does NOT touch the MenuBarExtra popover's own
/// NSWindow (a separate AppKit window), so the popover stays open and responsive
/// — the exact cross-window isolation `ScrollerHider` already proves works here.
/// This is what fixes BUG 1: the old `.sheet` presented inside the `.window`
/// popover stole focus and auto-dismissed the whole bar.
@MainActor
final class SettingsWindowController {
/// One window, max. A second show() reuses it rather than spawning a duplicate.
static let shared = SettingsWindowController()
private var window: NSWindow?
/// Retained so the delegate isn't deallocated while the window lives.
private var delegate: SettingsWindowDelegate?
private init() {}
/// Show (or re-focus) the settings window, hosting the LIVE view model so the
/// appearance picker drives both this window and the menu-bar popover.
func show(viewModel: BarViewModel) {
if let existing = window {
// Reuse: bring the single window to front instead of opening another.
existing.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
// SwiftUI root: the SAME view model + the SAME ThemedRoot pipeline as the
// popover, so the settings window themes identically and live-syncs.
let root = SettingsWindowRoot(viewModel: viewModel)
let hosting = NSHostingController(rootView: root)
let window = NSWindow(contentViewController: hosting)
window.title = "CCS Bar Settings"
// Titled + closable only: it's a settings dialog, not a document window, so
// no miniaturize/zoom. Resizable (no .nonResizable) per the spec.
window.styleMask = [.titled, .closable, .resizable]
window.setContentSize(NSSize(width: 460, height: 600))
window.minSize = NSSize(width: 420, height: 520)
// Reuse the instance on reopen instead of tearing it down on close.
window.isReleasedWhenClosed = false
// Center on the screen that contains the mouse cursor (= the clicked screen)
// rather than NSScreen.main (which is the screen with the key window and may
// differ from the display where the menu bar icon was clicked). Falls back to
// the primary screen via NSWindow.center() when no match is found.
centerOnClickedScreen(window)
let delegate = SettingsWindowDelegate(onClose: { [weak self] in self?.handleClose() })
window.delegate = delegate
self.delegate = delegate
self.window = window
// Accessory (menu-bar-only) apps can't take key focus; upgrade to .regular
// so the window focuses and shows in the app switcher while it's open.
NSApp.setActivationPolicy(.regular)
window.makeKeyAndOrderFront(nil)
// Force front even when invoked from another frontmost app.
NSApp.activate(ignoringOtherApps: true)
}
/// Close the window programmatically (e.g. the Done button). Routes through
/// `performClose` so the delegate restores `.accessory` mode exactly like the
/// title-bar close button — `@Environment(\.dismiss)` is a no-op in a plain
/// NSHostingController window, so this is the reliable path.
func close() { window?.performClose(nil) }
/// Delegate callback on window close: drop the dock icon back so we return to
/// menu-bar-only mode. The popover is untouched throughout. The window itself
/// is kept (isReleasedWhenClosed = false) for cheap reuse, but we clear our
/// reference so the next show() rebuilds a fresh, correctly-centered window.
private func handleClose() {
NSApp.setActivationPolicy(.accessory)
window = nil
delegate = nil
}
/// Centers `window` on the screen that contains the mouse cursor at call-time
/// (i.e. the display where the user clicked the settings button). This ensures
/// the settings window opens on the same display as the menu bar icon that was
/// clicked, rather than defaulting to NSScreen.main (the primary / key-window
/// screen, which may be a different monitor in a multi-display setup).
///
/// Falls back to `window.center()` (primary screen, macOS default) when no
/// candidate screen can be determined.
private func centerOnClickedScreen(_ window: NSWindow) {
let mouseGlobal = NSEvent.mouseLocation
let screens = NSScreen.screens
guard let target = BarScreenPicker.screen(for: mouseGlobal, in: screens) else {
window.center()
return
}
let visibleFrame = target.visibleFrame
let windowSize = window.frame.size
let x = visibleFrame.midX - windowSize.width / 2
let y = visibleFrame.midY - windowSize.height / 2
window.setFrameOrigin(NSPoint(
x: max(visibleFrame.minX, min(x, visibleFrame.maxX - windowSize.width)),
y: max(visibleFrame.minY, min(y, visibleFrame.maxY - windowSize.height))
))
}
}
/// Bridges `NSWindow` close back to the controller so it can restore the
/// `.accessory` activation policy (menu-bar-only mode).
final class SettingsWindowDelegate: NSObject, NSWindowDelegate {
private let onClose: () -> Void
init(onClose: @escaping () -> Void) {
self.onClose = onClose
}
func windowWillClose(_ notification: Notification) {
onClose()
}
}
/// SwiftUI root hosted inside the settings `NSWindow`. Wraps the reused
/// `BarPreferencesView` in the SAME `ThemedRoot` token pipeline as the popover,
/// so the settings window themes identically. Observing the shared
/// `BarViewModel` means a theme pick here re-renders BOTH windows live.
struct SettingsWindowRoot: View {
@ObservedObject var viewModel: BarViewModel
/// The prefs adapter the view edits; shares the standard suite with the view
/// model so a write-through is visible on the next poll (same as the popover).
private let prefs = BarPreferences()
var body: some View {
// ThemedRoot applies .preferredColorScheme + injects tokens, and its
// .background WindowAppearanceForcer forces THIS NSWindow's appearance so
// system materials + semantic colors flip too — single source of truth with
// the popover. Fill the window so the plate covers the full content area.
ThemedRoot(appearance: viewModel.appearance) {
BarPreferencesView(viewModel: viewModel, prefs: prefs)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}