Skip to content

Commit 16040f7

Browse files
authored
feat: add theme registry distribution (#319)
* feat: add theme registry distribution for community themes * fix: address review issues in theme registry installer * fix: address all PR review comments * fix: address second round of PR review comments * refactor: remove redundant ThemePresets fallback references * fix: load built-in themes from bundle root (Xcode synced groups flatten dirs) * fix: preserve built-in theme display order * feat: add app-level appearance mode (Light/Dark/Auto) separate from theme * refactor: reduce built-in themes to 4 (Default Light/Dark, Dracula, Nord) * fix: use System Mono in Dracula theme to avoid invalid Picker selection * refactor: rewrite Layout and Colors panes to use native macOS Form patterns * fix: eliminate theme flicker on update, add isEditable guards, fix localization
1 parent 1c7bb82 commit 16040f7

26 files changed

+3501
-595
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131

3232
### Added
3333

34+
- Theme registry distribution: browse, install, uninstall, and update community themes from the plugin registry (Settings > Plugins > Browse, filtered by Themes category)
3435
- 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
3536
- Theme browser with visual preview cards in Settings > Appearance
3637
- Per-theme customization of all colors (editor syntax, data grid, UI, sidebar, toolbar) and fonts

TablePro/Core/Storage/AppSettingsManager.swift

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,22 @@ final class AppSettingsManager {
2929
var appearance: AppearanceSettings {
3030
didSet {
3131
storage.saveAppearance(appearance)
32-
appearance.theme.apply()
32+
ThemeEngine.shared.activateTheme(id: appearance.activeThemeId)
33+
ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode)
3334
}
3435
}
3536

3637
var editor: EditorSettings {
3738
didSet {
3839
storage.saveEditor(editor)
39-
// Update cached theme values for thread-safe access
40-
SQLEditorTheme.reloadFromSettings(editor)
40+
// Update behavioral settings in ThemeEngine
41+
ThemeEngine.shared.updateEditorSettings(
42+
highlightCurrentLine: editor.highlightCurrentLine,
43+
showLineNumbers: editor.showLineNumbers,
44+
tabWidth: editor.clampedTabWidth,
45+
autoIndent: editor.autoIndent,
46+
wordWrap: editor.wordWrap
47+
)
4148
notifyChange(.editorSettingsDidChange)
4249
}
4350
}
@@ -60,7 +67,6 @@ final class AppSettingsManager {
6067
storage.saveDataGrid(validated)
6168
// Update date formatting service with new format
6269
DateFormattingService.shared.updateFormat(validated.dateFormat)
63-
DataGridFontCache.reloadFromSettings(validated)
6470
notifyChange(.dataGridSettingsDidChange)
6571
}
6672
}
@@ -126,18 +132,25 @@ final class AppSettingsManager {
126132
self.keyboard = storage.loadKeyboard()
127133
self.ai = storage.loadAI()
128134

129-
// Apply appearance settings immediately
130-
appearance.theme.apply()
135+
// Apply language immediately
131136
general.language.apply()
132137

133-
// Load editor theme settings into cache (pass settings directly to avoid circular dependency)
134-
SQLEditorTheme.reloadFromSettings(editor)
138+
// ThemeEngine initializes itself from persisted theme ID
139+
// Apply app-level appearance mode
140+
ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode)
141+
142+
// Sync editor behavioral settings to ThemeEngine
143+
ThemeEngine.shared.updateEditorSettings(
144+
highlightCurrentLine: editor.highlightCurrentLine,
145+
showLineNumbers: editor.showLineNumbers,
146+
tabWidth: editor.clampedTabWidth,
147+
autoIndent: editor.autoIndent,
148+
wordWrap: editor.wordWrap
149+
)
135150

136151
// Initialize DateFormattingService with current format
137152
DateFormattingService.shared.updateFormat(dataGrid.dateFormat)
138153

