329 lines
11 KiB
Swift
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
|
|
}
|
|
}
|