diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2ef973..2455db43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Theme registry distribution: browse, install, uninstall, and update community themes from the plugin registry (Settings > Plugins > Browse, filtered by Themes category) - Full theme engine with 9 built-in presets (Default Light/Dark, Dracula, Solarized Light/Dark, One Dark, GitHub Light/Dark, Nord) and custom theme support - Theme browser with visual preview cards in Settings > Appearance - Per-theme customization of all colors (editor syntax, data grid, UI, sidebar, toolbar) and fonts diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 9966e2c8..5e10e299 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -29,15 +29,22 @@ final class AppSettingsManager { var appearance: AppearanceSettings { didSet { storage.saveAppearance(appearance) - appearance.theme.apply() + ThemeEngine.shared.activateTheme(id: appearance.activeThemeId) + ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode) } } var editor: EditorSettings { didSet { storage.saveEditor(editor) - // Update cached theme values for thread-safe access - SQLEditorTheme.reloadFromSettings(editor) + // Update behavioral settings in ThemeEngine + ThemeEngine.shared.updateEditorSettings( + highlightCurrentLine: editor.highlightCurrentLine, + showLineNumbers: editor.showLineNumbers, + tabWidth: editor.clampedTabWidth, + autoIndent: editor.autoIndent, + wordWrap: editor.wordWrap + ) notifyChange(.editorSettingsDidChange) } } @@ -60,7 +67,6 @@ final class AppSettingsManager { storage.saveDataGrid(validated) // Update date formatting service with new format DateFormattingService.shared.updateFormat(validated.dateFormat) - DataGridFontCache.reloadFromSettings(validated) notifyChange(.dataGridSettingsDidChange) } } @@ -126,18 +132,25 @@ final class AppSettingsManager { self.keyboard = storage.loadKeyboard() self.ai = storage.loadAI() - // Apply appearance settings immediately - appearance.theme.apply() + // Apply language immediately general.language.apply() - // Load editor theme settings into cache (pass settings directly to avoid circular dependency) - SQLEditorTheme.reloadFromSettings(editor) + // ThemeEngine initializes itself from persisted theme ID + // Apply app-level appearance mode + ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode) + + // Sync editor behavioral settings to ThemeEngine + ThemeEngine.shared.updateEditorSettings( + highlightCurrentLine: editor.highlightCurrentLine, + showLineNumbers: editor.showLineNumbers, + tabWidth: editor.clampedTabWidth, + autoIndent: editor.autoIndent, + wordWrap: editor.wordWrap + ) // Initialize DateFormattingService with current format DateFormattingService.shared.updateFormat(dataGrid.dateFormat) - DataGridFontCache.reloadFromSettings(dataGrid) - // Observe system accessibility text size changes and re-apply editor fonts observeAccessibilityTextSizeChanges() } @@ -156,7 +169,7 @@ final class AppSettingsManager { /// Uses NSWorkspace.accessibilityDisplayOptionsDidChangeNotification which fires when the user /// changes settings in System Settings > Accessibility > Display (including the Text Size slider). private func observeAccessibilityTextSizeChanges() { - lastAccessibilityScale = SQLEditorTheme.accessibilityScaleFactor + lastAccessibilityScale = EditorFontCache.computeAccessibilityScale() accessibilityTextSizeObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil, @@ -164,17 +177,11 @@ final class AppSettingsManager { ) { [weak self] _ in Task { @MainActor [weak self] in guard let self else { return } - let newScale = SQLEditorTheme.accessibilityScaleFactor - // Only reload if the text size scale actually changed (this notification - // also fires for contrast, reduce motion, etc.) + 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))") - // Re-apply editor fonts with the updated accessibility scale factor - SQLEditorTheme.reloadFromSettings(editor) - DataGridFontCache.reloadFromSettings(dataGrid) - notifyChange(.dataGridSettingsDidChange) - // Notify the editor view to rebuild its configuration + ThemeEngine.shared.reloadFontCaches() NotificationCenter.default.post(name: .accessibilityTextSizeDidChange, object: self) } } diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index e47a85eb..8dc8f314 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -99,95 +99,62 @@ struct GeneralSettings: Codable, Equatable { // MARK: - Appearance Settings -/// App theme options -enum AppTheme: String, Codable, CaseIterable, Identifiable { - case system = "system" - case light = "light" - case dark = "dark" - - var id: String { rawValue } +/// Controls NSApp.appearance independent of the active theme. +enum AppAppearanceMode: String, Codable, CaseIterable { + case light + case dark + case auto var displayName: String { switch self { - case .system: return String(localized: "System") case .light: return String(localized: "Light") case .dark: return String(localized: "Dark") - } - } - - /// Apply this theme to the app - func apply() { - guard let app = NSApp else { return } - switch self { - case .system: - app.appearance = nil - case .light: - app.appearance = NSAppearance(named: .aqua) - case .dark: - app.appearance = NSAppearance(named: .darkAqua) + case .auto: return String(localized: "Auto") } } } -/// Accent color options -enum AccentColorOption: String, Codable, CaseIterable, Identifiable { - case system = "system" - case blue = "blue" - case purple = "purple" - case pink = "pink" - case red = "red" - case orange = "orange" - case yellow = "yellow" - case green = "green" - case graphite = "graphite" +/// Appearance settings +struct AppearanceSettings: Codable, Equatable { + var activeThemeId: String + var appearanceMode: AppAppearanceMode - var id: String { rawValue } + static let `default` = AppearanceSettings( + activeThemeId: "tablepro.default-light", + appearanceMode: .auto + ) - var displayName: String { - switch self { - case .system: return String(localized: "System") - case .blue: return String(localized: "Blue") - case .purple: return String(localized: "Purple") - case .pink: return String(localized: "Pink") - case .red: return String(localized: "Red") - case .orange: return String(localized: "Orange") - case .yellow: return String(localized: "Yellow") - case .green: return String(localized: "Green") - case .graphite: return String(localized: "Graphite") - } + init(activeThemeId: String = "tablepro.default-light", appearanceMode: AppAppearanceMode = .auto) { + self.activeThemeId = activeThemeId + self.appearanceMode = appearanceMode } - /// Color for display in settings picker (always returns a concrete color) - var color: Color { - switch self { - case .system: return .accentColor - case .blue: return .blue - case .purple: return .purple - case .pink: return .pink - case .red: return .red - case .orange: return .orange - case .yellow: return .yellow - case .green: return .green - case .graphite: return .gray + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Migration: try new field first, then fall back to old theme field + if let themeId = try container.decodeIfPresent(String.self, forKey: .activeThemeId) { + activeThemeId = themeId + } else if let oldTheme = try? container.decodeIfPresent(String.self, forKey: .theme) { + // Migrate from old AppTheme enum + switch oldTheme { + case "dark": activeThemeId = "tablepro.default-dark" + default: activeThemeId = "tablepro.default-light" + } + } else { + activeThemeId = "tablepro.default-light" } + appearanceMode = try container.decodeIfPresent(AppAppearanceMode.self, forKey: .appearanceMode) ?? .auto } - /// Tint color for applying to views (nil means use system default) - /// Derived from `color` property for DRY - only .system returns nil - var tintColor: Color? { - self == .system ? nil : color + private enum CodingKeys: String, CodingKey { + case activeThemeId, theme, appearanceMode } -} - -/// Appearance settings -struct AppearanceSettings: Codable, Equatable { - var theme: AppTheme - var accentColor: AccentColorOption - static let `default` = AppearanceSettings( - theme: .system, - accentColor: .system - ) + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(activeThemeId, forKey: .activeThemeId) + try container.encode(appearanceMode, forKey: .appearanceMode) + } } // MARK: - Editor Settings @@ -243,8 +210,6 @@ enum EditorFont: String, Codable, CaseIterable, Identifiable { /// Editor settings struct EditorSettings: Codable, Equatable { - var fontFamily: EditorFont - var fontSize: Int // 11-18pt var showLineNumbers: Bool var highlightCurrentLine: Bool var tabWidth: Int // 2, 4, or 8 spaces @@ -253,8 +218,6 @@ struct EditorSettings: Codable, Equatable { var vimModeEnabled: Bool static let `default` = EditorSettings( - fontFamily: .systemMono, - fontSize: 13, showLineNumbers: true, highlightCurrentLine: true, tabWidth: 4, @@ -264,8 +227,6 @@ struct EditorSettings: Codable, Equatable { ) init( - fontFamily: EditorFont = .systemMono, - fontSize: Int = 13, showLineNumbers: Bool = true, highlightCurrentLine: Bool = true, tabWidth: Int = 4, @@ -273,8 +234,6 @@ struct EditorSettings: Codable, Equatable { wordWrap: Bool = false, vimModeEnabled: Bool = false ) { - self.fontFamily = fontFamily - self.fontSize = fontSize self.showLineNumbers = showLineNumbers self.highlightCurrentLine = highlightCurrentLine self.tabWidth = tabWidth @@ -285,21 +244,15 @@ struct EditorSettings: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - fontFamily = try container.decode(EditorFont.self, forKey: .fontFamily) - fontSize = try container.decode(Int.self, forKey: .fontSize) - showLineNumbers = try container.decode(Bool.self, forKey: .showLineNumbers) - highlightCurrentLine = try container.decode(Bool.self, forKey: .highlightCurrentLine) - tabWidth = try container.decode(Int.self, forKey: .tabWidth) - autoIndent = try container.decode(Bool.self, forKey: .autoIndent) - wordWrap = try container.decode(Bool.self, forKey: .wordWrap) + // Old fontFamily/fontSize keys are ignored (moved to ThemeFonts) + showLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .showLineNumbers) ?? true + highlightCurrentLine = try container.decodeIfPresent(Bool.self, forKey: .highlightCurrentLine) ?? true + tabWidth = try container.decodeIfPresent(Int.self, forKey: .tabWidth) ?? 4 + autoIndent = try container.decodeIfPresent(Bool.self, forKey: .autoIndent) ?? true + wordWrap = try container.decodeIfPresent(Bool.self, forKey: .wordWrap) ?? false vimModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .vimModeEnabled) ?? false } - /// Clamped font size (11-18) - var clampedFontSize: Int { - min(max(fontSize, 11), 18) - } - /// Clamped tab width (1-16) var clampedTabWidth: Int { min(max(tabWidth, 1), 16) @@ -354,8 +307,6 @@ enum DateFormatOption: String, Codable, CaseIterable, Identifiable { /// Data grid settings struct DataGridSettings: Codable, Equatable { - var fontFamily: EditorFont - var fontSize: Int var rowHeight: DataGridRowHeight var dateFormat: DateFormatOption var nullDisplay: String @@ -363,11 +314,16 @@ struct DataGridSettings: Codable, Equatable { var showAlternateRows: Bool var autoShowInspector: Bool - static let `default` = DataGridSettings() + static let `default` = DataGridSettings( + rowHeight: .normal, + dateFormat: .iso8601, + nullDisplay: "NULL", + defaultPageSize: 1_000, + showAlternateRows: true, + autoShowInspector: false + ) init( - fontFamily: EditorFont = .systemMono, - fontSize: Int = 13, rowHeight: DataGridRowHeight = .normal, dateFormat: DateFormatOption = .iso8601, nullDisplay: String = "NULL", @@ -375,8 +331,6 @@ struct DataGridSettings: Codable, Equatable { showAlternateRows: Bool = true, autoShowInspector: Bool = false ) { - self.fontFamily = fontFamily - self.fontSize = fontSize self.rowHeight = rowHeight self.dateFormat = dateFormat self.nullDisplay = nullDisplay @@ -387,21 +341,15 @@ struct DataGridSettings: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - fontFamily = try container.decodeIfPresent(EditorFont.self, forKey: .fontFamily) ?? .systemMono - fontSize = try container.decodeIfPresent(Int.self, forKey: .fontSize) ?? 13 - rowHeight = try container.decode(DataGridRowHeight.self, forKey: .rowHeight) - dateFormat = try container.decode(DateFormatOption.self, forKey: .dateFormat) - nullDisplay = try container.decode(String.self, forKey: .nullDisplay) - defaultPageSize = try container.decode(Int.self, forKey: .defaultPageSize) - showAlternateRows = try container.decode(Bool.self, forKey: .showAlternateRows) + // Old fontFamily/fontSize keys are ignored (moved to ThemeFonts) + rowHeight = try container.decodeIfPresent(DataGridRowHeight.self, forKey: .rowHeight) ?? .normal + dateFormat = try container.decodeIfPresent(DateFormatOption.self, forKey: .dateFormat) ?? .iso8601 + nullDisplay = try container.decodeIfPresent(String.self, forKey: .nullDisplay) ?? "NULL" + defaultPageSize = try container.decodeIfPresent(Int.self, forKey: .defaultPageSize) ?? 1_000 + showAlternateRows = try container.decodeIfPresent(Bool.self, forKey: .showAlternateRows) ?? true autoShowInspector = try container.decodeIfPresent(Bool.self, forKey: .autoShowInspector) ?? false } - /// Clamped font size (10-18) - var clampedFontSize: Int { - min(max(fontSize, 10), 18) - } - // MARK: - Validated Properties /// Validated and sanitized nullDisplay (max 20 chars, no newlines) diff --git a/TablePro/Resources/Themes/tablepro.default-dark.json b/TablePro/Resources/Themes/tablepro.default-dark.json new file mode 100644 index 00000000..0767b0fa --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.default-dark.json @@ -0,0 +1,131 @@ +{ + "id": "tablepro.default-dark", + "name": "Default Dark", + "version": 1, + "appearance": "dark", + "author": "TablePro", + "editor": { + "background": "#1E1E1E", + "text": "#D4D4D4", + "cursor": "#007AFF", + "currentLineHighlight": "#007AFF14", + "selection": "#264F78", + "lineNumber": "#858585", + "invisibles": "#4D4D4D", + "syntax": { + "keyword": "#569CD6", + "string": "#CE9178", + "number": "#B5CEA8", + "comment": "#6A9955", + "null": "#FF8C00", + "operator": "#D4D4D4", + "function": "#DCDCAA", + "type": "#4EC9B0" + } + }, + "dataGrid": { + "background": "#1E1E1E", + "text": "#D4D4D4", + "alternateRow": "#FFFFFF08", + "nullValue": "#858585", + "boolTrue": "#32D74B", + "boolFalse": "#FF453A", + "rowNumber": "#858585", + "modified": "#FFD60A4D", + "inserted": "#32D74B26", + "deleted": "#FF453A26", + "deletedText": "#FF453A80", + "focusBorder": "#007AFF" + }, + "ui": { + "windowBackground": "#1E1E1E", + "controlBackground": "#2D2D2D", + "cardBackground": "#252526", + "border": "#3C3C3C", + "primaryText": "#D4D4D4", + "secondaryText": "#A0A0A0", + "tertiaryText": "#6D6D6D", + "accentColor": null, + "selectionBackground": "#264F78", + "hoverBackground": "#007AFF0D", + "status": { + "success": "#32D74B", + "warning": "#FF9F0A", + "error": "#FF453A", + "info": "#0A84FF" + }, + "badges": { + "background": "#3C3C3C", + "primaryKey": "#0A84FF26", + "autoIncrement": "#BF5AF226" + } + }, + "sidebar": { + "background": "#252526", + "text": "#CCCCCC", + "selectedItem": "#094771", + "hover": "#2A2D2E", + "sectionHeader": "#858585" + }, + "toolbar": { + "secondaryText": "#A0A0A0", + "tertiaryText": "#6D6D6D" + }, + "fonts": { + "editorFontFamily": "System Mono", + "editorFontSize": 13, + "dataGridFontFamily": "System Mono", + "dataGridFontSize": 13 + }, + "spacing": { + "xxxs": 2, + "xxs": 4, + "xs": 8, + "sm": 12, + "md": 16, + "lg": 20, + "xl": 24, + "listRowInsets": { + "top": 4, + "leading": 8, + "bottom": 4, + "trailing": 8 + } + }, + "typography": { + "tiny": 9, + "caption": 10, + "small": 11, + "medium": 12, + "body": 13, + "title3": 15, + "title2": 17 + }, + "iconSizes": { + "tinyDot": 6, + "statusDot": 8, + "small": 12, + "default": 14, + "medium": 16, + "large": 20, + "extraLarge": 24, + "huge": 32, + "massive": 64 + }, + "cornerRadius": { + "small": 4, + "medium": 6, + "large": 8 + }, + "rowHeights": { + "compact": 24, + "table": 32, + "comfortable": 44 + }, + "animations": { + "fast": 0.1, + "normal": 0.15, + "smooth": 0.2, + "slow": 0.3 + } +} diff --git a/TablePro/Resources/Themes/tablepro.default-light.json b/TablePro/Resources/Themes/tablepro.default-light.json new file mode 100644 index 00000000..f5e839b3 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.default-light.json @@ -0,0 +1,131 @@ +{ + "id": "tablepro.default-light", + "name": "Default Light", + "version": 1, + "appearance": "light", + "author": "TablePro", + "editor": { + "background": "#FFFFFF", + "text": "#000000", + "cursor": "#007AFF", + "currentLineHighlight": "#007AFF14", + "selection": "#B4D8FD", + "lineNumber": "#8E8E93", + "invisibles": "#C7C7CC", + "syntax": { + "keyword": "#0A49A5", + "string": "#C41A16", + "number": "#6C36A9", + "comment": "#007400", + "null": "#C55B00", + "operator": "#000000", + "function": "#326D74", + "type": "#3F6E74" + } + }, + "dataGrid": { + "background": "#FFFFFF", + "text": "#000000", + "alternateRow": "#F5F5F5", + "nullValue": "#8E8E93", + "boolTrue": "#248A3D", + "boolFalse": "#D70015", + "rowNumber": "#8E8E93", + "modified": "#FFD60A4D", + "inserted": "#34C7594D", + "deleted": "#FF3B304D", + "deletedText": "#FF3B3080", + "focusBorder": "#007AFF" + }, + "ui": { + "windowBackground": "#ECECEC", + "controlBackground": "#FFFFFF", + "cardBackground": "#F5F5F5", + "border": "#D5D5D5", + "primaryText": "#000000", + "secondaryText": "#8E8E93", + "tertiaryText": "#C7C7CC", + "accentColor": null, + "selectionBackground": "#B4D8FD", + "hoverBackground": "#007AFF0D", + "status": { + "success": "#248A3D", + "warning": "#C55B00", + "error": "#D70015", + "info": "#007AFF" + }, + "badges": { + "background": "#E5E5EA", + "primaryKey": "#007AFF26", + "autoIncrement": "#AF52DE26" + } + }, + "sidebar": { + "background": "#F5F5F5", + "text": "#000000", + "selectedItem": "#007AFF33", + "hover": "#0000000A", + "sectionHeader": "#8E8E93" + }, + "toolbar": { + "secondaryText": "#8E8E93", + "tertiaryText": "#C7C7CC" + }, + "fonts": { + "editorFontFamily": "System Mono", + "editorFontSize": 13, + "dataGridFontFamily": "System Mono", + "dataGridFontSize": 13 + }, + "spacing": { + "xxxs": 2, + "xxs": 4, + "xs": 8, + "sm": 12, + "md": 16, + "lg": 20, + "xl": 24, + "listRowInsets": { + "top": 4, + "leading": 8, + "bottom": 4, + "trailing": 8 + } + }, + "typography": { + "tiny": 9, + "caption": 10, + "small": 11, + "medium": 12, + "body": 13, + "title3": 15, + "title2": 17 + }, + "iconSizes": { + "tinyDot": 6, + "statusDot": 8, + "small": 12, + "default": 14, + "medium": 16, + "large": 20, + "extraLarge": 24, + "huge": 32, + "massive": 64 + }, + "cornerRadius": { + "small": 4, + "medium": 6, + "large": 8 + }, + "rowHeights": { + "compact": 24, + "table": 32, + "comfortable": 44 + }, + "animations": { + "fast": 0.1, + "normal": 0.15, + "smooth": 0.2, + "slow": 0.3 + } +} diff --git a/TablePro/Resources/Themes/tablepro.dracula.json b/TablePro/Resources/Themes/tablepro.dracula.json new file mode 100644 index 00000000..32d673d8 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.dracula.json @@ -0,0 +1,131 @@ +{ + "id": "tablepro.dracula", + "name": "Dracula", + "version": 1, + "appearance": "dark", + "author": "TablePro", + "editor": { + "background": "#282A36", + "text": "#F8F8F2", + "cursor": "#F8F8F2", + "currentLineHighlight": "#44475A", + "selection": "#44475A", + "lineNumber": "#6272A4", + "invisibles": "#424450", + "syntax": { + "keyword": "#FF79C6", + "string": "#F1FA8C", + "number": "#BD93F9", + "comment": "#6272A4", + "null": "#FFB86C", + "operator": "#FF79C6", + "function": "#50FA7B", + "type": "#8BE9FD" + } + }, + "dataGrid": { + "background": "#282A36", + "text": "#F8F8F2", + "alternateRow": "#21222C", + "nullValue": "#6272A4", + "boolTrue": "#50FA7B", + "boolFalse": "#FF5555", + "rowNumber": "#6272A4", + "modified": "#FFB86C4D", + "inserted": "#50FA7B26", + "deleted": "#FF555526", + "deletedText": "#FF555580", + "focusBorder": "#BD93F9" + }, + "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" + }, + "toolbar": { + "secondaryText": "#BFC0C9", + "tertiaryText": "#6272A4" + }, + "fonts": { + "editorFontFamily": "System Mono", + "editorFontSize": 13, + "dataGridFontFamily": "System Mono", + "dataGridFontSize": 13 + }, + "spacing": { + "xxxs": 2, + "xxs": 4, + "xs": 8, + "sm": 12, + "md": 16, + "lg": 20, + "xl": 24, + "listRowInsets": { + "top": 4, + "leading": 8, + "bottom": 4, + "trailing": 8 + } + }, + "typography": { + "tiny": 9, + "caption": 10, + "small": 11, + "medium": 12, + "body": 13, + "title3": 15, + "title2": 17 + }, + "iconSizes": { + "tinyDot": 6, + "statusDot": 8, + "small": 12, + "default": 14, + "medium": 16, + "large": 20, + "extraLarge": 24, + "huge": 32, + "massive": 64 + }, + "cornerRadius": { + "small": 4, + "medium": 6, + "large": 8 + }, + "rowHeights": { + "compact": 24, + "table": 32, + "comfortable": 44 + }, + "animations": { + "fast": 0.1, + "normal": 0.15, + "smooth": 0.2, + "slow": 0.3 + } +} diff --git a/TablePro/Resources/Themes/tablepro.nord.json b/TablePro/Resources/Themes/tablepro.nord.json new file mode 100644 index 00000000..66bca5a4 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.nord.json @@ -0,0 +1,131 @@ +{ + "id": "tablepro.nord", + "name": "Nord", + "version": 1, + "appearance": "dark", + "author": "TablePro", + "editor": { + "background": "#2E3440", + "text": "#D8DEE9", + "cursor": "#88C0D0", + "currentLineHighlight": "#3B4252", + "selection": "#434C5E", + "lineNumber": "#4C566A", + "invisibles": "#3B4252", + "syntax": { + "keyword": "#81A1C1", + "string": "#A3BE8C", + "number": "#B48EAD", + "comment": "#616E88", + "null": "#D08770", + "operator": "#81A1C1", + "function": "#88C0D0", + "type": "#EBCB8B" + } + }, + "dataGrid": { + "background": "#2E3440", + "text": "#D8DEE9", + "alternateRow": "#3B4252", + "nullValue": "#4C566A", + "boolTrue": "#A3BE8C", + "boolFalse": "#BF616A", + "rowNumber": "#4C566A", + "modified": "#EBCB8B4D", + "inserted": "#A3BE8C26", + "deleted": "#BF616A26", + "deletedText": "#BF616A80", + "focusBorder": "#88C0D0" + }, + "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" + }, + "toolbar": { + "secondaryText": "#9DA5B4", + "tertiaryText": "#616E88" + }, + "fonts": { + "editorFontFamily": "System Mono", + "editorFontSize": 13, + "dataGridFontFamily": "System Mono", + "dataGridFontSize": 13 + }, + "spacing": { + "xxxs": 2, + "xxs": 4, + "xs": 8, + "sm": 12, + "md": 16, + "lg": 20, + "xl": 24, + "listRowInsets": { + "top": 4, + "leading": 8, + "bottom": 4, + "trailing": 8 + } + }, + "typography": { + "tiny": 9, + "caption": 10, + "small": 11, + "medium": 12, + "body": 13, + "title3": 15, + "title2": 17 + }, + "iconSizes": { + "tinyDot": 6, + "statusDot": 8, + "small": 12, + "default": 14, + "medium": 16, + "large": 20, + "extraLarge": 24, + "huge": 32, + "massive": 64 + }, + "cornerRadius": { + "small": 4, + "medium": 6, + "large": 8 + }, + "rowHeights": { + "compact": 24, + "table": 32, + "comfortable": 44 + }, + "animations": { + "fast": 0.1, + "normal": 0.15, + "smooth": 0.2, + "slow": 0.3 + } +} diff --git a/TablePro/Theme/RegistryThemeMeta.swift b/TablePro/Theme/RegistryThemeMeta.swift new file mode 100644 index 00000000..536ced7c --- /dev/null +++ b/TablePro/Theme/RegistryThemeMeta.swift @@ -0,0 +1,16 @@ +import Foundation + +internal struct RegistryThemeMeta: Codable { + var installed: [InstalledRegistryTheme] + + init(installed: [InstalledRegistryTheme] = []) { + self.installed = installed + } +} + +internal struct InstalledRegistryTheme: Codable, Identifiable { + let id: String + let registryPluginId: String + let version: String + let installedDate: Date +} diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift new file mode 100644 index 00000000..ea7e1d80 --- /dev/null +++ b/TablePro/Theme/ThemeDefinition.swift @@ -0,0 +1,757 @@ +import SwiftUI + +internal struct ThemeDefinition: Codable, Identifiable, Equatable, Sendable { + var id: String + var name: String + var version: Int + var appearance: ThemeAppearance + var author: String + var editor: EditorThemeColors + var dataGrid: DataGridThemeColors + var ui: UIThemeColors + var sidebar: SidebarThemeColors + var toolbar: ToolbarThemeColors + var fonts: ThemeFonts + var spacing: ThemeSpacing + var typography: ThemeTypography + var iconSizes: ThemeIconSizes + var cornerRadius: ThemeCornerRadius + var rowHeights: ThemeRowHeights + var animations: ThemeAnimations + + 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, + spacing: .default, + typography: .default, + iconSizes: .default, + cornerRadius: .default, + rowHeights: .default, + animations: .default + ) + + init( + id: String, + name: String, + version: Int, + appearance: ThemeAppearance, + author: String, + editor: EditorThemeColors, + dataGrid: DataGridThemeColors, + ui: UIThemeColors, + sidebar: SidebarThemeColors, + toolbar: ToolbarThemeColors, + fonts: ThemeFonts, + spacing: ThemeSpacing = .default, + typography: ThemeTypography = .default, + iconSizes: ThemeIconSizes = .default, + cornerRadius: ThemeCornerRadius = .default, + rowHeights: ThemeRowHeights = .default, + animations: ThemeAnimations = .default + ) { + 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 + self.spacing = spacing + self.typography = typography + self.iconSizes = iconSizes + self.cornerRadius = cornerRadius + self.rowHeights = rowHeights + self.animations = animations + } + + 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 + spacing = try container.decodeIfPresent(ThemeSpacing.self, forKey: .spacing) ?? fallback.spacing + typography = try container.decodeIfPresent(ThemeTypography.self, forKey: .typography) ?? fallback.typography + iconSizes = try container.decodeIfPresent(ThemeIconSizes.self, forKey: .iconSizes) ?? fallback.iconSizes + cornerRadius = try container.decodeIfPresent(ThemeCornerRadius.self, forKey: .cornerRadius) ?? fallback.cornerRadius + rowHeights = try container.decodeIfPresent(ThemeRowHeights.self, forKey: .rowHeights) ?? fallback.rowHeights + animations = try container.decodeIfPresent(ThemeAnimations.self, forKey: .animations) ?? fallback.animations + } +} + +internal enum ThemeAppearance: String, Codable, Sendable { + case light, dark, auto +} + +// 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: "#9B2393", + string: "#C41A16", + number: "#1C00CF", + comment: "#5D6C79", + null: "#9B2393", + 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 syntax: SyntaxColors + + static let defaultLight = EditorThemeColors( + background: "#FFFFFF", + text: "#000000", + cursor: "#000000", + currentLineHighlight: "#ECF5FF", + selection: "#B4D8FD", + lineNumber: "#747478", + invisibles: "#D6D6D6", + syntax: .defaultLight + ) + + init( + background: String, + text: String, + cursor: String, + currentLineHighlight: String, + selection: String, + lineNumber: String, + invisibles: 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.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 + 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 + var focusBorder: String + + static let defaultLight = DataGridThemeColors( + background: "#FFFFFF", + text: "#000000", + alternateRow: "#F5F5F5", + nullValue: "#B0B0B0", + boolTrue: "#34A853", + boolFalse: "#EA4335", + rowNumber: "#747478", + modified: "#FFF9C4", + inserted: "#E8F5E9", + deleted: "#FFEBEE", + deletedText: "#B0B0B0", + focusBorder: "#2196F3" + ) + + init( + background: String, + text: String, + alternateRow: String, + nullValue: String, + boolTrue: String, + boolFalse: String, + rowNumber: String, + modified: String, + inserted: String, + deleted: String, + deletedText: String, + focusBorder: 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 + self.focusBorder = focusBorder + } + + 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) ?? fallback.background + text = try container.decodeIfPresent(String.self, forKey: .text) ?? fallback.text + alternateRow = try container.decodeIfPresent(String.self, forKey: .alternateRow) ?? fallback.alternateRow + nullValue = try container.decodeIfPresent(String.self, forKey: .nullValue) ?? fallback.nullValue + boolTrue = try container.decodeIfPresent(String.self, forKey: .boolTrue) ?? fallback.boolTrue + boolFalse = try container.decodeIfPresent(String.self, forKey: .boolFalse) ?? fallback.boolFalse + rowNumber = try container.decodeIfPresent(String.self, forKey: .rowNumber) ?? fallback.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 + focusBorder = try container.decodeIfPresent(String.self, forKey: .focusBorder) ?? fallback.focusBorder + } +} + +// 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: "#34A853", + warning: "#FBBC04", + error: "#EA4335", + info: "#4285F4" + ) + + 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: "#E8E8ED", + primaryKey: "#FFCC00", + autoIncrement: "#AF52DE" + ) + + 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 accentColor: String? + var selectionBackground: String + var hoverBackground: String + var status: StatusColors + var badges: BadgeColors + + static let defaultLight = UIThemeColors( + windowBackground: "#ECECEC", + controlBackground: "#FFFFFF", + cardBackground: "#FFFFFF", + border: "#D1D1D6", + primaryText: "#000000", + secondaryText: "#3C3C43", + tertiaryText: "#8E8E93", + accentColor: nil, + selectionBackground: "#0A84FF", + hoverBackground: "#F2F2F7", + status: .defaultLight, + badges: .defaultLight + ) + + init( + windowBackground: String, + controlBackground: String, + cardBackground: String, + border: String, + primaryText: String, + secondaryText: String, + tertiaryText: String, + accentColor: 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.accentColor = accentColor + 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) + ?? fallback.windowBackground + controlBackground = try container.decodeIfPresent(String.self, forKey: .controlBackground) + ?? fallback.controlBackground + cardBackground = try container.decodeIfPresent(String.self, forKey: .cardBackground) ?? fallback.cardBackground + border = try container.decodeIfPresent(String.self, forKey: .border) ?? fallback.border + primaryText = try container.decodeIfPresent(String.self, forKey: .primaryText) ?? fallback.primaryText + secondaryText = try container.decodeIfPresent(String.self, forKey: .secondaryText) ?? fallback.secondaryText + tertiaryText = try container.decodeIfPresent(String.self, forKey: .tertiaryText) ?? fallback.tertiaryText + accentColor = try container.decodeIfPresent(String.self, forKey: .accentColor) + selectionBackground = try container.decodeIfPresent(String.self, forKey: .selectionBackground) + ?? fallback.selectionBackground + hoverBackground = try container.decodeIfPresent(String.self, forKey: .hoverBackground) + ?? fallback.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: "#F5F5F5", + text: "#000000", + selectedItem: "#0A84FF", + hover: "#E5E5EA", + sectionHeader: "#8E8E93" + ) + + 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) + let fallback = SidebarThemeColors.defaultLight + + background = try container.decodeIfPresent(String.self, forKey: .background) ?? fallback.background + text = try container.decodeIfPresent(String.self, forKey: .text) ?? fallback.text + selectedItem = try container.decodeIfPresent(String.self, forKey: .selectedItem) ?? fallback.selectedItem + hover = try container.decodeIfPresent(String.self, forKey: .hover) ?? fallback.hover + sectionHeader = try container.decodeIfPresent(String.self, forKey: .sectionHeader) ?? fallback.sectionHeader + } +} + +// MARK: - Toolbar Theme Colors + +internal struct ToolbarThemeColors: Codable, Equatable, Sendable { + var secondaryText: String + var tertiaryText: String + + static let defaultLight = ToolbarThemeColors( + secondaryText: "#3C3C43", + tertiaryText: "#8E8E93" + ) + + init(secondaryText: String, tertiaryText: String) { + self.secondaryText = secondaryText + self.tertiaryText = tertiaryText + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = ToolbarThemeColors.defaultLight + + secondaryText = try container.decodeIfPresent(String.self, forKey: .secondaryText) ?? fallback.secondaryText + tertiaryText = try container.decodeIfPresent(String.self, forKey: .tertiaryText) ?? fallback.tertiaryText + } +} + +// 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 + } +} + +// MARK: - Theme Spacing + +internal struct ThemeSpacing: Codable, Equatable, Sendable { + var xxxs: CGFloat + var xxs: CGFloat + var xs: CGFloat + var sm: CGFloat + var md: CGFloat + var lg: CGFloat + var xl: CGFloat + var listRowInsets: ThemeEdgeInsets + + static let `default` = ThemeSpacing( + xxxs: 2, xxs: 4, xs: 8, sm: 12, md: 16, lg: 20, xl: 24, + listRowInsets: .default + ) + + init( + xxxs: CGFloat, xxs: CGFloat, xs: CGFloat, sm: CGFloat, + md: CGFloat, lg: CGFloat, xl: CGFloat, listRowInsets: ThemeEdgeInsets + ) { + self.xxxs = xxxs; self.xxs = xxs; self.xs = xs; self.sm = sm + self.md = md; self.lg = lg; self.xl = xl; self.listRowInsets = listRowInsets + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = ThemeSpacing.default + xxxs = try container.decodeIfPresent(CGFloat.self, forKey: .xxxs) ?? fallback.xxxs + xxs = try container.decodeIfPresent(CGFloat.self, forKey: .xxs) ?? fallback.xxs + xs = try container.decodeIfPresent(CGFloat.self, forKey: .xs) ?? fallback.xs + sm = try container.decodeIfPresent(CGFloat.self, forKey: .sm) ?? fallback.sm + md = try container.decodeIfPresent(CGFloat.self, forKey: .md) ?? fallback.md + lg = try container.decodeIfPresent(CGFloat.self, forKey: .lg) ?? fallback.lg + xl = try container.decodeIfPresent(CGFloat.self, forKey: .xl) ?? fallback.xl + listRowInsets = try container.decodeIfPresent(ThemeEdgeInsets.self, forKey: .listRowInsets) ?? fallback.listRowInsets + } +} + +internal struct ThemeEdgeInsets: Codable, Equatable, Sendable { + var top: CGFloat + var leading: CGFloat + var bottom: CGFloat + var trailing: CGFloat + + static let `default` = ThemeEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) + + var swiftUI: EdgeInsets { EdgeInsets(top: top, leading: leading, bottom: bottom, trailing: trailing) } + var appKit: NSEdgeInsets { NSEdgeInsets(top: top, left: leading, bottom: bottom, right: trailing) } + + init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { + self.top = top; self.leading = leading; self.bottom = bottom; self.trailing = trailing + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = ThemeEdgeInsets.default + top = try container.decodeIfPresent(CGFloat.self, forKey: .top) ?? fallback.top + leading = try container.decodeIfPresent(CGFloat.self, forKey: .leading) ?? fallback.leading + bottom = try container.decodeIfPresent(CGFloat.self, forKey: .bottom) ?? fallback.bottom + trailing = try container.decodeIfPresent(CGFloat.self, forKey: .trailing) ?? fallback.trailing + } +} + +// MARK: - Theme Typography + +internal struct ThemeTypography: Codable, Equatable, Sendable { + var tiny: CGFloat + var caption: CGFloat + var small: CGFloat + var medium: CGFloat + var body: CGFloat + var title3: CGFloat + var title2: CGFloat + + static let `default` = ThemeTypography( + tiny: 9, caption: 10, small: 11, medium: 12, body: 13, title3: 15, title2: 17 + ) + + init( + tiny: CGFloat, caption: CGFloat, small: CGFloat, medium: CGFloat, + body: CGFloat, title3: CGFloat, title2: CGFloat + ) { + self.tiny = tiny; self.caption = caption; self.small = small; self.medium = medium + self.body = body; self.title3 = title3; self.title2 = title2 + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = ThemeTypography.default + tiny = try container.decodeIfPresent(CGFloat.self, forKey: .tiny) ?? fallback.tiny + caption = try container.decodeIfPresent(CGFloat.self, forKey: .caption) ?? fallback.caption + small = try container.decodeIfPresent(CGFloat.self, forKey: .small) ?? fallback.small + medium = try container.decodeIfPresent(CGFloat.self, forKey: .medium) ?? fallback.medium + body = try container.decodeIfPresent(CGFloat.self, forKey: .body) ?? fallback.body + title3 = try container.decodeIfPresent(CGFloat.self, forKey: .title3) ?? fallback.title3 + title2 = try container.decodeIfPresent(CGFloat.self, forKey: .title2) ?? fallback.title2 + } +} + +// MARK: - Theme Icon Sizes + +internal struct ThemeIconSizes: Codable, Equatable, Sendable { + var tinyDot: CGFloat + var statusDot: CGFloat + var small: CGFloat + var `default`: CGFloat + var medium: CGFloat + var large: CGFloat + var extraLarge: CGFloat + var huge: CGFloat + var massive: CGFloat + + static let `default` = ThemeIconSizes( + tinyDot: 6, statusDot: 8, small: 12, default: 14, medium: 16, + large: 20, extraLarge: 24, huge: 32, massive: 64 + ) + + init( + tinyDot: CGFloat, statusDot: CGFloat, small: CGFloat, `default`: CGFloat, + medium: CGFloat, large: CGFloat, extraLarge: CGFloat, huge: CGFloat, massive: CGFloat + ) { + self.tinyDot = tinyDot; self.statusDot = statusDot; self.small = small + self.`default` = `default`; self.medium = medium; self.large = large + self.extraLarge = extraLarge; self.huge = huge; self.massive = massive + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = ThemeIconSizes.default + tinyDot = try container.decodeIfPresent(CGFloat.self, forKey: .tinyDot) ?? fallback.tinyDot + statusDot = try container.decodeIfPresent(CGFloat.self, forKey: .statusDot) ?? fallback.statusDot + small = try container.decodeIfPresent(CGFloat.self, forKey: .small) ?? fallback.small + `default` = try container.decodeIfPresent(CGFloat.self, forKey: .default) ?? fallback.default + medium = try container.decodeIfPresent(CGFloat.self, forKey: .medium) ?? fallback.medium + large = try container.decodeIfPresent(CGFloat.self, forKey: .large) ?? fallback.large + extraLarge = try container.decodeIfPresent(CGFloat.self, forKey: .extraLarge) ?? fallback.extraLarge + huge = try container.decodeIfPresent(CGFloat.self, forKey: .huge) ?? fallback.huge + massive = try container.decodeIfPresent(CGFloat.self, forKey: .massive) ?? fallback.massive + } +} + +// MARK: - Theme Corner Radius + +internal struct ThemeCornerRadius: Codable, Equatable, Sendable { + var small: CGFloat + var medium: CGFloat + var large: CGFloat + + static let `default` = ThemeCornerRadius(small: 4, medium: 6, large: 8) + + init(small: CGFloat, medium: CGFloat, large: CGFloat) { + self.small = small; self.medium = medium; self.large = large + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = ThemeCornerRadius.default + small = try container.decodeIfPresent(CGFloat.self, forKey: .small) ?? fallback.small + medium = try container.decodeIfPresent(CGFloat.self, forKey: .medium) ?? fallback.medium + large = try container.decodeIfPresent(CGFloat.self, forKey: .large) ?? fallback.large + } +} + +// MARK: - Theme Row Heights + +internal struct ThemeRowHeights: Codable, Equatable, Sendable { + var compact: CGFloat + var table: CGFloat + var comfortable: CGFloat + + static let `default` = ThemeRowHeights(compact: 24, table: 32, comfortable: 44) + + init(compact: CGFloat, table: CGFloat, comfortable: CGFloat) { + self.compact = compact; self.table = table; self.comfortable = comfortable + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = ThemeRowHeights.default + compact = try container.decodeIfPresent(CGFloat.self, forKey: .compact) ?? fallback.compact + table = try container.decodeIfPresent(CGFloat.self, forKey: .table) ?? fallback.table + comfortable = try container.decodeIfPresent(CGFloat.self, forKey: .comfortable) ?? fallback.comfortable + } +} + +// MARK: - Theme Animations + +internal struct ThemeAnimations: Codable, Equatable, Sendable { + var fast: Double + var normal: Double + var smooth: Double + var slow: Double + + static let `default` = ThemeAnimations(fast: 0.1, normal: 0.15, smooth: 0.2, slow: 0.3) + + init(fast: Double, normal: Double, smooth: Double, slow: Double) { + self.fast = fast; self.normal = normal; self.smooth = smooth; self.slow = slow + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fallback = ThemeAnimations.default + fast = try container.decodeIfPresent(Double.self, forKey: .fast) ?? fallback.fast + normal = try container.decodeIfPresent(Double.self, forKey: .normal) ?? fallback.normal + smooth = try container.decodeIfPresent(Double.self, forKey: .smooth) ?? fallback.smooth + slow = try container.decodeIfPresent(Double.self, forKey: .slow) ?? fallback.slow + } +} diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift new file mode 100644 index 00000000..82b0c3a1 --- /dev/null +++ b/TablePro/Theme/ThemeEngine.swift @@ -0,0 +1,337 @@ +// +// ThemeEngine.swift +// TablePro +// +// Central @Observable singleton managing the active theme. +// Replaces Theme.swift, SQLEditorTheme, DataGridFontCache, ToolbarDesignTokens. +// + +import AppKit +import CodeEditSourceEditor +import Foundation +import Observation +import os +import SwiftUI + +// 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 + static let medium = 2 + static let rowNumber = 3 +} + +internal struct EditorFontCache { + let font: NSFont + let lineNumberFont: NSFont + let scaleFactor: CGFloat + + init(from fonts: ThemeFonts) { + let scale = Self.computeAccessibilityScale() + scaleFactor = scale + let scaledSize = round(CGFloat(min(max(fonts.editorFontSize, 11), 18)) * scale) + font = EditorFont(rawValue: fonts.editorFontFamily)?.font(size: scaledSize) + ?? NSFont.monospacedSystemFont(ofSize: scaledSize, weight: .regular) + let lineNumSize = max(round((scaledSize - 2)), 9) + lineNumberFont = NSFont.monospacedSystemFont(ofSize: lineNumSize, 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 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 = EditorFont(rawValue: fonts.dataGridFontFamily)?.font(size: scaledSize) + ?? NSFont.monospacedSystemFont(ofSize: scaledSize, weight: .regular) + 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 + } +} + +// MARK: - ThemeEngine + +@Observable +@MainActor +internal final class ThemeEngine { + static let shared = ThemeEngine() + + // MARK: - Active Theme + + private(set) var activeTheme: ThemeDefinition + + /// Pre-resolved colors (rebuilt on theme change) + private(set) var colors: ResolvedThemeColors + + /// Cached editor fonts + private(set) var editorFonts: EditorFontCache + + /// Cached data grid fonts + private(set) var dataGridFonts: DataGridFontCacheResolved + + // MARK: - Available Themes + + private(set) var availableThemes: [ThemeDefinition] + + // MARK: - Editor Behavioral Settings (read from AppSettingsManager) + + /// These are not theme properties but are needed by makeEditorTheme() + @ObservationIgnored var highlightCurrentLine: Bool = true + @ObservationIgnored var showLineNumbers: Bool = true + @ObservationIgnored var tabWidth: Int = 4 + @ObservationIgnored var autoIndent: Bool = true + @ObservationIgnored var wordWrap: Bool = false + + // MARK: - Private + + @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEngine") + @ObservationIgnored private var accessibilityObserver: NSObjectProtocol? + @ObservationIgnored private var lastAccessibilityScale: CGFloat = 1.0 + + // MARK: - Init + + private init() { + let allThemes = ThemeStorage.loadAllThemes() + let activeId = ThemeStorage.loadActiveThemeId() + let theme = allThemes.first { $0.id == activeId } ?? .default + + self.activeTheme = theme + self.colors = ResolvedThemeColors(from: theme) + self.editorFonts = EditorFontCache(from: theme.fonts) + self.dataGridFonts = DataGridFontCacheResolved(from: theme.fonts) + self.availableThemes = allThemes + + observeAccessibilityChanges() + } + + // MARK: - Theme Lifecycle + + func activateTheme(id: String) { + guard let theme = availableThemes.first(where: { $0.id == id }) + ?? ThemeStorage.loadTheme(id: id) + else { + Self.logger.warning("Theme not found: \(id)") + return + } + + activateTheme(theme) + } + + func activateTheme(_ theme: ThemeDefinition) { + activeTheme = theme + colors = ResolvedThemeColors(from: theme) + editorFonts = EditorFontCache(from: theme.fonts) + dataGridFonts = DataGridFontCacheResolved(from: theme.fonts) + + ThemeStorage.saveActiveThemeId(theme.id) + notifyThemeDidChange() + + Self.logger.info("Activated theme: \(theme.name) (\(theme.id))") + } + + // MARK: - Theme CRUD + + func saveUserTheme(_ theme: ThemeDefinition) throws { + try ThemeStorage.saveUserTheme(theme) + reloadAvailableThemes() + + // If editing the active theme, re-activate to apply changes + 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 the active theme, fall back to default + if id == activeTheme.id { + activateTheme(id: "tablepro.default-light") + } + } + + 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 + } + + func importTheme(from url: URL) throws -> ThemeDefinition { + let theme = try ThemeStorage.importTheme(from: url) + reloadAvailableThemes() + return theme + } + + func exportTheme(_ theme: ThemeDefinition, to url: URL) throws { + try ThemeStorage.exportTheme(theme, to: url) + } + + var registryThemes: [ThemeDefinition] { + availableThemes.filter(\.isRegistry) + } + + func uninstallRegistryTheme(registryPluginId: String) throws { + try ThemeRegistryInstaller.shared.uninstall(registryPluginId: registryPluginId) + } + + func reloadAvailableThemes() { + availableThemes = ThemeStorage.loadAllThemes() + } + + // MARK: - Font Cache Reload (accessibility) + + func reloadFontCaches() { + editorFonts = EditorFontCache(from: activeTheme.fonts) + dataGridFonts = DataGridFontCacheResolved(from: activeTheme.fonts) + notifyThemeDidChange() + } + + // MARK: - Update Editor Behavioral Settings + + func updateEditorSettings( + highlightCurrentLine: Bool, + showLineNumbers: Bool, + tabWidth: Int, + autoIndent: Bool, + wordWrap: Bool + ) { + self.highlightCurrentLine = highlightCurrentLine + self.showLineNumbers = showLineNumbers + self.tabWidth = tabWidth + self.autoIndent = autoIndent + self.wordWrap = wordWrap + } + + // MARK: - CodeEditSourceEditor 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 lineHighlight: NSColor = highlightCurrentLine ? c.currentLineHighlight : .clear + + return EditorTheme( + text: textAttr, + insertionPoint: srgb(c.cursor), + invisibles: EditorTheme.Attribute(color: srgb(c.invisibles)), + background: srgb(c.background), + lineHighlight: srgb(lineHighlight), + selection: srgb(c.selection), + keywords: keywordAttr, + commands: keywordAttr, + types: typeAttr, + attributes: variableAttr, + variables: variableAttr, + values: variableAttr, + numbers: numberAttr, + strings: stringAttr, + characters: stringAttr, + comments: commentAttr + ) + } + + // MARK: - Appearance + + @ObservationIgnored private(set) var appearanceMode: AppAppearanceMode = .auto + + func updateAppearanceMode(_ mode: AppAppearanceMode) { + appearanceMode = mode + applyAppearance(mode) + } + + private func applyAppearance(_ mode: AppAppearanceMode) { + switch mode { + case .light: + NSApp?.appearance = NSAppearance(named: .aqua) + case .dark: + NSApp?.appearance = NSAppearance(named: .darkAqua) + case .auto: + NSApp?.appearance = nil + } + } + + // MARK: - Notifications + + private func notifyThemeDidChange() { + NotificationCenter.default.post(name: .themeDidChange, object: self) + } + + // MARK: - Accessibility + + private func observeAccessibilityChanges() { + lastAccessibilityScale = EditorFontCache.computeAccessibilityScale() + 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() + NotificationCenter.default.post(name: .accessibilityTextSizeDidChange, object: self) + } + } + } + + // MARK: - Helpers + + private func srgb(_ color: NSColor) -> NSColor { + color.usingColorSpace(.sRGB) ?? color + } +} + +// MARK: - Database Type Colors (preserved from old Theme.swift) + +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(ThemeEngine.shared.colors.ui.controlBackgroundSwiftUI) + .clipShape(RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.medium)) + } + + func toolbarButtonStyle() -> some View { + self + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + } +} diff --git a/TablePro/Theme/ThemeRegistryInstaller.swift b/TablePro/Theme/ThemeRegistryInstaller.swift new file mode 100644 index 00000000..6abb1089 --- /dev/null +++ b/TablePro/Theme/ThemeRegistryInstaller.swift @@ -0,0 +1,272 @@ +// +// ThemeRegistryInstaller.swift +// TablePro +// +// Handles install/uninstall/update of themes from the plugin registry. +// Themes are pure JSON (no executable code, no .tableplugin bundles). +// + +import CryptoKit +import Foundation +import os + +@MainActor +@Observable +internal final class ThemeRegistryInstaller { + static let shared = ThemeRegistryInstaller() + + @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeRegistryInstaller") + + private init() {} + + // MARK: - Install + + func install( + _ plugin: RegistryPlugin, + progress: @escaping @MainActor @Sendable (Double) -> Void + ) async throws { + guard !isInstalled(plugin.id) else { + throw PluginError.pluginConflict(existingName: plugin.name) + } + + let decodedThemes = try await downloadAndDecode(plugin, progress: progress) + + var installedThemes: [InstalledRegistryTheme] = [] + + for theme in decodedThemes { + try ThemeStorage.saveRegistryTheme(theme) + + installedThemes.append(InstalledRegistryTheme( + id: theme.id, + registryPluginId: plugin.id, + version: plugin.version, + installedDate: Date() + )) + } + + var meta = ThemeStorage.loadRegistryMeta() + meta.installed.append(contentsOf: installedThemes) + try ThemeStorage.saveRegistryMeta(meta) + + ThemeEngine.shared.reloadAvailableThemes() + progress(1.0) + + Self.logger.info("Installed \(installedThemes.count) theme(s) from registry plugin: \(plugin.id)") + } + + // MARK: - Uninstall + + func uninstall(registryPluginId: String) throws { + let removedThemeIds = try removeRegistryFiles(for: registryPluginId) + + ThemeEngine.shared.reloadAvailableThemes() + + // Fall back if the active theme was uninstalled + if removedThemeIds.contains(ThemeEngine.shared.activeTheme.id) { + ThemeEngine.shared.activateTheme(id: "tablepro.default-light") + } + + Self.logger.info("Uninstalled registry themes for plugin: \(registryPluginId)") + } + + // MARK: - Update + + func update( + _ 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 + _ = try removeRegistryFiles(for: plugin.id) + + // Write new themes + var installedThemes: [InstalledRegistryTheme] = [] + for theme in stagedThemes { + try ThemeStorage.saveRegistryTheme(theme) + installedThemes.append(InstalledRegistryTheme( + id: theme.id, + registryPluginId: plugin.id, + version: plugin.version, + installedDate: Date() + )) + } + + var meta = ThemeStorage.loadRegistryMeta() + meta.installed.append(contentsOf: installedThemes) + try ThemeStorage.saveRegistryMeta(meta) + + // Single reload after swap is complete — no intermediate flicker + ThemeEngine.shared.reloadAvailableThemes() + + if ThemeEngine.shared.availableThemes.contains(where: { $0.id == activeId }) { + ThemeEngine.shared.activateTheme(id: activeId) + } + + Self.logger.info("Updated \(installedThemes.count) theme(s) for registry plugin: \(plugin.id)") + } + + /// Removes meta entries and files for a registry plugin. Returns removed theme IDs. + /// Does NOT reload ThemeEngine or trigger fallback — callers manage that. + @discardableResult + private func removeRegistryFiles(for registryPluginId: String) throws -> Set { + var meta = ThemeStorage.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) + + for entry in themesToRemove { + do { + try ThemeStorage.deleteRegistryTheme(id: entry.id) + } catch { + Self.logger.warning("Failed to delete registry theme file \(entry.id): \(error)") + } + } + + return removedIds + } + + // MARK: - Query + + func isInstalled(_ registryPluginId: String) -> Bool { + let meta = ThemeStorage.loadRegistryMeta() + return meta.installed.contains { $0.registryPluginId == registryPluginId } + } + + func installedVersion(for registryPluginId: String) -> String? { + let meta = ThemeStorage.loadRegistryMeta() + return meta.installed.first { $0.registryPluginId == registryPluginId }?.version + } + + func availableUpdates(manifest: RegistryManifest) -> [RegistryPlugin] { + let meta = ThemeStorage.loadRegistryMeta() + let installedVersions = Dictionary( + meta.installed.map { ($0.registryPluginId, $0.version) }, + uniquingKeysWith: { first, _ in first } + ) + + return manifest.plugins.filter { plugin in + guard plugin.category == .theme, + let installed = installedVersions[plugin.id] else { return false } + return plugin.version.compare(installed, options: .numeric) == .orderedDescending + } + } + + // MARK: - Download & Decode + + private func downloadAndDecode( + _ plugin: RegistryPlugin, + progress: @escaping @MainActor @Sendable (Double) -> Void + ) async throws -> [ThemeDefinition] { + if let minAppVersion = plugin.minAppVersion { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + if appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending { + throw PluginError.incompatibleWithCurrentApp(minimumRequired: minAppVersion) + } + } + + let resolved = try plugin.resolvedBinary() + + guard let downloadURL = URL(string: resolved.url) else { + throw PluginError.downloadFailed("Invalid download URL") + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let session = RegistryClient.shared.session + let (tempDownloadURL, response) = try await session.download(from: downloadURL) + + guard let httpResponse = response as? HTTPURLResponse, + (200 ... 299).contains(httpResponse.statusCode) else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw PluginError.downloadFailed("HTTP \(statusCode)") + } + + progress(0.5) + + let downloadedData = try Data(contentsOf: tempDownloadURL) + let digest = SHA256.hash(data: downloadedData) + let hexChecksum = digest.map { String(format: "%02x", $0) }.joined() + + if hexChecksum != resolved.sha256.lowercased() { + throw PluginError.checksumMismatch + } + + progress(0.7) + + let extractDir = tempDir.appendingPathComponent("extracted", isDirectory: true) + try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) + + let zipPath = tempDir.appendingPathComponent("theme.zip") + try FileManager.default.moveItem(at: tempDownloadURL, to: zipPath) + + try await Task.detached(priority: .userInitiated) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.arguments = ["-xk", zipPath.path, extractDir.path] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw PluginError.installFailed("Failed to extract theme archive") + } + }.value + + let jsonFiles = try findJsonFiles(in: extractDir) + guard !jsonFiles.isEmpty else { + throw PluginError.installFailed("No theme files found in archive") + } + + 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)" + decodedThemes.append(theme) + } + + let ids = decodedThemes.map(\.id) + guard ids.count == Set(ids).count else { + throw PluginError.installFailed("Theme pack contains duplicate IDs after namespace rewrite") + } + + return decodedThemes + } + + // MARK: - Helpers + + private func findJsonFiles(in directory: URL) throws -> [URL] { + var results: [URL] = [] + let fm = FileManager.default + + guard let enumerator = fm.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + for case let fileURL as URL in enumerator { + if fileURL.pathExtension.lowercased() == "json" && + fileURL.lastPathComponent != "registry-meta.json" { + results.append(fileURL) + } + } + + return results + } +} diff --git a/TablePro/Theme/ThemeStorage.swift b/TablePro/Theme/ThemeStorage.swift new file mode 100644 index 00000000..c34e6fb8 --- /dev/null +++ b/TablePro/Theme/ThemeStorage.swift @@ -0,0 +1,269 @@ +// +// 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 = { + guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return FileManager.default.temporaryDirectory.appendingPathComponent("TablePro/Themes", isDirectory: true) + } + return appSupport.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 no bundled themes loaded, use compiled presets as fallback + if themes.isEmpty { + themes = [ThemeDefinition.default] + } + + // Load registry themes + ensureRegistryDirectory() + themes.append(contentsOf: loadThemes(from: registryThemesDirectory, isBuiltIn: false)) + + // Load user themes + ensureUserDirectory() + themes.append(contentsOf: loadThemes(from: userThemesDirectory, isBuiltIn: false)) + + return themes + } + + // MARK: - Load Single Theme + + static func loadTheme(id: String) -> ThemeDefinition? { + guard let userFile = try? themeFileURL(in: userThemesDirectory, id: id) else { return nil } + if let theme = loadTheme(from: userFile) { + return theme + } + + if let registryFile = try? themeFileURL(in: registryThemesDirectory, id: id), + let theme = loadTheme(from: registryFile) { + return theme + } + + if let bundleDir = bundledThemesDirectory, + let bundleFile = try? themeFileURL(in: bundleDir, id: id), + let theme = loadTheme(from: bundleFile) { + return theme + } + + // Fallback to compiled presets + 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: - Active Theme Persistence + + private static let activeThemeKey = "com.TablePro.settings.activeThemeId" + + static func loadActiveThemeId() -> String { + UserDefaults.standard.string(forKey: activeThemeKey) ?? "tablepro.default-light" + } + + static func saveActiveThemeId(_ id: String) { + UserDefaults.standard.set(id, forKey: activeThemeKey) + } + + // 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 { + logger.error("Failed to load theme from \(url.lastPathComponent): \(error)") + return nil + } + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift new file mode 100644 index 00000000..28072480 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift @@ -0,0 +1,262 @@ +// +// ThemeEditorColorsSection.swift +// TablePro +// + +import AppKit +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 + +internal struct ThemeEditorColorsSection: View { + private var engine: ThemeEngine { ThemeEngine.shared } + private var theme: ThemeDefinition { engine.activeTheme } + + var body: some View { + Form { + editorSection + syntaxSection + dataGridSection + interfaceSection + statusSection + badgesSection + sidebarSection + toolbarSection + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + // MARK: - Editor + + 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 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)) + } + } + } + + // MARK: - Data Grid + + private var dataGridSection: some View { + Section(String(localized: "Data Grid")) { + LabeledContent(String(localized: "Background")) { + HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.background)) + } + LabeledContent(String(localized: "Text")) { + HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.text)) + } + LabeledContent(String(localized: "Alternate Row")) { + HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.alternateRow)) + } + LabeledContent(String(localized: "NULL Value")) { + HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.nullValue)) + } + LabeledContent(String(localized: "Bool True")) { + HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.boolTrue)) + } + LabeledContent(String(localized: "Bool False")) { + HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.boolFalse)) + } + LabeledContent(String(localized: "Row Number")) { + HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.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)) + } + LabeledContent(String(localized: "Focus Border")) { + HexColorPicker(label: "", hex: colorBinding(for: \.dataGrid.focusBorder)) + } + } + } + + // MARK: - Interface + + private var interfaceSection: some View { + Section(String(localized: "Interface")) { + LabeledContent(String(localized: "Window Background")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.windowBackground)) + } + LabeledContent(String(localized: "Control Background")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.controlBackground)) + } + LabeledContent(String(localized: "Card Background")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.cardBackground)) + } + LabeledContent(String(localized: "Border")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.border)) + } + LabeledContent(String(localized: "Primary Text")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.primaryText)) + } + LabeledContent(String(localized: "Secondary Text")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.secondaryText)) + } + LabeledContent(String(localized: "Tertiary Text")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.tertiaryText)) + } + LabeledContent(String(localized: "Selection")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.selectionBackground)) + } + LabeledContent(String(localized: "Hover")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.hoverBackground)) + } + } + } + + 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)) + } + } + } + + private var badgesSection: some View { + Section(String(localized: "Badges")) { + LabeledContent(String(localized: "Badge Background")) { + HexColorPicker(label: "", hex: colorBinding(for: \.ui.badges.background)) + } + 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)) + } + } + } + + // MARK: - Sidebar + + private var sidebarSection: some View { + Section(String(localized: "Sidebar")) { + LabeledContent(String(localized: "Background")) { + HexColorPicker(label: "", hex: colorBinding(for: \.sidebar.background)) + } + LabeledContent(String(localized: "Text")) { + HexColorPicker(label: "", hex: colorBinding(for: \.sidebar.text)) + } + LabeledContent(String(localized: "Selected Item")) { + HexColorPicker(label: "", hex: colorBinding(for: \.sidebar.selectedItem)) + } + LabeledContent(String(localized: "Hover")) { + HexColorPicker(label: "", hex: colorBinding(for: \.sidebar.hover)) + } + LabeledContent(String(localized: "Section Header")) { + HexColorPicker(label: "", hex: colorBinding(for: \.sidebar.sectionHeader)) + } + } + } + + // MARK: - Toolbar + + private var toolbarSection: some View { + Section(String(localized: "Toolbar")) { + LabeledContent(String(localized: "Secondary Text")) { + HexColorPicker(label: "", hex: colorBinding(for: \.toolbar.secondaryText)) + } + LabeledContent(String(localized: "Tertiary Text")) { + HexColorPicker(label: "", hex: colorBinding(for: \.toolbar.tertiaryText)) + } + } + } + + // MARK: - Helpers + + private func colorBinding(for keyPath: WritableKeyPath) -> Binding { + Binding( + get: { theme[keyPath: keyPath] }, + set: { newValue in + guard theme.isEditable else { return } + var updated = theme + updated[keyPath: keyPath] = newValue + try? engine.saveUserTheme(updated) + } + ) + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorLayoutSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorLayoutSection.swift new file mode 100644 index 00000000..47560f7d --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorLayoutSection.swift @@ -0,0 +1,149 @@ +// +// ThemeEditorLayoutSection.swift +// TablePro +// + +import SwiftUI + +internal struct ThemeEditorLayoutSection: View { + private var engine: ThemeEngine { ThemeEngine.shared } + private var theme: ThemeDefinition { engine.activeTheme } + + var body: some View { + Form { + typographySection + spacingSection + iconSizesSection + cornerRadiusSection + rowHeightsSection + animationsSection + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + // MARK: - Sections + + private var typographySection: some View { + Section(String(localized: "Typography")) { + numericField(String(localized: "Tiny"), keyPath: \.typography.tiny, range: 1...20) + numericField(String(localized: "Caption"), keyPath: \.typography.caption, range: 1...20) + numericField(String(localized: "Small"), keyPath: \.typography.small, range: 1...20) + numericField(String(localized: "Medium"), keyPath: \.typography.medium, range: 1...20) + numericField(String(localized: "Body"), keyPath: \.typography.body, range: 1...20) + numericField(String(localized: "Title 3"), keyPath: \.typography.title3, range: 1...30) + numericField(String(localized: "Title 2"), keyPath: \.typography.title2, range: 1...30) + } + } + + private var spacingSection: some View { + Section(String(localized: "Spacing")) { + numericField("xxxs", keyPath: \.spacing.xxxs, range: 0...10) + numericField("xxs", keyPath: \.spacing.xxs, range: 0...20) + numericField("xs", keyPath: \.spacing.xs, range: 0...30) + numericField("sm", keyPath: \.spacing.sm, range: 0...30) + numericField("md", keyPath: \.spacing.md, range: 0...40) + numericField("lg", keyPath: \.spacing.lg, range: 0...40) + numericField("xl", keyPath: \.spacing.xl, range: 0...50) + } + } + + private var iconSizesSection: some View { + Section(String(localized: "Icon Sizes")) { + numericField(String(localized: "Tiny Dot"), keyPath: \.iconSizes.tinyDot, range: 2...20) + numericField(String(localized: "Status Dot"), keyPath: \.iconSizes.statusDot, range: 2...20) + numericField(String(localized: "Small"), keyPath: \.iconSizes.small, range: 4...30) + numericField(String(localized: "Default"), keyPath: \.iconSizes.default, range: 4...30) + numericField(String(localized: "Medium"), keyPath: \.iconSizes.medium, range: 4...40) + numericField(String(localized: "Large"), keyPath: \.iconSizes.large, range: 8...50) + numericField(String(localized: "Extra Large"), keyPath: \.iconSizes.extraLarge, range: 8...60) + numericField(String(localized: "Huge"), keyPath: \.iconSizes.huge, range: 16...80) + numericField(String(localized: "Massive"), keyPath: \.iconSizes.massive, range: 32...128) + } + } + + private var cornerRadiusSection: some View { + Section(String(localized: "Corner Radius")) { + numericField(String(localized: "Small"), keyPath: \.cornerRadius.small, range: 0...20) + numericField(String(localized: "Medium"), keyPath: \.cornerRadius.medium, range: 0...20) + numericField(String(localized: "Large"), keyPath: \.cornerRadius.large, range: 0...30) + } + } + + private var rowHeightsSection: some View { + Section(String(localized: "Row Heights")) { + numericField(String(localized: "Compact"), keyPath: \.rowHeights.compact, range: 16...60) + numericField(String(localized: "Table"), keyPath: \.rowHeights.table, range: 20...80) + numericField(String(localized: "Comfortable"), keyPath: \.rowHeights.comfortable, range: 30...100) + } + } + + private var animationsSection: some View { + Section(String(localized: "Animations")) { + doubleField(String(localized: "Fast"), keyPath: \.animations.fast, range: 0.01...1.0) + doubleField(String(localized: "Normal"), keyPath: \.animations.normal, range: 0.01...1.0) + doubleField(String(localized: "Smooth"), keyPath: \.animations.smooth, range: 0.01...1.0) + doubleField(String(localized: "Slow"), keyPath: \.animations.slow, range: 0.01...2.0) + } + } + + // MARK: - Binding Helpers + + private func binding(for keyPath: WritableKeyPath) -> Binding { + Binding( + get: { theme[keyPath: keyPath] }, + set: { newValue in + guard theme.isEditable else { return } + var updated = theme + updated[keyPath: keyPath] = newValue + try? engine.saveUserTheme(updated) + } + ) + } + + private func doubleBinding(for keyPath: WritableKeyPath) -> Binding { + Binding( + get: { theme[keyPath: keyPath] }, + set: { newValue in + guard theme.isEditable else { return } + var updated = theme + updated[keyPath: keyPath] = newValue + try? engine.saveUserTheme(updated) + } + ) + } + + // MARK: - Row Helpers + + private func numericField( + _ label: String, + keyPath: WritableKeyPath, + range: ClosedRange, + step: CGFloat = 1 + ) -> some View { + LabeledContent(label) { + HStack(spacing: 4) { + TextField("", value: binding(for: keyPath), formatter: NumberFormatter()) + .frame(width: 60) + Stepper("", value: binding(for: keyPath), in: range, step: step) + .labelsHidden() + } + } + } + + private func doubleField( + _ label: String, + keyPath: WritableKeyPath, + range: ClosedRange, + step: Double = 0.05 + ) -> some View { + LabeledContent(label) { + HStack(spacing: 4) { + TextField("", value: doubleBinding(for: keyPath), format: .number.precision(.fractionLength(2))) + .frame(width: 60) + Stepper("", value: doubleBinding(for: keyPath), in: range, step: step) + .labelsHidden() + } + } + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift new file mode 100644 index 00000000..c5fc3c1d --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift @@ -0,0 +1,128 @@ +// +// ThemeEditorView.swift +// TablePro +// +// Right panel of the appearance HSplitView: theme header, accent color, and tabbed editor sections. +// + +import SwiftUI + +internal struct ThemeEditorView: View { + @Binding var selectedThemeId: String + + private var engine: ThemeEngine { ThemeEngine.shared } + private var theme: ThemeDefinition { engine.activeTheme } + private var isEditable: Bool { theme.isEditable } + + @State private var activeTab: EditorTab = .fonts + + @State private var errorMessage: String? + @State private var showError = false + + private enum EditorTab: String, CaseIterable { + case fonts = "Fonts" + case colors = "Colors" + case layout = "Layout" + + var localizedName: String { + switch self { + case .fonts: return String(localized: "Fonts") + case .colors: return String(localized: "Colors") + case .layout: return String(localized: "Layout") + } + } + } + + var body: some View { + VStack(spacing: 0) { + Text(theme.name) + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + + Picker("", selection: $activeTab) { + ForEach(EditorTab.allCases, id: \.self) { tab in + Text(tab.localizedName).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.bottom, 8) + + Divider() + + tabContent + } + .alert(String(localized: "Error"), isPresented: $showError) { + Button(String(localized: "OK")) {} + } message: { + if let errorMessage { + Text(errorMessage) + } + } + } + + @ViewBuilder + private var tabContent: some View { + switch activeTab { + case .fonts: + ThemeEditorFontsSection(onThemeDuplicated: { newTheme in + selectedThemeId = newTheme.id + }) + case .colors: + if isEditable { + ThemeEditorColorsSection() + } else { + duplicatePrompt + } + case .layout: + if isEditable { + ThemeEditorLayoutSection() + } else { + duplicatePrompt + } + } + } + + private var duplicatePrompt: some View { + VStack(spacing: 12) { + Spacer() + + Image(systemName: "lock.fill") + .font(.system(size: 24)) + .foregroundStyle(.secondary) + + Text(theme.isBuiltIn + ? String(localized: "This is a built-in theme.") + : String(localized: "This is a registry theme.")) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + + Text(String(localized: "Duplicate it to customize colors and layout.")) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + + Button(String(localized: "Duplicate Theme")) { + duplicateAndSelect() + } + .controlSize(.large) + + Spacer() + } + .frame(maxWidth: .infinity) + } + + private func duplicateAndSelect() { + let copy = engine.duplicateTheme(theme, newName: theme.name + " (Copy)") + do { + try engine.saveUserTheme(copy) + engine.activateTheme(copy) + selectedThemeId = copy.id + } catch { + errorMessage = error.localizedDescription + showError = true + } + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeListRowView.swift b/TablePro/Views/Settings/Appearance/ThemeListRowView.swift new file mode 100644 index 00000000..6f838a76 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeListRowView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +internal struct ThemeListRowView: View { + let theme: ThemeDefinition + + var body: some View { + HStack(spacing: 8) { + ThemePreviewCard(theme: theme, isActive: false, onSelect: {}, size: .compact) + + VStack(alignment: .leading, spacing: 2) { + Text(theme.name) + .font(.system(size: 12)) + .lineLimit(1) + + Text(theme.isBuiltIn + ? String(localized: "Built-in") + : theme.isRegistry + ? String(localized: "Registry") + : String(localized: "Custom")) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift new file mode 100644 index 00000000..a3e868b6 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -0,0 +1,203 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +internal struct ThemeListView: View { + @Binding var selectedThemeId: String + + private var engine: ThemeEngine { ThemeEngine.shared } + + @State private var showDeleteConfirmation = false + @State private var errorMessage: String? + @State private var showError = false + + private var builtInThemes: [ThemeDefinition] { + engine.availableThemes.filter(\.isBuiltIn) + } + + private var registryThemes: [ThemeDefinition] { + engine.registryThemes + } + + private var customThemes: [ThemeDefinition] { + engine.availableThemes.filter(\.isEditable) + } + + private var selectedTheme: ThemeDefinition? { + engine.availableThemes.first { $0.id == selectedThemeId } + } + + private var isDeleteDisabled: Bool { + guard let theme = selectedTheme else { return true } + return !theme.isEditable + } + + var body: some View { + VStack(spacing: 0) { + List(selection: $selectedThemeId) { + Section("Built-in") { + ForEach(builtInThemes) { theme in + ThemeListRowView(theme: theme) + .tag(theme.id) + } + } + + if !registryThemes.isEmpty { + Section("Registry") { + ForEach(registryThemes) { theme in + ThemeListRowView(theme: theme) + .tag(theme.id) + } + } + } + + if !customThemes.isEmpty { + Section("Custom") { + ForEach(customThemes) { theme in + ThemeListRowView(theme: theme) + .tag(theme.id) + } + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + + 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) + } + .menuIndicator(.hidden) + .buttonStyle(.borderless) + .frame(width: 28) + + Button { + showDeleteConfirmation = true + } label: { + Image(systemName: "minus") + .frame(width: 24, height: 24) + } + .buttonStyle(.borderless) + .disabled(isDeleteDisabled) + + 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) + } + .menuIndicator(.hidden) + .buttonStyle(.borderless) + .frame(width: 28) + + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .onChange(of: selectedThemeId) { + engine.activateTheme(id: selectedThemeId) + } + .alert(String(localized: "Delete Theme"), isPresented: $showDeleteConfirmation) { + 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(localized: "Are you sure you want to delete \"\(name)\"?")) + } + .alert(String(localized: "Error"), isPresented: $showError) { + Button(String(localized: "OK")) {} + } message: { + if let errorMessage { + Text(errorMessage) + } + } + } + + // MARK: - Actions + + private func duplicateActiveTheme() { + let theme = engine.activeTheme + let copy = engine.duplicateTheme(theme, newName: theme.name + " (Copy)") + do { + try engine.saveUserTheme(copy) + engine.activateTheme(copy) + selectedThemeId = copy.id + } catch { + errorMessage = error.localizedDescription + showError = true + } + } + + private func deleteSelectedTheme() { + do { + try engine.deleteUserTheme(id: selectedThemeId) + selectedThemeId = engine.activeTheme.id + } catch { + errorMessage = error.localizedDescription + showError = true + } + } + + 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 + } + } + + private func exportActiveTheme() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = engine.activeTheme.name + ".json" + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + try? engine.exportTheme(engine.activeTheme, to: url) + } + + private func importTheme() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let imported = try engine.importTheme(from: url) + engine.activateTheme(imported) + selectedThemeId = imported.id + } catch { + errorMessage = error.localizedDescription + showError = true + } + } +} diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 63b4bc2f..6f66088e 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -2,7 +2,7 @@ // AppearanceSettingsView.swift // TablePro // -// Settings for theme and accent color +// Settings for theme browsing, customization, and accent color. // import SwiftUI @@ -11,34 +11,39 @@ struct AppearanceSettingsView: View { @Binding var settings: AppearanceSettings var body: some View { - Form { - Picker("Appearance:", selection: $settings.theme) { - ForEach(AppTheme.allCases) { theme in - Text(theme.displayName).tag(theme) - } - } - .pickerStyle(.segmented) - - Picker("Accent Color:", selection: $settings.accentColor) { - ForEach(AccentColorOption.allCases) { option in - HStack { - if option != .system { - Circle() - .fill(option.color) - .frame(width: 12, height: 12) - } - Text(option.displayName) + VStack(spacing: 0) { + HStack(spacing: 12) { + Text("Appearance") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + Picker("", selection: $settings.appearanceMode) { + ForEach(AppAppearanceMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) } - .tag(option) } + .pickerStyle(.segmented) + .fixedSize() + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + + Divider() + + HSplitView { + ThemeListView(selectedThemeId: $settings.activeThemeId) + .frame(minWidth: 180, idealWidth: 210, maxWidth: 250) + + ThemeEditorView(selectedThemeId: $settings.activeThemeId) + .frame(minWidth: 400) } } - .formStyle(.grouped) - .scrollContentBackground(.hidden) } } #Preview { AppearanceSettingsView(settings: .constant(.default)) - .frame(width: 450, height: 200) + .frame(width: 720, height: 500) } diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift index 1c7193da..dfa4f177 100644 --- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift +++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift @@ -216,16 +216,26 @@ struct BrowsePluginsView: View { private func isPluginInstalled(_ pluginId: String) -> Bool { pluginManager.plugins.contains { $0.id == pluginId } + || ThemeRegistryInstaller.shared.isInstalled(pluginId) } private func installPlugin(_ plugin: RegistryPlugin) { Task { installTracker.beginInstall(pluginId: plugin.id) do { - _ = try await pluginManager.installFromRegistry(plugin) { fraction in - installTracker.updateProgress(pluginId: plugin.id, fraction: fraction) - if fraction >= 1.0 { - installTracker.markInstalling(pluginId: plugin.id) + if plugin.category == .theme { + try await ThemeRegistryInstaller.shared.install(plugin) { fraction in + installTracker.updateProgress(pluginId: plugin.id, fraction: fraction) + if fraction >= 1.0 { + installTracker.markInstalling(pluginId: plugin.id) + } + } + } else { + _ = try await pluginManager.installFromRegistry(plugin) { fraction in + installTracker.updateProgress(pluginId: plugin.id, fraction: fraction) + if fraction >= 1.0 { + installTracker.markInstalling(pluginId: plugin.id) + } } } installTracker.completeInstall(pluginId: plugin.id) diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift index cabe1ca5..42c383cf 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift @@ -86,6 +86,11 @@ struct RegistryPluginDetailView: View { if !isInstalled { Divider() installActionView + } else if plugin.category == .theme { + Divider() + Label("Installed", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.callout) } } .padding(20) @@ -123,7 +128,9 @@ struct RegistryPluginDetailView: View { .controlSize(.regular) } } else { - Button("Install Plugin") { onInstall() } + Button(plugin.category == .theme + ? String(localized: "Install Theme") + : String(localized: "Install Plugin")) { onInstall() } .buttonStyle(.borderedProminent) .controlSize(.regular) } diff --git a/docs/customization/appearance.mdx b/docs/customization/appearance.mdx index b1aaf22d..0c869429 100644 --- a/docs/customization/appearance.mdx +++ b/docs/customization/appearance.mdx @@ -1,6 +1,6 @@ --- title: Appearance -description: Theme modes, accent colors, connection colors, and database type color coding +description: Theme engine with built-in presets, custom themes, color and font customization, import/export --- # Appearance Settings @@ -8,7 +8,7 @@ description: Theme modes, accent colors, connection colors, and database type co Configure how TablePro looks in **Settings** > **Appearance**. {/* Screenshot: Appearance settings panel */} - + **Appearance**. /> -## Theme +## Theme Engine -Three theme modes: +TablePro ships with 9 built-in themes and supports custom themes. You can also install community themes from the plugin registry. Each theme controls all visual aspects of the app: editor syntax colors, data grid colors, sidebar and toolbar styling, UI element colors, fonts, spacing, and layout tokens. -| Theme | Description | -|-------|-------------| -| **System** | Matches macOS appearance automatically (default) | -| **Light** | Always light | -| **Dark** | Always dark | +### Theme Editor Layout -### System Theme +The Appearance tab is a split view with two panels: -When set to **System**, TablePro follows your macOS appearance: +- **Left panel** (sidebar list): Up to three sections: "Built-in", "Registry", and "Custom". Each row shows a 72x45 color thumbnail, the theme name, and a source label. Select a theme to activate it and edit it in the right panel. +- **Right panel** (editor): Displays the selected theme's name and a segmented picker with three tabs: Fonts, Colors, and Layout. -1. Open **System Settings** > **Appearance** -2. Choose Light, Dark, or Auto -3. TablePro updates automatically +At the bottom of the sidebar, an action bar provides: + +| Button | Action | +|--------|--------| +| **+** menu | New Theme, Import... | +| **-** button | Delete selected theme (disabled for built-in and registry themes) | +| **Gear** menu | Duplicate, Export..., Uninstall (registry themes only) | + +### Built-in Themes + +| Theme | Appearance | Based On | +|-------|------------|----------| +| **Default Light** | Light | macOS system colors | +| **Default Dark** | Dark | macOS system colors | +| **Dracula** | Dark | Dracula color scheme | +| **Solarized Light** | Light | Ethan Schoonover's Solarized | +| **Solarized Dark** | Dark | Ethan Schoonover's Solarized | +| **One Dark** | Dark | Atom One Dark | +| **GitHub Light** | Light | GitHub UI | +| **GitHub Dark** | Dark | GitHub UI | +| **Nord** | Dark | Arctic north-bluish palette | + +### Theme Appearance Mode + +Each theme has a fixed `appearance` property: `light`, `dark`, or `auto`. When you activate a theme, TablePro sets `NSApp.appearance` to match. A light theme forces light chrome across the entire app; a dark theme forces dark chrome; `auto` follows the system setting. + +You pick one active theme at a time. There is no separate "light theme" and "dark theme" assignment. {/* Screenshot: Side-by-side light and dark themes */} @@ -53,82 +74,109 @@ When set to **System**, TablePro follows your macOS appearance: /> -### Light Theme +## Customizing Themes + +Select a theme in the sidebar, then edit it directly in the right panel. + +### Fonts Tab -- White/light gray backgrounds -- Dark text for readability -- Good for well-lit environments -- Lower contrast for bright rooms +Always editable, even on built-in themes. If you change a font on a built-in theme, TablePro auto-duplicates it as a custom theme so the original stays untouched. -### Dark Theme +Four settings: -- Dark gray/black backgrounds -- Light text for contrast -- Reduces eye strain in low light -- Lower battery usage on OLED displays +- **Editor font family** (used in the SQL editor) +- **Editor font size** +- **Data grid font family** (used in result grid cells) +- **Data grid font size** + +A live preview updates as you change values. -Dark theme works well for long sessions, especially in dim environments. +If you only want to change fonts without touching colors, just adjust the font fields. The auto-duplicate handles the rest. -## Accent Color - -| Option | Description | -|--------|-------------| -| **System** | Uses your macOS accent color | -| **Blue** | Classic blue | -| **Purple** | Purple | -| **Pink** | Pink | -| **Red** | Red | -| **Orange** | Orange | -| **Yellow** | Yellow | -| **Green** | Green | -| **Graphite** | Gray/neutral | - -### Where Accent Color Appears - -- Selected items and highlights -- Buttons and interactive elements -- Progress indicators -- Focus rings -- Active tab indicators - -{/* Screenshot: Different accent colors applied */} - - Accent colors - Accent colors - +### Colors Tab -{/* Screenshot: Accent color applied to UI */} - - Accent color applied to UI elements - Accent color applied to UI elements - +Defines 50+ colors across 5 categories. For built-in themes, the Colors tab shows a "Duplicate to customize" prompt. Duplicate first, then edit freely. + +| Category | What It Controls | +|----------|-----------------| +| **Editor** | Background, text, cursor, current line highlight, selection, line numbers, invisibles, and 8 syntax colors (keyword, string, number, comment, null, operator, function, type) | +| **Data Grid** | Background, text, alternate row, null value, boolean true/false, row number, modified/inserted/deleted row highlights, focus border | +| **Interface** | Window/control/card backgrounds, borders, primary/secondary/tertiary text, accent color, selection, hover, status colors (success/warning/error/info), badge colors | +| **Sidebar** | Background, text, selected item, hover, section header | +| **Toolbar** | Secondary text, tertiary text | + +Each color is edited via a hex color picker. + +### Layout Tab + +Defines 30+ layout tokens across 6 sections. Same lock behavior as Colors: built-in themes require duplication before editing. + +| Section | Tokens | +|---------|--------| +| **Typography** | Font sizes: tiny (9), caption (10), small (11), medium (12), body (13), title3 (15), title2 (17) | +| **Spacing** | Scale from xxxs (2) to xl (24), plus list row insets | +| **Icon Sizes** | 9 sizes from tinyDot (6) to massive (64) | +| **Corner Radius** | Small (4), medium (6), large (8) | +| **Row Heights** | Compact (24), table (32), comfortable (44) | +| **Animations** | Durations: fast (0.1s), normal (0.15s), smooth (0.2s), slow (0.3s) | + +Values in parentheses are defaults. + +## Import and Export + +### Exporting a Theme + +1. Select the theme in the sidebar +2. Click the **gear** menu at the bottom of the sidebar +3. Choose **Export...** +4. Pick a save location + +The exported `.json` file contains all colors, fonts, and layout tokens. + +### Importing a Theme + +1. Click the **+** menu at the bottom of the sidebar +2. Choose **Import...** +3. Select a `.json` theme file + +The imported theme appears in the "Custom" section. + + +Custom themes are stored as JSON files in `~/Library/Application Support/TablePro/Themes/`. + + +## Registry Themes -### Matching macOS +Install community-created themes from the plugin registry without leaving the app. -To use your system accent color: +### Installing a Theme -1. Set TablePro's accent color to **System** -2. Open **System Settings** > **Appearance** -3. Pick your preferred accent color -4. TablePro updates automatically +1. Go to **Settings > Plugins > Browse** +2. Filter by **Themes** category +3. Click **Install Theme** on the theme you want +4. The theme appears in the "Registry" section of **Settings > Appearance** + +Registry themes are read-only. To customize one, duplicate it first: the copy appears in the "Custom" section and is fully editable. + +### Uninstalling a Theme + +1. Select the registry theme in **Settings > Appearance** +2. Click the **gear** menu +3. Choose **Uninstall** + +If the uninstalled theme was active, TablePro falls back to Default Light. + + +Registry themes are stored in `~/Library/Application Support/TablePro/Themes/Registry/`. They use a `registry.*` ID prefix to avoid conflicts with built-in and custom themes. + + +### Theme File Format + +Theme files are JSON with these top-level keys: `id`, `name`, `version`, `appearance`, `author`, `editor`, `dataGrid`, `ui`, `sidebar`, `toolbar`, `fonts`, `spacing`, `typography`, `iconSizes`, `cornerRadius`, `rowHeights`, `animations`. + +All fields are optional on import. Missing fields fall back to Default Light values. ## Connection Colors @@ -224,98 +272,6 @@ These appear on: /> -## Editor Theme - -SQL syntax highlighting colors: - -| Element | Color | -|---------|-------| -| Keywords | Pink | -| Strings | Green | -| Numbers | Blue | -| Comments | Gray | -| Identifiers | Default text color | - -Colors automatically adapt to light/dark theme. - -## UI Elements - -### Status Colors - -| Color | Meaning | -|-------|---------| -| Green | Success, connected, valid | -| Orange | Warning, in progress, connecting | -| Red | Error, failed, invalid | -| Blue | Information | -| Gray | Neutral, disconnected | - -### Data Type Colors - -In the data grid: - -| Type | Display | -|------|---------| -| NULL | Gray, italicized | -| Boolean True | Green | -| Boolean False | Red | -| Numbers | Standard color | -| Text | Standard color | - -## Best Practices - -### For Readability - -1. **Match environment**: Dark theme in low light, light in bright rooms -2. **Contrast**: Make sure contrast is sufficient for your lighting -3. **Font size**: Increase if text is hard to read - -### For Organization - -1. **Connection colors**: Pick a consistent color scheme -2. **Environment coding**: Red=prod, green=dev, etc. -3. **Team conventions**: Agree on color meanings with your team - -### For Eye Health - -1. **Dark theme at night**: Reduces blue light exposure -2. **Take breaks**: Look away from screen periodically -3. **Night Shift**: Enable macOS Night Shift for warmer colors - -## Accessibility - -### Color Blindness - -1. Don't rely on color alone for meaning -2. Connection names provide text identification -3. Icons supplement color information - -### High Contrast - -1. Use light or dark theme (not auto) -2. Choose high-contrast accent colors (blue, green) -3. Increase font sizes in editor settings - -## Troubleshooting - -### Theme Not Changing - -1. Restart TablePro after changing settings -2. Check macOS appearance if using System theme -3. Verify settings were saved - -### Colors Look Wrong - -1. Check display color profile in System Settings -2. Verify monitor calibration -3. Check True Tone settings on supported displays - -### Accent Color Not Applying - -1. Restart TablePro -2. Set to a specific color instead of System -3. Check for conflicting system extensions - ## Related Settings diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx index 6cf65bb6..576396ad 100644 --- a/docs/development/plugin-registry.mdx +++ b/docs/development/plugin-registry.mdx @@ -100,6 +100,31 @@ These two database drivers ship as downloadable plugins instead of being bundled Replace `` with the actual SHA-256 output from `build-plugin.sh`. The `binaries` array provides per-architecture downloads; `downloadURL`/`sha256` flat fields point to arm64 as fallback for older app versions. +## Theme Distribution + +Themes use the same registry manifest but with `category: "theme"`. Unlike database driver plugins, themes are pure JSON data with no executable code, no code signing, and no `.tableplugin` bundle. + +A theme registry entry's ZIP contains one or more `.json` files, each a valid `ThemeDefinition`. On install, TablePro extracts the JSONs, rewrites their IDs to `registry.{pluginId}.{originalSuffix}`, and stores them in `~/Library/Application Support/TablePro/Themes/Registry/`. + +Example theme registry entry: + +```json +{ + "id": "com.example.monokai-theme", + "name": "Monokai Theme", + "version": "1.0.0", + "summary": "Classic Monokai color scheme for TablePro", + "author": { "name": "Theme Author" }, + "category": "theme", + "downloadURL": "https://example.com/monokai-theme.zip", + "sha256": "", + "iconName": "paintpalette", + "isVerified": false +} +``` + +Theme packs (multiple themes in one ZIP) are supported. All themes from a pack are linked to the same registry plugin ID, so uninstalling removes all of them. + ## Custom Registry URL For enterprise or private plugin registries, you can override the default registry URL: diff --git a/docs/vi/customization/appearance.mdx b/docs/vi/customization/appearance.mdx index ca6efc1d..25d577fc 100644 --- a/docs/vi/customization/appearance.mdx +++ b/docs/vi/customization/appearance.mdx @@ -1,6 +1,6 @@ --- title: Giao diện -description: Chế độ theme, màu nhấn, màu kết nối và mã màu loại database +description: Theme engine với preset có sẵn, theme tùy chỉnh, màu sắc và phông chữ, nhập/xuất --- # Cài đặt Giao diện @@ -8,7 +8,7 @@ description: Chế độ theme, màu nhấn, màu kết nối và mã màu loạ Cấu hình giao diện TablePro trong **Settings** > **Appearance**. {/* Screenshot: Appearance settings panel */} - + **Appearance**. /> -## Chủ đề +## Theme -Ba chế độ: +Tab Appearance hiển thị giao diện HSplitView chia thành hai phần: -| Chủ đề | Mô tả | -|-------|-------------| -| **System** | Theo giao diện macOS tự động (mặc định) | -| **Light** | Luôn sáng | -| **Dark** | Luôn tối | +- **Panel trái**: Sidebar danh sách theme, chia thành ba section — "Built-in" (9 theme có sẵn), "Registry" (theme cài từ registry) và "Custom" (theme tùy chỉnh). Mỗi hàng hiển thị thumbnail thu nhỏ, tên theme và nhãn. Thanh công cụ phía dưới có nút `+` (New Theme, Import...), `−` (xóa theme, không khả dụng cho built-in và registry), và biểu tượng gear (Duplicate, Export..., Uninstall cho registry theme). +- **Panel phải**: Hiển thị tên theme đang chọn, kèm segmented picker gồm ba tab: **Fonts**, **Colors** và **Layout**. -### Chủ đề Hệ thống +### Theme Có sẵn -Khi đặt **System**, TablePro theo giao diện macOS: +| Theme | Kiểu | Mô tả | +|-------|------|-------| +| **Default Light** | Light | Giao diện sáng chuẩn theo macOS | +| **Default Dark** | Dark | Giao diện tối chuẩn theo macOS | +| **Dracula** | Dark | Theme tối tông tím theo bảng màu Dracula | +| **Solarized Light** | Light | Bảng màu sáng ấm của Ethan Schoonover | +| **Solarized Dark** | Dark | Bảng màu tối mát của Ethan Schoonover | +| **One Dark** | Dark | Bảng màu One Dark của Atom | +| **GitHub Light** | Light | Màu giao diện sáng GitHub | +| **GitHub Dark** | Dark | Màu giao diện tối GitHub | +| **Nord** | Dark | Bảng màu Bắc Cực, tông xanh dương | -1. Mở **System Settings** > **Appearance** -2. Chọn Light, Dark hoặc Auto -3. TablePro tự động cập nhật +### Giao diện Hệ thống + +Mỗi theme có thuộc tính `appearance`: `light`, `dark` hoặc `auto`. Giá trị này quyết định `NSApp.appearance` khi theme được áp dụng. Khi giao diện macOS thay đổi giữa sáng và tối, TablePro chuyển sang theme bạn đã gán cho chế độ đó. {/* Screenshot: Side-by-side light and dark themes */} @@ -53,82 +60,100 @@ Khi đặt **System**, TablePro theo giao diện macOS: /> -### Chủ đề Sáng +## Tùy chỉnh Theme + +Panel phải có ba tab chỉnh sửa qua segmented picker: + +### Fonts + +Tab Fonts luôn cho phép chỉnh sửa, kể cả trên theme built-in. Khi bạn thay đổi font của theme built-in, TablePro tự động tạo bản sao (duplicate) để giữ nguyên preset gốc. -- Nền trắng/xám nhạt -- Văn bản tối dễ đọc -- Phù hợp môi trường đủ sáng -- Độ tương phản thấp cho phòng sáng +Hai cài đặt font: -### Chủ đề Tối +- **Editor font**: họ font và cỡ chữ cho SQL editor +- **Data grid font**: họ font và cỡ chữ cho lưới kết quả -- Nền xám đậm/đen -- Văn bản sáng tạo tương phản -- Giảm mỏi mắt trong ánh sáng yếu -- Tiết kiệm pin trên màn hình OLED +Mỗi thay đổi hiển thị ngay trên bản xem trước (live preview). -Chủ đề tối phù hợp cho phiên làm việc dài, đặc biệt trong môi trường thiếu sáng. +Nếu chỉ muốn đổi font mà không đổi màu, chỉ cần chỉnh tab Fonts — theme sẽ tự duplicate nếu là built-in. -## Màu nhấn - -| Tùy chọn | Mô tả | -|--------|-------------| -| **System** | Dùng màu nhấn macOS | -| **Blue** | Xanh dương cổ điển | -| **Purple** | Tím | -| **Pink** | Hồng | -| **Red** | Đỏ | -| **Orange** | Cam | -| **Yellow** | Vàng | -| **Green** | Xanh lá | -| **Graphite** | Xám/trung tính | - -### Vị trí Hiển thị Màu nhấn - -- Mục đã chọn và phần tô sáng -- Nút và phần tử tương tác -- Thanh tiến trình -- Viền focus -- Chỉ báo tab đang hoạt động - -{/* Screenshot: Different accent colors applied */} - - Màu nhấn - Màu nhấn - +### Colors -{/* Screenshot: Màu nhấn áp dụng trên giao diện */} - - Màu nhấn áp dụng trên các phần tử giao diện - Màu nhấn áp dụng trên các phần tử giao diện - +5 danh mục màu, mỗi màu dùng hex color picker: + +| Danh mục | Kiểm soát | +|----------|-----------| +| **Editor** | Background, text, cursor, selection, line numbers, 8 màu token cú pháp | +| **Data Grid** | 12 thuộc tính màu: ô, nền, NULL placeholder, boolean, hàng xen kẽ, v.v. | +| **Interface** | Text tiers, backgrounds, borders, status, badges | +| **Sidebar** | 5 thuộc tính màu sidebar | +| **Toolbar** | 2 thuộc tính màu toolbar | + +Theme built-in không cho chỉnh trực tiếp. Tab Colors hiển thị thông báo "Duplicate to customize" — nhấp để tạo bản sao rồi chỉnh. + +### Layout + +6 danh mục layout: + +| Danh mục | Nội dung | +|----------|----------| +| **Typography** | 7 cỡ chữ | +| **Spacing** | 7 spacing token + list row insets | +| **Icon Sizes** | 9 kích thước icon | +| **Corner Radius** | 3 giá trị bo góc | +| **Row Heights** | 3 chiều cao hàng | +| **Animations** | 4 cài đặt animation | + +Cùng cơ chế khóa như Colors: theme built-in cần duplicate trước khi chỉnh. + +## Nhập và Xuất + +Nhập/xuất theme qua thanh công cụ phía dưới sidebar: + +### Xuất Theme + +1. Chọn theme trong sidebar +2. Nhấp biểu tượng gear > **Export...** +3. Chọn vị trí lưu -### Khớp với macOS +File `.json` xuất ra chứa toàn bộ định nghĩa: màu sắc, font, spacing, layout. -Để dùng màu nhấn hệ thống: +### Nhập Theme -1. Đặt màu nhấn TablePro thành **System** -2. Mở **System Settings** > **Appearance** -3. Chọn màu ưa thích -4. TablePro tự động cập nhật +1. Nhấp nút `+` > **Import...** +2. Chọn file theme `.json` +3. Theme xuất hiện trong section "Custom" + + +Theme được lưu tại `~/Library/Application Support/TablePro/Themes/` dưới dạng JSON. + + +## Theme từ Registry + +Cài đặt theme từ cộng đồng thông qua plugin registry. + +### Cài đặt Theme + +1. Vào **Settings > Plugins > Browse** +2. Lọc theo danh mục **Themes** +3. Nhấn **Install Theme** +4. Theme xuất hiện trong section "Registry" của **Settings > Appearance** + +Theme từ registry ở chế độ chỉ đọc. Để tùy chỉnh, nhân bản theme trước: bản sao xuất hiện trong section "Custom" và có thể chỉnh sửa. + +### Gỡ cài đặt Theme + +1. Chọn registry theme trong **Settings > Appearance** +2. Nhấp biểu tượng **gear** +3. Chọn **Uninstall** + +Nếu theme đang dùng bị gỡ, TablePro chuyển về Default Light. + + +Registry theme được lưu tại `~/Library/Application Support/TablePro/Themes/Registry/` với tiền tố ID `registry.*`. + ## Màu Kết nối @@ -226,7 +251,7 @@ Hiển thị trên: ## Chủ đề Editor -Màu tô sáng cú pháp SQL: +Màu tô sáng cú pháp SQL do theme đang dùng quyết định. Ví dụ, trong Default Dark: | Phần tử | Màu | |---------|-------| @@ -236,7 +261,7 @@ Màu tô sáng cú pháp SQL: | Comments | Xám | | Identifiers | Màu văn bản mặc định | -Tự động thích ứng với chủ đề sáng/tối. +Chuyển theme sẽ thay đổi màu cú pháp. Tùy chỉnh bất kỳ màu nào qua tab Colors của theme. ## Phần tử UI @@ -262,59 +287,11 @@ Trong bảng dữ liệu: | Numbers | Màu tiêu chuẩn | | Text | Màu tiêu chuẩn | -## Thực hành Tốt - -### Cho Khả năng Đọc - -1. **Khớp môi trường**: Tối trong ánh sáng yếu, sáng trong phòng sáng -2. **Độ tương phản**: Đảm bảo đủ tương phản cho điều kiện ánh sáng -3. **Kích thước font**: Tăng nếu khó đọc - -### Cho Tổ chức - -1. **Màu kết nối**: Chọn bảng màu nhất quán -2. **Mã hóa môi trường**: Đỏ=prod, xanh lá=dev, v.v. -3. **Quy ước nhóm**: Thống nhất ý nghĩa màu - -### Cho Sức khỏe Mắt - -1. **Chủ đề tối vào đêm**: Giảm ánh sáng xanh -2. **Nghỉ giải lao**: Nhìn xa màn hình định kỳ -3. **Night Shift**: Bật Night Shift macOS cho màu ấm hơn - -## Khả năng Tiếp cận - -### Mù Màu - -1. Không chỉ dựa vào màu để truyền đạt ý nghĩa -2. Tên kết nối cung cấp nhận dạng bằng văn bản -3. Biểu tượng bổ sung thông tin màu - -### Độ Tương phản Cao - -1. Dùng chủ đề sáng hoặc tối (không auto) -2. Chọn màu nhấn tương phản cao (xanh dương, xanh lá) -3. Tăng kích thước font trong cài đặt editor - ## Khắc phục Sự cố -### Chủ đề Không Đổi - -1. Khởi động lại TablePro -2. Kiểm tra giao diện macOS nếu dùng System -3. Xác minh cài đặt đã lưu - -### Màu Trông Sai - -1. Kiểm tra color profile trong System Settings -2. Xác minh hiệu chỉnh màn hình -3. Kiểm tra True Tone trên màn hình hỗ trợ - -### Màu nhấn Không Áp dụng - -1. Khởi động lại TablePro -2. Đặt màu cụ thể thay vì System -3. Kiểm tra extension hệ thống xung đột +- **Theme không thay đổi**: Khởi động lại TablePro. Nếu dùng chế độ auto, kiểm tra giao diện macOS trong System Settings. +- **Màu hiển thị sai**: Kiểm tra color profile và hiệu chỉnh màn hình trong System Settings. True Tone có thể ảnh hưởng trên màn hình hỗ trợ. +- **Theme tùy chỉnh bị mất**: Kiểm tra thư mục `~/Library/Application Support/TablePro/Themes/` — file JSON của theme phải có ở đó. ## Cài đặt Liên quan diff --git a/docs/vi/development/plugin-registry.mdx b/docs/vi/development/plugin-registry.mdx index 88d8826f..80b943d6 100644 --- a/docs/vi/development/plugin-registry.mdx +++ b/docs/vi/development/plugin-registry.mdx @@ -100,6 +100,31 @@ Hai database driver này được phân phối dưới dạng plugin tải về Thay `` bằng SHA-256 thực tế từ output của `build-plugin.sh`. Mảng `binaries` cung cấp tải về theo kiến trúc; trường phẳng `downloadURL`/`sha256` trỏ đến arm64 làm fallback cho phiên bản ứng dụng cũ. +## Phân phối Theme + +Theme sử dụng cùng manifest registry nhưng với `category: "theme"`. Khác với plugin driver, theme chỉ là dữ liệu JSON thuần túy: không có mã thực thi, không cần code signing, không cần `.tableplugin` bundle. + +ZIP chứa một hoặc nhiều file `.json`, mỗi file là một `ThemeDefinition` hợp lệ. Khi cài đặt, TablePro giải nén các JSON, gán lại ID thành `registry.{pluginId}.{originalSuffix}`, và lưu vào `~/Library/Application Support/TablePro/Themes/Registry/`. + +Ví dụ registry entry cho theme: + +```json +{ + "id": "com.example.monokai-theme", + "name": "Monokai Theme", + "version": "1.0.0", + "summary": "Classic Monokai color scheme for TablePro", + "author": { "name": "Theme Author" }, + "category": "theme", + "downloadURL": "https://example.com/monokai-theme.zip", + "sha256": "", + "iconName": "paintpalette", + "isVerified": false +} +``` + +Theme pack (nhiều theme trong một ZIP) được hỗ trợ. Tất cả theme trong pack liên kết cùng một registry plugin ID, nên gỡ cài đặt sẽ xóa tất cả. + ## URL Registry Tùy Chỉnh Đối với registry plugin riêng hoặc doanh nghiệp, bạn có thể thay đổi URL registry mặc định: diff --git a/docs/zh/customization/appearance.mdx b/docs/zh/customization/appearance.mdx index 085a99b7..443044d9 100644 --- a/docs/zh/customization/appearance.mdx +++ b/docs/zh/customization/appearance.mdx @@ -1,6 +1,6 @@ --- title: 外观 -description: 主题模式、强调色、连接颜色和数据库类型颜色编码 +description: 主题引擎:9 个内置主题、自定义颜色/字体/布局、JSON 导入导出 --- # 外观设置 @@ -8,7 +8,7 @@ description: 主题模式、强调色、连接颜色和数据库类型颜色编 在 **Settings** > **Appearance** 中配置 TablePro 的外观。 {/* Screenshot: Appearance settings panel */} - + **Appearance** -2. 选择 Light、Dark 或 Auto -3. TablePro 自动更新 +### 系统外观 + +每个主题有 `appearance` 属性,取值为 `light`、`dark` 或 `auto`,直接控制 `NSApp.appearance`。浅色主题强制浅色界面,深色主题强制深色界面,`auto` 跟随系统设置。同一时间只能激活一个主题。 {/* Screenshot: Side-by-side light and dark themes */} @@ -53,89 +60,106 @@ description: 主题模式、强调色、连接颜色和数据库类型颜色编 /> -### 浅色主题 +## 自定义主题 + +右侧面板的三个标签页控制主题的不同方面。 -- 白色/浅灰色背景 -- 深色文字,可读性好 -- 适合光线充足的环境 -- 明亮房间中对比度较低 +### Fonts 标签页 -### 深色主题 +字体设置始终可编辑。修改内置主题的字体时,会自动创建副本(不会修改原始主题)。 -- 深灰色/黑色背景 -- 浅色文字,对比鲜明 -- 在低光照环境下减轻眼睛疲劳 -- 在 OLED 屏幕上更省电 +包含以下设置: + +- **Editor font**:SQL 编辑器的字体族和字号 +- **Data Grid font**:数据表格的字体族和字号 +- 实时预览区域,修改后即时生效 -深色主题适合长时间工作,尤其是在光线较暗的环境中。 +只想换字体不换颜色?直接在 Fonts 标签页修改即可,内置主题会自动复制为自定义主题。 -## 强调色 - -| 选项 | 描述 | -|--------|-------------| -| **System** | 使用 macOS 系统强调色 | -| **Blue** | 经典蓝色 | -| **Purple** | 紫色 | -| **Pink** | 粉色 | -| **Red** | 红色 | -| **Orange** | 橙色 | -| **Yellow** | 黄色 | -| **Green** | 绿色 | -| **Graphite** | 灰色/中性色 | - -### 强调色的应用位置 - -- 选中项目和高亮显示 -- 按钮和交互元素 -- 进度指示器 -- 焦点轮廓 -- 活动标签页指示器 - -{/* Screenshot: Different accent colors applied */} - - 强调色 - 强调色 - +### Colors 标签页 -{/* Screenshot: Accent color applied to UI */} - - 强调色应用于界面元素 - 强调色应用于界面元素 - +5 个颜色分类,每项使用 Hex 颜色选择器: + +| 分类 | 包含的颜色 | +|------|-----------| +| **Editor** | 背景、文本、光标、选中区域、行号、8 种语法高亮颜色(关键字、字符串、数字、注释等) | +| **Data Grid** | 单元格文本、背景、NULL 占位符、布尔值颜色、交替行等(12 项) | +| **Interface** | 文本层级、背景、边框、状态色、标签色 | +| **Sidebar** | 侧边栏相关颜色(5 项) | +| **Toolbar** | 工具栏相关颜色(2 项) | + +内置主题的 Colors 标签页显示 "Duplicate to customize" 提示。点击后创建副本,然后可自由修改。 + +### Layout 标签页 + +6 个布局分类: + +| 分类 | 包含的设置 | +|------|-----------| +| **Typography** | 7 个字号级别 | +| **Spacing** | 7 个间距 token + 列表行内边距 | +| **Icon Sizes** | 9 个图标尺寸 | +| **Corner Radius** | 3 个圆角半径 | +| **Row Heights** | 3 个行高设置 | +| **Animations** | 4 个动画参数 | + +与 Colors 标签页相同,内置主题需先复制才能修改。 + +## 导入和导出 + +通过侧边栏底部的操作栏管理主题文件: + +### 导出主题 + +1. 在侧边栏选择主题 +2. 点击齿轮按钮,选择 **Export...** +3. 选择保存位置 + +导出的 `.json` 文件包含完整的颜色、字体和布局定义。 -### 与 macOS 保持一致 +### 导入主题 -使用系统强调色: +1. 点击 `+` 按钮,选择 **Import...** +2. 选择 `.json` 主题文件 +3. 主题出现在侧边栏的 "Custom" 分组中 -1. 将 TablePro 的强调色设置为 **System** -2. 打开 **System Settings** > **Appearance** -3. 选择你喜欢的强调色 -4. TablePro 自动更新 + +主题文件存储在 `~/Library/Application Support/TablePro/Themes/`。JSON schema 覆盖编辑器颜色、数据表格颜色、UI 颜色、侧边栏、工具栏、字体、间距、排版、图标尺寸、圆角、行高和动画等全部属性。 + + +## 注册表主题 + +从插件注册表安装社区创建的主题。 + +### 安装主题 + +1. 前往 **Settings > Plugins > Browse** +2. 按 **Themes** 分类筛选 +3. 点击 **Install Theme** +4. 主题出现在 **Settings > Appearance** 的 "Registry" 分组中 + +注册表主题为只读。如需自定义,先复制主题:副本出现在 "Custom" 分组中,可自由编辑。 + +### 卸载主题 + +1. 在 **Settings > Appearance** 中选择注册表主题 +2. 点击 **齿轮** 菜单 +3. 选择 **Uninstall** + +如果卸载的主题正在使用,TablePro 会切换到 Default Light。 + + +注册表主题存储在 `~/Library/Application Support/TablePro/Themes/Registry/`,使用 `registry.*` ID 前缀。 + ## 连接颜色 为连接分配颜色以便于视觉区分: | 颜色 | 建议用途 | -|-------|---------------| +|------|---------| | 无 | 默认,中性 | | 红色 | 生产数据库 | | 橙色 | 预发布环境 | @@ -198,7 +222,7 @@ description: 主题模式、强调色、连接颜色和数据库类型颜色编 每种数据库类型都有固定的颜色用于快速识别: | 数据库 | 颜色 | -|----------|-------| +|--------|------| | MySQL | 橙色 | | MariaDB | 青色 | | PostgreSQL | 蓝色 | @@ -226,24 +250,14 @@ description: 主题模式、强调色、连接颜色和数据库类型颜色编 ## 编辑器主题 -SQL 语法高亮颜色: - -| 元素 | 颜色 | -|---------|-------| -| 关键字 | 粉色 | -| 字符串 | 绿色 | -| 数字 | 蓝色 | -| 注释 | 灰色 | -| 标识符 | 默认文字颜色 | - -颜色会自动适应浅色/深色主题。 +SQL 语法高亮颜色由当前主题定义。每个主题包含 8 种语法 token 颜色。切换主题后,编辑器配色随之改变。通过 Colors 标签页的 Editor 分类可自定义每种语法颜色。 ## 界面元素 ### 状态颜色 | 颜色 | 含义 | -|-------|---------| +|------|------| | 绿色 | 成功、已连接、有效 | | 橙色 | 警告、进行中、连接中 | | 红色 | 错误、失败、无效 | @@ -262,59 +276,22 @@ SQL 语法高亮颜色: | 数字 | 标准颜色 | | 文本 | 标准颜色 | -## 最佳实践 - -### 提升可读性 - -1. **适应环境**:低光照用深色主题,明亮环境用浅色主题 -2. **对比度**:确保对比度适合你的光照条件 -3. **字体大小**:如果文字不易阅读,可以增大字号 - -### 提高组织效率 - -1. **连接颜色**:选择一套统一的颜色方案 -2. **环境编码**:红色=生产、绿色=开发,以此类推 -3. **团队约定**:与团队成员协商统一的颜色含义 - -### 保护眼睛 - -1. **夜间用深色主题**:减少蓝光暴露 -2. **适当休息**:定期将目光从屏幕移开 -3. **Night Shift**:启用 macOS Night Shift 获得更暖的色调 - -## 无障碍 - -### 色觉辨识 - -1. 不要仅依赖颜色来传达信息 -2. 连接名称提供文字标识 -3. 图标辅助补充颜色信息 - -### 高对比度 - -1. 使用浅色或深色主题(不使用自动模式) -2. 选择高对比度的强调色(蓝色、绿色) -3. 在编辑器设置中增大字号 - ## 故障排除 -### 主题未切换 +### 主题未生效 1. 更改设置后重启 TablePro -2. 如果使用 System 主题,检查 macOS 外观设置 +2. 检查主题的 `appearance` 属性是否匹配当前系统外观 3. 确认设置已保存 -### 颜色显示异常 +### 自定义主题丢失 -1. 在系统设置中检查显示器颜色配置文件 -2. 确认显示器校准正确 -3. 在支持的显示器上检查 True Tone 设置 +检查 `~/Library/Application Support/TablePro/Themes/` 目录下的 JSON 文件是否存在。 -### 强调色未生效 +### 颜色显示异常 -1. 重启 TablePro -2. 设置为具体颜色而非 System -3. 检查是否有冲突的系统扩展 +1. 在系统设置中检查显示器颜色配置文件 +2. 检查 True Tone 设置 ## 相关设置 diff --git a/docs/zh/development/plugin-registry.mdx b/docs/zh/development/plugin-registry.mdx index 1f7a3e65..3d5df9d4 100644 --- a/docs/zh/development/plugin-registry.mdx +++ b/docs/zh/development/plugin-registry.mdx @@ -100,6 +100,31 @@ description: 如何将可下载插件发布到 TablePro 插件注册表 将 `` 替换为 `build-plugin.sh` 输出的实际 SHA-256 值。`binaries` 数组提供按架构区分的下载;`downloadURL`/`sha256` 平级字段指向 arm64 作为旧版应用的后备。 +## 主题分发 + +主题使用相同的注册表 manifest,但 `category` 为 `"theme"`。与数据库驱动插件不同,主题是纯 JSON 数据:没有可执行代码,不需要代码签名,不需要 `.tableplugin` bundle。 + +ZIP 包含一个或多个 `.json` 文件,每个文件是一个有效的 `ThemeDefinition`。安装时,TablePro 解压 JSON 文件,将 ID 重写为 `registry.{pluginId}.{originalSuffix}`,并存储到 `~/Library/Application Support/TablePro/Themes/Registry/`。 + +示例主题注册表条目: + +```json +{ + "id": "com.example.monokai-theme", + "name": "Monokai Theme", + "version": "1.0.0", + "summary": "Classic Monokai color scheme for TablePro", + "author": { "name": "Theme Author" }, + "category": "theme", + "downloadURL": "https://example.com/monokai-theme.zip", + "sha256": "", + "iconName": "paintpalette", + "isVerified": false +} +``` + +支持主题包(一个 ZIP 中包含多个主题)。同一包中的所有主题关联相同的 registry plugin ID,卸载时会一并移除。 + ## 自定义 Registry URL 对于企业或私有插件 registry,可以覆盖默认的 registry URL: