Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 26 additions & 19 deletions TablePro/Core/Storage/AppSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -156,25 +169,19 @@ 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,
queue: .main
) { [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)
}
}
Expand Down
168 changes: 58 additions & 110 deletions TablePro/Models/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -264,17 +227,13 @@ struct EditorSettings: Codable, Equatable {
)

init(
fontFamily: EditorFont = .systemMono,
fontSize: Int = 13,
showLineNumbers: Bool = true,
highlightCurrentLine: Bool = true,
tabWidth: Int = 4,
autoIndent: Bool = true,
wordWrap: Bool = false,
vimModeEnabled: Bool = false
) {
self.fontFamily = fontFamily
self.fontSize = fontSize
self.showLineNumbers = showLineNumbers
self.highlightCurrentLine = highlightCurrentLine
self.tabWidth = tabWidth
Expand All @@ -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)
Expand Down Expand Up @@ -354,29 +307,30 @@ 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
var defaultPageSize: Int
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",
defaultPageSize: Int = 1_000,
showAlternateRows: Bool = true,
autoShowInspector: Bool = false
) {
self.fontFamily = fontFamily
self.fontSize = fontSize
self.rowHeight = rowHeight
self.dateFormat = dateFormat
self.nullDisplay = nullDisplay
Expand All @@ -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)
Expand Down
Loading
Loading