mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
fix(bar-app): settings as standalone window, real theme forcing, roomier layout
Move Settings out of the menu-bar popover into a standalone resizable AppKit window so clicking it no longer steals focus and dismisses the bar (the .sheet inside MenuBarExtra(.window) was the root cause). Force the actual NSWindow appearance (aqua/darkAqua/system) on both the popover and the settings window so Light/Dark visibly flips materials and semantic colors, not just custom tokens. Replace the fragile popover quit dialog with an inline two-step arm/confirm. Widen the dropdown and increase spacing/type for readability. Fill the settings window responsively and make Done close it via the window controller (dismiss() is a no-op in a hosted NSWindow).
This commit is contained in:
@@ -36,7 +36,7 @@ struct BarAnalyticsView: View {
|
||||
|
||||
/// By-surface + top-models detail, tightened and subordinate.
|
||||
@ViewBuilder private var breakdown: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Surface breakdown: "how much Claude Code vs Codex" — only shown when
|
||||
// the backend supplies at least one surface entry. Top 5 keeps it compact.
|
||||
if !analytics.bySurface.isEmpty {
|
||||
@@ -67,7 +67,7 @@ struct BarAnalyticsView: View {
|
||||
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
|
||||
/// line, and a thin inline 30-day sparkline when there is real spend.
|
||||
private var spendStrip: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
SectionLabel("Spend")
|
||||
if analytics.hasRecentData {
|
||||
Text(spendCaption)
|
||||
@@ -75,7 +75,7 @@ struct BarAnalyticsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
if !sparklineIsEmpty {
|
||||
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent)
|
||||
.frame(height: 16)
|
||||
.frame(height: 18)
|
||||
}
|
||||
} else {
|
||||
Text(idleCaption)
|
||||
@@ -134,10 +134,10 @@ private struct SurfaceBar: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
.frame(height: 26)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,10 +164,10 @@ private struct ModelBar: View {
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
.frame(height: 26)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ struct SectionLabel: View {
|
||||
init(_ text: String) { self.text = text }
|
||||
var body: some View {
|
||||
Text(text.uppercased())
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import CCSBarCore
|
||||
/// footer controls.
|
||||
struct BarMenuView: View {
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
/// Drives the preferences sheet from the footer gear.
|
||||
@State private var showingPrefs = false
|
||||
@State private var confirmingQuit = false
|
||||
/// The prefs adapter the sheet edits; shares the standard suite with the
|
||||
/// view model so a write-through is visible on the next poll.
|
||||
private let prefs = BarPreferences()
|
||||
/// Resolved theme injected by ThemedRoot — used to tint the armed Quit control
|
||||
/// with the themed red ramp so it matches the dropdown on both plates.
|
||||
@Environment(\.barTheme) private var theme
|
||||
/// Two-step inline quit confirm. First footer-Quit click arms it (icon swaps
|
||||
/// hollow->filled, tints red); second click terminates. Reset on every popover
|
||||
/// open via .onAppear so a stale armed state never carries across sessions —
|
||||
/// no modal, no .confirmationDialog (those steal focus and dismiss the popover,
|
||||
/// the exact fragility of BUG 1).
|
||||
@State private var quitArmed = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -30,12 +33,12 @@ struct BarMenuView: View {
|
||||
// for the common 1-4 subscription setup; the scroll only engages for
|
||||
// genuine pool/model overflow.
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// (1) ALERTS first — urgent quota crossings surface above everything.
|
||||
// Spend-cap alerts are opt-in OFF by default, so by default only
|
||||
// quota/reauth/cooldown conditions appear here.
|
||||
if !viewModel.activeAlerts.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel("Alerts")
|
||||
ForEach(viewModel.activeAlerts) { alert in
|
||||
AlertRow(alert: alert)
|
||||
@@ -66,22 +69,24 @@ struct BarMenuView: View {
|
||||
// scroller at runtime (belt-and-suspenders with .scrollIndicators).
|
||||
ScrollerHider().frame(width: 0, height: 0)
|
||||
}
|
||||
.padding(12)
|
||||
.padding(14)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
// 580 fits the full reordered layout (subscription cards + spend strip +
|
||||
// pool rows + surface/models) without wasted whitespace for a typical
|
||||
// 1-4 account setup. Scroll engages gracefully only on real overflow.
|
||||
.frame(maxHeight: 580)
|
||||
// 620 fits the full reordered layout (subscription cards + spend strip +
|
||||
// pool rows + surface/models) plus the added vertical breathing room
|
||||
// before scroll engages, for a typical 1-4 account setup. Scroll engages
|
||||
// gracefully only on real overflow.
|
||||
.frame(maxHeight: 620)
|
||||
}
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 340)
|
||||
.onAppear { viewModel.onOpen() }
|
||||
.sheet(isPresented: $showingPrefs) {
|
||||
BarPreferencesView(viewModel: viewModel, prefs: prefs)
|
||||
.frame(width: 380)
|
||||
.onAppear {
|
||||
viewModel.onOpen()
|
||||
// Disarm quit on every popover open so a stale armed state never persists.
|
||||
quitArmed = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +98,7 @@ struct BarMenuView: View {
|
||||
/// present, preserving the single "Accounts" header for a CLIProxy-only setup.
|
||||
@ViewBuilder private var accountsSection: some View {
|
||||
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let error = viewModel.lastError {
|
||||
ErrorBanner(message: error)
|
||||
}
|
||||
@@ -124,7 +129,7 @@ struct BarMenuView: View {
|
||||
@ViewBuilder private var poolSection: some View {
|
||||
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
|
||||
if !parts.subscriptions.isEmpty && !parts.pool.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel("Pool accounts")
|
||||
ForEach(parts.pool) { row in
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
@@ -199,7 +204,7 @@ struct BarMenuView: View {
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
openDashboard()
|
||||
} label: {
|
||||
@@ -215,7 +220,11 @@ struct BarMenuView: View {
|
||||
}
|
||||
.help("Toggle the menu-bar icon between color and monochrome (does not change the bar theme)")
|
||||
Button {
|
||||
showingPrefs = true
|
||||
// Open Settings as a standalone AppKit NSWindow (NOT a .sheet on this
|
||||
// popover). A sheet hosted in a .window-style MenuBarExtra popover pulls
|
||||
// focus off the popover and auto-dismisses the whole bar (BUG 1). The
|
||||
// window opens beside the popover and leaves it untouched.
|
||||
SettingsWindowController.shared.show(viewModel: viewModel)
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
@@ -227,25 +236,38 @@ struct BarMenuView: View {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh")
|
||||
// Quit confirms first — a stray click here previously closed the whole
|
||||
// app (removing the menu-bar item) with no easy way back.
|
||||
Button {
|
||||
confirmingQuit = true
|
||||
} label: {
|
||||
Image(systemName: "power")
|
||||
}
|
||||
.help("Quit CCS Bar")
|
||||
.confirmationDialog("Quit CCS Bar?", isPresented: $confirmingQuit) {
|
||||
Button("Quit", role: .destructive) { NSApplication.shared.terminate(nil) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This closes the menu-bar app. Reopen it from Applications.")
|
||||
}
|
||||
// Quit confirms via a two-step INLINE arm/confirm — no modal, no sheet, no
|
||||
// .confirmationDialog. Those all steal focus and auto-dismiss the popover
|
||||
// (the exact fragility of BUG 1). A stray single click can no longer kill
|
||||
// the app: the first click only arms; the popover stays open and responsive.
|
||||
quitButton
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
|
||||
/// Two visual states in one footer slot. Disarmed: hollow power icon that arms
|
||||
/// on click. Armed: filled power icon tinted themed red that terminates on
|
||||
/// click. Reopening the popover disarms it (.onAppear on the root VStack).
|
||||
@ViewBuilder private var quitButton: some View {
|
||||
if !quitArmed {
|
||||
Button {
|
||||
quitArmed = true
|
||||
} label: {
|
||||
Image(systemName: "power")
|
||||
}
|
||||
.help("Quit CCS Bar (click again to confirm)")
|
||||
} else {
|
||||
Button {
|
||||
NSApplication.shared.terminate(nil)
|
||||
} label: {
|
||||
Image(systemName: "power.circle.fill")
|
||||
}
|
||||
.help("Click to confirm quit")
|
||||
.foregroundStyle(theme.bandRed)
|
||||
}
|
||||
}
|
||||
|
||||
private func openDashboard() {
|
||||
@@ -273,14 +295,14 @@ struct BarRowView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 9) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 7) {
|
||||
Text(row.displayName ?? row.accountId)
|
||||
.font(.system(.body, design: .default).weight(.medium))
|
||||
.lineLimit(1)
|
||||
@@ -327,8 +349,8 @@ struct BarRowView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
@@ -433,7 +455,7 @@ struct QuotaGaugeView: View {
|
||||
.frame(width: max(2, geo.size.width * fill))
|
||||
}
|
||||
}
|
||||
.frame(width: 44, height: 5)
|
||||
.frame(width: 54, height: 6)
|
||||
}
|
||||
|
||||
private func color(for band: BarQuotaGauge.Band) -> Color {
|
||||
@@ -543,7 +565,7 @@ struct Chip: View {
|
||||
}
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 9.5, weight: .semibold))
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1.5)
|
||||
.background(tint.opacity(0.22), in: Capsule())
|
||||
|
||||
@@ -8,7 +8,6 @@ import CCSBarCore
|
||||
struct BarPreferencesView: View {
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
let prefs: BarPreferences
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.barTheme) private var theme
|
||||
|
||||
// Local editable mirror of the persisted prefs. Loaded on appear; each change
|
||||
@@ -34,7 +33,10 @@ struct BarPreferencesView: View {
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 360, height: 460)
|
||||
// Fill the hosting window responsively (it is a real resizable NSWindow now,
|
||||
// not a fixed 360x460 sheet) so there are no dead margins and resizing works.
|
||||
.frame(minWidth: 420, idealWidth: 460, maxWidth: .infinity,
|
||||
minHeight: 520, idealHeight: 600, maxHeight: .infinity)
|
||||
.onAppear(perform: hydrate)
|
||||
}
|
||||
|
||||
@@ -156,7 +158,7 @@ struct BarPreferencesView: View {
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done") { commitLevels(); dismiss() }
|
||||
Button("Done") { commitLevels(); SettingsWindowController.shared.close() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
|
||||
@@ -33,7 +33,7 @@ struct BarSubscriptionCard: View {
|
||||
staleFootnote
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
.padding(.vertical, 11)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
@@ -46,7 +46,7 @@ struct BarSubscriptionCard: View {
|
||||
/// Health dot + product name + reauth chip + tier chip. No pause toggle —
|
||||
/// subscriptions are not routable pool accounts.
|
||||
private var titleRow: some View {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
@@ -71,7 +71,7 @@ struct BarSubscriptionCard: View {
|
||||
let ordered = orderedWindows
|
||||
let bindingKey = binding?.key
|
||||
|
||||
return VStack(alignment: .leading, spacing: 3) {
|
||||
return VStack(alignment: .leading, spacing: 5) {
|
||||
ForEach(ordered) { w in
|
||||
windowBarRow(w, isBinding: w.key == bindingKey)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ struct BarSubscriptionCard: View {
|
||||
? .system(.caption2, design: .monospaced).weight(.semibold)
|
||||
: .system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(isBinding ? .primary : .secondary)
|
||||
.frame(width: 28, alignment: .leading)
|
||||
.frame(width: 32, alignment: .leading)
|
||||
|
||||
// Horizontal fill bar — wider than the old secondary thinBar so fine
|
||||
// gradations are visible. Remaining fraction fills from the left so a
|
||||
@@ -127,9 +127,9 @@ struct BarSubscriptionCard: View {
|
||||
Capsule().fill(Color.primary.opacity(isBinding ? 0.14 : 0.09))
|
||||
Capsule()
|
||||
.fill(barColor)
|
||||
.frame(width: max(2, 88 * fill))
|
||||
.frame(width: max(2, 110 * fill))
|
||||
}
|
||||
.frame(width: 88, height: isBinding ? 6 : 4)
|
||||
.frame(width: 110, height: isBinding ? 7 : 5)
|
||||
|
||||
Spacer(minLength: 5)
|
||||
|
||||
@@ -137,7 +137,7 @@ struct BarSubscriptionCard: View {
|
||||
Text("\(Int(w.remainingPercent.rounded()))%")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(isBinding ? barColor : .secondary)
|
||||
.frame(width: 28, alignment: .trailing)
|
||||
.frame(width: 32, alignment: .trailing)
|
||||
|
||||
Spacer(minLength: 5)
|
||||
|
||||
@@ -188,7 +188,7 @@ struct BarSubscriptionCard: View {
|
||||
.foregroundStyle(isBinding ? .secondary : .tertiary)
|
||||
}
|
||||
}
|
||||
.frame(width: 42, alignment: .trailing)
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
|
||||
/// Extract the "~Th Mm" part from paceClause for the at-risk inline warning.
|
||||
|
||||
@@ -43,8 +43,15 @@ struct ThemedRoot<Content: View>: View {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
// Order matters: .preferredColorScheme first updates the SwiftUI \.colorScheme
|
||||
// environment for descendants (so the token resolver + Color.primary/.secondary
|
||||
// pick up the chosen scheme), THEN the .background WindowAppearanceForcer sets
|
||||
// the actual host NSWindow.appearance so system materials + semantic-color
|
||||
// inversions flip at the AppKit layer too — not just the custom RGB tokens.
|
||||
// The two are complementary: env tokens + real window appearance. KEEP both.
|
||||
ResolvedThemeHost(content: content)
|
||||
.preferredColorScheme(appearance.forced)
|
||||
.background(WindowAppearanceForcer(appearance: appearance))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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
|
||||
window.center()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import CCSBarCore
|
||||
|
||||
/// Zero-size bridge that walks up to the host `NSWindow` and forces its
|
||||
/// `appearance` to match the user's chosen `BarAppearance`.
|
||||
///
|
||||
/// Why this 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.
|
||||
///
|
||||
/// 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)
|
||||
// Defer until the view is in the hierarchy; at make-time `view.window` is nil.
|
||||
DispatchQueue.main.async { apply(to: probe) }
|
||||
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.
|
||||
DispatchQueue.main.async { apply(to: nsView) }
|
||||
}
|
||||
|
||||
/// Force the host window's appearance from the chosen theme.
|
||||
/// .system -> nil (follow the OS)
|
||||
/// .light -> aqua
|
||||
/// .dark -> darkAqua
|
||||
private func apply(to view: NSView) {
|
||||
guard let window = view.window else { return }
|
||||
switch appearance {
|
||||
case .system:
|
||||
window.appearance = nil
|
||||
case .light:
|
||||
window.appearance = NSAppearance(named: .aqua)
|
||||
case .dark:
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user