diff --git a/TablePro/Core/AI/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestionManager.swift index bc20934e..536cf28b 100644 --- a/TablePro/Core/AI/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestionManager.swift @@ -310,7 +310,7 @@ final class InlineSuggestionManager { layer.allowsFontSubpixelQuantization = true // Use the editor's font and grey color for ghost appearance - let font = SQLEditorTheme.font + let font = ThemeEngine.shared.editorFonts.font let attrs: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: NSColor.tertiaryLabelColor diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 9949e160..ff11f612 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -515,7 +515,7 @@ final class PluginManager { if let hex = PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?.brandColorHex { return Color(hex: hex) } - return Theme.defaultDatabaseColor + return Color.gray } func supportsDatabaseSwitching(for databaseType: DatabaseType) -> Bool { diff --git a/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift b/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift index 697811b0..d63e17af 100644 --- a/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/SettingsNotifications.swift @@ -18,6 +18,10 @@ extension Notification.Name { static let editorSettingsDidChange = Notification.Name("editorSettingsDidChange") /// Posted when the system accessibility text size preference changes. - /// Observers should reload fonts via SQLEditorTheme.reloadFromSettings(). + /// Observers should reload fonts via ThemeEngine.shared.reloadFontCaches(). static let accessibilityTextSizeDidChange = Notification.Name("accessibilityTextSizeDidChange") + + /// Posted when the active theme changes (colors, fonts, or entire theme switch). + /// Used by AppKit components that cannot observe @Observable directly. + static let themeDidChange = Notification.Name("themeDidChange") } diff --git a/TablePro/Core/Vim/VimCursorManager.swift b/TablePro/Core/Vim/VimCursorManager.swift index 6e2cc082..db1b6872 100644 --- a/TablePro/Core/Vim/VimCursorManager.swift +++ b/TablePro/Core/Vim/VimCursorManager.swift @@ -96,7 +96,7 @@ final class VimCursorManager { } // Calculate character width from the editor font - let font = SQLEditorTheme.font + let font = ThemeEngine.shared.editorFonts.font let charWidth = (NSString(" ").size(withAttributes: [.font: font])).width guard charWidth > 0 else { @@ -122,7 +122,7 @@ final class VimCursorManager { // Create new layer let layer = CALayer() layer.contentsScale = textView.window?.backingScaleFactor ?? 2.0 - layer.backgroundColor = SQLEditorTheme.insertionPoint.withAlphaComponent(0.4).cgColor + layer.backgroundColor = ThemeEngine.shared.colors.editor.cursor.withAlphaComponent(0.4).cgColor layer.frame = frame // Add blink animation diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 9440e950..73ee30d8 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1192,6 +1192,7 @@ } }, "1 John Doe john@example.com NULL" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -1659,6 +1660,7 @@ } }, "Accent Color:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2300,6 +2302,9 @@ } } } + }, + "Alternate Row" : { + }, "Always" : { "extractionState" : "stale", @@ -2365,6 +2370,9 @@ } } } + }, + "Animations" : { + }, "API Key" : { "localizations" : { @@ -2399,6 +2407,7 @@ } }, "Appearance:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2718,6 +2727,9 @@ } } } + }, + "Auto" : { + }, "AUTO" : { "extractionState" : "stale", @@ -2786,7 +2798,6 @@ } }, "Auto Increment" : { - "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2865,6 +2876,15 @@ } } } + }, + "Background" : { + + }, + "Badge Background" : { + + }, + "Badges" : { + }, "Base32-encoded secret from your authenticator setup" : { "localizations" : { @@ -2929,6 +2949,18 @@ } } } + }, + "Body" : { + + }, + "Bool False" : { + + }, + "Bool True" : { + + }, + "Border" : { + }, "Browse" : { "localizations" : { @@ -3139,6 +3171,12 @@ } } } + }, + "Caption" : { + + }, + "Card Background" : { + }, "Cascade" : { "localizations" : { @@ -3839,6 +3877,9 @@ } } } + }, + "Colors" : { + }, "Column" : { "localizations" : { @@ -4012,7 +4053,6 @@ } }, "Comment" : { - "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -4447,6 +4487,9 @@ } } } + }, + "Control Background" : { + }, "Conversation History" : { "extractionState" : "stale", @@ -4724,6 +4767,9 @@ } } } + }, + "Corner Radius" : { + }, "Could not fetch plugin registry" : { "localizations" : { @@ -5061,6 +5107,9 @@ } } } + }, + "Current Line" : { + }, "Current schema: %@ (⌘K to switch)" : { "extractionState" : "stale", @@ -5111,6 +5160,9 @@ } } } + }, + "Cursor" : { + }, "Cursor position %lld exceeds SQL length (%lld)" : { "localizations" : { @@ -5261,6 +5313,9 @@ } } } + }, + "Data Grid Font" : { + }, "Data Size" : { "localizations" : { @@ -6012,6 +6067,15 @@ } } } + }, + "Delete Theme" : { + + }, + "Deleted" : { + + }, + "Deleted Text" : { + }, "Delimiter" : { "extractionState" : "stale", @@ -6448,6 +6512,9 @@ } } } + }, + "Duplicate it to customize colors and layout." : { + }, "Duplicate Row" : { "localizations" : { @@ -6481,6 +6548,9 @@ } } } + }, + "Duplicate Theme" : { + }, "Each SQLite file is a separate database.\nTo open a different database, create a new connection." : { "localizations" : { @@ -6627,6 +6697,9 @@ } } } + }, + "Editor Font" : { + }, "Email:" : { "localizations" : { @@ -7502,6 +7575,9 @@ } } } + }, + "Extra Large" : { + }, "Failed at line %lld" : { "localizations" : { @@ -7967,6 +8043,12 @@ } } } + }, + "Family" : { + + }, + "Fast" : { + }, "Feature Routing" : { "localizations" : { @@ -8257,8 +8339,12 @@ } } } + }, + "Focus Border" : { + }, "Font" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8275,6 +8361,7 @@ } }, "Font:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8289,6 +8376,9 @@ } } } + }, + "Fonts" : { + }, "Forever" : { "localizations" : { @@ -8369,6 +8459,9 @@ } } } + }, + "Function" : { + }, "General" : { "localizations" : { @@ -8483,6 +8576,7 @@ } }, "Graphite" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8754,6 +8848,15 @@ } } } + }, + "Hover" : { + + }, + "Huge" : { + + }, + "Icon Sizes" : { + }, "Ignore foreign key checks" : { "localizations" : { @@ -9203,6 +9306,9 @@ } } } + }, + "Info" : { + }, "Inline Suggestions" : { "localizations" : { @@ -9251,6 +9357,9 @@ } } } + }, + "Inserted" : { + }, "Inspector" : { "localizations" : { @@ -9332,6 +9441,9 @@ } } } + }, + "Install Theme" : { + }, "Installation Failed" : { "localizations" : { @@ -9397,6 +9509,9 @@ } } } + }, + "Interface" : { + }, "Invalid argument: %@" : { "extractionState" : "stale", @@ -9561,6 +9676,9 @@ } } } + }, + "Invisibles" : { + }, "is empty" : { "localizations" : { @@ -9802,6 +9920,9 @@ } } } + }, + "Keyword" : { + }, "Language:" : { "localizations" : { @@ -9818,6 +9939,9 @@ } } } + }, + "Large" : { + }, "Last query execution summary" : { "localizations" : { @@ -9898,6 +10022,9 @@ } } } + }, + "Layout" : { + }, "Length" : { "extractionState" : "stale", @@ -10045,6 +10172,9 @@ } } } + }, + "Line Number" : { + }, "Load" : { "extractionState" : "stale", @@ -10291,6 +10421,9 @@ } } } + }, + "Massive" : { + }, "Match ALL filters (AND) or ANY filter (OR)" : { "localizations" : { @@ -10419,6 +10552,9 @@ } } } + }, + "Medium" : { + }, "METADATA" : { "localizations" : { @@ -10910,6 +11046,9 @@ } } } + }, + "New Theme" : { + }, "New View..." : { "localizations" : { @@ -12071,6 +12210,9 @@ } } } + }, + "NULL Value" : { + }, "Nullable" : { "extractionState" : "stale", @@ -12088,6 +12230,9 @@ } } } + }, + "Number" : { + }, "Number of documents per insertMany statement. Higher values create fewer statements." : { "extractionState" : "stale", @@ -12381,6 +12526,9 @@ } } } + }, + "Operator" : { + }, "Optimize Query" : { "localizations" : { @@ -13533,6 +13681,9 @@ } } } + }, + "Primary Key" : { + }, "Primary Preferred" : { "extractionState" : "stale", @@ -13550,6 +13701,9 @@ } } } + }, + "Primary Text" : { + }, "Privacy" : { "localizations" : { @@ -14315,6 +14469,9 @@ } } } + }, + "Registry" : { + }, "Remove filter" : { "localizations" : { @@ -14692,6 +14849,9 @@ } } } + }, + "Row Heights" : { + }, "Row number" : { "localizations" : { @@ -14708,6 +14868,9 @@ } } } + }, + "Row Number" : { + }, "Rows" : { "localizations" : { @@ -15374,8 +15537,15 @@ } } } + }, + "Secondary Text" : { + + }, + "Section Header" : { + }, "SELECT * FROM users WHERE id = 1;" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -15390,6 +15560,9 @@ } } } + }, + "SELECT * FROM users WHERE id = 42;" : { + }, "Select a Plugin" : { "localizations" : { @@ -15552,6 +15725,12 @@ } } } + }, + "Selected Item" : { + + }, + "Selection" : { + }, "Send Message" : { "localizations" : { @@ -15979,6 +16158,9 @@ } } } + }, + "Sidebar" : { + }, "Sidebar Panel" : { "extractionState" : "stale", @@ -16062,6 +16244,7 @@ } }, "Size:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -16092,6 +16275,15 @@ } } } + }, + "Slow" : { + + }, + "Small" : { + + }, + "Smooth" : { + }, "Software Update" : { "localizations" : { @@ -16125,6 +16317,9 @@ } } } + }, + "Spacing" : { + }, "Spacious" : { "localizations" : { @@ -16667,6 +16862,12 @@ } } } + }, + "Status Colors" : { + + }, + "Status Dot" : { + }, "Stop" : { "localizations" : { @@ -16715,6 +16916,9 @@ } } } + }, + "String" : { + }, "Structure" : { "localizations" : { @@ -16878,6 +17082,9 @@ } } } + }, + "Syntax Colors" : { + }, "System" : { "localizations" : { @@ -17224,6 +17431,9 @@ } } } + }, + "Tertiary Text" : { + }, "Test" : { "localizations" : { @@ -17256,6 +17466,9 @@ } } } + }, + "Text" : { + }, "The %@ plugin is not installed. Would you like to download it from the plugin marketplace?" : { "localizations" : { @@ -17429,6 +17642,12 @@ } } } + }, + "This is a built-in theme." : { + + }, + "This is a registry theme." : { + }, "This Month" : { "localizations" : { @@ -17634,6 +17853,18 @@ } } } + }, + "Tiny" : { + + }, + "Tiny Dot" : { + + }, + "Title 2" : { + + }, + "Title 3" : { + }, "to view data" : { "localizations" : { @@ -17896,6 +18127,9 @@ } } } + }, + "Toolbar" : { + }, "Total Size" : { "localizations" : { @@ -18169,6 +18403,9 @@ } } } + }, + "Typography" : { + }, "Undo" : { "localizations" : { @@ -18971,6 +19208,9 @@ } } } + }, + "Warning" : { + }, "WARNING: Failed to re-enable foreign key checks: %@. Please manually verify FK constraints are enabled." : { "extractionState" : "stale", @@ -19124,6 +19364,9 @@ } } } + }, + "Window Background" : { + }, "With Headers" : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index f4fd579f..65b93484 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -400,17 +400,11 @@ struct TableProApp: App { } } - /// Get tint color from settings (nil for system default) - private var accentTint: Color? { - settingsManager.appearance.accentColor.tintColor - } - var body: some Scene { // Welcome Window - opens on launch (must be first Window scene so SwiftUI // restores it by default when clicking the dock icon) Window("Welcome to TablePro", id: "welcome") { WelcomeWindowView() - .tint(accentTint) .background(OpenWindowHandler()) // Handle window notifications from startup } .windowStyle(.hiddenTitleBar) @@ -420,7 +414,6 @@ struct TableProApp: App { // Connection Form Window - opens when creating/editing a connection WindowGroup(id: "connection-form", for: UUID?.self) { $connectionId in ConnectionFormView(connectionId: connectionId ?? nil) - .tint(accentTint) } .windowResizability(.contentSize) @@ -430,7 +423,6 @@ struct TableProApp: App { ContentView(payload: payload) .environment(AppState.shared) .background(OpenWindowHandler()) - .tint(accentTint) } .windowStyle(.automatic) .defaultSize(width: 1_200, height: 800) @@ -439,7 +431,6 @@ struct TableProApp: App { Settings { SettingsView() .environment(updaterBridge) - .tint(accentTint) } .commands { diff --git a/TablePro/Theme/DataGridFontCache.swift b/TablePro/Theme/DataGridFontCache.swift deleted file mode 100644 index 06f2d1fc..00000000 --- a/TablePro/Theme/DataGridFontCache.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// DataGridFontCache.swift -// TablePro -// -// Cached font variants for the data grid. -// Updated via reloadFromSettings() when user changes font preferences. -// - -import AppKit - -/// Tags stored on NSTextField.tag to identify which font variant a cell uses. -/// Used by `updateVisibleCellFonts` to re-apply the correct variant after a font change. -enum DataGridFontVariant { - static let regular = 0 - static let italic = 1 - static let medium = 2 - static let rowNumber = 3 -} - -@MainActor -struct DataGridFontCache { - private(set) static var regular = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - private(set) static var italic = regular.withTraits(.italic) - private(set) static var medium = NSFont.monospacedSystemFont(ofSize: 13, weight: .medium) - private(set) static var rowNumber = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular) - private(set) static var measureFont = regular - private(set) static var monoCharWidth: CGFloat = { - let attrs: [NSAttributedString.Key: Any] = [.font: regular] - return ("M" as NSString).size(withAttributes: attrs).width - }() - - @MainActor - static func reloadFromSettings(_ settings: DataGridSettings) { - let scale = SQLEditorTheme.accessibilityScaleFactor - let scaledSize = round(CGFloat(settings.clampedFontSize) * scale) - regular = settings.fontFamily.font(size: scaledSize) - 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) - measureFont = regular - let attrs: [NSAttributedString.Key: Any] = [.font: regular] - monoCharWidth = ("M" as NSString).size(withAttributes: attrs).width - } -} diff --git a/TablePro/Theme/DesignConstants.swift b/TablePro/Theme/DesignConstants.swift deleted file mode 100644 index 751be7eb..00000000 --- a/TablePro/Theme/DesignConstants.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// DesignConstants.swift -// TablePro -// -// Design system constants following macOS Human Interface Guidelines. -// Use these constants throughout the app for consistent sizing, spacing, and typography. -// - -import AppKit -import Foundation -import SwiftUI - -/// Design system constants following macOS Human Interface Guidelines -enum DesignConstants { - // MARK: - Font Sizes - - /// Standard font sizes following macOS typography scale - enum FontSize { - /// Tiny text (9pt) - Use for ultra-compact badges, minimal labels (use sparingly) - static let tiny: CGFloat = 9 - - /// Caption text (10pt) - Use for badges, metadata, fine print - static let caption: CGFloat = 10 - - /// Small text (11pt) - Use for secondary labels, helper text - static let small: CGFloat = 11 - - /// Medium text (12pt) - Use for UI controls, toolbar labels (between small and body) - static let medium: CGFloat = 12 - - /// Body text (13pt) - Use for primary content, form fields - static let body: CGFloat = 13 - - /// Title 3 (15pt) - Use for section headers - static let title3: CGFloat = 15 - - /// Title 2 (17pt) - Use for panel titles - static let title2: CGFloat = 17 - } - - // MARK: - Icon Sizes - - /// Standard icon sizes following macOS design patterns - enum IconSize { - /// Tiny indicators (6pt) - Use for minimal status dots (use sparingly) - static let tinyDot: CGFloat = 6 - - /// Status dot (8pt) - Use for connection status indicators - static let statusDot: CGFloat = 8 - - /// Small icons (12pt) - Use for tight UI elements, badges - static let small: CGFloat = 12 - - /// Default icons (14pt) - Use for most UI icons, status indicators - static let `default`: CGFloat = 14 - - /// Medium icons (16pt) - Use for toolbar icons, headers - static let medium: CGFloat = 16 - - /// Large icons (20pt) - Use for prominent UI elements - static let large: CGFloat = 20 - - /// Extra large icons (24pt) - Use for empty states, feature highlights - static let extraLarge: CGFloat = 24 - - /// Huge icons (32pt) - Use for welcome screens, large empty states - static let huge: CGFloat = 32 - - /// Massive icons (64pt) - Use for success/error full-screen states - static let massive: CGFloat = 64 - } - - // MARK: - Spacing - - /// Standard spacing increments following 4pt grid system - enum Spacing { - /// 2pt spacing - Use sparingly for very tight layouts - static let xxxs: CGFloat = 2 - - /// 4pt spacing - Minimum recommended spacing between elements - static let xxs: CGFloat = 4 - - /// 8pt spacing - Standard spacing for related elements - static let xs: CGFloat = 8 - - /// 12pt spacing - Comfortable spacing between groups - static let sm: CGFloat = 12 - - /// 16pt spacing - Spacing for separate sections - static let md: CGFloat = 16 - - /// 20pt spacing - Large spacing for visual separation - static let lg: CGFloat = 20 - - /// 24pt spacing - Extra large spacing for major sections - static let xl: CGFloat = 24 - } - - // MARK: - Row Heights - - /// Standard row heights for lists and tables - enum RowHeight { - /// Compact row height (24pt) - Use for dense data tables, autocomplete - static let compact: CGFloat = 24 - - /// Table row height (32pt) - Use for table editors like column list - static let table: CGFloat = 32 - - /// Comfortable row height (44pt) - Use for touch-friendly lists, multi-line content - static let comfortable: CGFloat = 44 - } - - // MARK: - Insets - - /// Standard list row insets following macOS patterns (AppKit) - static let listRowInsets = NSEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) - - /// SwiftUI EdgeInsets version for list rows - /// Note: SwiftUI EdgeInsets uses top/leading/bottom/trailing - static let swiftUIListRowInsets = EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) - - // MARK: - Colors - - /// Semantic colors for UI elements - enum Colors { - // Backgrounds - static let sectionBackground = Color(nsColor: .controlBackgroundColor) - static let cardBackground = Color(nsColor: .windowBackgroundColor) - static let alternateRow = Color(nsColor: .controlBackgroundColor).opacity(0.5) - static let hoverBackground = Color(nsColor: .controlAccentColor).opacity(0.05) - static let selectedBackground = Color(nsColor: .selectedContentBackgroundColor) - - // Borders - static let border = Color(nsColor: .separatorColor) - static let selectedBorder = Color.accentColor - - // Text - static let primaryText = Color.primary - static let secondaryText = Color.secondary - static let tertiaryText = Color(nsColor: .tertiaryLabelColor) - - // Semantic - static let success = Color(nsColor: .systemGreen) - static let warning = Color(nsColor: .systemOrange) - static let error = Color(nsColor: .systemRed) - static let info = Color(nsColor: .systemBlue) - - // Badges - static let badgeBackground = Color(nsColor: .quaternaryLabelColor) - static let primaryKeyBadge = Color(nsColor: .systemBlue).opacity(0.15) - static let autoIncrementBadge = Color(nsColor: .systemPurple).opacity(0.15) - static let nullBadge = Color(nsColor: .quaternaryLabelColor) - } - - // MARK: - Corner Radius - - /// Standard corner radius values - enum CornerRadius { - /// Small radius (4pt) - Use for badges, pills - static let small: CGFloat = 4 - - /// Medium radius (6pt) - Use for cards, sections - static let medium: CGFloat = 6 - - /// Large radius (8pt) - Use for panels, modals - static let large: CGFloat = 8 - } - - // MARK: - Animation Duration - - /// Standard animation durations - enum AnimationDuration { - /// Fast animation (100ms) - Use for hover states - static let fast: Double = 0.1 - - /// Normal animation (150ms) - Use for button presses - static let normal: Double = 0.15 - - /// Smooth animation (200ms) - Use for panel slides - static let smooth: Double = 0.2 - - /// Slow animation (300ms) - Use for section expand/collapse - static let slow: Double = 0.3 - } - - // MARK: - Shadow - - /// Standard shadow styles - enum Shadow { - /// Card shadow (subtle elevation) - static let card: (color: Color, radius: CGFloat, x: CGFloat, y: CGFloat) = - (Color(nsColor: .shadowColor).opacity(0.15), 4, 0, 2) - - /// Panel shadow (stronger elevation) - static let panel: (color: Color, radius: CGFloat, x: CGFloat, y: CGFloat) = - (Color(nsColor: .shadowColor).opacity(0.2), 8, -2, 0) - } - - // MARK: - Column Widths (for table editors) - - /// Standard column widths for table-style layouts - enum ColumnWidth { - /// Drag handle column (24pt) - static let dragHandle: CGFloat = 24 - - /// Name column (minimum 120pt, flexible) - static let nameMin: CGFloat = 120 - - /// Type column (minimum 110pt, flexible) - static let typeMin: CGFloat = 110 - - /// Length column (60pt, fixed) - static let length: CGFloat = 60 - - /// Default column (minimum 80pt, flexible) - static let defaultMin: CGFloat = 80 - - /// Checkbox column (28pt, fixed) - static let checkbox: CGFloat = 28 - } -} diff --git a/TablePro/Theme/HexColor.swift b/TablePro/Theme/HexColor.swift new file mode 100644 index 00000000..dfec5aa4 --- /dev/null +++ b/TablePro/Theme/HexColor.swift @@ -0,0 +1,72 @@ +import AppKit +import SwiftUI + +extension String { + /// Parse this hex color string to NSColor (sRGB). Returns gray for invalid input. + var nsColor: NSColor { + let hex = trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "#")) + + let hexLength = (hex as NSString).length + guard hexLength == 6 || hexLength == 8 else { + return .gray + } + + var value: UInt64 = 0 + guard Scanner(string: hex).scanHexInt64(&value) else { + return .gray + } + + let r, g, b, a: CGFloat + + if hexLength == 8 { + r = CGFloat((value >> 24) & 0xFF) / 255.0 + g = CGFloat((value >> 16) & 0xFF) / 255.0 + b = CGFloat((value >> 8) & 0xFF) / 255.0 + a = CGFloat(value & 0xFF) / 255.0 + } else { + r = CGFloat((value >> 16) & 0xFF) / 255.0 + g = CGFloat((value >> 8) & 0xFF) / 255.0 + b = CGFloat(value & 0xFF) / 255.0 + a = 1.0 + } + + return NSColor(srgbRed: r, green: g, blue: b, alpha: a) + } + + /// Parse this hex color string to SwiftUI Color. + var swiftUIColor: Color { + Color(nsColor: nsColor) + } + + /// Parse this hex color string to CGColor (sRGB). + var cgColor: CGColor { + nsColor.cgColor + } +} + +extension NSColor { + /// Convert this NSColor to a hex string "#RRGGBB" or "#RRGGBBAA" (if alpha < 1). + var hexString: String { + guard let converted = usingColorSpace(.sRGB) else { + return "#808080" + } + + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + converted.getRed(&r, green: &g, blue: &b, alpha: &a) + + let ri = Int(round(r * 255)) + let gi = Int(round(g * 255)) + let bi = Int(round(b * 255)) + + if a < 1.0 { + let ai = Int(round(a * 255)) + return String(format: "#%02X%02X%02X%02X", ri, gi, bi, ai) + } + + return String(format: "#%02X%02X%02X", ri, gi, bi) + } +} diff --git a/TablePro/Theme/ResolvedThemeColors.swift b/TablePro/Theme/ResolvedThemeColors.swift new file mode 100644 index 00000000..156b387e --- /dev/null +++ b/TablePro/Theme/ResolvedThemeColors.swift @@ -0,0 +1,279 @@ +import AppKit +import SwiftUI + +struct ResolvedEditorColors { + let background: NSColor + let backgroundSwiftUI: Color + let text: NSColor + let textSwiftUI: Color + let cursor: NSColor + let cursorSwiftUI: Color + let currentLineHighlight: NSColor + let currentLineHighlightSwiftUI: Color + let selection: NSColor + let selectionSwiftUI: Color + let lineNumber: NSColor + let lineNumberSwiftUI: Color + let invisibles: NSColor + let invisiblesSwiftUI: Color + + let keyword: NSColor + let keywordSwiftUI: Color + let string: NSColor + let stringSwiftUI: Color + let number: NSColor + let numberSwiftUI: Color + let comment: NSColor + let commentSwiftUI: Color + let null: NSColor + let nullSwiftUI: Color + let `operator`: NSColor + let operatorSwiftUI: Color + let function: NSColor + let functionSwiftUI: Color + let type: NSColor + let typeSwiftUI: Color + + init(from colors: EditorThemeColors) { + background = colors.background.nsColor + backgroundSwiftUI = colors.background.swiftUIColor + text = colors.text.nsColor + textSwiftUI = colors.text.swiftUIColor + cursor = colors.cursor.nsColor + cursorSwiftUI = colors.cursor.swiftUIColor + currentLineHighlight = colors.currentLineHighlight.nsColor + currentLineHighlightSwiftUI = colors.currentLineHighlight.swiftUIColor + selection = colors.selection.nsColor + selectionSwiftUI = colors.selection.swiftUIColor + lineNumber = colors.lineNumber.nsColor + lineNumberSwiftUI = colors.lineNumber.swiftUIColor + invisibles = colors.invisibles.nsColor + invisiblesSwiftUI = colors.invisibles.swiftUIColor + + keyword = colors.syntax.keyword.nsColor + keywordSwiftUI = colors.syntax.keyword.swiftUIColor + string = colors.syntax.string.nsColor + stringSwiftUI = colors.syntax.string.swiftUIColor + number = colors.syntax.number.nsColor + numberSwiftUI = colors.syntax.number.swiftUIColor + comment = colors.syntax.comment.nsColor + commentSwiftUI = colors.syntax.comment.swiftUIColor + null = colors.syntax.null.nsColor + nullSwiftUI = colors.syntax.null.swiftUIColor + `operator` = colors.syntax.operator.nsColor + operatorSwiftUI = colors.syntax.operator.swiftUIColor + function = colors.syntax.function.nsColor + functionSwiftUI = colors.syntax.function.swiftUIColor + type = colors.syntax.type.nsColor + typeSwiftUI = colors.syntax.type.swiftUIColor + } +} + +struct ResolvedDataGridColors { + let background: NSColor + let backgroundSwiftUI: Color + let text: NSColor + let textSwiftUI: Color + let alternateRow: NSColor + let alternateRowSwiftUI: Color + let nullValue: NSColor + let nullValueSwiftUI: Color + let boolTrue: NSColor + let boolTrueSwiftUI: Color + let boolFalse: NSColor + let boolFalseSwiftUI: Color + let rowNumber: NSColor + let rowNumberSwiftUI: Color + + let modified: NSColor + let modifiedSwiftUI: Color + let modifiedCG: CGColor + let inserted: NSColor + let insertedSwiftUI: Color + let insertedCG: CGColor + let deleted: NSColor + let deletedSwiftUI: Color + let deletedCG: CGColor + let deletedText: NSColor + let deletedTextSwiftUI: Color + + let focusBorder: NSColor + let focusBorderCG: CGColor + + init(from colors: DataGridThemeColors) { + background = colors.background.nsColor + backgroundSwiftUI = colors.background.swiftUIColor + text = colors.text.nsColor + textSwiftUI = colors.text.swiftUIColor + alternateRow = colors.alternateRow.nsColor + alternateRowSwiftUI = colors.alternateRow.swiftUIColor + nullValue = colors.nullValue.nsColor + nullValueSwiftUI = colors.nullValue.swiftUIColor + boolTrue = colors.boolTrue.nsColor + boolTrueSwiftUI = colors.boolTrue.swiftUIColor + boolFalse = colors.boolFalse.nsColor + boolFalseSwiftUI = colors.boolFalse.swiftUIColor + rowNumber = colors.rowNumber.nsColor + rowNumberSwiftUI = colors.rowNumber.swiftUIColor + + modified = colors.modified.nsColor + modifiedSwiftUI = colors.modified.swiftUIColor + modifiedCG = colors.modified.cgColor + inserted = colors.inserted.nsColor + insertedSwiftUI = colors.inserted.swiftUIColor + insertedCG = colors.inserted.cgColor + deleted = colors.deleted.nsColor + deletedSwiftUI = colors.deleted.swiftUIColor + deletedCG = colors.deleted.cgColor + deletedText = colors.deletedText.nsColor + deletedTextSwiftUI = colors.deletedText.swiftUIColor + + focusBorder = colors.focusBorder.nsColor + focusBorderCG = colors.focusBorder.cgColor + } +} + +struct ResolvedUIColors { + let windowBackground: NSColor + let windowBackgroundSwiftUI: Color + let controlBackground: NSColor + let controlBackgroundSwiftUI: Color + let cardBackground: NSColor + let cardBackgroundSwiftUI: Color + let border: NSColor + let borderSwiftUI: Color + + let primaryText: NSColor + let primaryTextSwiftUI: Color + let secondaryText: NSColor + let secondaryTextSwiftUI: Color + let tertiaryText: NSColor + let tertiaryTextSwiftUI: Color + + let accentColor: NSColor? + let accentColorSwiftUI: Color? + + let selectionBackground: NSColor + let selectionBackgroundSwiftUI: Color + let hoverBackground: NSColor + let hoverBackgroundSwiftUI: Color + + let success: NSColor + let successSwiftUI: Color + let warning: NSColor + let warningSwiftUI: Color + let error: NSColor + let errorSwiftUI: Color + let info: NSColor + let infoSwiftUI: Color + + let badgeBackground: NSColor + let badgeBackgroundSwiftUI: Color + let badgePrimaryKey: NSColor + let badgePrimaryKeySwiftUI: Color + let badgeAutoIncrement: NSColor + let badgeAutoIncrementSwiftUI: Color + + init(from colors: UIThemeColors) { + windowBackground = colors.windowBackground.nsColor + windowBackgroundSwiftUI = colors.windowBackground.swiftUIColor + controlBackground = colors.controlBackground.nsColor + controlBackgroundSwiftUI = colors.controlBackground.swiftUIColor + cardBackground = colors.cardBackground.nsColor + cardBackgroundSwiftUI = colors.cardBackground.swiftUIColor + border = colors.border.nsColor + borderSwiftUI = colors.border.swiftUIColor + + primaryText = colors.primaryText.nsColor + primaryTextSwiftUI = colors.primaryText.swiftUIColor + secondaryText = colors.secondaryText.nsColor + secondaryTextSwiftUI = colors.secondaryText.swiftUIColor + tertiaryText = colors.tertiaryText.nsColor + tertiaryTextSwiftUI = colors.tertiaryText.swiftUIColor + + if let accent = colors.accentColor { + accentColor = accent.nsColor + accentColorSwiftUI = accent.swiftUIColor + } else { + accentColor = nil + accentColorSwiftUI = nil + } + + selectionBackground = colors.selectionBackground.nsColor + selectionBackgroundSwiftUI = colors.selectionBackground.swiftUIColor + hoverBackground = colors.hoverBackground.nsColor + hoverBackgroundSwiftUI = colors.hoverBackground.swiftUIColor + + success = colors.status.success.nsColor + successSwiftUI = colors.status.success.swiftUIColor + warning = colors.status.warning.nsColor + warningSwiftUI = colors.status.warning.swiftUIColor + error = colors.status.error.nsColor + errorSwiftUI = colors.status.error.swiftUIColor + info = colors.status.info.nsColor + infoSwiftUI = colors.status.info.swiftUIColor + + badgeBackground = colors.badges.background.nsColor + badgeBackgroundSwiftUI = colors.badges.background.swiftUIColor + badgePrimaryKey = colors.badges.primaryKey.nsColor + badgePrimaryKeySwiftUI = colors.badges.primaryKey.swiftUIColor + badgeAutoIncrement = colors.badges.autoIncrement.nsColor + badgeAutoIncrementSwiftUI = colors.badges.autoIncrement.swiftUIColor + } +} + +struct ResolvedSidebarColors { + let background: NSColor + let backgroundSwiftUI: Color + let text: NSColor + let textSwiftUI: Color + let selectedItem: NSColor + let selectedItemSwiftUI: Color + let hover: NSColor + let hoverSwiftUI: Color + let sectionHeader: NSColor + let sectionHeaderSwiftUI: Color + + init(from colors: SidebarThemeColors) { + background = colors.background.nsColor + backgroundSwiftUI = colors.background.swiftUIColor + text = colors.text.nsColor + textSwiftUI = colors.text.swiftUIColor + selectedItem = colors.selectedItem.nsColor + selectedItemSwiftUI = colors.selectedItem.swiftUIColor + hover = colors.hover.nsColor + hoverSwiftUI = colors.hover.swiftUIColor + sectionHeader = colors.sectionHeader.nsColor + sectionHeaderSwiftUI = colors.sectionHeader.swiftUIColor + } +} + +struct ResolvedToolbarColors { + let secondaryText: NSColor + let secondaryTextSwiftUI: Color + let tertiaryText: NSColor + let tertiaryTextSwiftUI: Color + + init(from colors: ToolbarThemeColors) { + secondaryText = colors.secondaryText.nsColor + secondaryTextSwiftUI = colors.secondaryText.swiftUIColor + tertiaryText = colors.tertiaryText.nsColor + tertiaryTextSwiftUI = colors.tertiaryText.swiftUIColor + } +} + +struct ResolvedThemeColors { + let editor: ResolvedEditorColors + let dataGrid: ResolvedDataGridColors + let ui: ResolvedUIColors + let sidebar: ResolvedSidebarColors + let toolbar: ResolvedToolbarColors + + init(from theme: ThemeDefinition) { + editor = ResolvedEditorColors(from: theme.editor) + dataGrid = ResolvedDataGridColors(from: theme.dataGrid) + ui = ResolvedUIColors(from: theme.ui) + sidebar = ResolvedSidebarColors(from: theme.sidebar) + toolbar = ResolvedToolbarColors(from: theme.toolbar) + } +} diff --git a/TablePro/Theme/Theme.swift b/TablePro/Theme/Theme.swift deleted file mode 100644 index 1904b8a7..00000000 --- a/TablePro/Theme/Theme.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// Theme.swift -// TablePro -// -// Created by Ngo Quoc Dat on 16/12/25. -// - -import AppKit -import SwiftUI - -/// App-wide theme colors and styles -enum Theme { - // MARK: - Brand Colors - - static let primaryColor = Color("AccentColor") - static let defaultDatabaseColor = Color.gray - - // MARK: - Semantic Colors - - static var background: Color { - Color(nsColor: .windowBackgroundColor) - } - - static var secondaryBackground: Color { - Color(nsColor: .controlBackgroundColor) - } - - static var textBackground: Color { - Color(nsColor: .textBackgroundColor) - } - - static var separator: Color { - Color(nsColor: .separatorColor) - } - - // MARK: - Editor Colors - - static let editorBackground = Color(nsColor: .textBackgroundColor) - static let editorFont = Font.system(.body, design: .monospaced) - - static let syntaxKeyword = Color.pink - static let syntaxString = Color.green - static let syntaxNumber = Color.blue - static let syntaxComment = Color.gray - - // MARK: - Results Table Colors - - static var tableAlternateRow: Color { - Color(nsColor: .alternatingContentBackgroundColors[1]) - } - - static let nullValue = Color(nsColor: .tertiaryLabelColor) - static let boolTrue = Color(nsColor: .systemGreen) - static let boolFalse = Color(nsColor: .systemRed) - - // MARK: - Status Colors - - static let success = Color(nsColor: .systemGreen) - static let warning = Color(nsColor: .systemOrange) - static let error = Color(nsColor: .systemRed) - static let info = Color(nsColor: .systemBlue) - - // MARK: - Connection Status - - static let connected = Color(nsColor: .systemGreen) - static let disconnected = Color(nsColor: .systemGray) - static let connecting = Color(nsColor: .systemOrange) -} - -// MARK: - View Extensions - -extension View { - /// Apply card-like styling - func cardStyle() -> some View { - self - .background(Theme.secondaryBackground) - .clipShape(RoundedRectangle(cornerRadius: DesignConstants.CornerRadius.medium)) - } - - /// Apply toolbar button styling - func toolbarButtonStyle() -> some View { - self - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - } -} - -// MARK: - Database Type Colors - -extension DatabaseType { - @MainActor var themeColor: Color { - PluginManager.shared.brandColor(for: self) - } -} diff --git a/TablePro/Theme/ToolbarDesignTokens.swift b/TablePro/Theme/ToolbarDesignTokens.swift deleted file mode 100644 index c15f5f08..00000000 --- a/TablePro/Theme/ToolbarDesignTokens.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// ToolbarDesignTokens.swift -// TablePro -// -// Component-specific design tokens for toolbar display. -// Builds on DesignConstants.swift by referencing base values and adding toolbar-specific semantics. -// -// ARCHITECTURE: DesignConstants (base) → ToolbarDesignTokens (component-specific) -// - -import AppKit -import Foundation -import SwiftUI - -/// Component-specific design tokens for toolbar components -/// References DesignConstants for shared values, defines only toolbar-specific semantics -enum ToolbarDesignTokens { - // MARK: - Typography Hierarchy (Xcode-inspired) - - enum Typography { - /// Database type label (11pt, regular, monospaced) - subtle - static let databaseType = Font.system( - size: DesignConstants.FontSize.small, - weight: .regular, - design: .monospaced - ) - - /// Database name (12pt, medium) - clean and readable - static let databaseName = Font.system( - size: DesignConstants.FontSize.medium, - weight: .medium - ) - - /// Execution time (11pt, regular, monospaced) - static let executionTime = Font.system( - size: DesignConstants.FontSize.small, - weight: .regular, - design: .monospaced - ) - - /// Tag label (11pt, medium) - clean like Xcode breadcrumbs - static let tagLabel = Font.system( - size: DesignConstants.FontSize.small, - weight: .medium - ) - } - - // MARK: - Tag Styling - - enum Tag { - /// Tag capsule background opacity - static let backgroundOpacity: CGFloat = 0.2 - - /// Tag horizontal padding (8pt) - static let horizontalPadding = DesignConstants.Spacing.xs - - /// Tag vertical padding (4pt) - static let verticalPadding = DesignConstants.Spacing.xxs - } - - // MARK: - Colors (Xcode-inspired minimal) - - enum Colors { - /// Secondary text color - references base constant - static let secondaryText = DesignConstants.Colors.secondaryText - - /// Tertiary text color - system semantic color - static let tertiaryText = Color(nsColor: .tertiaryLabelColor) - } -} diff --git a/TablePro/Views/About/AboutView.swift b/TablePro/Views/About/AboutView.swift index a9b6915a..677d20e2 100644 --- a/TablePro/Views/About/AboutView.swift +++ b/TablePro/Views/About/AboutView.swift @@ -12,31 +12,31 @@ struct AboutView: View { @State private var hoveredLink: String? var body: some View { - VStack(spacing: DesignConstants.Spacing.md) { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.md) { Spacer() Image(nsImage: NSApp.applicationIconImage) .resizable() .frame(width: 80, height: 80) - VStack(spacing: DesignConstants.Spacing.xxs) { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.xxs) { Text("TablePro") .font( .system( - size: DesignConstants.IconSize.extraLarge, weight: .semibold, + size: ThemeEngine.shared.activeTheme.iconSizes.extraLarge, weight: .semibold, design: .rounded)) Text("Version \(Bundle.main.appVersion) (Build \(Bundle.main.buildNumber))") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.secondary) } Text("© 2026 Ngo Quoc Dat.\n\(String(localized: "All rights reserved."))") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) - HStack(spacing: DesignConstants.Spacing.lg) { + HStack(spacing: ThemeEngine.shared.activeTheme.spacing.lg) { linkButton( title: String(localized: "Website"), icon: "globe", @@ -65,11 +65,11 @@ struct AboutView: View { NSWorkspace.shared.open(link) } } label: { - VStack(spacing: DesignConstants.Spacing.xxxs) { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.xxxs) { Image(systemName: icon) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) Text(title) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .underline(hoveredLink == title) } .foregroundStyle(.secondary) diff --git a/TablePro/Views/Components/EmptyStateView.swift b/TablePro/Views/Components/EmptyStateView.swift index a8eff6dc..c0cb65e9 100644 --- a/TablePro/Views/Components/EmptyStateView.swift +++ b/TablePro/Views/Components/EmptyStateView.swift @@ -30,23 +30,23 @@ struct EmptyStateView: View { } var body: some View { - VStack(spacing: DesignConstants.Spacing.sm) { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.sm) { // Icon Image(systemName: icon) - .font(.system(size: DesignConstants.IconSize.huge)) - .foregroundStyle(DesignConstants.Colors.tertiaryText) - .padding(.bottom, DesignConstants.Spacing.xxs) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.huge)) + .foregroundStyle(ThemeEngine.shared.colors.ui.tertiaryTextSwiftUI) + .padding(.bottom, ThemeEngine.shared.activeTheme.spacing.xxs) // Title Text(title) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - .foregroundStyle(DesignConstants.Colors.secondaryText) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) + .foregroundStyle(ThemeEngine.shared.colors.ui.secondaryTextSwiftUI) // Description (optional) if let description = description { Text(description) - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(DesignConstants.Colors.tertiaryText) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(ThemeEngine.shared.colors.ui.tertiaryTextSwiftUI) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } @@ -56,13 +56,13 @@ struct EmptyStateView: View { Button(action: action) { HStack(spacing: 4) { Image(systemName: "plus") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) Text(actionTitle) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) } } .buttonStyle(.borderless) - .padding(.top, DesignConstants.Spacing.xxs) + .padding(.top, ThemeEngine.shared.activeTheme.spacing.xxs) } } .frame(maxWidth: .infinity) diff --git a/TablePro/Views/Components/SQLReviewPopover.swift b/TablePro/Views/Components/SQLReviewPopover.swift index ec814793..8673b6d0 100644 --- a/TablePro/Views/Components/SQLReviewPopover.swift +++ b/TablePro/Views/Components/SQLReviewPopover.swift @@ -48,7 +48,7 @@ struct SQLReviewPopover: View { private var contentHeight: CGFloat { let lineHeight: CGFloat = 18 let headerHeight: CGFloat = 30 - let padding: CGFloat = DesignConstants.Spacing.md * 2 + DesignConstants.Spacing.sm + let padding: CGFloat = ThemeEngine.shared.activeTheme.spacing.md * 2 + ThemeEngine.shared.activeTheme.spacing.sm let editorInsets: CGFloat = 16 // top + bottom content insets // Count lines directly from statements to avoid recomputing combinedSQL. @@ -72,7 +72,7 @@ struct SQLReviewPopover: View { } var body: some View { - VStack(spacing: DesignConstants.Spacing.sm) { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.sm) { headerView if statements.isEmpty { emptyState @@ -80,7 +80,7 @@ struct SQLReviewPopover: View { editorView } } - .padding(DesignConstants.Spacing.md) + .padding(ThemeEngine.shared.activeTheme.spacing.md) .frame(width: 520, height: contentHeight) .onExitCommand { dismiss() @@ -101,18 +101,18 @@ struct SQLReviewPopover: View { private var headerView: some View { HStack { Text("\(PluginManager.shared.queryLanguageName(for: databaseType)) Preview") - .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .semibold)) if !statements.isEmpty { Text( "(\(statements.count) \(statements.count == 1 ? String(localized: "statement") : String(localized: "statements")))" ) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) } Spacer() if !statements.isEmpty { Button(action: copyAllToClipboard) { - HStack(spacing: DesignConstants.Spacing.xxs) { + HStack(spacing: ThemeEngine.shared.activeTheme.spacing.xxs) { Image(systemName: copied ? "checkmark" : "doc.on.doc") Text(copied ? String(localized: "Copied!") : String(localized: "Copy All")) } @@ -126,13 +126,13 @@ struct SQLReviewPopover: View { // MARK: - Empty State private var emptyState: some View { - VStack(spacing: DesignConstants.Spacing.xs) { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.xs) { Spacer() Image(systemName: "doc.plaintext") - .font(.system(size: DesignConstants.IconSize.huge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.huge)) .foregroundStyle(.tertiary) Text(String(localized: "No pending changes")) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) .foregroundStyle(.secondary) Spacer() } @@ -150,17 +150,17 @@ struct SQLReviewPopover: View { configuration: Self.makeConfiguration(), state: $editorState ) - .clipShape(RoundedRectangle(cornerRadius: DesignConstants.CornerRadius.medium)) + .clipShape(RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.medium)) .overlay( - RoundedRectangle(cornerRadius: DesignConstants.CornerRadius.medium) + RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.medium) .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) ) } else { // Lightweight placeholder while SourceEditor loads Color(nsColor: .textBackgroundColor) - .clipShape(RoundedRectangle(cornerRadius: DesignConstants.CornerRadius.medium)) + .clipShape(RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.medium)) .overlay( - RoundedRectangle(cornerRadius: DesignConstants.CornerRadius.medium) + RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.medium) .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) ) } @@ -173,7 +173,7 @@ struct SQLReviewPopover: View { appearance: .init( theme: TableProEditorTheme.make(), font: NSFont.monospacedSystemFont( - ofSize: DesignConstants.FontSize.medium, weight: .regular), + ofSize: ThemeEngine.shared.activeTheme.typography.medium, weight: .regular), wrapLines: true ), behavior: .init( diff --git a/TablePro/Views/Components/SectionHeaderView.swift b/TablePro/Views/Components/SectionHeaderView.swift index b6005a4d..0dd7989c 100644 --- a/TablePro/Views/Components/SectionHeaderView.swift +++ b/TablePro/Views/Components/SectionHeaderView.swift @@ -45,43 +45,43 @@ struct SectionHeaderView: View { } private var headerContent: some View { - HStack(spacing: DesignConstants.Spacing.xs) { + HStack(spacing: ThemeEngine.shared.activeTheme.spacing.xs) { if isCollapsible { Image(systemName: "chevron.right") - .font(.system(size: DesignConstants.FontSize.caption, weight: .semibold)) - .foregroundStyle(DesignConstants.Colors.tertiaryText) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.caption, weight: .semibold)) + .foregroundStyle(ThemeEngine.shared.colors.ui.tertiaryTextSwiftUI) .rotationEffect(.degrees(isExpanded ? 90 : 0)) - .animation(.easeInOut(duration: DesignConstants.AnimationDuration.normal), value: isExpanded) + .animation(.easeInOut(duration: ThemeEngine.shared.activeTheme.animations.normal), value: isExpanded) } if let icon = icon { Image(systemName: icon) - .font(.system(size: DesignConstants.FontSize.body)) - .foregroundStyle(DesignConstants.Colors.secondaryText) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) + .foregroundStyle(ThemeEngine.shared.colors.ui.secondaryTextSwiftUI) } Text(title) - .font(.system(size: DesignConstants.FontSize.title3, weight: .semibold)) - .foregroundStyle(DesignConstants.Colors.primaryText) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.title3, weight: .semibold)) + .foregroundStyle(ThemeEngine.shared.colors.ui.primaryTextSwiftUI) if let count = count { Text("(\(count))") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(DesignConstants.Colors.tertiaryText) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(ThemeEngine.shared.colors.ui.tertiaryTextSwiftUI) } Spacer() actions() } - .padding(.horizontal, DesignConstants.Spacing.sm) - .padding(.vertical, DesignConstants.Spacing.xs) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.sm) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xs) .background( isCollapsible ? - DesignConstants.Colors.sectionBackground.opacity(0.5) : + ThemeEngine.shared.colors.ui.controlBackgroundSwiftUI.opacity(0.5) : Color.clear ) - .cornerRadius(DesignConstants.CornerRadius.medium) + .cornerRadius(ThemeEngine.shared.activeTheme.cornerRadius.medium) .contentShape(Rectangle()) } } diff --git a/TablePro/Views/Connection/ConnectionColorPicker.swift b/TablePro/Views/Connection/ConnectionColorPicker.swift index 9b44440e..f5a9adf6 100644 --- a/TablePro/Views/Connection/ConnectionColorPicker.swift +++ b/TablePro/Views/Connection/ConnectionColorPicker.swift @@ -39,20 +39,20 @@ private struct ColorDot: View { // "None" option - shows as crossed circle Circle() .stroke(Color.secondary, lineWidth: 1) - .frame(width: DesignConstants.IconSize.large, height: DesignConstants.IconSize.large) + .frame(width: ThemeEngine.shared.activeTheme.iconSizes.large, height: ThemeEngine.shared.activeTheme.iconSizes.large) Image(systemName: "circle.slash") - .font(.system(size: DesignConstants.IconSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.small)) .foregroundStyle(.secondary) } else { Circle() .fill(color.color) - .frame(width: DesignConstants.IconSize.large, height: DesignConstants.IconSize.large) + .frame(width: ThemeEngine.shared.activeTheme.iconSizes.large, height: ThemeEngine.shared.activeTheme.iconSizes.large) } if isSelected { Circle() .stroke(Color.primary, lineWidth: 2) - .frame(width: DesignConstants.IconSize.extraLarge, height: DesignConstants.IconSize.extraLarge) + .frame(width: ThemeEngine.shared.activeTheme.iconSizes.extraLarge, height: ThemeEngine.shared.activeTheme.iconSizes.extraLarge) } } .frame(width: 28, height: 28) diff --git a/TablePro/Views/Connection/ConnectionGroupPicker.swift b/TablePro/Views/Connection/ConnectionGroupPicker.swift index 67ad1dd2..1e648e95 100644 --- a/TablePro/Views/Connection/ConnectionGroupPicker.swift +++ b/TablePro/Views/Connection/ConnectionGroupPicker.swift @@ -162,13 +162,13 @@ private struct GroupColorPicker: View { Button(action: { selectedColor = color }) { Circle() .fill(color == .none ? Color(nsColor: .quaternaryLabelColor) : color.color) - .frame(width: DesignConstants.IconSize.medium, height: DesignConstants.IconSize.medium) + .frame(width: ThemeEngine.shared.activeTheme.iconSizes.medium, height: ThemeEngine.shared.activeTheme.iconSizes.medium) .overlay( Circle() .stroke(Color.primary, lineWidth: selectedColor == color ? 2 : 0) .frame( - width: DesignConstants.IconSize.large, - height: DesignConstants.IconSize.large + width: ThemeEngine.shared.activeTheme.iconSizes.large, + height: ThemeEngine.shared.activeTheme.iconSizes.large ) ) } diff --git a/TablePro/Views/Connection/ConnectionSidebarHeader.swift b/TablePro/Views/Connection/ConnectionSidebarHeader.swift index 00c7713b..6ad1debf 100644 --- a/TablePro/Views/Connection/ConnectionSidebarHeader.swift +++ b/TablePro/Views/Connection/ConnectionSidebarHeader.swift @@ -92,17 +92,17 @@ struct ConnectionSidebarHeader: View { if let session = currentSession { Image(session.connection.type.iconName) .renderingMode(.template) - .font(.system(size: DesignConstants.IconSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.medium)) .foregroundStyle(session.connection.displayColor) } else { Image(systemName: "cylinder") - .font(.system(size: DesignConstants.IconSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.medium)) .foregroundStyle(.secondary) } // Connection name Text(currentSession?.connection.name ?? "No Connection") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) .lineLimit(1) Spacer() @@ -113,17 +113,17 @@ struct ConnectionSidebarHeader: View { Circle() .fill(statusColor(for: session)) .frame( - width: DesignConstants.IconSize.tinyDot, - height: DesignConstants.IconSize.tinyDot) + width: ThemeEngine.shared.activeTheme.iconSizes.tinyDot, + height: ThemeEngine.shared.activeTheme.iconSizes.tinyDot) } Image(systemName: "chevron.down") - .font(.system(size: DesignConstants.FontSize.tiny, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, weight: .medium)) .foregroundStyle(.secondary) } } .padding(.horizontal, 12) - .padding(.vertical, DesignConstants.Spacing.sm) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.sm) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -144,8 +144,8 @@ struct ConnectionSidebarHeader: View { Circle() .fill(statusColor(for: session)) .frame( - width: DesignConstants.IconSize.tinyDot, - height: DesignConstants.IconSize.tinyDot) + width: ThemeEngine.shared.activeTheme.iconSizes.tinyDot, + height: ThemeEngine.shared.activeTheme.iconSizes.tinyDot) if case .connecting = session.status { ProgressView() diff --git a/TablePro/Views/Connection/ConnectionTagEditor.swift b/TablePro/Views/Connection/ConnectionTagEditor.swift index ee187aac..bef73d69 100644 --- a/TablePro/Views/Connection/ConnectionTagEditor.swift +++ b/TablePro/Views/Connection/ConnectionTagEditor.swift @@ -186,13 +186,13 @@ private struct TagColorPicker: View { Button(action: { selectedColor = color }) { Circle() .fill(color.color) - .frame(width: DesignConstants.IconSize.medium, height: DesignConstants.IconSize.medium) + .frame(width: ThemeEngine.shared.activeTheme.iconSizes.medium, height: ThemeEngine.shared.activeTheme.iconSizes.medium) .overlay( Circle() .stroke(Color.primary, lineWidth: selectedColor == color ? 2 : 0) .frame( - width: DesignConstants.IconSize.large, - height: DesignConstants.IconSize.large + width: ThemeEngine.shared.activeTheme.iconSizes.large, + height: ThemeEngine.shared.activeTheme.iconSizes.large ) ) } diff --git a/TablePro/Views/Connection/OnboardingContentView.swift b/TablePro/Views/Connection/OnboardingContentView.swift index 7a12c65a..e7370e35 100644 --- a/TablePro/Views/Connection/OnboardingContentView.swift +++ b/TablePro/Views/Connection/OnboardingContentView.swift @@ -76,7 +76,7 @@ struct OnboardingContentView: View { // MARK: - Welcome Page private var welcomePage: some View { - VStack(spacing: DesignConstants.Spacing.md) { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.md) { Image(nsImage: NSApp.applicationIconImage) .resizable() .frame(width: 80, height: 80) @@ -85,7 +85,7 @@ struct OnboardingContentView: View { .font(.system(size: 24, weight: .bold, design: .rounded)) Text("A fast, lightweight native macOS database client") - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) .foregroundStyle(.secondary) } } @@ -93,9 +93,9 @@ struct OnboardingContentView: View { // MARK: - Features Page private var featuresPage: some View { - VStack(spacing: DesignConstants.Spacing.xl) { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.xl) { Text("What you can do") - .font(.system(size: DesignConstants.FontSize.title2, weight: .semibold)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.title2, weight: .semibold)) VStack(alignment: .leading, spacing: 16) { featureRow( @@ -132,15 +132,15 @@ struct OnboardingContentView: View { private func featureRow(icon: String, title: String, description: String) -> some View { HStack(spacing: 16) { Image(systemName: icon) - .font(.system(size: DesignConstants.IconSize.extraLarge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.extraLarge)) .foregroundStyle(.tint) .frame(width: 40) - VStack(alignment: .leading, spacing: DesignConstants.Spacing.xxxs) { + VStack(alignment: .leading, spacing: ThemeEngine.shared.activeTheme.spacing.xxxs) { Text(title) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) Text(description) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) } } @@ -149,7 +149,7 @@ struct OnboardingContentView: View { // MARK: - Get Started Page private var getStartedPage: some View { - VStack(spacing: DesignConstants.Spacing.md) { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.md) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 48)) .foregroundStyle(.green) @@ -158,7 +158,7 @@ struct OnboardingContentView: View { .font(.system(size: 22, weight: .bold, design: .rounded)) Text("Create a connection to get started with\nyour databases.") - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } @@ -208,8 +208,8 @@ struct OnboardingContentView: View { } .animation(.easeInOut(duration: 0.35), value: currentPage) } - .padding(.horizontal, DesignConstants.Spacing.xl) - .padding(.bottom, DesignConstants.Spacing.lg) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.xl) + .padding(.bottom, ThemeEngine.shared.activeTheme.spacing.lg) } // MARK: - Actions diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 0d1d23b0..3fb1145a 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -152,11 +152,11 @@ struct WelcomeWindowView: View { Text("TablePro") .font( .system( - size: DesignConstants.IconSize.extraLarge, weight: .semibold, + size: ThemeEngine.shared.activeTheme.iconSizes.extraLarge, weight: .semibold, design: .rounded)) Text("Version \(Bundle.main.appVersion)") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.secondary) } } @@ -181,9 +181,9 @@ struct WelcomeWindowView: View { KeyboardHint(keys: "⌘N", label: "New") KeyboardHint(keys: "⌘,", label: "Settings") } - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.tertiary) - .padding(.bottom, DesignConstants.Spacing.lg) + .padding(.bottom, ThemeEngine.shared.activeTheme.spacing.lg) } .frame(width: 260) } @@ -196,11 +196,11 @@ struct WelcomeWindowView: View { HStack(spacing: 8) { Button(action: { openWindow(id: "connection-form") }) { Image(systemName: "plus") - .font(.system(size: DesignConstants.FontSize.medium, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium, weight: .medium)) .foregroundStyle(.secondary) .frame( - width: DesignConstants.IconSize.extraLarge, - height: DesignConstants.IconSize.extraLarge + width: ThemeEngine.shared.activeTheme.iconSizes.extraLarge, + height: ThemeEngine.shared.activeTheme.iconSizes.extraLarge ) .background( RoundedRectangle(cornerRadius: 6) @@ -212,11 +212,11 @@ struct WelcomeWindowView: View { Button(action: { showNewGroupSheet = true }) { Image(systemName: "folder.badge.plus") - .font(.system(size: DesignConstants.FontSize.medium, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium, weight: .medium)) .foregroundStyle(.secondary) .frame( - width: DesignConstants.IconSize.extraLarge, - height: DesignConstants.IconSize.extraLarge + width: ThemeEngine.shared.activeTheme.iconSizes.extraLarge, + height: ThemeEngine.shared.activeTheme.iconSizes.extraLarge ) .background( RoundedRectangle(cornerRadius: 6) @@ -228,22 +228,22 @@ struct WelcomeWindowView: View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.tertiary) TextField("Search for connection...", text: $searchText) .textFieldStyle(.plain) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) } - .padding(.horizontal, DesignConstants.Spacing.sm) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.sm) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 8) .fill(Color(nsColor: .quaternaryLabelColor)) ) } - .padding(.horizontal, DesignConstants.Spacing.md) - .padding(.vertical, DesignConstants.Spacing.sm) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.md) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.sm) Divider() @@ -331,7 +331,7 @@ struct WelcomeWindowView: View { } ) .tag(connection.id) - .listRowInsets(DesignConstants.swiftUIListRowInsets) + .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) .listRowSeparator(.hidden) } @@ -351,7 +351,7 @@ struct WelcomeWindowView: View { }) { HStack(spacing: 6) { Image(systemName: collapsedGroupIds.contains(group.id) ? "chevron.right" : "chevron.down") - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium)) .foregroundStyle(.tertiary) .frame(width: 12) @@ -362,11 +362,11 @@ struct WelcomeWindowView: View { } Text(group.name) - .font(.system(size: DesignConstants.FontSize.small, weight: .semibold)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .semibold)) .foregroundStyle(.secondary) Text("\(connections(in: group).count)") - .font(.system(size: DesignConstants.FontSize.tiny)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) .foregroundStyle(.tertiary) Spacer() @@ -422,30 +422,30 @@ struct WelcomeWindowView: View { Spacer() Image(systemName: "cylinder.split.1x2") - .font(.system(size: DesignConstants.IconSize.huge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.huge)) .foregroundStyle(.tertiary) if searchText.isEmpty { Text("No Connections") - .font(.system(size: DesignConstants.FontSize.title3, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.title3, weight: .medium)) .foregroundStyle(.secondary) Text("Create a connection to get started") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.tertiary) Button(action: { openWindow(id: "connection-form") }) { Label("New Connection", systemImage: "plus") } .controlSize(.large) - .padding(.top, DesignConstants.Spacing.xxs) + .padding(.top, ThemeEngine.shared.activeTheme.spacing.xxs) } else { Text("No Matching Connections") - .font(.system(size: DesignConstants.FontSize.title3, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.title3, weight: .medium)) .foregroundStyle(.secondary) Text("Try a different search term") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.tertiary) } @@ -639,25 +639,25 @@ private struct ConnectionRow: View { // Database type icon Image(connection.type.iconName) .renderingMode(.template) - .font(.system(size: DesignConstants.IconSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.medium)) .foregroundStyle(connection.displayColor) .frame( - width: DesignConstants.IconSize.medium, height: DesignConstants.IconSize.medium) + width: ThemeEngine.shared.activeTheme.iconSizes.medium, height: ThemeEngine.shared.activeTheme.iconSizes.medium) // Connection info VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(connection.name) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) .foregroundStyle(.primary) // Tag (single) if let tag = displayTag { Text(tag.name) - .font(.system(size: DesignConstants.FontSize.tiny)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) .foregroundStyle(tag.color.color) - .padding(.horizontal, DesignConstants.Spacing.xxs) - .padding(.vertical, DesignConstants.Spacing.xxxs) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.xxs) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxxs) .background( RoundedRectangle(cornerRadius: 4).fill( tag.color.color.opacity(0.15))) @@ -665,14 +665,14 @@ private struct ConnectionRow: View { } Text(connectionSubtitle) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) .lineLimit(1) } Spacer() } - .padding(.vertical, DesignConstants.Spacing.xxs) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxs) .contentShape(Rectangle()) .overlay( DoubleClickView { onConnect?() } @@ -743,7 +743,7 @@ private struct EnvironmentBadge: View { var body: some View { Text("(\(environment.rawValue.lowercased()))") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(environment.badgeColor) } } @@ -753,10 +753,10 @@ private struct EnvironmentBadge: View { private struct WelcomeButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) .foregroundStyle(.primary) - .padding(.horizontal, DesignConstants.Spacing.md) - .padding(.vertical, DesignConstants.Spacing.sm) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.md) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.sm) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 8) @@ -777,9 +777,9 @@ private struct KeyboardHint: View { var body: some View { HStack(spacing: 4) { Text(keys) - .font(.system(size: DesignConstants.FontSize.caption, design: .monospaced)) - .padding(.horizontal, DesignConstants.Spacing.xxs + 1) - .padding(.vertical, DesignConstants.Spacing.xxxs) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.caption, design: .monospaced)) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.xxs + 1) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxxs) .background( RoundedRectangle(cornerRadius: 3) .fill(Color(nsColor: .quaternaryLabelColor)) diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift index 86711bbb..cdd4c14a 100644 --- a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -36,7 +36,7 @@ struct CreateDatabaseSheet: View { VStack(spacing: 0) { // Header Text("Create Database") - .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .semibold)) .padding(.vertical, 12) Divider() @@ -46,18 +46,18 @@ struct CreateDatabaseSheet: View { // Database name VStack(alignment: .leading, spacing: 6) { Text("Database Name") - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium)) .foregroundStyle(.secondary) TextField("Enter database name", text: $databaseName) .textFieldStyle(.roundedBorder) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) } // Charset VStack(alignment: .leading, spacing: 6) { Text("Character Set") - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium)) .foregroundStyle(.secondary) Picker("", selection: $charset) { @@ -67,13 +67,13 @@ struct CreateDatabaseSheet: View { } .labelsHidden() .pickerStyle(.menu) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) } // Collation VStack(alignment: .leading, spacing: 6) { Text("Collation") - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium)) .foregroundStyle(.secondary) Picker("", selection: $collation) { @@ -83,13 +83,13 @@ struct CreateDatabaseSheet: View { } .labelsHidden() .pickerStyle(.menu) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) } // Error message if let error = errorMessage { Text(error) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.red) } } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 2de881e9..5b3594ff 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -59,7 +59,7 @@ struct DatabaseSwitcherSheet: View { Text(isSchemaMode ? String(localized: "Open Schema") : String(localized: "Open Database")) - .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .semibold)) .padding(.vertical, 12) // Databases / Schemas toggle (PostgreSQL only) @@ -139,7 +139,7 @@ struct DatabaseSwitcherSheet: View { // Search HStack(spacing: 6) { Image(systemName: "magnifyingglass") - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) .foregroundStyle(.tertiary) TextField(isSchemaMode @@ -147,7 +147,7 @@ struct DatabaseSwitcherSheet: View { : String(localized: "Search databases..."), text: $viewModel.searchText) .textFieldStyle(.plain) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) if !viewModel.searchText.isEmpty { Button(action: { viewModel.searchText = "" }) { @@ -200,7 +200,7 @@ struct DatabaseSwitcherSheet: View { } header: { Text("RECENT") .font( - .system(size: DesignConstants.FontSize.caption, weight: .semibold) + .system(size: ThemeEngine.shared.activeTheme.typography.caption, weight: .semibold) ) .foregroundStyle(.secondary) } @@ -217,7 +217,7 @@ struct DatabaseSwitcherSheet: View { ? String(localized: "ALL SCHEMAS") : String(localized: "ALL DATABASES")) .font( - .system(size: DesignConstants.FontSize.caption, weight: .semibold) + .system(size: ThemeEngine.shared.activeTheme.typography.caption, weight: .semibold) ) .foregroundStyle(.secondary) } @@ -276,7 +276,7 @@ struct DatabaseSwitcherSheet: View { .fill(isSelected ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) .padding(.horizontal, 4) ) - .listRowInsets(DesignConstants.swiftUIListRowInsets) + .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) .listRowSeparator(.hidden) .id(database.name) .tag(database.name) @@ -297,7 +297,7 @@ struct DatabaseSwitcherSheet: View { Text(isSchemaMode ? String(localized: "Loading schemas...") : String(localized: "Loading databases...")) - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -306,16 +306,16 @@ struct DatabaseSwitcherSheet: View { private func errorView(_ message: String) -> some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") - .font(.system(size: DesignConstants.IconSize.extraLarge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.extraLarge)) .foregroundStyle(.orange) Text(isSchemaMode ? String(localized: "Failed to load schemas") : String(localized: "Failed to load databases")) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) Text(message) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) @@ -332,16 +332,16 @@ struct DatabaseSwitcherSheet: View { private var sqliteEmptyState: some View { VStack(spacing: 12) { Image(systemName: "doc.fill") - .font(.system(size: DesignConstants.IconSize.extraLarge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.extraLarge)) .foregroundStyle(.secondary) Text("SQLite is file-based") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) Text( "Each SQLite file is a separate database.\nTo open a different database, create a new connection." ) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) @@ -352,24 +352,24 @@ struct DatabaseSwitcherSheet: View { private var emptyState: some View { VStack(spacing: 12) { Image(systemName: "magnifyingglass") - .font(.system(size: DesignConstants.IconSize.extraLarge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.extraLarge)) .foregroundStyle(.secondary) if viewModel.searchText.isEmpty { Text(isSchemaMode ? String(localized: "No schemas found") : String(localized: "No databases found")) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) } else { Text(isSchemaMode ? String(localized: "No matching schemas") : String(localized: "No matching databases")) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) Text(isSchemaMode ? String(localized: "No schemas match \"\(viewModel.searchText)\"") : String(localized: "No databases match \"\(viewModel.searchText)\"")) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) } } diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index 2f7dc0f3..6822172c 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -105,7 +105,7 @@ private extension HistoryPanelView { } } .listStyle(.plain) - .environment(\.defaultMinListRowHeight, DesignConstants.RowHeight.comfortable) + .environment(\.defaultMinListRowHeight, ThemeEngine.shared.activeTheme.rowHeights.comfortable) .onDeleteCommand { deleteSelectedEntry() } @@ -145,24 +145,24 @@ private extension HistoryPanelView { VStack(spacing: 8) { if !searchText.isEmpty || dateFilter != .all { Image(systemName: "magnifyingglass") - .font(.system(size: DesignConstants.IconSize.huge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.huge)) .foregroundStyle(.tertiary) Text("No Matching Queries") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) .foregroundStyle(.secondary) Text("Try adjusting your search terms\nor date filter.") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } else { Image(systemName: "clock.arrow.circlepath") - .font(.system(size: DesignConstants.IconSize.huge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.huge)) .foregroundStyle(.tertiary) Text("No Query History Yet") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) .foregroundStyle(.secondary) Text("Your executed queries will\nappear here for quick access.") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } @@ -209,17 +209,17 @@ private extension HistoryPanelView { databaseType: entry.query.trimmingCharacters(in: .whitespaces) .hasPrefix("db.") ? .mongodb : .mysql // Redis commands use SQL patterns for highlighting ) - .background(Color(nsColor: SQLEditorTheme.background)) + .background(Color(nsColor: ThemeEngine.shared.colors.editor.background)) Divider() // Metadata VStack(alignment: .leading, spacing: 4) { Text(buildPrimaryMetadata(entry)) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) Text(buildSecondaryMetadata(entry)) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.tertiary) } .frame(maxWidth: .infinity, alignment: .leading) @@ -252,13 +252,13 @@ private extension HistoryPanelView { var previewEmptyState: some View { VStack(spacing: 8) { Image(systemName: "doc.text") - .font(.system(size: DesignConstants.IconSize.huge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.huge)) .foregroundStyle(.tertiary) Text("Select a Query") - .font(.system(size: DesignConstants.FontSize.title3, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.title3, weight: .medium)) .foregroundStyle(.secondary) Text("Choose a query from the list\nto see its full content here.") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } @@ -395,25 +395,25 @@ private struct HistoryRowSwiftUI: View { HStack(spacing: 8) { Image(systemName: entry.wasSuccessful ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundStyle(entry.wasSuccessful ? .green : .red) - .font(.system(size: DesignConstants.IconSize.default)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.default)) VStack(alignment: .leading, spacing: 2) { Text(entry.queryPreview) - .font(.system(size: DesignConstants.FontSize.medium, design: .monospaced)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium, design: .monospaced)) .lineLimit(1) Text(entry.databaseName) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) .lineLimit(1) HStack { Text(relativeTime(entry.executedAt)) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.tertiary) Spacer() Text(entry.formattedExecutionTime) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.tertiary) } } diff --git a/TablePro/Views/Editor/SQLEditorTheme.swift b/TablePro/Views/Editor/SQLEditorTheme.swift deleted file mode 100644 index f0f6eee6..00000000 --- a/TablePro/Views/Editor/SQLEditorTheme.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// SQLEditorTheme.swift -// TablePro -// -// Centralized theme constants for the SQL editor. -// User-configurable values are cached and updated via reloadFromSettings(). -// - -import AppKit -import os - -/// Centralized theme configuration for the SQL editor -struct SQLEditorTheme { - // MARK: - Cached Settings (Thread-Safe) - - private static let logger = Logger(subsystem: "com.TablePro", category: "SQLEditorTheme") - - /// Cached font from settings - call reloadFromSettings() on main thread to update - private(set) static var font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - - /// Cached line number font - call reloadFromSettings() on main thread to update - private(set) static var lineNumberFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) - - /// Cached line highlight enabled flag - private(set) static var highlightCurrentLine = true - - /// Cached show line numbers flag - private(set) static var showLineNumbers = true - - /// Cached tab width setting - private(set) static var tabWidth = 4 - - /// Cached auto-indent setting - private(set) static var autoIndent = true - - /// Cached word wrap setting - private(set) static var wordWrap = false - - // MARK: - Accessibility Text Size - - /// The default macOS body font size (13pt). Used as the baseline for computing - /// the accessibility scale factor from NSFont.preferredFont(forTextStyle:). - private static let defaultBodyFontSize: CGFloat = 13.0 - - /// Scale factor derived from the system's accessibility text size preference - /// (System Settings > Accessibility > Display > Text Size). - /// Computed by comparing the preferred body font size to the default 13pt baseline. - /// Applied as a multiplier on top of the user's configured font size. - static var accessibilityScaleFactor: CGFloat { - let preferredBodyFont = NSFont.preferredFont(forTextStyle: .body) - let scale = preferredBodyFont.pointSize / defaultBodyFontSize - // Clamp to a reasonable range to prevent extreme sizes - return min(max(scale, 0.5), 3.0) - } - - /// Reload settings from provided EditorSettings. Must be called on main thread. - /// The user's chosen font size is scaled by the system's accessibility text size preference. - @MainActor - static func reloadFromSettings(_ settings: EditorSettings) { - let scale = accessibilityScaleFactor - let scaledSize = round(CGFloat(settings.clampedFontSize) * scale) - font = settings.fontFamily.font(size: scaledSize) - let lineNumberSize = max(round((CGFloat(settings.clampedFontSize) - 2) * scale), 9) - lineNumberFont = NSFont.monospacedSystemFont(ofSize: lineNumberSize, weight: .regular) - highlightCurrentLine = settings.highlightCurrentLine - showLineNumbers = settings.showLineNumbers - tabWidth = settings.clampedTabWidth - autoIndent = settings.autoIndent - wordWrap = settings.wordWrap - - if scale != 1.0 { - logger.debug("Accessibility scale factor: \(scale, format: .fixed(precision: 2)), effective font size: \(scaledSize)") - } - } - - // MARK: - Colors - - /// Background color for the editor - static let background = NSColor.textBackgroundColor - - /// Default text color - static let text = NSColor.textColor - - /// Current line highlight color (respects cached setting) - static var currentLineHighlight: NSColor { - if highlightCurrentLine { - return NSColor.controlAccentColor.withAlphaComponent(0.08) - } else { - return .clear - } - } - - /// Insertion point (cursor) color - static let insertionPoint = NSColor.controlAccentColor - - // MARK: - Syntax Highlighting Colors - - /// SQL keywords (SELECT, FROM, WHERE, etc.) - static let keyword = NSColor.systemBlue - - /// String literals ('...', "...", `...`) - static let string = NSColor.systemRed - - /// Numeric literals - static let number = NSColor.systemPurple - - /// Comments (-- and /* */) - static let comment = NSColor.systemGreen - - /// NULL, TRUE, FALSE - static let null = NSColor.systemOrange -} diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 7c11e56f..cd602d76 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -124,18 +124,18 @@ struct SQLEditorView: View { SourceEditorConfiguration( appearance: .init( theme: TableProEditorTheme.make(), - font: SQLEditorTheme.font, - wrapLines: SQLEditorTheme.wordWrap, - tabWidth: SQLEditorTheme.tabWidth + font: ThemeEngine.shared.editorFonts.font, + wrapLines: ThemeEngine.shared.wordWrap, + tabWidth: ThemeEngine.shared.tabWidth ), behavior: .init( - indentOption: .spaces(count: SQLEditorTheme.tabWidth) + indentOption: .spaces(count: ThemeEngine.shared.tabWidth) ), layout: .init( contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) ), peripherals: .init( - showGutter: SQLEditorTheme.showLineNumbers, + showGutter: ThemeEngine.shared.showLineNumbers, showMinimap: false, showFoldingRibbon: false ) diff --git a/TablePro/Views/Editor/TableProEditorTheme.swift b/TablePro/Views/Editor/TableProEditorTheme.swift index 9d69ac7b..2818cc52 100644 --- a/TablePro/Views/Editor/TableProEditorTheme.swift +++ b/TablePro/Views/Editor/TableProEditorTheme.swift @@ -2,45 +2,16 @@ // TableProEditorTheme.swift // TablePro // -// Adapts SQLEditorTheme colors to CodeEditSourceEditor's EditorTheme. +// Adapts ThemeEngine colors to CodeEditSourceEditor's EditorTheme. // import AppKit import CodeEditSourceEditor -/// Maps TablePro's SQLEditorTheme colors to CodeEditSourceEditor's EditorTheme +/// Maps ThemeEngine's active theme to CodeEditSourceEditor's EditorTheme struct TableProEditorTheme { - /// Build an EditorTheme from the current SQLEditorTheme settings + @MainActor static func make() -> EditorTheme { - let textAttr = EditorTheme.Attribute(color: rgb(SQLEditorTheme.text)) - let commentAttr = EditorTheme.Attribute(color: rgb(SQLEditorTheme.comment)) - let keywordAttr = EditorTheme.Attribute(color: rgb(SQLEditorTheme.keyword), bold: true) - let stringAttr = EditorTheme.Attribute(color: rgb(SQLEditorTheme.string)) - let numberAttr = EditorTheme.Attribute(color: rgb(SQLEditorTheme.number)) - let variableAttr = EditorTheme.Attribute(color: rgb(SQLEditorTheme.null)) - - return EditorTheme( - text: textAttr, - insertionPoint: rgb(SQLEditorTheme.insertionPoint), - invisibles: EditorTheme.Attribute(color: rgb(.tertiaryLabelColor)), - background: rgb(SQLEditorTheme.background), - lineHighlight: rgb(SQLEditorTheme.currentLineHighlight), - selection: rgb(.selectedTextBackgroundColor), - keywords: keywordAttr, - commands: keywordAttr, - types: EditorTheme.Attribute(color: rgb(.systemTeal)), - attributes: variableAttr, - variables: variableAttr, - values: variableAttr, - numbers: numberAttr, - strings: stringAttr, - characters: stringAttr, - comments: commentAttr - ) - } - - /// Convert any NSColor to sRGB so that `brightnessComponent` (used by MinimapView) works. - private static func rgb(_ color: NSColor) -> NSColor { - color.usingColorSpace(.sRGB) ?? color + ThemeEngine.shared.makeEditorTheme() } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 179c01c3..608adb8a 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -157,7 +157,7 @@ struct ExportDialog: View { // Header with title and selection count HStack { Text("Items") - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium)) .foregroundStyle(.secondary) Spacer() @@ -165,7 +165,7 @@ struct ExportDialog: View { if let plugin = currentPlugin { ForEach(type(of: plugin).perTableOptionColumns) { column in Text(column.label) - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium)) .foregroundStyle(.secondary) .frame(width: column.width, alignment: .center) } @@ -184,7 +184,7 @@ struct ExportDialog: View { ProgressView() .scaleEffect(0.8) Text("Loading databases...") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) .padding(.top, 8) Spacer() @@ -209,7 +209,7 @@ struct ExportDialog: View { HStack { Spacer() Text("No export formats available. Enable export plugins in Settings > Plugins.") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) Spacer() } @@ -235,12 +235,12 @@ struct ExportDialog: View { // Selection count (shows exportable count when some tables have no options) VStack(spacing: 2) { Text("\(exportableCount) table\(exportableCount == 1 ? "" : "s") to export") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) if let plugin = currentPlugin, !type(of: plugin).perTableOptionColumns.isEmpty, exportableCount < selectedCount { Text("\(selectedCount - exportableCount) skipped (no options)") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.orange) } } @@ -271,17 +271,17 @@ struct ExportDialog: View { // File name section VStack(alignment: .leading, spacing: 6) { Text("File name") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) HStack(spacing: 4) { TextField("export", text: $config.fileName) .textFieldStyle(.roundedBorder) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) Text(".\(fileExtension)") .foregroundStyle(.secondary) - .font(.system(size: DesignConstants.FontSize.body, design: .monospaced)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, design: .monospaced)) .lineLimit(1) .fixedSize() } @@ -289,7 +289,7 @@ struct ExportDialog: View { // Show validation error if filename is invalid if let validationError = fileNameValidationError { Text(validationError) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.red) } } @@ -314,7 +314,7 @@ struct ExportDialog: View { .scaleEffect(0.7) Text(currentExportTable) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index cb14289d..a1dd9f9d 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -63,11 +63,11 @@ struct FilterPanelView: View { private var filterHeader: some View { HStack(spacing: 8) { Text("Filters") - .font(.system(size: DesignConstants.FontSize.medium, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium, weight: .medium)) if filterState.hasAppliedFilters { Text("(\(filterState.appliedFilters.count) active)") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) } @@ -88,7 +88,7 @@ struct FilterPanelView: View { // Settings button Button(action: { showSettingsPopover.toggle() }) { Image(systemName: "gearshape") - .font(.system(size: DesignConstants.IconSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.small)) } .buttonStyle(.borderless) .foregroundStyle(.secondary) @@ -103,7 +103,7 @@ struct FilterPanelView: View { filterState.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) }) { Image(systemName: "plus") - .font(.system(size: DesignConstants.IconSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.small)) } .buttonStyle(.borderless) .foregroundStyle(.tint) @@ -111,7 +111,7 @@ struct FilterPanelView: View { .help("Add Filter (Cmd+Shift+F)") } .padding(.horizontal, 8) - .padding(.vertical, DesignConstants.Spacing.xs) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xs) .background(Color(nsColor: .controlBackgroundColor)) .contentShape(Rectangle()) .onTapGesture { filterState.focusedFilterId = nil } @@ -167,7 +167,7 @@ struct FilterPanelView: View { } } label: { Image(systemName: "folder") - .font(.system(size: DesignConstants.IconSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.small)) } .buttonStyle(.borderless) .foregroundStyle(.secondary) @@ -217,7 +217,7 @@ struct FilterPanelView: View { HStack(spacing: 8) { Toggle("Select All", isOn: selectAllBinding) .toggleStyle(.checkbox) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) .disabled(filterState.filters.isEmpty) diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index 0bf9a5d3..bda614d4 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -80,7 +80,7 @@ struct FilterRowView: View { // Action buttons actionButtons } - .padding(.vertical, DesignConstants.Spacing.xs) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xs) .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 4) diff --git a/TablePro/Views/Filter/QuickSearchField.swift b/TablePro/Views/Filter/QuickSearchField.swift index 83749a10..04dfe6d0 100644 --- a/TablePro/Views/Filter/QuickSearchField.swift +++ b/TablePro/Views/Filter/QuickSearchField.swift @@ -23,12 +23,12 @@ struct QuickSearchField: View { var body: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.secondary) TextField("Quick search across all columns...", text: $localText) .textFieldStyle(.plain) - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .focused($isTextFieldFocused) .onSubmit { if !localText.isEmpty { @@ -44,7 +44,7 @@ struct QuickSearchField: View { onClear() }) { Image(systemName: "xmark.circle.fill") - .font(.system(size: DesignConstants.IconSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.small)) .foregroundStyle(.secondary) } .buttonStyle(.borderless) diff --git a/TablePro/Views/Filter/SQLPreviewSheet.swift b/TablePro/Views/Filter/SQLPreviewSheet.swift index 120ffb3f..6db1b68c 100644 --- a/TablePro/Views/Filter/SQLPreviewSheet.swift +++ b/TablePro/Views/Filter/SQLPreviewSheet.swift @@ -20,11 +20,11 @@ struct SQLPreviewSheet: View { VStack(spacing: 16) { HStack { Text("Generated WHERE Clause") - .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .semibold)) Spacer() Button(action: { dismiss() }) { Image(systemName: "xmark.circle.fill") - .font(.system(size: DesignConstants.IconSize.default)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.default)) .foregroundStyle(.tertiary) } .buttonStyle(.borderless) @@ -32,7 +32,7 @@ struct SQLPreviewSheet: View { ScrollView { Text(sql.isEmpty ? "(no conditions)" : sql) - .font(.system(size: DesignConstants.FontSize.medium, design: .monospaced)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(12) @@ -49,9 +49,9 @@ struct SQLPreviewSheet: View { Button(action: copyToClipboard) { HStack(spacing: 4) { Image(systemName: copied ? "checkmark" : "doc.on.doc") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) Text(copied ? "Copied!" : "Copy") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) } } .buttonStyle(.bordered) diff --git a/TablePro/Views/Import/SQLCodePreview.swift b/TablePro/Views/Import/SQLCodePreview.swift index 826c2c5d..a28fd944 100644 --- a/TablePro/Views/Import/SQLCodePreview.swift +++ b/TablePro/Views/Import/SQLCodePreview.swift @@ -39,7 +39,7 @@ struct SQLCodePreview: View { SourceEditorConfiguration( appearance: .init( theme: TableProEditorTheme.make(), - font: SQLEditorTheme.font, + font: ThemeEngine.shared.editorFonts.font, wrapLines: false ), behavior: .init( diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift index 498d188a..6affa8eb 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -26,7 +26,7 @@ internal struct QuickSwitcherSheet: View { VStack(spacing: 0) { // Header Text("Quick Switcher") - .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .semibold)) .padding(.vertical, 12) Divider() @@ -79,12 +79,12 @@ internal struct QuickSwitcherSheet: View { private var searchToolbar: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) .foregroundStyle(.tertiary) TextField("Search tables, views, databases...", text: $viewModel.searchText) .textFieldStyle(.plain) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) if !viewModel.searchText.isEmpty { Button(action: { viewModel.searchText = "" }) { @@ -116,7 +116,7 @@ internal struct QuickSwitcherSheet: View { } } header: { Text(sectionTitle(for: group.kind)) - .font(.system(size: DesignConstants.FontSize.caption, weight: .semibold)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.caption, weight: .semibold)) .foregroundStyle(.secondary) } } @@ -144,18 +144,18 @@ internal struct QuickSwitcherSheet: View { return HStack(spacing: 10) { Image(systemName: item.iconName) - .font(.system(size: DesignConstants.IconSize.default)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.default)) .foregroundStyle(isSelected ? .white : .secondary) Text(item.name) - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) .foregroundStyle(isSelected ? .white : .primary) .lineLimit(1) .truncationMode(.tail) if !item.subtitle.isEmpty { Text(item.subtitle) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(isSelected ? Color.white.opacity(0.7) : Color.secondary) .lineLimit(1) } @@ -163,7 +163,7 @@ internal struct QuickSwitcherSheet: View { Spacer() Text(item.kindLabel) - .font(.system(size: DesignConstants.FontSize.caption, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.caption, weight: .medium)) .foregroundStyle(isSelected ? .white.opacity(0.7) : .secondary) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -179,7 +179,7 @@ internal struct QuickSwitcherSheet: View { .fill(isSelected ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) .padding(.horizontal, 4) ) - .listRowInsets(DesignConstants.swiftUIListRowInsets) + .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) .listRowSeparator(.hidden) .id(item.id) .tag(item.id) @@ -198,7 +198,7 @@ internal struct QuickSwitcherSheet: View { ProgressView() .scaleEffect(0.8) Text("Loading...") - .font(.system(size: DesignConstants.FontSize.medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -207,18 +207,18 @@ internal struct QuickSwitcherSheet: View { private var emptyState: some View { VStack(spacing: 12) { Image(systemName: "magnifyingglass") - .font(.system(size: DesignConstants.IconSize.extraLarge)) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.extraLarge)) .foregroundStyle(.secondary) if viewModel.searchText.isEmpty { Text("No objects found") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) } else { Text("No matching objects") - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) Text("No objects match \"\(viewModel.searchText)\"") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) } } diff --git a/TablePro/Views/Results/BooleanCellEditor.swift b/TablePro/Views/Results/BooleanCellEditor.swift index e7a27aeb..b6471d12 100644 --- a/TablePro/Views/Results/BooleanCellEditor.swift +++ b/TablePro/Views/Results/BooleanCellEditor.swift @@ -32,7 +32,7 @@ final class BooleanCellEditor: NSPopUpButton { // Style to match text fields bezelStyle = .texturedSquare - font = .monospacedSystemFont(ofSize: DesignConstants.FontSize.body, weight: .regular) + font = .monospacedSystemFont(ofSize: ThemeEngine.shared.activeTheme.typography.body, weight: .regular) } @objc private func valueChanged() { diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 85ba973e..0c61d679 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -56,7 +56,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { let cellRect = cellView.convert(cellView.bounds, to: tableView) // Determine overlay height — at least the cell height, up to 120pt - let lineHeight: CGFloat = DataGridFontCache.regular.boundingRectForFont.height + 4 + let lineHeight: CGFloat = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 let lineCount = CGFloat(value.components(separatedBy: .newlines).count) let contentHeight = max(lineCount * lineHeight + 8, cellRect.height) let overlayHeight = min(contentHeight, 120) @@ -73,7 +73,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { textView.overlayEditor = self textView.isRichText = false textView.allowsUndo = true - textView.font = DataGridFontCache.regular + textView.font = ThemeEngine.shared.dataGridFonts.regular textView.textColor = .labelColor textView.backgroundColor = .textBackgroundColor textView.isVerticallyResizable = true diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index f54d12ad..8e1d139a 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -70,16 +70,6 @@ final class DataGridCellFactory { } } - // MARK: - Cached Colors (avoid allocation per cell render) - - private enum CellColors { - static let deletedBackground = NSColor.systemRed.withAlphaComponent(0.15).cgColor - static let insertedBackground = NSColor.systemGreen.withAlphaComponent(0.15).cgColor - static let modifiedBackground = NSColor.systemYellow.withAlphaComponent(0.3).cgColor - static let deletedText = NSColor.systemRed.withAlphaComponent(0.5) - static let focusBorder = NSColor.selectedControlColor.cgColor - } - // MARK: - Row Number Cell func makeRowNumberCell( @@ -96,14 +86,14 @@ final class DataGridCellFactory { let textField = reused.textField { cellView = reused cell = textField - cell.font = DataGridFontCache.rowNumber + cell.font = ThemeEngine.shared.dataGridFonts.rowNumber } else { cellView = NSTableCellView() cellView.identifier = cellViewId cell = NSTextField(labelWithString: "") cell.alignment = .right - cell.font = DataGridFontCache.rowNumber + cell.font = ThemeEngine.shared.dataGridFonts.rowNumber cell.tag = DataGridFontVariant.rowNumber cell.textColor = .secondaryLabelColor cell.translatesAutoresizingMaskIntoConstraints = false @@ -124,7 +114,7 @@ final class DataGridCellFactory { } cell.stringValue = "\(row + 1)" - cell.textColor = visualState.isDeleted ? CellColors.deletedText : .secondaryLabelColor + cell.textColor = visualState.isDeleted ? ThemeEngine.shared.colors.dataGrid.deletedText : .secondaryLabelColor if Self.cachedVoiceOverEnabled { cellView.setAccessibilityLabel(String(localized: "Row \(row + 1)")) } @@ -178,7 +168,7 @@ final class DataGridCellFactory { cellView.canDrawSubviewsIntoLayer = true cell = CellTextField() - cell.font = DataGridFontCache.regular + cell.font = ThemeEngine.shared.dataGridFonts.regular cell.drawsBackground = false cell.isBordered = false cell.focusRingType = .none @@ -275,11 +265,11 @@ final class DataGridCellFactory { // Update background color if isDeleted { - cellView.layer?.backgroundColor = CellColors.deletedBackground + cellView.layer?.backgroundColor = ThemeEngine.shared.colors.dataGrid.deletedCG } else if isInserted { - cellView.layer?.backgroundColor = CellColors.insertedBackground + cellView.layer?.backgroundColor = ThemeEngine.shared.colors.dataGrid.insertedCG } else if isModified { - cellView.layer?.backgroundColor = CellColors.modifiedBackground + cellView.layer?.backgroundColor = ThemeEngine.shared.colors.dataGrid.modifiedCG } else { cellView.layer?.backgroundColor = nil } @@ -289,7 +279,7 @@ final class DataGridCellFactory { cellView.layer?.borderWidth = 0 } else if isFocused { cellView.layer?.borderWidth = 2 - cellView.layer?.borderColor = CellColors.focusBorder + cellView.layer?.borderColor = ThemeEngine.shared.colors.dataGrid.focusBorderCG } else { cellView.layer?.borderWidth = 0 } @@ -314,7 +304,7 @@ final class DataGridCellFactory { if value == nil { cell.stringValue = "" - cell.font = DataGridFontCache.italic + cell.font = ThemeEngine.shared.dataGridFonts.italic cell.tag = DataGridFontVariant.italic if !isLargeDataset { cell.placeholderString = nullDisplayString @@ -322,7 +312,7 @@ final class DataGridCellFactory { cell.textColor = .secondaryLabelColor } else if value == "__DEFAULT__" { cell.stringValue = "" - cell.font = DataGridFontCache.medium + cell.font = ThemeEngine.shared.dataGridFonts.medium cell.tag = DataGridFontVariant.medium if !isLargeDataset { cell.placeholderString = "DEFAULT" @@ -330,7 +320,7 @@ final class DataGridCellFactory { cell.textColor = .systemBlue } else if value == "" { cell.stringValue = "" - cell.font = DataGridFontCache.italic + cell.font = ThemeEngine.shared.dataGridFonts.italic cell.tag = DataGridFontVariant.italic if !isLargeDataset { cell.placeholderString = "Empty" @@ -355,7 +345,7 @@ final class DataGridCellFactory { cell.stringValue = displayValue (cell as? CellTextField)?.originalValue = value cell.textColor = .labelColor - cell.font = DataGridFontCache.regular + cell.font = ThemeEngine.shared.dataGridFonts.regular cell.tag = DataGridFontVariant.regular } } @@ -371,11 +361,13 @@ final class DataGridCellFactory { /// Maximum characters to consider per cell for width estimation private static let maxMeasureChars = 50 /// Font for measuring header - private static let headerFont = NSFont.systemFont(ofSize: DesignConstants.FontSize.body, weight: .semibold) + private var headerFont: NSFont { + NSFont.systemFont(ofSize: ThemeEngine.shared.activeTheme.typography.body, weight: .semibold) + } /// Calculate column width based on header name only (used for initial display) func calculateColumnWidth(for columnName: String) -> CGFloat { - let attributes: [NSAttributedString.Key: Any] = [.font: Self.headerFont] + let attributes: [NSAttributedString.Key: Any] = [.font: headerFont] let size = (columnName as NSString).size(withAttributes: attributes) let width = size.width + 48 // padding for sort indicator + margins return min(max(width, Self.minColumnWidth), Self.maxColumnWidth) @@ -401,14 +393,14 @@ final class DataGridCellFactory { // instead of CoreText measurement. ~0.6 of mono width is a good estimate // for proportional system font. let headerCharCount = (columnName as NSString).length - var maxWidth = CGFloat(headerCharCount) * DataGridFontCache.monoCharWidth * 0.75 + 48 + var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48 let totalRows = rowProvider.totalRowCount let columnCount = rowProvider.columns.count // Reduce sample count for wide tables to keep total work bounded let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount let step = max(1, totalRows / effectiveSampleCount) - let charWidth = DataGridFontCache.monoCharWidth + let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { guard let value = rowProvider.value(atRow: i, column: columnIndex) else { continue } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index f6bdc441..e85e1ba4 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -600,6 +600,10 @@ struct DataGridView: NSViewRepresentable { NotificationCenter.default.removeObserver(observer) coordinator.settingsObserver = nil } + if let observer = coordinator.themeObserver { + NotificationCenter.default.removeObserver(observer) + coordinator.themeObserver = nil + } } func makeCoordinator() -> TableViewCoordinator { @@ -666,6 +670,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // Settings observer for real-time updates fileprivate var settingsObserver: NSObjectProtocol? + // Theme observer for font/color changes + fileprivate var themeObserver: NSObjectProtocol? /// Snapshot of last-seen data grid settings for change detection private var lastDataGridSettings: DataGridSettings @@ -722,6 +728,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData super.init() updateCache() + // Subscribe to theme changes for font/color updates + observeThemeChanges() + // Subscribe to settings changes for real-time updates settingsObserver = NotificationCenter.default.addObserver( forName: .dataGridSettingsDidChange, @@ -742,17 +751,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData tableView.tile() } - // Font-only change: update fonts in-place without reloadData - // to avoid recycling cells through the reuse pool outside the - // normal SwiftUI update cycle, which can cause stale data. - let fontChanged = prev.fontFamily != settings.fontFamily || prev.fontSize != settings.fontSize + // Font changes are handled by .themeDidChange observer. + // Check for data format changes that need cell re-rendering. let dataChanged = prev.dateFormat != settings.dateFormat || prev.nullDisplay != settings.nullDisplay - if fontChanged { - Self.updateVisibleCellFonts(tableView: tableView) - } - if dataChanged { let visibleRect = tableView.visibleRect let visibleRange = tableView.rows(in: visibleRect) @@ -767,10 +770,24 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } + func observeThemeChanges() { + themeObserver = NotificationCenter.default.addObserver( + forName: .themeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self, let tableView = self.tableView else { return } + Self.updateVisibleCellFonts(tableView: tableView) + } + } + deinit { if let observer = settingsObserver { NotificationCenter.default.removeObserver(observer) } + if let observer = themeObserver { + NotificationCenter.default.removeObserver(observer) + } } func updateCache() { @@ -797,13 +814,13 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData switch textField.tag { case DataGridFontVariant.rowNumber: - textField.font = DataGridFontCache.rowNumber + textField.font = ThemeEngine.shared.dataGridFonts.rowNumber case DataGridFontVariant.italic: - textField.font = DataGridFontCache.italic + textField.font = ThemeEngine.shared.dataGridFonts.italic case DataGridFontVariant.medium: - textField.font = DataGridFontCache.medium + textField.font = ThemeEngine.shared.dataGridFonts.medium default: - textField.font = DataGridFontCache.regular + textField.font = ThemeEngine.shared.dataGridFonts.regular } } } diff --git a/TablePro/Views/Results/DatePickerCellEditor.swift b/TablePro/Views/Results/DatePickerCellEditor.swift index 37c98ff8..66c11d0a 100644 --- a/TablePro/Views/Results/DatePickerCellEditor.swift +++ b/TablePro/Views/Results/DatePickerCellEditor.swift @@ -71,7 +71,7 @@ final class DatePickerCellEditor: NSDatePicker { private func setupUI() { datePickerStyle = .textFieldAndStepper datePickerElements = [.yearMonthDay, .hourMinuteSecond] - font = .monospacedSystemFont(ofSize: DesignConstants.FontSize.body, weight: .regular) + font = .monospacedSystemFont(ofSize: ThemeEngine.shared.activeTheme.typography.body, weight: .regular) isBezeled = false isBordered = false drawsBackground = false diff --git a/TablePro/Views/Results/JSONEditorContentView.swift b/TablePro/Views/Results/JSONEditorContentView.swift index f067adab..f5610bfa 100644 --- a/TablePro/Views/Results/JSONEditorContentView.swift +++ b/TablePro/Views/Results/JSONEditorContentView.swift @@ -110,7 +110,7 @@ private struct JSONSyntaxTextView: NSViewRepresentable { textView.isEditable = true textView.isSelectable = true - textView.font = NSFont.monospacedSystemFont(ofSize: DesignConstants.FontSize.medium, weight: .regular) + textView.font = NSFont.monospacedSystemFont(ofSize: ThemeEngine.shared.activeTheme.typography.medium, weight: .regular) textView.textContainerInset = NSSize(width: 8, height: 8) textView.backgroundColor = NSColor.textBackgroundColor textView.textColor = NSColor.labelColor @@ -147,7 +147,7 @@ private struct JSONSyntaxTextView: NSViewRepresentable { guard length > 0 else { return } let fullRange = NSRange(location: 0, length: length) - let font = textView.font ?? NSFont.monospacedSystemFont(ofSize: DesignConstants.FontSize.medium, weight: .regular) + let font = textView.font ?? NSFont.monospacedSystemFont(ofSize: ThemeEngine.shared.activeTheme.typography.medium, weight: .regular) let content = textStorage.string let maxHighlightLength = 10_000 let highlightRange: NSRange diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 4fce7593..86895a74 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -50,13 +50,13 @@ struct EditableFieldView: View { } Text(columnName) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .lineLimit(1) Spacer() Text(columnTypeEnum.badgeLabel) - .font(.system(size: DesignConstants.FontSize.tiny, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, weight: .medium)) .foregroundStyle(.tertiary) .padding(.horizontal, 5) .padding(.vertical, 1) @@ -82,7 +82,7 @@ struct EditableFieldView: View { if isPendingNull || isPendingDefault { TextField(isPendingNull ? "NULL" : "DEFAULT", text: .constant("")) .textFieldStyle(.roundedBorder) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .disabled(true) } else if columnTypeEnum.isEnumType, let values = columnTypeEnum.enumValues, !values.isEmpty { @@ -121,7 +121,7 @@ struct EditableFieldView: View { isSetPopoverPresented = true } label: { Text(displayLabel) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } @@ -160,7 +160,7 @@ struct EditableFieldView: View { content() } label: { Text(label) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } @@ -175,7 +175,7 @@ struct EditableFieldView: View { private var multiLineEditor: some View { TextField(placeholderText, text: $value, axis: .vertical) .textFieldStyle(.roundedBorder) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .lineLimit(3...6) .focused($isFocused) } @@ -183,7 +183,7 @@ struct EditableFieldView: View { private var singleLineEditor: some View { TextField(placeholderText, text: $value) .textFieldStyle(.roundedBorder) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .focused($isFocused) } @@ -268,13 +268,13 @@ struct ReadOnlyFieldView: View { // Line 1: field name + type badge HStack(spacing: 4) { Text(columnName) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .lineLimit(1) Spacer() Text(columnTypeEnum.badgeLabel) - .font(.system(size: DesignConstants.FontSize.tiny, weight: .medium)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, weight: .medium)) .foregroundStyle(.tertiary) .padding(.horizontal, 5) .padding(.vertical, 1) @@ -286,19 +286,19 @@ struct ReadOnlyFieldView: View { if let value { if isLongText { Text(value) - .font(.system(size: DesignConstants.FontSize.small, design: .monospaced)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, maxHeight: 80, alignment: .topLeading) } else { TextField("", text: .constant(value)) .textFieldStyle(.roundedBorder) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .disabled(true) } } else { TextField("NULL", text: .constant("")) .textFieldStyle(.roundedBorder) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .disabled(true) } } diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index dd424725..f1754036 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -148,17 +148,17 @@ struct RightSidebarView: View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") .foregroundStyle(.tertiary) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) TextField("Search for field...", text: $searchText) .textFieldStyle(.plain) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) } .buttonStyle(.plain) } @@ -172,7 +172,7 @@ struct RightSidebarView: View { Section { if filtered.isEmpty && !searchText.isEmpty { Text("No matching fields") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity) } else { diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift new file mode 100644 index 00000000..5d60442a --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorFontsSection.swift @@ -0,0 +1,135 @@ +import AppKit +import SwiftUI + +struct ThemeEditorFontsSection: View { + var onThemeDuplicated: ((ThemeDefinition) -> Void)? + + private var engine: ThemeEngine { ThemeEngine.shared } + + @State private var editingTheme: ThemeDefinition? + + private var theme: ThemeDefinition { engine.activeTheme } + + private var currentThemeFonts: ThemeFonts { + editingTheme?.fonts ?? theme.fonts + } + + var body: some View { + Form { + editorFontSection + dataGridFontSection + previewSection + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .onChange(of: engine.activeTheme.id) { + editingTheme = nil + } + } + + // MARK: - Editor Font + + private var editorFontSection: some View { + Section(String(localized: "Editor Font")) { + fontPicker( + label: String(localized: "Family"), + selection: currentThemeFonts.editorFontFamily, + onChange: { newFamily in + updateFont { $0.editorFontFamily = newFamily } + } + ) + sizePicker( + label: String(localized: "Size"), + value: currentThemeFonts.editorFontSize, + range: 11...18, + onChange: { newSize in + updateFont { $0.editorFontSize = newSize } + } + ) + } + } + + // MARK: - Data Grid Font + + private var dataGridFontSection: some View { + Section(String(localized: "Data Grid Font")) { + fontPicker( + label: String(localized: "Family"), + selection: currentThemeFonts.dataGridFontFamily, + onChange: { newFamily in + updateFont { $0.dataGridFontFamily = newFamily } + } + ) + sizePicker( + label: String(localized: "Size"), + value: currentThemeFonts.dataGridFontSize, + range: 10...18, + onChange: { newSize in + updateFont { $0.dataGridFontSize = newSize } + } + ) + } + } + + // MARK: - Preview + + private var previewSection: some View { + Section(String(localized: "Preview")) { + let fonts = currentThemeFonts + let editorFont = EditorFont(rawValue: fonts.editorFontFamily)? + .font(size: CGFloat(fonts.editorFontSize)) + ?? NSFont.monospacedSystemFont(ofSize: CGFloat(fonts.editorFontSize), weight: .regular) + + Text("SELECT * FROM users WHERE id = 42;") + .font(Font(editorFont)) + .foregroundStyle(theme.editor.text.swiftUIColor) + .padding(theme.spacing.xs) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.editor.background.swiftUIColor) + .clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius.small)) + } + } + + // MARK: - Helpers + + private func fontPicker(label: String, selection: String, onChange: @escaping (String) -> Void) -> some View { + Picker(label, selection: Binding( + get: { selection }, + set: { onChange($0) } + )) { + ForEach(EditorFont.allCases.filter(\.isAvailable)) { font in + Text(font.displayName).tag(font.rawValue) + } + } + } + + private func sizePicker(label: String, value: Int, range: ClosedRange, + onChange: @escaping (Int) -> Void) -> some View { + Picker(label, selection: Binding( + get: { value }, + set: { onChange($0) } + )) { + ForEach(range, id: \.self) { size in + Text("\(size) pt").tag(size) + } + } + } + + private func updateFont(_ mutate: (inout ThemeFonts) -> Void) { + let base = editingTheme ?? theme + + if base.isBuiltIn { + var copy = engine.duplicateTheme(base, newName: base.name + " (Custom)") + mutate(©.fonts) + try? engine.saveUserTheme(copy) + engine.activateTheme(copy) + editingTheme = copy + onThemeDuplicated?(copy) + } else { + var updated = base + mutate(&updated.fonts) + try? engine.saveUserTheme(updated) + editingTheme = updated + } + } +} diff --git a/TablePro/Views/Settings/DataGridSettingsView.swift b/TablePro/Views/Settings/DataGridSettingsView.swift index d9caa213..385c8136 100644 --- a/TablePro/Views/Settings/DataGridSettingsView.swift +++ b/TablePro/Views/Settings/DataGridSettingsView.swift @@ -2,7 +2,7 @@ // DataGridSettingsView.swift // TablePro // -// Settings for data grid display and pagination +// Settings for data grid display and pagination (fonts moved to theme) // import SwiftUI @@ -12,27 +12,6 @@ struct DataGridSettingsView: View { var body: some View { Form { - Section("Font") { - Picker("Font:", selection: $settings.fontFamily) { - ForEach(EditorFont.allCases.filter { $0.isAvailable }) { font in - Text(font.displayName).tag(font) - } - } - - Picker("Size:", selection: $settings.fontSize) { - ForEach(10...18, id: \.self) { size in - Text("\(size) pt").tag(size) - } - } - - GroupBox("Preview") { - Text("1 John Doe john@example.com NULL") - .font(Font(settings.fontFamily.font(size: CGFloat(settings.clampedFontSize)))) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - } - } - Section("Display") { Picker("Row height:", selection: $settings.rowHeight) { ForEach(DataGridRowHeight.allCases) { height in @@ -46,7 +25,6 @@ struct DataGridSettingsView: View { } } - // NULL Display with validation VStack(alignment: .leading, spacing: 4) { TextField("NULL display:", text: $settings.nullDisplay) diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index e754b05e..fd11fb0b 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -2,7 +2,7 @@ // EditorSettingsView.swift // TablePro // -// Settings for SQL editor font and behavior +// Settings for SQL editor behavior (fonts moved to theme) // import SwiftUI @@ -12,28 +12,6 @@ struct EditorSettingsView: View { var body: some View { Form { - Section("Font") { - Picker("Font:", selection: $settings.fontFamily) { - ForEach(EditorFont.allCases.filter { $0.isAvailable }) { font in - Text(font.displayName).tag(font) - } - } - - Picker("Size:", selection: $settings.fontSize) { - ForEach(11...18, id: \.self) { size in - Text("\(size) pt").tag(size) - } - } - - // Preview - GroupBox("Preview") { - Text("SELECT * FROM users WHERE id = 1;") - .font(.custom(settings.fontFamily.displayName, size: CGFloat(settings.clampedFontSize))) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - } - } - Section("Display") { Toggle("Show line numbers", isOn: $settings.showLineNumbers) Toggle("Highlight current line", isOn: $settings.highlightCurrentLine) @@ -57,5 +35,5 @@ struct EditorSettingsView: View { #Preview { EditorSettingsView(settings: .constant(.default)) - .frame(width: 450, height: 350) + .frame(width: 450, height: 250) } diff --git a/TablePro/Views/Settings/ShortcutRecorderView.swift b/TablePro/Views/Settings/ShortcutRecorderView.swift index 1073794f..bd646a89 100644 --- a/TablePro/Views/Settings/ShortcutRecorderView.swift +++ b/TablePro/Views/Settings/ShortcutRecorderView.swift @@ -144,7 +144,7 @@ final class ShortcutRecorderNSView: NSView { // Text let text = displayText let textColor: NSColor = isRecording ? .secondaryLabelColor : .labelColor - let font = NSFont.systemFont(ofSize: DesignConstants.FontSize.medium, weight: .medium) + let font = NSFont.systemFont(ofSize: ThemeEngine.shared.activeTheme.typography.medium, weight: .medium) let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: textColor diff --git a/TablePro/Views/Settings/ThemePreviewCard.swift b/TablePro/Views/Settings/ThemePreviewCard.swift new file mode 100644 index 00000000..30cad10e --- /dev/null +++ b/TablePro/Views/Settings/ThemePreviewCard.swift @@ -0,0 +1,186 @@ +// +// ThemePreviewCard.swift +// TablePro +// +// Visual card showing a miniature preview of a theme's color palette. +// + +import SwiftUI + +struct ThemePreviewCard: View { + enum CardSize { + case standard + case compact + } + + let theme: ThemeDefinition + let isActive: Bool + let onSelect: () -> Void + var size: CardSize = .standard + + var body: some View { + switch size { + case .standard: + standardCard + case .compact: + compactCard + } + } + + // MARK: - Standard Card + + private var standardCard: some View { + VStack(spacing: ThemeEngine.shared.activeTheme.spacing.xxs) { + thumbnail + .frame(width: 160, height: 100) + .clipShape(RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.medium)) + .overlay( + RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.medium) + .strokeBorder(isActive ? Color.accentColor : Color.clear, lineWidth: 2.5) + ) + .shadow(color: .black.opacity(0.1), radius: 2, y: 1) + + VStack(spacing: 1) { + Text(theme.name) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .lineLimit(1) + .foregroundStyle(.primary) + + Text(theme.isBuiltIn + ? String(localized: "Built-in") + : String(localized: "Custom")) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) + .foregroundStyle(.secondary) + } + } + .frame(width: 160) + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + } + + // MARK: - Compact Card + + private var compactCard: some View { + thumbnail + .frame(width: 72, height: 45) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(isActive ? Color.accentColor : Color.clear, lineWidth: 0.5) + ) + } + + // MARK: - Thumbnail + + private var sidebarStripWidth: CGFloat { + size == .compact ? 22 : 28 + } + + private var codeLineHeight: CGFloat { + size == .compact ? 2.5 : 3 + } + + private var dataGridRowCount: Int { + size == .compact ? 2 : 3 + } + + private var dataGridHeight: CGFloat { + size == .compact ? 14 : 28 + } + + private var thumbnail: some View { + HStack(spacing: 0) { + sidebarStrip + .frame(width: sidebarStripWidth) + + VStack(spacing: 0) { + editorArea + dataGridArea + } + } + } + + private var sidebarStrip: some View { + ZStack(alignment: .topLeading) { + Rectangle() + .fill(theme.sidebar.background.swiftUIColor) + + VStack(alignment: .leading, spacing: size == .compact ? 3 : 4) { + let widths: [CGFloat] = size == .compact + ? [10, 14, 13, 9] + : [14, 18, 17, 12] + ForEach(0..<4, id: \.self) { i in + RoundedRectangle(cornerRadius: 1) + .fill(i == 1 + ? theme.sidebar.selectedItem.swiftUIColor.opacity(0.6) + : theme.sidebar.text.swiftUIColor.opacity(0.25)) + .frame( + width: widths[i], + height: codeLineHeight + ) + } + } + .padding(.top, size == .compact ? 5 : 8) + .padding(.leading, size == .compact ? 3 : 4) + } + } + + private var editorArea: some View { + ZStack(alignment: .topLeading) { + Rectangle() + .fill(theme.editor.background.swiftUIColor) + + VStack(alignment: .leading, spacing: size == .compact ? 3 : 4) { + if size == .compact { + codeLine(widths: [10, 16, 7], + colors: [theme.editor.syntax.keyword, theme.editor.syntax.function, theme.editor.syntax.type]) + codeLine(widths: [7, 22], + colors: [theme.editor.syntax.keyword, theme.editor.syntax.string]) + codeLine(widths: [13, 6, 9], + colors: [theme.editor.syntax.type, theme.editor.syntax.operator, theme.editor.syntax.number]) + } else { + codeLine(widths: [14, 22, 10], + colors: [theme.editor.syntax.keyword, theme.editor.syntax.function, theme.editor.syntax.type]) + codeLine(widths: [10, 30], + colors: [theme.editor.syntax.keyword, theme.editor.syntax.string]) + codeLine(widths: [18, 8, 12], + colors: [theme.editor.syntax.type, theme.editor.syntax.operator, theme.editor.syntax.number]) + codeLine(widths: [26], + colors: [theme.editor.syntax.comment]) + } + } + .padding(.top, size == .compact ? 4 : 6) + .padding(.leading, size == .compact ? 4 : 6) + } + } + + private func codeLine(widths: [CGFloat], colors: [String]) -> some View { + HStack(spacing: size == .compact ? 2 : 3) { + ForEach(Array(zip(widths, colors).enumerated()), id: \.offset) { _, pair in + RoundedRectangle(cornerRadius: 1) + .fill(pair.1.swiftUIColor) + .frame(width: pair.0, height: codeLineHeight) + } + } + } + + private var dataGridArea: some View { + VStack(spacing: 0) { + ForEach(0..