diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 743ddbca..3be11613 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -1,4 +1,16 @@ /* English is the default UI language. */ +"Appearance" = "Appearance"; +"Theme" = "Theme"; +"Color theme" = "Color theme"; +"Appearance mode" = "Appearance mode"; +"Lithe" = "Lithe"; +"Codex" = "Codex"; +"Linear" = "Linear"; +"System" = "System"; +"Light" = "Light"; +"Dark" = "Dark"; +"Choose whether Lithe follows the system appearance or always uses a light or dark theme." = "Choose whether Lithe follows the system appearance or always uses a light or dark theme."; +"Choose a color theme and whether Lithe follows the system appearance." = "Choose a color theme and whether Lithe follows the system appearance."; "Editor tabs" = "Editor tabs"; "Single row" = "Single row"; "Wrap into rows" = "Wrap into rows"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 177befed..e5eb91e6 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -3,6 +3,18 @@ "Close Settings" = "关闭设置"; "Project" = "项目"; "General" = "通用"; +"Appearance" = "外观"; +"Theme" = "主题"; +"Color theme" = "配色主题"; +"Appearance mode" = "外观模式"; +"Lithe" = "Lithe"; +"Codex" = "Codex"; +"Linear" = "Linear"; +"System" = "跟随系统"; +"Light" = "浅色"; +"Dark" = "深色"; +"Choose whether Lithe follows the system appearance or always uses a light or dark theme." = "选择跟随系统外观,或始终使用浅色或深色主题。"; +"Choose a color theme and whether Lithe follows the system appearance." = "选择配色主题,并设置是否跟随系统外观。"; "Editor" = "编辑器"; "Terminal" = "终端"; "Updates" = "更新"; diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index e6fc341a..f5ae6295 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -4,20 +4,9 @@ import SwiftUI @MainActor final class LitheAppDelegate: NSObject, NSApplicationDelegate { weak var projectSessions: ProjectSessionManager? - var mainWindow: NSWindow? - func registerMainWindow(_ window: NSWindow) { - mainWindow = window - } - - func applicationShouldHandleReopen( - _ sender: NSApplication, - hasVisibleWindows: Bool - ) -> Bool { - guard !hasVisibleWindows, let mainWindow else { return true } - mainWindow.makeKeyAndOrderFront(nil) - sender.activate(ignoringOtherApps: true) - return false + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { @@ -97,7 +86,7 @@ struct LitheApp: App { // changes. Re-identify the root so a language selection takes // effect immediately across every workspace, including sheets. .id(settings.language) - .preferredColorScheme(.dark) + .preferredColorScheme(settings.themePreference.preferredColorScheme) .task { memoryUsageMonitor.start() } @@ -245,3 +234,13 @@ struct LitheApp: App { ) } } + +private extension AppThemePreference { + var preferredColorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} diff --git a/Sources/Lithe/Models/AppSettings.swift b/Sources/Lithe/Models/AppSettings.swift index 1ff55a54..8f4bd4a6 100644 --- a/Sources/Lithe/Models/AppSettings.swift +++ b/Sources/Lithe/Models/AppSettings.swift @@ -3,6 +3,8 @@ import Foundation @MainActor final class AppSettings: ObservableObject { private enum Key { + static let colorTheme = "settings.colorTheme" + static let themePreference = "settings.themePreference" static let language = "settings.language" static let editorFontSize = "settings.editorFontSize" static let tabWidth = "settings.tabWidth" @@ -20,6 +22,15 @@ final class AppSettings: ObservableObject { private let defaults: any KeyValueStore + @Published var colorTheme: AppColorTheme { + didSet { + AppThemeRuntime.shared.activate(colorTheme) + defaults.set(colorTheme.rawValue, forKey: Key.colorTheme) + } + } + @Published var themePreference: AppThemePreference { + didSet { defaults.set(themePreference.rawValue, forKey: Key.themePreference) } + } @Published var language: AppLanguage { didSet { defaults.set(language.rawValue, forKey: Key.language) } } @Published var editorFontSize: Double { didSet { defaults.set(editorFontSize, forKey: Key.editorFontSize) } } @Published var tabWidth: Int { didSet { defaults.set(tabWidth, forKey: Key.tabWidth) } } @@ -56,6 +67,12 @@ final class AppSettings: ObservableObject { init(store: any KeyValueStore) { self.defaults = store + colorTheme = AppColorTheme( + rawValue: defaults.string(forKey: Key.colorTheme) ?? "" + ) ?? .lithe + themePreference = AppThemePreference( + rawValue: defaults.string(forKey: Key.themePreference) ?? "" + ) ?? .dark language = AppLanguage(rawValue: defaults.string(forKey: Key.language) ?? "") ?? .english editorFontSize = defaults.object(forKey: Key.editorFontSize) as? Double ?? 13 tabWidth = defaults.object(forKey: Key.tabWidth) as? Int ?? 4 @@ -82,6 +99,7 @@ final class AppSettings: ObservableObject { } else { commitMessageAI = .default } + AppThemeRuntime.shared.activate(colorTheme) } var terminalShellPath: String? { terminalShell.path } @@ -111,6 +129,8 @@ final class AppSettings: ObservableObject { } func restoreDefaults() { + colorTheme = .lithe + themePreference = .dark language = .english editorFontSize = 13 tabWidth = 4 @@ -212,6 +232,59 @@ final class AppSettings: ObservableObject { } } +enum AppColorTheme: String, CaseIterable, Identifiable { + case lithe + case codex + case linear + + var id: String { rawValue } + + var title: String { + switch self { + case .lithe: "Lithe" + case .codex: "Codex" + case .linear: "Linear" + } + } +} + +final class AppThemeRuntime: @unchecked Sendable { + static let shared = AppThemeRuntime() + + private let lock = NSLock() + private var value: AppColorTheme = .lithe + + private init() {} + + func activate(_ theme: AppColorTheme) { + lock.lock() + value = theme + lock.unlock() + } + + var activeTheme: AppColorTheme { + lock.lock() + defer { lock.unlock() } + return value + } +} + +enum AppThemePreference: String, CaseIterable, Identifiable { + case system + case light + case dark + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "System" + case .light: "Light" + case .dark: "Dark" + } + } +} + enum EditorTabLayoutMode: String, CaseIterable, Identifiable { case singleLine case multipleRows diff --git a/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index 362daf0e..56015a1f 100644 --- a/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -7,6 +7,28 @@ import SwiftTerm final class LitheTerminalView: LocalProcessTerminalView { var onOpenLink: ((String, [String: String]) -> Void)? + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + applyThemeColors() + } + + func applyThemeColors() { + let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + nativeBackgroundColor = isDark + ? NSColor(srgbRed: 0.071, green: 0.075, blue: 0.081, alpha: 1) + : NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) + nativeForegroundColor = isDark + ? NSColor(srgbRed: 0.86, green: 0.87, blue: 0.89, alpha: 1) + : NSColor(srgbRed: 0.15, green: 0.16, blue: 0.18, alpha: 1) + caretColor = isDark + ? NSColor(srgbRed: 0.35, green: 0.67, blue: 0.98, alpha: 1) + : NSColor(srgbRed: 0.18, green: 0.43, blue: 0.79, alpha: 1) + selectedTextBackgroundColor = isDark + ? NSColor(srgbRed: 0.16, green: 0.31, blue: 0.48, alpha: 1) + : NSColor(srgbRed: 0.69, green: 0.82, blue: 0.98, alpha: 1) + needsDisplay = true + } + override func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) { onOpenLink?(link, params) } @@ -47,30 +69,7 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L self?.onLink?(link, params) } view.font = Self.preferredTerminalFont() - view.nativeBackgroundColor = NSColor( - calibratedRed: 0.071, - green: 0.075, - blue: 0.081, - alpha: 1 - ) - view.nativeForegroundColor = NSColor( - calibratedRed: 0.86, - green: 0.87, - blue: 0.89, - alpha: 1 - ) - view.caretColor = NSColor( - calibratedRed: 0.35, - green: 0.67, - blue: 0.98, - alpha: 1 - ) - view.selectedTextBackgroundColor = NSColor( - calibratedRed: 0.16, - green: 0.31, - blue: 0.48, - alpha: 1 - ) + view.applyThemeColors() view.allowMouseReporting = true view.linkReporting = .implicit view.linkHighlightMode = .hoverWithModifier diff --git a/Sources/Lithe/Theme/LitheIcons.swift b/Sources/Lithe/Theme/LitheIcons.swift index fdbf4540..2316e9ff 100644 --- a/Sources/Lithe/Theme/LitheIcons.swift +++ b/Sources/Lithe/Theme/LitheIcons.swift @@ -456,6 +456,7 @@ enum LitheIcons { // MARK: - SwiftUI 图标 struct LitheIcon: View { + @Environment(\.colorScheme) private var colorScheme let kind: LitheIconKind var size: CGFloat = 14 @@ -464,6 +465,11 @@ struct LitheIcon: View { Image(nsImage: image) .resizable() .interpolation(.high) + // IntelliJ's catalog is authored against a dark canvas. A + // small contrast normalization keeps its semantic colors + // legible on light surfaces without turning them monochrome. + .saturation(colorScheme == .light ? 0.94 : 1) + .contrast(colorScheme == .light ? 0.90 : 1) .frame(width: size, height: size) } else { switch LitheIcons.appearance(for: kind) { @@ -488,6 +494,7 @@ struct LitheIDEAIcon: View { var body: some View { if let image = LitheIcons.ideaImage(resourcePath: resourcePath) { Image(nsImage: image) + .renderingMode(.template) .resizable() .interpolation(.high) .frame(width: size, height: size) @@ -517,6 +524,8 @@ struct LitheSystemIcon: View { ) } else { Image(systemName: systemImage) + .font(.system(size: size, weight: .medium)) + .frame(width: size, height: size) } } } diff --git a/Sources/Lithe/Theme/LitheTheme.swift b/Sources/Lithe/Theme/LitheTheme.swift index 6a8c7bd7..c8751ac2 100644 --- a/Sources/Lithe/Theme/LitheTheme.swift +++ b/Sources/Lithe/Theme/LitheTheme.swift @@ -2,66 +2,329 @@ import AppKit import SwiftUI enum LitheTheme { + private struct RGBA { + let red: CGFloat + let green: CGFloat + let blue: CGFloat + let alpha: CGFloat + + init(_ hex: UInt32, alpha: CGFloat = 1) { + red = CGFloat((hex >> 16) & 0xff) / 255 + green = CGFloat((hex >> 8) & 0xff) / 255 + blue = CGFloat(hex & 0xff) / 255 + self.alpha = alpha + } + + func withAlpha(_ alpha: CGFloat) -> RGBA { + RGBA(red: red, green: green, blue: blue, alpha: alpha) + } + + func mixed(with other: RGBA, amount: CGFloat) -> RGBA { + let amount = min(max(amount, 0), 1) + return RGBA( + red: red + (other.red - red) * amount, + green: green + (other.green - green) * amount, + blue: blue + (other.blue - blue) * amount, + alpha: alpha + (other.alpha - alpha) * amount + ) + } + + init(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + var nsColor: NSColor { + NSColor(srgbRed: red, green: green, blue: blue, alpha: alpha) + } + } + + private struct Palette { + let window: RGBA + let titlebar: RGBA + let toolHeader: RGBA + let sidebar: RGBA + let editor: RGBA + let raised: RGBA + let selection: RGBA + let subtleSelection: RGBA + let hoverBackground: RGBA + let pressedBackground: RGBA + let activeTabBackground: RGBA + let tabUnderline: RGBA + let diffInformationBackground: RGBA + let diffInformationText: RGBA + let divider: RGBA + let panelBorder: RGBA + let inputBackground: RGBA + let inputBorder: RGBA + let inputFocusBorder: RGBA + let popupBackground: RGBA + let popupShadow: RGBA + let badgeBackground: RGBA + let primaryText: RGBA + let secondaryText: RGBA + let tertiaryText: RGBA + let accent: RGBA + let success: RGBA + let warning: RGBA + let error: RGBA + let skill: RGBA + let link: RGBA + let guide: RGBA + let activeGuide: RGBA + + static func make(theme: AppColorTheme, isDark: Bool) -> Palette { + let surface: RGBA + let ink: RGBA + let accent: RGBA + let diffAdded: RGBA + let diffRemoved: RGBA + let skill: RGBA + let contrast: CGFloat + + switch (theme, isDark) { + case (.lithe, _): + return lithe(isDark: isDark) + case (.codex, true): + surface = RGBA(0x111111) + ink = RGBA(0xfcfcfc) + accent = RGBA(0x0169cc) + diffAdded = RGBA(0x00a240) + diffRemoved = RGBA(0xe02e2a) + skill = RGBA(0xb06dff) + contrast = 0.60 + case (.codex, false): + surface = RGBA(0xffffff) + ink = RGBA(0x0d0d0d) + accent = RGBA(0x0169cc) + diffAdded = RGBA(0x00a240) + diffRemoved = RGBA(0xe02e2a) + skill = RGBA(0x751ed9) + contrast = 0.45 + case (.linear, true): + surface = RGBA(0x0f0f11) + ink = RGBA(0xe3e4e6) + accent = RGBA(0x606acc) + diffAdded = RGBA(0x69c967) + diffRemoved = RGBA(0xff7e78) + skill = RGBA(0xc2a1ff) + contrast = 0.60 + case (.linear, false): + surface = RGBA(0xfcfcfd) + ink = RGBA(0x1b1b1b) + accent = RGBA(0x5e6ad2) + diffAdded = RGBA(0x52a450) + diffRemoved = RGBA(0xc94446) + skill = RGBA(0x8160d8) + contrast = 0.45 + } + + let chromeAmount = (isDark ? 0.045 : 0.035) * (contrast / 0.45) + let strongChromeAmount = (isDark ? 0.075 : 0.055) * (contrast / 0.45) + let subtleAccent = surface.mixed(with: accent, amount: isDark ? 0.20 : 0.11) + + return Palette( + window: surface, + titlebar: surface.mixed(with: ink, amount: strongChromeAmount), + toolHeader: surface.mixed(with: ink, amount: chromeAmount), + sidebar: isDark + ? surface.mixed(with: RGBA(0x000000), amount: 0.10) + : surface.mixed(with: ink, amount: chromeAmount), + editor: surface, + raised: surface.mixed(with: ink, amount: isDark ? 0.085 : 0.018), + selection: accent, + subtleSelection: subtleAccent, + hoverBackground: ink.withAlpha(isDark ? 0.065 : 0.055), + pressedBackground: ink.withAlpha(isDark ? 0.11 : 0.095), + activeTabBackground: surface.mixed(with: ink, amount: isDark ? 0.075 : 0.012), + tabUnderline: accent, + diffInformationBackground: surface.mixed(with: accent, amount: isDark ? 0.18 : 0.10), + diffInformationText: accent, + divider: ink.withAlpha(isDark ? 0.10 : 0.12), + panelBorder: ink.withAlpha(isDark ? 0.16 : 0.16), + inputBackground: isDark + ? surface.mixed(with: RGBA(0x000000), amount: 0.15) + : surface, + inputBorder: ink.withAlpha(isDark ? 0.15 : 0.18), + inputFocusBorder: accent.withAlpha(0.90), + popupBackground: surface.mixed(with: ink, amount: isDark ? 0.065 : 0.008), + popupShadow: RGBA(0x000000, alpha: isDark ? 0.55 : 0.20), + badgeBackground: ink.withAlpha(isDark ? 0.12 : 0.08), + primaryText: ink, + secondaryText: ink.withAlpha(isDark ? 0.62 : 0.60), + tertiaryText: ink.withAlpha(isDark ? 0.43 : 0.42), + accent: accent, + success: diffAdded, + warning: isDark ? RGBA(0xe6a23c) : RGBA(0xa96500), + error: diffRemoved, + skill: skill, + link: accent, + guide: ink.withAlpha(isDark ? 0.10 : 0.11), + activeGuide: ink.withAlpha(isDark ? 0.26 : 0.27) + ) + } + + private static func lithe(isDark: Bool) -> Palette { + typealias Components = (CGFloat, CGFloat, CGFloat, CGFloat) + func adaptive(light: Components, dark: Components) -> RGBA { + let value = isDark ? dark : light + return RGBA(red: value.0, green: value.1, blue: value.2, alpha: value.3) + } + + return Palette( + window: adaptive(light: (0.965, 0.969, 0.976, 1), dark: (0.106, 0.113, 0.125, 1)), + titlebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.145, 0.155, 0.169, 1)), + toolHeader: adaptive(light: (0.945, 0.949, 0.957, 1), dark: (0.122, 0.130, 0.142, 1)), + sidebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.090, 0.096, 0.106, 1)), + editor: adaptive(light: (1, 1, 1, 1), dark: (0.074, 0.079, 0.088, 1)), + raised: adaptive(light: (1, 1, 1, 1), dark: (0.165, 0.175, 0.190, 1)), + selection: adaptive(light: (0.205, 0.435, 0.765, 1), dark: (0.170, 0.290, 0.490, 1)), + subtleSelection: adaptive(light: (0.855, 0.902, 0.973, 1), dark: (0.205, 0.218, 0.238, 1)), + hoverBackground: adaptive(light: (0, 0, 0, 0.050), dark: (1, 1, 1, 0.055)), + pressedBackground: adaptive(light: (0, 0, 0, 0.090), dark: (1, 1, 1, 0.095)), + activeTabBackground: adaptive(light: (1, 1, 1, 1), dark: (0.145, 0.155, 0.170, 1)), + tabUnderline: adaptive(light: (0.180, 0.425, 0.790, 1), dark: (0.31, 0.58, 0.98, 1)), + diffInformationBackground: adaptive(light: (0.895, 0.935, 0.990, 1), dark: (0.13, 0.20, 0.30, 1)), + diffInformationText: adaptive(light: (0.105, 0.365, 0.680, 1), dark: (0.50, 0.72, 0.98, 1)), + divider: adaptive(light: (0, 0, 0, 0.100), dark: (1, 1, 1, 0.075)), + panelBorder: adaptive(light: (0, 0, 0, 0.145), dark: (1, 1, 1, 0.13)), + inputBackground: adaptive(light: (1, 1, 1, 1), dark: (0.065, 0.070, 0.078, 1)), + inputBorder: adaptive(light: (0, 0, 0, 0.150), dark: (1, 1, 1, 0.12)), + inputFocusBorder: adaptive(light: (0.180, 0.425, 0.790, 0.90), dark: (0.31, 0.58, 0.98, 0.85)), + popupBackground: adaptive(light: (1, 1, 1, 1), dark: (0.135, 0.143, 0.157, 1)), + popupShadow: adaptive(light: (0, 0, 0, 0.20), dark: (0, 0, 0, 0.55)), + badgeBackground: adaptive(light: (0, 0, 0, 0.075), dark: (1, 1, 1, 0.10)), + primaryText: adaptive(light: (0, 0, 0, 0.82), dark: (1, 1, 1, 0.86)), + secondaryText: adaptive(light: (0, 0, 0, 0.55), dark: (1, 1, 1, 0.50)), + tertiaryText: adaptive(light: (0, 0, 0, 0.38), dark: (1, 1, 1, 0.34)), + accent: adaptive(light: (0.180, 0.425, 0.790, 1), dark: (0.31, 0.58, 0.98, 1)), + success: adaptive(light: (0.105, 0.545, 0.235, 1), dark: (0.28, 0.72, 0.39, 1)), + warning: adaptive(light: (0.690, 0.410, 0.035, 1), dark: (0.91, 0.63, 0.20, 1)), + error: adaptive(light: (0.780, 0.175, 0.175, 1), dark: (0.92, 0.33, 0.33, 1)), + skill: adaptive(light: (0.55, 0.18, 0.64, 1), dark: (0.80, 0.48, 0.77, 1)), + link: adaptive(light: (0.110, 0.390, 0.740, 1), dark: (0.42, 0.68, 1.00, 1)), + guide: adaptive(light: (0, 0, 0, 0.105), dark: (1, 1, 1, 0.085)), + activeGuide: adaptive(light: (0, 0, 0, 0.25), dark: (1, 1, 1, 0.24)) + ) + } + } + + static var activeTheme: AppColorTheme { AppThemeRuntime.shared.activeTheme } + + enum ResolvedColorToken { + case editor + case sidebar + case primaryText + case secondaryText + case accent + case success + case warning + case error + case skill + case guide + case activeGuide + } + + static func nsColor( + _ token: ResolvedColorToken, + theme: AppColorTheme = activeTheme, + isDark: Bool + ) -> NSColor { + let palette = Palette.make(theme: theme, isDark: isDark) + return switch token { + case .editor: palette.editor.nsColor + case .sidebar: palette.sidebar.nsColor + case .primaryText: palette.primaryText.nsColor + case .secondaryText: palette.secondaryText.nsColor + case .accent: palette.accent.nsColor + case .success: palette.success.nsColor + case .warning: palette.warning.nsColor + case .error: palette.error.nsColor + case .skill: palette.skill.nsColor + case .guide: palette.guide.nsColor + case .activeGuide: palette.activeGuide.nsColor + } + } + // MARK: - 背景层次 - // 从深到浅:window < sidebar < editor < raised。层间对比度刻意拉开, - // 让工具窗口、编辑区和浮层在暗色下依然能区分出前后关系。 - static let window = Color(red: 0.106, green: 0.113, blue: 0.125) - static let titlebar = Color(red: 0.145, green: 0.155, blue: 0.169) - static let toolHeader = Color(red: 0.122, green: 0.130, blue: 0.142) - static let sidebar = Color(red: 0.090, green: 0.096, blue: 0.106) - static let editor = Color(red: 0.074, green: 0.079, blue: 0.088) - static let raised = Color(red: 0.165, green: 0.175, blue: 0.190) + static var window: Color { adaptive(\.window) } + static var titlebar: Color { adaptive(\.titlebar) } + static var toolHeader: Color { adaptive(\.toolHeader) } + static var sidebar: Color { adaptive(\.sidebar) } + static var editor: Color { adaptive(\.editor) } + static var raised: Color { adaptive(\.raised) } // MARK: - 选中与悬停 - static let selection = Color(red: 0.170, green: 0.290, blue: 0.490) - static let subtleSelection = Color(red: 0.205, green: 0.218, blue: 0.238) - static let hoverBackground = Color.white.opacity(0.055) - static let pressedBackground = Color.white.opacity(0.095) + static var selection: Color { adaptive(\.selection) } + static var subtleSelection: Color { adaptive(\.subtleSelection) } + static var hoverBackground: Color { adaptive(\.hoverBackground) } + static var pressedBackground: Color { adaptive(\.pressedBackground) } // MARK: - 标签页 - static let activeTabBackground = Color(red: 0.145, green: 0.155, blue: 0.170) + static var activeTabBackground: Color { adaptive(\.activeTabBackground) } static let inactiveTabBackground = Color.clear - static let tabUnderline = Color(red: 0.31, green: 0.58, blue: 0.98) + static var tabUnderline: Color { adaptive(\.tabUnderline) } + static var diffInformationBackground: Color { adaptive(\.diffInformationBackground) } + static var diffInformationText: Color { adaptive(\.diffInformationText) } // MARK: - 分隔与边框 - static let divider = Color.white.opacity(0.075) - static let panelBorder = Color.white.opacity(0.13) + static var divider: Color { adaptive(\.divider) } + static var panelBorder: Color { adaptive(\.panelBorder) } // MARK: - 输入控件 - static let inputBackground = Color(red: 0.065, green: 0.070, blue: 0.078) - static let inputBorder = Color.white.opacity(0.12) - static let inputFocusBorder = Color(red: 0.31, green: 0.58, blue: 0.98).opacity(0.85) + static var inputBackground: Color { adaptive(\.inputBackground) } + static var inputBorder: Color { adaptive(\.inputBorder) } + static var inputFocusBorder: Color { adaptive(\.inputFocusBorder) } // MARK: - 浮层 - static let popupBackground = Color(red: 0.135, green: 0.143, blue: 0.157) - static let popupShadow = Color.black.opacity(0.55) - static let badgeBackground = Color.white.opacity(0.10) + static var popupBackground: Color { adaptive(\.popupBackground) } + static var popupShadow: Color { adaptive(\.popupShadow) } + static var badgeBackground: Color { adaptive(\.badgeBackground) } // MARK: - 文本 - static let primaryText = Color.white.opacity(0.86) - static let secondaryText = Color.white.opacity(0.50) - static let tertiaryText = Color.white.opacity(0.34) + static var primaryText: Color { adaptive(\.primaryText) } + static var secondaryText: Color { adaptive(\.secondaryText) } + static var tertiaryText: Color { adaptive(\.tertiaryText) } // MARK: - 语义色 - static let accent = Color(red: 0.31, green: 0.58, blue: 0.98) - static let success = Color(red: 0.28, green: 0.72, blue: 0.39) - static let warning = Color(red: 0.91, green: 0.63, blue: 0.20) - static let error = Color(red: 0.92, green: 0.33, blue: 0.33) + static var accent: Color { adaptive(\.accent) } + static var success: Color { adaptive(\.success) } + static var warning: Color { adaptive(\.warning) } + static var error: Color { adaptive(\.error) } + static var skill: Color { adaptive(\.skill) } /// Cmd/Ctrl 悬停时标识符转成的“可点击”色。 - static let link = Color(red: 0.42, green: 0.68, blue: 1.00) + static var link: Color { adaptive(\.link) } // 语义化别名,便于 AppKit 装饰代码与设计稿 token 同名。 - static let linkColor = link + static var linkColor: Color { link } // MARK: - 编辑器缩进竖线 - static let guide = Color.white.opacity(0.085) - static let activeGuide = Color.white.opacity(0.24) - static let guideColor = guide - static let activeGuideColor = activeGuide + static var guide: Color { adaptive(\.guide) } + static var activeGuide: Color { adaptive(\.activeGuide) } + static var guideColor: Color { guide } + static var activeGuideColor: Color { activeGuide } + + private static func adaptive(_ keyPath: KeyPath) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let palette = Palette.make(theme: activeTheme, isDark: isDark) + return palette[keyPath: keyPath].nsColor + }) + } - static let uiFont = Font.system(size: 14, weight: .regular) - static let smallFont = Font.system(size: 12, weight: .regular) + static var uiFont: Font { uiFont(size: 14) } + static var smallFont: Font { uiFont(size: 12) } static let codeFont = Font.system(size: 13, design: .monospaced) + private static func uiFont(size: CGFloat) -> Font { + if activeTheme != .lithe, NSFont(name: "Inter", size: size) != nil { + return Font.custom("Inter", size: size) + } + return Font.system(size: size, weight: .regular) + } + /// 统一的尺寸与间距刻度,避免各视图各写一套魔法数字。 enum Metrics { static let rowHeight: CGFloat = 24 diff --git a/Sources/Lithe/Views/CloneRepositoryView.swift b/Sources/Lithe/Views/CloneRepositoryView.swift index 401e026d..6aac313c 100644 --- a/Sources/Lithe/Views/CloneRepositoryView.swift +++ b/Sources/Lithe/Views/CloneRepositoryView.swift @@ -74,7 +74,6 @@ struct CloneRepositoryView: View { } .frame(width: 560, height: 360) .background(LitheTheme.window) - .preferredColorScheme(.dark) .onAppear { focusedField = .remote } diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift index e203f6c8..b9bc55f1 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/CodeEditorView.swift @@ -1,7 +1,58 @@ import AppKit import SwiftUI +fileprivate struct CodeEditorPalette { + let isDark: Bool + let theme: AppColorTheme + + static let dark = CodeEditorPalette(isDark: true, theme: .lithe) + + var background: NSColor { themeColor(.editor) } + var gutterBackground: NSColor { themeColor(.sidebar) } + var text: NSColor { themeColor(.primaryText) } + var caret: NSColor { themeColor(.primaryText) } + var selection: NSColor { themeColor(.accent).withAlphaComponent(isDark ? 0.42 : 0.24) } + var selectionText: NSColor { themeColor(.primaryText) } + var currentLine: NSColor { color(light: (0, 0, 0, 0.035), dark: (1, 1, 1, 0.035)) } + var bracket: NSColor { color(light: (0.18, 0.43, 0.79, 0.19), dark: (0.72, 0.72, 0.72, 0.22)) } + var symbol: NSColor { color(light: (0.18, 0.43, 0.79, 0.11), dark: (0.68, 0.68, 0.68, 0.14)) } + var guide: NSColor { themeColor(.guide) } + var activeGuide: NSColor { themeColor(.activeGuide) } + var unusedCode: NSColor { color(light: (0.48, 0.49, 0.52, 1), dark: (0.48, 0.48, 0.48, 1)) } + var link: NSColor { themeColor(.accent) } + var lineNumber: NSColor { color(light: (0.43, 0.45, 0.49, 1), dark: (0.34, 0.34, 0.34, 1)) } + var foldHover: NSColor { color(light: (0, 0, 0, 0.07), dark: (1, 1, 1, 0.07)) } + var foldIndicator: NSColor { color(light: (0.28, 0.30, 0.34, 0.58), dark: (0.62, 0.62, 0.62, 0.46)) } + var foldIndicatorHover: NSColor { color(light: (0.12, 0.14, 0.17, 0.90), dark: (0.86, 0.86, 0.86, 0.96)) } + var blameText: NSColor { color(light: (0.38, 0.40, 0.44, 1), dark: (0.53, 0.53, 0.53, 1)) } + + var keyword: NSColor { themeColor(.skill) } + var annotation: NSColor { themeColor(.warning) } + var type: NSColor { themeColor(.accent) } + var number: NSColor { themeColor(.warning) } + var string: NSColor { themeColor(.success) } + var comment: NSColor { themeColor(.secondaryText) } + + private func themeColor(_ token: LitheTheme.ResolvedColorToken) -> NSColor { + LitheTheme.nsColor(token, theme: theme, isDark: isDark) + } + + private func color( + light: (CGFloat, CGFloat, CGFloat, CGFloat), + dark: (CGFloat, CGFloat, CGFloat, CGFloat) + ) -> NSColor { + let components = isDark ? dark : light + return NSColor( + srgbRed: components.0, + green: components.1, + blue: components.2, + alpha: components.3 + ) + } +} + struct CodeEditorView: NSViewRepresentable { + @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings @ObservedObject var document: EditorDocument @@ -19,6 +70,7 @@ struct CodeEditorView: NSViewRepresentable { } func makeNSView(context: Context) -> EditorContainerView { + let palette = CodeEditorPalette(isDark: colorScheme == .dark, theme: settings.colorTheme) let container = EditorContainerView() let scrollView = NSScrollView(frame: .zero) scrollView.translatesAutoresizingMaskIntoConstraints = false @@ -27,7 +79,7 @@ struct CodeEditorView: NSViewRepresentable { scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.drawsBackground = true - scrollView.backgroundColor = NSColor(red: 0.085, green: 0.089, blue: 0.096, alpha: 1) + scrollView.backgroundColor = palette.background scrollView.wantsLayer = true scrollView.layer?.masksToBounds = true @@ -65,18 +117,12 @@ struct CodeEditorView: NSViewRepresentable { textView.textContainerInset = NSSize(width: 12, height: 10) textView.font = .monospacedSystemFont(ofSize: settings.editorFontSize, weight: .regular) textView.indentationWidth = settings.tabWidth - textView.backgroundColor = scrollView.backgroundColor - textView.textColor = NSColor(white: 0.82, alpha: 1) - textView.insertionPointColor = .white + textView.applyAppearance(palette) textView.isEditable = !document.isReadOnly textView.isSelectable = true textView.onWindowAttached = { [weak coordinator = context.coordinator] in coordinator?.requestInitialFocusIfNeeded() } - textView.selectedTextAttributes = [ - .backgroundColor: NSColor(red: 0.16, green: 0.31, blue: 0.54, alpha: 1), - .foregroundColor: NSColor.white - ] textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false @@ -100,6 +146,7 @@ struct CodeEditorView: NSViewRepresentable { scrollView.documentView = textView gutter.attach(textView: textView, scrollView: scrollView) + gutter.applyAppearance(palette) context.coordinator.attachMarkdownScrollSync(to: scrollView) context.coordinator.textView = textView @@ -108,6 +155,8 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView) context.coordinator.codeVisionOverlay = CodeVisionOverlayController(textView: textView) context.coordinator.inlayHintOverlay = JavaInlayHintOverlayController(textView: textView) + context.coordinator.isDarkAppearance = palette.isDark + context.coordinator.colorTheme = settings.colorTheme context.coordinator.highlight() textView.updateEditorDecorations() context.coordinator.refreshFoldRegions(useDefaultImportFold: true) @@ -124,20 +173,28 @@ struct CodeEditorView: NSViewRepresentable { func updateNSView(_ container: EditorContainerView, context: Context) { guard let textView = container.scrollView?.documentView as? NSTextView else { return } + let palette = CodeEditorPalette(isDark: colorScheme == .dark, theme: settings.colorTheme) + let appearanceChanged = context.coordinator.isDarkAppearance != palette.isDark + || context.coordinator.colorTheme != settings.colorTheme context.coordinator.document = document context.coordinator.model = model context.coordinator.debugService = debugService context.coordinator.shouldFocus = shouldFocus context.coordinator.markdownScrollPosition = markdownScrollPosition if let scrollView = container.scrollView { + scrollView.backgroundColor = palette.background context.coordinator.attachMarkdownScrollSync(to: scrollView) context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView) } + context.coordinator.isDarkAppearance = palette.isDark + context.coordinator.colorTheme = settings.colorTheme context.coordinator.requestInitialFocusIfNeeded() textView.font = .monospacedSystemFont(ofSize: settings.editorFontSize, weight: .regular) if let codeTextView = textView as? CodeTextView { codeTextView.indentationWidth = settings.tabWidth + codeTextView.applyAppearance(palette) } + container.gutter?.applyAppearance(palette) textView.isEditable = !document.isReadOnly textView.isSelectable = true // Keep IME marked text (for example, an active Chinese pinyin @@ -153,6 +210,10 @@ struct CodeEditorView: NSViewRepresentable { (textView as? CodeTextView)?.updateEditorDecorations() container.gutter?.needsDisplay = true } + if appearanceChanged { + context.coordinator.highlight() + (textView as? CodeTextView)?.updateEditorDecorations() + } context.coordinator.updateCodeVisionAndBlame() context.coordinator.updateDiagnostics() context.coordinator.applyNavigationTargetIfNeeded() @@ -174,6 +235,8 @@ struct CodeEditorView: NSViewRepresentable { var codeVisionOverlay: CodeVisionOverlayController? var inlayHintOverlay: JavaInlayHintOverlayController? var isApplyingEditorChange = false + var isDarkAppearance = true + var colorTheme: AppColorTheme = .lithe var shouldFocus = true var markdownScrollPosition: Binding? var appliedNavigationTargetID: UUID? @@ -389,7 +452,11 @@ struct CodeEditorView: NSViewRepresentable { func highlight() { guard let textStorage = textView?.textStorage else { return } - SyntaxHighlighter.apply(to: textStorage, fileExtension: fileExtension) + SyntaxHighlighter.apply( + to: textStorage, + fileExtension: fileExtension, + isDark: isDarkAppearance + ) } func refreshFoldRegions(useDefaultImportFold: Bool) { @@ -632,12 +699,15 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var findMatchRanges: [NSRange] = [] private var currentFindMatchIndex = 0 - private let currentLineColor = NSColor(white: 1, alpha: 0.035) - private let bracketColor = NSColor(white: 0.72, alpha: 0.22) - private let symbolColor = NSColor(white: 0.68, alpha: 0.14) - private let guideColor = NSColor(white: 1, alpha: 0.085) - private let activeGuideColor = NSColor(white: 1, alpha: 0.24) - private let unusedCodeColor = NSColor(white: 0.48, alpha: 1) + private var currentLineColor = CodeEditorPalette.dark.currentLine + private var bracketColor = CodeEditorPalette.dark.bracket + private var symbolColor = CodeEditorPalette.dark.symbol + private var guideColor = CodeEditorPalette.dark.guide + private var activeGuideColor = CodeEditorPalette.dark.activeGuide + private var unusedCodeColor = CodeEditorPalette.dark.unusedCode + private var linkColor = CodeEditorPalette.dark.link + private var appliedDarkAppearance: Bool? + private var appliedColorTheme: AppColorTheme? private var foldRegions: [JavaFoldRegion] = [] private var collapsedFoldIDs: Set = [] private var onToggleFold: ((JavaFoldRegion) -> Void)? @@ -649,6 +719,28 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var lineIndex = TextLineIndex(source: "" as NSString) nonisolated(unsafe) private var windowResignObserver: NSObjectProtocol? + fileprivate func applyAppearance(_ palette: CodeEditorPalette) { + guard appliedDarkAppearance != palette.isDark + || appliedColorTheme != palette.theme else { return } + appliedDarkAppearance = palette.isDark + appliedColorTheme = palette.theme + backgroundColor = palette.background + textColor = palette.text + insertionPointColor = palette.caret + selectedTextAttributes = [ + .backgroundColor: palette.selection, + .foregroundColor: palette.selectionText + ] + currentLineColor = palette.currentLine + bracketColor = palette.bracket + symbolColor = palette.symbol + guideColor = palette.guide + activeGuideColor = palette.activeGuide + unusedCodeColor = palette.unusedCode + linkColor = palette.link + needsDisplay = true + } + override func paste(_ sender: Any?) { if onPasteImage?() == true { return } super.paste(sender) @@ -1328,7 +1420,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { let layoutManager else { return } layoutManager.addTemporaryAttribute( .foregroundColor, - value: NSColor(red: 0.42, green: 0.68, blue: 1, alpha: 1), + value: linkColor, forCharacterRange: linkRange ) layoutManager.addTemporaryAttribute( @@ -1338,7 +1430,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { ) layoutManager.addTemporaryAttribute( .underlineColor, - value: NSColor(red: 0.42, green: 0.68, blue: 1, alpha: 1), + value: linkColor, forCharacterRange: linkRange ) } @@ -1677,6 +1769,7 @@ final class LineNumberGutterView: NSView { private var scrollRefreshScheduled = false private var hoveredFoldID: String? private var trackingArea: NSTrackingArea? + private var palette = CodeEditorPalette.dark override var isFlipped: Bool { true } @@ -1699,7 +1792,7 @@ final class LineNumberGutterView: NSView { self.textView = textView self.scrollView = scrollView wantsLayer = true - layer?.backgroundColor = NSColor(red: 0.075, green: 0.080, blue: 0.087, alpha: 1).cgColor + layer?.backgroundColor = palette.gutterBackground.cgColor scrollView.contentView.postsBoundsChangedNotifications = true boundsObserver = NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, @@ -1712,6 +1805,13 @@ final class LineNumberGutterView: NSView { } } + fileprivate func applyAppearance(_ palette: CodeEditorPalette) { + guard self.palette.isDark != palette.isDark || self.palette.theme != palette.theme else { return } + self.palette = palette + layer?.backgroundColor = palette.gutterBackground.cgColor + needsDisplay = true + } + private func scheduleScrollRefresh() { guard !scrollRefreshScheduled else { return } scrollRefreshScheduled = true @@ -1825,7 +1925,7 @@ final class LineNumberGutterView: NSView { let layoutManager = textView.layoutManager, let textContainer = textView.textContainer else { return } - NSColor(red: 0.075, green: 0.080, blue: 0.087, alpha: 1).setFill() + palette.gutterBackground.setFill() dirtyRect.fill() let visibleRect = scrollView.documentVisibleRect @@ -1875,7 +1975,7 @@ final class LineNumberGutterView: NSView { continue } if lineNumber - 1 == currentLine { - NSColor(white: 1, alpha: 0.035).setFill() + palette.currentLine.setFill() NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill() } if isBlameVisible, let blame = blameByLine[lineNumber - 1] { @@ -1933,7 +2033,7 @@ final class LineNumberGutterView: NSView { let label = String(number) as NSString let attributes: [NSAttributedString.Key: Any] = [ .font: NSFont.monospacedDigitSystemFont(ofSize: 10.5, weight: .regular), - .foregroundColor: NSColor(white: 0.34, alpha: 1) + .foregroundColor: palette.lineNumber ] let size = label.size(withAttributes: attributes) label.draw(at: NSPoint(x: bounds.width - size.width - 9, y: y), withAttributes: attributes) @@ -1949,7 +2049,7 @@ final class LineNumberGutterView: NSView { width: 18, height: 17 ) - NSColor(white: 1, alpha: 0.07).setFill() + palette.foldHover.setFill() NSBezierPath(roundedRect: hoverRect, xRadius: 3, yRadius: 3).fill() } let path = NSBezierPath() @@ -1963,10 +2063,7 @@ final class LineNumberGutterView: NSView { path.line(to: NSPoint(x: 13, y: centerY - 2)) } path.close() - NSColor( - white: isHovered ? 0.86 : 0.62, - alpha: isHovered ? 0.96 : 0.46 - ).setFill() + (isHovered ? palette.foldIndicatorHover : palette.foldIndicator).setFill() path.fill() } @@ -2002,7 +2099,7 @@ final class LineNumberGutterView: NSView { private func drawBlame(_ blame: GitBlameLine, y: CGFloat) { let attributes: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 10.5), - .foregroundColor: NSColor(white: 0.53, alpha: 1) + .foregroundColor: palette.blameText ] (blame.date as NSString).draw(at: NSPoint(x: 8, y: y), withAttributes: attributes) (blame.authorName as NSString).draw( @@ -2316,22 +2413,23 @@ private final class ClosureButton: NSButton { @MainActor private enum SyntaxHighlighter { - static func apply(to storage: NSTextStorage, fileExtension: String) { + static func apply(to storage: NSTextStorage, fileExtension: String, isDark: Bool) { let fullRange = NSRange(location: 0, length: storage.length) guard fullRange.length > 0 else { return } + let palette = CodeEditorPalette(isDark: isDark, theme: LitheTheme.activeTheme) storage.beginEditing() storage.setAttributes([ .font: NSFont.monospacedSystemFont(ofSize: 13, weight: .regular), - .foregroundColor: NSColor(white: 0.80, alpha: 1) + .foregroundColor: palette.text ], range: fullRange) - apply(pattern: #"\b(class|struct|enum|protocol|extension|func|let|var|if|else|guard|switch|case|for|while|return|throw|throws|try|catch|async|await|public|private|internal|protected|static|final|new|import|package|interface|implements|extends|void|boolean|int|long|const|function|def|in|from|as|true|false|null|nil|self|this)\b"#, color: NSColor(red: 0.80, green: 0.48, blue: 0.77, alpha: 1), storage: storage) - apply(pattern: #"@[A-Za-z_][A-Za-z0-9_]*"#, color: NSColor(red: 0.86, green: 0.72, blue: 0.34, alpha: 1), storage: storage) - apply(pattern: #"\b[A-Z][A-Za-z0-9_]*\b"#, color: NSColor(red: 0.42, green: 0.72, blue: 0.90, alpha: 1), storage: storage) - apply(pattern: #"\b\d+(?:\.\d+)?\b"#, color: NSColor(red: 0.65, green: 0.75, blue: 0.49, alpha: 1), storage: storage) - apply(pattern: #"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'"#, color: NSColor(red: 0.55, green: 0.75, blue: 0.48, alpha: 1), storage: storage) - apply(pattern: #"//.*$|#.*$|/\*[\s\S]*?\*/"#, options: [.anchorsMatchLines], color: NSColor(red: 0.39, green: 0.56, blue: 0.42, alpha: 1), storage: storage) + apply(pattern: #"\b(class|struct|enum|protocol|extension|func|let|var|if|else|guard|switch|case|for|while|return|throw|throws|try|catch|async|await|public|private|internal|protected|static|final|new|import|package|interface|implements|extends|void|boolean|int|long|const|function|def|in|from|as|true|false|null|nil|self|this)\b"#, color: palette.keyword, storage: storage) + apply(pattern: #"@[A-Za-z_][A-Za-z0-9_]*"#, color: palette.annotation, storage: storage) + apply(pattern: #"\b[A-Z][A-Za-z0-9_]*\b"#, color: palette.type, storage: storage) + apply(pattern: #"\b\d+(?:\.\d+)?\b"#, color: palette.number, storage: storage) + apply(pattern: #"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'"#, color: palette.string, storage: storage) + apply(pattern: #"//.*$|#.*$|/\*[\s\S]*?\*/"#, options: [.anchorsMatchLines], color: palette.comment, storage: storage) storage.endEditing() } diff --git a/Sources/Lithe/Views/DatabaseSQLWorkspaceView.swift b/Sources/Lithe/Views/DatabaseSQLWorkspaceView.swift index c69e35cb..090ffe32 100644 --- a/Sources/Lithe/Views/DatabaseSQLWorkspaceView.swift +++ b/Sources/Lithe/Views/DatabaseSQLWorkspaceView.swift @@ -1252,6 +1252,7 @@ private struct DatabaseBackupScheduleEditor: View { } private struct SQLSyntaxEditor: NSViewRepresentable { + @Environment(\.colorScheme) private var colorScheme @Binding var text: String let completions: [String] let onRun: () -> Void @@ -1260,19 +1261,17 @@ private struct SQLSyntaxEditor: NSViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator(text: $text, completions: completions, onRun: onRun, onSelectionChange: onSelectionChange) } func makeNSView(context: Context) -> NSScrollView { + let isDark = colorScheme == .dark let scrollView = NSScrollView() scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true scrollView.autohidesScrollers = true - scrollView.backgroundColor = NSColor(red: 0.075, green: 0.082, blue: 0.102, alpha: 1) let textView = SQLTextView(frame: NSRect(x: 0, y: 0, width: 900, height: 240)) textView.delegate = context.coordinator textView.string = text textView.font = .monospacedSystemFont(ofSize: 12.5, weight: .regular) - textView.textColor = NSColor(white: 0.84, alpha: 1) - textView.backgroundColor = scrollView.backgroundColor - textView.insertionPointColor = .white + applyAppearance(to: textView, in: scrollView, isDark: isDark) textView.textContainerInset = NSSize(width: 11, height: 9) textView.isRichText = false textView.importsGraphics = false @@ -1292,6 +1291,7 @@ private struct SQLSyntaxEditor: NSViewRepresentable { textView.onRun = onRun context.coordinator.onSelectionChange = onSelectionChange context.coordinator.textView = textView + context.coordinator.isDarkAppearance = isDark context.coordinator.highlight() scrollView.documentView = textView return scrollView @@ -1299,10 +1299,17 @@ private struct SQLSyntaxEditor: NSViewRepresentable { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? SQLTextView else { return } + let isDark = colorScheme == .dark + let appearanceChanged = context.coordinator.isDarkAppearance != isDark + context.coordinator.isDarkAppearance = isDark context.coordinator.completions = completions context.coordinator.onSelectionChange = onSelectionChange textView.completionItems = completions textView.onRun = onRun + if appearanceChanged { + applyAppearance(to: textView, in: scrollView, isDark: isDark) + context.coordinator.highlight() + } if textView.string != text, !textView.hasMarkedText(), !context.coordinator.isApplyingChange { let selection = textView.selectedRange() textView.string = text @@ -1313,6 +1320,25 @@ private struct SQLSyntaxEditor: NSViewRepresentable { } } + private func applyAppearance(to textView: NSTextView, in scrollView: NSScrollView, isDark: Bool) { + let background = isDark + ? NSColor(srgbRed: 0.075, green: 0.082, blue: 0.102, alpha: 1) + : NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) + let foreground = isDark + ? NSColor(srgbRed: 0.84, green: 0.84, blue: 0.84, alpha: 1) + : NSColor(srgbRed: 0.16, green: 0.17, blue: 0.19, alpha: 1) + scrollView.backgroundColor = background + textView.backgroundColor = background + textView.textColor = foreground + textView.insertionPointColor = foreground + textView.selectedTextAttributes = [ + .backgroundColor: isDark + ? NSColor(srgbRed: 0.16, green: 0.31, blue: 0.54, alpha: 1) + : NSColor(srgbRed: 0.69, green: 0.82, blue: 0.98, alpha: 1), + .foregroundColor: foreground + ] + } + @MainActor final class Coordinator: NSObject, NSTextViewDelegate { private var text: Binding @@ -1321,6 +1347,7 @@ private struct SQLSyntaxEditor: NSViewRepresentable { var onSelectionChange: (String) -> Void weak var textView: SQLTextView? var isApplyingChange = false + var isDarkAppearance = true init(text: Binding, completions: [String], onRun: @escaping () -> Void, onSelectionChange: @escaping (String) -> Void) { self.text = text @@ -1361,7 +1388,7 @@ private struct SQLSyntaxEditor: NSViewRepresentable { func highlight() { guard let storage = textView?.textStorage else { return } - SQLSyntaxHighlighter.apply(to: storage) + SQLSyntaxHighlighter.apply(to: storage, isDark: isDarkAppearance) } } } @@ -1385,17 +1412,20 @@ private final class SQLTextView: NSTextView { } private enum SQLSyntaxHighlighter { - static func apply(to storage: NSTextStorage) { + static func apply(to storage: NSTextStorage, isDark: Bool) { let range = NSRange(location: 0, length: storage.length) + let textColor = isDark + ? NSColor(srgbRed: 0.84, green: 0.84, blue: 0.84, alpha: 1) + : NSColor(srgbRed: 0.16, green: 0.17, blue: 0.19, alpha: 1) storage.beginEditing() storage.setAttributes([ .font: NSFont.monospacedSystemFont(ofSize: 12.5, weight: .regular), - .foregroundColor: NSColor(white: 0.84, alpha: 1) + .foregroundColor: textColor ], range: range) - apply(pattern: "(?i)\\b(SELECT|FROM|WHERE|INSERT|INTO|VALUES|UPDATE|DELETE|MERGE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|TABLE|VIEW|INDEX|DATABASE|SCHEMA|TRIGGER|PROCEDURE|FUNCTION|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|NOT|NULL|IS|IN|EXISTS|BETWEEN|LIKE|DISTINCT|GROUP|BY|ORDER|HAVING|LIMIT|OFFSET|UNION|ALL|WITH|RETURNING|SET|SHOW|DESCRIBE|DESC|EXPLAIN|PRAGMA|BEGIN|COMMIT|ROLLBACK|GRANT|REVOKE)\\b", color: NSColor(red: 0.43, green: 0.67, blue: 0.98, alpha: 1), in: storage) - apply(pattern: "\\b\\d+(?:\\.\\d+)?\\b", color: NSColor(red: 0.87, green: 0.69, blue: 0.38, alpha: 1), in: storage) - apply(pattern: "'(?:''|[^'])*'|\\\"(?:\\\"\\\"|[^\\\"])*\\\"|`(?:``|[^`])*`", color: NSColor(red: 0.66, green: 0.80, blue: 0.53, alpha: 1), in: storage) - apply(pattern: "--[^\\n]*|/\\*[\\s\\S]*?\\*/", color: NSColor(red: 0.48, green: 0.55, blue: 0.61, alpha: 1), in: storage) + apply(pattern: "(?i)\\b(SELECT|FROM|WHERE|INSERT|INTO|VALUES|UPDATE|DELETE|MERGE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|TABLE|VIEW|INDEX|DATABASE|SCHEMA|TRIGGER|PROCEDURE|FUNCTION|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|AND|OR|NOT|NULL|IS|IN|EXISTS|BETWEEN|LIKE|DISTINCT|GROUP|BY|ORDER|HAVING|LIMIT|OFFSET|UNION|ALL|WITH|RETURNING|SET|SHOW|DESCRIBE|DESC|EXPLAIN|PRAGMA|BEGIN|COMMIT|ROLLBACK|GRANT|REVOKE)\\b", color: isDark ? NSColor(srgbRed: 0.43, green: 0.67, blue: 0.98, alpha: 1) : NSColor(srgbRed: 0.10, green: 0.39, blue: 0.72, alpha: 1), in: storage) + apply(pattern: "\\b\\d+(?:\\.\\d+)?\\b", color: isDark ? NSColor(srgbRed: 0.87, green: 0.69, blue: 0.38, alpha: 1) : NSColor(srgbRed: 0.55, green: 0.39, blue: 0.03, alpha: 1), in: storage) + apply(pattern: "'(?:''|[^'])*'|\\\"(?:\\\"\\\"|[^\\\"])*\\\"|`(?:``|[^`])*`", color: isDark ? NSColor(srgbRed: 0.66, green: 0.80, blue: 0.53, alpha: 1) : NSColor(srgbRed: 0.17, green: 0.48, blue: 0.20, alpha: 1), in: storage) + apply(pattern: "--[^\\n]*|/\\*[\\s\\S]*?\\*/", color: isDark ? NSColor(srgbRed: 0.48, green: 0.55, blue: 0.61, alpha: 1) : NSColor(srgbRed: 0.38, green: 0.42, blue: 0.46, alpha: 1), in: storage) storage.endEditing() } diff --git a/Sources/Lithe/Views/DiffReviewView.swift b/Sources/Lithe/Views/DiffReviewView.swift index 69a87e6e..5404d55d 100644 --- a/Sources/Lithe/Views/DiffReviewView.swift +++ b/Sources/Lithe/Views/DiffReviewView.swift @@ -703,11 +703,11 @@ struct SingleFileDiffRowView: View { .lineLimit(1) Spacer() } - .foregroundStyle(Color(red: 0.50, green: 0.72, blue: 0.98)) + .foregroundStyle(LitheTheme.diffInformationText) .padding(.horizontal, 12) .frame(height: 27) .frame(maxWidth: .infinity) - .background(Color(red: 0.13, green: 0.20, blue: 0.30).opacity(isSearchMatch ? 0.92 : 1)) + .background(LitheTheme.diffInformationBackground.opacity(isSearchMatch ? 0.92 : 1)) .overlay(searchMatchOverlay) } else { HStack(spacing: 0) { @@ -769,7 +769,7 @@ struct SingleFileDiffRowView: View { } private var changeColor: Color { - isAddition ? LitheTheme.success : .red + isAddition ? LitheTheme.success : LitheTheme.error } } @@ -816,11 +816,11 @@ struct DiffRowView: View { .lineLimit(1) Spacer() } - .foregroundStyle(Color(red: 0.50, green: 0.72, blue: 0.98)) + .foregroundStyle(LitheTheme.diffInformationText) .padding(.horizontal, 12) .frame(height: 27) .frame(maxWidth: .infinity) - .background(Color(red: 0.13, green: 0.20, blue: 0.30)) + .background(LitheTheme.diffInformationBackground) .overlay(searchMatchOverlay) } else { HStack(spacing: 0) { @@ -1389,13 +1389,13 @@ enum DiffSyntaxHighlighter { let color: Color } - private static let keywordColor = Color(red: 0.82, green: 0.52, blue: 0.78) - private static let typeColor = Color(red: 0.43, green: 0.72, blue: 0.92) - private static let stringColor = Color(red: 0.58, green: 0.76, blue: 0.49) - private static let numberColor = Color(red: 0.70, green: 0.76, blue: 0.48) - private static let commentColor = Color(red: 0.39, green: 0.57, blue: 0.43) - private static let tagColor = Color(red: 0.82, green: 0.66, blue: 0.37) - private static let baseColor = LitheTheme.primaryText + private static var keywordColor: Color { LitheTheme.skill } + private static var typeColor: Color { LitheTheme.accent } + private static var stringColor: Color { LitheTheme.success } + private static var numberColor: Color { LitheTheme.warning } + private static var commentColor: Color { LitheTheme.secondaryText } + private static var tagColor: Color { LitheTheme.warning } + private static var baseColor: Color { LitheTheme.primaryText } private static let keywords: Set = [ "class", "struct", "enum", "protocol", "extension", "func", "let", "var", "if", "else", diff --git a/Sources/Lithe/Views/DiffSplitPaneView.swift b/Sources/Lithe/Views/DiffSplitPaneView.swift index 6d8e00c0..92de1026 100644 --- a/Sources/Lithe/Views/DiffSplitPaneView.swift +++ b/Sources/Lithe/Views/DiffSplitPaneView.swift @@ -237,11 +237,11 @@ private struct DiffSideRowView: View { .lineLimit(1) Spacer() } - .foregroundStyle(Color(red: 0.50, green: 0.72, blue: 0.98)) + .foregroundStyle(LitheTheme.diffInformationText) .padding(.horizontal, 12) .frame(height: DiffLayoutMetrics.informationRowHeight) .frame(maxWidth: .infinity) - .background(Color(red: 0.13, green: 0.20, blue: 0.30)) + .background(LitheTheme.diffInformationBackground) .overlay(searchMatchOverlay) } else { HStack(spacing: 0) { diff --git a/Sources/Lithe/Views/JavaRunConfigurationEditorView.swift b/Sources/Lithe/Views/JavaRunConfigurationEditorView.swift index 3585ccd5..d6bc291f 100644 --- a/Sources/Lithe/Views/JavaRunConfigurationEditorView.swift +++ b/Sources/Lithe/Views/JavaRunConfigurationEditorView.swift @@ -50,7 +50,6 @@ struct JavaRunConfigurationEditorView: View { } .frame(width: 520, height: 470) .background(LitheTheme.window) - .preferredColorScheme(.dark) .onChange(of: options) { _ in feature.updateOptions(options, for: configuration) } diff --git a/Sources/Lithe/Views/OpenProjectLocationDialog.swift b/Sources/Lithe/Views/OpenProjectLocationDialog.swift index 259849fe..60973a2b 100644 --- a/Sources/Lithe/Views/OpenProjectLocationDialog.swift +++ b/Sources/Lithe/Views/OpenProjectLocationDialog.swift @@ -67,6 +67,5 @@ struct OpenProjectLocationDialog: View { } .frame(width: 470) .background(LitheTheme.window) - .preferredColorScheme(.dark) } } diff --git a/Sources/Lithe/Views/OutputTextView.swift b/Sources/Lithe/Views/OutputTextView.swift index 39c6609a..aac6617a 100644 --- a/Sources/Lithe/Views/OutputTextView.swift +++ b/Sources/Lithe/Views/OutputTextView.swift @@ -33,7 +33,7 @@ struct OutputTextView: View { } else { Text(renderedOutput) .font(.custom("Menlo", size: 11.5)) - .tint(Color(red: 0.35, green: 0.55, blue: 0.90)) + .tint(LitheTheme.accent) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(12) @@ -225,7 +225,7 @@ struct OutputTextView: View { .padding(.vertical, 5) .background(LitheTheme.raised.opacity(0.9)) .clipShape(Capsule()) - .overlay(Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1)) + .overlay(Capsule().stroke(LitheTheme.panelBorder, lineWidth: 1)) } .buttonStyle(.plain) .lithePointer() @@ -248,7 +248,7 @@ struct OutputTextView: View { .padding(.vertical, 5) .background(LitheTheme.raised.opacity(0.92)) .clipShape(Capsule()) - .overlay(Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1)) + .overlay(Capsule().stroke(LitheTheme.panelBorder, lineWidth: 1)) } .buttonStyle(.plain) .lithePointer() @@ -344,7 +344,7 @@ private struct ScrollPositionTracker: NSViewRepresentable { /// 终端、Maven 构建输出与运行输出共用。 enum ANSIOutputRenderer { struct Style { - var foreground = Color(red: 0.82, green: 0.84, blue: 0.86) + var foreground = LitheTheme.primaryText var background: Color? var bold = false } diff --git a/Sources/Lithe/Views/RootView.swift b/Sources/Lithe/Views/RootView.swift index d70269b2..c2c1c603 100644 --- a/Sources/Lithe/Views/RootView.swift +++ b/Sources/Lithe/Views/RootView.swift @@ -228,18 +228,11 @@ extension ProjectSessionManager: ProjectWindowSessionHandling { final class LitheWindowCoordinator: NSObject, NSWindowDelegate { var projectSessions: any ProjectWindowSessionHandling weak var window: NSWindow? - private let registerWindow: @MainActor (NSWindow) -> Void private var layout: LitheWindowLayout? private var restoredWorkspaceFrame: NSRect? - init( - projectSessions: any ProjectWindowSessionHandling, - registerWindow: @escaping @MainActor (NSWindow) -> Void = { window in - (NSApplication.shared.delegate as? LitheAppDelegate)?.registerMainWindow(window) - } - ) { + init(projectSessions: any ProjectWindowSessionHandling) { self.projectSessions = projectSessions - self.registerWindow = registerWindow } func attach(to window: NSWindow?, layout: LitheWindowLayout) { @@ -247,7 +240,6 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { if self.window !== window { self.window = window window.delegate = self - registerWindow(window) self.layout = nil restoredWorkspaceFrame = nil } @@ -278,12 +270,9 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { func windowShouldClose(_ sender: NSWindow) -> Bool { if projectSessions.hasActiveProject { projectSessions.closeActiveProject() - } else { - // Keep the SwiftUI scene alive so a Dock reopen event can bring - // the welcome window back instead of leaving a headless process. - sender.orderOut(nil) + return false } - return false + return true } private func apply(_ layout: LitheWindowLayout, to window: NSWindow) { diff --git a/Sources/Lithe/Views/SettingsView.swift b/Sources/Lithe/Views/SettingsView.swift index fd18c5dd..97977be0 100644 --- a/Sources/Lithe/Views/SettingsView.swift +++ b/Sources/Lithe/Views/SettingsView.swift @@ -41,7 +41,6 @@ struct SettingsView: View { } .frame(width: 820, height: 620) .background(LitheTheme.window) - .preferredColorScheme(.dark) .onAppear { syncVisibilityDrafts() syncRuntimeDrafts() @@ -243,6 +242,36 @@ struct SettingsView: View { private var generalSettings: some View { VStack(alignment: .leading, spacing: 18) { + group("Appearance") { + row("Color theme") { + Picker("", selection: $settings.colorTheme) { + ForEach(AppColorTheme.allCases) { theme in + Text(LocalizedStringKey(theme.title)).tag(theme) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 180, alignment: .leading) + .lithePointer() + } + + row("Appearance mode") { + Picker("", selection: $settings.themePreference) { + ForEach(AppThemePreference.allCases) { preference in + Text(LocalizedStringKey(preference.title)).tag(preference) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 260) + .lithePointer() + } + + Text("Choose a color theme and whether Lithe follows the system appearance.") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + } + group("Language") { Picker("Language", selection: $settings.language) { ForEach(AppLanguage.allCases) { language in diff --git a/Sources/Lithe/Views/TerminalView.swift b/Sources/Lithe/Views/TerminalView.swift index 0b38627e..aff0d7fe 100644 --- a/Sources/Lithe/Views/TerminalView.swift +++ b/Sources/Lithe/Views/TerminalView.swift @@ -13,7 +13,7 @@ struct TerminalView: View { terminalToolbar terminalCanvas } - .background(Color(red: 0.071, green: 0.075, blue: 0.081)) + .background(LitheTheme.editor) .task(id: session.id) { requestInputFocus() } @@ -191,10 +191,10 @@ struct TerminalView: View { .padding(.horizontal, 8) .padding(.vertical, 8) } else { - Color(red: 0.071, green: 0.075, blue: 0.081) + LitheTheme.editor } } - .background(Color(red: 0.071, green: 0.075, blue: 0.081)) + .background(LitheTheme.editor) } private var existingShells: [String] { diff --git a/Tests/LitheTests/CommitMessageTests.swift b/Tests/LitheTests/CommitMessageTests.swift index 9eebee16..4e8323eb 100644 --- a/Tests/LitheTests/CommitMessageTests.swift +++ b/Tests/LitheTests/CommitMessageTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @testable import Lithe @@ -307,6 +308,69 @@ private let testCommitMessageInput = CommitMessageInput( @Suite("Commit message settings") @MainActor struct CommitMessageSettingsTests { + @Test + func themeSettingsPersistAndDefaultToDarkLithe() { + let store = InMemoryKeyValueStore() + let initialSettings = AppSettings(store: store) + #expect(initialSettings.colorTheme == .lithe) + #expect(initialSettings.themePreference == .dark) + #expect(AppThemeRuntime.shared.activeTheme == .lithe) + + initialSettings.colorTheme = .linear + initialSettings.themePreference = .light + #expect(AppThemeRuntime.shared.activeTheme == .linear) + let reloadedSettings = AppSettings(store: store) + #expect(reloadedSettings.colorTheme == .linear) + #expect(reloadedSettings.themePreference == .light) + + reloadedSettings.restoreDefaults() + #expect(reloadedSettings.colorTheme == .lithe) + #expect(reloadedSettings.themePreference == .dark) + } + + @Test + func bundledThemeTokensMatchTheirDefinitions() { + let cases: [(AppColorTheme, Bool, LitheTheme.ResolvedColorToken, UInt32)] = [ + (.codex, true, .editor, 0x111111), + (.codex, true, .primaryText, 0xfcfcfc), + (.codex, true, .accent, 0x0169cc), + (.codex, true, .success, 0x00a240), + (.codex, true, .error, 0xe02e2a), + (.codex, true, .skill, 0xb06dff), + (.codex, false, .editor, 0xffffff), + (.codex, false, .primaryText, 0x0d0d0d), + (.codex, false, .accent, 0x0169cc), + (.codex, false, .success, 0x00a240), + (.codex, false, .error, 0xe02e2a), + (.codex, false, .skill, 0x751ed9), + (.linear, true, .editor, 0x0f0f11), + (.linear, true, .primaryText, 0xe3e4e6), + (.linear, true, .accent, 0x606acc), + (.linear, true, .success, 0x69c967), + (.linear, true, .error, 0xff7e78), + (.linear, true, .skill, 0xc2a1ff), + (.linear, false, .editor, 0xfcfcfd), + (.linear, false, .primaryText, 0x1b1b1b), + (.linear, false, .accent, 0x5e6ad2), + (.linear, false, .success, 0x52a450), + (.linear, false, .error, 0xc94446), + (.linear, false, .skill, 0x8160d8) + ] + + for (theme, isDark, token, expected) in cases { + let color = LitheTheme.nsColor(token, theme: theme, isDark: isDark) + #expect(rgbHex(color) == expected) + } + } + + private func rgbHex(_ color: NSColor) -> UInt32? { + guard let color = color.usingColorSpace(.sRGB) else { return nil } + let red = UInt32((color.redComponent * 255).rounded()) + let green = UInt32((color.greenComponent * 255).rounded()) + let blue = UInt32((color.blueComponent * 255).rounded()) + return (red << 16) | (green << 8) | blue + } + @Test func projectOpenBehaviorPersistsAndDefaultsToAsk() { let store = InMemoryKeyValueStore() diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 2d265366..bac5f785 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -9,10 +9,7 @@ struct LitheCoreLogicTests { @MainActor func closingAWorkspaceWindowClosesTheProjectInsteadOfTheWindow() { let sessions = TestProjectWindowSessions(hasActiveProject: true) - let coordinator = LitheWindowCoordinator( - projectSessions: sessions, - registerWindow: { _ in } - ) + let coordinator = LitheWindowCoordinator(projectSessions: sessions) let window = NSWindow() #expect(!coordinator.windowShouldClose(window)) @@ -21,28 +18,28 @@ struct LitheCoreLogicTests { @Test @MainActor - func closingTheWelcomeWindowKeepsItAvailableForDockReopen() { + func closingTheWelcomeWindowAllowsTheApplicationToTerminate() { let sessions = TestProjectWindowSessions(hasActiveProject: false) - let coordinator = LitheWindowCoordinator( - projectSessions: sessions, - registerWindow: { _ in } - ) + let coordinator = LitheWindowCoordinator(projectSessions: sessions) let window = NSWindow() - window.orderFront(nil) - #expect(!coordinator.windowShouldClose(window)) - #expect(!window.isVisible) + #expect(coordinator.windowShouldClose(window)) #expect(sessions.closeActiveProjectCallCount == 0) } + @Test + @MainActor + func applicationTerminatesAfterItsLastWindowCloses() { + let appDelegate = LitheAppDelegate() + + #expect(appDelegate.applicationShouldTerminateAfterLastWindowClosed(NSApplication.shared)) + } + @Test @MainActor func welcomeAndWorkspaceUseDistinctWindowSizes() { let sessions = TestProjectWindowSessions(hasActiveProject: false) - let coordinator = LitheWindowCoordinator( - projectSessions: sessions, - registerWindow: { _ in } - ) + let coordinator = LitheWindowCoordinator(projectSessions: sessions) let window = NSWindow() coordinator.attach(to: window, layout: .welcome) @@ -74,10 +71,7 @@ struct LitheCoreLogicTests { @MainActor func workspaceTitleBarZoomsToTheVisibleScreenAndRestores() { let sessions = TestProjectWindowSessions(hasActiveProject: true) - let coordinator = LitheWindowCoordinator( - projectSessions: sessions, - registerWindow: { _ in } - ) + let coordinator = LitheWindowCoordinator(projectSessions: sessions) let window = NSWindow() coordinator.attach(to: window, layout: .workspace) let restoredFrame = NSRect(x: 120, y: 70, width: 1000, height: 680) @@ -91,24 +85,6 @@ struct LitheCoreLogicTests { #expect(window.frame == restoredFrame) } - @Test - @MainActor - func dockReopenShowsTheHiddenWelcomeWindow() { - let appDelegate = LitheAppDelegate() - let window = NSWindow() - appDelegate.registerMainWindow(window) - window.orderOut(nil) - defer { window.orderOut(nil) } - - let shouldPerformDefaultReopen = appDelegate.applicationShouldHandleReopen( - NSApplication.shared, - hasVisibleWindows: false - ) - - #expect(!shouldPerformDefaultReopen) - #expect(window.isVisible) - } - @Test func databaseSidecarParsesCapabilitiesWithoutStartingUntilRequested() throws { let runner = RecordingProcessRunner { request in