diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe136b7c..4546a3fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Header, grid line, selection and focus colors in the theme editor. - Themes that name a system color for a slot, so the built-in themes keep the system's own contrast settings. - Reason shown in Settings > Appearance for a theme file that could not be loaded. +- Panel and status colors in the theme editor, covering the results, inspector, structure, compare and query plan panes. - **Refresh Materialized View…** on PostgreSQL, with a concurrent refresh where the view qualifies. (#2726) - **Show DDL** and **Copy DDL** for views and materialized views. (#2726) - **Edit Comment…** for PostgreSQL tables, views, materialized views and foreign tables. (#2726) @@ -46,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Beancount connections held at Safe Mode Read-Only. (#2030) - MySQL sessions on the server's default `utf8mb4` collation. - Theme file format 2. Themes written for earlier versions are not read and need to be recreated. +- Settings > Editor grouped into Display, Gutter and Editing. - Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data & Results, and kept per Mac. - Editor font size range of 10 to 24 points everywhere, including zoom. - Structure editor options the connected PostgreSQL server does not support left out: generated columns before 12, BRIN before 9.5, and the MySQL-only FULLTEXT and SPATIAL index types. @@ -75,6 +77,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Query history preview ignoring the theme and the editor font. - Theme editor changing the active theme instead of the theme selected for the slot being edited. - Malformed theme files loading as Default Light under their own name. +- Content panes outside the editor and the data grid ignoring the theme. +- JSON and PHP tree values colored differently from the same values in the row inspector. +- Autocomplete icon colors ignoring the theme. +- Theme color wells and the theme preview unreadable to VoiceOver. - Color with a typo in it rendering as a different color instead of being reported. - Text past the first 64 KB of a UTF-16 SQL import arriving byte-swapped. - SQL import failing on a file whose encoding is not UTF-8 when a character lands on a 64 KB boundary. diff --git a/CLAUDE.md b/CLAUDE.md index a86f64d6b..ec0fe83e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,6 +199,8 @@ To ship one: add the record type or field in CloudKit Console (or `xcrun cktool **An installed driver is a handle, not a live connection**: `ConnectionSession.driver` is held from the moment it is built until something replaces it, and a socket the server closed is indistinguishable from a working one until a ping asks. `reconnectDriver` and `reconnectSession` both disconnect it and leave it installed, so `hasDriver` stayed true for the whole of an outage and for good once the monitor gave up: the window went on showing rows, tabs and an enabled toolbar over a dead handle, `ensureConnected` returned early so Reconnect did nothing, and the connections strip painted the failure from `status` while the pane beside it disagreed. `ConnectionSession.liveness` is the single answer to "can this driver be believed", and `ConnectionWindowPhaseMachine.onSessionChanged` reads it before `hasDriver`. `status` cannot serve: it is `.connecting` throughout an ordinary database switch on the four engines that reconnect to perform one, and `.disconnected` is the struct's own default value. Four rules follow. The driver is never nil'd to signal this, because it is the handle every metadata read, every query route and the reconnect itself still goes through, and taking it away makes a database switch look like a dropped connection and trips the `exists && !hasDriver` arm into a permanent fake "Connecting". `recovering` is not a failure, so a blip that repairs itself inside `DatabaseManager.unreachableAfterAttempt` leaves the window untouched; the threshold is the fifth attempt because the 2, 4, 8, 16 backoff announces it a full 30 seconds after the ping that failed, which is one whole ping interval of not answering. Every give-up site and the threshold go through `markSessionUnreachable(_:startedWith:info:)`, whose driver-identity check is the generation guard a reconnect blocked in a C call needs, and every path that installs a working driver calls `markSessionLive` so the mark and its reason go together. And everything that reports connection health reads `ConnectionSession.reportedStatus`, never `status` directly, or the strip and the pane drift apart again. +**A theme reaches content, and `NSAppearance` is the only lever it has on chrome**: the sidebar, the trailing pane, the editor tab strip and the window background cannot be painted from a theme without fighting AppKit, so the app does not try. Measured on macOS 27 against a plain `NSSplitViewController` in a `.fullSizeContentView` window with a toolbar. The sidebar's material is a framework-owned `NSGlassEffectView` two levels above the pane's own root (`NSView > ContentHolderView > NSGlassEffectView > _NSSplitViewItemViewWrapper`), so painting the pane means covering a view AppKit owns, and `NSSplitViewItem.h` documents that translucent material as standard sidebar behaviour with no knob to turn it off. The pane is also full-height (`allowsFullHeightLayout` is `true` by default and its root reports `safeAreaInsets.top` of 66pt), so a fill pinned to that root paints the whole titlebar and toolbar band, which Apple's Liquid Glass guidance tells apps not to do and which is what broke native window tabs in Sublime Text. Sidebar selection is the same story from the other side: under `.sourceList` AppKit inserts its own `NSVisualEffectView` as the row's first subview and calls `drawSelection(in:)` zero times, so the documented override never runs; setting `selectionHighlightStyle = .none` does suppress it but collapses `interiorBackgroundStyle` from emphasized to normal, and that is the channel `NSTableCellView.backgroundStyle` publishes as SwiftUI's `backgroundProminence`, which every sidebar row label answers through `.primary` and `.secondary`. An absolute colour ignores prominence, so a themed label paints the wrong colour on a selected row under the default theme too. The editor tab strip is titlebar-resident (`NSTitlebarAccessoryViewController`), so it falls under the same rule as the toolbar. What remains is the one lever macOS does give: `ThemeEngine` sets `NSApp.appearance` from the appearance mode, a theme declares `light` or `dark`, and `ThemeSlotValidation` keeps a theme in the slot whose appearance it matches, so choosing a dark theme gives dark chrome. Every theme slot is namespaced `content.` for this reason, and `ThemeSlotCoverageTests` fails the build for a slot no call site reads, which is what let the old `ui`, `sidebar` and `toolbar` groups ship with zero readers for two years. + **The app runs the AppKit lifecycle, and AppKit owns the menu bar**: `main.swift` assigns the delegate before `NSApplicationMain`, and `MainMenuBuilder.install` runs in `applicationWillFinishLaunching`. Do not reintroduce a SwiftUI `App`. SwiftUI reconciles `NSApp.mainMenu` once shortly after launch and removes every item it did not build itself, and no hook can undo it: `NSApp.mainMenu` is not KVO-compliant, `didUpdateNotification`, `didBecomeKeyNotification` and the `applicationDidUpdate(_:)` delegate method never fire under `@NSApplicationDelegateAdaptor`, and `applicationDidBecomeActive` fires before the reconciliation. Only a wall-clock delay worked, which is why #2057 shipped a menu bar that vanished half a second after launch and had to be reverted (#2071). Every window is an `NSWindowController`; the Welcome window is one too, so closing it is an ordinary `close()` and the old "closed, never ordered out" rule no longer applies. **An emptied tab manager is not the same as "the user closed every tab"**: a coordinator torn down by a lost session has already emptied `tabManager.tabs`, so any persistence path that reads "no tabs" as "clear the saved tabs" wipes tabs the user never closed. The fix is that the teardown path cannot clear at all: `TabPersistenceCoordinator.saveAggregatedSync()`, which disconnect and window-close call, opens with `guard !tabs.isEmpty else { return }`. Clearing requires explicit consent and happens on the `closeTabsByUser` path instead. Keep those two paths separate; the moment a teardown path can write an empty aggregate, the bug is back. diff --git a/TablePro/Core/Autocomplete/SQLCompletionItem.swift b/TablePro/Core/Autocomplete/SQLCompletionItem.swift index 39e7030e3..378d17094 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionItem.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionItem.swift @@ -36,16 +36,17 @@ enum SQLCompletionKind: String, CaseIterable { } /// Color for the icon + @MainActor var iconColor: NSColor { switch self { - case .keyword: return .systemBlue - case .table: return .systemTeal - case .view: return .systemPurple - case .column: return .systemOrange - case .function: return .systemPink - case .schema: return .systemGreen - case .alias: return .systemGray - case .operator: return .systemIndigo + case .keyword: return ThemeEngine.shared.palette[.syntaxKeyword] + case .table: return ThemeEngine.shared.palette[.syntaxType] + case .view: return ThemeEngine.shared.palette[.syntaxType] + case .column: return ThemeEngine.shared.palette[.syntaxNull] + case .function: return ThemeEngine.shared.palette[.syntaxFunction] + case .schema: return ThemeEngine.shared.palette[.syntaxType] + case .alias: return ThemeEngine.shared.palette[.syntaxNull] + case .operator: return ThemeEngine.shared.palette[.syntaxOperator] case .favorite: return .systemYellow } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index f1b3c867f..f6802b77e 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -714,8 +714,8 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// intermediate value instead of letting one run-loop turn settle on the final one. private func refreshPanes(of workspace: ConnectionWorkspace) { workspace.panes.sidebar.rootView = AnyView(buildSidebarView(for: workspace)) - workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace)) - workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace)) + workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace).themedContent()) + workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace).themedContent()) workspace.panes.assistant.rootView = AnyView(buildAssistantView(for: workspace)) refreshTabStripPane(of: workspace) workspace.panes.markRendered(workspace.paneRenderKey) @@ -903,7 +903,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// publishes those actions. func rebuildTrailingPanes() { guard let selected = workspaces.selected else { return } - selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected)) + selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected).themedContent()) selected.panes.assistant.rootView = AnyView(buildAssistantView(for: selected)) } diff --git a/TablePro/Models/UI/JSONTreeNode.swift b/TablePro/Models/UI/JSONTreeNode.swift index 8ed335fdc..1b6ccd3b6 100644 --- a/TablePro/Models/UI/JSONTreeNode.swift +++ b/TablePro/Models/UI/JSONTreeNode.swift @@ -27,13 +27,14 @@ internal enum JSONValueType { } } + @MainActor var color: NSColor { switch self { - case .object, .array: return .systemBlue - case .string: return .systemRed - case .number: return .systemPurple - case .boolean, .null: return .systemOrange - case .truncated: return .secondaryLabelColor + case .object, .array: return ThemeEngine.shared.palette[.syntaxKeyword] + case .string: return ThemeEngine.shared.palette[.syntaxString] + case .number: return ThemeEngine.shared.palette[.syntaxNumber] + case .boolean, .null: return ThemeEngine.shared.palette[.syntaxNull] + case .truncated: return ThemeEngine.shared.palette[.panelSecondaryText] } } } diff --git a/TablePro/Models/UI/PhpTreeNode.swift b/TablePro/Models/UI/PhpTreeNode.swift index 3683a5e41..11eec35a0 100644 --- a/TablePro/Models/UI/PhpTreeNode.swift +++ b/TablePro/Models/UI/PhpTreeNode.swift @@ -35,13 +35,14 @@ internal enum PhpNodeType { } } + @MainActor var color: NSColor { switch self { - case .array, .object: return .systemBlue - case .string: return .systemRed - case .int, .float: return .systemPurple - case .bool, .null: return .systemOrange - case .serializable: return .systemTeal + case .array, .object: return ThemeEngine.shared.palette[.syntaxKeyword] + case .string: return ThemeEngine.shared.palette[.syntaxString] + case .int, .float: return ThemeEngine.shared.palette[.syntaxNumber] + case .bool, .null: return ThemeEngine.shared.palette[.syntaxNull] + case .serializable: return ThemeEngine.shared.palette[.syntaxType] case .reference: return .systemGray case .unsupported, .truncated: return .secondaryLabelColor } diff --git a/TablePro/Resources/Themes/tablepro.dracula.json b/TablePro/Resources/Themes/tablepro.dracula.json index 70ffe0a60..1148a8f2f 100644 --- a/TablePro/Resources/Themes/tablepro.dracula.json +++ b/TablePro/Resources/Themes/tablepro.dracula.json @@ -42,6 +42,14 @@ }, "text": "#F8F8F2" }, + "panel": { + "background": "#282A36", + "controlBackground": "#343746", + "secondaryText": "#BFC0C9", + "separator": "#44475A", + "tertiaryText": "#6272A4", + "text": "#F8F8F2" + }, "status": { "error": "#FF5555", "success": "#50FA7B", diff --git a/TablePro/Resources/Themes/tablepro.nord.json b/TablePro/Resources/Themes/tablepro.nord.json index ef58c5fe3..744812843 100644 --- a/TablePro/Resources/Themes/tablepro.nord.json +++ b/TablePro/Resources/Themes/tablepro.nord.json @@ -42,6 +42,14 @@ }, "text": "#D8DEE9" }, + "panel": { + "background": "#2E3440", + "controlBackground": "#3B4252", + "secondaryText": "#9DA5B4", + "separator": "#434C5E", + "tertiaryText": "#616E88", + "text": "#D8DEE9" + }, "status": { "error": "#BF616A", "success": "#A3BE8C", diff --git a/TablePro/Theme/BuiltInThemes.swift b/TablePro/Theme/BuiltInThemes.swift index 61481ad8d..560c37bd8 100644 --- a/TablePro/Theme/BuiltInThemes.swift +++ b/TablePro/Theme/BuiltInThemes.swift @@ -39,6 +39,7 @@ internal enum BuiltInThemes { deleted: "#FF3B304D", deletedText: "#FF3B3080" ), + panel: .systemPanel, status: StatusThemeColors( success: .hex("#248A3D"), warning: .hex("#C55B00"), @@ -77,6 +78,7 @@ internal enum BuiltInThemes { deleted: "#FF453A26", deletedText: "#FF453A80" ), + panel: .systemPanel, status: StatusThemeColors( success: .hex("#32D74B"), warning: .hex("#FF9F0A"), @@ -125,3 +127,16 @@ internal enum BuiltInThemes { ) } } + +internal extension PanelThemeColors { + /// The semantic colours these surfaces already named before the theme owned them, so a default + /// theme is invisible against the unthemed app and keeps the system's contrast handling. + static let systemPanel = PanelThemeColors( + background: .system(.controlBackground), + controlBackground: .system(.textBackground), + text: .system(.label), + secondaryText: .system(.secondaryLabel), + tertiaryText: .system(.tertiaryLabel), + separator: .system(.separator) + ) +} diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift index 4fccb5f3d..e0904f6f6 100644 --- a/TablePro/Theme/ThemeDefinition.swift +++ b/TablePro/Theme/ThemeDefinition.swift @@ -55,6 +55,15 @@ internal struct StatusThemeColors: Equatable, Sendable { var error: ThemeColorValue } +internal struct PanelThemeColors: Equatable, Sendable { + var background: ThemeColorValue + var controlBackground: ThemeColorValue + var text: ThemeColorValue + var secondaryText: ThemeColorValue + var tertiaryText: ThemeColorValue + var separator: ThemeColorValue +} + internal struct ThemeDefinition: Identifiable, Equatable, Sendable { var id: String var name: String @@ -62,6 +71,7 @@ internal struct ThemeDefinition: Identifiable, Equatable, Sendable { var appearance: ThemeAppearance var editor: EditorThemeColors var dataGrid: DataGridThemeColors + var panel: PanelThemeColors var status: StatusThemeColors internal static let builtInPrefix = "tablepro." @@ -115,10 +125,18 @@ internal enum ThemeSlot: String, CaseIterable, Sendable { case gridDeleted = "content.dataGrid.deleted" case gridDeletedText = "content.dataGrid.deletedText" + case panelBackground = "content.panel.background" + case panelControlBackground = "content.panel.controlBackground" + case panelText = "content.panel.text" + case panelSecondaryText = "content.panel.secondaryText" + case panelTertiaryText = "content.panel.tertiaryText" + case panelSeparator = "content.panel.separator" + case statusSuccess = "content.status.success" case statusWarning = "content.status.warning" case statusError = "content.status.error" + internal var since: Int { 2 } internal var group: ThemeSlotGroup { @@ -134,6 +152,9 @@ internal enum ThemeSlot: String, CaseIterable, Sendable { .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return .dataGrid + case .panelBackground, .panelControlBackground, .panelText, + .panelSecondaryText, .panelTertiaryText, .panelSeparator: + return .panel case .statusSuccess, .statusWarning, .statusError: return .status } @@ -178,6 +199,13 @@ internal enum ThemeSlot: String, CaseIterable, Sendable { case .gridDeleted: return \.dataGrid.deleted case .gridDeletedText: return \.dataGrid.deletedText + case .panelBackground: return \.panel.background + case .panelControlBackground: return \.panel.controlBackground + case .panelText: return \.panel.text + case .panelSecondaryText: return \.panel.secondaryText + case .panelTertiaryText: return \.panel.tertiaryText + case .panelSeparator: return \.panel.separator + case .statusSuccess: return \.status.success case .statusWarning: return \.status.warning case .statusError: return \.status.error @@ -220,6 +248,12 @@ internal enum ThemeSlot: String, CaseIterable, Sendable { case .statusSuccess: return String(localized: "Success") case .statusWarning: return String(localized: "Warning") case .statusError: return String(localized: "Error") + case .panelBackground: return String(localized: "Pane Background") + case .panelControlBackground: return String(localized: "Field Background") + case .panelText: return String(localized: "Pane Text") + case .panelSecondaryText: return String(localized: "Secondary Text") + case .panelTertiaryText: return String(localized: "Tertiary Text") + case .panelSeparator: return String(localized: "Separator") } } } @@ -228,6 +262,7 @@ internal enum ThemeSlotGroup: String, CaseIterable, Sendable { case editor case syntax case dataGrid + case panel case status internal var label: String { @@ -235,6 +270,7 @@ internal enum ThemeSlotGroup: String, CaseIterable, Sendable { case .editor: return String(localized: "Editor") case .syntax: return String(localized: "Syntax Colors") case .dataGrid: return String(localized: "Data Grid") + case .panel: return String(localized: "Panels") case .status: return String(localized: "Status") } } diff --git a/TablePro/Theme/ThemedContentSurface.swift b/TablePro/Theme/ThemedContentSurface.swift new file mode 100644 index 000000000..16998c847 --- /dev/null +++ b/TablePro/Theme/ThemedContentSurface.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// Re-resolves `.secondary` and `.tertiary` inside a content pane to the theme's own text levels, +/// which is how 184 hierarchical foreground styles across the content surfaces follow a theme +/// without each one being rewritten. +/// +/// It never branches on the theme. `WorkspacePanes` depends on every pane builder erasing one +/// stable view identity, so an `if` here would tear down the grid, the editor's undo stack and the +/// scroll position on a theme change. It reads the engine inside `body`, so the palette it applies +/// is the current one rather than whatever was captured when the pane was built. +private struct ThemedContentSurface: ViewModifier { + func body(content: Content) -> some View { + let palette = ThemeEngine.shared.palette + + return content.foregroundStyle( + palette.color(.panelText), + palette.color(.panelSecondaryText), + palette.color(.panelTertiaryText) + ) + } +} + +internal extension View { + func themedContent() -> some View { + modifier(ThemedContentSurface()) + } +} diff --git a/TablePro/Views/Editor/FileModifiedOnDiskBanner.swift b/TablePro/Views/Editor/FileModifiedOnDiskBanner.swift index e8cb0ebde..c11b03354 100644 --- a/TablePro/Views/Editor/FileModifiedOnDiskBanner.swift +++ b/TablePro/Views/Editor/FileModifiedOnDiskBanner.swift @@ -13,7 +13,7 @@ internal struct FileModifiedOnDiskBanner: View { var body: some View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .accessibilityHidden(true) Text(String(format: String(localized: "\"%@\" was modified on disk."), fileName)) @@ -41,6 +41,6 @@ internal struct FileModifiedOnDiskBanner: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(.yellow.opacity(0.12)) + .background(ThemeEngine.shared.palette.color(.statusWarning).opacity(0.12)) } } diff --git a/TablePro/Views/Editor/History/HistoryDetailPane.swift b/TablePro/Views/Editor/History/HistoryDetailPane.swift index 9cc5aa74c..30cc5ba1a 100644 --- a/TablePro/Views/Editor/History/HistoryDetailPane.swift +++ b/TablePro/Views/Editor/History/HistoryDetailPane.swift @@ -66,7 +66,7 @@ struct HistoryDetailPane: View { if let errorMessage = entry.errorMessage { RevealedTextView(errorMessage) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } diff --git a/TablePro/Views/Editor/History/HistoryRowView.swift b/TablePro/Views/Editor/History/HistoryRowView.swift index 37151159a..7bc2ac460 100644 --- a/TablePro/Views/Editor/History/HistoryRowView.swift +++ b/TablePro/Views/Editor/History/HistoryRowView.swift @@ -71,7 +71,7 @@ struct HistoryRowView: View { .foregroundStyle(Color.secondary) } else { Image(systemName: "exclamationmark.circle.fill") - .selectionAwareTint(.red) + .selectionAwareTint(ThemeEngine.shared.palette.color(.statusError)) } } diff --git a/TablePro/Views/Editor/QueryCompletionAdapter.swift b/TablePro/Views/Editor/QueryCompletionAdapter.swift index 915c16e2c..a87138ec7 100644 --- a/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -222,7 +222,10 @@ final class SQLSuggestionEntry: CodeSuggestionEntry { Image(systemName: item.kind.iconName) } + /// The suggestion protocol is nonisolated and the panel only ever reads this on the main + /// thread. The kind is lifted out first so the closure sends a value rather than `self`. var imageColor: Color { - Color(nsColor: item.kind.iconColor) + let kind = item.kind + return MainActor.assumeIsolated { Color(nsColor: kind.iconColor) } } } diff --git a/TablePro/Views/Editor/QueryDiagnosticsController.swift b/TablePro/Views/Editor/QueryDiagnosticsController.swift index 5f4237932..ce15ccb4e 100644 --- a/TablePro/Views/Editor/QueryDiagnosticsController.swift +++ b/TablePro/Views/Editor/QueryDiagnosticsController.swift @@ -75,6 +75,14 @@ final class QueryDiagnosticsController { apply(produced, in: controller) } + /// An emphasis bakes its colour into a `CAShapeLayer`, so a theme change inside one appearance + /// leaves an existing underline on the previous colour. `refresh(for:)` cannot repaint it: the + /// diagnostics themselves have not changed, so it returns early. + func reapplyColors(in controller: TextViewController) { + guard !diagnostics.isEmpty else { return } + apply(diagnostics, in: controller) + } + func clear(in controller: TextViewController?) { pendingTask?.cancel() pendingTask = nil @@ -104,8 +112,8 @@ final class QueryDiagnosticsController { private func color(for severity: QueryDiagnostic.Severity) -> NSColor { switch severity { - case .error: return .systemRed - case .warning: return .systemOrange + case .error: return ThemeEngine.shared.palette[.statusError] + case .warning: return ThemeEngine.shared.palette[.statusWarning] } } } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index aa6bfd91b..6ca5dcc3b 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -97,7 +97,6 @@ struct QueryEditorView: View { .frame(minHeight: 100) .clipped() } - .background(Color(nsColor: .textBackgroundColor)) } // MARK: - Toolbar diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index ebbab9e1e..4a92746fe 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -27,6 +27,13 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { private static let languageServiceLengthLimit = EditorHighlighting.maxHighlightableCharacters @ObservationIgnored weak var controller: TextViewController? + + /// The editor configuration carries the new colours, but an emphasis already on screen baked + /// its own into a `CAShapeLayer` that nothing else repaints. + func reapplyThemeColors() { + guard let controller else { return } + diagnosticsController.reapplyColors(in: controller) + } @ObservationIgnored private lazy var diagnosticsController = QueryDiagnosticsController( databaseType: databaseType ) diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index ec3db9799..022862a38 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -147,6 +147,7 @@ struct SQLEditorView: View { } .onReceive(AppEvents.shared.themeChanged) { _ in editorConfiguration = Self.makeConfiguration() + coordinator.reapplyThemeColors() } .onAppear { initializeEditor() diff --git a/TablePro/Views/Import/SQLCodePreview.swift b/TablePro/Views/Import/SQLCodePreview.swift index 2789b3332..669a10ca0 100644 --- a/TablePro/Views/Import/SQLCodePreview.swift +++ b/TablePro/Views/Import/SQLCodePreview.swift @@ -19,7 +19,7 @@ struct SQLCodePreview: View { var body: some View { if text.isEmpty { - Color(nsColor: .textBackgroundColor) + ThemeEngine.shared.palette.color(.editorBackground) } else { SourceEditor( $text, diff --git a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift index 8470242aa..8252d6689 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift @@ -53,7 +53,7 @@ struct QueryInsightsActivityChart: View { } .chartForegroundStyleScale([ Outcome.succeeded: Color.accentColor, - Outcome.failed: Color.orange, + Outcome.failed: ThemeEngine.shared.palette.color(.statusWarning), ]) .chartLegend(position: .top, alignment: .trailing, spacing: 8) .chartYAxis { diff --git a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift index 8cdd2d1e6..4f3e39227 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift @@ -48,7 +48,7 @@ struct QueryInsightsGroupList: View { if let error = group.latestErrorMessage, case .failures = metric { RevealedTextView(error) .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .lineLimit(2) } } @@ -86,7 +86,7 @@ struct QueryInsightsGroupList: View { private func headlineTint(_ group: QueryInsightsGroup) -> Color { switch metric { - case .failures: return .orange + case .failures: return ThemeEngine.shared.palette.color(.statusWarning) case .duration: return .primary case .callCount: return .primary } @@ -160,7 +160,7 @@ struct QueryInsightsRegressionList: View { .font(.system(.callout, design: .monospaced)) .fontWeight(.medium) .monospacedDigit() - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .frame(minWidth: 76, alignment: .trailing) VStack(alignment: .leading, spacing: 3) { diff --git a/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift b/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift index 24f099283..c7d09b1c0 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift @@ -30,7 +30,7 @@ struct QueryInsightsSummaryBar: View { totals.totalCount.formatted() ), systemImage: "exclamationmark.triangle", - tint: totals.failedCount > 0 ? .orange : .secondary + tint: totals.failedCount > 0 ? ThemeEngine.shared.palette.color(.statusWarning) : .secondary ) metric( label: String(localized: "Average"), diff --git a/TablePro/Views/Results/ArrayValueEditorView.swift b/TablePro/Views/Results/ArrayValueEditorView.swift index c720bcff4..03d3df7a1 100644 --- a/TablePro/Views/Results/ArrayValueEditorView.swift +++ b/TablePro/Views/Results/ArrayValueEditorView.swift @@ -162,7 +162,7 @@ struct ArrayValueEditorView: View { .pickerStyle(.menu) if ArrayValueEditorModel.isDriftedValue(row.element, allowedValues: allowedValues) { Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .help(Text("This value is not one of the type's current labels")) } } diff --git a/TablePro/Views/Results/ColumnValueFilterPopover.swift b/TablePro/Views/Results/ColumnValueFilterPopover.swift index 007afaec7..f6159949d 100644 --- a/TablePro/Views/Results/ColumnValueFilterPopover.swift +++ b/TablePro/Views/Results/ColumnValueFilterPopover.swift @@ -102,7 +102,7 @@ struct ColumnValueFilterPopover: View { Text(label(for: value)) .lineLimit(1) .truncationMode(.tail) - .foregroundStyle(value.isNull ? Color.secondary : Color.primary) + .foregroundStyle(value.isNull ? ThemeEngine.shared.palette.color(.gridNullValue) : Color.primary) Spacer(minLength: 8) Text("\(value.count)") .font(.callout.monospacedDigit()) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 7b592681e..dea8c9598 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -507,7 +507,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var settingsCancellable: AnyCancellable? var themeCancellable: AnyCancellable? var systemTimeZoneCancellable: AnyCancellable? - private var accessibilityActivationObserver: (any NSObjectProtocol)? + internal var accessibilityActivationObserver: (any NSObjectProtocol)? private var accessibilityMountedRows = NSRange(location: 0, length: 0) private var lastDataGridSettings: DataGridSettings @@ -593,71 +593,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } - func applyDataGridSettingsChange(from previous: DataGridSettings, to settings: DataGridSettings) { - guard let tableView else { return } - let newRowHeight = CGFloat(settings.rowHeight.rawValue) - if tableView.rowHeight != newRowHeight { - tableView.rowHeight = newRowHeight - tableView.tile() - repaintRowGutter() - } - - let dataChanged = previous.dateFormat != settings.dateFormat - || previous.nullDisplay != settings.nullDisplay - || previous.enableSmartValueDetection != settings.enableSmartValueDetection - - if dataChanged { - reformatDisplayedText() - } - } - - func observeThemeChanges() { - themeCancellable = AppEvents.shared.themeChanged - .receive(on: RunLoop.main) - .sink { [weak self] _ in - if let tableView = self?.tableView { - DataGridBodyChrome.applyBackground(to: tableView) - tableView.headerView?.needsDisplay = true - tableView.cornerView?.needsDisplay = true - } - self?.reloadVisibleRowsAndStates() - /// The row-number font is a theme value and it decides the column's width, which - /// the pinned gutter mirrors. Nothing re-measured it on a theme change before, so - /// the width was already going stale here. - self?.resizeRowNumberColumnForCurrentRange() - self?.repaintRowGutter() - } - } - - /// The grid mounts no view for a data cell, so a client that attaches mid-session finds a table - /// of empty cells until the visible rows are built again. The remount is deferred off the - /// accessibility query that raised the flag, because rebuilding rows inside it would re-enter - /// the tree AppKit is walking. - private func observeAccessibilityActivation() { - accessibilityActivationObserver = NotificationCenter.default.addObserver( - forName: DataGridAccessibility.didActivateNotification, - object: nil, - queue: nil - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.remountAccessibilityCells() - } - } - } - - /// Registered for the life of the coordinator, so it has to come off when the grid goes. - /// - /// `NotificationCenter` retains a block observer's closure, and a coordinator is built fresh on - /// every mount, so an entry left behind at teardown is never fired again and never reclaimed. - /// The closure captures `self` weakly, so this leaks the registration rather than the grid. - func detachAccessibilityActivationObserver() { - guard let accessibilityActivationObserver else { return } - NotificationCenter.default.removeObserver(accessibilityActivationObserver) - self.accessibilityActivationObserver = nil - } - - var hasAccessibilityActivationObserver: Bool { accessibilityActivationObserver != nil } - /// Whether this row is one an assistive client can be reading right now. /// /// Walking the accessibility tree makes `NSTableView` prepare every row of the page, not the diff --git a/TablePro/Views/Results/Extensions/TableViewCoordinator+Observation.swift b/TablePro/Views/Results/Extensions/TableViewCoordinator+Observation.swift new file mode 100644 index 000000000..137809916 --- /dev/null +++ b/TablePro/Views/Results/Extensions/TableViewCoordinator+Observation.swift @@ -0,0 +1,75 @@ +// +// TableViewCoordinator+Observation.swift +// TablePro +// + +import AppKit +import Combine + +internal extension TableViewCoordinator { + func applyDataGridSettingsChange(from previous: DataGridSettings, to settings: DataGridSettings) { + guard let tableView else { return } + let newRowHeight = CGFloat(settings.rowHeight.rawValue) + if tableView.rowHeight != newRowHeight { + tableView.rowHeight = newRowHeight + tableView.tile() + repaintRowGutter() + } + + let dataChanged = previous.dateFormat != settings.dateFormat + || previous.nullDisplay != settings.nullDisplay + || previous.enableSmartValueDetection != settings.enableSmartValueDetection + + if dataChanged { + reformatDisplayedText() + } + } + + func observeThemeChanges() { + themeCancellable = AppEvents.shared.themeChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in + if let tableView = self?.tableView { + DataGridBodyChrome.applyBackground(to: tableView) + tableView.headerView?.needsDisplay = true + tableView.cornerView?.needsDisplay = true + } + self?.reloadVisibleRowsAndStates() + /// The row-number font is a theme value and it decides the column's width, which + /// the pinned gutter mirrors. Nothing re-measured it on a theme change before, so + /// the width was already going stale here. + self?.resizeRowNumberColumnForCurrentRange() + self?.repaintRowGutter() + self?.selectionController.overlay?.needsDisplay = true + } + } + + /// The grid mounts no view for a data cell, so a client that attaches mid-session finds a table + /// of empty cells until the visible rows are built again. The remount is deferred off the + /// accessibility query that raised the flag, because rebuilding rows inside it would re-enter + /// the tree AppKit is walking. + func observeAccessibilityActivation() { + accessibilityActivationObserver = NotificationCenter.default.addObserver( + forName: DataGridAccessibility.didActivateNotification, + object: nil, + queue: nil + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.remountAccessibilityCells() + } + } + } + + /// Registered for the life of the coordinator, so it has to come off when the grid goes. + /// + /// `NotificationCenter` retains a block observer's closure, and a coordinator is built fresh on + /// every mount, so an entry left behind at teardown is never fired again and never reclaimed. + /// The closure captures `self` weakly, so this leaks the registration rather than the grid. + func detachAccessibilityActivationObserver() { + guard let accessibilityActivationObserver else { return } + NotificationCenter.default.removeObserver(accessibilityActivationObserver) + self.accessibilityActivationObserver = nil + } + + var hasAccessibilityActivationObserver: Bool { accessibilityActivationObserver != nil } +} diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift index 85b6f64b8..2d2e5b721 100644 --- a/TablePro/Views/Results/ForeignKeyPickerView.swift +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -96,7 +96,7 @@ struct ForeignKeyPickerView: View { private var content: some View { if let errorMessage { RevealedTextView(errorMessage) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index 4dad91971..e1f52d8de 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -104,7 +104,7 @@ struct ForeignKeyPreviewView: View { .frame(height: 60) } else if let errorMessage { RevealedTextView(errorMessage) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) .font(.callout) .padding(10) } else if values.isEmpty { diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index a66ae62a3..b24a12195 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -99,11 +99,11 @@ struct HexEditorBody: View { if sourceIsTruncated || isTruncated { Text(String(localized: "Truncated, read only")) .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) } else if !isValid, !editableHex.isEmpty { Text(String(localized: "Invalid hex")) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } Spacer() @@ -227,8 +227,8 @@ private struct HexDumpDisplayView: NSViewRepresentable { textView.isSelectable = true textView.font = font textView.textContainerInset = NSSize(width: 8, height: 8) - textView.backgroundColor = NSColor.textBackgroundColor - textView.textColor = NSColor.secondaryLabelColor + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelSecondaryText] textView.string = text return scrollView @@ -239,6 +239,8 @@ private struct HexDumpDisplayView: NSViewRepresentable { if textView.font != font { textView.font = font } + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelSecondaryText] if textView.string != text { textView.string = text } @@ -265,8 +267,8 @@ private struct HexInputTextView: NSViewRepresentable { textView.isSelectable = true textView.font = font textView.textContainerInset = NSSize(width: 8, height: 8) - textView.backgroundColor = NSColor.textBackgroundColor - textView.textColor = NSColor.labelColor + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelText] textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false @@ -288,6 +290,8 @@ private struct HexInputTextView: NSViewRepresentable { if textView.font != font { textView.font = font } + textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] + textView.textColor = ThemeEngine.shared.palette[.panelText] if textView.string != text, !context.coordinator.isUpdating { textView.string = text } diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index fd174460d..5c604273e 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -21,7 +21,7 @@ struct InlineErrorBanner: View { var body: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) ScrollView(.vertical) { RevealedTextView(message) .font(.subheadline) @@ -63,7 +63,7 @@ struct InlineErrorBanner: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(.red.opacity(0.08)) + .background(ThemeEngine.shared.palette.color(.statusError).opacity(0.08)) } } diff --git a/TablePro/Views/Results/JSONTreeView.swift b/TablePro/Views/Results/JSONTreeView.swift index 049e99f90..332e26dc5 100644 --- a/TablePro/Views/Results/JSONTreeView.swift +++ b/TablePro/Views/Results/JSONTreeView.swift @@ -30,7 +30,7 @@ private struct JSONTreeRowView: View { if let key = node.key { Text(key) .font(ThemeEngine.shared.valueFontEmphasizedSwiftUI) - .foregroundStyle(.blue) + .foregroundStyle(ThemeEngine.shared.palette.color(.syntaxKeyword)) .lineLimit(1) Text(":") .foregroundStyle(.secondary) diff --git a/TablePro/Views/Results/PhpTreeView.swift b/TablePro/Views/Results/PhpTreeView.swift index 7722b604a..efdb111ab 100644 --- a/TablePro/Views/Results/PhpTreeView.swift +++ b/TablePro/Views/Results/PhpTreeView.swift @@ -30,7 +30,7 @@ private struct PhpTreeRowView: View { if let key = node.key { Text(key) .font(ThemeEngine.shared.valueFontEmphasizedSwiftUI) - .foregroundStyle(.blue) + .foregroundStyle(ThemeEngine.shared.palette.color(.syntaxKeyword)) .lineLimit(1) if let badge = node.visibilityBadge { Text(badge) diff --git a/TablePro/Views/Results/ResultChartCanvas.swift b/TablePro/Views/Results/ResultChartCanvas.swift index 799b3e871..463224849 100644 --- a/TablePro/Views/Results/ResultChartCanvas.swift +++ b/TablePro/Views/Results/ResultChartCanvas.swift @@ -41,27 +41,27 @@ struct ResultChartCanvas: View { .chartYAxis { AxisMarks(position: .leading) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSeparator).opacity(0.55)) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSeparator)) AxisValueLabel() - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSecondaryText)) } } .chartXAxis { AxisMarks(values: .automatic(desiredCount: 7)) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSeparator).opacity(0.55)) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSeparator)) AxisValueLabel(collisionResolution: .greedy(minimumSpacing: 8)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .foregroundStyle(ThemeEngine.shared.palette.color(.panelSecondaryText)) } } .chartPlotStyle { plotArea in plotArea .background( - Color(nsColor: .controlBackgroundColor).opacity(0.45), + ThemeEngine.shared.palette.color(.panelBackground).opacity(0.45), in: .rect(cornerRadius: 8) ) } diff --git a/TablePro/Views/Results/ResultSuccessView.swift b/TablePro/Views/Results/ResultSuccessView.swift index 395469844..1488dcae1 100644 --- a/TablePro/Views/Results/ResultSuccessView.swift +++ b/TablePro/Views/Results/ResultSuccessView.swift @@ -28,7 +28,7 @@ struct ResultSuccessView: View { Spacer() Image(systemName: "checkmark.circle.fill") .font(.largeTitle) - .foregroundStyle(.green) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusSuccess)) Text(primaryMessage) .font(.body) if let time = executionTime { diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift index bd1cd85a6..f12f6d070 100644 --- a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -37,7 +37,7 @@ final class GridSelectionOverlay: NSView { let totalRows = tableView.numberOfRows let editingCell = activeOverlayCell(in: coordinator) - NSColor.selectedContentBackgroundColor.withAlphaComponent(Self.borderAlpha).setStroke() + ThemeEngine.shared.palette[.gridSelection].withAlphaComponent(Self.borderAlpha).setStroke() for rect in selection.rectangles { guard let frame = frame(for: rect, in: tableView, coordinator: coordinator) else { continue } guard frame.intersects(dirtyRect) else { continue } diff --git a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift index d2f150327..20ab3e650 100644 --- a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift @@ -94,11 +94,11 @@ internal struct BlobHexEditorView: View { if isTruncated { Text("Truncated, read only") .font(.caption2) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) } else if BlobFormattingService.shared.parseHex(hexEditText) == nil, !hexEditText.isEmpty { Text("Invalid hex") .font(.caption2) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } } } diff --git a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index affe056c6..3f1974221 100644 --- a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -106,7 +106,7 @@ internal struct PendingStatePill: View { .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .topLeading) .padding(.horizontal, 6) .padding(.vertical, 3) - .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .background(ThemeEngine.shared.palette.color(.panelControlBackground), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift index 369a9da12..d0c4bfbe0 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift @@ -34,7 +34,7 @@ internal struct ImageFieldView: View { ) .frame(height: 220) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) .task(id: context.value.wrappedValue) { data = context.value.wrappedValue.storedBytes } diff --git a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift index 65428a310..c4da8a40f 100644 --- a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift @@ -29,7 +29,7 @@ internal struct JsonEditorView: View { ) { JSONCodeEditor(text: $displayText, isEditable: !context.isReadOnly) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) .overlay(alignment: .bottomTrailing) { actionButtons } } .onChange(of: displayText) { propagateEdit() } diff --git a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift index e2b38f2a6..f0a875d36 100644 --- a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift @@ -26,7 +26,7 @@ internal struct MultiLineEditorView: View { movesFocusOnTab: true ) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) .overlay(alignment: .topLeading) { placeholder } .overlay(alignment: .bottomTrailing) { popOutButton } } diff --git a/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift index cf499ad52..0d919505b 100644 --- a/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift @@ -18,6 +18,6 @@ internal struct PhpSerializedFieldView: View { .frame(height: isExpanded ? ResizableFieldMetrics.expandedHeight : nil) .frame(minHeight: isExpanded ? nil : 80, maxHeight: isExpanded ? nil : 200) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift b/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift index 643860139..ad065ee48 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift @@ -39,7 +39,7 @@ internal struct ResizableEditorContainer: View { private var resizeHandle: some View { Capsule() - .fill(Color(nsColor: .tertiaryLabelColor)) + .fill(ThemeEngine.shared.palette.color(.panelTertiaryText)) .frame(width: 26, height: 4) .opacity(isHandleHovered ? 1 : 0.5) .frame(maxWidth: .infinity, minHeight: 11) diff --git a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift index f960266bb..d6abbbe42 100644 --- a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift @@ -29,7 +29,7 @@ internal struct SingleLineEditorView: View { .frame(maxWidth: .infinity, minHeight: 16, alignment: .leading) .padding(.horizontal, 6) .padding(.vertical, 4) - .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .background(ThemeEngine.shared.palette.color(.panelControlBackground), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) } } diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index 94ec4eadd..d6926bc4d 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -66,7 +66,7 @@ struct JSONRowInspectorView: View { ) .overlay( RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color.red.opacity(0.6)) + .strokeBorder(ThemeEngine.shared.palette.color(.statusError).opacity(0.6)) .opacity(viewModel.isFilterInvalid ? 1 : 0) ) .help(viewModel.isFilterInvalid diff --git a/TablePro/Views/ServerDashboard/MetricsBarView.swift b/TablePro/Views/ServerDashboard/MetricsBarView.swift index 89f4c503d..61d983975 100644 --- a/TablePro/Views/ServerDashboard/MetricsBarView.swift +++ b/TablePro/Views/ServerDashboard/MetricsBarView.swift @@ -17,7 +17,7 @@ struct MetricsBarView: View { Image(systemName: "exclamationmark.triangle") } .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } } .padding(.horizontal, 12) diff --git a/TablePro/Views/ServerDashboard/SessionsTableView.swift b/TablePro/Views/ServerDashboard/SessionsTableView.swift index 93300d3e4..e211f48cb 100644 --- a/TablePro/Views/ServerDashboard/SessionsTableView.swift +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -19,7 +19,7 @@ struct SessionsTableView: View { Image(systemName: "exclamationmark.triangle") } .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) } } .padding(.horizontal, 12) @@ -86,10 +86,10 @@ struct SessionsTableView: View { private func stateColor(_ state: String) -> Color { switch state.lowercased() { - case "active", "running": return .green + case "active", "running": return ThemeEngine.shared.palette.color(.statusSuccess) case "idle": return .secondary - case "idle in transaction": return .orange - case "waiting", "locked": return .red + case "idle in transaction": return ThemeEngine.shared.palette.color(.statusWarning) + case "waiting", "locked": return ThemeEngine.shared.palette.color(.statusError) default: return .primary } } diff --git a/TablePro/Views/ServerDashboard/SlowQueryListView.swift b/TablePro/Views/ServerDashboard/SlowQueryListView.swift index 5c2358ef5..12aa31a35 100644 --- a/TablePro/Views/ServerDashboard/SlowQueryListView.swift +++ b/TablePro/Views/ServerDashboard/SlowQueryListView.swift @@ -19,7 +19,7 @@ struct SlowQueryListView: View { Image(systemName: "exclamationmark.triangle.fill") } .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) } } .padding(.horizontal, 12) @@ -49,7 +49,7 @@ struct SlowQueryListView: View { Text(query.duration) .font(.system(.caption, design: .monospaced)) .monospacedDigit() - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .frame(width: 50, alignment: .trailing) VStack(alignment: .leading, spacing: 2) { diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift index c37f98a5a..1af7d0b23 100644 --- a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift +++ b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift @@ -45,6 +45,7 @@ internal struct ThemeEditorColorsSection: View { ColorPicker("", selection: binding(for: slot), supportsOpacity: true) .labelsHidden() + .accessibilityLabel(Text(slot.label)) } .contextMenu { if case .hex = value, case let .system(name) = BuiltInThemes.default(for: theme.appearance)[keyPath: slot.keyPath] { diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index 7faf14401..7213093f1 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -13,16 +13,22 @@ struct EditorSettingsView: View { Form { TypographySection(domain: .editor, settings: $typography) - Section("SQL Editor") { + Section("Display") { Toggle("Show line numbers", isOn: $settings.showLineNumbers) Toggle("Highlight current line", isOn: $settings.highlightCurrentLine) Toggle("Highlight current statement", isOn: $settings.highlightCurrentStatement) + Toggle("Show invisible characters", isOn: $settings.showInvisibleCharacters) Toggle("Word wrap", isOn: $settings.wordWrap) + } + + Section("Gutter") { Toggle("Code folding", isOn: $settings.codeFoldingEnabled) Toggle("Run button beside each statement", isOn: $settings.showStatementRunControls) .disabled(!settings.showLineNumbers) .help(Text("The run button sits in the gutter, which needs line numbers.")) - Toggle("Show invisible characters", isOn: $settings.showInvisibleCharacters) + } + + Section("Editing") { Picker("Tab width:", selection: $settings.tabWidth) { Text("2 spaces").tag(2) Text("4 spaces").tag(4) diff --git a/TablePro/Views/Settings/Sections/TypographySection.swift b/TablePro/Views/Settings/Sections/TypographySection.swift index cb50b3a81..1817451d6 100644 --- a/TablePro/Views/Settings/Sections/TypographySection.swift +++ b/TablePro/Views/Settings/Sections/TypographySection.swift @@ -29,7 +29,7 @@ internal struct TypographySection: View { @Binding internal var settings: TypographySettings internal var body: some View { - Section(domain.title) { + Section { Picker(String(localized: "Family:"), selection: familyBinding) { ForEach(EditorFontResolver.availableMonospacedFamilies) { family in Text(family.displayName).tag(family.id) @@ -41,10 +41,10 @@ internal struct TypographySection: View { Text(verbatim: "\(size) pt").tag(size) } } - + } header: { + Text(domain.title) + } footer: { Text(domain.caption) - .font(.caption) - .foregroundStyle(.secondary) } } diff --git a/TablePro/Views/Settings/ThemePreviewCard.swift b/TablePro/Views/Settings/ThemePreviewCard.swift index c43c1147d..e261be638 100644 --- a/TablePro/Views/Settings/ThemePreviewCard.swift +++ b/TablePro/Views/Settings/ThemePreviewCard.swift @@ -91,7 +91,14 @@ struct ThemePreviewCard: View { size == .compact ? 14 : 28 } + /// A miniature of the theme's own colors. The name and kind beside it carry the meaning, so + /// the shapes are decorative and say nothing. private var thumbnail: some View { + thumbnailContent + .accessibilityHidden(true) + } + + private var thumbnailContent: some View { HStack(spacing: 0) { sidebarStrip .frame(width: sidebarStripWidth) diff --git a/TablePro/Views/Structure/ClickHousePartsView.swift b/TablePro/Views/Structure/ClickHousePartsView.swift index 18874f9db..7b50340c3 100644 --- a/TablePro/Views/Structure/ClickHousePartsView.swift +++ b/TablePro/Views/Structure/ClickHousePartsView.swift @@ -30,7 +30,7 @@ struct ClickHousePartsView: View { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .accessibilityHidden(true) RevealedTextView(error) .foregroundStyle(.secondary) @@ -99,7 +99,7 @@ struct ClickHousePartsView: View { .width(min: 100, ideal: 160) TableColumn("Active") { part in Image(systemName: part.active ? "checkmark.circle.fill" : "xmark.circle") - .foregroundStyle(part.active ? .green : .secondary) + .foregroundStyle(part.active ? ThemeEngine.shared.palette.color(.statusSuccess) : .secondary) } .width(min: 50, ideal: 60) } diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 1131978c7..b458e3ed4 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -178,7 +178,7 @@ struct CreateTableView: View { Spacer() } .padding() - .background(Color(nsColor: .controlBackgroundColor)) + .background(ThemeEngine.shared.palette.color(.panelBackground)) .onChange(of: draft.tableOptions.charset) { _, newCharset in if let first = CreateTableOptions.collations[newCharset]?.first { draft.tableOptions.collation = first diff --git a/TablePro/Views/Structure/DDLTextView.swift b/TablePro/Views/Structure/DDLTextView.swift index 3f16dd064..858f8492e 100644 --- a/TablePro/Views/Structure/DDLTextView.swift +++ b/TablePro/Views/Structure/DDLTextView.swift @@ -32,7 +32,7 @@ struct DDLTextView: View { var body: some View { if ddl.isEmpty { - Color(nsColor: .textBackgroundColor) + ThemeEngine.shared.palette.color(.editorBackground) } else { SourceEditor( $text, diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index ea56cc249..1a9c4c9fe 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -110,7 +110,7 @@ extension TableStructureView { if showCopyConfirmation { HStack { Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusSuccess)) Text("Copied!") } .transition(.opacity) diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index a768c3365..c81ba75f0 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -527,7 +527,7 @@ struct TableStructureView: View { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) - .foregroundStyle(.orange) + .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) .accessibilityHidden(true) RevealedTextView(message) .foregroundStyle(.secondary) diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 54aae67b7..ca27c0b2e 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -252,7 +252,7 @@ private struct TriggerListPane: View { private func enabledIndicator(_ trigger: TriggerInfo) -> some View { if let enabled = trigger.enabled { Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") - .foregroundStyle(enabled ? Color.green : Color.secondary) + .foregroundStyle(enabled ? ThemeEngine.shared.palette.color(.statusSuccess) : Color.secondary) .accessibilityLabel(enabled ? String(localized: "Enabled") : String(localized: "Disabled")) } } @@ -274,7 +274,7 @@ private struct TriggerDetailPane: View { onOpenInEditor: { onOpenInEditor(trigger) } ) } else { - Color(nsColor: .textBackgroundColor) + ThemeEngine.shared.palette.color(.editorBackground) } } diff --git a/docs/customization/appearance.mdx b/docs/customization/appearance.mdx index 97ae4631e..502011b2d 100644 --- a/docs/customization/appearance.mdx +++ b/docs/customization/appearance.mdx @@ -39,10 +39,13 @@ Custom themes get color wells here. A built-in or registry theme shows a lock an | Editor | Background, text, cursor, selection, current line, current statement, line number, invisibles | | Syntax Colors | Keyword, string, number, comment, NULL, operator, function, type | | Data Grid | Background, text, alternate row, header background, header text, grid line, selection, selected text, inactive selection, focus border, NULL value, bool true/false, row number, modified/inserted/deleted rows, deleted text | +| Panels | Pane background, field background, pane text, secondary text, tertiary text, separator | | Status | Success, warning, error | A slot holds either a hex color or the name of a macOS system color. Default Light and Default Dark keep the data grid surrounds on system colors, which is why an untouched theme follows your system accent and its Increase Contrast setting. Right-click a well that a built-in leaves on a system color to put that system color back. +A theme colors the panes that show database content. The sidebar, the toolbar, the tab strip and the window frame stay on the macOS appearance, which is what the **Appearance** control at the top of the tab sets: pick a dark theme and the window frame goes dark with it. + ## Theme files A theme is one JSON file. **Export…** in the gear menu writes every color the selected theme sets, which is the shortest way to start a new one; **Import…** in the **+** menu reads one back into `~/Library/Application Support/TablePro/Themes/`. Community themes install from the [registry](/features/plugins) under **Settings > Plugins > Browse**, Themes category. @@ -54,6 +57,7 @@ A theme is one JSON file. **Export…** in the gear menu writes every color the | `appearance` | `light` or `dark`. Decides which slot lists the theme | | `content.editor` | Editor colors, with syntax colors nested under `syntax` | | `content.dataGrid` | Grid colors | +| `content.panel` | Colors of the panes around the editor and the grid: results, inspector, structure, compare, query plan | | `content.status` | Success, warning and error colors | ```json diff --git a/docs/customization/editor-settings.mdx b/docs/customization/editor-settings.mdx index 5b2ea15c3..2da58ec33 100644 --- a/docs/customization/editor-settings.mdx +++ b/docs/customization/editor-settings.mdx @@ -20,16 +20,27 @@ The font here is set per Mac, not per theme, and is not synced. The picker lists the monospaced families installed on your Mac, at 10 to 24 pt. `Cmd+=` and `Cmd+-` change the size over the same range. -## SQL editor +## Display | Setting | Default | Notes | |---------|---------|-------| | Show line numbers | On | Turning this off also hides the per-statement run button | | Highlight current line | On | | | Highlight current statement | On | A faint band behind the statement the cursor is in. See [Statement markers](/features/sql-editor#statement-markers) | +| Show invisible characters | Off | See [Invisible characters](/features/sql-editor#invisible-characters) | | Word wrap | Off | Off means long lines scroll horizontally | + +## Gutter + +| Setting | Default | Notes | +|---------|---------|-------| | Code folding | On | Shows the fold ribbon in the gutter. See [Code Folding](/features/code-folding) | | Run button beside each statement | On | Gutter run buttons, revealed when the pointer is over the gutter. Needs line numbers on | + +## Editing + +| Setting | Default | Notes | +|---------|---------|-------| | Tab width | 4 spaces | 2, 4, or 8 | | Auto-uppercase keywords | Off | Uppercases SQL keywords on word boundaries. Strings, comments, and quoted identifiers are untouched | | Query parameters (`:name` syntax) | On | Detects `:name` placeholders and shows the parameter panel. See [Query Parameters](/features/query-parameters) |