Files
steipete--codexbar/Sources/CodexBar/PreferencesView.swift
T
2026-07-13 12:22:33 +08:00

329 lines
11 KiB
Swift

import AppKit
import CodexBarCore
import SwiftUI
/// Sidebar destinations of the settings window: fixed app panes plus one entry per provider.
enum SettingsPane: Hashable {
case general
case notifications
case menuBar
case menu
case advanced
case about
case debug
case provider(UsageProvider)
static let windowWidth: CGFloat = 880
static let windowHeight: CGFloat = 620
static let windowMinWidth: CGFloat = 800
static let windowMinHeight: CGFloat = 540
static let sidebarWidth: CGFloat = 260
static let detailMaxWidth: CGFloat = 780
var title: String {
switch self {
case .general: L("tab_general")
case .notifications: L("tab_notifications")
case .menuBar: L("tab_menu_bar")
case .menu: L("tab_menu")
case .advanced: L("tab_advanced")
case .about: L("tab_about")
case .debug: L("tab_debug")
case let .provider(provider):
ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
}
}
}
@MainActor
struct PreferencesView: View {
@Bindable var settings: SettingsStore
@Bindable var store: UsageStore
let updater: UpdaterProviding
@Bindable var selection: PreferencesSelection
let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator
let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator
let runProviderLoginFlow: @MainActor (UsageProvider) async -> Void
@Environment(\.colorScheme) private var colorScheme
init(
settings: SettingsStore,
store: UsageStore,
updater: UpdaterProviding,
selection: PreferencesSelection,
managedCodexAccountCoordinator: ManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator(),
codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil,
runProviderLoginFlow: @escaping @MainActor (UsageProvider) async -> Void = { _ in })
{
self.settings = settings
self.store = store
self.updater = updater
self.selection = selection
self.managedCodexAccountCoordinator = managedCodexAccountCoordinator
self.codexAccountPromotionCoordinator = codexAccountPromotionCoordinator
?? CodexAccountPromotionCoordinator(
settingsStore: settings,
usageStore: store,
managedAccountCoordinator: managedCodexAccountCoordinator)
self.runProviderLoginFlow = runProviderLoginFlow
}
var body: some View {
HStack(spacing: 0) {
// Golden Gate-style sidebar: edge-to-edge material with a hairline separator,
// no floating card chrome. The material ignores the safe area so it runs up
// behind the transparent titlebar.
SettingsSidebarView(settings: self.settings, store: self.store, selection: self.$selection.pane)
.frame(width: SettingsPane.sidebarWidth)
.background {
SettingsSidebarMaterial()
.ignoresSafeArea()
}
Divider()
.ignoresSafeArea()
self.detailView
.frame(
maxWidth: SettingsPane.detailMaxWidth,
maxHeight: .infinity,
alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.frame(
minWidth: SettingsPane.windowMinWidth,
idealWidth: SettingsPane.windowWidth,
maxWidth: .infinity,
minHeight: SettingsPane.windowMinHeight,
idealHeight: SettingsPane.windowHeight,
maxHeight: .infinity)
.id(self.settings.appLanguage)
.background {
SettingsWindowAppearanceBridge(colorScheme: self.colorScheme, windowTitle: self.selection.pane.title)
.allowsHitTesting(false)
}
.onAppear {
self.ensureValidSelection()
}
.onChange(of: self.settings.debugMenuEnabled) { _, _ in
self.ensureValidSelection()
}
}
@ViewBuilder
private var detailView: some View {
switch self.selection.pane {
case .general:
GeneralPane(settings: self.settings)
case .notifications:
NotificationsPane(settings: self.settings)
case .menuBar:
MenuBarPane(settings: self.settings, store: self.store)
case .menu:
MenuPane(settings: self.settings, store: self.store)
case .advanced:
AdvancedPane(settings: self.settings, store: self.store)
case .about:
AboutPane(updater: self.updater)
case .debug:
DebugPane(settings: self.settings, store: self.store)
case let .provider(provider):
ProvidersPane(
provider: provider,
settings: self.settings,
store: self.store,
managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator,
runProviderLoginFlow: self.runProviderLoginFlow)
.id(provider)
}
}
private func ensureValidSelection() {
if !self.settings.debugMenuEnabled, self.selection.pane == .debug {
self.selection.pane = .general
}
}
}
@MainActor
enum SettingsWindowSizing {
static func enforceMinimumSize(_ window: NSWindow) {
let toolbarHeight = max(0, window.frame.height - window.contentLayoutRect.height)
let minimumSize = NSSize(
width: SettingsPane.windowMinWidth,
height: SettingsPane.windowMinHeight + toolbarHeight)
window.minSize = minimumSize
if window.frame.width < minimumSize.width || window.frame.height < minimumSize.height {
var frame = window.frame
let repairedSize = NSSize(
width: max(frame.width, minimumSize.width),
height: max(frame.height, minimumSize.height))
frame.origin.y += frame.height - repairedSize.height
frame.size = repairedSize
window.setFrame(frame, display: true)
}
}
}
@MainActor
enum SettingsWindowAppearance {
typealias ResetAction = @MainActor @Sendable () -> Void
typealias ResetScheduler = @MainActor @Sendable (@escaping ResetAction) -> Void
static func refresh(
_ window: NSWindow,
application: NSApplication = NSApp,
scheduleReset: ResetScheduler = Self.scheduleReset)
{
SettingsWindowSizing.enforceMinimumSize(window)
window.appearanceSource = application
// Pulse the exact effective appearance so the native toolbar redraws without
// dropping inherited accessibility attributes, then restore KVO inheritance.
window.appearance = application.effectiveAppearance
scheduleReset { [weak window] in
if let window {
SettingsWindowSizing.enforceMinimumSize(window)
}
window?.appearance = nil
window?.viewsNeedDisplay = true
}
}
static func scheduleReset(_ action: @escaping ResetAction) {
Task { @MainActor in
await Task.yield()
action()
}
}
}
@MainActor
struct SettingsWindowAppearanceBridge: NSViewRepresentable {
let colorScheme: ColorScheme
let windowTitle: String
func makeNSView(context: Context) -> SettingsWindowAppearanceView {
SettingsWindowAppearanceView()
}
func updateNSView(_ nsView: SettingsWindowAppearanceView, context: Context) {
nsView.refreshWindowAppearance(for: self.colorScheme, windowTitle: self.windowTitle)
}
}
@MainActor
final class SettingsWindowAppearanceView: NSView {
private let scheduleReset: SettingsWindowAppearance.ResetScheduler
private var colorScheme: ColorScheme?
private var windowTitle: String?
init(scheduleReset: @escaping SettingsWindowAppearance.ResetScheduler = SettingsWindowAppearance.scheduleReset) {
self.scheduleReset = scheduleReset
super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
NotificationCenter.default.removeObserver(self, name: NSWindow.didUpdateNotification, object: nil)
if let window {
NotificationCenter.default.addObserver(
self,
selector: #selector(self.windowDidUpdate(_:)),
name: NSWindow.didUpdateNotification,
object: window)
}
self.configureWindowStyle()
self.refreshWindowAppearance()
}
@objc private func windowDidUpdate(_ notification: Notification) {
self.configureWindowStyle()
}
func refreshWindowAppearance(for colorScheme: ColorScheme, windowTitle: String? = nil) {
let colorSchemeChanged = self.colorScheme != colorScheme
let windowTitleChanged = self.windowTitle != windowTitle
guard colorSchemeChanged || windowTitleChanged else { return }
self.colorScheme = colorScheme
self.windowTitle = windowTitle
guard let window else { return }
self.configureWindowStyle()
if windowTitleChanged, let windowTitle {
window.title = windowTitle
}
if colorSchemeChanged {
SettingsWindowAppearance.refresh(window, scheduleReset: self.scheduleReset)
}
}
private func refreshWindowAppearance() {
guard let window else { return }
self.configureWindowStyle()
if let windowTitle {
window.title = windowTitle
}
SettingsWindowAppearance.refresh(window, scheduleReset: self.scheduleReset)
}
override func layout() {
super.layout()
self.configureWindowStyle()
}
private func configureWindowStyle() {
guard let window else { return }
if !window.styleMask.contains(.resizable) {
window.styleMask.insert(.resizable)
}
if !window.titlebarAppearsTransparent {
window.titlebarAppearsTransparent = true
}
if window.titleVisibility != .visible {
window.titleVisibility = .visible
}
if window.titlebarSeparatorStyle != .none {
window.titlebarSeparatorStyle = .none
}
if window.toolbar != nil {
window.toolbar = nil
}
// Full-size content lets the sidebar material extend behind the titlebar so the
// edge-to-edge sidebar reaches the top of the window; content stays below the
// titlebar via the safe area.
if !window.styleMask.contains(.fullSizeContentView) {
window.styleMask.insert(.fullSizeContentView)
}
}
}
@MainActor
private struct SettingsSidebarMaterial: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
self.configure(view)
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
self.configure(nsView)
}
private func configure(_ view: NSVisualEffectView) {
view.material = .sidebar
view.blendingMode = .behindWindow
view.state = .followsWindowActiveState
}
}