diff --git a/.claude/rules/data-sync-storage.md b/.claude/rules/data-sync-storage.md index 0dbe8eb1f0..2331ba46b3 100644 --- a/.claude/rules/data-sync-storage.md +++ b/.claude/rules/data-sync-storage.md @@ -25,6 +25,7 @@ This path holds user data. A silent wrong answer here looks exactly like an empt | --- | --- | --- | | Connection passwords | Keychain | `ConnectionStorage` | | Preferences | UserDefaults | `AppSettingsStorage`, `AppSettingsManager` | +| Editor and grid fonts | UserDefaults, device-local, never synced | `TypographySettings` through `AppSettingsManager` | | Query history | SQLite FTS5 | `QueryHistoryStorage` | | Tab state | JSON | `TabPersistenceCoordinator`, `TabDiskActor` | | Filter defaults and presets | UserDefaults | `FilterSettingsStorage`, `FilterPresetStorage` | diff --git a/.claude/rules/editor.md b/.claude/rules/editor.md index 2f1cb266a3..3b04fd26fb 100644 --- a/.claude/rules/editor.md +++ b/.claude/rules/editor.md @@ -8,7 +8,11 @@ paths: # Editor and theme -- **`ThemeEngine` is the single source of editor colors and fonts**; `ThemeEngine.makeEditorTheme()` builds the `TableProEditorTheme` adapter. +- **`ThemeEngine` is the single source of theme colors**; `ThemeEngine.makeEditorTheme()` builds the `TableProEditorTheme` adapter. +- **A slot exists only with a reader.** `ThemeSlot` is the one registry the gate, the theme editor and the decoder enumerate, and `ThemeSlotCoverageTests` fails a slot nothing reads. A theme file must declare every slot of its schema, as `#hex` or `system:`. +- **Two color tiers.** `palette[slot]` is a dynamic color that answers for the theme in effect; use it wherever a view holds a color. `resolved[slot]` is a snapshot for code that bakes one (`CALayer`, the editor theme, the grid's cell drawing), paired with an `AppEvents.shared.themeChanged` subscription. +- **A theme change has to reach the view.** A SwiftUI view that reads the engine observes it (`@ObservedObject private var themeEngine = ThemeEngine.shared`), because SwiftUI caches `Color(nsColor:)` by object identity; an AppKit view subscribes to `themeChanged` and redisplays. +- **Fonts are not theme.** `TypographySettings` is device-local and stays out of `AppSettingsCategory.synced`: sync replaces a category wholesale, so an older Mac would reset it and each zoom press would push a record. - **Two font domains.** A view that also wears the editor's colors (the SQL editor, `JSONCodeEditor`, previews) uses `editorFonts`. Every control that shows or edits a stored value uses `ThemeEngine.valueFont` or `valueFontSwiftUI` (the Data Grid Font), so a value reads the same in the grid, the inspector and a popover; a system text style there looks right only while the two settings are equal. `InspectorFieldRow` sets it for the whole inspector, and its `FieldEditorKind` switch is exhaustive on purpose. - **Multi-cursor lives in `cursorPositions: [CursorPosition]`.** - **`TableProEditor` is still in Swift 5 language mode** (`.swiftLanguageMode(.v5)`); moving it to Swift 6 is its own change with its own tests. diff --git a/AGENTS.md b/AGENTS.md index 954e49f53f..1d4c3ee26c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ swiftlint lint --strict **Change tracking.** A cell edit goes to `DataChangeManager`; Save turns it into statements through `SQLStatementGenerator`; `AnyChangeManager` abstracts the concrete managers. Undo comes from `StructureChangeManager`'s private `UndoManager` and `ConnectionWorkspace.undoManager`. -**Editor.** `ThemeEngine` owns the active theme, editor colors and fonts. `CompletionEngine` is framework-agnostic and `QueryCompletionAdapter` bridges it to the editor. Editor tabs are drawn by `EditorTabStrip`, not native window tabs. Details: `.claude/rules/editor.md`. +**Editor and theme.** `ThemeCatalog` owns the theme list, `ThemeResolver` picks the light and dark pair, and `ThemeEngine` holds the palette and the font caches; the fonts themselves are `TypographySettings`. `CompletionEngine` is framework-agnostic and `QueryCompletionAdapter` bridges it to the editor. Editor tabs are drawn by `EditorTabStrip`, not native window tabs. Details: `.claude/rules/editor.md`. **Storage.** Passwords in the Keychain, preferences in UserDefaults, query history in SQLite FTS5, tab state as JSON. The full table of which store owns what is in `.claude/rules/data-sync-storage.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6950de15ce..f9feaf852a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Header, grid line, selection and focus colors in the theme editor. +- Panel and status colors in the theme editor, covering the results, inspector, structure, compare and query plan panes. +- Themes that name a system color for a slot, so the built-in themes keep the system's own contrast settings. +- Reason shown in Settings > Appearance for a theme file that could not be loaded. + +### Changed + +- Theme file format 2. Themes written for earlier versions are not read and need to be recreated. +- Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac. +- Editor font size range of 10 to 24 points everywhere, including zoom. + +### Removed + +- Interface, Sidebar and Toolbar color groups from the theme editor and the theme file. +- Fonts from the theme file. + +### Fixed + +- Interface, Sidebar and Toolbar colors having no effect anywhere in the app. +- Line Number color having no effect on the editor gutter. +- Default Light used for the dark theme when the chosen dark theme could not be loaded. +- Editor zoom lost on the next appearance or theme change. +- DDL, trigger and SQL import previews keeping the previous theme's colors. +- Query history preview ignoring the theme and the editor font. +- Theme editor changing the active theme instead of the theme selected for the slot being edited. +- Malformed theme files loading as Default Light under their own name. +- Color with a typo in it rendering as a different color instead of being reported. +- Content panes outside the editor and the data grid ignoring the theme. +- JSON and PHP tree values colored differently from the same values in the row inspector. +- Autocomplete icon colors ignoring the theme. + ## [0.80.0] - 2026-10-11 Google Cloud IAM sign-in for MySQL and PostgreSQL on Cloud SQL, with no Cloud SQL Auth Proxy. diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift b/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift index e3b89a3a96..3b64171d31 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -162,10 +162,11 @@ public extension SourceEditorConfiguration { (controller.textView as? SourceEditorTextView)?.statementHighlightColor = theme.statementHighlight - controller.gutterView.textColor = theme.text.color.withAlphaComponent(0.35) - controller.gutterView.statementRunRibbon.glyphColor = theme.text.color.withAlphaComponent(0.35) + let gutterColor = theme.lineNumber ?? theme.text.color.withAlphaComponent(0.35) + controller.gutterView.textColor = gutterColor + controller.gutterView.statementRunRibbon.glyphColor = gutterColor controller.gutterView.statementRunRibbon.hoveredGlyphColor = theme.text.color - controller.gutterView.foldingRibbon.chevronColor = theme.text.color.withAlphaComponent(0.35) + controller.gutterView.foldingRibbon.chevronColor = gutterColor controller.gutterView.foldingRibbon.collapsedChevronColor = theme.text.color.withAlphaComponent(0.65) controller.gutterView.foldingRibbon.hoveredChevronColor = theme.text.color controller.gutterView.foldingRibbon.foldExtentColor = theme.text.color.withAlphaComponent(0.2) diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift index 657d4a6914..4964d8077e 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift @@ -35,6 +35,10 @@ public struct EditorTheme: Equatable { public var statementHighlight: NSColor public var selection: NSColor + + /// The gutter's line numbers. `nil` keeps the derived look: the text colour at 35% alpha. + public var lineNumber: NSColor? + public var keywords: Attribute public var commands: Attribute public var types: Attribute @@ -56,6 +60,7 @@ public struct EditorTheme: Equatable { lineHighlight: NSColor, statementHighlight: NSColor = .clear, selection: NSColor, + lineNumber: NSColor? = nil, keywords: Attribute, commands: Attribute, types: Attribute, @@ -76,6 +81,7 @@ public struct EditorTheme: Equatable { self.lineHighlight = lineHighlight self.statementHighlight = statementHighlight self.selection = selection + self.lineNumber = lineNumber self.keywords = keywords self.commands = commands self.types = types diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 5c2b8ca0fd..32651b5b9e 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -69,7 +69,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// Only a screenshot run overrides the mode, so only a screenshot run resolves it twice. if let screenshotMode = ScreenshotEnvironment.appearanceMode { let appearanceSettings = AppSettingsManager.shared.appearance - ThemeEngine.shared.updateAppearanceAndTheme( + ThemeEngine.shared.apply( mode: screenshotMode, lightThemeId: appearanceSettings.preferredLightThemeId, darkThemeId: appearanceSettings.preferredDarkThemeId diff --git a/TablePro/Core/Autocomplete/SQLCompletionItem.swift b/TablePro/Core/Autocomplete/SQLCompletionItem.swift index 2a50f3089e..6e06f929a8 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionItem.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionItem.swift @@ -36,16 +36,17 @@ enum SQLCompletionKind: String, CaseIterable { } /// Color for the icon + @MainActor var iconColor: NSColor { switch self { - case .keyword: return .systemBlue - case .table: return .systemTeal - case .view: return .systemPurple - case .column: return .systemOrange - case .function: return .systemPink - case .schema: return .systemGreen - case .alias: return .systemGray - case .operator: return .systemIndigo + case .keyword: return ThemeEngine.shared.palette[.syntaxKeyword] + case .table: return ThemeEngine.shared.palette[.syntaxType] + case .view: return ThemeEngine.shared.palette[.syntaxType] + case .column: return ThemeEngine.shared.palette[.syntaxNull] + case .function: return ThemeEngine.shared.palette[.syntaxFunction] + case .schema: return ThemeEngine.shared.palette[.syntaxType] + case .alias: return ThemeEngine.shared.palette[.syntaxNull] + case .operator: return ThemeEngine.shared.palette[.syntaxOperator] case .favorite: return .systemYellow } } diff --git a/TablePro/Core/Events/AppEvents.swift b/TablePro/Core/Events/AppEvents.swift index 5c10196086..8b7c3ef9f6 100644 --- a/TablePro/Core/Events/AppEvents.swift +++ b/TablePro/Core/Events/AppEvents.swift @@ -13,7 +13,11 @@ final class AppEvents { // MARK: - Theme & Accessibility - let themeChanged = PassthroughSubject() + /// Carries the palette revision and the effective appearance. A consumer that snapshots + /// colours or fonts needs both: a forced Light/Dark switch never changes the theme, and a + /// theme switch never changes the appearance. Subscribing reaches a view that is alive but + /// detached from every window, which a walk of the window tree would miss. + let themeChanged = PassthroughSubject() let accessibilityTextSizeChanged = PassthroughSubject() diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift index e2621e5113..0441981841 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift @@ -144,10 +144,18 @@ extension MainSplitViewController { /// these first, so this is what Zoom In means everywhere else. extension MainSplitViewController: ZoomCommandResponding { @objc func zoomIn(_ sender: Any?) { - ThemeEngine.shared.adjustEditorFontSize(by: 1) + adjustEditorTextSize(by: 1) } @objc func zoomOut(_ sender: Any?) { - ThemeEngine.shared.adjustEditorFontSize(by: -1) + adjustEditorTextSize(by: -1) + } + + private func adjustEditorTextSize(by delta: Int) { + var typography = AppSettingsManager.shared.typography + let size = TypographySettings.clamp(typography.editorFontSize + delta) + guard size != typography.editorFontSize else { return } + typography.editorFontSize = size + AppSettingsManager.shared.typography = typography } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index c17d0b3b0d..60fe381bae 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -830,8 +830,8 @@ internal final class MainSplitViewController: NSSplitViewController { /// intermediate value instead of letting one run-loop turn settle on the final one. private func refreshPanes(of workspace: ConnectionWorkspace) { workspace.panes.sidebar.rootView = AnyView(buildSidebarView(for: workspace)) - workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace)) - workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace)) + workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace).themedContent()) + workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace).themedContent()) workspace.panes.assistant.rootView = AnyView(buildAssistantView(for: workspace)) refreshAgentPanes(of: workspace) refreshTabStripPane(of: workspace) @@ -1055,7 +1055,7 @@ internal final class MainSplitViewController: NSSplitViewController { /// publishes those actions. func rebuildTrailingPanes() { guard let selected = workspaces.selected else { return } - selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected)) + selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected).themedContent()) selected.panes.assistant.rootView = AnyView(buildAssistantView(for: selected)) } diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 09af972600..69289faee5 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -25,7 +25,7 @@ final class AppSettingsManager: ObservableObject { @Published var appearance: AppearanceSettings { didSet { storage.saveAppearance(appearance) - themeEngine.updateAppearanceAndTheme( + themeEngine.apply( mode: appearance.appearanceMode, lightThemeId: appearance.preferredLightThemeId, darkThemeId: appearance.preferredDarkThemeId @@ -37,19 +37,21 @@ final class AppSettingsManager: ObservableObject { @Published var editor: EditorSettings { didSet { storage.saveEditor(editor) - themeEngine.updateEditorSettings( - highlightCurrentLine: editor.highlightCurrentLine, - highlightCurrentStatement: editor.highlightCurrentStatement, - showLineNumbers: editor.showLineNumbers, - tabWidth: editor.clampedTabWidth, - - wordWrap: editor.wordWrap - ) appEvents.editorSettingsChanged.send(()) syncTracker.markDirty(.settings, id: AppSettingsCategory.editor) } } + /// Device-local: it is deliberately absent from `AppSettingsCategory.synced`, so a zoom press + /// never pushes an iCloud record and a Mac on an older build cannot reset it. + @Published var typography: TypographySettings { + didSet { + guard typography != oldValue else { return } + storage.saveTypography(typography) + themeEngine.apply(typography: typography) + } + } + @Published var notifications: NotificationSettings { didSet { guard !isValidating else { return } @@ -245,23 +247,17 @@ final class AppSettingsManager: ObservableObject { self.sync = storage.loadSync() self.mcp = storage.loadMCP() self.notifications = storage.loadNotifications() + self.typography = storage.loadTypography() general.language.apply() - themeEngine.updateAppearanceAndTheme( + themeEngine.apply(typography: typography) + themeEngine.apply( mode: appearance.appearanceMode, lightThemeId: appearance.preferredLightThemeId, darkThemeId: appearance.preferredDarkThemeId ) - themeEngine.updateEditorSettings( - highlightCurrentLine: editor.highlightCurrentLine, - highlightCurrentStatement: editor.highlightCurrentStatement, - showLineNumbers: editor.showLineNumbers, - tabWidth: editor.clampedTabWidth, - wordWrap: editor.wordWrap - ) - dateFormattingService.updateFormat(dataGrid.dateFormat) if ai.enabled, ai.providers.contains(where: { $0.type == .copilot }) { @@ -302,6 +298,7 @@ final class AppSettingsManager: ObservableObject { ai = .default sync = .default mcp = .default + typography = .default storage.resetToDefaults() connectionListPreferences.resetShowsRecent() SoftwareUpdater.shared.resetUpdatePreferences() diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index aa7a11dcd0..45bb685f25 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -32,6 +32,7 @@ final class AppSettingsStorage: Sendable { static let sync = "com.TablePro.settings.sync" static let mcp = "com.TablePro.settings.mcp" static let notifications = "com.TablePro.settings.notifications" + static let typography = "com.TablePro.settings.typography" static let hasSeenWelcomeSheet = "com.TablePro.settings.hasCompletedOnboarding" static let startupReopenMigration = "com.TablePro.settings.didMigrateStartupToReopenLast" static let jsonFieldHeightMigration = "com.TablePro.settings.didMigrateJsonFieldHeightKey" @@ -85,6 +86,29 @@ final class AppSettingsStorage: Sendable { load(key: Keys.appearance, default: .default) } + /// Fonts moved out of the theme file, and the theme file format they lived in is no longer + /// read. Without this every user who had picked a font or zoomed the editor would silently be + /// returned to System Mono 13 on upgrade, so their sizes are lifted once from whichever theme + /// their slot pointed at, and the theme file itself is left untouched. + func loadTypography() -> TypographySettings { + if let stored = defaults.data(forKey: Keys.typography) { + do { + return try decoder.decode(TypographySettings.self, from: stored) + } catch { + Self.logger.error("Failed to decode typography settings: \(error)") + return .default + } + } + + let carried = LegacyThemeFonts.read(preferring: loadAppearance()) + saveTypography(carried) + return carried + } + + func saveTypography(_ settings: TypographySettings) { + save(settings, key: Keys.typography) + } + func saveAppearance(_ settings: AppearanceSettings) { save(settings, key: Keys.appearance) } @@ -230,6 +254,7 @@ final class AppSettingsStorage: Sendable { saveAI(.default) saveSync(.default) saveMCP(.default) + saveTypography(.default) defaults.removeObject(forKey: PreferenceKeys.selectedSettingsPane.name) defaults.removeObject(forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) defaults.removeObject(forKey: PreferenceKeys.rowInspectorTextFieldHeight.name) diff --git a/TablePro/Core/Vim/VimCursorManager.swift b/TablePro/Core/Vim/VimCursorManager.swift index 0b06236f07..998196f567 100644 --- a/TablePro/Core/Vim/VimCursorManager.swift +++ b/TablePro/Core/Vim/VimCursorManager.swift @@ -154,7 +154,7 @@ final class VimCursorManager { } else { let layer = CALayer() layer.contentsScale = textView.window?.backingScaleFactor ?? 2.0 - layer.backgroundColor = ThemeEngine.shared.colors.editor.cursor.withAlphaComponent(0.4).cgColor + layer.backgroundColor = ThemeEngine.shared.resolved[.editorCursor].withAlphaComponent(0.4).cgColor layer.frame = frame if !isPaused { diff --git a/TablePro/Core/Vim/VimTextBufferAdapter.swift b/TablePro/Core/Vim/VimTextBufferAdapter.swift index cf6e958d33..4d012d8074 100644 --- a/TablePro/Core/Vim/VimTextBufferAdapter.swift +++ b/TablePro/Core/Vim/VimTextBufferAdapter.swift @@ -414,7 +414,7 @@ final class VimTextBufferAdapter: VimTextBuffer { } func indentWidth() -> Int { - ThemeEngine.shared.tabWidth + AppSettingsManager.shared.editor.clampedTabWidth } // MARK: - Helpers diff --git a/TablePro/Models/Settings/LegacyThemeFonts.swift b/TablePro/Models/Settings/LegacyThemeFonts.swift new file mode 100644 index 0000000000..4a767deb2f --- /dev/null +++ b/TablePro/Models/Settings/LegacyThemeFonts.swift @@ -0,0 +1,67 @@ +import Foundation +import os + +/// Reads the `fonts` object out of a theme file written before fonts moved into settings. It is +/// the only code that still opens the old format, it runs once, and it writes nothing back: the +/// theme file keeps its own shape and is rejected by the current loader like any other old file. +internal enum LegacyThemeFonts { + private static let logger = Logger(subsystem: "com.TablePro", category: "LegacyThemeFonts") + + private struct Document: Decodable { + struct Fonts: Decodable { + var editorFontFamily: String? + var editorFontSize: Int? + var dataGridFontFamily: String? + var dataGridFontSize: Int? + } + + var fonts: Fonts? + } + + internal static func read(preferring appearance: AppearanceSettings) -> TypographySettings { + let candidates = [appearance.preferredLightThemeId, appearance.preferredDarkThemeId] + + for id in candidates { + guard let fonts = fonts(forThemeId: id) else { continue } + return settings(from: fonts) + } + + return .default + } + + private static func fonts(forThemeId id: String) -> Document.Fonts? { + let directories = [userThemesDirectory, userThemesDirectory.appendingPathComponent("Registry")] + + (Bundle.main.resourceURL.map { [$0] } ?? []) + + for directory in directories { + let url = directory.appendingPathComponent("\(id).json", isDirectory: false) + guard FileManager.default.fileExists(atPath: url.path) else { continue } + + do { + let document = try JSONDecoder().decode(Document.self, from: try Data(contentsOf: url)) + guard let fonts = document.fonts else { continue } + return fonts + } catch { + logger.error("Could not read legacy fonts from \(url.lastPathComponent): \(error)") + } + } + + return nil + } + + private static func settings(from fonts: Document.Fonts) -> TypographySettings { + let fallback = TypographySettings.default + + return TypographySettings( + editorFontFamily: fonts.editorFontFamily ?? fallback.editorFontFamily, + editorFontSize: TypographySettings.clamp(fonts.editorFontSize ?? fallback.editorFontSize), + dataGridFontFamily: fonts.dataGridFontFamily ?? fallback.dataGridFontFamily, + dataGridFontSize: TypographySettings.clamp(fonts.dataGridFontSize ?? fallback.dataGridFontSize) + ) + } + + private static var userThemesDirectory: URL { + AppStorageEnvironment.shared.applicationSupportRoot + .appendingPathComponent("TablePro/Themes", isDirectory: true) + } +} diff --git a/TablePro/Models/Settings/TypographySettings.swift b/TablePro/Models/Settings/TypographySettings.swift new file mode 100644 index 0000000000..dfac2c91ee --- /dev/null +++ b/TablePro/Models/Settings/TypographySettings.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Device-local on purpose. Fonts used to live inside the theme file, so a zoom shortcut rewrote +/// and saved a whole theme. Moving them into a synced settings category would be worse: sync +/// replaces the whole struct on apply, so a Mac still on an older build would push a payload +/// without these keys and reset the fonts on every other Mac, and each zoom press would push a +/// record. The one range here is shared by the pickers, the zoom commands and the font caches; +/// three disagreeing ranges are what let zoom store a size the renderer clamped away. +internal struct TypographySettings: Codable, Equatable, Sendable { + internal var editorFontFamily: String + internal var editorFontSize: Int + internal var dataGridFontFamily: String + internal var dataGridFontSize: Int + + internal static let sizeRange = 10...24 + internal static let systemMonoFamily = "System Mono" + + internal static let `default` = TypographySettings( + editorFontFamily: systemMonoFamily, + editorFontSize: 13, + dataGridFontFamily: systemMonoFamily, + dataGridFontSize: 13 + ) + + internal init( + editorFontFamily: String = systemMonoFamily, + editorFontSize: Int = 13, + dataGridFontFamily: String = systemMonoFamily, + dataGridFontSize: Int = 13 + ) { + self.editorFontFamily = editorFontFamily + self.editorFontSize = editorFontSize + self.dataGridFontFamily = dataGridFontFamily + self.dataGridFontSize = dataGridFontSize + } + + internal init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = TypographySettings.default + + editorFontFamily = try container.decodeIfPresent(String.self, forKey: .editorFontFamily) + ?? fallback.editorFontFamily + editorFontSize = try container.decodeIfPresent(Int.self, forKey: .editorFontSize) + ?? fallback.editorFontSize + dataGridFontFamily = try container.decodeIfPresent(String.self, forKey: .dataGridFontFamily) + ?? fallback.dataGridFontFamily + dataGridFontSize = try container.decodeIfPresent(Int.self, forKey: .dataGridFontSize) + ?? fallback.dataGridFontSize + } + + internal var clampedEditorFontSize: Int { + Self.clamp(editorFontSize) + } + + internal var clampedDataGridFontSize: Int { + Self.clamp(dataGridFontSize) + } + + internal static func clamp(_ size: Int) -> Int { + min(max(size, sizeRange.lowerBound), sizeRange.upperBound) + } +} diff --git a/TablePro/Models/UI/FilterableTreeNode.swift b/TablePro/Models/UI/FilterableTreeNode.swift index 93fca9979a..02d2a2c227 100644 --- a/TablePro/Models/UI/FilterableTreeNode.swift +++ b/TablePro/Models/UI/FilterableTreeNode.swift @@ -178,15 +178,16 @@ internal enum TreeValueTone: Equatable, Sendable { case reference case muted + @MainActor var color: NSColor { switch self { - case .container: return .systemBlue - case .string: return .systemRed - case .number: return .systemPurple - case .literal: return .systemOrange - case .serialized: return .systemTeal + case .container: return ThemeEngine.shared.palette[.syntaxKeyword] + case .string: return ThemeEngine.shared.palette[.syntaxString] + case .number: return ThemeEngine.shared.palette[.syntaxNumber] + case .literal: return ThemeEngine.shared.palette[.syntaxNull] + case .serialized: return ThemeEngine.shared.palette[.syntaxType] case .reference: return .systemGray - case .muted: return .secondaryLabelColor + case .muted: return ThemeEngine.shared.palette[.panelSecondaryText] } } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 95df9fcfef..9d4955a3fe 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -768,6 +768,9 @@ } } } + }, + "\"%@\" is a reserved theme identifier." : { + }, "“%@” is compressed." : { "localizations" : { @@ -873,6 +876,9 @@ } } } + }, + "\"%@\" is not a system color TablePro knows." : { + }, "\"%@\" is not a URL. Enter an endpoint such as http://localhost:8000." : { "extractionState" : "manual", @@ -895,6 +901,9 @@ } } } + }, + "\"%@\" is not a valid color." : { + }, "\"%@\" is not a valid entry point name." : { "extractionState" : "manual", @@ -919,6 +928,9 @@ } } } + }, + "\"%@\" is not a valid theme identifier." : { + }, "\"%@\" is not an AWS region" : { "extractionState" : "manual", @@ -25362,6 +25374,12 @@ } } } + }, + "Applies to grid cells, the inspector, cell popovers and the row diff." : { + + }, + "Applies to the SQL editor, the JSON viewer's Text mode, and the previews." : { + }, "Apply" : { "localizations" : { @@ -28550,6 +28568,7 @@ } }, "Auto Increment" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -30034,6 +30053,7 @@ } }, "Badge Background" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -30074,6 +30094,7 @@ } }, "Badges" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -30956,6 +30977,7 @@ "generatesSymbol" : false }, "Border" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -33633,6 +33655,7 @@ "generatesSymbol" : false }, "Card Background" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -42255,6 +42278,7 @@ } }, "Colors" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -48362,6 +48386,7 @@ } }, "Control Background" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -55310,6 +55335,9 @@ } } } + }, + "Current Statement" : { + }, "Current value" : { "localizations" : { @@ -55833,6 +55861,9 @@ } } } + }, + "Custom theme" : { + }, "Custom…" : { "localizations" : { @@ -59359,6 +59390,9 @@ } } } + }, + "Default Dark" : { + }, "Default entry point" : { "localizations" : { @@ -59409,6 +59443,9 @@ } } } + }, + "Default Light" : { + }, "Default Operator" : { "localizations" : { @@ -66893,8 +66930,12 @@ } } } + }, + "Duplicate it to change its colors." : { + }, "Duplicate it to customize colors." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -67013,6 +67054,9 @@ } } } + }, + "Duplicate Selected" : { + }, "Duplicate Theme" : { "localizations" : { @@ -78724,6 +78768,7 @@ } }, "Family" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -78762,6 +78807,9 @@ } } } + }, + "Family:" : { + }, "Fast" : { "extractionState" : "stale", @@ -79297,6 +79345,9 @@ } } } + }, + "Field Background" : { + }, "Field name" : { "localizations" : { @@ -81932,6 +81983,9 @@ } } } + }, + "Focus Border" : { + }, "Focus Editor" : { "localizations" : { @@ -82324,6 +82378,7 @@ } }, "Fonts" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -85634,6 +85689,9 @@ "Grid" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Grid Line" : { + }, "Group" : { "localizations" : { @@ -86148,6 +86206,12 @@ } } } + }, + "Header Background" : { + + }, + "Header Text" : { + }, "Heart" : { "extractionState" : "manual", @@ -87870,6 +87934,7 @@ "generatesSymbol" : false }, "Hover" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -90551,6 +90616,9 @@ } } } + }, + "Inactive Selection" : { + }, "Include" : { "localizations" : { @@ -91905,6 +91973,7 @@ "generatesSymbol" : false }, "Info" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -93738,6 +93807,7 @@ } }, "Interface" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -112088,6 +112158,7 @@ } }, "New Theme" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -119891,6 +119962,9 @@ } } } + }, + "Not Loaded" : { + }, "Not loaded yet" : { "localizations" : { @@ -128231,6 +128305,15 @@ } } } + }, + "Pane Background" : { + + }, + "Pane Text" : { + + }, + "Panels" : { + }, "Panels to read. Omit for all." : { "localizations" : { @@ -134354,6 +134437,7 @@ } }, "Primary Text" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -146451,6 +146535,7 @@ } }, "Reset to System Default" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -157057,6 +157142,7 @@ } }, "Section Header" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -157264,6 +157350,7 @@ } }, "SELECT * FROM users WHERE id = 42;" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -158286,6 +158373,7 @@ } }, "Selected Item" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -158380,6 +158468,9 @@ } } } + }, + "Selected Text" : { + }, "Selection" : { "localizations" : { @@ -165122,7 +165213,6 @@ } }, "Size:" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -170474,6 +170564,7 @@ } }, "Status Colors" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -183439,6 +183530,9 @@ } } } + }, + "The file does not declare a theme format version." : { + }, "The file goes back to its last committed version. You can't undo this action." : { "localizations" : { @@ -186420,6 +186514,9 @@ } } } + }, + "The run button sits in the gutter, which needs line numbers." : { + }, "The run was rolled back, but %@ cannot roll back, so the rows already written there stay. Compare again to see where the target stands." : { "localizations" : { @@ -189351,6 +189448,15 @@ } } } + }, + "The theme declares %1$d colors TablePro does not use, starting with \"%2$@\"." : { + + }, + "The theme is missing %1$d required colors, starting with \"%2$@\"." : { + + }, + "The theme is missing the required field \"%@\"." : { + }, "the TLS handshake failed" : { "extractionState" : "manual", @@ -190003,6 +190109,7 @@ "generatesSymbol" : false }, "Theme" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -190083,6 +190190,12 @@ } } } + }, + "Theme format %1$d is no longer supported. This version of TablePro reads format %2$d." : { + + }, + "Theme format %1$d was made for a newer TablePro. This version reads format %2$d." : { + }, "Theme Update Failed" : { "localizations" : { @@ -198336,6 +198449,7 @@ } }, "Toolbar" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -204881,6 +204995,9 @@ } } } + }, + "Use System Color (%@)" : { + }, "Use text mode to enter JSON." : { @@ -210781,6 +210898,7 @@ } }, "Window Background" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { diff --git a/TablePro/Resources/Themes/tablepro.default-dark.json b/TablePro/Resources/Themes/tablepro.default-dark.json deleted file mode 100644 index d28e2b4149..0000000000 --- a/TablePro/Resources/Themes/tablepro.default-dark.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "id": "tablepro.default-dark", - "name": "Default Dark", - "version": 1, - "appearance": "dark", - "author": "TablePro", - "editor": { - "background": "#1E1E1E", - "text": "#D4D4D4", - "cursor": "#007AFF", - "currentLineHighlight": "#007AFF14", - "currentStatementHighlight": "#FFFFFF0F", - "selection": "#264F78", - "lineNumber": "#858585", - "invisibles": "#4D4D4D", - "syntax": { - "keyword": "#569CD6", - "string": "#CE9178", - "number": "#B5CEA8", - "comment": "#6A9955", - "null": "#FF8C00", - "operator": "#D4D4D4", - "function": "#DCDCAA", - "type": "#4EC9B0" - } - }, - "dataGrid": { - "modified": "#FFD60A4D", - "inserted": "#32D74B26", - "deleted": "#FF453A26", - "deletedText": "#FF453A80" - }, - "ui": { - "accentColor": null, - "status": { - "success": "#32D74B", - "warning": "#FF9F0A", - "error": "#FF453A", - "info": "#0A84FF" - }, - "badges": { - "background": "#3C3C3C", - "primaryKey": "#0A84FF26", - "autoIncrement": "#BF5AF226" - } - }, - "fonts": { - "editorFontFamily": "System Mono", - "editorFontSize": 13, - "dataGridFontFamily": "System Mono", - "dataGridFontSize": 13 - } -} diff --git a/TablePro/Resources/Themes/tablepro.default-light.json b/TablePro/Resources/Themes/tablepro.default-light.json deleted file mode 100644 index 4de5f91386..0000000000 --- a/TablePro/Resources/Themes/tablepro.default-light.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "id": "tablepro.default-light", - "name": "Default Light", - "version": 1, - "appearance": "light", - "author": "TablePro", - "editor": { - "background": "#FFFFFF", - "text": "#000000", - "cursor": "#007AFF", - "currentLineHighlight": "#007AFF14", - "currentStatementHighlight": "#0A0A0A0F", - "selection": "#B4D8FD", - "lineNumber": "#8E8E93", - "invisibles": "#C7C7CC", - "syntax": { - "keyword": "#0A49A5", - "string": "#C41A16", - "number": "#6C36A9", - "comment": "#007400", - "null": "#C55B00", - "operator": "#000000", - "function": "#326D74", - "type": "#3F6E74" - } - }, - "dataGrid": { - "modified": "#FFD60A4D", - "inserted": "#34C7594D", - "deleted": "#FF3B304D", - "deletedText": "#FF3B3080" - }, - "ui": { - "accentColor": null, - "status": { - "success": "#248A3D", - "warning": "#C55B00", - "error": "#D70015", - "info": "#007AFF" - }, - "badges": { - "background": "#E5E5EA", - "primaryKey": "#007AFF26", - "autoIncrement": "#AF52DE26" - } - }, - "fonts": { - "editorFontFamily": "System Mono", - "editorFontSize": 13, - "dataGridFontFamily": "System Mono", - "dataGridFontSize": 13 - } -} diff --git a/TablePro/Resources/Themes/tablepro.dracula.json b/TablePro/Resources/Themes/tablepro.dracula.json index 083586caf0..1148a8f2f9 100644 --- a/TablePro/Resources/Themes/tablepro.dracula.json +++ b/TablePro/Resources/Themes/tablepro.dracula.json @@ -1,80 +1,62 @@ { - "id": "tablepro.dracula", - "name": "Dracula", - "version": 1, - "appearance": "dark", - "author": "TablePro", - "editor": { - "background": "#282A36", - "text": "#F8F8F2", - "cursor": "#F8F8F2", - "currentLineHighlight": "#44475A", - "currentStatementHighlight": "#FFFFFF0F", - "selection": "#44475A", - "lineNumber": "#6272A4", - "invisibles": "#424450", - "syntax": { - "keyword": "#FF79C6", - "string": "#F1FA8C", - "number": "#BD93F9", - "comment": "#6272A4", - "null": "#FFB86C", - "operator": "#FF79C6", - "function": "#50FA7B", - "type": "#8BE9FD" - } - }, + "appearance": "dark", + "author": "TablePro", + "content": { "dataGrid": { - "background": "#282A36", - "text": "#F8F8F2", - "alternateRow": "#21222C", - "nullValue": "#6272A4", - "boolTrue": "#50FA7B", - "boolFalse": "#FF5555", - "rowNumber": "#6272A4", - "modified": "#FFB86C4D", - "inserted": "#50FA7B26", - "deleted": "#FF555526", - "deletedText": "#FF555580" + "alternateRow": "#21222C", + "background": "#282A36", + "boolFalse": "#FF5555", + "boolTrue": "#50FA7B", + "deleted": "#FF555526", + "deletedText": "#FF555580", + "focusBorder": "#BD93F9", + "gridLine": "#44475A", + "headerBackground": "#21222C", + "headerText": "#F8F8F2", + "inactiveSelection": "#44475A80", + "inserted": "#50FA7B26", + "modified": "#FFB86C4D", + "nullValue": "#6272A4", + "rowNumber": "#6272A4", + "selection": "#44475A", + "selectionText": "#F8F8F2", + "text": "#F8F8F2" }, - "ui": { - "windowBackground": "#282A36", - "controlBackground": "#343746", - "cardBackground": "#21222C", - "border": "#44475A", - "primaryText": "#F8F8F2", - "secondaryText": "#BFC0C9", - "tertiaryText": "#6272A4", - "accentColor": "#BD93F9", - "selectionBackground": "#44475A", - "hoverBackground": "#44475A80", - "status": { - "success": "#50FA7B", - "warning": "#FFB86C", - "error": "#FF5555", - "info": "#8BE9FD" - }, - "badges": { - "background": "#44475A", - "primaryKey": "#8BE9FD26", - "autoIncrement": "#BD93F926" - } - }, - "sidebar": { - "background": "#21222C", - "text": "#F8F8F2", - "selectedItem": "#44475A", - "hover": "#343746", - "sectionHeader": "#6272A4" + "editor": { + "background": "#282A36", + "currentLine": "#44475A", + "currentStatement": "#FFFFFF0F", + "cursor": "#F8F8F2", + "invisibles": "#424450", + "lineNumber": "#6272A4", + "selection": "#44475A", + "syntax": { + "comment": "#6272A4", + "function": "#50FA7B", + "keyword": "#FF79C6", + "null": "#FFB86C", + "number": "#BD93F9", + "operator": "#FF79C6", + "string": "#F1FA8C", + "type": "#8BE9FD" + }, + "text": "#F8F8F2" }, - "toolbar": { - "secondaryText": "#BFC0C9", - "tertiaryText": "#6272A4" + "panel": { + "background": "#282A36", + "controlBackground": "#343746", + "secondaryText": "#BFC0C9", + "separator": "#44475A", + "tertiaryText": "#6272A4", + "text": "#F8F8F2" }, - "fonts": { - "editorFontFamily": "System Mono", - "editorFontSize": 13, - "dataGridFontFamily": "System Mono", - "dataGridFontSize": 13 + "status": { + "error": "#FF5555", + "success": "#50FA7B", + "warning": "#FFB86C" } + }, + "id": "tablepro.dracula", + "name": "Dracula", + "schema": 2 } diff --git a/TablePro/Resources/Themes/tablepro.nord.json b/TablePro/Resources/Themes/tablepro.nord.json index 7b9c17c916..7448128435 100644 --- a/TablePro/Resources/Themes/tablepro.nord.json +++ b/TablePro/Resources/Themes/tablepro.nord.json @@ -1,80 +1,62 @@ { - "id": "tablepro.nord", - "name": "Nord", - "version": 1, - "appearance": "dark", - "author": "TablePro", - "editor": { - "background": "#2E3440", - "text": "#D8DEE9", - "cursor": "#88C0D0", - "currentLineHighlight": "#3B4252", - "currentStatementHighlight": "#FFFFFF0F", - "selection": "#434C5E", - "lineNumber": "#4C566A", - "invisibles": "#3B4252", - "syntax": { - "keyword": "#81A1C1", - "string": "#A3BE8C", - "number": "#B48EAD", - "comment": "#616E88", - "null": "#D08770", - "operator": "#81A1C1", - "function": "#88C0D0", - "type": "#EBCB8B" - } - }, + "appearance": "dark", + "author": "TablePro", + "content": { "dataGrid": { - "background": "#2E3440", - "text": "#D8DEE9", - "alternateRow": "#3B4252", - "nullValue": "#4C566A", - "boolTrue": "#A3BE8C", - "boolFalse": "#BF616A", - "rowNumber": "#4C566A", - "modified": "#EBCB8B4D", - "inserted": "#A3BE8C26", - "deleted": "#BF616A26", - "deletedText": "#BF616A80" + "alternateRow": "#3B4252", + "background": "#2E3440", + "boolFalse": "#BF616A", + "boolTrue": "#A3BE8C", + "deleted": "#BF616A26", + "deletedText": "#BF616A80", + "focusBorder": "#88C0D0", + "gridLine": "#434C5E", + "headerBackground": "#3B4252", + "headerText": "#D8DEE9", + "inactiveSelection": "#434C5E80", + "inserted": "#A3BE8C26", + "modified": "#EBCB8B4D", + "nullValue": "#4C566A", + "rowNumber": "#4C566A", + "selection": "#434C5E", + "selectionText": "#ECEFF4", + "text": "#D8DEE9" }, - "ui": { - "windowBackground": "#2E3440", - "controlBackground": "#3B4252", - "cardBackground": "#3B4252", - "border": "#434C5E", - "primaryText": "#D8DEE9", - "secondaryText": "#9DA5B4", - "tertiaryText": "#616E88", - "accentColor": "#88C0D0", - "selectionBackground": "#434C5E", - "hoverBackground": "#88C0D00D", - "status": { - "success": "#A3BE8C", - "warning": "#D08770", - "error": "#BF616A", - "info": "#5E81AC" - }, - "badges": { - "background": "#434C5E", - "primaryKey": "#5E81AC26", - "autoIncrement": "#B48EAD26" - } - }, - "sidebar": { - "background": "#3B4252", - "text": "#D8DEE9", - "selectedItem": "#434C5E", - "hover": "#434C5E80", - "sectionHeader": "#4C566A" + "editor": { + "background": "#2E3440", + "currentLine": "#3B4252", + "currentStatement": "#FFFFFF0F", + "cursor": "#88C0D0", + "invisibles": "#3B4252", + "lineNumber": "#4C566A", + "selection": "#434C5E", + "syntax": { + "comment": "#616E88", + "function": "#88C0D0", + "keyword": "#81A1C1", + "null": "#D08770", + "number": "#B48EAD", + "operator": "#81A1C1", + "string": "#A3BE8C", + "type": "#EBCB8B" + }, + "text": "#D8DEE9" }, - "toolbar": { - "secondaryText": "#9DA5B4", - "tertiaryText": "#616E88" + "panel": { + "background": "#2E3440", + "controlBackground": "#3B4252", + "secondaryText": "#9DA5B4", + "separator": "#434C5E", + "tertiaryText": "#616E88", + "text": "#D8DEE9" }, - "fonts": { - "editorFontFamily": "System Mono", - "editorFontSize": 13, - "dataGridFontFamily": "System Mono", - "dataGridFontSize": 13 + "status": { + "error": "#BF616A", + "success": "#A3BE8C", + "warning": "#D08770" } + }, + "id": "tablepro.nord", + "name": "Nord", + "schema": 2 } diff --git a/TablePro/Theme/BuiltInThemes.swift b/TablePro/Theme/BuiltInThemes.swift new file mode 100644 index 0000000000..560c37bd84 --- /dev/null +++ b/TablePro/Theme/BuiltInThemes.swift @@ -0,0 +1,142 @@ +import Foundation + +/// Default Light and Default Dark are Swift, not JSON: they are the fallback every other theme is +/// measured against, so they cannot themselves be a file that fails to load. Their surround slots +/// stay on `system:` values, which keeps the unthemed app pixel-identical to before the theme +/// owned these surfaces and keeps the system's Increase Contrast and vibrancy adaptations. +internal enum BuiltInThemes { + internal static let defaultLightId = "tablepro.default-light" + internal static let defaultDarkId = "tablepro.default-dark" + + internal static let light = ThemeDefinition( + id: defaultLightId, + name: String(localized: "Default Light"), + author: "TablePro", + appearance: .light, + editor: EditorThemeColors( + background: .hex("#FFFFFF"), + text: .hex("#000000"), + cursor: .hex("#007AFF"), + selection: .hex("#B4D8FD"), + currentLine: .hex("#007AFF14"), + currentStatement: .hex("#0A0A0A0F"), + lineNumber: .hex("#8E8E93"), + invisibles: .hex("#C7C7CC"), + syntax: SyntaxThemeColors( + keyword: .hex("#0A49A5"), + string: .hex("#C41A16"), + number: .hex("#6C36A9"), + comment: .hex("#007400"), + null: .hex("#C55B00"), + operator: .hex("#000000"), + function: .hex("#326D74"), + type: .hex("#3F6E74") + ) + ), + dataGrid: systemDataGrid( + modified: "#FFD60A4D", + inserted: "#34C7594D", + deleted: "#FF3B304D", + deletedText: "#FF3B3080" + ), + panel: .systemPanel, + status: StatusThemeColors( + success: .hex("#248A3D"), + warning: .hex("#C55B00"), + error: .hex("#D70015") + ) + ) + + internal static let dark = ThemeDefinition( + id: defaultDarkId, + name: String(localized: "Default Dark"), + author: "TablePro", + appearance: .dark, + editor: EditorThemeColors( + background: .hex("#1E1E1E"), + text: .hex("#D4D4D4"), + cursor: .hex("#007AFF"), + selection: .hex("#264F78"), + currentLine: .hex("#007AFF14"), + currentStatement: .hex("#FFFFFF0F"), + lineNumber: .hex("#858585"), + invisibles: .hex("#4D4D4D"), + syntax: SyntaxThemeColors( + keyword: .hex("#569CD6"), + string: .hex("#CE9178"), + number: .hex("#B5CEA8"), + comment: .hex("#6A9955"), + null: .hex("#FF8C00"), + operator: .hex("#D4D4D4"), + function: .hex("#DCDCAA"), + type: .hex("#4EC9B0") + ) + ), + dataGrid: systemDataGrid( + modified: "#FFD60A4D", + inserted: "#32D74B26", + deleted: "#FF453A26", + deletedText: "#FF453A80" + ), + panel: .systemPanel, + status: StatusThemeColors( + success: .hex("#32D74B"), + warning: .hex("#FF9F0A"), + error: .hex("#FF453A") + ) + ) + + internal static let all: [ThemeDefinition] = [light, dark] + + internal static func `default`(for appearance: ThemeAppearance) -> ThemeDefinition { + switch appearance { + case .light: return light + case .dark: return dark + } + } + + internal static func defaultId(for appearance: ThemeAppearance) -> String { + `default`(for: appearance).id + } + + private static func systemDataGrid( + modified: String, + inserted: String, + deleted: String, + deletedText: String + ) -> DataGridThemeColors { + DataGridThemeColors( + background: .system(.controlBackground), + text: .system(.label), + alternateRow: .system(.alternatingContentBackgroundOdd), + headerBackground: .system(.windowBackground), + headerText: .system(.label), + gridLine: .system(.grid), + selection: .system(.selectedContentBackground), + selectionText: .system(.alternateSelectedControlText), + inactiveSelection: .system(.unemphasizedSelectedContentBackground), + focusBorder: .system(.keyboardFocusIndicator), + nullValue: .system(.secondaryLabel), + boolTrue: .system(.label), + boolFalse: .system(.label), + rowNumber: .system(.secondaryLabel), + modified: .hex(modified), + inserted: .hex(inserted), + deleted: .hex(deleted), + deletedText: .hex(deletedText) + ) + } +} + +internal extension PanelThemeColors { + /// The semantic colours these surfaces already named before the theme owned them, so a default + /// theme is invisible against the unthemed app and keeps the system's contrast handling. + static let systemPanel = PanelThemeColors( + background: .system(.controlBackground), + controlBackground: .system(.textBackground), + text: .system(.label), + secondaryText: .system(.secondaryLabel), + tertiaryText: .system(.tertiaryLabel), + separator: .system(.separator) + ) +} diff --git a/TablePro/Theme/HexColor.swift b/TablePro/Theme/HexColor.swift deleted file mode 100644 index 5378d3acc9..0000000000 --- a/TablePro/Theme/HexColor.swift +++ /dev/null @@ -1,68 +0,0 @@ -import AppKit -import SwiftUI - -extension String { - var nsColor: NSColor { - let hex = trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "#")) - - let hexLength = (hex as NSString).length - guard hexLength == 6 || hexLength == 8 else { - return .labelColor - } - - var value: UInt64 = 0 - guard Scanner(string: hex).scanHexInt64(&value) else { - return .labelColor - } - - let r, g, b, a: CGFloat - - if hexLength == 8 { - r = CGFloat((value >> 24) & 0xFF) / 255.0 - g = CGFloat((value >> 16) & 0xFF) / 255.0 - b = CGFloat((value >> 8) & 0xFF) / 255.0 - a = CGFloat(value & 0xFF) / 255.0 - } else { - r = CGFloat((value >> 16) & 0xFF) / 255.0 - g = CGFloat((value >> 8) & 0xFF) / 255.0 - b = CGFloat(value & 0xFF) / 255.0 - a = 1.0 - } - - return NSColor(srgbRed: r, green: g, blue: b, alpha: a) - } - - var swiftUIColor: Color { - Color(nsColor: nsColor) - } - - var cgColor: CGColor { - nsColor.cgColor - } -} - -extension NSColor { - var hexString: String { - guard let converted = usingColorSpace(.sRGB) else { - return "#808080" - } - - var r: CGFloat = 0 - var g: CGFloat = 0 - var b: CGFloat = 0 - var a: CGFloat = 0 - converted.getRed(&r, green: &g, blue: &b, alpha: &a) - - let ri = Int(round(r * 255)) - let gi = Int(round(g * 255)) - let bi = Int(round(b * 255)) - - if a < 1.0 { - let ai = Int(round(a * 255)) - return String(format: "#%02X%02X%02X%02X", ri, gi, bi, ai) - } - - return String(format: "#%02X%02X%02X", ri, gi, bi) - } -} diff --git a/TablePro/Theme/ResolvedThemeColors.swift b/TablePro/Theme/ResolvedThemeColors.swift deleted file mode 100644 index 8557126d58..0000000000 --- a/TablePro/Theme/ResolvedThemeColors.swift +++ /dev/null @@ -1,265 +0,0 @@ -import AppKit -import SwiftUI - -struct ResolvedEditorColors { - let background: NSColor - let backgroundSwiftUI: Color - let text: NSColor - let textSwiftUI: Color - let cursor: NSColor - let cursorSwiftUI: Color - let currentLineHighlight: NSColor - let currentLineHighlightSwiftUI: Color - let currentStatementHighlight: NSColor - let selection: NSColor - let selectionSwiftUI: Color - let lineNumber: NSColor - let lineNumberSwiftUI: Color - let invisibles: NSColor - let invisiblesSwiftUI: Color - - let keyword: NSColor - let keywordSwiftUI: Color - let string: NSColor - let stringSwiftUI: Color - let number: NSColor - let numberSwiftUI: Color - let comment: NSColor - let commentSwiftUI: Color - let null: NSColor - let nullSwiftUI: Color - let `operator`: NSColor - let operatorSwiftUI: Color - let function: NSColor - let functionSwiftUI: Color - let type: NSColor - let typeSwiftUI: Color - - init(from colors: EditorThemeColors) { - background = colors.background.nsColor - backgroundSwiftUI = colors.background.swiftUIColor - text = colors.text.nsColor - textSwiftUI = colors.text.swiftUIColor - cursor = colors.cursor.nsColor - cursorSwiftUI = colors.cursor.swiftUIColor - currentLineHighlight = colors.currentLineHighlight.nsColor - currentLineHighlightSwiftUI = colors.currentLineHighlight.swiftUIColor - currentStatementHighlight = colors.currentStatementHighlight.nsColor - selection = colors.selection.nsColor - selectionSwiftUI = colors.selection.swiftUIColor - lineNumber = colors.lineNumber.nsColor - lineNumberSwiftUI = colors.lineNumber.swiftUIColor - invisibles = colors.invisibles.nsColor - invisiblesSwiftUI = colors.invisibles.swiftUIColor - - keyword = colors.syntax.keyword.nsColor - keywordSwiftUI = colors.syntax.keyword.swiftUIColor - string = colors.syntax.string.nsColor - stringSwiftUI = colors.syntax.string.swiftUIColor - number = colors.syntax.number.nsColor - numberSwiftUI = colors.syntax.number.swiftUIColor - comment = colors.syntax.comment.nsColor - commentSwiftUI = colors.syntax.comment.swiftUIColor - null = colors.syntax.null.nsColor - nullSwiftUI = colors.syntax.null.swiftUIColor - `operator` = colors.syntax.operator.nsColor - operatorSwiftUI = colors.syntax.operator.swiftUIColor - function = colors.syntax.function.nsColor - functionSwiftUI = colors.syntax.function.swiftUIColor - type = colors.syntax.type.nsColor - typeSwiftUI = colors.syntax.type.swiftUIColor - } -} - -struct ResolvedDataGridColors { - let background: NSColor - let backgroundSwiftUI: Color - let text: NSColor - let textSwiftUI: Color - let alternateRow: NSColor - let alternateRowSwiftUI: Color - let nullValue: NSColor - let boolTrue: NSColor? - let boolFalse: NSColor? - let rowNumber: NSColor - - let modified: NSColor - let modifiedSwiftUI: Color - let modifiedCG: CGColor - let inserted: NSColor - let insertedSwiftUI: Color - let insertedCG: CGColor - let deleted: NSColor - let deletedSwiftUI: Color - let deletedCG: CGColor - let deletedText: NSColor - let deletedTextSwiftUI: Color - - init(from colors: DataGridThemeColors) { - background = colors.background?.nsColor ?? .controlBackgroundColor - backgroundSwiftUI = colors.background?.swiftUIColor ?? Color(nsColor: .controlBackgroundColor) - text = colors.text?.nsColor ?? .labelColor - textSwiftUI = colors.text?.swiftUIColor ?? Color(nsColor: .labelColor) - alternateRow = colors.alternateRow?.nsColor ?? Self.systemAlternateRow - alternateRowSwiftUI = colors.alternateRow?.swiftUIColor ?? Color(nsColor: Self.systemAlternateRow) - nullValue = colors.nullValue?.nsColor ?? .secondaryLabelColor - boolTrue = colors.boolTrue?.nsColor - boolFalse = colors.boolFalse?.nsColor - rowNumber = colors.rowNumber?.nsColor ?? .secondaryLabelColor - - modified = colors.modified.nsColor - modifiedSwiftUI = colors.modified.swiftUIColor - modifiedCG = colors.modified.cgColor - inserted = colors.inserted.nsColor - insertedSwiftUI = colors.inserted.swiftUIColor - insertedCG = colors.inserted.cgColor - deleted = colors.deleted.nsColor - deletedSwiftUI = colors.deleted.swiftUIColor - deletedCG = colors.deleted.cgColor - deletedText = colors.deletedText.nsColor - deletedTextSwiftUI = colors.deletedText.swiftUIColor - } - - private static var systemAlternateRow: NSColor { - NSColor.alternatingContentBackgroundColors.last ?? .controlBackgroundColor - } -} - -struct ResolvedUIColors { - let windowBackground: NSColor - let windowBackgroundSwiftUI: Color - let controlBackground: NSColor - let controlBackgroundSwiftUI: Color - let cardBackground: NSColor - let cardBackgroundSwiftUI: Color - let border: NSColor - let borderSwiftUI: Color - - let primaryText: NSColor - let primaryTextSwiftUI: Color - let secondaryText: NSColor - let secondaryTextSwiftUI: Color - let tertiaryText: NSColor - let tertiaryTextSwiftUI: Color - - let selectionBackground: NSColor - let selectionBackgroundSwiftUI: Color - let hoverBackground: NSColor - let hoverBackgroundSwiftUI: Color - - let success: NSColor - let successSwiftUI: Color - let warning: NSColor - let warningSwiftUI: Color - let error: NSColor - let errorSwiftUI: Color - let info: NSColor - let infoSwiftUI: Color - - let badgeBackground: NSColor - let badgeBackgroundSwiftUI: Color - let badgePrimaryKey: NSColor - let badgePrimaryKeySwiftUI: Color - let badgeAutoIncrement: NSColor - let badgeAutoIncrementSwiftUI: Color - - init(from colors: UIThemeColors) { - windowBackground = colors.windowBackground?.nsColor ?? .windowBackgroundColor - windowBackgroundSwiftUI = colors.windowBackground?.swiftUIColor ?? Color(nsColor: .windowBackgroundColor) - controlBackground = colors.controlBackground?.nsColor ?? .controlBackgroundColor - controlBackgroundSwiftUI = colors.controlBackground?.swiftUIColor - ?? Color(nsColor: .controlBackgroundColor) - cardBackground = colors.cardBackground?.nsColor ?? .controlBackgroundColor - cardBackgroundSwiftUI = colors.cardBackground?.swiftUIColor ?? Color(nsColor: .controlBackgroundColor) - border = colors.border?.nsColor ?? .separatorColor - borderSwiftUI = colors.border?.swiftUIColor ?? Color(nsColor: .separatorColor) - - primaryText = colors.primaryText?.nsColor ?? .labelColor - primaryTextSwiftUI = colors.primaryText?.swiftUIColor ?? Color(nsColor: .labelColor) - secondaryText = colors.secondaryText?.nsColor ?? .secondaryLabelColor - secondaryTextSwiftUI = colors.secondaryText?.swiftUIColor ?? Color(nsColor: .secondaryLabelColor) - tertiaryText = colors.tertiaryText?.nsColor ?? .tertiaryLabelColor - tertiaryTextSwiftUI = colors.tertiaryText?.swiftUIColor ?? Color(nsColor: .tertiaryLabelColor) - - selectionBackground = colors.selectionBackground?.nsColor ?? .selectedContentBackgroundColor - selectionBackgroundSwiftUI = colors.selectionBackground?.swiftUIColor - ?? Color(nsColor: .selectedContentBackgroundColor) - hoverBackground = colors.hoverBackground?.nsColor ?? .unemphasizedSelectedContentBackgroundColor - hoverBackgroundSwiftUI = colors.hoverBackground?.swiftUIColor - ?? Color(nsColor: .unemphasizedSelectedContentBackgroundColor) - - success = colors.status.success.nsColor - successSwiftUI = colors.status.success.swiftUIColor - warning = colors.status.warning.nsColor - warningSwiftUI = colors.status.warning.swiftUIColor - error = colors.status.error.nsColor - errorSwiftUI = colors.status.error.swiftUIColor - info = colors.status.info.nsColor - infoSwiftUI = colors.status.info.swiftUIColor - - badgeBackground = colors.badges.background.nsColor - badgeBackgroundSwiftUI = colors.badges.background.swiftUIColor - badgePrimaryKey = colors.badges.primaryKey.nsColor - badgePrimaryKeySwiftUI = colors.badges.primaryKey.swiftUIColor - badgeAutoIncrement = colors.badges.autoIncrement.nsColor - badgeAutoIncrementSwiftUI = colors.badges.autoIncrement.swiftUIColor - } -} - -struct ResolvedSidebarColors { - let background: NSColor - let backgroundSwiftUI: Color - let text: NSColor - let textSwiftUI: Color - let selectedItem: NSColor - let selectedItemSwiftUI: Color - let hover: NSColor - let hoverSwiftUI: Color - let sectionHeader: NSColor - let sectionHeaderSwiftUI: Color - - init(from colors: SidebarThemeColors) { - background = colors.background?.nsColor ?? .windowBackgroundColor - backgroundSwiftUI = colors.background?.swiftUIColor ?? Color(nsColor: .windowBackgroundColor) - text = colors.text?.nsColor ?? .labelColor - textSwiftUI = colors.text?.swiftUIColor ?? Color(nsColor: .labelColor) - selectedItem = colors.selectedItem?.nsColor ?? .selectedContentBackgroundColor - selectedItemSwiftUI = colors.selectedItem?.swiftUIColor - ?? Color(nsColor: .selectedContentBackgroundColor) - hover = colors.hover?.nsColor ?? .unemphasizedSelectedContentBackgroundColor - hoverSwiftUI = colors.hover?.swiftUIColor - ?? Color(nsColor: .unemphasizedSelectedContentBackgroundColor) - sectionHeader = colors.sectionHeader?.nsColor ?? .secondaryLabelColor - sectionHeaderSwiftUI = colors.sectionHeader?.swiftUIColor ?? Color(nsColor: .secondaryLabelColor) - } -} - -struct ResolvedToolbarColors { - let secondaryText: NSColor - let secondaryTextSwiftUI: Color - let tertiaryText: NSColor - let tertiaryTextSwiftUI: Color - - init(from colors: ToolbarThemeColors) { - secondaryText = colors.secondaryText?.nsColor ?? .secondaryLabelColor - secondaryTextSwiftUI = colors.secondaryText?.swiftUIColor ?? Color(nsColor: .secondaryLabelColor) - tertiaryText = colors.tertiaryText?.nsColor ?? .tertiaryLabelColor - tertiaryTextSwiftUI = colors.tertiaryText?.swiftUIColor ?? Color(nsColor: .tertiaryLabelColor) - } -} - -struct ResolvedThemeColors { - let editor: ResolvedEditorColors - let dataGrid: ResolvedDataGridColors - let ui: ResolvedUIColors - let sidebar: ResolvedSidebarColors - let toolbar: ResolvedToolbarColors - - init(from theme: ThemeDefinition) { - editor = ResolvedEditorColors(from: theme.editor) - dataGrid = ResolvedDataGridColors(from: theme.dataGrid) - ui = ResolvedUIColors(from: theme.ui) - sidebar = ResolvedSidebarColors(from: theme.sidebar) - toolbar = ResolvedToolbarColors(from: theme.toolbar) - } -} diff --git a/TablePro/Theme/ThemeCatalog.swift b/TablePro/Theme/ThemeCatalog.swift new file mode 100644 index 0000000000..cb957a85db --- /dev/null +++ b/TablePro/Theme/ThemeCatalog.swift @@ -0,0 +1,282 @@ +import Combine +import Foundation +import os + +internal struct ThemeCatalogContents: Equatable, Sendable { + internal var themes: [ThemeDefinition] + internal var rejected: [RejectedThemeRecord] + + internal static let empty = ThemeCatalogContents(themes: BuiltInThemes.all, rejected: []) +} + +internal struct RejectedThemeRecord: Equatable, Sendable { + internal let path: String + internal let fileName: String + internal let reason: String +} + +internal enum ThemeOrigin: Sendable { + case bundle + case registry + case user + + internal var allowedPrefix: String? { + switch self { + case .bundle: return ThemeDefinition.builtInPrefix + case .registry: return ThemeDefinition.registryPrefix + case .user: return nil + } + } +} + +/// Owns the list. Loading is off the main actor; every mutation updates the in-memory list +/// synchronously before anything activates, because the old storage refreshed through a detached +/// Task and a save was therefore followed by an activation of the stale copy. +@MainActor +internal final class ThemeCatalog: ObservableObject { + internal static let shared = ThemeCatalog() + + @Published internal private(set) var themes: [ThemeDefinition] = BuiltInThemes.all + @Published internal private(set) var rejected: [RejectedThemeRecord] = [] + + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeCatalog") + + nonisolated private static let bundledOrder = [ + BuiltInThemes.defaultLightId, + BuiltInThemes.defaultDarkId, + "tablepro.dracula", + "tablepro.nord", + ] + + /// Loaded before the first activation, not after it. The old catalog refreshed through a + /// detached Task, so the launch activation ran against built-ins alone and a user theme in a + /// slot resolved to the default until something else re-activated it. + private init() { + reloadSynchronously() + } + + internal func theme(id: String) -> ThemeDefinition? { + themes.first { $0.id == id } + } + + internal func reload() async { + let contents = await Task.detached { ThemeCatalog.loadContents() }.value + themes = contents.themes + rejected = contents.rejected + } + + internal func reloadSynchronously() { + let contents = Self.loadContents() + themes = contents.themes + rejected = contents.rejected + } + + internal func save(_ theme: ThemeDefinition) throws { + guard theme.isEditable else { throw ThemeLoadError.reservedIdentifier(theme.id) } + try Self.write(theme, to: Self.userDirectory) + upsert(theme) + } + + internal func delete(id: String) throws { + guard !id.hasPrefix(ThemeDefinition.builtInPrefix), !id.hasPrefix(ThemeDefinition.registryPrefix) else { + throw ThemeLoadError.reservedIdentifier(id) + } + try Self.remove(id: id, from: Self.userDirectory) + themes.removeAll { $0.id == id } + } + + internal func saveRegistryTheme(_ theme: ThemeDefinition) throws { + try Self.write(theme, to: Self.registryDirectory) + upsert(theme) + } + + internal func deleteRegistryTheme(id: String) throws { + try Self.remove(id: id, from: Self.registryDirectory) + themes.removeAll { $0.id == id } + rejected.removeAll { $0.fileName == "\(id).json" } + } + + internal func importTheme(from url: URL) throws -> ThemeDefinition { + let document = try ThemeDocument(data: try Data(contentsOf: url)) + var theme = document.resolved() + + if !theme.isEditable || containsTheme(id: theme.id) { + theme.id = ThemeIdentifier.generated() + } + + try Self.write(theme, to: Self.userDirectory) + upsert(theme) + return theme + } + + internal func exportTheme(_ theme: ThemeDefinition, to url: URL) throws { + try ThemeEncoder.data(for: theme).write(to: url, options: .atomic) + } + + internal func loadRegistryMeta() -> RegistryThemeMeta { + let url = Self.registryMetaURL + guard FileManager.default.fileExists(atPath: url.path) else { return RegistryThemeMeta() } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(RegistryThemeMeta.self, from: try Data(contentsOf: url)) + } catch { + Self.logger.error("Failed to load registry meta: \(error)") + return RegistryThemeMeta() + } + } + + internal func saveRegistryMeta(_ meta: RegistryThemeMeta) throws { + Self.ensureDirectory(Self.registryDirectory) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(meta).write(to: Self.registryMetaURL, options: .atomic) + } + + private func containsTheme(id: String) -> Bool { + themes.contains { $0.id == id } + } + + private func upsert(_ theme: ThemeDefinition) { + if let index = themes.firstIndex(where: { $0.id == theme.id }) { + themes[index] = theme + return + } + themes.append(theme) + } + + // MARK: - Locations + + nonisolated private static let userDirectory: URL = + AppStorageEnvironment.shared.applicationSupportRoot + .appendingPathComponent("TablePro/Themes", isDirectory: true) + + nonisolated private static let registryDirectory: URL = + userDirectory.appendingPathComponent("Registry", isDirectory: true) + + nonisolated private static let registryMetaURL: URL = + registryDirectory.appendingPathComponent("registry-meta.json") + + // MARK: - Disk + + nonisolated private static func loadContents() -> ThemeCatalogContents { + var themes = BuiltInThemes.all + var rejected: [RejectedThemeRecord] = [] + + ensureDirectory(userDirectory) + ensureDirectory(registryDirectory) + + let sources: [(URL, ThemeOrigin)] = [ + (Bundle.main.resourceURL, .bundle), + (registryDirectory, .registry), + (userDirectory, .user), + ].compactMap { url, source in url.map { ($0, source) } } + + for (directory, source) in sources { + let loaded = load(from: directory, source: source) + rejected.append(contentsOf: loaded.rejected) + + for theme in loaded.themes { + if let index = themes.firstIndex(where: { $0.id == theme.id }) { + themes[index] = theme + continue + } + themes.append(theme) + } + } + + themes.sort { lhs, rhs in + let left = bundledOrder.firstIndex(of: lhs.id) ?? Int.max + let right = bundledOrder.firstIndex(of: rhs.id) ?? Int.max + guard left == right else { return left < right } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + + return ThemeCatalogContents(themes: themes, rejected: rejected) + } + + nonisolated private static func load( + from directory: URL, + source: ThemeOrigin + ) -> (themes: [ThemeDefinition], rejected: [RejectedThemeRecord]) { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: directory.path) else { return ([], []) } + + let files: [URL] + do { + files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" && $0.lastPathComponent != "registry-meta.json" } + } catch { + logger.error("Failed to list \(directory.lastPathComponent): \(error)") + return ([], []) + } + + var themes: [ThemeDefinition] = [] + var rejected: [RejectedThemeRecord] = [] + + for file in files { + if source == .bundle, !file.lastPathComponent.hasPrefix(ThemeDefinition.builtInPrefix) { continue } + + do { + let document = try ThemeDocument(data: try Data(contentsOf: file)) + try validate(identifier: document.id, from: source) + themes.append(document.resolved()) + } catch { + guard source != .bundle else { + logger.error("Bundled theme \(file.lastPathComponent) rejected: \(error.publicLogShape, privacy: .public)") + continue + } + rejected.append( + RejectedThemeRecord( + path: file.path, + fileName: file.lastPathComponent, + reason: error.localizedDescription + ) + ) + } + } + + return (themes, rejected) + } + + /// A reserved prefix is accepted only from the directory that owns it. A file dropped into the + /// user folder claiming `tablepro.` or `registry.` would otherwise shadow the real theme, be + /// read-only because the prefix says built-in, and be impossible to delete. + nonisolated private static func validate(identifier: String, from source: ThemeOrigin) throws { + guard ThemeIdentifier.isValid(identifier) else { + throw ThemeLoadError.invalidIdentifier(identifier) + } + + let reserved = [ThemeDefinition.builtInPrefix, ThemeDefinition.registryPrefix] + for prefix in reserved where identifier.hasPrefix(prefix) { + guard source.allowedPrefix == prefix else { + throw ThemeLoadError.reservedIdentifier(identifier) + } + } + } + + nonisolated private static func write(_ theme: ThemeDefinition, to directory: URL) throws { + ensureDirectory(directory) + guard ThemeIdentifier.isValid(theme.id) else { throw ThemeLoadError.invalidIdentifier(theme.id) } + let url = directory.appendingPathComponent("\(theme.id).json", isDirectory: false) + try ThemeEncoder.data(for: theme).write(to: url, options: .atomic) + } + + nonisolated private static func remove(id: String, from directory: URL) throws { + guard ThemeIdentifier.isValid(id) else { throw ThemeLoadError.invalidIdentifier(id) } + let url = directory.appendingPathComponent("\(id).json", isDirectory: false) + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } + + nonisolated private static func ensureDirectory(_ url: URL) { + let fileManager = FileManager.default + guard !fileManager.fileExists(atPath: url.path) else { return } + do { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } catch { + logger.error("Failed to create \(url.lastPathComponent): \(error)") + } + } +} diff --git a/TablePro/Theme/ThemeColorValue.swift b/TablePro/Theme/ThemeColorValue.swift new file mode 100644 index 0000000000..2a446ea2f6 --- /dev/null +++ b/TablePro/Theme/ThemeColorValue.swift @@ -0,0 +1,194 @@ +import AppKit +import SwiftUI + +internal enum SystemColorName: String, Codable, CaseIterable, Sendable { + case label + case secondaryLabel + case tertiaryLabel + case quaternaryLabel + case text + case textBackground + case selectedText + case selectedTextBackground + case placeholderText + case disabledControlText + case controlBackground + case windowBackground + case underPageBackground + case separator + case grid + case selectedContentBackground + case unemphasizedSelectedContentBackground + case alternateSelectedControlText + case controlAccent + case keyboardFocusIndicator + case alternatingContentBackgroundEven + case alternatingContentBackgroundOdd + case systemRed + case systemGreen + case systemBlue + case systemOrange + case systemYellow + case systemPurple + case systemTeal + case systemGray + + internal var color: NSColor { + switch self { + case .label: return .labelColor + case .secondaryLabel: return .secondaryLabelColor + case .tertiaryLabel: return .tertiaryLabelColor + case .quaternaryLabel: return .quaternaryLabelColor + case .text: return .textColor + case .textBackground: return .textBackgroundColor + case .selectedText: return .selectedTextColor + case .selectedTextBackground: return .selectedTextBackgroundColor + case .placeholderText: return .placeholderTextColor + case .disabledControlText: return .disabledControlTextColor + case .controlBackground: return .controlBackgroundColor + case .windowBackground: return .windowBackgroundColor + case .underPageBackground: return .underPageBackgroundColor + case .separator: return .separatorColor + case .grid: return .gridColor + case .selectedContentBackground: return .selectedContentBackgroundColor + case .unemphasizedSelectedContentBackground: return .unemphasizedSelectedContentBackgroundColor + case .alternateSelectedControlText: return .alternateSelectedControlTextColor + case .controlAccent: return .controlAccentColor + case .keyboardFocusIndicator: return .keyboardFocusIndicatorColor + case .alternatingContentBackgroundEven: return Self.alternatingContentBackground(at: 0) + case .alternatingContentBackgroundOdd: return Self.alternatingContentBackground(at: 1) + case .systemRed: return .systemRed + case .systemGreen: return .systemGreen + case .systemBlue: return .systemBlue + case .systemOrange: return .systemOrange + case .systemYellow: return .systemYellow + case .systemPurple: return .systemPurple + case .systemTeal: return .systemTeal + case .systemGray: return .systemGray + } + } + + private static func alternatingContentBackground(at index: Int) -> NSColor { + let colors = NSColor.alternatingContentBackgroundColors + guard colors.indices.contains(index) else { return .controlBackgroundColor } + return colors[index] + } +} + +/// A theme slot's value. `system` keeps a slot on the AppKit semantic colour its call site used +/// before the theme owned it, so the built-in themes stay pixel-identical to the unthemed app and +/// keep the system's own Increase Contrast and vibrancy adaptations. A custom theme uses `hex`. +internal enum ThemeColorValue: Equatable, Hashable, Sendable { + case hex(String) + case system(SystemColorName) + + internal static let systemPrefix = "system:" + + internal init(validating raw: String) throws { + if raw.hasPrefix(Self.systemPrefix) { + let name = String(raw.dropFirst(Self.systemPrefix.count)) + guard let known = SystemColorName(rawValue: name) else { + throw ThemeLoadError.unknownSystemColor(name) + } + self = .system(known) + return + } + + guard let canonical = HexColor.canonicalize(raw) else { + throw ThemeLoadError.invalidColor(raw) + } + self = .hex(canonical) + } + + internal var rawValue: String { + switch self { + case let .hex(value): return value + case let .system(name): return Self.systemPrefix + name.rawValue + } + } + + internal var nsColor: NSColor { + switch self { + case let .hex(value): return HexColor.color(value) + case let .system(name): return name.color + } + } + + internal var swiftUIColor: Color { + Color(nsColor: nsColor) + } + + internal var isSystem: Bool { + if case .system = self { return true } + return false + } +} + +extension ThemeColorValue: Codable { + internal init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + try self.init(validating: raw) + } + + internal func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// The parser rejects any string it does not consume whole. The previous one asked only whether +/// `Scanner.scanHexInt64` succeeded, which it does on a valid prefix, so `#FF79CG` silently +/// rendered as `#0FF79C` instead of reporting a typo. +internal enum HexColor { + internal static func canonicalize(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("#") else { return nil } + + let digits = trimmed.dropFirst() + guard digits.count == 6 || digits.count == 8 else { return nil } + guard digits.allSatisfy(\.isHexDigit) else { return nil } + + return "#" + digits.uppercased() + } + + internal static func color(_ canonical: String) -> NSColor { + let digits = canonical.dropFirst() + var value: UInt64 = 0 + guard Scanner(string: String(digits)).scanHexInt64(&value) else { return .labelColor } + + if digits.count == 8 { + return NSColor( + srgbRed: CGFloat((value >> 24) & 0xFF) / 255, + green: CGFloat((value >> 16) & 0xFF) / 255, + blue: CGFloat((value >> 8) & 0xFF) / 255, + alpha: CGFloat(value & 0xFF) / 255 + ) + } + + return NSColor( + srgbRed: CGFloat((value >> 16) & 0xFF) / 255, + green: CGFloat((value >> 8) & 0xFF) / 255, + blue: CGFloat(value & 0xFF) / 255, + alpha: 1 + ) + } + + internal static func string(from color: NSColor) -> String { + guard let converted = color.usingColorSpace(.sRGB) else { return "#808080" } + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + converted.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + let redValue = Int(round(red * 255)) + let greenValue = Int(round(green * 255)) + let blueValue = Int(round(blue * 255)) + + guard alpha < 1 else { + return String(format: "#%02X%02X%02X", redValue, greenValue, blueValue) + } + return String(format: "#%02X%02X%02X%02X", redValue, greenValue, blueValue, Int(round(alpha * 255))) + } +} diff --git a/TablePro/Theme/ThemeColors.swift b/TablePro/Theme/ThemeColors.swift deleted file mode 100644 index 1b4fbd47c1..0000000000 --- a/TablePro/Theme/ThemeColors.swift +++ /dev/null @@ -1,397 +0,0 @@ -// -// ThemeColors.swift -// TablePro -// - -import Foundation -import SwiftUI -// MARK: - Syntax Colors - -internal struct SyntaxColors: Codable, Equatable, Sendable { - var keyword: String - var string: String - var number: String - var comment: String - var null: String - var `operator`: String - var function: String - var type: String - - static let defaultLight = SyntaxColors( - keyword: "#0A49A5", - string: "#C41A16", - number: "#6C36A9", - comment: "#007400", - null: "#C55B00", - operator: "#000000", - function: "#326D74", - type: "#3F6E74" - ) - - init( - keyword: String, - string: String, - number: String, - comment: String, - null: String, - operator: String, - function: String, - type: String - ) { - self.keyword = keyword - self.string = string - self.number = number - self.comment = comment - self.null = null - self.operator = `operator` - self.function = function - self.type = type - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = SyntaxColors.defaultLight - - keyword = try container.decodeIfPresent(String.self, forKey: .keyword) ?? fallback.keyword - string = try container.decodeIfPresent(String.self, forKey: .string) ?? fallback.string - number = try container.decodeIfPresent(String.self, forKey: .number) ?? fallback.number - comment = try container.decodeIfPresent(String.self, forKey: .comment) ?? fallback.comment - null = try container.decodeIfPresent(String.self, forKey: .null) ?? fallback.null - `operator` = try container.decodeIfPresent(String.self, forKey: .operator) ?? fallback.operator - function = try container.decodeIfPresent(String.self, forKey: .function) ?? fallback.function - type = try container.decodeIfPresent(String.self, forKey: .type) ?? fallback.type - } -} - -// MARK: - Editor Theme Colors - -internal struct EditorThemeColors: Codable, Equatable, Sendable { - var background: String - var text: String - var cursor: String - var currentLineHighlight: String - var selection: String - var lineNumber: String - var invisibles: String - var currentStatementHighlight: String - var syntax: SyntaxColors - - static let defaultLight = EditorThemeColors( - background: "#FFFFFF", - text: "#000000", - cursor: "#007AFF", - currentLineHighlight: "#007AFF14", - selection: "#B4D8FD", - lineNumber: "#8E8E93", - invisibles: "#C7C7CC", - currentStatementHighlight: "#0A0A0A0F", - syntax: .defaultLight - ) - - init( - background: String, - text: String, - cursor: String, - currentLineHighlight: String, - selection: String, - lineNumber: String, - invisibles: String, - currentStatementHighlight: String, - syntax: SyntaxColors - ) { - self.background = background - self.text = text - self.cursor = cursor - self.currentLineHighlight = currentLineHighlight - self.selection = selection - self.lineNumber = lineNumber - self.invisibles = invisibles - self.currentStatementHighlight = currentStatementHighlight - self.syntax = syntax - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = EditorThemeColors.defaultLight - - background = try container.decodeIfPresent(String.self, forKey: .background) ?? fallback.background - text = try container.decodeIfPresent(String.self, forKey: .text) ?? fallback.text - cursor = try container.decodeIfPresent(String.self, forKey: .cursor) ?? fallback.cursor - currentLineHighlight = try container.decodeIfPresent(String.self, forKey: .currentLineHighlight) - ?? fallback.currentLineHighlight - selection = try container.decodeIfPresent(String.self, forKey: .selection) ?? fallback.selection - lineNumber = try container.decodeIfPresent(String.self, forKey: .lineNumber) ?? fallback.lineNumber - invisibles = try container.decodeIfPresent(String.self, forKey: .invisibles) ?? fallback.invisibles - currentStatementHighlight = try container.decodeIfPresent(String.self, forKey: .currentStatementHighlight) - ?? fallback.currentStatementHighlight - syntax = try container.decodeIfPresent(SyntaxColors.self, forKey: .syntax) ?? fallback.syntax - } -} - -// MARK: - Data Grid Theme Colors - -internal struct DataGridThemeColors: Codable, Equatable, Sendable { - var background: String? - var text: String? - var alternateRow: String? - var nullValue: String? - var boolTrue: String? - var boolFalse: String? - var rowNumber: String? - var modified: String - var inserted: String - var deleted: String - var deletedText: String - - static let defaultLight = DataGridThemeColors( - background: nil, - text: nil, - alternateRow: nil, - nullValue: nil, - boolTrue: nil, - boolFalse: nil, - rowNumber: nil, - modified: "#FFD60A4D", - inserted: "#34C7594D", - deleted: "#FF3B304D", - deletedText: "#FF3B3080" - ) - - init( - background: String?, - text: String?, - alternateRow: String?, - nullValue: String?, - boolTrue: String?, - boolFalse: String?, - rowNumber: String?, - modified: String, - inserted: String, - deleted: String, - deletedText: String - ) { - self.background = background - self.text = text - self.alternateRow = alternateRow - self.nullValue = nullValue - self.boolTrue = boolTrue - self.boolFalse = boolFalse - self.rowNumber = rowNumber - self.modified = modified - self.inserted = inserted - self.deleted = deleted - self.deletedText = deletedText - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = DataGridThemeColors.defaultLight - - background = try container.decodeIfPresent(String.self, forKey: .background) - text = try container.decodeIfPresent(String.self, forKey: .text) - alternateRow = try container.decodeIfPresent(String.self, forKey: .alternateRow) - nullValue = try container.decodeIfPresent(String.self, forKey: .nullValue) - boolTrue = try container.decodeIfPresent(String.self, forKey: .boolTrue) - boolFalse = try container.decodeIfPresent(String.self, forKey: .boolFalse) - rowNumber = try container.decodeIfPresent(String.self, forKey: .rowNumber) - modified = try container.decodeIfPresent(String.self, forKey: .modified) ?? fallback.modified - inserted = try container.decodeIfPresent(String.self, forKey: .inserted) ?? fallback.inserted - deleted = try container.decodeIfPresent(String.self, forKey: .deleted) ?? fallback.deleted - deletedText = try container.decodeIfPresent(String.self, forKey: .deletedText) ?? fallback.deletedText - } -} - -// MARK: - Status Colors - -internal struct StatusColors: Codable, Equatable, Sendable { - var success: String - var warning: String - var error: String - var info: String - - static let defaultLight = StatusColors( - success: "#248A3D", - warning: "#C55B00", - error: "#D70015", - info: "#007AFF" - ) - - init(success: String, warning: String, error: String, info: String) { - self.success = success - self.warning = warning - self.error = error - self.info = info - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = StatusColors.defaultLight - - success = try container.decodeIfPresent(String.self, forKey: .success) ?? fallback.success - warning = try container.decodeIfPresent(String.self, forKey: .warning) ?? fallback.warning - error = try container.decodeIfPresent(String.self, forKey: .error) ?? fallback.error - info = try container.decodeIfPresent(String.self, forKey: .info) ?? fallback.info - } -} - -// MARK: - Badge Colors - -internal struct BadgeColors: Codable, Equatable, Sendable { - var background: String - var primaryKey: String - var autoIncrement: String - - static let defaultLight = BadgeColors( - background: "#E5E5EA", - primaryKey: "#007AFF26", - autoIncrement: "#AF52DE26" - ) - - init(background: String, primaryKey: String, autoIncrement: String) { - self.background = background - self.primaryKey = primaryKey - self.autoIncrement = autoIncrement - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = BadgeColors.defaultLight - - background = try container.decodeIfPresent(String.self, forKey: .background) ?? fallback.background - primaryKey = try container.decodeIfPresent(String.self, forKey: .primaryKey) ?? fallback.primaryKey - autoIncrement = try container.decodeIfPresent(String.self, forKey: .autoIncrement) ?? fallback.autoIncrement - } -} - -// MARK: - UI Theme Colors - -internal struct UIThemeColors: Codable, Equatable, Sendable { - var windowBackground: String? - var controlBackground: String? - var cardBackground: String? - var border: String? - var primaryText: String? - var secondaryText: String? - var tertiaryText: String? - var selectionBackground: String? - var hoverBackground: String? - var status: StatusColors - var badges: BadgeColors - - static let defaultLight = UIThemeColors( - windowBackground: nil, - controlBackground: nil, - cardBackground: nil, - border: nil, - primaryText: nil, - secondaryText: nil, - tertiaryText: nil, - selectionBackground: nil, - hoverBackground: nil, - status: .defaultLight, - badges: .defaultLight - ) - - init( - windowBackground: String?, - controlBackground: String?, - cardBackground: String?, - border: String?, - primaryText: String?, - secondaryText: String?, - tertiaryText: String?, - selectionBackground: String?, - hoverBackground: String?, - status: StatusColors, - badges: BadgeColors - ) { - self.windowBackground = windowBackground - self.controlBackground = controlBackground - self.cardBackground = cardBackground - self.border = border - self.primaryText = primaryText - self.secondaryText = secondaryText - self.tertiaryText = tertiaryText - self.selectionBackground = selectionBackground - self.hoverBackground = hoverBackground - self.status = status - self.badges = badges - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = UIThemeColors.defaultLight - - windowBackground = try container.decodeIfPresent(String.self, forKey: .windowBackground) - controlBackground = try container.decodeIfPresent(String.self, forKey: .controlBackground) - cardBackground = try container.decodeIfPresent(String.self, forKey: .cardBackground) - border = try container.decodeIfPresent(String.self, forKey: .border) - primaryText = try container.decodeIfPresent(String.self, forKey: .primaryText) - secondaryText = try container.decodeIfPresent(String.self, forKey: .secondaryText) - tertiaryText = try container.decodeIfPresent(String.self, forKey: .tertiaryText) - selectionBackground = try container.decodeIfPresent(String.self, forKey: .selectionBackground) - hoverBackground = try container.decodeIfPresent(String.self, forKey: .hoverBackground) - status = try container.decodeIfPresent(StatusColors.self, forKey: .status) ?? fallback.status - badges = try container.decodeIfPresent(BadgeColors.self, forKey: .badges) ?? fallback.badges - } -} - -// MARK: - Sidebar Theme Colors - -internal struct SidebarThemeColors: Codable, Equatable, Sendable { - var background: String? - var text: String? - var selectedItem: String? - var hover: String? - var sectionHeader: String? - - static let defaultLight = SidebarThemeColors( - background: nil, - text: nil, - selectedItem: nil, - hover: nil, - sectionHeader: nil - ) - - init(background: String?, text: String?, selectedItem: String?, hover: String?, sectionHeader: String?) { - self.background = background - self.text = text - self.selectedItem = selectedItem - self.hover = hover - self.sectionHeader = sectionHeader - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - background = try container.decodeIfPresent(String.self, forKey: .background) - text = try container.decodeIfPresent(String.self, forKey: .text) - selectedItem = try container.decodeIfPresent(String.self, forKey: .selectedItem) - hover = try container.decodeIfPresent(String.self, forKey: .hover) - sectionHeader = try container.decodeIfPresent(String.self, forKey: .sectionHeader) - } -} - -// MARK: - Toolbar Theme Colors - -internal struct ToolbarThemeColors: Codable, Equatable, Sendable { - var secondaryText: String? - var tertiaryText: String? - - static let defaultLight = ToolbarThemeColors( - secondaryText: nil, - tertiaryText: nil - ) - - init(secondaryText: String?, tertiaryText: String?) { - self.secondaryText = secondaryText - self.tertiaryText = tertiaryText - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - secondaryText = try container.decodeIfPresent(String.self, forKey: .secondaryText) - tertiaryText = try container.decodeIfPresent(String.self, forKey: .tertiaryText) - } -} diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift index f0eaddf405..e0904f6f61 100644 --- a/TablePro/Theme/ThemeDefinition.swift +++ b/TablePro/Theme/ThemeDefinition.swift @@ -1,80 +1,286 @@ -import SwiftUI +import Foundation -internal struct ThemeDefinition: Codable, Identifiable, Equatable, Sendable { +internal enum ThemeAppearance: String, Codable, CaseIterable, Sendable { + case light + case dark +} + +internal struct SyntaxThemeColors: Equatable, Sendable { + var keyword: ThemeColorValue + var string: ThemeColorValue + var number: ThemeColorValue + var comment: ThemeColorValue + var null: ThemeColorValue + var `operator`: ThemeColorValue + var function: ThemeColorValue + var type: ThemeColorValue +} + +internal struct EditorThemeColors: Equatable, Sendable { + var background: ThemeColorValue + var text: ThemeColorValue + var cursor: ThemeColorValue + var selection: ThemeColorValue + var currentLine: ThemeColorValue + var currentStatement: ThemeColorValue + var lineNumber: ThemeColorValue + var invisibles: ThemeColorValue + var syntax: SyntaxThemeColors +} + +internal struct DataGridThemeColors: Equatable, Sendable { + var background: ThemeColorValue + var text: ThemeColorValue + var alternateRow: ThemeColorValue + var headerBackground: ThemeColorValue + var headerText: ThemeColorValue + var gridLine: ThemeColorValue + var selection: ThemeColorValue + var selectionText: ThemeColorValue + var inactiveSelection: ThemeColorValue + var focusBorder: ThemeColorValue + var nullValue: ThemeColorValue + var boolTrue: ThemeColorValue + var boolFalse: ThemeColorValue + var rowNumber: ThemeColorValue + var modified: ThemeColorValue + var inserted: ThemeColorValue + var deleted: ThemeColorValue + var deletedText: ThemeColorValue +} + +internal struct StatusThemeColors: Equatable, Sendable { + var success: ThemeColorValue + var warning: ThemeColorValue + var error: ThemeColorValue +} + +internal struct PanelThemeColors: Equatable, Sendable { + var background: ThemeColorValue + var controlBackground: ThemeColorValue + var text: ThemeColorValue + var secondaryText: ThemeColorValue + var tertiaryText: ThemeColorValue + var separator: ThemeColorValue +} + +internal struct ThemeDefinition: Identifiable, Equatable, Sendable { var id: String var name: String - var version: Int - var appearance: ThemeAppearance var author: String + var appearance: ThemeAppearance var editor: EditorThemeColors var dataGrid: DataGridThemeColors - var ui: UIThemeColors - var sidebar: SidebarThemeColors - var toolbar: ToolbarThemeColors - var fonts: ThemeFonts - - var isBuiltIn: Bool { id.hasPrefix("tablepro.") } - var isRegistry: Bool { id.hasPrefix("registry.") } - var isEditable: Bool { !isBuiltIn && !isRegistry } - - static let `default` = ThemeDefinition( - id: "tablepro.default-light", - name: "Default Light", - version: 1, - appearance: .light, - author: "TablePro", - editor: .defaultLight, - dataGrid: .defaultLight, - ui: .defaultLight, - sidebar: .defaultLight, - toolbar: .defaultLight, - fonts: .default - ) - - init( - id: String, - name: String, - version: Int, - appearance: ThemeAppearance, - author: String, - editor: EditorThemeColors, - dataGrid: DataGridThemeColors, - ui: UIThemeColors, - sidebar: SidebarThemeColors, - toolbar: ToolbarThemeColors, - fonts: ThemeFonts - ) { - self.id = id - self.name = name - self.version = version - self.appearance = appearance - self.author = author - self.editor = editor - self.dataGrid = dataGrid - self.ui = ui - self.sidebar = sidebar - self.toolbar = toolbar - self.fonts = fonts + var panel: PanelThemeColors + var status: StatusThemeColors + + internal static let builtInPrefix = "tablepro." + internal static let registryPrefix = "registry." + internal static let userPrefix = "user." + + internal var isBuiltIn: Bool { id.hasPrefix(Self.builtInPrefix) } + internal var isRegistry: Bool { id.hasPrefix(Self.registryPrefix) } + internal var isEditable: Bool { !isBuiltIn && !isRegistry } +} + +/// The single registry every slot goes through. A slot exists here because a call site reads it, +/// which is what `ThemeSlotCoverageTests` enforces: the previous schema let whole groups +/// ship with no reader at all, and nothing caught it. The gate, the theme editor and the document +/// decoder all enumerate this list rather than keeping their own copies. +internal enum ThemeSlot: String, CaseIterable, Sendable { + case editorBackground = "content.editor.background" + case editorText = "content.editor.text" + case editorCursor = "content.editor.cursor" + case editorSelection = "content.editor.selection" + case editorCurrentLine = "content.editor.currentLine" + case editorCurrentStatement = "content.editor.currentStatement" + case editorLineNumber = "content.editor.lineNumber" + case editorInvisibles = "content.editor.invisibles" + + case syntaxKeyword = "content.editor.syntax.keyword" + case syntaxString = "content.editor.syntax.string" + case syntaxNumber = "content.editor.syntax.number" + case syntaxComment = "content.editor.syntax.comment" + case syntaxNull = "content.editor.syntax.null" + case syntaxOperator = "content.editor.syntax.operator" + case syntaxFunction = "content.editor.syntax.function" + case syntaxType = "content.editor.syntax.type" + + case gridBackground = "content.dataGrid.background" + case gridText = "content.dataGrid.text" + case gridAlternateRow = "content.dataGrid.alternateRow" + case gridHeaderBackground = "content.dataGrid.headerBackground" + case gridHeaderText = "content.dataGrid.headerText" + case gridLine = "content.dataGrid.gridLine" + case gridSelection = "content.dataGrid.selection" + case gridSelectionText = "content.dataGrid.selectionText" + case gridInactiveSelection = "content.dataGrid.inactiveSelection" + case gridFocusBorder = "content.dataGrid.focusBorder" + case gridNullValue = "content.dataGrid.nullValue" + case gridBoolTrue = "content.dataGrid.boolTrue" + case gridBoolFalse = "content.dataGrid.boolFalse" + case gridRowNumber = "content.dataGrid.rowNumber" + case gridModified = "content.dataGrid.modified" + case gridInserted = "content.dataGrid.inserted" + case gridDeleted = "content.dataGrid.deleted" + case gridDeletedText = "content.dataGrid.deletedText" + + case panelBackground = "content.panel.background" + case panelControlBackground = "content.panel.controlBackground" + case panelText = "content.panel.text" + case panelSecondaryText = "content.panel.secondaryText" + case panelTertiaryText = "content.panel.tertiaryText" + case panelSeparator = "content.panel.separator" + + case statusSuccess = "content.status.success" + case statusWarning = "content.status.warning" + case statusError = "content.status.error" + + + internal var since: Int { 2 } + + internal var group: ThemeSlotGroup { + switch self { + case .editorBackground, .editorText, .editorCursor, .editorSelection, + .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles: + return .editor + case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, + .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType: + return .syntax + case .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, + .gridLine, .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder, + .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, + .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: + return .dataGrid + case .panelBackground, .panelControlBackground, .panelText, + .panelSecondaryText, .panelTertiaryText, .panelSeparator: + return .panel + case .statusSuccess, .statusWarning, .statusError: + return .status + } + } + + internal var keyPath: WritableKeyPath { + switch self { + case .editorBackground: return \.editor.background + case .editorText: return \.editor.text + case .editorCursor: return \.editor.cursor + case .editorSelection: return \.editor.selection + case .editorCurrentLine: return \.editor.currentLine + case .editorCurrentStatement: return \.editor.currentStatement + case .editorLineNumber: return \.editor.lineNumber + case .editorInvisibles: return \.editor.invisibles + + case .syntaxKeyword: return \.editor.syntax.keyword + case .syntaxString: return \.editor.syntax.string + case .syntaxNumber: return \.editor.syntax.number + case .syntaxComment: return \.editor.syntax.comment + case .syntaxNull: return \.editor.syntax.null + case .syntaxOperator: return \.editor.syntax.operator + case .syntaxFunction: return \.editor.syntax.function + case .syntaxType: return \.editor.syntax.type + + case .gridBackground: return \.dataGrid.background + case .gridText: return \.dataGrid.text + case .gridAlternateRow: return \.dataGrid.alternateRow + case .gridHeaderBackground: return \.dataGrid.headerBackground + case .gridHeaderText: return \.dataGrid.headerText + case .gridLine: return \.dataGrid.gridLine + case .gridSelection: return \.dataGrid.selection + case .gridSelectionText: return \.dataGrid.selectionText + case .gridInactiveSelection: return \.dataGrid.inactiveSelection + case .gridFocusBorder: return \.dataGrid.focusBorder + case .gridNullValue: return \.dataGrid.nullValue + case .gridBoolTrue: return \.dataGrid.boolTrue + case .gridBoolFalse: return \.dataGrid.boolFalse + case .gridRowNumber: return \.dataGrid.rowNumber + case .gridModified: return \.dataGrid.modified + case .gridInserted: return \.dataGrid.inserted + case .gridDeleted: return \.dataGrid.deleted + case .gridDeletedText: return \.dataGrid.deletedText + + case .panelBackground: return \.panel.background + case .panelControlBackground: return \.panel.controlBackground + case .panelText: return \.panel.text + case .panelSecondaryText: return \.panel.secondaryText + case .panelTertiaryText: return \.panel.tertiaryText + case .panelSeparator: return \.panel.separator + + case .statusSuccess: return \.status.success + case .statusWarning: return \.status.warning + case .statusError: return \.status.error + } + } + + internal var label: String { + switch self { + case .editorBackground, .gridBackground: return String(localized: "Background") + case .editorText, .gridText: return String(localized: "Text") + case .editorCursor: return String(localized: "Cursor") + case .editorSelection, .gridSelection: return String(localized: "Selection") + case .editorCurrentLine: return String(localized: "Current Line") + case .editorCurrentStatement: return String(localized: "Current Statement") + case .editorLineNumber: return String(localized: "Line Number") + case .editorInvisibles: return String(localized: "Invisibles") + case .syntaxKeyword: return String(localized: "Keyword") + case .syntaxString: return String(localized: "String") + case .syntaxNumber: return String(localized: "Number") + case .syntaxComment: return String(localized: "Comment") + case .syntaxNull: return String(localized: "NULL") + case .syntaxOperator: return String(localized: "Operator") + case .syntaxFunction: return String(localized: "Function") + case .syntaxType: return String(localized: "Type") + case .gridAlternateRow: return String(localized: "Alternate Row") + case .gridHeaderBackground: return String(localized: "Header Background") + case .gridHeaderText: return String(localized: "Header Text") + case .gridLine: return String(localized: "Grid Line") + case .gridSelectionText: return String(localized: "Selected Text") + case .gridInactiveSelection: return String(localized: "Inactive Selection") + case .gridFocusBorder: return String(localized: "Focus Border") + case .gridNullValue: return String(localized: "NULL Value") + case .gridBoolTrue: return String(localized: "Bool True") + case .gridBoolFalse: return String(localized: "Bool False") + case .gridRowNumber: return String(localized: "Row Number") + case .gridModified: return String(localized: "Modified") + case .gridInserted: return String(localized: "Inserted") + case .gridDeleted: return String(localized: "Deleted") + case .gridDeletedText: return String(localized: "Deleted Text") + case .statusSuccess: return String(localized: "Success") + case .statusWarning: return String(localized: "Warning") + case .statusError: return String(localized: "Error") + case .panelBackground: return String(localized: "Pane Background") + case .panelControlBackground: return String(localized: "Field Background") + case .panelText: return String(localized: "Pane Text") + case .panelSecondaryText: return String(localized: "Secondary Text") + case .panelTertiaryText: return String(localized: "Tertiary Text") + case .panelSeparator: return String(localized: "Separator") + } + } +} + +internal enum ThemeSlotGroup: String, CaseIterable, Sendable { + case editor + case syntax + case dataGrid + case panel + case status + + internal var label: String { + switch self { + case .editor: return String(localized: "Editor") + case .syntax: return String(localized: "Syntax Colors") + case .dataGrid: return String(localized: "Data Grid") + case .panel: return String(localized: "Panels") + case .status: return String(localized: "Status") + } } - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = ThemeDefinition.default - - id = try container.decodeIfPresent(String.self, forKey: .id) ?? fallback.id - name = try container.decodeIfPresent(String.self, forKey: .name) ?? fallback.name - version = try container.decodeIfPresent(Int.self, forKey: .version) ?? fallback.version - appearance = try container.decodeIfPresent(ThemeAppearance.self, forKey: .appearance) ?? fallback.appearance - author = try container.decodeIfPresent(String.self, forKey: .author) ?? fallback.author - editor = try container.decodeIfPresent(EditorThemeColors.self, forKey: .editor) ?? fallback.editor - dataGrid = try container.decodeIfPresent(DataGridThemeColors.self, forKey: .dataGrid) ?? fallback.dataGrid - ui = try container.decodeIfPresent(UIThemeColors.self, forKey: .ui) ?? fallback.ui - sidebar = try container.decodeIfPresent(SidebarThemeColors.self, forKey: .sidebar) ?? fallback.sidebar - toolbar = try container.decodeIfPresent(ToolbarThemeColors.self, forKey: .toolbar) ?? fallback.toolbar - fonts = try container.decodeIfPresent(ThemeFonts.self, forKey: .fonts) ?? fallback.fonts + internal var slots: [ThemeSlot] { + ThemeSlot.allCases.filter { $0.group == self } } } -internal enum ThemeAppearance: String, Codable, Sendable { - case light, dark, auto +internal enum ThemeSchema { + internal static let current = 2 + internal static let oldestSupported = 2 } diff --git a/TablePro/Theme/ThemeDocument.swift b/TablePro/Theme/ThemeDocument.swift new file mode 100644 index 0000000000..4debffd57a --- /dev/null +++ b/TablePro/Theme/ThemeDocument.swift @@ -0,0 +1,200 @@ +import Foundation + +internal indirect enum ThemeJSONNode: Decodable { + case string(String) + case number(Int) + case object([String: ThemeJSONNode]) + + internal init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + if let value = try? container.decode(Int.self) { + self = .number(value) + return + } + self = .object(try container.decode([String: ThemeJSONNode].self)) + } + + internal var stringValue: String? { + if case let .string(value) = self { return value } + return nil + } + + internal var intValue: Int? { + if case let .number(value) = self { return value } + return nil + } + + internal func flattened(prefix: String = "") -> [String: String] { + switch self { + case let .string(value): + return prefix.isEmpty ? [:] : [prefix: value] + case .number: + return [:] + case let .object(children): + var result: [String: String] = [:] + for (key, child) in children { + let path = prefix.isEmpty ? key : "\(prefix).\(key)" + result.merge(child.flattened(prefix: path)) { _, new in new } + } + return result + } + } +} + +/// The theme file format, and the only place a file can be rejected. The previous decoder read +/// every key with `decodeIfPresent` and a Default Light fallback, so an empty object and a VS Code +/// theme both decoded "successfully" and rendered as Default Light under the file's own name. +internal struct ThemeDocument { + internal let schema: Int + internal let id: String + internal let name: String + internal let author: String + internal let appearance: ThemeAppearance + internal let colors: [ThemeSlot: ThemeColorValue] + + private enum MetadataKey: String, CaseIterable { + case schema + case id + case name + case author + case appearance + } + + internal init(data: Data) throws { + let root = try JSONDecoder().decode([String: ThemeJSONNode].self, from: data) + + guard let schema = root[MetadataKey.schema.rawValue]?.intValue else { + throw ThemeLoadError.missingSchema + } + guard schema >= ThemeSchema.oldestSupported else { + throw ThemeLoadError.schemaTooOld(found: schema, supported: ThemeSchema.current) + } + guard schema <= ThemeSchema.current else { + throw ThemeLoadError.schemaTooNew(found: schema, supported: ThemeSchema.current) + } + self.schema = schema + + guard let id = root[MetadataKey.id.rawValue]?.stringValue, !id.isEmpty else { + throw ThemeLoadError.missingField(MetadataKey.id.rawValue) + } + guard ThemeIdentifier.isValid(id) else { + throw ThemeLoadError.invalidIdentifier(id) + } + self.id = id + + guard let name = root[MetadataKey.name.rawValue]?.stringValue, !name.isEmpty else { + throw ThemeLoadError.missingField(MetadataKey.name.rawValue) + } + self.name = name + + guard + let appearanceRaw = root[MetadataKey.appearance.rawValue]?.stringValue, + let appearance = ThemeAppearance(rawValue: appearanceRaw) + else { + throw ThemeLoadError.missingField(MetadataKey.appearance.rawValue) + } + self.appearance = appearance + + author = root[MetadataKey.author.rawValue]?.stringValue ?? "" + + var flat: [String: String] = [:] + for (key, node) in root where MetadataKey(rawValue: key) == nil { + flat.merge(node.flattened(prefix: key)) { _, new in new } + } + + var parsed: [ThemeSlot: ThemeColorValue] = [:] + var unknown: [String] = [] + + for (path, raw) in flat { + guard let slot = ThemeSlot(rawValue: path) else { + unknown.append(path) + continue + } + parsed[slot] = try ThemeColorValue(validating: raw) + } + + guard unknown.isEmpty else { + throw ThemeLoadError.unknownKeys(unknown.sorted()) + } + + let missing = ThemeSlot.allCases + .filter { $0.since <= schema && parsed[$0] == nil } + .map(\.rawValue) + + guard missing.isEmpty else { + throw ThemeLoadError.missingSlots(missing.sorted()) + } + + colors = parsed + } + + /// A slot introduced after this file's schema revision takes the same-appearance built-in's + /// value for that exact slot. Every slot the file's own revision declares is required, so this + /// never papers over an omission the author could have made. + internal func resolved() -> ThemeDefinition { + var definition = BuiltInThemes.default(for: appearance) + definition.id = id + definition.name = name + definition.author = author + definition.appearance = appearance + + for slot in ThemeSlot.allCases { + guard let value = colors[slot] else { continue } + definition[keyPath: slot.keyPath] = value + } + + return definition + } +} + +internal enum ThemeIdentifier { + private static let pattern = #"^[A-Za-z0-9._-]+$"# + + internal static func isValid(_ id: String) -> Bool { + id.range(of: pattern, options: .regularExpression) != nil + && !id.contains("..") + } + + internal static func generated() -> String { + ThemeDefinition.userPrefix + UUID().uuidString.lowercased().prefix(8) + } +} + +internal enum ThemeEncoder { + internal static func data(for theme: ThemeDefinition) throws -> Data { + var root: [String: Any] = [ + "schema": ThemeSchema.current, + "id": theme.id, + "name": theme.name, + "author": theme.author, + "appearance": theme.appearance.rawValue, + ] + + for slot in ThemeSlot.allCases { + insert(theme[keyPath: slot.keyPath].rawValue, at: slot.rawValue.components(separatedBy: "."), into: &root) + } + + return try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + } + + private static func insert(_ value: String, at path: [String], into container: inout [String: Any]) { + guard let head = path.first else { return } + + guard path.count > 1 else { + container[head] = value + return + } + + var child = container[head] as? [String: Any] ?? [:] + insert(value, at: Array(path.dropFirst()), into: &child) + container[head] = child + } +} diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 6946a016b1..e1495cb65b 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -1,11 +1,3 @@ -// -// ThemeEngine.swift -// TablePro -// -// Central @Observable singleton managing the active theme. -// Replaces Theme.swift, SQLEditorTheme, DataGridFontCache, ToolbarDesignTokens. -// - import AppKit import Combine import Foundation @@ -13,9 +5,6 @@ import os import SwiftUI import TableProEditorKit -// MARK: - Font Caches - -/// Tags stored on NSTextField.tag to identify which font variant a cell uses. internal enum DataGridFontVariant { static let regular = 0 static let italic = 1 @@ -23,446 +12,261 @@ internal enum DataGridFontVariant { static let rowNumber = 3 } -internal struct EditorFontCache { - let font: NSFont - let lineNumberFont: NSFont - let scaleFactor: CGFloat +internal struct EditorFontCache: Equatable { + internal let font: NSFont + internal let lineNumberFont: NSFont + internal let scaleFactor: CGFloat - init(from fonts: ThemeFonts) { - let scale = Self.computeAccessibilityScale() + internal init(from typography: TypographySettings) { + let scale = Self.accessibilityScale() scaleFactor = scale - let scaledSize = round(CGFloat(min(max(fonts.editorFontSize, 11), 18)) * scale) - font = EditorFontResolver.resolve(familyId: fonts.editorFontFamily, size: scaledSize) - let lineNumSize = max(round((scaledSize - 2)), 9) - lineNumberFont = NSFont.monospacedSystemFont(ofSize: lineNumSize, weight: .regular) + + let size = round(CGFloat(typography.clampedEditorFontSize) * scale) + font = EditorFontResolver.resolve(familyId: typography.editorFontFamily, size: size) + lineNumberFont = NSFont.monospacedSystemFont(ofSize: max(round(size - 2), 9), weight: .regular) } - static func computeAccessibilityScale() -> CGFloat { - let preferredBodyFont = NSFont.preferredFont(forTextStyle: .body) - let scale = preferredBodyFont.pointSize / 13.0 - return min(max(scale, 0.5), 3.0) + internal static func accessibilityScale() -> CGFloat { + let preferred = NSFont.preferredFont(forTextStyle: .body) + return min(max(preferred.pointSize / 13.0, 0.5), 3.0) } } -internal struct DataGridFontCacheResolved { - let regular: NSFont - let italic: NSFont - let medium: NSFont - let rowNumber: NSFont - let monoCharWidth: CGFloat - - init(from fonts: ThemeFonts) { - let scale = EditorFontCache.computeAccessibilityScale() - let scaledSize = round(CGFloat(min(max(fonts.dataGridFontSize, 10), 18)) * scale) - regular = EditorFontResolver.resolve(familyId: fonts.dataGridFontFamily, size: scaledSize) +internal struct DataGridFontCache: Equatable { + internal let regular: NSFont + internal let italic: NSFont + internal let medium: NSFont + internal let rowNumber: NSFont + internal let monoCharWidth: CGFloat + + internal init(from typography: TypographySettings) { + let scale = EditorFontCache.accessibilityScale() + let size = round(CGFloat(typography.clampedDataGridFontSize) * scale) + + regular = EditorFontResolver.resolve(familyId: typography.dataGridFontFamily, size: size) italic = regular.withTraits(.italic) medium = NSFontManager.shared.convert(regular, toHaveTrait: .boldFontMask) - let rowNumSize = max(round(scaledSize - 1), 9) - rowNumber = NSFont.monospacedDigitSystemFont(ofSize: rowNumSize, weight: .regular) - let attrs: [NSAttributedString.Key: Any] = [.font: regular] - monoCharWidth = ("M" as NSString).size(withAttributes: attrs).width + rowNumber = NSFont.monospacedDigitSystemFont(ofSize: max(round(size - 1), 9), weight: .regular) + monoCharWidth = ("M" as NSString).size(withAttributes: [.font: regular]).width } } -// MARK: - ThemeEngine - +/// Owns the active palette and the font caches, and nothing else: the catalog is `ThemeCatalog`, +/// the choice is `ThemeResolver`, and the fonts come from settings. It never writes settings back, +/// so the flow is one way. @MainActor internal final class ThemeEngine: ObservableObject { - static let shared = ThemeEngine() - - // MARK: - Active Theme - - @Published private(set) var activeTheme: ThemeDefinition - - /// Pre-resolved colors (rebuilt on theme change) - @Published private(set) var colors: ResolvedThemeColors - - /// Cached editor fonts - @Published private(set) var editorFonts: EditorFontCache - - /// Cached data grid fonts - @Published private(set) var dataGridFonts: DataGridFontCacheResolved + internal static let shared = ThemeEngine() - // MARK: - Stored Value Font + @Published internal private(set) var pair: ThemePair + @Published internal private(set) var effectiveAppearance: ThemeAppearance + @Published internal private(set) var revision: Int + @Published internal private(set) var palette: ThemePalette + @Published internal private(set) var resolved: ResolvedTheme + @Published internal private(set) var editorFonts: EditorFontCache + @Published internal private(set) var dataGridFonts: DataGridFontCache - /// The font every control that shows or edits a stored value uses, so one value reads the same in - /// the grid cell, its inline editor, the row inspector and a pop-out window. It is the data grid - /// font today; a viewer that also wears the editor's colours and syntax palette takes - /// `editorFonts` instead. - var valueFont: NSFont { dataGridFonts.regular } + internal var activeTheme: ThemeDefinition { pair[effectiveAppearance] } - var valueFontSwiftUI: Font { Font(valueFont) } - - /// The emphasised variant, for the key half of a key/value pair. - var valueFontEmphasizedSwiftUI: Font { Font(dataGridFonts.medium) } - - // MARK: - Available Themes - - @Published private(set) var availableThemes: [ThemeDefinition] - - // MARK: - Editor Behavioral Settings (read from AppSettingsManager) - - /// These are not theme properties but are needed by makeEditorTheme() - var highlightCurrentLine: Bool = true - var highlightCurrentStatement: Bool = true - var showLineNumbers: Bool = true - var tabWidth: Int = 4 - var wordWrap: Bool = false + internal var change: ThemeChange { + ThemeChange(revision: revision, appearance: effectiveAppearance) + } - // MARK: - Private + /// Every control that shows or edits a stored value takes this, so one value reads the same in + /// the grid cell, its inline editor, the row inspector, a cell popover and a pop-out window. + internal var valueFont: NSFont { dataGridFonts.regular } + internal var valueFontSwiftUI: Font { Font(valueFont) } + internal var valueFontEmphasizedSwiftUI: Font { Font(dataGridFonts.medium) } nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEngine") - private var accessibilityObserver: NSObjectProtocol? - private var lastAccessibilityScale: CGFloat = 1.0 - // MARK: - Init + private var mode: AppAppearanceMode = .auto + private var typography: TypographySettings = .default + private var appearanceObservation: NSKeyValueObservation? + private var accessibilityObserver: NSObjectProtocol? + private var lastAccessibilityScale: CGFloat = 1 private init() { - let theme = ThemeDefinition.default - - self.activeTheme = theme - self.colors = ResolvedThemeColors(from: theme) - self.editorFonts = EditorFontCache(from: theme.fonts) - self.dataGridFonts = DataGridFontCacheResolved(from: theme.fonts) - self.availableThemes = [theme] + pair = .builtIn + effectiveAppearance = .light + revision = 1 + palette = ThemePalette(revision: 1) + resolved = ResolvedTheme(definition: BuiltInThemes.light, appearance: .light) + editorFonts = EditorFontCache(from: .default) + dataGridFonts = DataGridFontCache(from: .default) observeAccessibilityChanges() - - Task { - let themes = await Task.detached { ThemeStorage.loadAllThemes() }.value - self.availableThemes = themes - } - } - - // MARK: - Theme Lifecycle - - func activateTheme(id: String) { - if let theme = availableThemes.first(where: { $0.id == id }) - ?? ThemeStorage.loadTheme(id: id) { - activateTheme(theme) - return - } - - Self.logger.warning("Theme '\(id)' not found; falling back to default") - activateTheme(.default) } - func activateTheme(_ theme: ThemeDefinition) { - activeTheme = theme - colors = ResolvedThemeColors(from: theme) - editorFonts = EditorFontCache(from: theme.fonts) - dataGridFonts = DataGridFontCacheResolved(from: theme.fonts) - - notifyThemeDidChange() - - Self.logger.info("Activated theme: \(theme.name) (\(theme.id))") - } - - // MARK: - Theme CRUD - - func saveUserTheme(_ theme: ThemeDefinition) throws { - try ThemeStorage.saveUserTheme(theme) - - if let index = availableThemes.firstIndex(where: { $0.id == theme.id }) { - availableThemes[index] = theme - } else { - availableThemes.append(theme) - } - reloadAvailableThemes() - - if theme.id == activeTheme.id { - activateTheme(theme) - } - } - - func deleteUserTheme(id: String) throws { - guard !id.hasPrefix("tablepro."), !id.hasPrefix("registry.") else { return } - try ThemeStorage.deleteUserTheme(id: id) - reloadAvailableThemes() - - // If deleted a preferred theme, reset that slot to default - var appearance = AppSettingsManager.shared.appearance - var changed = false - if id == appearance.preferredLightThemeId { - appearance.preferredLightThemeId = "tablepro.default-light" - changed = true - } - if id == appearance.preferredDarkThemeId { - appearance.preferredDarkThemeId = "tablepro.default-dark" - changed = true - } - if changed { - AppSettingsManager.shared.appearance = appearance - } else if id == activeTheme.id { - // Deleted a non-preferred but currently active theme — re-anchor to preferred - let appearance = AppSettingsManager.shared.appearance - updateAppearanceAndTheme( - mode: appearance.appearanceMode, - lightThemeId: appearance.preferredLightThemeId, - darkThemeId: appearance.preferredDarkThemeId - ) - } - } + // MARK: - Settings entry points - func duplicateTheme(_ theme: ThemeDefinition, newName: String) -> ThemeDefinition { - var copy = theme - copy.id = "user.\(UUID().uuidString.lowercased().prefix(8))" - copy.name = newName - copy.author = theme.author - return copy - } + internal func apply(mode: AppAppearanceMode, lightThemeId: String, darkThemeId: String) { + self.mode = mode + applyApplicationAppearance(mode) + updateSystemAppearanceObserver(mode) - func importTheme(from url: URL) throws -> ThemeDefinition { - let theme = try ThemeStorage.importTheme(from: url) - reloadAvailableThemes() - return theme - } + let selection = ThemeResolver.resolve( + mode: mode, + lightThemeId: lightThemeId, + darkThemeId: darkThemeId, + themes: ThemeCatalog.shared.themes, + systemIsDark: Self.systemIsDark() + ) - func exportTheme(_ theme: ThemeDefinition, to url: URL) throws { - try ThemeStorage.exportTheme(theme, to: url) + adopt(selection) } - var registryThemes: [ThemeDefinition] { - availableThemes.filter(\.isRegistry) + internal func apply(typography: TypographySettings) { + guard typography != self.typography else { return } + self.typography = typography + editorFonts = EditorFontCache(from: typography) + dataGridFonts = DataGridFontCache(from: typography) + bumpRevision() + publishChange() } - func uninstallRegistryTheme(registryPluginId: String) throws { - try ThemeRegistryInstaller.shared.uninstall(registryPluginId: registryPluginId) + /// Called when the catalog changes under a selection that is already live, so a saved edit is + /// visible without the settings round trip that used to re-activate a stale cached copy. + internal func reapply(lightThemeId: String, darkThemeId: String) { + apply(mode: mode, lightThemeId: lightThemeId, darkThemeId: darkThemeId) } - func reloadAvailableThemes() { - Task { - let themes = await Task.detached { ThemeStorage.loadAllThemes() }.value - self.availableThemes = themes - } - } + /// The resolver's output applied to the engine. Every entry point above funnels here, and it + /// is the seam a test uses to put a known pair in front of the grid without touching settings. + internal func adopt(_ selection: ThemeSelection) { + let appearanceChanged = selection.effectiveAppearance != effectiveAppearance + let pairChanged = selection.pair != pair - // MARK: - Editor Font Size Zoom + guard appearanceChanged || pairChanged else { return } - func adjustEditorFontSize(by delta: Int) { - var theme = activeTheme - let newSize = max(9, min(24, theme.fonts.editorFontSize + delta)) - guard newSize != theme.fonts.editorFontSize else { return } - theme.fonts.editorFontSize = newSize - activeTheme = theme - editorFonts = EditorFontCache(from: theme.fonts) - notifyThemeDidChange() + pair = selection.pair + effectiveAppearance = selection.effectiveAppearance + ThemeSource.shared.update(selection.pair) - // Persist so the zoom survives re-activation (e.g. system appearance change) - if theme.isEditable { - try? ThemeStorage.saveUserTheme(theme) + if pairChanged { + bumpRevision() } - } + resolved = ResolvedTheme(definition: activeTheme, appearance: effectiveAppearance) - // MARK: - Font Cache Reload (accessibility) - - func reloadFontCaches() { - editorFonts = EditorFontCache(from: activeTheme.fonts) - dataGridFonts = DataGridFontCacheResolved(from: activeTheme.fonts) - notifyThemeDidChange() + publishChange() + Self.logger.info("Theme \(self.activeTheme.id, privacy: .public) revision \(self.revision)") } - // MARK: - Update Editor Behavioral Settings - - func updateEditorSettings( - highlightCurrentLine: Bool, - highlightCurrentStatement: Bool, - showLineNumbers: Bool, - tabWidth: Int, - wordWrap: Bool - ) { - self.highlightCurrentLine = highlightCurrentLine - self.highlightCurrentStatement = highlightCurrentStatement - self.showLineNumbers = showLineNumbers - self.tabWidth = tabWidth - self.wordWrap = wordWrap + private func bumpRevision() { + revision += 1 + palette = ThemePalette(revision: revision) } - // MARK: - TableProEditorKit Theme - - func makeEditorTheme() -> EditorTheme { - let c = colors.editor - - let textAttr = EditorTheme.Attribute(color: srgb(c.text)) - let commentAttr = EditorTheme.Attribute(color: srgb(c.comment)) - let keywordAttr = EditorTheme.Attribute(color: srgb(c.keyword), bold: true) - let stringAttr = EditorTheme.Attribute(color: srgb(c.string)) - let numberAttr = EditorTheme.Attribute(color: srgb(c.number)) - let variableAttr = EditorTheme.Attribute(color: srgb(c.null)) - let typeAttr = EditorTheme.Attribute(color: srgb(c.type)) - let operatorAttr = EditorTheme.Attribute(color: srgb(c.operator)) - let functionAttr = EditorTheme.Attribute(color: srgb(c.function)) - - let lineHighlight: NSColor = highlightCurrentLine ? c.currentLineHighlight : .clear - let statementHighlight: NSColor = highlightCurrentStatement ? resolvedStatementHighlight(c) : .clear - - return EditorTheme( - text: textAttr, - insertionPoint: srgb(c.cursor), - invisibles: EditorTheme.Attribute(color: srgb(c.invisibles)), - background: srgb(c.background), - lineHighlight: srgb(lineHighlight), - statementHighlight: srgb(statementHighlight), - selection: srgb(c.selection), - keywords: keywordAttr, - commands: keywordAttr, - types: typeAttr, - attributes: variableAttr, - variables: variableAttr, - values: variableAttr, - numbers: numberAttr, - strings: stringAttr, - characters: stringAttr, - comments: commentAttr, - operators: operatorAttr, - functions: functionAttr - ) + private func publishChange() { + AppEvents.shared.themeChanged.send(change) } - /// The band's colour, corrected for a theme that never declared one. - /// - /// `EditorThemeColors` falls back to its light defaults for any key a theme omits, and every theme written before - /// this key existed omits it. On a dark custom theme that fallback is a near-black wash on a near-black - /// background: invisible, and indistinguishable from the feature being broken. Deriving the band from the - /// theme's own text colour instead is what the gutter glyph already does. - private func resolvedStatementHighlight(_ colors: ResolvedEditorColors) -> NSColor { - let declared = colors.currentStatementHighlight - let backgroundIsDark = (colors.background.usingColorSpace(.deviceRGB)?.brightnessComponent ?? 1) < 0.5 - let bandIsDark = (declared.usingColorSpace(.deviceRGB)?.brightnessComponent ?? 0) < 0.5 - guard backgroundIsDark, bandIsDark else { return declared } - return colors.text.withAlphaComponent(declared.alphaComponent) - } - - // MARK: - Appearance - - private(set) var appearanceMode: AppAppearanceMode = .auto - @Published private(set) var effectiveAppearance: ThemeAppearance = .light - private var currentLightThemeId: String = "tablepro.default-light" - private var currentDarkThemeId: String = "tablepro.default-dark" - private var systemAppearanceObservation: NSKeyValueObservation? - - /// Central entry point: resolves effective appearance, picks the correct theme, activates it, - /// and derives NSApp.appearance from the theme's own appearance metadata. - func updateAppearanceAndTheme( - mode: AppAppearanceMode, - lightThemeId: String, - darkThemeId: String - ) { - appearanceMode = mode - currentLightThemeId = lightThemeId - currentDarkThemeId = darkThemeId - - applyNSAppAppearance(mode: mode) - - let resolved = resolveEffectiveAppearance(mode) - effectiveAppearance = resolved - - let themeId = resolved == .dark ? darkThemeId : lightThemeId - activateTheme(id: themeId) - - updateSystemAppearanceObserver(mode: mode) - } + // MARK: - Application appearance - /// Resolve which appearance is in effect right now. - private func resolveEffectiveAppearance(_ mode: AppAppearanceMode) -> ThemeAppearance { + private func applyApplicationAppearance(_ mode: AppAppearanceMode) { switch mode { - case .light: return .light - case .dark: return .dark - case .auto: return systemIsDark() ? .dark : .light + case .light: NSApp?.appearance = NSAppearance(named: .aqua) + case .dark: NSApp?.appearance = NSAppearance(named: .darkAqua) + case .auto: NSApp?.appearance = nil } } - private func systemIsDark() -> Bool { - NSApp?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - } - - /// Set NSApp.appearance based on the appearance mode (not the theme). - /// Auto mode sets nil so the system controls the chrome. - private func applyNSAppAppearance(mode: AppAppearanceMode) { - switch mode { - case .light: - NSApp?.appearance = NSAppearance(named: .aqua) - case .dark: - NSApp?.appearance = NSAppearance(named: .darkAqua) - case .auto: - NSApp?.appearance = nil - } + private static func systemIsDark() -> Bool { + NSApp?.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua } - // MARK: - System Appearance Observer - - private func updateSystemAppearanceObserver(mode: AppAppearanceMode) { - systemAppearanceObservation = nil - + /// KVO on `NSApplication.effectiveAppearance` is the channel Apple names in the + /// `NSControlTintDidChangeNotification` deprecation text. Only the static tier and the change + /// signal depend on it: the dynamic slot colours answer for the drawing appearance themselves. + private func updateSystemAppearanceObserver(_ mode: AppAppearanceMode) { + appearanceObservation = nil guard mode == .auto else { return } - systemAppearanceObservation = NSApp?.observe(\.effectiveAppearance) { [weak self] _, _ in + appearanceObservation = NSApp?.observe(\.effectiveAppearance) { [weak self] _, _ in Task { @MainActor [weak self] in - guard let self, self.appearanceMode == .auto else { return } - let newAppearance: ThemeAppearance = self.systemIsDark() ? .dark : .light - guard newAppearance != self.effectiveAppearance else { return } - self.effectiveAppearance = newAppearance - let themeId = newAppearance == .dark ? self.currentDarkThemeId : self.currentLightThemeId - self.activateTheme(id: themeId) + self?.systemAppearanceDidChange() } } } - // MARK: - Notifications + private func systemAppearanceDidChange() { + guard mode == .auto else { return } + let appearance: ThemeAppearance = Self.systemIsDark() ? .dark : .light + guard appearance != effectiveAppearance else { return } - private func notifyThemeDidChange() { - AppEvents.shared.themeChanged.send(()) + effectiveAppearance = appearance + resolved = ResolvedTheme(definition: activeTheme, appearance: appearance) + publishChange() + } + + // MARK: - TableProEditorKit + + internal func makeEditorTheme() -> EditorTheme { + let editorSettings = AppSettingsManager.shared.editor + let text = EditorTheme.Attribute(color: resolved[.editorText]) + let comment = EditorTheme.Attribute(color: resolved[.syntaxComment]) + let keyword = EditorTheme.Attribute(color: resolved[.syntaxKeyword], bold: true) + let string = EditorTheme.Attribute(color: resolved[.syntaxString]) + let number = EditorTheme.Attribute(color: resolved[.syntaxNumber]) + let variable = EditorTheme.Attribute(color: resolved[.syntaxNull]) + let type = EditorTheme.Attribute(color: resolved[.syntaxType]) + let operatorAttribute = EditorTheme.Attribute(color: resolved[.syntaxOperator]) + let function = EditorTheme.Attribute(color: resolved[.syntaxFunction]) + + return EditorTheme( + text: text, + insertionPoint: resolved[.editorCursor], + invisibles: EditorTheme.Attribute(color: resolved[.editorInvisibles]), + background: resolved[.editorBackground], + lineHighlight: editorSettings.highlightCurrentLine ? resolved[.editorCurrentLine] : .clear, + statementHighlight: editorSettings.highlightCurrentStatement ? resolved[.editorCurrentStatement] : .clear, + selection: resolved[.editorSelection], + lineNumber: resolved[.editorLineNumber], + keywords: keyword, + commands: keyword, + types: type, + attributes: variable, + variables: variable, + values: variable, + numbers: number, + strings: string, + characters: string, + comments: comment, + operators: operatorAttribute, + functions: function + ) } // MARK: - Accessibility private func observeAccessibilityChanges() { - lastAccessibilityScale = EditorFontCache.computeAccessibilityScale() + lastAccessibilityScale = EditorFontCache.accessibilityScale() accessibilityObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in - guard let self else { return } - let newScale = EditorFontCache.computeAccessibilityScale() - guard abs(newScale - lastAccessibilityScale) > 0.01 else { return } - lastAccessibilityScale = newScale - Self.logger.debug("Accessibility text size changed, scale: \(newScale, format: .fixed(precision: 2))") - reloadFontCaches() - AppEvents.shared.accessibilityTextSizeChanged.send(()) + self?.accessibilityDisplayOptionsDidChange() } } } - // MARK: - Helpers + private func accessibilityDisplayOptionsDidChange() { + let scale = EditorFontCache.accessibilityScale() + guard abs(scale - lastAccessibilityScale) > 0.01 else { return } + lastAccessibilityScale = scale - private func srgb(_ color: NSColor) -> NSColor { - if let converted = color.usingColorSpace(.sRGB) { - return converted - } - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - if let deviceRgb = color.usingColorSpace(.deviceRGB) { - deviceRgb.getRed(&r, green: &g, blue: &b, alpha: &a) - } - return NSColor(srgbRed: r, green: g, blue: b, alpha: a) + editorFonts = EditorFontCache(from: typography) + dataGridFonts = DataGridFontCache(from: typography) + bumpRevision() + publishChange() + AppEvents.shared.accessibilityTextSizeChanged.send(()) } } -// MARK: - Database Type Colors (preserved from old Theme.swift) - -extension DatabaseType { +internal extension DatabaseType { @MainActor var themeColor: Color { PluginManager.shared.brandColor(for: self) } } - -// MARK: - View Extensions (preserved from old Theme.swift) - -extension View { - func cardStyle() -> some View { - self - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } -} diff --git a/TablePro/Theme/ThemeFonts.swift b/TablePro/Theme/ThemeFonts.swift deleted file mode 100644 index 8fa0901dd9..0000000000 --- a/TablePro/Theme/ThemeFonts.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// ThemeFonts.swift -// TablePro -// - -import Foundation - -// MARK: - Theme Fonts - -internal struct ThemeFonts: Codable, Equatable, Sendable { - var editorFontFamily: String - var editorFontSize: Int - var dataGridFontFamily: String - var dataGridFontSize: Int - - static let `default` = ThemeFonts( - editorFontFamily: "System Mono", - editorFontSize: 13, - dataGridFontFamily: "System Mono", - dataGridFontSize: 13 - ) - - init(editorFontFamily: String, editorFontSize: Int, dataGridFontFamily: String, dataGridFontSize: Int) { - self.editorFontFamily = editorFontFamily - self.editorFontSize = editorFontSize - self.dataGridFontFamily = dataGridFontFamily - self.dataGridFontSize = dataGridFontSize - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = ThemeFonts.default - - editorFontFamily = try container.decodeIfPresent(String.self, forKey: .editorFontFamily) - ?? fallback.editorFontFamily - editorFontSize = try container.decodeIfPresent(Int.self, forKey: .editorFontSize) ?? fallback.editorFontSize - dataGridFontFamily = try container.decodeIfPresent(String.self, forKey: .dataGridFontFamily) - ?? fallback.dataGridFontFamily - dataGridFontSize = try container.decodeIfPresent(Int.self, forKey: .dataGridFontSize) - ?? fallback.dataGridFontSize - } -} diff --git a/TablePro/Theme/ThemeLoadError.swift b/TablePro/Theme/ThemeLoadError.swift new file mode 100644 index 0000000000..9c20973d08 --- /dev/null +++ b/TablePro/Theme/ThemeLoadError.swift @@ -0,0 +1,55 @@ +import Foundation + +internal enum ThemeLoadError: LocalizedError, Equatable { + case missingSchema + case schemaTooOld(found: Int, supported: Int) + case schemaTooNew(found: Int, supported: Int) + case missingField(String) + case missingSlots([String]) + case invalidColor(String) + case unknownSystemColor(String) + case unknownKeys([String]) + case reservedIdentifier(String) + case invalidIdentifier(String) + + internal var errorDescription: String? { + switch self { + case .missingSchema: + return String(localized: "The file does not declare a theme format version.") + case let .schemaTooOld(found, supported): + return String( + format: String(localized: "Theme format %1$d is no longer supported. This version of TablePro reads format %2$d."), + found, + supported + ) + case let .schemaTooNew(found, supported): + return String( + format: String(localized: "Theme format %1$d was made for a newer TablePro. This version reads format %2$d."), + found, + supported + ) + case let .missingField(name): + return String(format: String(localized: "The theme is missing the required field \"%@\"."), name) + case let .missingSlots(names): + return String( + format: String(localized: "The theme is missing %1$d required colors, starting with \"%2$@\"."), + names.count, + names.first ?? "" + ) + case let .invalidColor(value): + return String(format: String(localized: "\"%@\" is not a valid color."), value) + case let .unknownSystemColor(name): + return String(format: String(localized: "\"%@\" is not a system color TablePro knows."), name) + case let .unknownKeys(names): + return String( + format: String(localized: "The theme declares %1$d colors TablePro does not use, starting with \"%2$@\"."), + names.count, + names.first ?? "" + ) + case let .reservedIdentifier(identifier): + return String(format: String(localized: "\"%@\" is a reserved theme identifier."), identifier) + case let .invalidIdentifier(identifier): + return String(format: String(localized: "\"%@\" is not a valid theme identifier."), identifier) + } + } +} diff --git a/TablePro/Theme/ThemePalette.swift b/TablePro/Theme/ThemePalette.swift new file mode 100644 index 0000000000..18d1a88c31 --- /dev/null +++ b/TablePro/Theme/ThemePalette.swift @@ -0,0 +1,134 @@ +import AppKit +import os +import SwiftUI + +internal struct ThemePair: Equatable, Sendable { + var light: ThemeDefinition + var dark: ThemeDefinition + + internal subscript(appearance: ThemeAppearance) -> ThemeDefinition { + switch appearance { + case .light: return light + case .dark: return dark + } + } + + internal static let builtIn = ThemePair(light: BuiltInThemes.light, dark: BuiltInThemes.dark) +} + +/// The live pair every dynamic slot colour reads through. A colour object an AppKit view is +/// already holding therefore answers for the theme in effect now, the way `NSColor.labelColor` +/// does, so marking that view for display is enough. Capturing the pair inside the provider +/// instead would freeze each colour at the revision that built it. +internal final class ThemeSource: Sendable { + internal static let shared = ThemeSource() + + private let state = OSAllocatedUnfairLock(initialState: ThemePair.builtIn) + + internal var pair: ThemePair { + state.withLock { $0 } + } + + internal func update(_ pair: ThemePair) { + state.withLock { $0 = pair } + } +} + +/// The dynamic tier. One `NSColor(name:dynamicProvider:)` per slot, so a slot resolves against +/// whatever appearance the view is drawing in, including the vibrant appearances inside a sidebar. +/// The revision only names the colour: SwiftUI caches `Color(nsColor:)` by object identity, so a +/// stable object would never repaint on a theme swap even though the provider answers correctly. +internal struct ThemePalette { + internal let revision: Int + + private let colors: [ThemeSlot: NSColor] + + internal init(revision: Int, source: ThemeSource = .shared) { + self.revision = revision + + var built: [ThemeSlot: NSColor] = [:] + built.reserveCapacity(ThemeSlot.allCases.count) + + for slot in ThemeSlot.allCases { + let name = NSColor.Name("tablepro.\(slot.rawValue).\(revision)") + built[slot] = NSColor(name: name) { appearance in + let definition = source.pair[ThemeAppearance(matching: appearance)] + return definition[keyPath: slot.keyPath].nsColor + } + } + + colors = built + } + + internal subscript(slot: ThemeSlot) -> NSColor { + colors[slot] ?? .labelColor + } + + internal func color(_ slot: ThemeSlot) -> Color { + Color(nsColor: self[slot]) + } +} + +/// The static tier. Snapshot consumers (the CodeEdit `EditorTheme`, `CALayer` colours, the data +/// grid's per-cell draw path) resolve once against the appearance the app has actually settled on. +/// They must not resolve a dynamic colour implicitly: outside a draw pass +/// `NSAppearance.currentDrawing()` still reports the old appearance for at least half a second +/// after `NSApp.appearance` changes, and a dynamic `cgColor` costs 254ns against 11ns for a +/// static one, which the grid pays per cell. +internal struct ResolvedTheme: Equatable { + internal let definition: ThemeDefinition + internal let appearance: ThemeAppearance + + private let colors: [ThemeSlot: NSColor] + + internal init(definition: ThemeDefinition, appearance: ThemeAppearance) { + self.definition = definition + self.appearance = appearance + + let nsAppearance = NSAppearance(named: appearance == .dark ? .darkAqua : .aqua) + var built: [ThemeSlot: NSColor] = [:] + built.reserveCapacity(ThemeSlot.allCases.count) + + let resolve = { + for slot in ThemeSlot.allCases { + let value = definition[keyPath: slot.keyPath] + built[slot] = value.nsColor.usingColorSpace(.sRGB) ?? value.nsColor + } + } + + if let nsAppearance { + nsAppearance.performAsCurrentDrawingAppearance(resolve) + } else { + resolve() + } + + colors = built + } + + internal subscript(slot: ThemeSlot) -> NSColor { + colors[slot] ?? .labelColor + } + + internal func color(_ slot: ThemeSlot) -> Color { + Color(nsColor: self[slot]) + } + + internal func cgColor(_ slot: ThemeSlot) -> CGColor { + self[slot].cgColor + } +} + +internal extension ThemeAppearance { + init(matching appearance: NSAppearance) { + let match = appearance.bestMatch(from: [.aqua, .darkAqua]) + self = match == .darkAqua ? .dark : .light + } +} + +/// Sent on a theme change and on an effective-appearance change, because the snapshot consumers +/// need both: a forced Light/Dark switch never changes the theme pair, and a theme switch never +/// changes the appearance. +internal struct ThemeChange: Equatable, Sendable { + internal let revision: Int + internal let appearance: ThemeAppearance +} diff --git a/TablePro/Theme/ThemeRegistryInstaller.swift b/TablePro/Theme/ThemeRegistryInstaller.swift index 52f95b6c26..37be2149ae 100644 --- a/TablePro/Theme/ThemeRegistryInstaller.swift +++ b/TablePro/Theme/ThemeRegistryInstaller.swift @@ -31,10 +31,12 @@ internal final class ThemeRegistryInstaller: ObservableObject { let decodedThemes = try await downloadAndDecode(plugin, progress: progress) + _ = try? removeRegistryFiles(for: plugin.id) + var installedThemes: [InstalledRegistryTheme] = [] for theme in decodedThemes { - try ThemeStorage.saveRegistryTheme(theme) + try ThemeCatalog.shared.saveRegistryTheme(theme) installedThemes.append(InstalledRegistryTheme( id: theme.id, @@ -44,11 +46,12 @@ internal final class ThemeRegistryInstaller: ObservableObject { )) } - var meta = ThemeStorage.loadRegistryMeta() + var meta = ThemeCatalog.shared.loadRegistryMeta() meta.installed.append(contentsOf: installedThemes) - try ThemeStorage.saveRegistryMeta(meta) + try ThemeCatalog.shared.saveRegistryMeta(meta) - ThemeEngine.shared.reloadAvailableThemes() + ThemeCatalog.shared.reloadSynchronously() + reactivate() progress(1.0) Self.logger.info("Installed \(installedThemes.count) theme(s) from registry plugin: \(plugin.id)") @@ -57,26 +60,10 @@ internal final class ThemeRegistryInstaller: ObservableObject { // MARK: - Uninstall func uninstall(registryPluginId: String) throws { - let removedThemeIds = try removeRegistryFiles(for: registryPluginId) - - ThemeEngine.shared.reloadAvailableThemes() + _ = try removeRegistryFiles(for: registryPluginId) - // Reset preferred theme slots if the uninstalled theme was preferred - var appearance = AppSettingsManager.shared.appearance - var changed = false - for id in removedThemeIds { - if id == appearance.preferredLightThemeId { - appearance.preferredLightThemeId = "tablepro.default-light" - changed = true - } - if id == appearance.preferredDarkThemeId { - appearance.preferredDarkThemeId = "tablepro.default-dark" - changed = true - } - } - if changed { - AppSettingsManager.shared.appearance = appearance - } + ThemeCatalog.shared.reloadSynchronously() + reactivate() Self.logger.info("Uninstalled registry themes for plugin: \(registryPluginId)") } @@ -87,9 +74,6 @@ internal final class ThemeRegistryInstaller: ObservableObject { _ plugin: RegistryPlugin, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws { - let activeId = ThemeEngine.shared.activeTheme.id - - // Download, verify, and decode new themes first (no side effects yet) let stagedThemes = try await downloadAndDecode(plugin, progress: progress) // Remove old files without triggering theme reload or fallback @@ -97,7 +81,7 @@ internal final class ThemeRegistryInstaller: ObservableObject { var installedThemes: [InstalledRegistryTheme] = [] for theme in stagedThemes { - try ThemeStorage.saveRegistryTheme(theme) + try ThemeCatalog.shared.saveRegistryTheme(theme) installedThemes.append(InstalledRegistryTheme( id: theme.id, registryPluginId: plugin.id, @@ -106,20 +90,12 @@ internal final class ThemeRegistryInstaller: ObservableObject { )) } - var meta = ThemeStorage.loadRegistryMeta() + var meta = ThemeCatalog.shared.loadRegistryMeta() meta.installed.append(contentsOf: installedThemes) - try ThemeStorage.saveRegistryMeta(meta) + try ThemeCatalog.shared.saveRegistryMeta(meta) - // Single reload after swap is complete — no intermediate flicker - ThemeEngine.shared.reloadAvailableThemes() - - // Re-activate the correct theme for the current appearance - let appearance = AppSettingsManager.shared.appearance - ThemeEngine.shared.updateAppearanceAndTheme( - mode: appearance.appearanceMode, - lightThemeId: appearance.preferredLightThemeId, - darkThemeId: appearance.preferredDarkThemeId - ) + ThemeCatalog.shared.reloadSynchronously() + reactivate() Self.logger.info("Updated \(installedThemes.count) theme(s) for registry plugin: \(plugin.id)") } @@ -128,16 +104,16 @@ internal final class ThemeRegistryInstaller: ObservableObject { /// Does NOT reload ThemeEngine or trigger fallback — callers manage that. @discardableResult private func removeRegistryFiles(for registryPluginId: String) throws -> Set { - var meta = ThemeStorage.loadRegistryMeta() + var meta = ThemeCatalog.shared.loadRegistryMeta() let themesToRemove = meta.installed.filter { $0.registryPluginId == registryPluginId } let removedIds = Set(themesToRemove.map(\.id)) meta.installed.removeAll { $0.registryPluginId == registryPluginId } - try ThemeStorage.saveRegistryMeta(meta) + try ThemeCatalog.shared.saveRegistryMeta(meta) for entry in themesToRemove { do { - try ThemeStorage.deleteRegistryTheme(id: entry.id) + try ThemeCatalog.shared.deleteRegistryTheme(id: entry.id) } catch { Self.logger.warning("Failed to delete registry theme file \(entry.id): \(error)") } @@ -149,17 +125,20 @@ internal final class ThemeRegistryInstaller: ObservableObject { // MARK: - Query func isInstalled(_ registryPluginId: String) -> Bool { - let meta = ThemeStorage.loadRegistryMeta() - return meta.installed.contains { $0.registryPluginId == registryPluginId } + let entries = ThemeCatalog.shared.loadRegistryMeta().installed + .filter { $0.registryPluginId == registryPluginId } + + guard !entries.isEmpty else { return false } + return entries.allSatisfy { ThemeCatalog.shared.theme(id: $0.id) != nil } } func installedVersion(for registryPluginId: String) -> String? { - let meta = ThemeStorage.loadRegistryMeta() + let meta = ThemeCatalog.shared.loadRegistryMeta() return meta.installed.first { $0.registryPluginId == registryPluginId }?.version } func availableUpdates(manifest: RegistryManifest) -> [RegistryPlugin] { - let meta = ThemeStorage.loadRegistryMeta() + let meta = ThemeCatalog.shared.loadRegistryMeta() let installedVersions = Dictionary( meta.installed.map { ($0.registryPluginId, $0.version) }, uniquingKeysWith: { first, _ in first } @@ -250,14 +229,12 @@ internal final class ThemeRegistryInstaller: ObservableObject { progress(0.9) - let decoder = JSONDecoder() var decodedThemes: [ThemeDefinition] = [] for jsonURL in jsonFiles { - let data = try Data(contentsOf: jsonURL) - var theme = try decoder.decode(ThemeDefinition.self, from: data) - let originalId = theme.id - theme.id = "registry.\(plugin.id).\(originalId)" + let document = try ThemeDocument(data: try Data(contentsOf: jsonURL)) + var theme = document.resolved() + theme.id = "\(ThemeDefinition.registryPrefix)\(plugin.id).\(theme.id)" decodedThemes.append(theme) } @@ -271,6 +248,14 @@ internal final class ThemeRegistryInstaller: ObservableObject { // MARK: - Helpers + private func reactivate() { + let appearance = AppSettingsManager.shared.appearance + ThemeEngine.shared.reapply( + lightThemeId: appearance.preferredLightThemeId, + darkThemeId: appearance.preferredDarkThemeId + ) + } + private func findJsonFiles(in directory: URL) throws -> [URL] { var results: [URL] = [] let fm = FileManager.default diff --git a/TablePro/Theme/ThemeResolver.swift b/TablePro/Theme/ThemeResolver.swift new file mode 100644 index 0000000000..084548c3ad --- /dev/null +++ b/TablePro/Theme/ThemeResolver.swift @@ -0,0 +1,68 @@ +import Foundation + +internal struct ThemeSelection: Equatable, Sendable { + internal let pair: ThemePair + internal let effectiveAppearance: ThemeAppearance + + internal var active: ThemeDefinition { pair[effectiveAppearance] } +} + +/// Pure. It never writes settings: a settings pane that rewrote the saved theme id to make its own +/// filter true silently replaced a theme the user had chosen and then hid it from the list they +/// would have used to put it back. A slot whose theme is missing, rejected, or of the wrong +/// appearance falls back to that slot's own built-in, so a broken dark theme never paints Default +/// Light inside dark chrome. +internal enum ThemeResolver { + internal static func resolve( + mode: AppAppearanceMode, + lightThemeId: String, + darkThemeId: String, + themes: [ThemeDefinition], + systemIsDark: Bool + ) -> ThemeSelection { + let pair = ThemePair( + light: theme(id: lightThemeId, slot: .light, in: themes), + dark: theme(id: darkThemeId, slot: .dark, in: themes) + ) + + return ThemeSelection( + pair: pair, + effectiveAppearance: effectiveAppearance(mode: mode, systemIsDark: systemIsDark) + ) + } + + internal static func effectiveAppearance(mode: AppAppearanceMode, systemIsDark: Bool) -> ThemeAppearance { + switch mode { + case .light: return .light + case .dark: return .dark + case .auto: return systemIsDark ? .dark : .light + } + } + + private static func theme( + id: String, + slot: ThemeAppearance, + in themes: [ThemeDefinition] + ) -> ThemeDefinition { + guard let found = themes.first(where: { $0.id == id }), found.appearance == slot else { + return BuiltInThemes.default(for: slot) + } + return found + } +} + +/// The list a slot offers. A theme only fits the slot whose appearance it declares, and the theme +/// the slot already holds stays listed so the user can always see and re-pick their own choice. +internal enum ThemeSlotValidation { + internal static func fits(_ appearance: ThemeAppearance, slot: ThemeAppearance) -> Bool { + appearance == slot + } + + internal static func eligibleThemes( + _ themes: [ThemeDefinition], + slot: ThemeAppearance, + keeping selectedId: String? + ) -> [ThemeDefinition] { + themes.filter { fits($0.appearance, slot: slot) || $0.id == selectedId } + } +} diff --git a/TablePro/Theme/ThemeSlotValidation.swift b/TablePro/Theme/ThemeSlotValidation.swift deleted file mode 100644 index 61343bd965..0000000000 --- a/TablePro/Theme/ThemeSlotValidation.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ThemeSlotValidation.swift -// TablePro -// - -import Foundation - -/// `ThemeDefinition.appearance` was declared but never read, so a dark theme could be assigned to -/// the light slot and the app would honour it. The list is filtered to the themes that suit the -/// slot, and the theme the slot already holds is always kept in it. -/// -/// Rewriting the saved id to make the filter true is not an option: a settings pane is a -/// presentation surface, and the rewrite fired on appear, so opening Appearance silently replaced -/// a theme the user had chosen and then hid it from the list they would have used to put it back. -internal enum ThemeSlotValidation { - internal static func fits(_ appearance: ThemeAppearance, slot: ThemeAppearance) -> Bool { - appearance == .auto || appearance == slot - } - - internal static func eligibleThemes( - _ themes: [ThemeDefinition], - slot: ThemeAppearance, - keeping selectedId: String? - ) -> [ThemeDefinition] { - themes.filter { fits($0.appearance, slot: slot) || $0.id == selectedId } - } -} diff --git a/TablePro/Theme/ThemeStorage.swift b/TablePro/Theme/ThemeStorage.swift deleted file mode 100644 index c0cbf9f154..0000000000 --- a/TablePro/Theme/ThemeStorage.swift +++ /dev/null @@ -1,258 +0,0 @@ -// -// ThemeStorage.swift -// TablePro -// -// File I/O for theme JSON files. -// Built-in themes loaded from app bundle, user themes from Application Support. -// - -import Foundation -import os - -internal struct ThemeStorage { - private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeStorage") - - private static let userThemesDirectory: URL = - AppStorageEnvironment.shared.applicationSupportRoot.appendingPathComponent("TablePro/Themes", isDirectory: true) - - private static let bundledThemesDirectory: URL? = { - Bundle.main.resourceURL - }() - - private static let registryThemesDirectory: URL = { - userThemesDirectory.appendingPathComponent("Registry", isDirectory: true) - }() - - private static func themeFileURL(in directory: URL, id: String) throws -> URL { - let allowed = #"^[A-Za-z0-9._-]+$"# - guard id.range(of: allowed, options: .regularExpression) != nil else { - throw CocoaError(.fileWriteInvalidFileName) - } - return directory.appendingPathComponent("\(id).json", isDirectory: false) - } - - // MARK: - Load All Themes - - static func loadAllThemes() -> [ThemeDefinition] { - var themes: [ThemeDefinition] = [] - - // Load built-in themes from app bundle (files copied flat to Resources/) - if let bundleDir = bundledThemesDirectory { - themes.append(contentsOf: loadBuiltInThemes(from: bundleDir)) - } - - if themes.isEmpty { - themes = [ThemeDefinition.default] - } - - ensureRegistryDirectory() - themes.append(contentsOf: loadThemes(from: registryThemesDirectory, isBuiltIn: false)) - - ensureUserDirectory() - themes.append(contentsOf: loadThemes(from: userThemesDirectory, isBuiltIn: false)) - - return themes - } - - // MARK: - Load Single Theme - - static func loadTheme(id: String) -> ThemeDefinition? { - let fm = FileManager.default - - if let userFile = try? themeFileURL(in: userThemesDirectory, id: id), - fm.fileExists(atPath: userFile.path), - let theme = loadTheme(from: userFile) { - return theme - } - - if let registryFile = try? themeFileURL(in: registryThemesDirectory, id: id), - fm.fileExists(atPath: registryFile.path), - let theme = loadTheme(from: registryFile) { - return theme - } - - // User themes are never bundled; skip the bundle search for them. - if !id.hasPrefix("user."), - let bundleDir = bundledThemesDirectory, - let bundleFile = try? themeFileURL(in: bundleDir, id: id), - fm.fileExists(atPath: bundleFile.path), - let theme = loadTheme(from: bundleFile) { - return theme - } - - return id == ThemeDefinition.default.id ? .default : nil - } - - // MARK: - Save User Theme - - static func saveUserTheme(_ theme: ThemeDefinition) throws { - ensureUserDirectory() - let url = try themeFileURL(in: userThemesDirectory, id: theme.id) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(theme) - try data.write(to: url, options: .atomic) - logger.info("Saved user theme: \(theme.id)") - } - - // MARK: - Delete User Theme - - static func deleteUserTheme(id: String) throws { - let url = try themeFileURL(in: userThemesDirectory, id: id) - guard FileManager.default.fileExists(atPath: url.path) else { return } - try FileManager.default.removeItem(at: url) - logger.info("Deleted user theme: \(id)") - } - - // MARK: - Save Registry Theme - - static func saveRegistryTheme(_ theme: ThemeDefinition) throws { - ensureRegistryDirectory() - let url = try themeFileURL(in: registryThemesDirectory, id: theme.id) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(theme) - try data.write(to: url, options: .atomic) - logger.info("Saved registry theme: \(theme.id)") - } - - // MARK: - Delete Registry Theme - - static func deleteRegistryTheme(id: String) throws { - let url = try themeFileURL(in: registryThemesDirectory, id: id) - guard FileManager.default.fileExists(atPath: url.path) else { return } - try FileManager.default.removeItem(at: url) - logger.info("Deleted registry theme: \(id)") - } - - // MARK: - Registry Meta - - private static let registryMetaURL: URL = { - registryThemesDirectory.appendingPathComponent("registry-meta.json") - }() - - static func loadRegistryMeta() -> RegistryThemeMeta { - guard FileManager.default.fileExists(atPath: registryMetaURL.path) else { - return RegistryThemeMeta() - } - do { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let data = try Data(contentsOf: registryMetaURL) - return try decoder.decode(RegistryThemeMeta.self, from: data) - } catch { - logger.error("Failed to load registry meta: \(error)") - return RegistryThemeMeta() - } - } - - static func saveRegistryMeta(_ meta: RegistryThemeMeta) throws { - ensureRegistryDirectory() - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(meta) - try data.write(to: registryMetaURL, options: .atomic) - } - - // MARK: - Import / Export - - static func importTheme(from sourceURL: URL) throws -> ThemeDefinition { - let data = try Data(contentsOf: sourceURL) - var theme = try JSONDecoder().decode(ThemeDefinition.self, from: data) - - // Avoid clobbering an existing theme on import - if theme.isBuiltIn || theme.isRegistry || loadTheme(id: theme.id) != nil { - theme.id = "user.\(UUID().uuidString.lowercased().prefix(8))" - } - - try saveUserTheme(theme) - return theme - } - - static func exportTheme(_ theme: ThemeDefinition, to destinationURL: URL) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(theme) - try data.write(to: destinationURL, options: .atomic) - logger.info("Exported theme: \(theme.id) to \(destinationURL.lastPathComponent)") - } - - // MARK: - Helpers - - private static func ensureUserDirectory() { - let fm = FileManager.default - if !fm.fileExists(atPath: userThemesDirectory.path) { - do { - try fm.createDirectory(at: userThemesDirectory, withIntermediateDirectories: true) - } catch { - logger.error("Failed to create user themes directory: \(error)") - } - } - } - - private static func ensureRegistryDirectory() { - let fm = FileManager.default - if !fm.fileExists(atPath: registryThemesDirectory.path) { - do { - try fm.createDirectory(at: registryThemesDirectory, withIntermediateDirectories: true) - } catch { - logger.error("Failed to create registry themes directory: \(error)") - } - } - } - - private static let builtInThemeOrder = [ - "tablepro.default-light", - "tablepro.default-dark", - "tablepro.dracula", - "tablepro.nord", - ] - - private static func loadBuiltInThemes(from directory: URL) -> [ThemeDefinition] { - let fm = FileManager.default - guard fm.fileExists(atPath: directory.path) else { return [] } - - do { - let files = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" && $0.lastPathComponent.hasPrefix("tablepro.") } - - let themes = files.compactMap { loadTheme(from: $0) } - return themes.sorted { lhs, rhs in - let li = builtInThemeOrder.firstIndex(of: lhs.id) ?? Int.max - let ri = builtInThemeOrder.firstIndex(of: rhs.id) ?? Int.max - return li < ri - } - } catch { - logger.error("Failed to list built-in themes: \(error)") - return [] - } - } - - private static func loadThemes(from directory: URL, isBuiltIn: Bool) -> [ThemeDefinition] { - let fm = FileManager.default - guard fm.fileExists(atPath: directory.path) else { return [] } - - do { - let files = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" && $0.lastPathComponent != "registry-meta.json" } - - return files.compactMap { loadTheme(from: $0) } - } catch { - logger.error("Failed to list themes in \(directory.lastPathComponent): \(error)") - return [] - } - } - - private static func loadTheme(from url: URL) -> ThemeDefinition? { - do { - let data = try Data(contentsOf: url) - return try JSONDecoder().decode(ThemeDefinition.self, from: data) - } catch CocoaError.fileNoSuchFile, CocoaError.fileReadNoSuchFile { - return nil - } catch { - logger.error("Failed to load theme from \(url.lastPathComponent): \(error)") - return nil - } - } -} diff --git a/TablePro/Theme/ThemedContentSurface.swift b/TablePro/Theme/ThemedContentSurface.swift new file mode 100644 index 0000000000..ff8711d799 --- /dev/null +++ b/TablePro/Theme/ThemedContentSurface.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// Re-resolves `.secondary` and `.tertiary` inside a content pane to the theme's own text levels, +/// which is how 184 hierarchical foreground styles across the content surfaces follow a theme +/// without each one being rewritten. +/// +/// It never branches on the theme. `WorkspacePanes` depends on every pane builder erasing one +/// stable view identity, so an `if` here would tear down the grid, the editor's undo stack and the +/// scroll position on a theme change. It reads the engine inside `body`, so the palette it applies +/// is the current one rather than whatever was captured when the pane was built. +private struct ThemedContentSurface: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + func body(content: Content) -> some View { + let palette = themeEngine.palette + + return content.foregroundStyle( + palette.color(.panelText), + palette.color(.panelSecondaryText), + palette.color(.panelTertiaryText) + ) + } +} + +internal extension View { + func themedContent() -> some View { + modifier(ThemedContentSurface()) + } +} diff --git a/TablePro/Views/Compare/CompareStatusStyle.swift b/TablePro/Views/Compare/CompareStatusStyle.swift index cb5c1b1248..8fdca4718a 100644 --- a/TablePro/Views/Compare/CompareStatusStyle.swift +++ b/TablePro/Views/Compare/CompareStatusStyle.swift @@ -45,16 +45,16 @@ internal enum CompareStatusStyle { @MainActor internal static func tint(for status: TableDiffStatus) -> Color { - let colors = ThemeEngine.shared.colors.ui + let palette = ThemeEngine.shared.palette switch status { case .onlyInSource: - return colors.successSwiftUI + return palette.color(.statusSuccess) case .onlyInTarget: - return colors.errorSwiftUI + return palette.color(.statusError) case .differs: - return colors.warningSwiftUI + return palette.color(.statusWarning) case .identical: - return colors.secondaryTextSwiftUI + return Color(nsColor: .secondaryLabelColor) } } @@ -92,18 +92,18 @@ internal enum CompareStatusStyle { @MainActor internal static func tint(for kind: RowDiffKind) -> Color { - let colors = ThemeEngine.shared.colors.ui + let palette = ThemeEngine.shared.palette switch kind { case .insert: - return colors.successSwiftUI + return palette.color(.statusSuccess) case .update: - return colors.warningSwiftUI + return palette.color(.statusWarning) case .delete: - return colors.errorSwiftUI + return palette.color(.statusError) case .identical: - return colors.secondaryTextSwiftUI + return Color(nsColor: .secondaryLabelColor) case .conflict: - return colors.warningSwiftUI + return palette.color(.statusWarning) } } @@ -111,14 +111,14 @@ internal enum CompareStatusStyle { /// difference here reads the way the same row reads in the grid. @MainActor internal static func rowTint(for kind: RowDiffKind) -> Color { - let colors = ThemeEngine.shared.colors.dataGrid + let palette = ThemeEngine.shared.palette switch kind { case .insert: - return colors.insertedSwiftUI + return palette.color(.gridInserted) case .update: - return colors.modifiedSwiftUI + return palette.color(.gridModified) case .delete: - return colors.deletedSwiftUI + return palette.color(.gridDeleted) case .identical, .conflict: return .clear } @@ -128,16 +128,16 @@ internal enum CompareStatusStyle { @MainActor internal static var warning: Color { - ThemeEngine.shared.colors.ui.warningSwiftUI + ThemeEngine.shared.palette.color(.statusWarning) } @MainActor internal static var error: Color { - ThemeEngine.shared.colors.ui.errorSwiftUI + ThemeEngine.shared.palette.color(.statusError) } @MainActor internal static var success: Color { - ThemeEngine.shared.colors.ui.successSwiftUI + ThemeEngine.shared.palette.color(.statusSuccess) } } diff --git a/TablePro/Views/Components/HighlightedSQLTextView.swift b/TablePro/Views/Components/HighlightedSQLTextView.swift index db27ed5e38..cee3306ddf 100644 --- a/TablePro/Views/Components/HighlightedSQLTextView.swift +++ b/TablePro/Views/Components/HighlightedSQLTextView.swift @@ -13,12 +13,13 @@ import TableProPluginKit /// Read-only text view that applies SQL/MQL syntax highlighting via regex struct HighlightedSQLTextView: NSViewRepresentable { let sql: String - var fontSize: CGFloat = 13 var databaseType: DatabaseType = .mysql /// A SwiftUI `.accessibilityIdentifier` lands on the representable's wrapper, not on the text /// view AppKit publishes, so the only way to name this element is to set it on the text view. var accessibilityIdentifier: String? + @ObservedObject private var themeEngine = ThemeEngine.shared + func makeNSView(context: Context) -> NSScrollView { let scrollView = NSTextView.scrollableTextView() @@ -30,10 +31,10 @@ struct HighlightedSQLTextView: NSViewRepresentable { textView.setAccessibilityLabel(String(localized: "Query preview")) textView.isEditable = false textView.isSelectable = true - textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + textView.font = themeEngine.editorFonts.font textView.textContainerInset = NSSize(width: 12, height: 12) - textView.backgroundColor = NSColor.textBackgroundColor - textView.textColor = NSColor.labelColor + textView.backgroundColor = themeEngine.palette[.editorBackground] + textView.textColor = themeEngine.palette[.editorText] // Disable line wrapping textView.textContainer?.widthTracksTextView = false @@ -49,27 +50,47 @@ struct HighlightedSQLTextView: NSViewRepresentable { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? NSTextView else { return } - if let currentFont = textView.font, currentFont.pointSize != fontSize { - textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) - if !textView.string.isEmpty { - applyHighlighting(to: textView) - } + let editorFont = themeEngine.editorFonts.font + let fontChanged = textView.font != editorFont + let textChanged = textView.string != sql + + if fontChanged { + textView.font = editorFont } + textView.backgroundColor = themeEngine.palette[.editorBackground] + textView.textColor = themeEngine.palette[.editorText] - if textView.string != sql { + if textChanged { textView.string = sql - if !sql.isEmpty { - applyHighlighting(to: textView) - } } + + /// The highlighting bakes a colour per range, so it is applied again whenever the theme or + /// the editor font moves, not only when the query does. + guard !sql.isEmpty, textChanged || fontChanged || context.coordinator.revision != themeRevision else { + return + } + context.coordinator.revision = themeRevision + applyHighlighting(to: textView) + } + + private var themeRevision: Int { + themeEngine.revision + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator { + var revision = -1 } // MARK: - Syntax Highlighting // MARK: - Pre-compiled Syntax Patterns - private static let syntaxPatterns: [(regex: NSRegularExpression, color: NSColor)] = { - var patterns: [(NSRegularExpression, NSColor)] = [] + private static let syntaxPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { + var patterns: [(NSRegularExpression, ThemeSlot)] = [] // SQL Keywords (blue) — single alternation regex for all keywords let keywords = [ @@ -86,22 +107,22 @@ struct HighlightedSQLTextView: NSViewRepresentable { ] let keywordPattern = "\\b(" + keywords.joined(separator: "|") + ")\\b" if let regex = try? NSRegularExpression(pattern: keywordPattern, options: .caseInsensitive) { - patterns.append((regex, .systemBlue)) + patterns.append((regex, .syntaxKeyword)) } // Strings (red) if let regex = try? NSRegularExpression(pattern: "'[^']*'", options: .caseInsensitive) { - patterns.append((regex, .systemRed)) + patterns.append((regex, .syntaxString)) } // Backticks (orange) if let regex = try? NSRegularExpression(pattern: "`[^`]*`", options: .caseInsensitive) { - patterns.append((regex, .systemOrange)) + patterns.append((regex, .syntaxNull)) } // Numbers (purple) if let regex = try? NSRegularExpression(pattern: "\\b\\d+\\b", options: .caseInsensitive) { - patterns.append((regex, .systemPurple)) + patterns.append((regex, .syntaxNumber)) } return patterns @@ -109,8 +130,8 @@ struct HighlightedSQLTextView: NSViewRepresentable { // MARK: - Pre-compiled MQL Syntax Patterns - private static let mqlPatterns: [(regex: NSRegularExpression, color: NSColor)] = { - var patterns: [(NSRegularExpression, NSColor)] = [] + private static let mqlPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { + var patterns: [(NSRegularExpression, ThemeSlot)] = [] // MongoDB methods (blue) — single alternation regex for all methods let methods = [ @@ -121,12 +142,12 @@ struct HighlightedSQLTextView: NSViewRepresentable { ] let methodPattern = "\\.(" + methods.joined(separator: "|") + ")\\s*\\(" if let regex = try? NSRegularExpression(pattern: methodPattern, options: []) { - patterns.append((regex, .systemBlue)) + patterns.append((regex, .syntaxKeyword)) } // db. prefix (blue) if let regex = try? NSRegularExpression(pattern: "\\bdb\\.", options: []) { - patterns.append((regex, .systemBlue)) + patterns.append((regex, .syntaxKeyword)) } // MongoDB operators $gt, $lt, $in, etc. (teal) @@ -137,22 +158,22 @@ struct HighlightedSQLTextView: NSViewRepresentable { + "oid|numberInt|numberLong|numberDouble|date|binary|timestamp|numberDecimal)\"", options: [] ) { - patterns.append((regex, .systemTeal)) + patterns.append((regex, .syntaxType)) } // Strings (red) if let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"", options: []) { - patterns.append((regex, .systemRed)) + patterns.append((regex, .syntaxString)) } // Numbers (purple) if let regex = try? NSRegularExpression(pattern: "\\b\\d+\\.?\\d*\\b", options: []) { - patterns.append((regex, .systemPurple)) + patterns.append((regex, .syntaxNumber)) } // Booleans and null (orange) if let regex = try? NSRegularExpression(pattern: "\\b(true|false|null)\\b", options: []) { - patterns.append((regex, .systemOrange)) + patterns.append((regex, .syntaxNull)) } return patterns @@ -167,12 +188,12 @@ struct HighlightedSQLTextView: NSViewRepresentable { textStorage.beginEditing() // Reset to base style - let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + let font = ThemeEngine.shared.editorFonts.font textStorage.addAttribute(.font, value: font, range: fullRange) - textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) + textStorage.addAttribute(.foregroundColor, value: ThemeEngine.shared.palette[.editorText], range: fullRange) // Apply pre-compiled patterns - let activePatterns: [(regex: NSRegularExpression, color: NSColor)] + let activePatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] switch PluginManager.shared.editorLanguage(for: databaseType) { case .javascript: activePatterns = Self.mqlPatterns @@ -187,10 +208,12 @@ struct HighlightedSQLTextView: NSViewRepresentable { } else { highlightRange = fullRange } - for (regex, color) in activePatterns { + let palette = ThemeEngine.shared.palette + + for (regex, slot) in activePatterns { let matches = regex.matches(in: text, options: [], range: highlightRange) for match in matches { - textStorage.addAttribute(.foregroundColor, value: color, range: match.range) + textStorage.addAttribute(.foregroundColor, value: palette[slot], range: match.range) } } diff --git a/TablePro/Views/Editor/Folding/FoldPreviewView.swift b/TablePro/Views/Editor/Folding/FoldPreviewView.swift index e594b6beee..2b1fb4d769 100644 --- a/TablePro/Views/Editor/Folding/FoldPreviewView.swift +++ b/TablePro/Views/Editor/Folding/FoldPreviewView.swift @@ -52,7 +52,7 @@ struct FoldPreviewView: View { .padding(.vertical, 5) } } - .background(themeEngine.colors.editor.backgroundSwiftUI) + .background(themeEngine.palette.color(.editorBackground)) .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) diff --git a/TablePro/Views/Editor/History/HistoryDetailPane.swift b/TablePro/Views/Editor/History/HistoryDetailPane.swift index 99056c8def..39adaff684 100644 --- a/TablePro/Views/Editor/History/HistoryDetailPane.swift +++ b/TablePro/Views/Editor/History/HistoryDetailPane.swift @@ -65,7 +65,7 @@ struct HistoryDetailPane: View { databaseType: entry.databaseType, accessibilityIdentifier: "query-history-detail-query" ) - .background(Color(nsColor: themeEngine.colors.editor.background)) + .background(themeEngine.palette.color(.editorBackground)) .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() @@ -94,7 +94,7 @@ struct HistoryDetailPane: View { if let errorMessage = entry.errorMessage { RevealedTextView(errorMessage) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } diff --git a/TablePro/Views/Editor/History/HistoryRowView.swift b/TablePro/Views/Editor/History/HistoryRowView.swift index a8cbd04a1a..ff34d2fed7 100644 --- a/TablePro/Views/Editor/History/HistoryRowView.swift +++ b/TablePro/Views/Editor/History/HistoryRowView.swift @@ -1,6 +1,7 @@ import SwiftUI struct HistoryRowView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let entry: QueryHistoryEntry let connectionLabel: HistoryConnectionLabel? @@ -72,7 +73,7 @@ struct HistoryRowView: View { .foregroundStyle(Color.secondary) } else { Image(systemName: "exclamationmark.circle.fill") - .selectionAwareTint(.red) + .selectionAwareTint(themeEngine.palette.color(.statusError)) } } diff --git a/TablePro/Views/Editor/QueryCompletionAdapter.swift b/TablePro/Views/Editor/QueryCompletionAdapter.swift index 91931fcf3e..aaabab0b7e 100644 --- a/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -218,7 +218,10 @@ final class SQLSuggestionEntry: CodeSuggestionEntry { Image(systemName: item.kind.iconName) } + /// The suggestion protocol is nonisolated and the panel only ever reads this on the main + /// thread. The kind is lifted out first so the closure sends a value rather than `self`. var imageColor: Color { - Color(nsColor: item.kind.iconColor) + let kind = item.kind + return MainActor.assumeIsolated { Color(nsColor: kind.iconColor) } } } diff --git a/TablePro/Views/Editor/QueryDiagnosticsController.swift b/TablePro/Views/Editor/QueryDiagnosticsController.swift index b229b096dc..6f16e8a6c1 100644 --- a/TablePro/Views/Editor/QueryDiagnosticsController.swift +++ b/TablePro/Views/Editor/QueryDiagnosticsController.swift @@ -75,6 +75,14 @@ final class QueryDiagnosticsController { apply(produced, in: controller) } + /// An emphasis bakes its colour into a `CAShapeLayer`, so a theme change inside one appearance + /// leaves an existing underline on the previous colour. `refresh(for:)` cannot repaint it: the + /// diagnostics themselves have not changed, so it returns early. + func reapplyColors(in controller: TextViewController) { + guard !diagnostics.isEmpty else { return } + apply(diagnostics, in: controller) + } + func clear(in controller: TextViewController?) { pendingTask?.cancel() pendingTask = nil @@ -104,8 +112,8 @@ final class QueryDiagnosticsController { private func color(for severity: QueryDiagnostic.Severity) -> NSColor { switch severity { - case .error: return .systemRed - case .warning: return .systemOrange + case .error: return ThemeEngine.shared.palette[.statusError] + case .warning: return ThemeEngine.shared.palette[.statusWarning] } } } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index a52abc1494..c570121d9b 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -112,6 +112,5 @@ struct QueryEditorView: View { ) .clipped() } - .background(Color(nsColor: .textBackgroundColor)) } } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index d9a5c54e1c..392b5f51f3 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -26,6 +26,14 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie private static let languageServiceLengthLimit = EditorHighlighting.maxHighlightableCharacters weak var controller: TextViewController? + + /// The editor configuration carries the new colours, but an emphasis already on screen baked + /// its own into a `CAShapeLayer` that nothing else repaints. + func reapplyThemeColors() { + guard let controller else { return } + diagnosticsController.reapplyColors(in: controller) + } + private lazy var diagnosticsController = QueryDiagnosticsController( databaseType: databaseType ) diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index d15f79276a..7d8b2a9ee2 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -147,6 +147,7 @@ struct SQLEditorView: View { } .onReceive(AppEvents.shared.themeChanged) { _ in editorConfiguration = Self.makeConfiguration() + coordinator.reapplyThemeColors() } .onAppear { initializeEditor() @@ -258,17 +259,17 @@ struct SQLEditorView: View { appearance: .init( theme: TableProEditorTheme.make(), font: ThemeEngine.shared.editorFonts.font, - wrapLines: ThemeEngine.shared.wordWrap, - tabWidth: ThemeEngine.shared.tabWidth + wrapLines: AppSettingsManager.shared.editor.wordWrap, + tabWidth: AppSettingsManager.shared.editor.clampedTabWidth ), behavior: .init( - indentOption: .spaces(count: ThemeEngine.shared.tabWidth) + indentOption: .spaces(count: AppSettingsManager.shared.editor.clampedTabWidth) ), layout: .init( contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) ), peripherals: EditorPeripherals.editor( - lineNumbers: ThemeEngine.shared.showLineNumbers, + lineNumbers: AppSettingsManager.shared.editor.showLineNumbers, folding: AppSettingsManager.shared.editor.codeFoldingEnabled, statementRunControls: AppSettingsManager.shared.editor.showStatementRunControls, invisibleCharacters: AppSettingsManager.shared.editor.showInvisibleCharacters diff --git a/TablePro/Views/Editor/SourceFileDiskChangeBanner.swift b/TablePro/Views/Editor/SourceFileDiskChangeBanner.swift index 15692cd5d7..bc230f1685 100644 --- a/TablePro/Views/Editor/SourceFileDiskChangeBanner.swift +++ b/TablePro/Views/Editor/SourceFileDiskChangeBanner.swift @@ -6,6 +6,7 @@ import SwiftUI internal struct SourceFileDiskChangeBanner: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let fileName: String let change: SourceFileDiskChange let onReload: () -> Void @@ -15,7 +16,7 @@ internal struct SourceFileDiskChangeBanner: View { var body: some View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) + .foregroundStyle(themeEngine.palette.color(.statusWarning)) .accessibilityHidden(true) Text(message) @@ -41,7 +42,7 @@ internal struct SourceFileDiskChangeBanner: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(.yellow.opacity(0.12)) + .background(themeEngine.palette.color(.statusWarning).opacity(0.12)) } private var message: String { diff --git a/TablePro/Views/Import/SQLCodePreview.swift b/TablePro/Views/Import/SQLCodePreview.swift index 2348355942..6cb912bdfd 100644 --- a/TablePro/Views/Import/SQLCodePreview.swift +++ b/TablePro/Views/Import/SQLCodePreview.swift @@ -21,7 +21,7 @@ struct SQLCodePreview: View { var body: some View { if text.isEmpty { - Color(nsColor: .textBackgroundColor) + ThemeEngine.shared.palette.color(.editorBackground) } else { SourceEditor( $text, @@ -33,6 +33,9 @@ struct SQLCodePreview: View { .onChange(of: colorScheme) { _ in editorConfiguration = Self.makeConfiguration() } + .onReceive(AppEvents.shared.themeChanged) { _ in + editorConfiguration = Self.makeConfiguration() + } } } diff --git a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift index 8470242aa3..715de0334d 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift @@ -2,6 +2,7 @@ import Charts import SwiftUI struct QueryInsightsActivityChart: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let buckets: [QueryInsightsActivityBucket] let granularity: QueryInsightsGranularity @@ -53,7 +54,7 @@ struct QueryInsightsActivityChart: View { } .chartForegroundStyleScale([ Outcome.succeeded: Color.accentColor, - Outcome.failed: Color.orange, + Outcome.failed: themeEngine.palette.color(.statusWarning), ]) .chartLegend(position: .top, alignment: .trailing, spacing: 8) .chartYAxis { diff --git a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift index 8cdd2d1e60..4ac5220cbb 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift @@ -9,6 +9,7 @@ enum QueryInsightsGroupMetric { } struct QueryInsightsGroupList: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let groups: [QueryInsightsGroup] let metric: QueryInsightsGroupMetric let onCopy: (QueryInsightsGroup) -> Void @@ -48,7 +49,7 @@ struct QueryInsightsGroupList: View { if let error = group.latestErrorMessage, case .failures = metric { RevealedTextView(error) .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(themeEngine.palette.color(.statusWarning)) .lineLimit(2) } } @@ -86,7 +87,7 @@ struct QueryInsightsGroupList: View { private func headlineTint(_ group: QueryInsightsGroup) -> Color { switch metric { - case .failures: return .orange + case .failures: return themeEngine.palette.color(.statusWarning) case .duration: return .primary case .callCount: return .primary } @@ -136,6 +137,7 @@ struct QueryInsightsGroupList: View { } struct QueryInsightsRegressionList: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let regressions: [QueryInsightsRegression] let onCopy: (String) -> Void let onLoadInEditor: (String) -> Void @@ -160,7 +162,7 @@ struct QueryInsightsRegressionList: View { .font(.system(.callout, design: .monospaced)) .fontWeight(.medium) .monospacedDigit() - .foregroundStyle(.orange) + .foregroundStyle(themeEngine.palette.color(.statusWarning)) .frame(minWidth: 76, alignment: .trailing) VStack(alignment: .leading, spacing: 3) { diff --git a/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift b/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift index 24f099283c..a33d3cbe27 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift @@ -1,6 +1,7 @@ import SwiftUI struct QueryInsightsSummaryBar: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let totals: QueryInsightsTotals var body: some View { @@ -30,7 +31,7 @@ struct QueryInsightsSummaryBar: View { totals.totalCount.formatted() ), systemImage: "exclamationmark.triangle", - tint: totals.failedCount > 0 ? .orange : .secondary + tint: totals.failedCount > 0 ? themeEngine.palette.color(.statusWarning) : .secondary ) metric( label: String(localized: "Average"), diff --git a/TablePro/Views/Results/ArrayValueEditorView.swift b/TablePro/Views/Results/ArrayValueEditorView.swift index 26b1a9e420..f40d0e2255 100644 --- a/TablePro/Views/Results/ArrayValueEditorView.swift +++ b/TablePro/Views/Results/ArrayValueEditorView.swift @@ -187,7 +187,7 @@ struct ArrayValueEditorView: View { .pickerStyle(.menu) if ArrayValueEditorModel.isDriftedValue(row.element, allowedValues: allowedValues) { Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .help(Text("This value is not one of the type's current labels")) .accessibilityLabel(Text("This value is not one of the type's current labels")) } diff --git a/TablePro/Views/Results/CellOverlayBase.swift b/TablePro/Views/Results/CellOverlayBase.swift index 68d2ae9b43..ce8b12493a 100644 --- a/TablePro/Views/Results/CellOverlayBase.swift +++ b/TablePro/Views/Results/CellOverlayBase.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine enum CellOverlayDismissReason { case userAction @@ -237,16 +238,27 @@ class CellOverlayBase: NSObject { final class CellOverlayContainerView: NSView { override var isFlipped: Bool { true } + private var themeCancellable: AnyCancellable? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil, themeCancellable == nil else { return } + + themeCancellable = AppEvents.shared.themeChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.applyLayerColors() } + } + override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() applyLayerColors() } /// A `CGColor` is a resolved colour and a layer never resolves it again, so the two layer - /// colours are reapplied whenever the appearance changes under an open overlay. + /// colours are reapplied whenever the appearance or the theme changes under an open overlay. func applyLayerColors() { effectiveAppearance.performAsCurrentDrawingAppearance { - layer?.borderColor = NSColor.keyboardFocusIndicatorColor.cgColor + layer?.borderColor = ThemeEngine.shared.palette[.gridFocusBorder].cgColor layer?.backgroundColor = NSColor.textBackgroundColor.cgColor } } diff --git a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift index 67f173144c..b8f02d5d90 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift @@ -121,6 +121,6 @@ struct DataGridCellAppearance: Equatable { guard kind == .boolean, let rawValue, let isTrue = StoredBoolean.value(of: rawValue) else { return palette.text } - return (isTrue ? palette.booleanTrueText : palette.booleanFalseText) ?? palette.text + return isTrue ? palette.booleanTrueText : palette.booleanFalseText } } diff --git a/TablePro/Views/Results/Cells/DataGridCellPalette.swift b/TablePro/Views/Results/Cells/DataGridCellPalette.swift index 0ddabe24fe..ee04d69fc7 100644 --- a/TablePro/Views/Results/Cells/DataGridCellPalette.swift +++ b/TablePro/Views/Results/Cells/DataGridCellPalette.swift @@ -12,8 +12,8 @@ struct DataGridCellPalette: Equatable { let mediumFont: NSFont let text: NSColor let placeholderText: NSColor - let booleanTrueText: NSColor? - let booleanFalseText: NSColor? + let booleanTrueText: NSColor + let booleanFalseText: NSColor let rowNumberText: NSColor let deletedRowText: NSColor let modifiedColumnTint: NSColor @@ -25,8 +25,8 @@ struct DataGridCellPalette: Equatable { mediumFont: .systemFont(ofSize: NSFont.systemFontSize, weight: .medium), text: .labelColor, placeholderText: .secondaryLabelColor, - booleanTrueText: nil, - booleanFalseText: nil, + booleanTrueText: .labelColor, + booleanFalseText: .labelColor, rowNumberText: .secondaryLabelColor, deletedRowText: .secondaryLabelColor, modifiedColumnTint: .systemYellow, @@ -40,13 +40,13 @@ extension ThemeEngine { regularFont: dataGridFonts.regular, italicFont: dataGridFonts.italic, mediumFont: dataGridFonts.medium, - text: colors.dataGrid.text, - placeholderText: colors.dataGrid.nullValue, - booleanTrueText: colors.dataGrid.boolTrue, - booleanFalseText: colors.dataGrid.boolFalse, - rowNumberText: colors.dataGrid.rowNumber, - deletedRowText: colors.dataGrid.deletedText, - modifiedColumnTint: colors.dataGrid.modified, + text: resolved[.gridText], + placeholderText: resolved[.gridNullValue], + booleanTrueText: resolved[.gridBoolTrue], + booleanFalseText: resolved[.gridBoolFalse], + rowNumberText: resolved[.gridRowNumber], + deletedRowText: resolved[.gridDeletedText], + modifiedColumnTint: resolved[.gridModified], findMatchTint: .findHighlightColor ) } diff --git a/TablePro/Views/Results/Cells/DataGridCellRenderer.swift b/TablePro/Views/Results/Cells/DataGridCellRenderer.swift index f88294b57e..92f20ccca4 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRenderer.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRenderer.swift @@ -159,7 +159,7 @@ final class DataGridCellRenderer { private func drawFocusRing(in rect: NSRect) { let path = NSBezierPath(rect: rect.insetBy(dx: 1, dy: 1)) path.lineWidth = 2 - NSColor.keyboardFocusIndicatorColor.setStroke() + ThemeEngine.shared.palette[.gridFocusBorder].setStroke() path.stroke() } } diff --git a/TablePro/Views/Results/ColumnValueFilterPopover.swift b/TablePro/Views/Results/ColumnValueFilterPopover.swift index 2e41aca36e..ae9db9793d 100644 --- a/TablePro/Views/Results/ColumnValueFilterPopover.swift +++ b/TablePro/Views/Results/ColumnValueFilterPopover.swift @@ -6,6 +6,7 @@ import SwiftUI struct ColumnValueFilterPopover: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let columnName: String let loadedRowCount: Int let onApply: (ColumnValueFilter?) -> Void @@ -101,7 +102,7 @@ struct ColumnValueFilterPopover: View { Text(value.label) .lineLimit(1) .truncationMode(.tail) - .foregroundStyle(value.isNull ? Color.secondary : Color.primary) + .foregroundStyle(value.isNull ? themeEngine.palette.color(.gridNullValue) : Color.primary) Spacer(minLength: 8) Text("\(value.count)") .font(.callout.monospacedDigit()) diff --git a/TablePro/Views/Results/DataGridBodyChrome.swift b/TablePro/Views/Results/DataGridBodyChrome.swift index 99685798b2..7e417dea94 100644 --- a/TablePro/Views/Results/DataGridBodyChrome.swift +++ b/TablePro/Views/Results/DataGridBodyChrome.swift @@ -114,12 +114,13 @@ enum DataGridBodyChrome { return bands } - /// The theme's background, falling back to the system's, for the table to paint itself with. - /// - /// Set on the table rather than read from the theme at each draw, because AppKit reads the - /// table's own `backgroundColor` too, for the area an elastic scroll uncovers. - static func applyBackground(to tableView: NSTableView) { - let background = ThemeEngine.shared.colors.dataGrid.background + /// The theme's background and grid line, set on the table rather than read from the theme at + /// each draw: AppKit reads the table's own `backgroundColor` for the area an elastic scroll + /// uncovers, and the separators draw in its `gridColor`. + static func applyThemeColors(to tableView: NSTableView) { + let palette = ThemeEngine.shared.palette + tableView.gridColor = palette[.gridLine] + let background = palette[.gridBackground] guard tableView.backgroundColor != background else { return } tableView.backgroundColor = background } @@ -132,8 +133,8 @@ enum DataGridBodyChrome { static func stripeColor(forRow row: Int, of tableView: NSTableView) -> NSColor? { guard tableView.usesAlternatingRowBackgroundColors else { return nil } return row.isMultiple(of: 2) - ? ThemeEngine.shared.colors.dataGrid.background - : ThemeEngine.shared.colors.dataGrid.alternateRow + ? ThemeEngine.shared.palette[.gridBackground] + : ThemeEngine.shared.palette[.gridAlternateRow] } /// What a row view lays down before its tint and selection: its stripe, or the table's own diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index e62705cd97..694300528f 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -636,7 +636,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData .receive(on: RunLoop.main) .sink { [weak self] _ in if let tableView = self?.tableView { - DataGridBodyChrome.applyBackground(to: tableView) + DataGridBodyChrome.applyThemeColors(to: tableView) + tableView.headerView?.needsDisplay = true + tableView.cornerView?.needsDisplay = true } self?.reloadVisibleRowsAndStates() /// The row-number font is a theme value and it decides the column's width, which @@ -644,6 +646,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData /// the width was already going stale here. self?.resizeRowNumberColumnForCurrentRange() self?.repaintRowGutter() + self?.selectionController.overlay?.needsDisplay = true } } diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index 5f2b03b457..f1bf49954b 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -289,8 +289,17 @@ class DataGridRowView: NSTableRowView { dirtyRect.fill() } - /// A cell range on a row the table has selected is already covered by `NSTableRowView`'s own - /// selection fill, which runs after this, so only the remaining rows are painted here. + /// `NSTableRowView` fills a selected row with the system's selection colour and offers no way + /// to change it, so the theme's own selection is painted here instead. `super` is not called: + /// its fill would sit on top of this one. + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected else { return } + cellSelectionFill.setFill() + dirtyRect.fill() + } + + /// A cell range on a row the table has selected is already covered by the row's own selection + /// fill, which runs after this, so only the remaining rows are painted here. private func drawCellSelectionFill(in dirtyRect: NSRect) { guard !isSelected, let coordinator, @@ -317,8 +326,9 @@ class DataGridRowView: NSTableRowView { /// emphasized accent is far too dark to sit behind text these rows do not recolour, so that /// one goes on as a tint. private var cellSelectionFill: NSColor { - guard isEmphasized else { return .unemphasizedSelectedContentBackgroundColor } - return NSColor.selectedContentBackgroundColor.withAlphaComponent(Self.emphasizedCellSelectionAlpha) + let palette = ThemeEngine.shared.palette + guard isEmphasized else { return palette[.gridInactiveSelection] } + return palette[.gridSelection].withAlphaComponent(Self.emphasizedCellSelectionAlpha) } private static let emphasizedCellSelectionAlpha: CGFloat = 0.28 diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index d0eb216801..7b1313c660 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -70,8 +70,8 @@ extension RowVisualState { /// The wash a row in this state carries, read by the row and by the pinned row gutter over it so /// the two cannot disagree. @MainActor var tint: NSColor? { - if isDeleted { return ThemeEngine.shared.colors.dataGrid.deleted } - if isInserted { return ThemeEngine.shared.colors.dataGrid.inserted } + if isDeleted { return ThemeEngine.shared.palette[.gridDeleted] } + if isInserted { return ThemeEngine.shared.palette[.gridInserted] } return highlight.rowColor?.washColor } @@ -162,7 +162,7 @@ struct DataGridView: NSViewRepresentable { tableView.intercellSpacing = NSSize(width: 1, height: 0) tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) tableView.usesAutomaticRowHeights = false - DataGridBodyChrome.applyBackground(to: tableView) + DataGridBodyChrome.applyThemeColors(to: tableView) tableView.delegate = context.coordinator tableView.dataSource = context.coordinator diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift index 948cf18f3a..25fed2b5b2 100644 --- a/TablePro/Views/Results/ForeignKeyPickerView.swift +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -142,7 +142,7 @@ struct ForeignKeyPickerView: View { private var content: some View { if let errorMessage { RevealedTextView(errorMessage) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index d836ab5156..9872660233 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -105,7 +105,7 @@ struct ForeignKeyPreviewView: View { .frame(height: 60) } else if let errorMessage { RevealedTextView(errorMessage) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) .font(.callout) .padding(10) } else if values.isEmpty { diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index 6638db272f..2db016cb90 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -100,11 +100,11 @@ struct HexEditorBody: View { if sourceIsTruncated || isTruncated { Text(String(localized: "Truncated, read only")) .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) } else if !isValid, !editableHex.isEmpty { Text(String(localized: "Invalid hex")) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } Spacer() @@ -228,8 +228,8 @@ private struct HexDumpDisplayView: NSViewRepresentable { textView.isSelectable = true textView.font = font textView.textContainerInset = NSSize(width: 8, height: 8) - textView.backgroundColor = NSColor.textBackgroundColor - textView.textColor = NSColor.secondaryLabelColor + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelSecondaryText] textView.string = text return scrollView @@ -240,6 +240,8 @@ private struct HexDumpDisplayView: NSViewRepresentable { if textView.font != font { textView.font = font } + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelSecondaryText] if textView.string != text { textView.string = text } @@ -266,8 +268,8 @@ private struct HexInputTextView: NSViewRepresentable { textView.isSelectable = true textView.font = font textView.textContainerInset = NSSize(width: 8, height: 8) - textView.backgroundColor = NSColor.textBackgroundColor - textView.textColor = NSColor.labelColor + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelText] textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false @@ -289,6 +291,8 @@ private struct HexInputTextView: NSViewRepresentable { if textView.font != font { textView.font = font } + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelText] if textView.string != text, !context.coordinator.isUpdating { textView.string = text } diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index 510ea60822..64cb55a6ce 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -9,6 +9,7 @@ import AppKit import SwiftUI struct InlineErrorBanner: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let message: String var onFixWithAI: (() -> Void)? var onDismiss: (() -> Void)? @@ -21,7 +22,7 @@ struct InlineErrorBanner: View { var body: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) + .foregroundStyle(themeEngine.palette.color(.statusError)) ScrollView(.vertical) { RevealedTextView(message) .font(.subheadline) @@ -63,7 +64,7 @@ struct InlineErrorBanner: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(.red.opacity(0.08)) + .background(themeEngine.palette.color(.statusError).opacity(0.08)) } } diff --git a/TablePro/Views/Results/ResultChartCanvas.swift b/TablePro/Views/Results/ResultChartCanvas.swift index e961fa4ce5..8ef311630a 100644 --- a/TablePro/Views/Results/ResultChartCanvas.swift +++ b/TablePro/Views/Results/ResultChartCanvas.swift @@ -7,6 +7,7 @@ import Charts import SwiftUI struct ResultChartCanvas: View { + @ObservedObject private var themeEngine = ThemeEngine.shared /// Solid first, then patterns that stay legible in monochrome and to a colour-blind reader, /// because colour alone does not distinguish a series. private static let seriesDashPatterns: [[CGFloat]] = [ @@ -41,27 +42,27 @@ struct ResultChartCanvas: View { .chartYAxis { AxisMarks(position: .leading) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + .foregroundStyle(themeEngine.palette.color(.panelSeparator).opacity(0.55)) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor)) + .foregroundStyle(themeEngine.palette.color(.panelSeparator)) AxisValueLabel() - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .foregroundStyle(themeEngine.palette.color(.panelSecondaryText)) } } .chartXAxis { AxisMarks(values: .automatic(desiredCount: 7)) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + .foregroundStyle(themeEngine.palette.color(.panelSeparator).opacity(0.55)) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor)) + .foregroundStyle(themeEngine.palette.color(.panelSeparator)) AxisValueLabel(collisionResolution: .greedy(minimumSpacing: 8)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .foregroundStyle(themeEngine.palette.color(.panelSecondaryText)) } } .chartPlotStyle { plotArea in plotArea .background( - Color(nsColor: .controlBackgroundColor).opacity(0.45), + themeEngine.palette.color(.panelBackground).opacity(0.45), in: .rect(cornerRadius: 8) ) } diff --git a/TablePro/Views/Results/ResultSuccessView.swift b/TablePro/Views/Results/ResultSuccessView.swift index facd401a21..4be8b13581 100644 --- a/TablePro/Views/Results/ResultSuccessView.swift +++ b/TablePro/Views/Results/ResultSuccessView.swift @@ -10,6 +10,7 @@ import SwiftUI import TableProPluginKit struct ResultSuccessView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let rowsAffected: Int let executionTime: TimeInterval? let statusMessage: String? @@ -61,7 +62,7 @@ struct ResultSuccessView: View { Spacer() Image(systemName: "checkmark.circle.fill") .font(.largeTitle) - .foregroundStyle(.green) + .foregroundStyle(themeEngine.palette.color(.statusSuccess)) Text(primaryMessage) .font(.body) if let time = executionTime { diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift index d1a759e312..ba8a028eff 100644 --- a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -36,7 +36,7 @@ final class GridSelectionOverlay: NSView { guard let tableView, let coordinator else { return } let editingCell = activeOverlayCell(in: coordinator) - NSColor.selectedContentBackgroundColor.withAlphaComponent(Self.borderAlpha).setStroke() + ThemeEngine.shared.palette[.gridSelection].withAlphaComponent(Self.borderAlpha).setStroke() for rect in selection.rectangles { guard let frame = frame(for: rect, in: tableView, coordinator: coordinator) else { continue } guard frame.intersects(dirtyRect) else { continue } diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 748957beb3..bd49a20b87 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -197,7 +197,9 @@ final class SortableHeaderCell: NSTableHeaderCell { } private func foregroundColor(emphasized: Bool) -> NSColor { - emphasized ? .alternateSelectedControlTextColor : .headerTextColor + emphasized + ? ThemeEngine.shared.palette[.gridSelectionText] + : ThemeEngine.shared.palette[.gridHeaderText] } private func commentColor(emphasized: Bool) -> NSColor { diff --git a/TablePro/Views/Results/SortableHeaderChrome.swift b/TablePro/Views/Results/SortableHeaderChrome.swift index 8eb7aba26c..18076f1ebc 100644 --- a/TablePro/Views/Results/SortableHeaderChrome.swift +++ b/TablePro/Views/Results/SortableHeaderChrome.swift @@ -11,12 +11,12 @@ enum SortableHeaderChrome { static let columnDividerHeight: CGFloat = 16 static func fillBackground(_ rect: NSRect) { - NSColor.windowBackgroundColor.setFill() + ThemeEngine.shared.palette[.gridHeaderBackground].setFill() rect.fill() } static func drawBottomSeparator(in bounds: NSRect) { - NSColor.separatorColor.setFill() + ThemeEngine.shared.palette[.gridLine].setFill() NSRect( x: bounds.minX, y: bounds.maxY - separatorThickness, @@ -27,7 +27,7 @@ enum SortableHeaderChrome { static func drawColumnDivider(in cellFrame: NSRect) { let dividerHeight = min(columnDividerHeight, cellFrame.height) - NSColor.separatorColor.setFill() + ThemeEngine.shared.palette[.gridLine].setFill() NSRect( x: cellFrame.maxX - separatorThickness, y: cellFrame.midY - dividerHeight / 2, diff --git a/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift b/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift index 48a50d7fa3..a986c35790 100644 --- a/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift +++ b/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift @@ -145,7 +145,7 @@ internal final class TreeOutlineCellView: NSTableCellView, NSTextFieldDelegate { let selectedText = NSColor.alternateSelectedControlTextColor let secondary = isEmphasized ? selectedText.withAlphaComponent(0.7) : NSColor.secondaryLabelColor - keyField.textColor = isEmphasized ? selectedText : .systemBlue + keyField.textColor = isEmphasized ? selectedText : ThemeEngine.shared.palette[.syntaxKeyword] separatorField.textColor = secondary visibilityField.textColor = secondary swatch.isOnEmphasizedRow = isEmphasized diff --git a/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift b/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift index faf1e9200f..6942605224 100644 --- a/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift +++ b/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine internal struct TreeOutlineContent { let rootNode: Node @@ -36,6 +37,7 @@ internal final class TreeOutlineCoordinator: NSObject, private var fonts: TreeOutlineFonts? private var isApplyingExpansion = false private var menuRows: [Item] = [] + private var themeCancellable: AnyCancellable? internal init(cache: TreeProjectionCache) { self.cache = cache @@ -50,6 +52,9 @@ internal final class TreeOutlineCoordinator: NSObject, let menu = NSMenu() menu.delegate = self outlineView.menu = menu + // A theme swap that keeps the appearance changes no view state, so AppKit repaints nothing. + themeCancellable = AppEvents.shared.themeChanged + .sink { [weak self] _ in self?.reconfigureVisibleCells() } } // MARK: - Content diff --git a/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift b/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift index 9ad837e46d..aaa4102ad3 100644 --- a/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift +++ b/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift @@ -7,6 +7,7 @@ import AppKit internal enum TreeValueStyle { /// Fixed system colors do not adapt on an emphasized row the way the label colors do. + @MainActor static func textColor(for tone: TreeValueTone, isEmphasized: Bool) -> NSColor { isEmphasized ? .alternateSelectedControlTextColor : tone.color } @@ -20,6 +21,7 @@ internal enum TreeValueStyle { /// No `.link` attribute: a text field cell draws that run in the link color whatever its /// foreground, blue on the accent color. `TreeValueField` puts it in the field editor only. + @MainActor static func attributedValue( _ content: TreeRowContent, font: NSFont, diff --git a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift index 2db3ce9da3..00323307c1 100644 --- a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift @@ -101,11 +101,11 @@ internal struct BlobHexEditorView: View { if isTruncated { Text("Truncated, read only") .font(.caption2) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) } else if BlobFormattingService.shared.parseHex(hexEditText) == nil, !hexEditText.isEmpty { Text("Invalid hex") .font(.caption2) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } } } diff --git a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index 1b60064ad2..a6a1c7abaa 100644 --- a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -134,7 +134,7 @@ internal struct PendingStatePill: View { .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .topLeading) .padding(.horizontal, 6) .padding(.vertical, 3) - .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .background(ThemeEngine.shared.palette.color(.panelControlBackground), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift index c3392981ad..7d51c597b1 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift @@ -35,7 +35,7 @@ internal struct ImageFieldView: View { ) .frame(height: 220) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) .task(id: context.value.wrappedValue) { data = context.value.wrappedValue.storedBytes } diff --git a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift index c591005f92..f38155fa75 100644 --- a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift @@ -6,6 +6,7 @@ import SwiftUI internal struct JsonEditorView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext var onPopOut: ((String) -> Void)? var isExpanded = false @@ -46,7 +47,7 @@ internal struct JsonEditorView: View { accessibilityIdentifier: "inspector-json-field" ) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(themeEngine.palette.color(.panelSeparator))) .overlay(alignment: .bottomTrailing) { actionButtons } } /// `newValue`, never a re-read of `context.value.wrappedValue`. An `onChange` action diff --git a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift index a895de41a3..e3be223a62 100644 --- a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift @@ -27,7 +27,7 @@ internal struct MultiLineEditorView: View { movesFocusOnTab: true ) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) .overlay(alignment: .topLeading) { placeholder } .overlay(alignment: .bottomTrailing) { popOutButton } } diff --git a/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift index cf499ad52e..3174a5ad86 100644 --- a/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift @@ -6,6 +6,7 @@ import SwiftUI internal struct PhpSerializedFieldView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext var onPopOut: ((String) -> Void)? var isExpanded = false @@ -18,6 +19,6 @@ internal struct PhpSerializedFieldView: View { .frame(height: isExpanded ? ResizableFieldMetrics.expandedHeight : nil) .frame(minHeight: isExpanded ? nil : 80, maxHeight: isExpanded ? nil : 200) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(themeEngine.palette.color(.panelSeparator))) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift b/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift index 6438601394..a0f0eb75d0 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift @@ -7,6 +7,7 @@ import AppKit import SwiftUI internal struct ResizableEditorContainer: View { + @ObservedObject private var themeEngine = ThemeEngine.shared @Binding var height: Double let range: ClosedRange @@ -39,7 +40,7 @@ internal struct ResizableEditorContainer: View { private var resizeHandle: some View { Capsule() - .fill(Color(nsColor: .tertiaryLabelColor)) + .fill(themeEngine.palette.color(.panelTertiaryText)) .frame(width: 26, height: 4) .opacity(isHandleHovered ? 1 : 0.5) .frame(maxWidth: .infinity, minHeight: 11) diff --git a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift index f960266bb5..29e99ef991 100644 --- a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift @@ -6,6 +6,7 @@ import SwiftUI internal struct SingleLineEditorView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext var body: some View { @@ -29,7 +30,7 @@ internal struct SingleLineEditorView: View { .frame(maxWidth: .infinity, minHeight: 16, alignment: .leading) .padding(.horizontal, 6) .padding(.vertical, 4) - .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .background(themeEngine.palette.color(.panelControlBackground), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(themeEngine.palette.color(.panelSeparator))) } } diff --git a/TablePro/Views/RowInspector/JSON/JSONRowColors.swift b/TablePro/Views/RowInspector/JSON/JSONRowColors.swift index 049a0354bc..8456937411 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowColors.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowColors.swift @@ -21,14 +21,14 @@ struct JSONRowColors { @MainActor static func current() -> JSONRowColors { - let colors = ThemeEngine.shared.colors.editor + let palette = ThemeEngine.shared.palette return JSONRowColors( - key: colors.keywordSwiftUI, - string: colors.stringSwiftUI, - number: colors.numberSwiftUI, - literal: colors.nullSwiftUI, - punctuation: colors.textSwiftUI, - placeholder: colors.commentSwiftUI + key: palette.color(.syntaxKeyword), + string: palette.color(.syntaxString), + number: palette.color(.syntaxNumber), + literal: palette.color(.syntaxNull), + punctuation: palette.color(.editorText), + placeholder: palette.color(.syntaxComment) ) } diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index 1fea0432fb..23750beb66 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -66,7 +66,7 @@ struct JSONRowInspectorView: View { ) .overlay( RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color.red.opacity(0.6)) + .strokeBorder(ThemeEngine.shared.palette.color(.statusError).opacity(0.6)) .opacity(viewModel.isFilterInvalid ? 1 : 0) ) .help(viewModel.isFilterInvalid @@ -99,7 +99,7 @@ struct JSONRowInspectorView: View { .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .topLeading) } - .background(Color(nsColor: themeEngine.colors.editor.background)) + .background(themeEngine.palette.color(.editorBackground)) .accessibilityLabel(String(localized: "Row as JSON")) } } diff --git a/TablePro/Views/ServerDashboard/MetricsBarView.swift b/TablePro/Views/ServerDashboard/MetricsBarView.swift index 89f4c503d6..40ff79ce34 100644 --- a/TablePro/Views/ServerDashboard/MetricsBarView.swift +++ b/TablePro/Views/ServerDashboard/MetricsBarView.swift @@ -1,6 +1,7 @@ import SwiftUI struct MetricsBarView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let metrics: [DashboardMetric] let error: String? @@ -17,7 +18,7 @@ struct MetricsBarView: View { Image(systemName: "exclamationmark.triangle") } .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(themeEngine.palette.color(.statusError)) } } .padding(.horizontal, 12) diff --git a/TablePro/Views/ServerDashboard/SessionsTableView.swift b/TablePro/Views/ServerDashboard/SessionsTableView.swift index 1cee670b7d..75a33dde0a 100644 --- a/TablePro/Views/ServerDashboard/SessionsTableView.swift +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -1,6 +1,7 @@ import SwiftUI struct SessionsTableView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared @ObservedObject var viewModel: ServerDashboardViewModel @State private var selection: Set = [] @@ -19,7 +20,7 @@ struct SessionsTableView: View { Image(systemName: "exclamationmark.triangle") } .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(themeEngine.palette.color(.statusError)) } } .padding(.horizontal, 12) @@ -86,10 +87,10 @@ struct SessionsTableView: View { private func stateColor(_ state: String) -> Color { switch state.lowercased() { - case "active", "running": return .green + case "active", "running": return themeEngine.palette.color(.statusSuccess) case "idle": return .secondary - case "idle in transaction": return .orange - case "waiting", "locked": return .red + case "idle in transaction": return themeEngine.palette.color(.statusWarning) + case "waiting", "locked": return themeEngine.palette.color(.statusError) default: return .primary } } diff --git a/TablePro/Views/ServerDashboard/SlowQueryListView.swift b/TablePro/Views/ServerDashboard/SlowQueryListView.swift index 5c2358ef5e..c02adf27be 100644 --- a/TablePro/Views/ServerDashboard/SlowQueryListView.swift +++ b/TablePro/Views/ServerDashboard/SlowQueryListView.swift @@ -1,6 +1,7 @@ import SwiftUI struct SlowQueryListView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let queries: [DashboardSlowQuery] let error: String? @@ -19,7 +20,7 @@ struct SlowQueryListView: View { Image(systemName: "exclamationmark.triangle.fill") } .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(themeEngine.palette.color(.statusWarning)) } } .padding(.horizontal, 12) @@ -49,7 +50,7 @@ struct SlowQueryListView: View { Text(query.duration) .font(.system(.caption, design: .monospaced)) .monospacedDigit() - .foregroundStyle(.orange) + .foregroundStyle(themeEngine.palette.color(.statusWarning)) .frame(width: 50, alignment: .trailing) VStack(alignment: .leading, spacing: 2) { diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift index 6b2045f661..9248e68f87 100644 --- a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift +++ b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift @@ -1,299 +1,116 @@ -// -// ThemeEditorColorsSection.swift -// TablePro -// - import AppKit import os import SwiftUI -// MARK: - HexColorPicker - -struct HexColorPicker: View { - let label: String - @Binding var hex: String - - var body: some View { - let colorBinding = Binding( - get: { hex.swiftUIColor }, - set: { newColor in - if let converted = NSColor(newColor).usingColorSpace(.sRGB) { - hex = converted.hexString - } - } - ) - ColorPicker(label, selection: colorBinding, supportsOpacity: true) - } -} - -// MARK: - ThemeEditorColorsSection - +/// One well per registered slot, driven by `ThemeSlot.allCases`, so a slot the app gains appears +/// here without a second hand-maintained list to keep in step. internal struct ThemeEditorColorsSection: View { - private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorColorsSection") - @ObservedObject private var engine = ThemeEngine.shared - private var theme: ThemeDefinition { engine.activeTheme } + internal let theme: ThemeDefinition - var body: some View { - Form { - editorSection - syntaxSection - dataGridSection - interfaceSection - statusSection - badgesSection - sidebarSection - toolbarSection - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } - - // MARK: - Editor + @State private var draft: ThemeDefinition? + @State private var saveTask: Task? - private var editorSection: some View { - Section(String(localized: "Editor")) { - LabeledContent(String(localized: "Background")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.background)) - } - LabeledContent(String(localized: "Text")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.text)) - } - LabeledContent(String(localized: "Cursor")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.cursor)) - } - LabeledContent(String(localized: "Current Line")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.currentLineHighlight)) - } - LabeledContent(String(localized: "Selection")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.selection)) - } - LabeledContent(String(localized: "Line Number")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.lineNumber)) - } - LabeledContent(String(localized: "Invisibles")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.invisibles)) - } - } - } + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorColors") + private static let saveDelay = Duration.milliseconds(250) - private var syntaxSection: some View { - Section(String(localized: "Syntax Colors")) { - LabeledContent(String(localized: "Keyword")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.keyword)) - } - LabeledContent(String(localized: "String")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.string)) - } - LabeledContent(String(localized: "Number")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.number)) - } - LabeledContent(String(localized: "Comment")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.comment)) - } - LabeledContent(String(localized: "NULL")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.null)) - } - LabeledContent(String(localized: "Operator")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.operator)) - } - LabeledContent(String(localized: "Function")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.function)) - } - LabeledContent(String(localized: "Type")) { - HexColorPicker(label: "", hex: colorBinding(for: \.editor.syntax.type)) - } - } + private var edited: ThemeDefinition { + guard let draft, draft.id == theme.id else { return theme } + return draft } - // MARK: - Data Grid - - private var dataGridSection: some View { - let system = ResolvedDataGridColors(from: .defaultLight) - let resolved = ResolvedDataGridColors(from: theme.dataGrid) - return Section(String(localized: "Data Grid")) { - optionalColorRow(String(localized: "Background"), keyPath: \.dataGrid.background, - fallback: system.background) - optionalColorRow(String(localized: "Text"), keyPath: \.dataGrid.text, - fallback: system.text) - optionalColorRow(String(localized: "Alternate Row"), keyPath: \.dataGrid.alternateRow, - fallback: system.alternateRow) - optionalColorRow(String(localized: "NULL Value"), keyPath: \.dataGrid.nullValue, - fallback: system.nullValue) - optionalColorRow(String(localized: "Bool True"), keyPath: \.dataGrid.boolTrue, - fallback: resolved.text) - optionalColorRow(String(localized: "Bool False"), keyPath: \.dataGrid.boolFalse, - fallback: resolved.text) - optionalColorRow(String(localized: "Row Number"), keyPath: \.dataGrid.rowNumber, - fallback: system.rowNumber) - LabeledContent(String(localized: "Modified")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.modified)) - } - LabeledContent(String(localized: "Inserted")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.inserted)) - } - LabeledContent(String(localized: "Deleted")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.deleted)) - } - LabeledContent(String(localized: "Deleted Text")) { - HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.deletedText)) + internal var body: some View { + Form { + ForEach(ThemeSlotGroup.allCases, id: \.self) { group in + Section(group.label) { + ForEach(group.slots, id: \.self) { slot in + row(for: slot) + } + } } } + .formStyle(.grouped) + .onDisappear { flush() } } - // MARK: - Interface - - private var interfaceSection: some View { - Section(String(localized: "Interface")) { - optionalColorRow(String(localized: "Window Background"), keyPath: \.ui.windowBackground, - fallback: .windowBackgroundColor) - optionalColorRow(String(localized: "Control Background"), keyPath: \.ui.controlBackground, - fallback: .controlBackgroundColor) - optionalColorRow(String(localized: "Card Background"), keyPath: \.ui.cardBackground, - fallback: .controlBackgroundColor) - optionalColorRow(String(localized: "Border"), keyPath: \.ui.border, - fallback: .separatorColor) - optionalColorRow(String(localized: "Primary Text"), keyPath: \.ui.primaryText, - fallback: .labelColor) - optionalColorRow(String(localized: "Secondary Text"), keyPath: \.ui.secondaryText, - fallback: .secondaryLabelColor) - optionalColorRow(String(localized: "Tertiary Text"), keyPath: \.ui.tertiaryText, - fallback: .tertiaryLabelColor) - optionalColorRow(String(localized: "Selection"), keyPath: \.ui.selectionBackground, - fallback: .selectedContentBackgroundColor) - optionalColorRow(String(localized: "Hover"), keyPath: \.ui.hoverBackground, - fallback: .unemphasizedSelectedContentBackgroundColor) - } - } + private func row(for slot: ThemeSlot) -> some View { + let value = edited[keyPath: slot.keyPath] - private var statusSection: some View { - Section(String(localized: "Status Colors")) { - LabeledContent(String(localized: "Success")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.status.success)) - } - LabeledContent(String(localized: "Warning")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.status.warning)) - } - LabeledContent(String(localized: "Error")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.status.error)) - } - LabeledContent(String(localized: "Info")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.status.info)) - } - } - } + return LabeledContent(slot.label) { + HStack(spacing: 8) { + if case let .system(name) = value { + Text(name.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } - private var badgesSection: some View { - Section(String(localized: "Badges")) { - LabeledContent(String(localized: "Badge Background")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.badges.background)) + ColorPicker(slot.label, selection: binding(for: slot), supportsOpacity: true) + .labelsHidden() } - LabeledContent(String(localized: "Primary Key")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.badges.primaryKey)) - } - LabeledContent(String(localized: "Auto Increment")) { - HexColorPicker(label: "", hex: colorBinding(for: \.ui.badges.autoIncrement)) + .contextMenu { + if case .hex = value, case let .system(name) = BuiltInThemes.default(for: theme.appearance)[keyPath: slot.keyPath] { + Button(String(format: String(localized: "Use System Color (%@)"), name.rawValue)) { + write(.system(name), to: slot) + } + } } } } - // MARK: - Sidebar - - private var sidebarSection: some View { - Section(String(localized: "Sidebar")) { - optionalColorRow(String(localized: "Background"), keyPath: \.sidebar.background, - fallback: .windowBackgroundColor) - optionalColorRow(String(localized: "Text"), keyPath: \.sidebar.text, - fallback: .labelColor) - optionalColorRow(String(localized: "Selected Item"), keyPath: \.sidebar.selectedItem, - fallback: .selectedContentBackgroundColor) - optionalColorRow(String(localized: "Hover"), keyPath: \.sidebar.hover, - fallback: .unemphasizedSelectedContentBackgroundColor) - optionalColorRow(String(localized: "Section Header"), keyPath: \.sidebar.sectionHeader, - fallback: .secondaryLabelColor) - } + private var editedAppearance: NSAppearance? { + NSAppearance(named: theme.appearance == .dark ? .darkAqua : .aqua) } - // MARK: - Toolbar + private func resolved(_ value: ThemeColorValue) -> NSColor { + guard value.isSystem, let editedAppearance else { return value.nsColor } - private var toolbarSection: some View { - Section(String(localized: "Toolbar")) { - optionalColorRow(String(localized: "Secondary Text"), keyPath: \.toolbar.secondaryText, - fallback: .secondaryLabelColor) - optionalColorRow(String(localized: "Tertiary Text"), keyPath: \.toolbar.tertiaryText, - fallback: .tertiaryLabelColor) + var color = value.nsColor + editedAppearance.performAsCurrentDrawingAppearance { + color = value.nsColor.usingColorSpace(.sRGB) ?? value.nsColor } + return color } - // MARK: - Helpers - - private func colorBinding(for keyPath: WritableKeyPath) -> Binding { + private func binding(for slot: ThemeSlot) -> Binding { Binding( - get: { theme[keyPath: keyPath] }, - set: { newValue in - guard theme.isEditable else { return } - var updated = theme - updated[keyPath: keyPath] = newValue - do { - try engine.saveUserTheme(updated) - } catch { - Self.logger.error("Failed to save theme: \(error.publicLogShape, privacy: .public)") - } + get: { Color(nsColor: resolved(edited[keyPath: slot.keyPath])) }, + set: { newColor in + write(.hex(HexColor.string(from: NSColor(newColor))), to: slot) } ) } - private func optionalColorBinding( - for keyPath: WritableKeyPath, - fallback: NSColor - ) -> Binding { - Binding( - get: { - if let hex = theme[keyPath: keyPath] { - return hex - } - return (fallback.usingColorSpace(.sRGB) ?? fallback).hexString - }, - set: { newValue in - guard theme.isEditable else { return } - var updated = theme - updated[keyPath: keyPath] = newValue - do { - try engine.saveUserTheme(updated) - } catch { - Self.logger.error("Failed to save theme: \(error.publicLogShape, privacy: .public)") - } - } - ) + /// The well emits on every drag tick, and each one used to write the file, rescan the themes + /// directory and re-activate. The draft absorbs the ticks and one save follows the settle. + private func write(_ value: ThemeColorValue, to slot: ThemeSlot) { + var updated = edited + updated[keyPath: slot.keyPath] = value + draft = updated + + saveTask?.cancel() + saveTask = Task { @MainActor in + try? await Task.sleep(for: Self.saveDelay) + guard !Task.isCancelled else { return } + save(updated) + } } - @ViewBuilder - private func optionalColorRow( - _ label: String, - keyPath: WritableKeyPath, - fallback: NSColor - ) -> some View { - LabeledContent(label) { - HStack(spacing: 4) { - HexColorPicker(label: "", hex: optionalColorBinding(for: keyPath, fallback: fallback)) - if theme[keyPath: keyPath] != nil { - Button { - guard theme.isEditable else { return } - var updated = theme - updated[keyPath: keyPath] = nil - do { - try engine.saveUserTheme(updated) - } catch { - Self.logger.error("Failed to save theme: \(error.publicLogShape, privacy: .public)") - } - } label: { - Image(systemName: "arrow.counterclockwise") - .font(.caption) - } - .buttonStyle(.borderless) - .help(String(localized: "Reset to System Default")) - .accessibilityLabel(String(localized: "Reset to System Default")) - } - } + private func flush() { + saveTask?.cancel() + saveTask = nil + guard let draft, draft != theme else { return } + save(draft) + } + + private func save(_ updated: ThemeDefinition) { + do { + try ThemeCatalog.shared.save(updated) + let appearance = AppSettingsManager.shared.appearance + ThemeEngine.shared.reapply( + lightThemeId: appearance.preferredLightThemeId, + darkThemeId: appearance.preferredDarkThemeId + ) + } catch { + Self.logger.error("Could not save theme: \(error.publicLogShape, privacy: .public)") } } } diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift deleted file mode 100644 index eb5b0575de..0000000000 --- a/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift +++ /dev/null @@ -1,135 +0,0 @@ -import AppKit -import SwiftUI - -struct ThemeEditorFontsSection: View { - var onThemeDuplicated: ((ThemeDefinition) -> Void)? - - @ObservedObject private var engine = ThemeEngine.shared - - @State private var editingTheme: ThemeDefinition? - - private var theme: ThemeDefinition { engine.activeTheme } - - private var currentThemeFonts: ThemeFonts { - editingTheme?.fonts ?? theme.fonts - } - - var body: some View { - Form { - editorFontSection - dataGridFontSection - previewSection - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - .onChange(of: engine.activeTheme.id) { _ in - editingTheme = nil - } - } - - // MARK: - Editor Font - - private var editorFontSection: some View { - Section(String(localized: "Editor Font")) { - fontPicker( - label: String(localized: "Family"), - selection: currentThemeFonts.editorFontFamily, - onChange: { newFamily in - updateFont { $0.editorFontFamily = newFamily } - } - ) - sizePicker( - label: String(localized: "Size"), - value: currentThemeFonts.editorFontSize, - range: 11...18, - onChange: { newSize in - updateFont { $0.editorFontSize = newSize } - } - ) - } - } - - // MARK: - Data Grid Font - - private var dataGridFontSection: some View { - Section(String(localized: "Data Grid Font")) { - fontPicker( - label: String(localized: "Family"), - selection: currentThemeFonts.dataGridFontFamily, - onChange: { newFamily in - updateFont { $0.dataGridFontFamily = newFamily } - } - ) - sizePicker( - label: String(localized: "Size"), - value: currentThemeFonts.dataGridFontSize, - range: 10...18, - onChange: { newSize in - updateFont { $0.dataGridFontSize = newSize } - } - ) - } - } - - // MARK: - Preview - - private var previewSection: some View { - Section(String(localized: "Preview")) { - let fonts = currentThemeFonts - let editorFont = EditorFontResolver.resolve( - familyId: fonts.editorFontFamily, - size: CGFloat(fonts.editorFontSize) - ) - - Text("SELECT * FROM users WHERE id = 42;") - .font(Font(editorFont)) - .foregroundStyle(theme.editor.text.swiftUIColor) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(theme.editor.background.swiftUIColor) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } - } - - // MARK: - Helpers - - private func fontPicker(label: String, selection: String, onChange: @escaping (String) -> Void) -> some View { - Picker(label, selection: Binding( - get: { selection }, - set: { onChange($0) } - )) { - ForEach(EditorFontResolver.availableMonospacedFamilies) { font in - Text(font.displayName).tag(font.id) - } - } - } - - private func sizePicker(label: String, value: Int, range: ClosedRange, - onChange: @escaping (Int) -> Void) -> some View { - Picker(label, selection: Binding( - get: { value }, - set: { onChange($0) } - )) { - ForEach(range, id: \.self) { size in - Text(verbatim: "\(size) pt").tag(size) - } - } - } - - private func updateFont(_ mutate: (inout ThemeFonts) -> Void) { - let base = editingTheme ?? theme - - if base.isBuiltIn { - var copy = engine.duplicateTheme(base, newName: base.name + " (Custom)") - mutate(©.fonts) - try? engine.saveUserTheme(copy) - editingTheme = copy - onThemeDuplicated?(copy) - } else { - var updated = base - mutate(&updated.fonts) - try? engine.saveUserTheme(updated) - editingTheme = updated - } - } -} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift index f85e6996e6..c5f8c8a55a 100644 --- a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift @@ -1,58 +1,35 @@ -// -// ThemeEditorView.swift -// TablePro -// -// Right panel of the appearance HSplitView: theme header, accent color, and tabbed editor sections. -// - +import os import SwiftUI +/// Edits the theme the slot holds, not the theme in effect. Reading `activeTheme` here meant that +/// with the Mac in light mode and the pane set to Editing: Dark, every edit, duplicate and delete +/// landed on the light theme, and the dark slot was then pointed at it. internal struct ThemeEditorView: View { - @Binding var selectedThemeId: String - - @ObservedObject private var engine = ThemeEngine.shared - private var theme: ThemeDefinition { engine.activeTheme } - private var isEditable: Bool { theme.isEditable } - - @State private var activeTab: EditorTab = .fonts + @Binding internal var selectedThemeId: String + internal let slotAppearance: ThemeAppearance @State private var errorMessage: String? @State private var showError = false - private enum EditorTab: String, CaseIterable { - case fonts = "Fonts" - case colors = "Colors" + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorView") - var localizedName: String { - switch self { - case .fonts: return String(localized: "Fonts") - case .colors: return String(localized: "Colors") - } - } + @ObservedObject private var catalog = ThemeCatalog.shared + + private var theme: ThemeDefinition { + catalog.theme(id: selectedThemeId) ?? BuiltInThemes.default(for: slotAppearance) } - var body: some View { + internal var body: some View { VStack(spacing: 0) { - Text(theme.name) - .font(.title3.weight(.semibold)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 8) - - Picker("Theme", selection: $activeTab) { - ForEach(EditorTab.allCases, id: \.self) { tab in - Text(tab.localizedName).tag(tab) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.horizontal, 16) - .padding(.bottom, 8) + header Divider() - tabContent + if theme.isEditable { + ThemeEditorColorsSection(theme: theme) + } else { + duplicatePrompt + } } .alert(String(localized: "Error"), isPresented: $showError) { Button(String(localized: "OK")) {} @@ -63,20 +40,19 @@ internal struct ThemeEditorView: View { } } - @ViewBuilder - private var tabContent: some View { - switch activeTab { - case .fonts: - ThemeEditorFontsSection(onThemeDuplicated: { newTheme in - selectedThemeId = newTheme.id - }) - case .colors: - if isEditable { - ThemeEditorColorsSection() - } else { - duplicatePrompt - } + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text(theme.name) + .font(.title3.weight(.semibold)) + + Text(theme.author.isEmpty ? String(localized: "Custom theme") : theme.author) + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 10) } private var duplicatePrompt: some View { @@ -93,11 +69,11 @@ internal struct ThemeEditorView: View { .font(.body) .foregroundStyle(.secondary) - Text(String(localized: "Duplicate it to customize colors.")) + Text("Duplicate it to change its colors.") .font(.subheadline) .foregroundStyle(.tertiary) - Button(String(localized: "Duplicate Theme")) { + Button("Duplicate Theme") { duplicateAndSelect() } .controlSize(.large) @@ -108,12 +84,15 @@ internal struct ThemeEditorView: View { } private func duplicateAndSelect() { - let copy = engine.duplicateTheme(theme, newName: theme.name + " (Copy)") + var copy = theme + copy.id = ThemeIdentifier.generated() + copy.name = String(format: String(localized: "%@ (Copy)"), theme.name) + do { - try engine.saveUserTheme(copy) - engine.activateTheme(copy) + try catalog.save(copy) selectedThemeId = copy.id } catch { + Self.logger.error("Could not duplicate theme: \(error.publicLogShape, privacy: .public)") errorMessage = error.localizedDescription showError = true } diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift index 7322cfcb50..ba66ede758 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -1,65 +1,60 @@ import AppKit +import os import SwiftUI import UniformTypeIdentifiers +/// Every action here acts on the theme the slot has selected. Acting on the active theme instead +/// let a delete in the dark slot write the light theme's id into it, so at dusk the editor turned +/// white inside dark chrome. internal struct ThemeListView: View { - @Binding var selectedThemeId: String + @Binding internal var selectedThemeId: String internal var slotAppearance: ThemeAppearance = .light - @ObservedObject private var engine = ThemeEngine.shared - @State private var showDeleteConfirmation = false @State private var errorMessage: String? @State private var showError = false + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeListView") + + @ObservedObject private var catalog = ThemeCatalog.shared + private var builtInThemes: [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes( - engine.availableThemes.filter(\.isBuiltIn), - slot: slotAppearance, - keeping: selectedThemeId - ) + eligible(catalog.themes.filter(\.isBuiltIn)) } private var registryThemes: [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes( - engine.registryThemes, - slot: slotAppearance, - keeping: selectedThemeId - ) + eligible(catalog.themes.filter(\.isRegistry)) } private var customThemes: [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes( - engine.availableThemes.filter(\.isEditable), - slot: slotAppearance, - keeping: selectedThemeId - ) + eligible(catalog.themes.filter(\.isEditable)) } private var selectedTheme: ThemeDefinition? { - engine.availableThemes.first { $0.id == selectedThemeId } + catalog.theme(id: selectedThemeId) + } + + private var fallbackThemeId: String { + BuiltInThemes.defaultId(for: slotAppearance) } - private var isDeleteDisabled: Bool { - guard let theme = selectedTheme else { return true } - return !theme.isEditable + private func eligible(_ themes: [ThemeDefinition]) -> [ThemeDefinition] { + ThemeSlotValidation.eligibleThemes(themes, slot: slotAppearance, keeping: selectedThemeId) } - var body: some View { + internal var body: some View { VStack(spacing: 0) { List(selection: $selectedThemeId) { Section("Built-in") { ForEach(builtInThemes) { theme in - ThemeListRowView(theme: theme) - .tag(theme.id) + ThemeListRowView(theme: theme).tag(theme.id) } } if !registryThemes.isEmpty { Section("Registry") { ForEach(registryThemes) { theme in - ThemeListRowView(theme: theme) - .tag(theme.id) + ThemeListRowView(theme: theme).tag(theme.id) } } } @@ -67,8 +62,15 @@ internal struct ThemeListView: View { if !customThemes.isEmpty { Section("Custom") { ForEach(customThemes) { theme in - ThemeListRowView(theme: theme) - .tag(theme.id) + ThemeListRowView(theme: theme).tag(theme.id) + } + } + } + + if !catalog.rejected.isEmpty { + Section("Not Loaded") { + ForEach(catalog.rejected, id: \.path) { record in + rejectedRow(record) } } } @@ -78,72 +80,16 @@ internal struct ThemeListView: View { Divider() - HStack(spacing: 4) { - Menu { - Button(String(localized: "New Theme")) { - duplicateActiveTheme() - } - Divider() - Button(String(localized: "Import…")) { - importTheme() - } - } label: { - Image(systemName: "plus") - .frame(width: 24, height: 24) - .accessibilityLabel(Text("Add Theme")) - } - .menuIndicator(.hidden) - .buttonStyle(.borderless) - .frame(width: 28) - .help(Text("Add Theme")) - - Button { - showDeleteConfirmation = true - } label: { - Image(systemName: "minus") - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .disabled(isDeleteDisabled) - .help(Text("Delete Theme")) - .accessibilityLabel(Text("Delete Theme")) - - Menu { - Button(String(localized: "Duplicate")) { - duplicateActiveTheme() - } - Button(String(localized: "Export…")) { - exportActiveTheme() - } - if selectedTheme?.isRegistry == true { - Divider() - Button(String(localized: "Uninstall"), role: .destructive) { - uninstallRegistryTheme() - } - } - } label: { - Image(systemName: "gearshape") - .frame(width: 24, height: 24) - .accessibilityLabel(Text("Theme Actions")) - } - .menuIndicator(.hidden) - .buttonStyle(.borderless) - .frame(width: 28) - .help(Text("Theme Actions")) - - Spacer() - } - .padding(.horizontal, 8) - .padding(.vertical, 4) + toolbar } .alert(String(localized: "Delete Theme"), isPresented: $showDeleteConfirmation) { - Button(String(localized: "Delete"), role: .destructive) { - deleteSelectedTheme() - } + Button(String(localized: "Delete"), role: .destructive) { deleteSelectedTheme() } Button(String(localized: "Cancel"), role: .cancel) {} } message: { - let name = engine.availableThemes.first(where: { $0.id == selectedThemeId })?.name ?? "" - Text(String(format: String(localized: "Are you sure you want to delete \"%@\"?"), name)) + Text(String( + format: String(localized: "Are you sure you want to delete \"%@\"?"), + selectedTheme?.name ?? "" + )) } .alert(String(localized: "Error"), isPresented: $showError) { Button(String(localized: "OK")) {} @@ -154,53 +100,110 @@ internal struct ThemeListView: View { } } - // MARK: - Actions + /// A file the loader refused is shown with its reason rather than dropped in silence, which is + /// what the previous loader did for a malformed theme. + private func rejectedRow(_ record: RejectedThemeRecord) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(record.fileName) + .font(.callout) + .lineLimit(1) + + Text(record.reason) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + .help(Text(record.reason)) + } - private func duplicateActiveTheme() { - let theme = engine.activeTheme - let copy = engine.duplicateTheme(theme, newName: theme.name + " (Copy)") - do { - try engine.saveUserTheme(copy) - selectedThemeId = copy.id - } catch { - errorMessage = error.localizedDescription - showError = true + private var toolbar: some View { + HStack(spacing: 4) { + Menu { + Button(String(localized: "Duplicate Selected")) { duplicateSelectedTheme() } + Divider() + Button(String(localized: "Import…")) { importTheme() } + } label: { + Image(systemName: "plus") + .frame(width: 24, height: 24) + .accessibilityLabel(Text("Add Theme")) + } + .menuIndicator(.hidden) + .buttonStyle(.borderless) + .frame(width: 28) + .help(Text("Add Theme")) + + Button { + showDeleteConfirmation = true + } label: { + Image(systemName: "minus").frame(width: 24, height: 24) + } + .buttonStyle(.borderless) + .disabled(selectedTheme?.isEditable != true) + .help(Text("Delete Theme")) + .accessibilityLabel(Text("Delete Theme")) + + Menu { + Button(String(localized: "Duplicate")) { duplicateSelectedTheme() } + Button(String(localized: "Export…")) { exportSelectedTheme() } + if selectedTheme?.isRegistry == true { + Divider() + Button(String(localized: "Uninstall"), role: .destructive) { uninstallRegistryTheme() } + } + } label: { + Image(systemName: "gearshape") + .frame(width: 24, height: 24) + .accessibilityLabel(Text("Theme Actions")) + } + .menuIndicator(.hidden) + .buttonStyle(.borderless) + .frame(width: 28) + .help(Text("Theme Actions")) + + Spacer() } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + + // MARK: - Actions + + private func duplicateSelectedTheme() { + guard let theme = selectedTheme else { return } + var copy = theme + copy.id = ThemeIdentifier.generated() + copy.name = String(format: String(localized: "%@ (Copy)"), theme.name) + + perform { try catalog.save(copy) } then: { selectedThemeId = copy.id } } private func deleteSelectedTheme() { - do { - try engine.deleteUserTheme(id: selectedThemeId) - selectedThemeId = engine.activeTheme.id - } catch { - errorMessage = error.localizedDescription - showError = true - } + guard let theme = selectedTheme, theme.isEditable else { return } + perform { try catalog.delete(id: theme.id) } then: { selectedThemeId = fallbackThemeId } } private func uninstallRegistryTheme() { guard let theme = selectedTheme, theme.isRegistry else { return } - let meta = ThemeStorage.loadRegistryMeta() - guard let entry = meta.installed.first(where: { $0.id == theme.id }) else { return } - do { - try engine.uninstallRegistryTheme(registryPluginId: entry.registryPluginId) - selectedThemeId = engine.activeTheme.id - } catch { - errorMessage = error.localizedDescription - showError = true + guard let entry = catalog.loadRegistryMeta().installed.first(where: { $0.id == theme.id }) else { return } + + perform { + try ThemeRegistryInstaller.shared.uninstall(registryPluginId: entry.registryPluginId) + } then: { + selectedThemeId = fallbackThemeId } } - private func exportActiveTheme() { - guard let window = AlertHelper.resolveWindow(nil) else { return } + private func exportSelectedTheme() { + guard let theme = selectedTheme, let window = AlertHelper.resolveWindow(nil) else { return } + let panel = NSSavePanel() panel.allowedContentTypes = [.json] - panel.nameFieldStringValue = engine.activeTheme.name + ".json" + panel.nameFieldStringValue = theme.name + ".json" panel.canCreateDirectories = true panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } do { - try engine.exportTheme(engine.activeTheme, to: url) + try catalog.exportTheme(theme, to: url) } catch { AlertHelper.showErrorSheet( title: String(localized: "Could not export the theme"), @@ -213,19 +216,29 @@ internal struct ThemeListView: View { private func importTheme() { guard let window = AlertHelper.resolveWindow(nil) else { return } + let panel = NSOpenPanel() panel.allowedContentTypes = [.json] panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } - do { - let imported = try self.engine.importTheme(from: url) - self.selectedThemeId = imported.id - } catch { - self.errorMessage = error.localizedDescription - self.showError = true + perform { + let imported = try catalog.importTheme(from: url) + guard imported.appearance == slotAppearance else { return } + selectedThemeId = imported.id } } } + + private func perform(_ work: () throws -> Void, then completion: () -> Void = {}) { + do { + try work() + completion() + } catch { + Self.logger.error("Theme action failed: \(error.publicLogShape, privacy: .public)") + errorMessage = error.localizedDescription + showError = true + } + } } diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 762db98ede..7a9c0507cc 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -84,7 +84,7 @@ struct AppearanceSettingsView: View { ThemeListView(selectedThemeId: slotThemeBinding, slotAppearance: slotAppearance) .frame(minWidth: 180, idealWidth: 210, maxWidth: 250) - ThemeEditorView(selectedThemeId: slotThemeBinding) + ThemeEditorView(selectedThemeId: slotThemeBinding, slotAppearance: slotAppearance) .frame(minWidth: 400) } } diff --git a/TablePro/Views/Settings/DataResultsSettingsView.swift b/TablePro/Views/Settings/DataResultsSettingsView.swift index a0a72fea87..af0554b2d0 100644 --- a/TablePro/Views/Settings/DataResultsSettingsView.swift +++ b/TablePro/Views/Settings/DataResultsSettingsView.swift @@ -9,9 +9,12 @@ struct DataResultsSettingsView: View { @Binding var dataGrid: DataGridSettings @Binding var history: HistorySettings @Binding var editor: EditorSettings + @Binding var typography: TypographySettings var body: some View { Form { + TypographySection(domain: .dataGrid, settings: $typography) + DataGridSection(settings: $dataGrid) Section("JSON Viewer") { @@ -34,7 +37,8 @@ struct DataResultsSettingsView: View { DataResultsSettingsView( dataGrid: .constant(.default), history: .constant(.default), - editor: .constant(.default) + editor: .constant(.default), + typography: .constant(.default) ) .frame(width: 450, height: 500) } diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index cb3b69b5c0..34f4b84bce 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -7,9 +7,12 @@ import SwiftUI struct EditorSettingsView: View { @Binding var settings: EditorSettings + @Binding var typography: TypographySettings var body: some View { Form { + TypographySection(domain: .editor, settings: $typography) + Section("SQL Editor") { Toggle("Show line numbers", isOn: $settings.showLineNumbers) Toggle("Highlight current line", isOn: $settings.highlightCurrentLine) @@ -17,6 +20,8 @@ struct EditorSettingsView: View { Toggle("Word wrap", isOn: $settings.wordWrap) Toggle("Code folding", isOn: $settings.codeFoldingEnabled) Toggle("Run button beside each statement", isOn: $settings.showStatementRunControls) + .disabled(!settings.showLineNumbers) + .help(Text("The run button sits in the gutter, which needs line numbers.")) Toggle("Show invisible characters", isOn: $settings.showInvisibleCharacters) Picker("Tab width:", selection: $settings.tabWidth) { Text("2 spaces").tag(2) @@ -39,6 +44,6 @@ struct EditorSettingsView: View { } #Preview { - EditorSettingsView(settings: .constant(.default)) + EditorSettingsView(settings: .constant(.default), typography: .constant(.default)) .frame(width: 450, height: 500) } diff --git a/TablePro/Views/Settings/Sections/TypographySection.swift b/TablePro/Views/Settings/Sections/TypographySection.swift new file mode 100644 index 0000000000..cb50b3a815 --- /dev/null +++ b/TablePro/Views/Settings/Sections/TypographySection.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// The two font domains, each on the pane that owns the surfaces it applies to. They used to live +/// inside the theme file, so a zoom shortcut rewrote a whole theme, and a font edit on a built-in +/// forked it into a copy. +internal struct TypographySection: View { + internal enum Domain { + case editor + case dataGrid + + internal var title: String { + switch self { + case .editor: return String(localized: "Editor Font") + case .dataGrid: return String(localized: "Data Grid Font") + } + } + + internal var caption: String { + switch self { + case .editor: + return String(localized: "Applies to the SQL editor, the JSON viewer's Text mode, and the previews.") + case .dataGrid: + return String(localized: "Applies to grid cells, the inspector, cell popovers and the row diff.") + } + } + } + + internal let domain: Domain + @Binding internal var settings: TypographySettings + + internal var body: some View { + Section(domain.title) { + Picker(String(localized: "Family:"), selection: familyBinding) { + ForEach(EditorFontResolver.availableMonospacedFamilies) { family in + Text(family.displayName).tag(family.id) + } + } + + Picker(String(localized: "Size:"), selection: sizeBinding) { + ForEach(TypographySettings.sizeRange, id: \.self) { size in + Text(verbatim: "\(size) pt").tag(size) + } + } + + Text(domain.caption) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var familyBinding: Binding { + switch domain { + case .editor: return $settings.editorFontFamily + case .dataGrid: return $settings.dataGridFontFamily + } + } + + private var sizeBinding: Binding { + switch domain { + case .editor: return $settings.editorFontSize + case .dataGrid: return $settings.dataGridFontSize + } + } +} diff --git a/TablePro/Views/Settings/SettingsWindowController.swift b/TablePro/Views/Settings/SettingsWindowController.swift index 3950abc5f7..4bd4748a64 100644 --- a/TablePro/Views/Settings/SettingsWindowController.swift +++ b/TablePro/Views/Settings/SettingsWindowController.swift @@ -148,12 +148,16 @@ private struct SettingsPaneContent: View { case .appearance: AppearanceSettingsView(settings: $settingsManager.appearance) case .editor: - EditorSettingsView(settings: $settingsManager.editor) + EditorSettingsView( + settings: $settingsManager.editor, + typography: $settingsManager.typography + ) case .data: DataResultsSettingsView( dataGrid: $settingsManager.dataGrid, history: $settingsManager.history, - editor: $settingsManager.editor + editor: $settingsManager.editor, + typography: $settingsManager.typography ) case .keyboard: KeyboardSettingsView(settings: $settingsManager.keyboard) diff --git a/TablePro/Views/Settings/ThemePreviewCard.swift b/TablePro/Views/Settings/ThemePreviewCard.swift index 0efd021cf5..c43c1147d7 100644 --- a/TablePro/Views/Settings/ThemePreviewCard.swift +++ b/TablePro/Views/Settings/ThemePreviewCard.swift @@ -108,36 +108,26 @@ struct ThemePreviewCard: View { /// in, not in whichever one Settings happens to be showing. private var previewColorScheme: ColorScheme { switch theme.appearance { - case .dark: - return .dark - case .light: - return .light - case .auto: - return colorScheme + case .dark: return .dark + case .light: return .light } } private var sidebarStrip: some View { ZStack(alignment: .topLeading) { Rectangle() - .fill(theme.sidebar.background?.swiftUIColor - ?? Color(nsColor: .windowBackgroundColor)) + .fill(theme.dataGrid.headerBackground.swiftUIColor) VStack(alignment: .leading, spacing: size == .compact ? 3 : 4) { let widths: [CGFloat] = size == .compact ? [10, 14, 13, 9] : [14, 18, 17, 12] - ForEach(0..<4, id: \.self) { i in + ForEach(0..<4, id: \.self) { index in RoundedRectangle(cornerRadius: 1) - .fill(i == 1 - ? (theme.sidebar.selectedItem?.swiftUIColor - ?? Color(nsColor: .selectedContentBackgroundColor)).opacity(0.6) - : (theme.sidebar.text?.swiftUIColor - ?? Color(nsColor: .labelColor)).opacity(0.25)) - .frame( - width: widths[i], - height: codeLineHeight - ) + .fill(index == 1 + ? theme.dataGrid.selection.swiftUIColor.opacity(0.6) + : theme.dataGrid.rowNumber.swiftUIColor.opacity(0.6)) + .frame(width: widths[index], height: codeLineHeight) } } .padding(.top, size == .compact ? 5 : 8) @@ -174,7 +164,7 @@ struct ThemePreviewCard: View { } } - private func codeLine(widths: [CGFloat], colors: [String]) -> some View { + private func codeLine(widths: [CGFloat], colors: [ThemeColorValue]) -> some View { HStack(spacing: size == .compact ? 2 : 3) { ForEach(Array(zip(widths, colors).enumerated()), id: \.offset) { _, pair in RoundedRectangle(cornerRadius: 1) @@ -185,22 +175,22 @@ struct ThemePreviewCard: View { } private var dataGridArea: some View { - let colors = ResolvedDataGridColors(from: theme.dataGrid) + let colors = theme.dataGrid return VStack(spacing: 0) { ForEach(0.. some View { if let enabled = trigger.enabled { Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") - .foregroundStyle(enabled ? Color.green : Color.secondary) + .foregroundStyle(enabled ? themeEngine.palette.color(.statusSuccess) : Color.secondary) .accessibilityLabel(enabled ? String(localized: "Enabled") : String(localized: "Disabled")) } } } private struct TriggerDetailPane: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let triggers: [TriggerInfo] @ObservedObject var state: TriggerInspectorState let databaseType: DatabaseType @@ -282,7 +284,7 @@ private struct TriggerDetailPane: View { onOpenInEditor: { onOpenInEditor(trigger) } ) } else { - Color(nsColor: .textBackgroundColor) + themeEngine.palette.color(.editorBackground) } } diff --git a/TablePro/Views/Structure/TriggerEditorView.swift b/TablePro/Views/Structure/TriggerEditorView.swift index 27e04c967f..4b93bbdca1 100644 --- a/TablePro/Views/Structure/TriggerEditorView.swift +++ b/TablePro/Views/Structure/TriggerEditorView.swift @@ -76,6 +76,9 @@ struct TriggerEditorView: View { .onChange(of: colorScheme) { _ in editorConfiguration = Self.makeConfiguration(fontSize: fontSize) } + .onReceive(AppEvents.shared.themeChanged) { _ in + editorConfiguration = Self.makeConfiguration(fontSize: fontSize) + } .onChange(of: fontSize) { newSize in editorConfiguration = Self.makeConfiguration(fontSize: newSize) } diff --git a/TableProTests/Models/UI/JSONTreeParserTests.swift b/TableProTests/Models/UI/JSONTreeParserTests.swift index 73a73b40ec..049991050b 100644 --- a/TableProTests/Models/UI/JSONTreeParserTests.swift +++ b/TableProTests/Models/UI/JSONTreeParserTests.swift @@ -316,15 +316,17 @@ struct JSONTreeParserTests { #expect(marker.decoration == .none) } - @Test("each tone keeps the color its values were drawn in") - func tonesKeepTheirColors() { - #expect(TreeValueTone.container.color == .systemBlue) - #expect(TreeValueTone.string.color == .systemRed) - #expect(TreeValueTone.number.color == .systemPurple) - #expect(TreeValueTone.literal.color == .systemOrange) - #expect(TreeValueTone.serialized.color == .systemTeal) + @MainActor + @Test("each tone takes its color from the theme's syntax and panel slots") + func tonesTakeTheThemeColors() { + let palette = ThemeEngine.shared.palette + #expect(TreeValueTone.container.color == palette[.syntaxKeyword]) + #expect(TreeValueTone.string.color == palette[.syntaxString]) + #expect(TreeValueTone.number.color == palette[.syntaxNumber]) + #expect(TreeValueTone.literal.color == palette[.syntaxNull]) + #expect(TreeValueTone.serialized.color == palette[.syntaxType]) #expect(TreeValueTone.reference.color == .systemGray) - #expect(TreeValueTone.muted.color == .secondaryLabelColor) + #expect(TreeValueTone.muted.color == palette[.panelSecondaryText]) } // MARK: - Search diff --git a/TableProTests/Theme/BundledThemeStatementHighlightTests.swift b/TableProTests/Theme/BundledThemeStatementHighlightTests.swift deleted file mode 100644 index b41097754b..0000000000 --- a/TableProTests/Theme/BundledThemeStatementHighlightTests.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// BundledThemeStatementHighlightTests.swift -// TableProTests -// -// `EditorThemeColors` falls back to the light defaults for any key a theme omits, so a dark theme that forgets -// `currentStatementHighlight` paints the light value over dark text. Nothing at runtime notices, which is why this -// reads the shipped JSON rather than the decoded theme. -// - -import Foundation -import Testing -@testable import TablePro - -struct BundledThemeStatementHighlightTests { - - private static let themeIds = [ - "tablepro.default-light", - "tablepro.default-dark", - "tablepro.dracula", - "tablepro.nord", - ] - - private static var themesDirectory: URL { - URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("TablePro/Resources/Themes", isDirectory: true) - } - - private func editorColors(for id: String) throws -> [String: Any] { - let url = Self.themesDirectory.appendingPathComponent("\(id).json") - let data = try Data(contentsOf: url) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let editor = json?["editor"] as? [String: Any] - return try #require(editor) - } - - @Test("Every bundled theme declares currentStatementHighlight", arguments: themeIds) - func themeDeclaresStatementHighlight(id: String) throws { - let colors = try editorColors(for: id) - let value = colors["currentStatementHighlight"] as? String - #expect(value?.isEmpty == false, "\(id) omits currentStatementHighlight") - } - - /// A band the same colour as the caret line or the selection tells the reader nothing. Dracula and Nord already - /// set those two equal to each other, so the collision is live rather than hypothetical. - @Test("The statement highlight differs from the line highlight and the selection", arguments: themeIds) - func statementHighlightIsDistinct(id: String) throws { - let colors = try editorColors(for: id) - let statement = colors["currentStatementHighlight"] as? String - - #expect(statement != colors["currentLineHighlight"] as? String, "\(id): band matches the caret line") - #expect(statement != colors["selection"] as? String, "\(id): band matches the selection") - #expect(statement != colors["background"] as? String, "\(id): band matches the background") - } - - /// The band is painted under the caret line highlight, so it has to layer with it rather than cover it. - @Test("The statement highlight carries alpha", arguments: themeIds) - func statementHighlightCarriesAlpha(id: String) throws { - let colors = try editorColors(for: id) - let value = try #require(colors["currentStatementHighlight"] as? String) - - #expect(value.count == 9, "\(id): expected #RRGGBBAA, got \(value)") - } -} diff --git a/TableProTests/Theme/DataGridThemeColorsTests.swift b/TableProTests/Theme/DataGridThemeColorsTests.swift deleted file mode 100644 index d3a745e32b..0000000000 --- a/TableProTests/Theme/DataGridThemeColorsTests.swift +++ /dev/null @@ -1,235 +0,0 @@ -// -// DataGridThemeColorsTests.swift -// TableProTests -// - -import AppKit -import Foundation -@testable import TablePro -import Testing - -/// Activates themes on the shared `ThemeEngine`. Every body that does is synchronous and -/// `@MainActor`, so nothing else on the main actor can run between activating a test theme and -/// restoring the original one. -@Suite("Data grid theme colors", .serialized) -@MainActor -struct DataGridThemeColorsTests { - private static let systemDefaultedKeys = [ - "background", "text", "alternateRow", "nullValue", "boolTrue", "boolFalse", "rowNumber", - ] - - private static var repositoryRoot: URL { - URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - } - - private func sRGB(_ color: NSColor?) -> NSColor? { - color?.usingColorSpace(.sRGB) - } - - private func matches(_ lhs: NSColor?, _ hex: String) -> Bool { - guard let lhs = sRGB(lhs), let rhs = sRGB(hex.nsColor) else { return false } - return abs(lhs.redComponent - rhs.redComponent) < 0.01 - && abs(lhs.greenComponent - rhs.greenComponent) < 0.01 - && abs(lhs.blueComponent - rhs.blueComponent) < 0.01 - && abs(lhs.alphaComponent - rhs.alphaComponent) < 0.01 - } - - private func withTheme(_ configure: (inout DataGridThemeColors) -> Void, _ body: () throws -> Void) rethrows { - let engine = ThemeEngine.shared - let original = engine.activeTheme - defer { engine.activateTheme(original) } - var theme = ThemeDefinition.default - theme.id = "test.grid-colors" - configure(&theme.dataGrid) - engine.activateTheme(theme) - try body() - } - - // MARK: - Decoding - - @Test("An undeclared grid color decodes as absent, a state tint as the default") - func emptyGroupDecodes() throws { - let decoded = try JSONDecoder().decode(DataGridThemeColors.self, from: Data("{}".utf8)) - - #expect(decoded == DataGridThemeColors.defaultLight) - #expect(decoded.background == nil) - #expect(decoded.boolTrue == nil) - #expect(decoded.modified == DataGridThemeColors.defaultLight.modified) - } - - @Test("A theme written with focusBorder still decodes, and the key is dropped on save") - func focusBorderIsIgnored() throws { - let json = """ - { "background": "#282A36", "text": "#F8F8F2", "focusBorder": "#BD93F9", "deleted": "#FF555526" } - """ - let decoded = try JSONDecoder().decode(DataGridThemeColors.self, from: Data(json.utf8)) - let encoded = try #require(String(data: JSONEncoder().encode(decoded), encoding: .utf8)) - - #expect(decoded.background == "#282A36") - #expect(decoded.text == "#F8F8F2") - #expect(decoded.deleted == "#FF555526") - #expect(!encoded.contains("focusBorder")) - } - - @Test("An undeclared grid color is left out of the saved file") - func undeclaredColorsAreNotWritten() throws { - let encoded = try #require(String(data: JSONEncoder().encode(DataGridThemeColors.defaultLight), encoding: .utf8)) - - for key in Self.systemDefaultedKeys { - #expect(!encoded.contains("\"\(key)\""), "\(key) was written") - } - } - - // MARK: - Resolution - - @Test("An undeclared grid color resolves to the color the grid draws without a theme") - func undeclaredColorsResolveToSystem() { - let resolved = ResolvedDataGridColors(from: .defaultLight) - - #expect(resolved.background == .controlBackgroundColor) - #expect(resolved.text == .labelColor) - #expect(resolved.alternateRow == NSColor.alternatingContentBackgroundColors.last) - #expect(resolved.nullValue == .secondaryLabelColor) - #expect(resolved.rowNumber == .secondaryLabelColor) - #expect(resolved.boolTrue == nil) - #expect(resolved.boolFalse == nil) - } - - @Test("A declared grid color resolves to its own value") - func declaredColorsResolve() { - var colors = DataGridThemeColors.defaultLight - colors.background = "#102030" - colors.text = "#405060" - colors.alternateRow = "#708090" - colors.nullValue = "#A0B0C0" - colors.boolTrue = "#00FF00" - colors.boolFalse = "#FF0000" - colors.rowNumber = "#123456" - let resolved = ResolvedDataGridColors(from: colors) - - #expect(matches(resolved.background, "#102030")) - #expect(matches(resolved.text, "#405060")) - #expect(matches(resolved.alternateRow, "#708090")) - #expect(matches(resolved.nullValue, "#A0B0C0")) - #expect(matches(resolved.boolTrue, "#00FF00")) - #expect(matches(resolved.boolFalse, "#FF0000")) - #expect(matches(resolved.rowNumber, "#123456")) - } - - // MARK: - The grid reads them - - @Test("The table and its stripes take the theme's background and alternate row") - func stripesFollowTheTheme() { - withTheme({ - $0.background = "#102030" - $0.alternateRow = "#708090" - }) { - let tableView = NSTableView() - tableView.usesAlternatingRowBackgroundColors = true - DataGridBodyChrome.applyBackground(to: tableView) - - #expect(matches(tableView.backgroundColor, "#102030")) - #expect(matches(DataGridBodyChrome.stripeColor(forRow: 0, of: tableView), "#102030")) - #expect(matches(DataGridBodyChrome.stripeColor(forRow: 1, of: tableView), "#708090")) - #expect(matches(DataGridBodyChrome.rowBackgroundColor(forRow: 3, of: tableView), "#708090")) - - tableView.usesAlternatingRowBackgroundColors = false - #expect(DataGridBodyChrome.stripeColor(forRow: 1, of: tableView) == nil) - #expect(matches(DataGridBodyChrome.rowBackgroundColor(forRow: 1, of: tableView), "#102030")) - } - } - - @Test("Without grid colors the stripes are the pair NSTableView hands its rows") - func stripesFallBackToSystem() { - withTheme({ _ in }) { - let tableView = NSTableView() - tableView.usesAlternatingRowBackgroundColors = true - DataGridBodyChrome.applyBackground(to: tableView) - let system = NSColor.alternatingContentBackgroundColors - - #expect(tableView.backgroundColor == .controlBackgroundColor) - #expect(DataGridBodyChrome.stripeColor(forRow: 0, of: tableView) == system.first) - #expect(DataGridBodyChrome.stripeColor(forRow: 1, of: tableView) == system.last) - } - } - - @Test("Row numbers take the theme's color, and a deleted row's the deleted text") - func rowNumberColor() { - withTheme({ - $0.rowNumber = "#123456" - $0.deletedText = "#FF000080" - }) { - let registry = DataGridCellRegistry() - let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []) - - #expect(matches(registry.rowNumberColor(for: .empty), "#123456")) - #expect(matches(registry.rowNumberColor(for: deleted), "#FF000080")) - } - } - - @Test("The cell palette carries the theme's text, NULL and boolean colors") - func paletteFollowsTheTheme() { - withTheme({ - $0.text = "#405060" - $0.nullValue = "#A0B0C0" - $0.boolTrue = "#00FF00" - }) { - let palette = ThemeEngine.shared.dataGridCellPalette - - #expect(matches(palette.text, "#405060")) - #expect(matches(palette.placeholderText, "#A0B0C0")) - #expect(matches(palette.booleanTrueText, "#00FF00")) - #expect(palette.booleanFalseText == nil) - } - } - - /// Eight of these keys were declared, editable and shipped in every theme while the grid drew - /// system colors instead, so a theme could set them and see nothing change. - @Test("Every data grid theme color is read by the grid") - func everyKeyHasAGridReader() throws { - let keys = Mirror(reflecting: DataGridThemeColors.defaultLight).children.compactMap(\.label) - let resultsDirectory = Self.repositoryRoot.appendingPathComponent("TablePro/Views/Results", isDirectory: true) - let enumerator = try #require(FileManager.default.enumerator(at: resultsDirectory, includingPropertiesForKeys: nil)) - var source = "" - for case let url as URL in enumerator where url.pathExtension == "swift" { - source += try String(contentsOf: url, encoding: .utf8) - } - - #expect(!keys.isEmpty) - for key in keys { - #expect(source.contains("dataGrid.\(key)"), "No grid code reads the \(key) theme color") - } - } - - // MARK: - Bundled themes - - private func dataGridGroup(ofTheme id: String) throws -> [String: Any] { - let url = Self.repositoryRoot.appendingPathComponent("TablePro/Resources/Themes/\(id).json") - let json = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any] - return try #require(json?["dataGrid"] as? [String: Any]) - } - - @Test("The default themes leave the grid on system colors", arguments: [ - "tablepro.default-light", "tablepro.default-dark", - ]) - func defaultThemesDeclareNoGridColors(id: String) throws { - let group = try dataGridGroup(ofTheme: id) - - for key in Self.systemDefaultedKeys + ["focusBorder"] { - #expect(group[key] == nil, "\(id) declares \(key)") - } - } - - @Test("The designed themes declare every grid color", arguments: ["tablepro.dracula", "tablepro.nord"]) - func designedThemesDeclareGridColors(id: String) throws { - let group = try dataGridGroup(ofTheme: id) - - for key in Self.systemDefaultedKeys { - #expect((group[key] as? String)?.isEmpty == false, "\(id) omits \(key)") - } - #expect(group["focusBorder"] == nil) - } -} diff --git a/TableProTests/Theme/ThemeDefinitionTests.swift b/TableProTests/Theme/ThemeDefinitionTests.swift deleted file mode 100644 index e65969ded1..0000000000 --- a/TableProTests/Theme/ThemeDefinitionTests.swift +++ /dev/null @@ -1,206 +0,0 @@ -// -// ThemeDefinitionTests.swift -// TableProTests -// -// Tests for ThemeDefinition and EditorThemeColors, focusing on the -// currentStatementHighlight field and Codable backward compatibility. -// - -import AppKit -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -struct ThemeDefinitionTests { - // MARK: - Default light theme - - /// The band is painted under the caret line highlight, so it carries alpha rather than being opaque. An opaque - /// value covers the caret line instead of layering with it. - @Test("Default light editor colors include currentStatementHighlight") - func defaultLightHasCurrentStatementHighlight() { - let colors = EditorThemeColors.defaultLight - #expect(colors.currentStatementHighlight == "#0A0A0A0F") - } - - @Test("Default light editor colors have expected background") - func defaultLightBackground() { - let colors = EditorThemeColors.defaultLight - #expect(colors.background == "#FFFFFF") - } - - // MARK: - Codable round-trip - - @Test("EditorThemeColors survives encode-decode round-trip") - func editorThemeColorsRoundTrip() throws { - let original = EditorThemeColors.defaultLight - let encoder = JSONEncoder() - let data = try encoder.encode(original) - let decoder = JSONDecoder() - let decoded = try decoder.decode(EditorThemeColors.self, from: data) - - #expect(decoded.currentStatementHighlight == original.currentStatementHighlight) - #expect(decoded.background == original.background) - #expect(decoded.text == original.text) - #expect(decoded.cursor == original.cursor) - #expect(decoded.currentLineHighlight == original.currentLineHighlight) - #expect(decoded.selection == original.selection) - #expect(decoded.lineNumber == original.lineNumber) - #expect(decoded.invisibles == original.invisibles) - #expect(decoded == original) - } - - @Test("Full ThemeDefinition survives encode-decode round-trip") - func themeDefinitionRoundTrip() throws { - let original = ThemeDefinition.default - let encoder = JSONEncoder() - let data = try encoder.encode(original) - let decoder = JSONDecoder() - let decoded = try decoder.decode(ThemeDefinition.self, from: data) - - #expect(decoded.editor.currentStatementHighlight == original.editor.currentStatementHighlight) - #expect(decoded == original) - } - - // MARK: - Backward compatibility - - @Test("Decoding JSON missing currentStatementHighlight falls back to default") - func backwardCompatibilityMissingField() throws { - // JSON with all editor fields EXCEPT currentStatementHighlight - let json = """ - { - "background": "#1E1E1E", - "text": "#D4D4D4", - "cursor": "#AEAFAD", - "currentLineHighlight": "#2A2D2E", - "selection": "#264F78", - "lineNumber": "#858585", - "invisibles": "#3B3B3B", - "syntax": { - "keyword": "#569CD6", - "string": "#CE9178", - "number": "#B5CEA8", - "comment": "#6A9955", - "null": "#569CD6", - "operator": "#D4D4D4", - "function": "#DCDCAA", - "type": "#4EC9B0" - } - } - """ - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(EditorThemeColors.self, from: data) - - // Should fall back to defaultLight's value - #expect(decoded.currentStatementHighlight == EditorThemeColors.defaultLight.currentStatementHighlight) - // Other fields should use the provided values - #expect(decoded.background == "#1E1E1E") - #expect(decoded.text == "#D4D4D4") - } - - @Test("Decoding empty JSON falls back to all defaults") - func emptyJsonFallsBackToDefaults() throws { - let json = "{}" - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(EditorThemeColors.self, from: data) - - #expect(decoded == EditorThemeColors.defaultLight) - } - - @Test("Decoding JSON with currentStatementHighlight preserves custom value") - func customCurrentStatementHighlight() throws { - let json = """ - { - "background": "#FFFFFF", - "text": "#000000", - "cursor": "#000000", - "currentLineHighlight": "#ECF5FF", - "selection": "#B4D8FD", - "lineNumber": "#747478", - "invisibles": "#D6D6D6", - "currentStatementHighlight": "#AABBCC", - "syntax": { - "keyword": "#9B2393", - "string": "#C41A16", - "number": "#1C00CF", - "comment": "#5D6C79", - "null": "#9B2393", - "operator": "#000000", - "function": "#326D74", - "type": "#3F6E74" - } - } - """ - let data = Data(json.utf8) - let decoded = try JSONDecoder().decode(EditorThemeColors.self, from: data) - - #expect(decoded.currentStatementHighlight == "#AABBCC") - } - - // MARK: - Editor font resolver - - @Test("Font resolver always exposes System Mono") - func resolverExposesSystemMono() { - let families = EditorFontResolver.availableMonospacedFamilies - #expect(families.contains { $0.id == EditorFontResolver.systemMonoId }) - } - - @Test("System Mono is first in picker list") - func systemMonoFirst() { - let families = EditorFontResolver.availableMonospacedFamilies - #expect(families.first?.id == EditorFontResolver.systemMonoId) - } - - @Test("Editor font cache falls back for unknown font family") - func editorCacheFallsBackForUnknownFamily() { - let fonts = ThemeFonts( - editorFontFamily: "NoSuchFamily-XYZ", - editorFontSize: 13, - dataGridFontFamily: "System Mono", - dataGridFontSize: 13 - ) - let cache = EditorFontCache(from: fonts) - #expect(cache.font.pointSize > 0) - } - - @Test("Data grid cache falls back for unknown font family") - func dataGridCacheFallsBackForUnknownFamily() { - let fonts = ThemeFonts( - editorFontFamily: "System Mono", - editorFontSize: 13, - dataGridFontFamily: "NoSuchFamily-XYZ", - dataGridFontSize: 13 - ) - let cache = DataGridFontCacheResolved(from: fonts) - #expect(cache.regular.pointSize > 0) - #expect(cache.monoCharWidth > 0) - } - - @Test("Resolver list has unique IDs") - func resolverListHasUniqueIds() { - let ids = EditorFontResolver.availableMonospacedFamilies.map(\.id) - #expect(Set(ids).count == ids.count) - } - - @Test("Unknown family reports unavailable") - func unknownFamilyUnavailable() { - #expect(EditorFontResolver.isAvailable(familyId: "NoSuchFamily-XYZ") == false) - } - - @Test("ThemeFonts decode keeps legacy family strings") - func themeFontsDecodeKeepsLegacyStrings() throws { - let json = #"{"editorFontFamily":"Menlo","editorFontSize":13,"dataGridFontFamily":"Monaco","dataGridFontSize":13}"# - let decoded = try JSONDecoder().decode(ThemeFonts.self, from: Data(json.utf8)) - #expect(decoded.editorFontFamily == "Menlo") - #expect(decoded.dataGridFontFamily == "Monaco") - } - - @Test("All resolver font families are monospaced") - func allResolverFamiliesAreMonospaced() { - let families = EditorFontResolver.availableMonospacedFamilies - for family in families where family.id != EditorFontResolver.systemMonoId { - let font = EditorFontResolver.resolve(familyId: family.id, size: 12) - #expect(font.fontDescriptor.symbolicTraits.contains(.monoSpace)) - } - } -} diff --git a/TableProTests/Theme/ThemeDocumentTests.swift b/TableProTests/Theme/ThemeDocumentTests.swift new file mode 100644 index 0000000000..9e2e54026e --- /dev/null +++ b/TableProTests/Theme/ThemeDocumentTests.swift @@ -0,0 +1,180 @@ +// +// ThemeDocumentTests.swift +// TableProTests +// +// The previous decoder read every key with `decodeIfPresent` and a Default Light fallback, so an +// empty object and a VS Code theme both decoded "successfully" and rendered as Default Light under +// the file's own name. These pin the gate that replaced it. +// + +import Foundation +@testable import TablePro +import Testing + +struct ThemeDocumentTests { + private static func json(_ object: [String: Any]) -> Data { + // swiftlint:disable:next force_try + try! JSONSerialization.data(withJSONObject: object) + } + + private static func completeTheme(appearance: ThemeAppearance = .dark) -> [String: Any] { + var root: [String: Any] = [ + "schema": ThemeSchema.current, + "id": "user.test", + "name": "Test", + "author": "Tests", + "appearance": appearance.rawValue, + ] + + for slot in ThemeSlot.allCases { + var cursor = root + insert(slot.rawValue.components(separatedBy: "."), value: "#112233", into: &cursor) + root = cursor + } + + return root + } + + private static func insert(_ path: [String], value: String, into container: inout [String: Any]) { + guard let head = path.first else { return } + guard path.count > 1 else { + container[head] = value + return + } + var child = container[head] as? [String: Any] ?? [:] + insert(Array(path.dropFirst()), value: value, into: &child) + container[head] = child + } + + @Test("A complete document decodes") + func completeDocumentDecodes() throws { + let document = try ThemeDocument(data: Self.json(Self.completeTheme())) + #expect(document.id == "user.test") + #expect(document.appearance == .dark) + #expect(document.colors.count == ThemeSlot.allCases.count) + } + + @Test("An empty object is rejected") + func emptyObjectIsRejected() { + #expect(throws: ThemeLoadError.missingSchema) { + try ThemeDocument(data: Self.json([:])) + } + } + + @Test("A file in the old format is rejected") + func oldSchemaIsRejected() { + let old: [String: Any] = [ + "version": 1, + "id": "user.old", + "name": "Old", + "appearance": "dark", + "editor": ["background": "#000000"], + ] + + #expect(throws: ThemeLoadError.missingSchema) { + try ThemeDocument(data: Self.json(old)) + } + } + + @Test("A schema from a newer TablePro is rejected") + func newerSchemaIsRejected() { + var root = Self.completeTheme() + root["schema"] = ThemeSchema.current + 1 + + #expect(throws: ThemeLoadError.schemaTooNew(found: ThemeSchema.current + 1, supported: ThemeSchema.current)) { + try ThemeDocument(data: Self.json(root)) + } + } + + @Test("A missing slot is reported, not filled in silently") + func missingSlotIsRejected() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + var editor = try #require(content["editor"] as? [String: Any]) + editor.removeValue(forKey: "cursor") + content["editor"] = editor + root["content"] = content + + #expect(throws: ThemeLoadError.missingSlots([ThemeSlot.editorCursor.rawValue])) { + try ThemeDocument(data: Self.json(root)) + } + } + + @Test("A key TablePro does not use is reported") + func unknownKeyIsRejected() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + content["sidebar"] = ["background": "#000000"] + root["content"] = content + + #expect(throws: ThemeLoadError.unknownKeys(["content.sidebar.background"])) { + try ThemeDocument(data: Self.json(root)) + } + } + + /// A valid prefix used to satisfy the parser, so `#FF79CG` rendered as `#0FF79C` with nothing + /// logged and nothing shown. + @Test("A color with a typo in it is rejected") + func malformedColorIsRejected() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + var editor = try #require(content["editor"] as? [String: Any]) + editor["background"] = "#FF79CG" + content["editor"] = editor + root["content"] = content + + #expect(throws: ThemeLoadError.invalidColor("#FF79CG")) { + try ThemeDocument(data: Self.json(root)) + } + } + + @Test("An unknown system color name is rejected") + func unknownSystemColorIsRejected() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + var editor = try #require(content["editor"] as? [String: Any]) + editor["background"] = "system:notARealColor" + content["editor"] = editor + root["content"] = content + + #expect(throws: ThemeLoadError.unknownSystemColor("notARealColor")) { + try ThemeDocument(data: Self.json(root)) + } + } + + @Test("A system color reference round-trips") + func systemColorRoundTrips() throws { + var root = Self.completeTheme() + var content = try #require(root["content"] as? [String: Any]) + var editor = try #require(content["editor"] as? [String: Any]) + editor["background"] = "system:textBackground" + content["editor"] = editor + root["content"] = content + + let theme = try ThemeDocument(data: Self.json(root)).resolved() + #expect(theme.editor.background == .system(.textBackground)) + + let reread = try ThemeDocument(data: ThemeEncoder.data(for: theme)).resolved() + #expect(reread.editor.background == .system(.textBackground)) + } + + @Test("Every slot round-trips through the encoder") + func everySlotRoundTrips() throws { + let theme = try ThemeDocument(data: Self.json(Self.completeTheme())).resolved() + let reread = try ThemeDocument(data: ThemeEncoder.data(for: theme)).resolved() + + for slot in ThemeSlot.allCases { + #expect(reread[keyPath: slot.keyPath] == theme[keyPath: slot.keyPath], "\(slot.rawValue)") + } + } + + @Test("Hex parsing consumes the whole string") + func hexParsingIsStrict() { + #expect(HexColor.canonicalize("#ff79c6") == "#FF79C6") + #expect(HexColor.canonicalize("#FF79C680") == "#FF79C680") + #expect(HexColor.canonicalize("#FF79CG") == nil) + #expect(HexColor.canonicalize("FF79C6") == nil) + #expect(HexColor.canonicalize("#FFF") == nil) + #expect(HexColor.canonicalize("") == nil) + } +} diff --git a/TableProTests/Theme/ThemeSlotCoverageTests.swift b/TableProTests/Theme/ThemeSlotCoverageTests.swift new file mode 100644 index 0000000000..83fd5a328e --- /dev/null +++ b/TableProTests/Theme/ThemeSlotCoverageTests.swift @@ -0,0 +1,114 @@ +// +// ThemeSlotCoverageTests.swift +// TableProTests +// +// Whole colour groups shipped declared, editable and read by nothing: `sidebar` and `toolbar` had +// no reader at all, `ui` had one, and `ui.accentColor` was declared by every bundled theme and had +// no property to decode into. A slot exists here only because a call site reads it. +// + +import Foundation +@testable import TablePro +import Testing + +struct ThemeSlotCoverageTests { + private static let repositoryRoot: URL = { + var url = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 3 { + url.deleteLastPathComponent() + } + return url + }() + + /// The files that declare the slots. Everything else counts as a reader, including + /// `ThemeEngine.makeEditorTheme`, which is where the editor and syntax slots are read. + private static let definitionFiles: Set = [ + "ThemeDefinition.swift", "BuiltInThemes.swift", "ThemeColorValue.swift", + "ThemeDocument.swift", "ThemePalette.swift", "ThemeCatalog.swift", "ThemeResolver.swift", + ] + + private static let appSource: String = { + let directory = repositoryRoot.appendingPathComponent("TablePro", isDirectory: true) + guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else { + return "" + } + + var source = "" + for case let url as URL in enumerator where url.pathExtension == "swift" { + guard !Self.definitionFiles.contains(url.lastPathComponent) else { continue } + source += (try? String(contentsOf: url, encoding: .utf8)) ?? "" + } + return source + }() + + @Test("Every slot is read by app code") + func everySlotHasAReader() { + #expect(!Self.appSource.isEmpty) + + for slot in ThemeSlot.allCases { + #expect(Self.appSource.contains(".\(String(describing: slot))"), "Nothing reads \(slot.rawValue)") + } + } + + @Test("Slot paths are unique and namespaced") + func slotPathsAreWellFormed() { + let paths = ThemeSlot.allCases.map(\.rawValue) + #expect(Set(paths).count == paths.count) + + for path in paths { + #expect(path.hasPrefix("content."), "\(path)") + #expect(path.components(separatedBy: ".").count >= 3, "\(path)") + } + } + + @Test("Every slot belongs to exactly one group") + func groupsPartitionTheSlots() { + let grouped = ThemeSlotGroup.allCases.flatMap(\.slots) + #expect(Set(grouped) == Set(ThemeSlot.allCases)) + #expect(grouped.count == ThemeSlot.allCases.count) + } + + // MARK: - Bundled themes + + @Test("Every bundled theme passes the gate", arguments: ["tablepro.dracula", "tablepro.nord"]) + func bundledThemesPassTheGate(id: String) throws { + let url = Self.repositoryRoot.appendingPathComponent("TablePro/Resources/Themes/\(id).json") + let document = try ThemeDocument(data: try Data(contentsOf: url)) + + #expect(document.id == id) + #expect(document.colors.count == ThemeSlot.allCases.count) + } + + /// The bundled themes are the only ones shipped, so a hand-typed colour that never renders is + /// caught here rather than by a user. + @Test("Bundled themes declare a real colour for every slot", arguments: ["tablepro.dracula", "tablepro.nord"]) + func bundledThemesDeclareHexColors(id: String) throws { + let url = Self.repositoryRoot.appendingPathComponent("TablePro/Resources/Themes/\(id).json") + let theme = try ThemeDocument(data: try Data(contentsOf: url)).resolved() + + for slot in ThemeSlot.allCases { + let value = theme[keyPath: slot.keyPath] + #expect(!value.isSystem, "\(id) leaves \(slot.rawValue) on a system colour") + } + } + + /// Default Light and Default Dark keep the surrounds on system colours so the unthemed app is + /// unchanged and keeps the system's Increase Contrast handling. + @Test("The default themes keep the grid surrounds on system colours") + func defaultThemesUseSystemSurrounds() { + for theme in BuiltInThemes.all { + #expect(theme.dataGrid.background.isSystem) + #expect(theme.dataGrid.text.isSystem) + #expect(theme.dataGrid.alternateRow.isSystem) + #expect(theme.dataGrid.selection.isSystem) + #expect(theme.editor.background.isSystem == false) + } + } + + @Test("The default themes declare the appearance of their slot") + func defaultThemesDeclareTheirAppearance() { + #expect(BuiltInThemes.light.appearance == .light) + #expect(BuiltInThemes.dark.appearance == .dark) + #expect(BuiltInThemes.default(for: .dark).id == BuiltInThemes.defaultDarkId) + } +} diff --git a/TableProTests/Theme/ThemeSlotValidationTests.swift b/TableProTests/Theme/ThemeSlotValidationTests.swift index 242955efaa..57aae11bff 100644 --- a/TableProTests/Theme/ThemeSlotValidationTests.swift +++ b/TableProTests/Theme/ThemeSlotValidationTests.swift @@ -19,27 +19,20 @@ struct ThemeSlotValidationTests { #expect(ThemeSlotValidation.fits(.light, slot: .dark) == false) } - @Test("An auto theme fits both slots") - func autoFitsBoth() { - #expect(ThemeSlotValidation.fits(.auto, slot: .light)) - #expect(ThemeSlotValidation.fits(.auto, slot: .dark)) - } - private func theme(_ id: String, _ appearance: ThemeAppearance) -> ThemeDefinition { - var copy = ThemeDefinition.default + var copy = BuiltInThemes.default(for: appearance) copy.id = id - copy.appearance = appearance return copy } private var sample: [ThemeDefinition] { - [theme("light", .light), theme("dark", .dark), theme("auto", .auto)] + [theme("light", .light), theme("dark", .dark)] } @Test("Only fitting themes stay in the list") func listIsFiltered() { let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: nil) - #expect(eligible.map(\.id) == ["light", "auto"]) + #expect(eligible.map(\.id) == ["light"]) } /// The row the user is standing on can never be filtered away, because the alternative was to @@ -47,18 +40,56 @@ struct ThemeSlotValidationTests { @Test("A contradicting theme stays listed while it is the one selected") func selectedContradictingThemeIsKept() { let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: "dark") - #expect(eligible.map(\.id) == ["light", "dark", "auto"]) + #expect(eligible.map(\.id) == ["light", "dark"]) } @Test("Keeping a selection does not duplicate a theme that already fits") func keptSelectionIsNotDuplicated() { let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: "light") - #expect(eligible.map(\.id) == ["light", "auto"]) + #expect(eligible.map(\.id) == ["light"]) } @Test("An unknown selected id adds nothing to the list") func unknownSelectionAddsNothing() { let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .dark, keeping: "does.not.exist") - #expect(eligible.map(\.id) == ["dark", "auto"]) + #expect(eligible.map(\.id) == ["dark"]) + } + + /// A slot whose theme is missing, rejected, or of the wrong appearance falls back to that + /// slot's own built-in. Falling back to Default Light whichever slot asked painted a white + /// editor inside dark chrome. + @Test("A dark slot that cannot resolve falls back to Default Dark") + func darkSlotFallsBackToDefaultDark() { + let selection = ThemeResolver.resolve( + mode: .dark, + lightThemeId: BuiltInThemes.defaultLightId, + darkThemeId: "user.deleted", + themes: BuiltInThemes.all, + systemIsDark: true + ) + + #expect(selection.pair.dark.id == BuiltInThemes.defaultDarkId) + #expect(selection.active.appearance == .dark) + } + + @Test("A light theme sitting in the dark slot falls back to Default Dark") + func misfitThemeInDarkSlotFallsBack() { + let selection = ThemeResolver.resolve( + mode: .dark, + lightThemeId: BuiltInThemes.defaultLightId, + darkThemeId: BuiltInThemes.defaultLightId, + themes: BuiltInThemes.all, + systemIsDark: true + ) + + #expect(selection.pair.dark.id == BuiltInThemes.defaultDarkId) + } + + @Test("Auto follows the system appearance") + func autoFollowsSystem() { + #expect(ThemeResolver.effectiveAppearance(mode: .auto, systemIsDark: true) == .dark) + #expect(ThemeResolver.effectiveAppearance(mode: .auto, systemIsDark: false) == .light) + #expect(ThemeResolver.effectiveAppearance(mode: .light, systemIsDark: true) == .light) + #expect(ThemeResolver.effectiveAppearance(mode: .dark, systemIsDark: false) == .dark) } } diff --git a/TableProTests/Theme/ValueFontTests.swift b/TableProTests/Theme/ValueFontTests.swift index faa5b58c63..c52eb3a836 100644 --- a/TableProTests/Theme/ValueFontTests.swift +++ b/TableProTests/Theme/ValueFontTests.swift @@ -13,10 +13,10 @@ import Foundation @testable import TablePro import Testing -/// The first two tests activate a theme on the shared `ThemeEngine`. What keeps that from reaching a +/// The first two tests change the fonts on the shared `ThemeEngine`. What keeps that from reaching a /// suite running in parallel is that both bodies are synchronous and `@MainActor`, so nothing else on -/// the main actor can interleave between activating the test theme and restoring the original one, the -/// same way `DataGridRowTintThemeTests` holds. Adding an `await` inside `withTheme` would break it. +/// the main actor can interleave between applying the test fonts and restoring the original ones, the +/// same way `DataGridRowTintThemeTests` holds. Adding an `await` inside `withTypography` breaks it. @Suite("Stored value font", .serialized) @MainActor struct ValueFontTests { @@ -28,30 +28,27 @@ struct ValueFontTests { return url }() - private static func theme(editorSize: Int, gridSize: Int) -> ThemeDefinition { - var theme = ThemeDefinition.default - theme.id = "user.value-font-tests" - theme.fonts = ThemeFonts( + private static func typography(editorSize: Int, gridSize: Int) -> TypographySettings { + TypographySettings( editorFontFamily: "Menlo", editorFontSize: editorSize, dataGridFontFamily: "Courier", dataGridFontSize: gridSize ) - return theme } - private func withTheme(_ theme: ThemeDefinition, _ body: () -> Void) { - let previous = ThemeEngine.shared.activeTheme - ThemeEngine.shared.activateTheme(theme) + private func withTypography(_ typography: TypographySettings, _ body: () -> Void) { + let previous = AppSettingsManager.shared.typography + ThemeEngine.shared.apply(typography: typography) body() - ThemeEngine.shared.activateTheme(previous) + ThemeEngine.shared.apply(typography: previous) } // MARK: - Which setting the value font comes from @Test("The value font is the data grid font, not the editor font") func valueFontFollowsTheDataGridFont() { - withTheme(Self.theme(editorSize: 18, gridSize: 11)) { + withTypography(Self.typography(editorSize: 18, gridSize: 11)) { let engine = ThemeEngine.shared #expect(engine.valueFont == engine.dataGridFonts.regular) #expect(engine.valueFont != engine.editorFonts.font) @@ -65,8 +62,8 @@ struct ValueFontTests { func valueFontIgnoresTheEditorSize() { var afterSmall: NSFont? var afterLarge: NSFont? - withTheme(Self.theme(editorSize: 11, gridSize: 13)) { afterSmall = ThemeEngine.shared.valueFont } - withTheme(Self.theme(editorSize: 18, gridSize: 13)) { afterLarge = ThemeEngine.shared.valueFont } + withTypography(Self.typography(editorSize: 11, gridSize: 13)) { afterSmall = ThemeEngine.shared.valueFont } + withTypography(Self.typography(editorSize: 18, gridSize: 13)) { afterLarge = ThemeEngine.shared.valueFont } #expect(afterSmall == afterLarge) } diff --git a/TableProTests/Views/Compare/CompareRowGridModelTests.swift b/TableProTests/Views/Compare/CompareRowGridModelTests.swift index 3d8ed59ef0..c4316cdccf 100644 --- a/TableProTests/Views/Compare/CompareRowGridModelTests.swift +++ b/TableProTests/Views/Compare/CompareRowGridModelTests.swift @@ -579,8 +579,8 @@ final class CompareRowGridModelTests: XCTestCase { mediumFont: .systemFont(ofSize: 13, weight: .medium), text: .labelColor, placeholderText: .secondaryLabelColor, - booleanTrueText: nil, - booleanFalseText: nil, + booleanTrueText: .labelColor, + booleanFalseText: .labelColor, rowNumberText: .secondaryLabelColor, deletedRowText: .systemRed, modifiedColumnTint: .systemYellow, diff --git a/TableProTests/Views/Editor/EditorThemeBridgeTests.swift b/TableProTests/Views/Editor/EditorThemeBridgeTests.swift index f231d5f39a..27b975013a 100644 --- a/TableProTests/Views/Editor/EditorThemeBridgeTests.swift +++ b/TableProTests/Views/Editor/EditorThemeBridgeTests.swift @@ -15,11 +15,11 @@ struct EditorThemeBridgeTests { @MainActor @Test("The theme's operator and function colours reach the editor") func themeCarriesOperatorAndFunctionColors() { - let colors = ThemeEngine.shared.colors.editor + let resolved = ThemeEngine.shared.resolved let theme = ThemeEngine.shared.makeEditorTheme() - #expect(Self.sameColor(theme.operators.color, colors.operator)) - #expect(Self.sameColor(theme.functions.color, colors.function)) + #expect(Self.sameColor(theme.operators.color, resolved[.syntaxOperator])) + #expect(Self.sameColor(theme.functions.color, resolved[.syntaxFunction])) } private static func sameColor(_ lhs: NSColor, _ rhs: NSColor) -> Bool { diff --git a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift index 6edc41aaaf..cd977b4660 100644 --- a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift +++ b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift @@ -193,7 +193,7 @@ struct QueryDiagnosticMessageTests { let emphases = manager.getEmphases(for: QueryDiagnosticsController.emphasisGroup) #expect(emphases.map(\.range) == [range]) - #expect(emphases.first?.style == .underline(color: .systemOrange)) + #expect(emphases.first?.style == .underline(color: ThemeEngine.shared.palette[.statusWarning])) #expect( manager.toolTip(at: try center(of: range, in: controller)) == "Full-width semicolon (U+FF1B). SQL reads only ; as a statement separator." diff --git a/TableProTests/Views/Results/DataGridBodyChromeTests.swift b/TableProTests/Views/Results/DataGridBodyChromeTests.swift index 9e7b21af84..ee33d85faa 100644 --- a/TableProTests/Views/Results/DataGridBodyChromeTests.swift +++ b/TableProTests/Views/Results/DataGridBodyChromeTests.swift @@ -167,6 +167,15 @@ struct DataGridBodyChromeTests { #expect(!grid.coordinator.presentsColumn(atTableColumnIndex: rowNumber)) } + @Test("The theme's grid line becomes the table's grid colour, which the separators draw in") + func themeGridLineBecomesTheTableGridColor() { + let tableView = NSTableView() + DataGridBodyChrome.applyThemeColors(to: tableView) + + #expect(tableView.gridColor == ThemeEngine.shared.palette[.gridLine]) + #expect(tableView.backgroundColor == ThemeEngine.shared.palette[.gridBackground]) + } + /// The colour has to come from `tableView.gridColor`, which is the dynamic catalog colour AppKit /// was filling with, so an appearance change carries the separator with it and there is no /// second spelling to keep in sync. A hardcoded colour would pass a geometry test and be wrong diff --git a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift index 663b4927dc..c77153bd2b 100644 --- a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift +++ b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift @@ -20,8 +20,8 @@ struct DataGridCellAppearanceTests { mediumFont: .systemFont(ofSize: 13, weight: .medium), text: .labelColor, placeholderText: .secondaryLabelColor, - booleanTrueText: nil, - booleanFalseText: nil, + booleanTrueText: .labelColor, + booleanFalseText: .labelColor, rowNumberText: .secondaryLabelColor, deletedRowText: .systemRed, modifiedColumnTint: .systemYellow, diff --git a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift index c8e0ab1165..dc4503b99e 100644 --- a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift +++ b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift @@ -518,13 +518,16 @@ struct DataGridRowNumberRenderingTests { @Test("Rows paint the theme's stripes, and the strip still matches them") func rowsPaintTheThemesStripes() throws { let engine = ThemeEngine.shared - let original = engine.activeTheme - defer { engine.activateTheme(original) } - var theme = ThemeDefinition.default + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + var theme = BuiltInThemes.light theme.id = "test.grid-stripes" - theme.dataGrid.background = "#282A36" - theme.dataGrid.alternateRow = "#44475A" - engine.activateTheme(theme) + theme.dataGrid.background = .hex("#282A36") + theme.dataGrid.alternateRow = .hex("#44475A") + engine.adopt(ThemeSelection( + pair: ThemePair(light: theme, dark: BuiltInThemes.dark), + effectiveAppearance: .light + )) let grid = makeGrid(appearance: .aqua) let rows = grid.rowsUnderTheStrip diff --git a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift index 9997f2c42d..26e5ca3adc 100644 --- a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift +++ b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift @@ -68,24 +68,30 @@ struct DataGridRowTintThemeTests { return try #require(color.usingColorSpace(.sRGB)) } - private func theme(_ base: ThemeDefinition, id: String, deletedHex: String) -> ThemeDefinition { + private func selection(_ base: ThemeDefinition, id: String, deletedHex: String) -> ThemeSelection { var copy = base copy.id = id - copy.dataGrid.deleted = deletedHex - return copy + copy.dataGrid.deleted = .hex(deletedHex) + + let pair = copy.appearance == .dark + ? ThemePair(light: BuiltInThemes.light, dark: copy) + : ThemePair(light: copy, dark: BuiltInThemes.dark) + + return ThemeSelection(pair: pair, effectiveAppearance: copy.appearance) } @Test("A deleted row takes the new theme's tint even though its state did not change") func deletedRowTintFollowsThemeChange() throws { let engine = ThemeEngine.shared let original = engine.activeTheme - defer { engine.activateTheme(original) } + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } - engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) + engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) let harness = makeRowView(state: Self.deleted) let firstTint = try renderedTint(of: harness.rowView) - engine.activateTheme(theme(original, id: "test.tint.blue", deletedHex: "#0000FF")) + engine.adopt(selection(original, id: "test.tint.blue", deletedHex: "#0000FF")) harness.rowView.invalidateVisualState() let secondTint = try renderedTint(of: harness.rowView) @@ -97,8 +103,9 @@ struct DataGridRowTintThemeTests { func rowPaintsTheLiveState() throws { let engine = ThemeEngine.shared let original = engine.activeTheme - defer { engine.activateTheme(original) } - engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) let harness = makeRowView(state: .empty) let before = try renderedTint(of: harness.rowView) @@ -114,8 +121,9 @@ struct DataGridRowTintThemeTests { func pendingDeleteOutranksHighlightWash() throws { let engine = ThemeEngine.shared let original = engine.activeTheme - defer { engine.activateTheme(original) } - engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) let highlight = RowHighlight( rowRule: HighlightRule(columnName: "status", value: "paid", color: .blue), @@ -125,7 +133,7 @@ struct DataGridRowTintThemeTests { let tint = try renderedTint(of: harness.rowView) #expect(tint.redComponent > tint.blueComponent) - #expect(Self.deleted.highlighted(highlight).tint == engine.colors.dataGrid.deleted) + #expect(Self.deleted.highlighted(highlight).tint == engine.palette[.gridDeleted]) #expect(RowVisualState.empty.highlighted(highlight).tint == HighlightColor.blue.washColor) } @@ -133,13 +141,14 @@ struct DataGridRowTintThemeTests { func plainRowStaysUntinted() throws { let engine = ThemeEngine.shared let original = engine.activeTheme - defer { engine.activateTheme(original) } + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } - engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) + engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) let harness = makeRowView(state: .empty) let firstTint = try renderedTint(of: harness.rowView) - engine.activateTheme(theme(original, id: "test.tint.blue", deletedHex: "#0000FF")) + engine.adopt(selection(original, id: "test.tint.blue", deletedHex: "#0000FF")) harness.rowView.invalidateVisualState() let secondTint = try renderedTint(of: harness.rowView) diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift index 3c3344dda4..6212959d73 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift @@ -85,7 +85,7 @@ struct TreeOutlineCellViewTests { #expect(field.isSelectable) #expect(!field.allowsEditingTextAttributes) #expect(field.stringValue == "\"Acme\"") - #expect(field.textColor == .systemRed) + #expect(field.textColor == ThemeEngine.shared.palette[.syntaxString]) #expect(field.font == TreeOutlineFixture.fonts.value) #expect(cell.accessibilityCustomActions()?.isEmpty ?? true) } @@ -158,8 +158,8 @@ struct TreeOutlineCellViewTests { #expect(plain.valueField.textColor == .alternateSelectedControlTextColor) #expect(plain.keyField.textColor == .alternateSelectedControlTextColor) plain.backgroundStyle = .normal - #expect(plain.valueField.textColor == .systemPurple) - #expect(plain.keyField.textColor == .systemBlue) + #expect(plain.valueField.textColor == ThemeEngine.shared.palette[.syntaxNumber]) + #expect(plain.keyField.textColor == ThemeEngine.shared.palette[.syntaxKeyword]) let link = try cell("site") link.backgroundStyle = .emphasized @@ -168,7 +168,7 @@ struct TreeOutlineCellViewTests { link.backgroundStyle = .normal let normal = link.valueField.attributedStringValue #expect(normal.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor == .linkColor) - #expect(normal.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == .systemRed) + #expect(normal.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == ThemeEngine.shared.palette[.syntaxString]) } @Test("The accessibility action opens the link through the link policy") diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift index 360cc7584e..19f6625bb1 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift @@ -41,6 +41,30 @@ struct TreeOutlineCoordinatorTests { #expect(harness.keys == ["name", "site", "brand", "theme", "ink", "deep", "count"]) } + @Test("A theme change recolors the rows already on screen") + func themeChangeRecolorsVisibleRows() throws { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let row = try harness.row("theme") + let cell = try #require(harness.outline.view(atColumn: 0, row: row, makeIfNecessary: false) as? TreeOutlineCellView) + let before = try #require(cell.keyField.textColor) + + var changed = engine.activeTheme + changed.id = "test.tree.recolor" + changed.editor.syntax.keyword = .hex("#FF0000") + let pair = changed.appearance == .dark + ? ThemePair(light: engine.pair.light, dark: changed) + : ThemePair(light: changed, dark: engine.pair.dark) + engine.adopt(ThemeSelection(pair: pair, effectiveAppearance: changed.appearance)) + + let after = try #require(cell.keyField.textColor) + #expect(after !== before) + #expect(after == engine.palette[.syntaxKeyword]) + } + @Test("Applying the same content twice reloads nothing") func unchangedContentDoesNotReload() throws { let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) diff --git a/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift b/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift index 8c0a7d01b3..679f7f3aa0 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift @@ -33,8 +33,8 @@ struct TreeValueStyleTests { #expect(text.string == "\"https://example.com/docs\"") #expect(TreeValueStyle.linkRange(in: content) == NSRange(location: 1, length: length - 2)) - #expect(color(text, at: 0) == .systemRed) - #expect(color(text, at: length - 1) == .systemRed) + #expect(color(text, at: 0) == ThemeEngine.shared.palette[.syntaxString]) + #expect(color(text, at: length - 1) == ThemeEngine.shared.palette[.syntaxString]) #expect(!isUnderlined(text, at: 0)) #expect(!isUnderlined(text, at: length - 1)) for index in 1 ..< length - 1 { @@ -77,7 +77,14 @@ struct TreeValueStyleTests { @Test("A color and a plain value are one run in the tone color") func otherValuesAreOneRun() throws { - for (key, tone) in [("brand", NSColor.systemRed), ("name", .systemRed), ("count", .systemPurple), ("theme", .systemBlue)] { + let palette = ThemeEngine.shared.palette + let tones = [ + ("brand", palette[.syntaxString]), + ("name", palette[.syntaxString]), + ("count", palette[.syntaxNumber]), + ("theme", palette[.syntaxKeyword]) + ] + for (key, tone) in tones { let content = try content(key) let text = TreeValueStyle.attributedValue(content, font: font, isEmphasized: false) var range = NSRange() @@ -99,7 +106,7 @@ struct TreeValueStyleTests { } } #expect(TreeValueStyle.textColor(for: .number, isEmphasized: true) == .alternateSelectedControlTextColor) - #expect(TreeValueStyle.textColor(for: .number, isEmphasized: false) == .systemPurple) + #expect(TreeValueStyle.textColor(for: .number, isEmphasized: false) == ThemeEngine.shared.palette[.syntaxNumber]) } @Test("The value font and tail truncation are set on the whole string") diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index d20ff7f8aa..fc0a789c96 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -218,6 +218,20 @@ internal class UITestCase: XCTestCase { waitForPredicate(timeout: timeout) { element.exists && element.isHittable } } + /// macOS clicks an element where it is and never scrolls it into view first, so a row below + /// the fold of a scrolling pane is not hittable until the pane has been scrolled down to it. + /// The scroll goes to a point inside the content. Measured: scrolling a window element posts + /// the event at its top-left corner, over the title bar, and the pane does not move. + internal func scrollUntilHittable(_ element: XCUIElement, in container: XCUIElement, timeout: TimeInterval) -> Bool { + let content = container.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) + let deadline = Date(timeIntervalSinceNow: timeout) + while !(element.exists && element.isHittable) { + guard Date() < deadline else { return false } + content.scroll(byDeltaX: 0, deltaY: -400) + } + return true + } + /// Whether the keyboard is on this element. /// /// `hasFocus` is declared on `XCUIElementAttributes` in ObjC diff --git a/TableProUITests/VimNormalModeChordUITests.swift b/TableProUITests/VimNormalModeChordUITests.swift index bcc07fd108..f1e13d02c1 100644 --- a/TableProUITests/VimNormalModeChordUITests.swift +++ b/TableProUITests/VimNormalModeChordUITests.swift @@ -90,7 +90,10 @@ final class VimNormalModeChordUITests: UITestCase { editorPane.click() let vimToggle = settings.descendants(matching: .any).matching(identifier: "vim-mode-toggle").firstMatch - XCTAssertTrue(waitUntilHittable(vimToggle, timeout: 10), "The Editor pane must offer Vim mode") + XCTAssertTrue( + scrollUntilHittable(vimToggle, in: settings, timeout: 30), + "The Editor pane must offer Vim mode" + ) if isOn(vimToggle) != enabled { vimToggle.click() }