Files
ccs/macos-bar/Sources/CCSBarApp/WindowAppearanceForcer.swift
Tam Nhu Tran 54a670c943 fix(bar): capture click location synchronously before async panel anchoring
NSEvent.mouseLocation was sampled inside the DispatchQueue.main.async
block that anchors the panel, so a cursor move between the click and
the block run could anchor to the wrong display. The location is now
captured in makeNSView at click time and threaded through apply() into
anchorToClickedScreen, making the chosen screen deterministic.
2026-06-10 13:28:34 -04:00

169 lines
8.0 KiB
Swift

import SwiftUI
import AppKit
import CCSBarCore
/// Zero-size bridge that walks up to the host `NSWindow` and:
/// 1. Forces its `appearance` to match the user's chosen `BarAppearance`.
/// 2. Fixes `collectionBehavior` to `.moveToActiveSpace` so the panel is
/// never visible on multiple Spaces / displays simultaneously (#1503).
/// 3. Anchors the panel to the screen where the user clicked the status item
/// by reading `NSEvent.mouseLocation` at open-time (#1502).
///
/// Why (1) exists on top of `.preferredColorScheme`: that modifier only
/// rewrites the SwiftUI `\.colorScheme` environment for descendant views — it
/// does NOT change the host `NSWindow.effectiveAppearance`. So AppKit-level
/// surfaces keep reading the OS appearance and fight the chosen theme:
/// - system materials (the MenuBarExtra popover's backing material)
/// - semantic colors (`Color.primary` / `.secondary`, used by Chip text,
/// health dots, captions) which invert off the window appearance.
/// Setting `window.appearance` directly fixes the theme at the AppKit layer so
/// the whole surface flips, not just the custom RGB tokens.
///
/// Why (2): the default `NSPanel` created by SwiftUI `MenuBarExtra(.window)`
/// can carry `NSWindow.CollectionBehavior.canJoinAllSpaces`, which makes it
/// appear on every Space and every display simultaneously. Switching to
/// `.moveToActiveSpace` (the correct behavior for a transient panel) removes
/// the multi-display ghost (#1503).
///
/// Why (3): with "Displays have separate Spaces" on, the menu bar icon appears
/// on every display but the panel can open on the wrong screen when its initial
/// frame is computed relative to `NSScreen.main` instead of the clicked screen.
/// `NSEvent.mouseLocation` is captured synchronously at click time (before the
/// async window-materialization hop) to avoid a placement race where a post-click
/// cursor move would anchor the panel to the wrong display.
/// If the panel landed on a different screen than the click we reposition it (#1502).
///
/// Modeled on the proven `ScrollerHider` pattern (which already reaches the host
/// window inside this popover), proving cross-window AppKit access works here.
struct WindowAppearanceForcer: NSViewRepresentable {
let appearance: BarAppearance
func makeNSView(context: Context) -> NSView {
let probe = NSView(frame: .zero)
// Capture the cursor position synchronously at click time (before any async
// dispatch) so the anchoring logic always sees the screen that was clicked,
// not wherever the cursor ended up by the time the async block executes.
let clickLocation = NSEvent.mouseLocation
// Defer window access until the view is in the hierarchy; at make-time
// `view.window` is nil.
DispatchQueue.main.async { apply(to: probe, clickLocation: clickLocation) }
return probe
}
func updateNSView(_ nsView: NSView, context: Context) {
// Re-apply on every update: the popover's NSWindow can be rebuilt on content
// changes, and the appearance pick itself changes mid-session. Screen
// anchoring is skipped on updates (clickLocation: nil) to avoid fighting
// SwiftUI's own layout passes once the panel is already shown.
DispatchQueue.main.async { apply(to: nsView, clickLocation: nil) }
}
/// Apply all window fixes: appearance, collectionBehavior, and (on first open)
/// screen anchoring.
///
/// .system -> nil (follow the OS)
/// .light -> aqua
/// .dark -> darkAqua
///
/// `clickLocation`: the cursor position captured synchronously at click time.
/// Non-nil only on the first open; nil on subsequent updates (anchoring skipped).
private func apply(to view: NSView, clickLocation: NSPoint?) {
guard let window = view.window else { return }
// (1) Appearance.
switch appearance {
case .system:
window.appearance = nil
case .light:
window.appearance = NSAppearance(named: .aqua)
case .dark:
window.appearance = NSAppearance(named: .darkAqua)
}
// (2) Collection behavior — fix #1503.
// Remove canJoinAllSpaces so the panel does not ghost across all displays.
// .moveToActiveSpace is the correct policy for a transient menu bar panel:
// it stays on the Space where it was opened, moves with the user's active
// Space on Space switches, and is never visible on multiple displays at once.
var behavior = window.collectionBehavior
behavior.remove(.canJoinAllSpaces)
behavior.insert(.moveToActiveSpace)
window.collectionBehavior = behavior
// (3) Screen anchoring on first open — fix #1502.
// clickLocation is the cursor position captured synchronously at click time
// (before the async hop). Passing it here avoids the placement race where
// a late-read NSEvent.mouseLocation could reflect a post-click cursor move
// and anchor the panel to the wrong display.
// This is a no-op on a single-display setup or when the panel is already
// on the correct screen.
if let location = clickLocation {
anchorToClickedScreen(window: window, clickLocation: location)
}
}
/// Repositions `window` to the screen that contains `clickLocation` (the
/// cursor position captured synchronously at click time) if that screen
/// differs from the screen the panel currently occupies.
///
/// The y-offset from the top of the screen is preserved so the panel still
/// appears just below the menu bar on the correct display.
private func anchorToClickedScreen(window: NSWindow, clickLocation: NSPoint) {
let mouseGlobal = clickLocation
let screens = NSScreen.screens
guard
let clickedScreen = BarScreenPicker.screen(for: mouseGlobal, in: screens),
let panelScreen = window.screen,
clickedScreen != panelScreen
else { return }
// Compute the distance from the top of the panel's current screen to the
// top of the panel frame. "Top" in macOS global coords = maxY (Y increases
// upward). Preserve this offset when moving to the clicked screen.
let currentMaxY = panelScreen.frame.maxY
let panelFrame = window.frame
let distanceFromTop = currentMaxY - panelFrame.maxY
// Place the panel at the same relative position from the top of the clicked
// screen, horizontally centered on that screen.
let targetMaxY = clickedScreen.frame.maxY - distanceFromTop
let targetX = clickedScreen.frame.midX - panelFrame.width / 2
let targetOrigin = NSPoint(x: targetX, y: targetMaxY - panelFrame.height)
window.setFrameOrigin(targetOrigin)
}
}
/// Pure helper: given a list of screen frames and a point in global coordinates,
/// returns the screen whose frame contains the point. Falls back to the first
/// screen if no screen contains the point (e.g. the cursor is between displays).
///
/// Extracted from `WindowAppearanceForcer` so the decision logic can be
/// unit-tested headlessly in the `ccs-bar-check` assert harness without AppKit.
enum BarScreenPicker {
/// Returns the element of `screens` whose `frame` contains `point`, or `nil`
/// if `screens` is empty. When no screen contains `point` exactly (gap between
/// displays), returns the closest screen by distance to frame center.
static func screen(for point: NSPoint, in screens: [NSScreen]) -> NSScreen? {
guard !screens.isEmpty else { return nil }
// Exact hit first.
if let exact = screens.first(where: { $0.frame.contains(point) }) {
return exact
}
// Fallback: closest by Euclidean distance from frame center (handles gaps).
return screens.min(by: { a, b in
distanceSquared(from: point, to: a.frame) < distanceSquared(from: point, to: b.frame)
})
}
// MARK: - Internal helpers (fileprivate for tests via same module)
/// Squared Euclidean distance from a point to the nearest point on a rect.
/// Zero when the point is inside the rect (handled by the caller first).
static func distanceSquared(from point: NSPoint, to rect: NSRect) -> CGFloat {
let dx = max(rect.minX - point.x, 0, point.x - rect.maxX)
let dy = max(rect.minY - point.y, 0, point.y - rect.maxY)
return dx * dx + dy * dy
}
}