139-
DataGridFontCache.reloadFromSettings(dataGrid)
140-
141154
// Observe system accessibility text size changes and re-apply editor fonts
142155
observeAccessibilityTextSizeChanges()
143156
}
@@ -156,25 +169,19 @@ final class AppSettingsManager {
156169
/// Uses NSWorkspace.accessibilityDisplayOptionsDidChangeNotification which fires when the user
157170
/// changes settings in System Settings > Accessibility > Display (including the Text Size slider).
158171
private func observeAccessibilityTextSizeChanges() {
159-
lastAccessibilityScale = SQLEditorTheme.accessibilityScaleFactor
172+
lastAccessibilityScale = EditorFontCache.computeAccessibilityScale()
160173
accessibilityTextSizeObserver = NSWorkspace.shared.notificationCenter.addObserver(
161174
forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
162175
object: nil,
163176
queue: .main
164177
) { [weak self] _ in
165178
Task { @MainActor [weak self] in
166179
guard let self else { return }
167-
let newScale = SQLEditorTheme.accessibilityScaleFactor
168-
// Only reload if the text size scale actually changed (this notification
169-
// also fires for contrast, reduce motion, etc.)
180+
let newScale = EditorFontCache.computeAccessibilityScale()
170181
guard abs(newScale - lastAccessibilityScale) > 0.01 else { return }
171182
lastAccessibilityScale = newScale
172183
Self.logger.debug("Accessibility text size changed, scale: \(newScale, format: .fixed(precision: 2))")
173-
// Re-apply editor fonts with the updated accessibility scale factor
174-
SQLEditorTheme.reloadFromSettings(editor)
175-
DataGridFontCache.reloadFromSettings(dataGrid)
176-
notifyChange(.dataGridSettingsDidChange)
177-
// Notify the editor view to rebuild its configuration
184+
ThemeEngine.shared.reloadFontCaches()
178185
NotificationCenter.default.post(name: .accessibilityTextSizeDidChange, object: self)
179186
}
180187
}

TablePro/Models/Settings/AppSettings.swift

Lines changed: 58 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -99,95 +99,62 @@ struct GeneralSettings: Codable, Equatable {
9999

100100
// MARK: - Appearance Settings
101101

102-
/// App theme options
103-
enum AppTheme: String, Codable, CaseIterable, Identifiable {
104-
case system = "system"
105-
case light = "light"
106-
case dark = "dark"
107-
108-
var id: String { rawValue }
102+
/// Controls NSApp.appearance independent of the active theme.
103+
enum AppAppearanceMode: String, Codable, CaseIterable {
104+
case light
105+
case dark
106+
case auto
109107

110108
var displayName: String {
111109
switch self {
112-
case .system: return String(localized: "System")
113110
case .light: return String(localized: "Light")
114111
case .dark: return String(localized: "Dark")
115-
}
116-
}
117-
118-
/// Apply this theme to the app
119-
func apply() {
120-
guard let app = NSApp else { return }
121-
switch self {
122-
case .system:
123-
app.appearance = nil
124-
case .light:
125-
app.appearance = NSAppearance(named: .aqua)
126-
case .dark:
127-
app.appearance = NSAppearance(named: .darkAqua)
112+
case .auto: return String(localized: "Auto")
128113
}
129114
}
130115
}
131116

132-
/// Accent color options
133-
enum AccentColorOption: String, Codable, CaseIterable, Identifiable {
134-
case system = "system"
135-
case blue = "blue"
136-
case purple = "purple"
137-
case pink = "pink"
138-
case red = "red"
139-
case orange = "orange"
140-
case yellow = "yellow"
141-
case green = "green"
142-
case graphite = "graphite"
117+
/// Appearance settings
118+
struct AppearanceSettings: Codable, Equatable {
119+
var activeThemeId: String
120+
var appearanceMode: AppAppearanceMode
143121

144-
var id: String { rawValue }
122+
static let `default` = AppearanceSettings(
123+
activeThemeId: "tablepro.default-light",
124+
appearanceMode: .auto
125+
)
145126

146-
var displayName: String {
147-
switch self {
148-
case .system: return String(localized: "System")
149-
case .blue: return String(localized: "Blue")
150-
case .purple: return String(localized: "Purple")
151-
case .pink: return String(localized: "Pink")
152-
case .red: return String(localized: "Red")
153-
case .orange: return String(localized: "Orange")
154-
case .yellow: return String(localized: "Yellow")
155-
case .green: return String(localized: "Green")
156-
case .graphite: return String(localized: "Graphite")
157-
}
127+
init(activeThemeId: String = "tablepro.default-light", appearanceMode: AppAppearanceMode = .auto) {
128+
self.activeThemeId = activeThemeId
129+
self.appearanceMode = appearanceMode
158130
}
159131

160-
/// Color for display in settings picker (always returns a concrete color)
161-
var color: Color {
162-
switch self {
163-
case .system: return .accentColor
164-
case .blue: return .blue
165-
case .purple: return .purple
166-
case .pink: return .pink
167-
case .red: return .red
168-
case .orange: return .orange
169-
case .yellow: return .yellow
170-
case .green: return .green
171-
case .graphite: return .gray
132+
init(from decoder: Decoder) throws {
133+
let container = try decoder.container(keyedBy: CodingKeys.self)
134+
// Migration: try new field first, then fall back to old theme field
135+
if let themeId = try container.decodeIfPresent(String.self, forKey: .activeThemeId) {
136+
activeThemeId = themeId
137+
} else if let oldTheme = try? container.decodeIfPresent(String.self, forKey: .theme) {
138+
// Migrate from old AppTheme enum
139+
switch oldTheme {
140+
case "dark": activeThemeId = "tablepro.default-dark"
141+
default: activeThemeId = "tablepro.default-light"
142+
}
143+
} else {
144+
activeThemeId = "tablepro.default-light"
172145
}
146+
appearanceMode = try container.decodeIfPresent(AppAppearanceMode.self, forKey: .appearanceMode) ?? .auto
173147
}
174148

175-
/// Tint color for applying to views (nil means use system default)
176-
/// Derived from `color` property for DRY - only .system returns nil
177-
var tintColor: Color? {
178-
self == .system ? nil : color
149+
private enum CodingKeys: String, CodingKey {
150+
case activeThemeId, theme, appearanceMode
179151
}
180-
}
181-
182-
/// Appearance settings
183-
struct AppearanceSettings: Codable, Equatable {
184-
var theme: AppTheme
185-
var accentColor: AccentColorOption
186152

187-
static let `default` = AppearanceSettings(
188-
theme: .system,
189-
accentColor: .system
190-
)
153+
func encode(to encoder: Encoder) throws {
154+
var container = encoder.container(keyedBy: CodingKeys.self)
155+
try container.encode(activeThemeId, forKey: .activeThemeId)
156+
try container.encode(appearanceMode, forKey: .appearanceMode)
157+
}
191158
}
192159

193160
// MARK: - Editor Settings
@@ -243,8 +210,6 @@ enum EditorFont: String, Codable, CaseIterable, Identifiable {
243210

244211
/// Editor settings
245212
struct EditorSettings: Codable, Equatable {
246-
var fontFamily: EditorFont
247-
var fontSize: Int // 11-18pt
248213
var showLineNumbers: Bool
249214
var highlightCurrentLine: Bool
250215
var tabWidth: Int // 2, 4, or 8 spaces
@@ -253,8 +218,6 @@ struct EditorSettings: Codable, Equatable {
253218
var vimModeEnabled: Bool
254219

255220
static let `default` = EditorSettings(
256-
fontFamily: .systemMono,
257-
fontSize: 13,
258221
showLineNumbers: true,
259222
highlightCurrentLine: true,
260223
tabWidth: 4,
@@ -264,17 +227,13 @@ struct EditorSettings: Codable, Equatable {
264227
)
265228

266229
init(
267-
fontFamily: EditorFont = .systemMono,
268-
fontSize: Int = 13,
269230
showLineNumbers: Bool = true,
270231
highlightCurrentLine: Bool = true,
271232
tabWidth: Int = 4,
272233
autoIndent: Bool = true,
273234
wordWrap: Bool = false,
274235
vimModeEnabled: Bool = false
275236
) {
276-
self.fontFamily = fontFamily
277-
self.fontSize = fontSize
278237
self.showLineNumbers = showLineNumbers
279238
self.highlightCurrentLine = highlightCurrentLine
280239
self.tabWidth = tabWidth
@@ -285,21 +244,15 @@ struct EditorSettings: Codable, Equatable {
285244

286245
init(from decoder: Decoder) throws {
287246
let container = try decoder.container(keyedBy: CodingKeys.self)
288-
fontFamily = try container.decode(EditorFont.self, forKey: .fontFamily)
289-
fontSize = try container.decode(Int.self, forKey: .fontSize)
290-
showLineNumbers = try container.decode(Bool.self, forKey: .showLineNumbers)
291-
highlightCurrentLine = try container.decode(Bool.self, forKey: .highlightCurrentLine)
292-
tabWidth = try container.decode(Int.self, forKey: .tabWidth)
293-
autoIndent = try container.decode(Bool.self, forKey: .autoIndent)
294-
wordWrap = try container.decode(Bool.self, forKey: .wordWrap)
247+
// Old fontFamily/fontSize keys are ignored (moved to ThemeFonts)
248+
showLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .showLineNumbers) ?? true
249+
highlightCurrentLine = try container.decodeIfPresent(Bool.self, forKey: .highlightCurrentLine) ?? true
250+
tabWidth = try container.decodeIfPresent(Int.self, forKey: .tabWidth) ?? 4
251+
autoIndent = try container.decodeIfPresent(Bool.self, forKey: .autoIndent) ?? true
252+
wordWrap = try container.decodeIfPresent(Bool.self, forKey: .wordWrap) ?? false
295253
vimModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .vimModeEnabled) ?? false
296254
}
297255

298-
/// Clamped font size (11-18)
299-
var clampedFontSize: Int {
300-
min(max(fontSize, 11), 18)
301-
}
302-
303256
/// Clamped tab width (1-16)
304257
var clampedTabWidth: Int {
305258
min(max(tabWidth, 1), 16)
@@ -354,29 +307,30 @@ enum DateFormatOption: String, Codable, CaseIterable, Identifiable {
354307

355308
/// Data grid settings
356309
struct DataGridSettings: Codable, Equatable {
357-
var fontFamily: EditorFont
358-
var fontSize: Int
359310
var rowHeight: DataGridRowHeight
360311
var dateFormat: DateFormatOption
361312
var nullDisplay: String
362313
var defaultPageSize: Int
363314
var showAlternateRows: Bool
364315
var autoShowInspector: Bool
365316

366-
static let `default` = DataGridSettings()
317+
static let `default` = DataGridSettings(
318+
rowHeight: .normal,
319+
dateFormat: .iso8601,
320+
nullDisplay: "NULL",
321+
defaultPageSize: 1_000,
322+
showAlternateRows: true,
323+
autoShowInspector: false
324+
)
367325

368326
init(
369-
fontFamily: EditorFont = .systemMono,
370-
fontSize: Int = 13,
371327
rowHeight: DataGridRowHeight = .normal,
372328
dateFormat: DateFormatOption = .iso8601,
373329
nullDisplay: String = "NULL",
374330
defaultPageSize: Int = 1_000,
375331
showAlternateRows: Bool = true,
376332
autoShowInspector: Bool = false
377333
) {
378-
self.fontFamily = fontFamily
379-
self.fontSize = fontSize
380334
self.rowHeight = rowHeight
381335
self.dateFormat = dateFormat
382336
self.nullDisplay = nullDisplay
@@ -387,21 +341,15 @@ struct DataGridSettings: Codable, Equatable {
387341

388342
init(from decoder: Decoder) throws {
389343
let container = try decoder.container(keyedBy: CodingKeys.self)
390-
fontFamily = try container.decodeIfPresent(EditorFont.self, forKey: .fontFamily) ?? .systemMono
391-
fontSize = try container.decodeIfPresent(Int.self, forKey: .fontSize) ?? 13
392-
rowHeight = try container.decode(DataGridRowHeight.self, forKey: .rowHeight)
393-
dateFormat = try container.decode(DateFormatOption.self, forKey: .dateFormat)
394-
nullDisplay = try container.decode(String.self, forKey: .nullDisplay)
395-
defaultPageSize = try container.decode(Int.self, forKey: .defaultPageSize)
396-
showAlternateRows = try container.decode(Bool.self, forKey: .showAlternateRows)
344+
// Old fontFamily/fontSize keys are ignored (moved to ThemeFonts)
345+
rowHeight = try container.decodeIfPresent(DataGridRowHeight.self, forKey: .rowHeight) ?? .normal
346+
dateFormat = try container.decodeIfPresent(DateFormatOption.self, forKey: .dateFormat) ?? .iso8601
347+
nullDisplay = try container.decodeIfPresent(String.self, forKey: .nullDisplay) ?? "NULL"
348+
defaultPageSize = try container.decodeIfPresent(Int.self, forKey: .defaultPageSize) ?? 1_000
349+
showAlternateRows = try container.decodeIfPresent(Bool.self, forKey: .showAlternateRows) ?? true
397350
autoShowInspector = try container.decodeIfPresent(Bool.self, forKey: .autoShowInspector) ?? false
398351
}
399352

400-
/// Clamped font size (10-18)
401-
var clampedFontSize: Int {
402-
min(max(fontSize, 10), 18)
403-
}
404-
405353
// MARK: - Validated Properties
406354

407355
/// Validated and sanitized nullDisplay (max 20 chars, no newlines)

0 commit comments

Comments
 (0)