diff --git a/.claude/rules/editor.md b/.claude/rules/editor.md index ecf3411406..79b3868a3f 100644 --- a/.claude/rules/editor.md +++ b/.claude/rules/editor.md @@ -6,13 +6,9 @@ paths: - "TablePro/Views/RowInspector/**/*" --- -# Editor and theme +# Editor -- **`ThemeEngine` is the single source of theme colors**; `ThemeEngine.makeEditorTheme()` builds the `TableProEditorTheme` adapter. -- **A theme colors two surfaces: code views and the data grid.** Code views are the SQL editor and every other `SourceEditor` or SQL text view (JSON editor, DDL, trigger, previews, AI code blocks, history). Every other pane, Settings and all chrome use semantic system colors directly and never read the theme. `NSApp.appearance` is the only lever on chrome. -- **A slot exists only with a reader.** `ThemeSlot` is the one registry the decoder, encoder and theme editor enumerate, and `ThemeSlotCoverageTests` fails a slot nothing reads. Every slot is optional: a theme that leaves one out draws `ThemeSlot.nativeColor`, the color AppKit itself uses there. Default Light and Default Dark override syntax only, with Xcode's Default values. -- **Two color tiers.** `palette[slot]` is a dynamic color over the live theme pair; use it wherever a view holds a color. `resolved[slot]` is the active theme's override or the system color object itself, never a flattened copy; turn it into a `CGColor` only inside the drawing view's appearance (`resolved.cgColor(_:in:)`), and check `resolved.isOverridden(_:)` before replacing a native drawing path (row selection, caret, header). Never call `withAlphaComponent` on a system color outside an `NSColor(name:dynamicProvider:)`: it freezes the color. -- **A theme change has to reach the view.** `AppEvents.shared.themeChanged` fires on a theme change, a light/dark flip, an Accent or Highlight change and an Increase Contrast toggle. A SwiftUI view that reads the engine observes it (`@ObservedObject private var themeEngine = ThemeEngine.shared`), because SwiftUI caches `Color(nsColor:)` by object identity; an AppKit view that bakes a color subscribes to `themeChanged` and re-bakes. +- **`ThemeEngine.makeEditorTheme()` builds the `TableProEditorTheme` adapter** the editor draws with. Theme colors and their scope: `.claude/rules/theme.md`. - **Fonts are not theme.** `TypographySettings` is device-local and stays out of `AppSettingsCategory.synced`: sync replaces a category wholesale, so an older Mac would reset it and each zoom press would push a record. - **Two font domains.** A view that also wears the editor's colors (the SQL editor, `JSONCodeEditor`, previews) uses `editorFonts`. Every control that shows or edits a stored value uses `ThemeEngine.valueFont` or `valueFontSwiftUI` (the Data Grid Font), so a value reads the same in the grid, the inspector and a popover; a system text style there looks right only while the two settings are equal. `InspectorFieldRow` sets it for the whole inspector, and its `FieldEditorKind` switch is exhaustive on purpose. - **Multi-cursor lives in `cursorPositions: [CursorPosition]`.** diff --git a/.claude/rules/theme.md b/.claude/rules/theme.md new file mode 100644 index 0000000000..54b92eefaa --- /dev/null +++ b/.claude/rules/theme.md @@ -0,0 +1,22 @@ +--- +paths: + - "TablePro/Theme/**/*" + - "TablePro/Views/**/*" + - "TablePro/Core/Services/Infrastructure/*Window*" +--- + +# Theme + +- **A theme colors content and the window backdrop.** Content is every code view, the data grid, value text and wells, value trees, diffs, record tables, diagrams, charts and the result-area states. `window.background` fills the detail pane and, on macOS 26 and later, `NSWindow.backgroundColor` of the four content windows (main, Data File, Compare & Sync, value viewers); the sidebar, inspector and toolbar glass take their tone from it. Controls, Accent, Highlight, Settings, Welcome, the connection form, sheet and popover chrome, SwiftUI `List`, `Form` and `Table` backgrounds and every bar stay system; the result bar only drops its material while `window.background` themes the detail pane. `NSApp.appearance` is the only lever on chrome. +- **A code field takes the editor theme wherever it appears.** Startup Commands, Pre-Connect Script, a favorite's query, Rewind and Agent SQL wear `editor.*`; the form or sheet around them stays system. +- **`ThemeEngine` is the single source of theme colors, and a slot exists only with a reader.** `ThemeSlot` is the one registry the decoder, encoder and theme editor enumerate, and `ThemeSlotCoverageTests` fails a slot nothing reads. Every slot is optional: one a theme leaves out draws `ThemeSlot.nativeColor`. Default Light and Default Dark override syntax only, with Xcode's Default values. +- **Every site passes what it draws natively.** A themed call site names a slot and, as its fallback, the exact system color or style it drew before, so while the active theme leaves the slot out the pixels are an unthemed app's. A default changes only on purpose, named in the PR and the CHANGELOG. +- **The slot family follows what the surface shows.** Code, values, value trees and wells: `editor.*`, with tree tones from `editor.syntax.*`. Records, schema objects, diagram nodes, chart plots and states that stand in for a grid: `dataGrid.*`. Diff rows: `dataGrid.inserted`, `modified` and `deleted` over the editor background and text. A region behind panes: `window.background`. Identity, severity and status hues stay system. +- **Two color tiers.** `palette[slot]` and `palette.color(_:otherwise:)` are dynamic colors over the live theme pair; use them wherever a view holds a color. `palette.color` returns a new object on every call, so never compare it to skip a write. `resolved[slot]` is the active theme's override or the system color object itself, never a flattened copy; bake it into a `CGColor` only inside the drawing view's appearance (`resolved.cgColor(_:otherwise:in:)`), and check `resolved.isOverridden(_:)` before replacing a native drawing path (row selection, caret, header). +- **The carriers.** SwiftUI: `.themeBackground(_:native:)`, `.themeForeground(_:native:)`, `CodeWell`, `.valueWell(clipsContent:)`, `TextValueEditor(style: .code)`. AppKit: `ThemeTextViewStyle` on an `NSTextView`; `ThemeTableChrome` (`.records` or `.code`) with `ThemeTableRowView` rows on a content `NSTableView` or `NSOutlineView`, never a source list; `ThemeWindowBackdrop` on a content window. +- **The file list is closed.** `ThemeColorReadGuardTests` names every file that may read the theme, use a carrier, or call a helper that reads it (`TreeValueTone.color`, `HighlightedSQLTextView`, `TableProEditorTheme.make()`). A new content surface joins it in the same change; chrome never does. +- **Hold every AppKit carrier strongly**, as a stored property on its owner. Its subscription and its row views keep weak references, so a carrier held only in a local stops at the first theme change. +- **A theme change has to reach the view.** `AppEvents.shared.themeChanged` fires on a theme change, a light/dark flip, an Accent or Highlight change and an Increase Contrast toggle. A SwiftUI view that reads the engine observes it (`@ObservedObject private var themeEngine = ThemeEngine.shared`), because SwiftUI caches `Color(nsColor:)` by object identity; an AppKit view that bakes a color re-bakes on it. Never call `withAlphaComponent` on a system color outside an `NSColor(name:dynamicProvider:)`: it freezes the color. +- **No fixed color on selectable-row text.** A theme color does not turn white on the emphasized selection the way `.primary` does, so `.themeForeground` never goes on a container of selectable rows. Row text keeps a hierarchical style unless the theme also sets that table's selection. +- **The window backdrop yields to Increase Contrast** and draws native there, since the format has no increased-contrast variant. Content slots keep the theme. +- **Never fill chrome.** No fill on the sidebar, inspector frame, toolbar or titlebar, and no `.tint` with a theme color. AppKit keeps its own selection unless the theme sets the selection slot. `PaneBackgroundGuardTests` bans `.scrollContentBackground(.hidden)`, `.listRowBackground(` and a window `backgroundColor` write outside `ThemeWindowBackdrop`. diff --git a/AGENTS.md b/AGENTS.md index 7ef08a47df..52b6512b5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ swiftlint lint --strict **Change tracking.** A cell edit goes to `DataChangeManager`; Save turns it into statements through `SQLStatementGenerator`; `AnyChangeManager` abstracts the concrete managers. Undo comes from `StructureChangeManager`'s private `UndoManager` and `ConnectionWorkspace.undoManager`. -**Editor and theme.** `ThemeCatalog` owns the theme list, `ThemeResolver` picks the light and dark pair, and `ThemeEngine` holds the palette and the font caches; the fonts themselves are `TypographySettings`. `CompletionEngine` is framework-agnostic and `QueryCompletionAdapter` bridges it to the editor. Editor tabs are drawn by `EditorTabStrip`, not native window tabs. Details: `.claude/rules/editor.md`. +**Editor and theme.** `ThemeCatalog` owns the theme list, `ThemeResolver` picks the light and dark pair, and `ThemeEngine` holds the palette and the font caches; the fonts themselves are `TypographySettings`. A theme colors content and the window backdrop, never chrome or controls; see `.claude/rules/theme.md`. `CompletionEngine` is framework-agnostic and `QueryCompletionAdapter` bridges it to the editor. Editor tabs are drawn by `EditorTabStrip`, not native window tabs. Details: `.claude/rules/editor.md`. **Storage.** Passwords in the Keychain, preferences in UserDefaults, query history in SQLite FTS5, tab state as JSON. The full table of which store owns what is in `.claude/rules/data-sync-storage.md`. @@ -137,13 +137,14 @@ For everything: docs, commits, CHANGELOG, UI strings, errors, PR descriptions. S | `connection-window.md` | connection windows, workspaces, sessions, `DatabaseManager` | | `data-grid.md` | the data grid under `TablePro/Views/Results` | | `data-sync-storage.md` | storage, CloudKit sync, the database core | -| `editor.md` | `Packages/TableProEditor`, `TablePro/Theme`, editor views | +| `editor.md` | `Packages/TableProEditor`, editor views, fonts | | `libs.md` | `Libs/` and the libs scripts | | `mongodb-driver.md`, `mysql-driver.md`, `redis-driver.md` | those drivers | | `native-bridges.md` | `Native/` and the Dameng and HANA plugins | | `plugin-system.md` | `Plugins/`, TableProPluginKit, `project.yml`, plugin CI | | `split-views.md` | split view controllers and workspace panes | | `tests.md` | `TableProTests`, `TableProUITests` | +| `theme.md` | `TablePro/Theme`, every view a theme reaches, content windows | | `ui-lifecycle.md` | views, view models, UI tests | | `welcome-library.md` | the welcome window's connection list | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7817a15a1c..6a85fbec35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,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. - Light theme and Dark theme pickers, and New Theme Based On, Rename and Show in Finder for custom themes in Settings > Appearance. - Reason shown in Settings > Appearance for a theme file that could not be loaded. +- Window background color in themes. On macOS 26 and later the sidebar, inspector and toolbar take it on. - Setting in Settings > General to hide the version and Check for Updates on the Welcome window. - Install Parquet… in the Export dialog's format menu when the Parquet plugin is not installed. - Cloud Firestore and Firebase Realtime Database as a registry plugin over their REST APIs. (#2504) @@ -19,8 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Default Light and Default Dark draw the editor and data grid in the system colors and use Xcode's default syntax colors. -- Themes color only the SQL editor, other code views and the data grid. Every other pane keeps the system colors. +- Themes color every content view, including trees, diffs, diagrams and charts. Controls and Settings keep the system look. +- JSON and PHP tree values use the editor's syntax colors, matching the same value shown as text. - Theme file format 3, where every color is optional. Themes written for earlier versions are not read and need to be recreated. +- Triggers and ClickHouse Parts in table structure shown as data grids, with multiple selection and Copy. +- Filter and maintenance SQL previews highlighted as SQL in the editor font. +- Result bar items separated by space instead of a dot. - Settings > Editor grouped into Display, Gutter and Editing. - Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac. - Editor font size range of 10 to 24 points everywhere, including zoom. @@ -69,6 +74,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Zoom In and Zoom Out staying enabled at the largest and smallest font size. - JSON and PHP tree values colored differently from the same values in the row inspector. - Theme color wells and the theme preview unreadable to VoiceOver. +- Query plan image export coming out light in Dark mode. +- Favorite query field turning straight quotes into curly quotes. - Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background. - GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. - Settings > AI reading sign-in tokens and running command line tools for providers that were never added. diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index a1f2a0c9b1..42fe631b26 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -93,6 +93,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { private var activity: NSUserActivity? + private let windowBackdrop: ThemeWindowBackdrop + /// `adopting` carries a connection that is moving here from another window, whole. Everything /// the user has in it lives on that object, so the window takes it rather than building a /// second one around the same connection. @@ -123,6 +125,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.subtitle = splitVC.windowSubtitle window.representedURL = splitVC.windowRepresentedURL splitVC.installTabStripAccessory(on: window) + windowBackdrop = ThemeWindowBackdrop(window: window) super.init(window: window) diff --git a/TablePro/Models/Query/ExecutionSlot.swift b/TablePro/Models/Query/ExecutionSlot.swift index 33e03a8063..cbe3a930e9 100644 --- a/TablePro/Models/Query/ExecutionSlot.swift +++ b/TablePro/Models/Query/ExecutionSlot.swift @@ -13,12 +13,9 @@ internal struct ExecutionSlot: Equatable { } internal let report: Report? - internal let leadsWithSeparator: Bool - internal init(isOffered: Bool, followsReadout: Bool, isRevealed: Bool, lastTiming: PluginQueryTiming?) { - let report = isOffered ? Self.report(isRevealed: isRevealed, lastTiming: lastTiming) : nil - self.report = report - leadsWithSeparator = followsReadout && report != nil + internal init(isOffered: Bool, isRevealed: Bool, lastTiming: PluginQueryTiming?) { + report = isOffered ? Self.report(isRevealed: isRevealed, lastTiming: lastTiming) : nil } private static func report(isRevealed: Bool, lastTiming: PluginQueryTiming?) -> Report? { diff --git a/TablePro/Models/UI/FilterableTreeNode.swift b/TablePro/Models/UI/FilterableTreeNode.swift index aaca3d4b34..f74b6193ae 100644 --- a/TablePro/Models/UI/FilterableTreeNode.swift +++ b/TablePro/Models/UI/FilterableTreeNode.swift @@ -3,7 +3,6 @@ // TablePro // -import AppKit import Foundation internal protocol FilterableTreeNode: Identifiable { @@ -169,9 +168,6 @@ internal struct TreeSiblingCounter { // MARK: - Row Content -/// Shared by the JSON and PHP tree viewers, the results tree and the JSON row inspector. These are -/// system colors on purpose: a theme colors only the SQL editor and the data grid, so a value reads -/// the same here under every theme. internal enum TreeValueTone: Equatable, Sendable { case key case container @@ -181,18 +177,6 @@ internal enum TreeValueTone: Equatable, Sendable { case serialized case reference case muted - - var color: NSColor { - switch self { - case .key, .container: return .systemBlue - case .string: return .systemRed - case .number: return .systemPurple - case .literal: return .systemOrange - case .serialized: return .systemTeal - case .reference: return .systemGray - case .muted: return .secondaryLabelColor - } - } } internal struct TreeRowContent: Equatable, Sendable { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 514da3e4b8..09570084dd 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -171521,16 +171521,6 @@ }, "Sum %@" : { - }, - "Sum %@ · Average %@ · Count %lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Sum %1$@ · Average %2$@ · Count %3$lld" - } - } - } }, "Sum %@, Average %@, Count %lld" : { "localizations" : { @@ -189087,7 +189077,7 @@ } } }, - "Themes color the SQL editor and the data grid. Everything else follows the system appearance." : { + "Themes color the editor, the data grid, other content views and the window background. Controls, sidebars and Settings follow the system appearance." : { }, "There are no connections to publish." : { diff --git a/TablePro/Resources/Themes/tablepro.dracula.json b/TablePro/Resources/Themes/tablepro.dracula.json index 8b08ec8f2c..cb383ae0f7 100644 --- a/TablePro/Resources/Themes/tablepro.dracula.json +++ b/TablePro/Resources/Themes/tablepro.dracula.json @@ -43,5 +43,8 @@ }, "id": "tablepro.dracula", "name": "Dracula", - "schema": 3 + "schema": 3, + "window": { + "background": "#282A36" + } } diff --git a/TablePro/Resources/Themes/tablepro.nord.json b/TablePro/Resources/Themes/tablepro.nord.json index 7ede3360da..2eb0c33f99 100644 --- a/TablePro/Resources/Themes/tablepro.nord.json +++ b/TablePro/Resources/Themes/tablepro.nord.json @@ -43,5 +43,8 @@ }, "id": "tablepro.nord", "name": "Nord", - "schema": 3 + "schema": 3, + "window": { + "background": "#2E3440" + } } diff --git a/TablePro/Theme/ThemeColor.swift b/TablePro/Theme/ThemeColor.swift index 2ee79d9d81..0c50791542 100644 --- a/TablePro/Theme/ThemeColor.swift +++ b/TablePro/Theme/ThemeColor.swift @@ -98,6 +98,8 @@ internal extension ThemeSlot { /// `withAlphaComponent` on a system color outside one freezes it at the current appearance. var nativeColor: NSColor { switch self { + case .windowBackdrop: return .windowBackgroundColor + case .editorBackground: return .textBackgroundColor case .editorText, .syntaxOperator: return .textColor case .editorCursor: return NativeThemeColors.insertionPoint diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift index 4b39fda49f..091ff476f5 100644 --- a/TablePro/Theme/ThemeDefinition.swift +++ b/TablePro/Theme/ThemeDefinition.swift @@ -5,10 +5,10 @@ internal enum ThemeAppearance: String, Codable, CaseIterable, Sendable { case dark } -/// A theme is a set of overrides for the two surfaces it may color, the SQL editor and the data -/// grid. A slot it leaves out draws in the color AppKit itself uses there, so Default Light and -/// Default Dark declare nothing but syntax colors and render exactly like a native text view and -/// table, following Accent, Highlight and Increase Contrast. +/// A theme is a set of overrides for the content views and the window backdrop. A slot it leaves out +/// draws in the color AppKit itself uses there, so Default Light and Default Dark declare nothing but +/// syntax colors and render exactly like a native app, following Accent, Highlight and Increase +/// Contrast. internal struct ThemeDefinition: Identifiable, Equatable, Sendable { var id: String var name: String @@ -26,6 +26,21 @@ internal struct ThemeDefinition: Identifiable, Equatable, Sendable { overrides[slot] != nil } + /// The format has no increased-contrast variant, so a slot that yields to Increase Contrast draws + /// native in an increased-contrast appearance even where the theme sets it. + internal func isOverridden(_ slot: ThemeSlot, in appearance: NSAppearance) -> Bool { + isOverridden(slot, highContrast: appearance.isHighContrast) + } + + internal func isOverridden(_ slot: ThemeSlot, highContrast: Bool) -> Bool { + isOverridden(slot) && !(slot.yieldsToIncreasedContrast && highContrast) + } + + internal func color(for slot: ThemeSlot, in appearance: NSAppearance) -> NSColor { + guard slot.yieldsToIncreasedContrast, appearance.isHighContrast else { return color(for: slot) } + return slot.nativeColor + } + /// Syntax has no system color, so a syntax slot the theme leaves out takes the same-appearance /// default's value, which switches to Xcode's high-contrast palette under Increase Contrast. /// Operator falls back to the editor text, overridden or not. @@ -48,6 +63,8 @@ internal struct ThemeDefinition: Identifiable, Equatable, Sendable { /// which `ThemeSlotCoverageTests` enforces. The document decoder, the encoder and the theme editor /// all enumerate this list rather than keeping their own copies. internal enum ThemeSlot: String, CaseIterable, Sendable { + case windowBackdrop = "window.background" + case editorBackground = "editor.background" case editorText = "editor.text" case editorCursor = "editor.cursor" @@ -87,6 +104,8 @@ internal enum ThemeSlot: String, CaseIterable, Sendable { internal var group: ThemeSlotGroup { switch self { + case .windowBackdrop: + return .window case .editorBackground, .editorText, .editorCursor, .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles: return .editor @@ -101,9 +120,14 @@ internal enum ThemeSlot: String, CaseIterable, Sendable { } } + /// Only the backdrop: content slots keep the theme under Increase Contrast, as they always have. + internal var yieldsToIncreasedContrast: Bool { + group == .window + } + internal var label: String { switch self { - case .editorBackground, .gridBackground: return String(localized: "Background") + case .windowBackdrop, .editorBackground, .gridBackground: return String(localized: "Background") case .editorText, .gridText: return String(localized: "Text") case .editorCursor: return String(localized: "Cursor") case .editorSelection, .gridSelection: return String(localized: "Selection") @@ -139,12 +163,14 @@ internal enum ThemeSlot: String, CaseIterable, Sendable { } internal enum ThemeSlotGroup: String, CaseIterable, Sendable { + case window case editor case syntax case dataGrid internal var label: String { switch self { + case .window: return String(localized: "Window") case .editor: return String(localized: "Editor") case .syntax: return String(localized: "Syntax") case .dataGrid: return String(localized: "Data Grid") diff --git a/TablePro/Theme/ThemePalette.swift b/TablePro/Theme/ThemePalette.swift index 9571bc6eda..f9792d7441 100644 --- a/TablePro/Theme/ThemePalette.swift +++ b/TablePro/Theme/ThemePalette.swift @@ -44,9 +44,11 @@ internal struct ThemePalette { internal let revision: Int private let colors: [ThemeSlot: NSColor] + private let source: ThemeSource internal init(revision: Int, source: ThemeSource = .shared) { self.revision = revision + self.source = source var built: [ThemeSlot: NSColor] = [:] built.reserveCapacity(ThemeSlot.allCases.count) @@ -54,7 +56,7 @@ internal struct ThemePalette { for slot in ThemeSlot.allCases { let name = NSColor.Name("tablepro.\(slot.rawValue).\(revision)") built[slot] = NSColor(name: name) { appearance in - source.pair[ThemeAppearance(matching: appearance)].color(for: slot) + source.pair[ThemeAppearance(matching: appearance)].color(for: slot, in: appearance) } } @@ -68,6 +70,17 @@ internal struct ThemePalette { internal func color(_ slot: ThemeSlot) -> Color { Color(nsColor: self[slot]) } + + /// For a surface that has its own system color today: the theme's color in each appearance whose + /// theme sets the slot, and `native` itself, still dynamic, in the other. + /// Unnamed, because two dynamic colors with one name compare equal whatever they draw. + internal func color(_ slot: ThemeSlot, otherwise native: NSColor) -> NSColor { + let source = source + return NSColor(name: nil) { appearance in + let theme = source.pair[ThemeAppearance(matching: appearance)] + return theme.isOverridden(slot, in: appearance) ? theme.color(for: slot) : native + } + } } /// The active theme's colors for code that holds on to them: the editor theme, a `CALayer`, the @@ -110,12 +123,33 @@ internal struct ResolvedTheme: Equatable { Color(nsColor: self[slot]) } + /// The override when the active theme sets the slot, else `native` itself. + internal func color(_ slot: ThemeSlot, otherwise native: NSColor) -> NSColor { + guard isOverridden(slot) else { return native } + guard slot.yieldsToIncreasedContrast else { return self[slot] } + let override = self[slot] + return NSColor(name: nil) { appearance in + appearance.isHighContrast ? native : override + } + } + /// A view drawing in the other appearance than the active theme (a preview of the other slot) /// takes that slot's theme, the way a `palette` color would. internal func cgColor(_ slot: ThemeSlot, in appearance: NSAppearance) -> CGColor { let color = ThemeAppearance(matching: appearance) == definition.appearance ? self[slot] : ThemeSource.shared.pair[ThemeAppearance(matching: appearance)].color(for: slot) + return Self.cgColor(color, in: appearance) + } + + internal func cgColor(_ slot: ThemeSlot, otherwise native: NSColor, in appearance: NSAppearance) -> CGColor { + let theme = ThemeAppearance(matching: appearance) == definition.appearance + ? definition + : ThemeSource.shared.pair[ThemeAppearance(matching: appearance)] + return Self.cgColor(theme.isOverridden(slot, in: appearance) ? theme.color(for: slot) : native, in: appearance) + } + + private static func cgColor(_ color: NSColor, in appearance: NSAppearance) -> CGColor { var result: CGColor? appearance.performAsCurrentDrawingAppearance { result = color.cgColor diff --git a/TablePro/Theme/ThemeShapeStyle.swift b/TablePro/Theme/ThemeShapeStyle.swift new file mode 100644 index 0000000000..b201851527 --- /dev/null +++ b/TablePro/Theme/ThemeShapeStyle.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// What a SwiftUI surface draws for a slot: the theme's color while the active theme sets it, and the +/// style the surface has always drawn otherwise, passed through untouched so a default theme draws +/// exactly what it did before the theme reached that surface. +@MainActor +internal enum ThemeShapeStyle { + internal static func override( + _ slot: ThemeSlot, + engine: ThemeEngine = .shared, + contrast: ColorSchemeContrast + ) -> Color? { + guard engine.activeTheme.isOverridden(slot, highContrast: contrast == .increased) else { return nil } + return Color(nsColor: engine.palette[slot]) + } + + internal static func style( + _ slot: ThemeSlot, + native: some ShapeStyle, + engine: ThemeEngine = .shared, + contrast: ColorSchemeContrast + ) -> AnyShapeStyle { + guard let color = override(slot, engine: engine, contrast: contrast) else { return AnyShapeStyle(native) } + return AnyShapeStyle(color) + } +} + +/// One erased style rather than a branch, so a theme switch repaints the surface without rebuilding +/// the views inside it. +private struct ThemeBackground: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + + let slot: ThemeSlot + let native: Native + let edges: Edge.Set + + func body(content: Content) -> some View { + content.background( + ThemeShapeStyle.style(slot, native: native, engine: themeEngine, contrast: contrast), + ignoresSafeAreaEdges: edges + ) + } +} + +private struct ThemeShapeBackground: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + + let slot: ThemeSlot + let native: Native + let shape: Fill + + func body(content: Content) -> some View { + content.background(ThemeShapeStyle.style(slot, native: native, engine: themeEngine, contrast: contrast), in: shape) + } +} + +private struct ThemeForeground: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + + let slot: ThemeSlot + let native: Native + + func body(content: Content) -> some View { + content.foregroundStyle(ThemeShapeStyle.style(slot, native: native, engine: themeEngine, contrast: contrast)) + } +} + +internal extension View { + func themeBackground( + _ slot: ThemeSlot, + native: some ShapeStyle, + ignoresSafeAreaEdges edges: Edge.Set = .all + ) -> some View { + modifier(ThemeBackground(slot: slot, native: native, edges: edges)) + } + + func themeBackground(_ slot: ThemeSlot, native: some ShapeStyle, in shape: some Shape) -> some View { + modifier(ThemeShapeBackground(slot: slot, native: native, shape: shape)) + } + + /// For one text or shape. A row that can be selected keeps a hierarchical style instead: a fixed + /// color does not turn white on the emphasized selection the way `.primary` does. + func themeForeground(_ slot: ThemeSlot, native: some ShapeStyle) -> some View { + modifier(ThemeForeground(slot: slot, native: native)) + } +} diff --git a/TablePro/Theme/ThemeTableChrome.swift b/TablePro/Theme/ThemeTableChrome.swift new file mode 100644 index 0000000000..2e8de4fd46 --- /dev/null +++ b/TablePro/Theme/ThemeTableChrome.swift @@ -0,0 +1,440 @@ +import AppKit +import Combine +import SwiftUI + +/// Themes an `NSTableView` or `NSOutlineView` that shows content. Never a source list: its selection +/// and background belong to the sidebar glass, which draws them without calling the row view. +/// +/// A property is written only while the active theme sets its slot, and gets back the value it had +/// once the theme stops, so under a default theme the table is exactly what AppKit draws. Rows take +/// the theme's background and selection through a `ThemeTableRowDrawing` row, and only while the +/// theme sets one of them: a row class that overrides a drawing method leaves AppKit's layer-backed +/// selection, the vibrant material in a popover among them. +@MainActor +internal final class ThemeTableChrome { + internal enum Family { + /// Records and schema objects, on the data grid's slots. + case records + /// Values and code, on the editor's slots. + case code + + internal var background: ThemeSlot { + switch self { + case .records: return .gridBackground + case .code: return .editorBackground + } + } + + internal var alternateRow: ThemeSlot? { + switch self { + case .records: return .gridAlternateRow + case .code: return nil + } + } + + internal var gridLine: ThemeSlot? { + switch self { + case .records: return .gridLine + case .code: return nil + } + } + + internal func selection(emphasized: Bool) -> ThemeSlot { + switch self { + case .records: return emphasized ? .gridSelection : .gridInactiveSelection + case .code: return .editorSelection + } + } + } + + private struct BackgroundOriginals { + let color: NSColor + let alternates: Bool + } + + internal let family: Family + + /// True while the theme sets the rows' background or a selection, which only a themed row class + /// draws. A delegate returns AppKit's own row view otherwise. + internal private(set) var themesRows = false + + /// True while the rows are rebuilt for a new row class. The expansion and selection AppKit + /// reports meanwhile are the ones the table already had. + internal private(set) var isRebuildingRows = false + + private weak var tableView: NSTableView? + private var backgroundOriginals: BackgroundOriginals? + private weak var themedScrollView: NSScrollView? + private var scrollViewDrewBackground = true + private var originalGridColor: NSColor? + private var themeCancellable: AnyCancellable? + + /// Attach before the table builds its rows, and once it is inside its scroll view. + internal init(tableView: NSTableView, family: Family) { + self.tableView = tableView + self.family = family + restyle() + themeCancellable = AppEvents.shared.themeChanged + .sink { [weak self] _ in self?.apply() } + } + + internal var isBackgroundThemed: Bool { + backgroundOriginals != nil + } + + internal func apply() { + restyle() + guard let tableView, hasStaleRows(in: tableView) else { return } + rebuildRows(of: tableView) + } + + /// Nil while the theme leaves the rows alone, so the table builds AppKit's own row view. + internal func makeRowView(in tableView: NSTableView) -> ThemeTableRowView? { + guard themesRows else { return nil } + let reused = tableView.makeView(withIdentifier: ThemeTableRowView.reuseIdentifier, owner: nil) + let rowView = reused as? ThemeTableRowView ?? ThemeTableRowView() + rowView.identifier = ThemeTableRowView.reuseIdentifier + rowView.chrome = self + return rowView + } + + /// Nil while the theme leaves the background alone, and the row draws AppKit's. A themed table + /// stripes its rows itself: AppKit's stripes are the system pair whatever the table's color. + internal func rowBackgroundColor(forRow row: Int) -> NSColor? { + guard let originals = backgroundOriginals, row >= 0 else { return nil } + let palette = ThemeEngine.shared.palette + guard originals.alternates, let alternate = family.alternateRow, !row.isMultiple(of: 2) else { + return palette[family.background] + } + return palette[alternate] + } + + /// Nil unless the theme sets its own selection, so a default theme keeps AppKit's, which follows + /// Accent and focus. + internal func selectionColor(emphasized: Bool) -> NSColor? { + let resolved = ThemeEngine.shared.resolved + let slot = family.selection(emphasized: emphasized) + return resolved.isOverridden(slot) ? resolved[slot] : nil + } + + internal func isOnThemedSelection(_ rowView: NSTableRowView) -> Bool { + rowView.isSelected && selectionColor(emphasized: rowView.isEmphasized) != nil + } + + private func restyle() { + guard let tableView else { return } + let resolved = ThemeEngine.shared.resolved + let backgroundSlots = [family.background] + [family.alternateRow].compactMap { $0 } + applyBackground(to: tableView, themed: backgroundSlots.contains { resolved.isOverridden($0) }) + applyGridColor(to: tableView, themed: family.gridLine.map { resolved.isOverridden($0) } ?? false) + let selectionSlots = [family.selection(emphasized: true), family.selection(emphasized: false)] + themesRows = isBackgroundThemed || selectionSlots.contains { resolved.isOverridden($0) } + tableView.needsDisplay = true + tableView.enumerateAvailableRowViews { rowView, _ in + rowView.needsDisplay = true + (rowView as? ThemeTableRowView)?.publishSelection() + } + } + + private func hasStaleRows(in tableView: NSTableView) -> Bool { + let themesRows = themesRows + var isStale = false + tableView.enumerateAvailableRowViews { rowView, _ in + if (rowView is any ThemeTableRowDrawing) != themesRows { isStale = true } + } + return isStale + } + + /// AppKit asks for a row view only when it builds a row, so a new class needs a reload. An + /// outline drops its selection on a reload, and the clip view clamps while rows are rebuilt. + private func rebuildRows(of tableView: NSTableView) { + isRebuildingRows = true + defer { isRebuildingRows = false } + let origin = tableView.enclosingScrollView?.contentView.bounds.origin + if let window = tableView.window, let editor = window.firstResponder as? NSTextView, editor.isFieldEditor, + let field = editor.delegate as? NSView, field.isDescendant(of: tableView) { + window.makeFirstResponder(tableView) + } + if let outlineView = tableView as? NSOutlineView { + reload(outlineView) + } else { + let selection = tableView.selectedRowIndexes + tableView.reloadData() + tableView.selectRowIndexes(selection, byExtendingSelection: false) + } + if let origin { + tableView.scroll(origin) + } + } + + private func reload(_ outlineView: NSOutlineView) { + let items = (0 ..< outlineView.numberOfRows).compactMap { outlineView.item(atRow: $0) } + let expanded = items.filter { outlineView.isItemExpanded($0) } + let selected = outlineView.selectedRowIndexes.compactMap { outlineView.item(atRow: $0) } + outlineView.reloadData() + outlineView.beginUpdates() + for item in expanded where !outlineView.isItemExpanded(item) { + outlineView.expandItem(item) + } + outlineView.endUpdates() + let rows = IndexSet(selected.map { outlineView.row(forItem: $0) }.filter { $0 >= 0 }) + outlineView.selectRowIndexes(rows, byExtendingSelection: false) + } + + /// The scroll view's own background is the content-background material; a themed table drops it + /// so the material never shows through an elastic scroll. + private func applyBackground(to tableView: NSTableView, themed: Bool) { + guard themed else { + restoreBackground(of: tableView) + return + } + if backgroundOriginals == nil { + backgroundOriginals = BackgroundOriginals( + color: tableView.backgroundColor, + alternates: tableView.usesAlternatingRowBackgroundColors + ) + } + tableView.backgroundColor = ThemeEngine.shared.palette[family.background] + tableView.usesAlternatingRowBackgroundColors = false + + if let scrollView = tableView.enclosingScrollView, scrollView !== themedScrollView { + restoreScrollView() + themedScrollView = scrollView + scrollViewDrewBackground = scrollView.drawsBackground + } + themedScrollView?.drawsBackground = false + } + + private func restoreBackground(of tableView: NSTableView) { + guard let originals = backgroundOriginals else { return } + tableView.backgroundColor = originals.color + tableView.usesAlternatingRowBackgroundColors = originals.alternates + restoreScrollView() + backgroundOriginals = nil + } + + private func restoreScrollView() { + themedScrollView?.drawsBackground = scrollViewDrewBackground + themedScrollView = nil + } + + private func applyGridColor(to tableView: NSTableView, themed: Bool) { + guard let slot = family.gridLine else { return } + guard themed else { + if let original = originalGridColor { + tableView.gridColor = original + originalGridColor = nil + } + return + } + if originalGridColor == nil { + originalGridColor = tableView.gridColor + } + tableView.gridColor = ThemeEngine.shared.palette[slot] + } +} + +// MARK: - Row drawing + +/// A row class that draws through its `ThemeTableChrome`. Each row class forwards its drawing +/// overrides here, so they exist only on a class the table uses while the theme draws its rows. +@MainActor +internal protocol ThemeTableRowDrawing: NSTableRowView { + var chrome: ThemeTableChrome? { get set } +} + +internal extension ThemeTableChrome { + /// False while the theme leaves the row's background alone, and the row draws AppKit's. + func drawBackground(of rowView: NSTableRowView, in dirtyRect: NSRect) -> Bool { + guard !rowView.isGroupRowStyle, + let tableView = rowView.superview as? NSTableView, + let fill = rowBackgroundColor(forRow: tableView.row(for: rowView)) + else { return false } + fill.setFill() + dirtyRect.fill() + return true + } + + /// AppKit's selection recolored rather than replaced, so it keeps the shape the table style gives + /// it: inset and rounded in an inset table, full width in a plain one. + func drawSelection(of rowView: NSTableRowView, in dirtyRect: NSRect, native: (NSRect) -> Void) { + guard let fill = selectionColor(emphasized: rowView.isEmphasized), + let context = NSGraphicsContext.current?.cgContext + else { + native(dirtyRect) + return + } + context.saveGState() + context.beginTransparencyLayer(auxiliaryInfo: nil) + native(dirtyRect) + fill.setFill() + dirtyRect.fill(using: .sourceIn) + context.endTransparencyLayer() + context.restoreGState() + } + + /// A theme picks its selection to sit behind its own text, so the text does not flip to white. + func interiorBackgroundStyle(of rowView: NSTableRowView, native: NSView.BackgroundStyle) -> NSView.BackgroundStyle { + isOnThemedSelection(rowView) ? .normal : native + } +} + +/// The row a `ThemeTableChrome` hands out while the theme draws the rows. It tells its records cells +/// when they sit on the theme's selection. +internal final class ThemeTableRowView: NSTableRowView, ThemeTableRowDrawing { + internal static let reuseIdentifier = NSUserInterfaceItemIdentifier("ThemeTableRow") + + internal weak var chrome: ThemeTableChrome? { + didSet { publishSelection() } + } + + override internal var isSelected: Bool { + didSet { publishSelection() } + } + + override internal var isEmphasized: Bool { + didSet { publishSelection() } + } + + override internal func drawBackground(in dirtyRect: NSRect) { + guard chrome?.drawBackground(of: self, in: dirtyRect) == true else { + super.drawBackground(in: dirtyRect) + return + } + } + + override internal func drawSelection(in dirtyRect: NSRect) { + guard let chrome else { + super.drawSelection(in: dirtyRect) + return + } + chrome.drawSelection(of: self, in: dirtyRect) { super.drawSelection(in: $0) } + } + + override internal var interiorBackgroundStyle: NSView.BackgroundStyle { + let native = super.interiorBackgroundStyle + return chrome?.interiorBackgroundStyle(of: self, native: native) ?? native + } + + internal var isOnThemedSelection: Bool { + chrome?.isOnThemedSelection(self) ?? false + } + + internal func publishSelection() { + let isThemed = isOnThemedSelection + for case let cell as ThemeTableChrome.HostingCell in subviews { + cell.isOnThemedSelection = isThemed + } + } +} + +// MARK: - Records cell text + +internal extension ThemeTableChrome { + /// The slot a records cell's text takes, or nil for the cell's own hierarchical text, which + /// turns white on AppKit's emphasized selection. + static func recordsTextSlot( + onThemedSelection: Bool, + onEmphasizedSelection: Bool, + highContrast: Bool, + engine: ThemeEngine = .shared + ) -> ThemeSlot? { + guard !onEmphasizedSelection else { return nil } + let theme = engine.activeTheme + if onThemedSelection, theme.isOverridden(.gridSelectionText, highContrast: highContrast) { + return .gridSelectionText + } + return theme.isOverridden(.gridText, highContrast: highContrast) ? .gridText : nil + } + + /// A SwiftUI cell of a records table. Its text takes the theme's grid text, and the selection text + /// while its `ThemeTableRowView` sits on the theme's selection. + final class HostingCell: NSHostingView { + private let selection = RecordsCellSelection() + + internal var isOnThemedSelection: Bool { + get { selection.isThemed } + set { + guard selection.isThemed != newValue else { return } + selection.isThemed = newValue + } + } + + internal static func make( + _ identifier: NSUserInterfaceItemIdentifier, + in tableView: NSTableView, + showing content: some View + ) -> HostingCell { + let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? HostingCell + ?? HostingCell(identifier: identifier) + cell.show(content) + return cell + } + + private convenience init(identifier: NSUserInterfaceItemIdentifier) { + self.init(rootView: AnyView(EmptyView())) + sizingOptions = [] + self.identifier = identifier + } + + internal func show(_ content: some View) { + rootView = AnyView(content.modifier(RecordsCellText(selection: selection))) + } + + /// A reused cell can land in another row, or in AppKit's own row view once the theme stops. + override internal func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + isOnThemedSelection = (superview as? ThemeTableRowView)?.isOnThemedSelection ?? false + } + } +} + +@MainActor +private final class RecordsCellSelection: ObservableObject { + @Published var isThemed = false +} + +private struct RecordsCellText: ViewModifier { + @ObservedObject var selection: RecordsCellSelection + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + + func body(content: Content) -> some View { + if #available(macOS 14, *) { + content.modifier( + ProminenceAwareRecordsCellText( + isOnThemedSelection: selection.isThemed, + engine: themeEngine, + contrast: contrast + ) + ) + } else { + content + } + } +} + +/// macOS 13 has no `backgroundProminence`, so a cell there cannot tell AppKit's emphasized selection +/// apart and keeps its system text. +@available(macOS 14, *) +private struct ProminenceAwareRecordsCellText: ViewModifier { + @Environment(\.backgroundProminence) private var prominence + + let isOnThemedSelection: Bool + let engine: ThemeEngine + let contrast: ColorSchemeContrast + + func body(content: Content) -> some View { + let slot = ThemeTableChrome.recordsTextSlot( + onThemedSelection: isOnThemedSelection, + onEmphasizedSelection: prominence == .increased, + highContrast: contrast == .increased, + engine: engine + ) + if let slot, let color = ThemeShapeStyle.override(slot, engine: engine, contrast: contrast) { + content.foregroundStyle(color) + } else { + content + } + } +} diff --git a/TablePro/Theme/ThemeTextViewStyle.swift b/TablePro/Theme/ThemeTextViewStyle.swift new file mode 100644 index 0000000000..0c3571c1d4 --- /dev/null +++ b/TablePro/Theme/ThemeTextViewStyle.swift @@ -0,0 +1,104 @@ +import AppKit +import Combine + +/// Puts a plain `NSTextView` on the editor's colors while the active theme sets them. A property the +/// theme leaves alone is never written, and one it stops setting gets back the value it had before, so +/// under a default theme the view stays exactly as AppKit configured it. +@MainActor +internal final class ThemeTextViewStyle { + internal struct Natives { + internal var background: NSColor + internal var text: NSColor + internal var insertionPoint: NSColor + internal var selectedTextBackground: NSColor? + + internal init(background: NSColor, text: NSColor, insertionPoint: NSColor, selectedTextBackground: NSColor?) { + self.background = background + self.text = text + self.insertionPoint = insertionPoint + self.selectedTextBackground = selectedTextBackground + } + + @MainActor + internal init(of textView: NSTextView) { + background = textView.backgroundColor + text = textView.textColor ?? .textColor + insertionPoint = textView.insertionPointColor ?? NativeThemeColors.insertionPoint + selectedTextBackground = textView.selectedTextAttributes[.backgroundColor] as? NSColor + } + } + + private enum Property: CaseIterable { + case background + case text + case insertionPoint + case selection + + var slot: ThemeSlot { + switch self { + case .background: return .editorBackground + case .text: return .editorText + case .insertionPoint: return .editorCursor + case .selection: return .editorSelection + } + } + } + + private weak var textView: NSTextView? + private let givenNatives: Natives? + private var natives: Natives? + private var themed: Set = [] + private var themeCancellable: AnyCancellable? + + /// Without `natives`, the values the text view holds when a theme first reaches it are the natives. + internal init(textView: NSTextView, natives: Natives? = nil) { + self.textView = textView + givenNatives = natives + apply() + themeCancellable = AppEvents.shared.themeChanged + .sink { [weak self] _ in self?.apply() } + } + + internal func apply() { + guard let textView else { return } + let resolved = ThemeEngine.shared.resolved + let wanted = Set(Property.allCases.filter { resolved.isOverridden($0.slot) }) + guard !wanted.isEmpty || !themed.isEmpty else { return } + + if themed.isEmpty { + natives = givenNatives ?? Natives(of: textView) + } + guard let natives else { return } + + let palette = ThemeEngine.shared.palette + for property in Property.allCases where wanted.contains(property) || themed.contains(property) { + write(wanted.contains(property) ? palette[property.slot] : nil, to: property, natives: natives, of: textView) + } + themed = wanted + } + + /// Hands every themed property back and stops following the theme. + internal func detach() { + themeCancellable = nil + guard let textView, let natives else { return } + for property in themed { + write(nil, to: property, natives: natives, of: textView) + } + themed = [] + } + + private func write(_ color: NSColor?, to property: Property, natives: Natives, of textView: NSTextView) { + switch property { + case .background: + textView.backgroundColor = color ?? natives.background + case .text: + textView.textColor = color ?? natives.text + case .insertionPoint: + textView.insertionPointColor = color ?? natives.insertionPoint + case .selection: + var attributes = textView.selectedTextAttributes + attributes[.backgroundColor] = color ?? natives.selectedTextBackground + textView.selectedTextAttributes = attributes + } + } +} diff --git a/TablePro/Theme/ThemeWindowBackdrop.swift b/TablePro/Theme/ThemeWindowBackdrop.swift new file mode 100644 index 0000000000..ed93e370b7 --- /dev/null +++ b/TablePro/Theme/ThemeWindowBackdrop.swift @@ -0,0 +1,42 @@ +import AppKit +import Combine + +/// Fills a content window's backdrop with the theme's window background, which the sidebar, +/// inspector and toolbar glass take their tone from on macOS 26 and later. Earlier sidebars blend +/// with the desktop instead, so there the window keeps its own color. +/// +/// The window gets a color only while the active theme sets one: any color other than a system one +/// removes the window's own content-background material, so a default theme leaves it untouched. +@MainActor +internal final class ThemeWindowBackdrop { + private weak var window: NSWindow? + private var original: NSColor? + private var themeCancellable: AnyCancellable? + + internal init(window: NSWindow) { + self.window = window + guard #available(macOS 26.0, *) else { return } + apply() + themeCancellable = AppEvents.shared.themeChanged + .sink { [weak self] _ in self?.apply() } + } + + internal func apply() { + guard #available(macOS 26.0, *), let window else { return } + let engine = ThemeEngine.shared + guard engine.activeTheme.isOverridden(.windowBackdrop, in: window.effectiveAppearance) else { + restore(window) + return + } + if original == nil { + original = window.backgroundColor + } + window.backgroundColor = engine.palette[.windowBackdrop] + } + + private func restore(_ window: NSWindow) { + guard let original else { return } + window.backgroundColor = original + self.original = nil + } +} diff --git a/TablePro/Views/AIChat/AIChatToolResultBlockView.swift b/TablePro/Views/AIChat/AIChatToolResultBlockView.swift index 9df11fae4c..60115594b7 100644 --- a/TablePro/Views/AIChat/AIChatToolResultBlockView.swift +++ b/TablePro/Views/AIChat/AIChatToolResultBlockView.swift @@ -3,7 +3,6 @@ // TablePro // -import AppKit import SwiftUI struct AIChatToolResultBlockView: View { @@ -11,6 +10,8 @@ struct AIChatToolResultBlockView: View { @State private var isExpanded: Bool = false + internal static let contentFont = Font.system(.caption, design: .monospaced) + var body: some View { VStack(alignment: .leading, spacing: 6) { Button { @@ -37,22 +38,7 @@ struct AIChatToolResultBlockView: View { .buttonStyle(.borderless) if isExpanded { - ScrollView(.horizontal, showsIndicators: false) { - Text(displayContent) - .font(.caption) - .fontDesign(.monospaced) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - } - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .textBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) + CodeWell(displayContent, font: Self.contentFont) } } .padding(.horizontal, 8) diff --git a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift index 7e13a7daee..40e42c8686 100644 --- a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift +++ b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift @@ -3,7 +3,6 @@ // TablePro // -import AppKit import SwiftUI struct AIChatToolUseBlockView: View { @@ -14,6 +13,8 @@ struct AIChatToolUseBlockView: View { @Environment(\.chatApprovalConnectionName) private var connectionName + internal static let inputFont = Font.system(.caption, design: .monospaced) + private var isPending: Bool { if case .pending = block.approvalState { return true } return false @@ -59,23 +60,9 @@ struct AIChatToolUseBlockView: View { .buttonStyle(.borderless) if isPending, let proposedStatement { - ScrollView(.horizontal, showsIndicators: false) { - Text(proposedStatement) - .font(themeEngine.valueFontSwiftUI) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - } - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .textBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - .accessibilityLabel(String(localized: "Proposed statement")) - .accessibilityValue(proposedStatement) + CodeWell(proposedStatement, font: themeEngine.valueFontSwiftUI) + .accessibilityLabel(String(localized: "Proposed statement")) + .accessibilityValue(proposedStatement) } if isPending { @@ -88,22 +75,7 @@ struct AIChatToolUseBlockView: View { } if shouldShowInput { - ScrollView(.horizontal, showsIndicators: false) { - Text(prettyInput) - .font(.caption) - .fontDesign(.monospaced) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - } - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .textBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) + CodeWell(prettyInput, font: Self.inputFont) } if case .pending = block.approvalState { diff --git a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index 173cfe1d4e..95083bf8f7 100644 --- a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -10,6 +10,8 @@ struct AIChatWalkthroughBlockView: View { @EnvironmentObject private var viewModel: AIChatViewModel @Environment(\.commandActions) private var actions + @Environment(\.colorSchemeContrast) private var contrast + @ObservedObject private var themeEngine = ThemeEngine.shared @State private var expandedStepIDs: Set = [] @State private var activeAnchor: SqlWalkthroughAnchor? @@ -111,7 +113,7 @@ struct AIChatWalkthroughBlockView: View { .padding(.vertical, 2) } .frame(maxHeight: 260) - .background(Color(nsColor: .textBackgroundColor)) + .themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) .onChange(of: scrollTarget) { target in guard let target else { return } @@ -136,11 +138,8 @@ struct AIChatWalkthroughBlockView: View { case .removed: "-" case .context: " " } - let tint: Color? = switch line.kind { - case .added: .green.opacity(0.16) - case .removed: .red.opacity(0.16) - case .context: nil - } + let lineKind = TextDiffView.lineKind(line.kind) + let tint = lineKind.map { lineTint(for: $0) } let statusLabel: String = switch line.kind { case .added: String(localized: "Added") case .removed: String(localized: "Removed") @@ -153,6 +152,7 @@ struct AIChatWalkthroughBlockView: View { .frame(width: 10) Text(line.text.isEmpty ? " " : line.text) .font(.system(.body, design: .monospaced)) + .diffLineForeground(lineKind) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 8) @@ -205,11 +205,12 @@ struct AIChatWalkthroughBlockView: View { private func splitRow(_ row: SplitRow, side: SqlWalkthroughAnchor.Side) -> some View { let marker = SplitDiffMarker.resolve(kind: row.kind, side: side) - let tint: Color? = switch (row.kind, side) { - case (.removed, .before), (.changed, .before): .red.opacity(0.16) - case (.added, .after), (.changed, .after): .green.opacity(0.16) + let lineKind: RowDiffKind? = switch (row.kind, side) { + case (.removed, .before), (.changed, .before): .delete + case (.added, .after), (.changed, .after): .insert default: nil } + let tint = lineKind.map { lineTint(for: $0) } return HStack(alignment: .top, spacing: 6) { if differentiateWithoutColor, let glyph = marker?.glyph { Text(verbatim: glyph) @@ -219,6 +220,7 @@ struct AIChatWalkthroughBlockView: View { } Text(row.text ?? " ") .font(.system(.body, design: .monospaced)) + .diffLineForeground(lineKind) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 8) @@ -241,10 +243,11 @@ struct AIChatWalkthroughBlockView: View { HStack(alignment: .top, spacing: 8) { Text(verbatim: "\(index + 1)") .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.tertiary) + .themeForeground(.editorLineNumber, native: .tertiary) .frame(width: 28, alignment: .trailing) Text(text.isEmpty ? " " : text) .font(.system(.body, design: .monospaced)) + .themeForeground(.editorText, native: .primary) .frame(maxWidth: .infinity, alignment: .leading) } .id(index + 1) @@ -256,7 +259,7 @@ struct AIChatWalkthroughBlockView: View { } } .frame(maxHeight: 220) - .background(Color(nsColor: .textBackgroundColor)) + .themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) .onChange(of: scrollTarget) { target in guard let target else { return } @@ -329,11 +332,11 @@ struct AIChatWalkthroughBlockView: View { private func anchoredSnippet(_ snippet: String) -> some View { Text(snippet) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) + .themeForeground(.editorText, native: .primary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) - .background(Color(nsColor: .textBackgroundColor)) + .themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 4)) } @@ -410,6 +413,14 @@ struct AIChatWalkthroughBlockView: View { // MARK: - Backgrounds + private func lineTint(for kind: RowDiffKind) -> Color { + CompareStatusStyle.lineTint(for: kind, native: Self.nativeTint(for: kind), engine: themeEngine, contrast: contrast) + } + + internal static func nativeTint(for kind: RowDiffKind) -> Color { + kind == .delete ? .red.opacity(0.16) : .green.opacity(0.16) + } + private func rowBackground(base: Color?, unified line: DiffUnifiedLine) -> Color { if let anchor = activeAnchor, unifiedLineMatches(line, anchor: anchor) { return .yellow.opacity(0.28) diff --git a/TablePro/Views/Agent/AgentProposedStatementsView.swift b/TablePro/Views/Agent/AgentProposedStatementsView.swift index 3896056b5e..e97f181b79 100644 --- a/TablePro/Views/Agent/AgentProposedStatementsView.swift +++ b/TablePro/Views/Agent/AgentProposedStatementsView.swift @@ -22,7 +22,7 @@ internal struct AgentProposedStatementsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(statements) { statement in - row(statement) + AgentProposedStatementRow(statement: statement) .contextMenu { Button(String(localized: "Copy")) { NSPasteboard.general.clearContents() @@ -33,14 +33,20 @@ internal struct AgentProposedStatementsView: View { .listStyle(.inset) } } +} + +/// Statement at the data-grid value font, metadata at `.caption`, matching the app's other list +/// of statements with state under them. A stored value drawn at a system text style is the +/// two-font-domain defect: it looks right only until the user changes one of the two settings. +internal struct AgentProposedStatementRow: View { + internal let statement: ProposedStatement - /// Statement at the data-grid value font, metadata at `.caption`, matching the app's other list - /// of statements with state under them. A stored value drawn at a system text style is the - /// two-font-domain defect: it looks right only until the user changes one of the two settings. - private func row(_ statement: ProposedStatement) -> some View { + var body: some View { VStack(alignment: .leading, spacing: 3) { Text(statement.sql) .font(ThemeEngine.shared.valueFontSwiftUI) + // The list has no selection, so a theme color here never sits on a selected-row fill. + .themeForeground(.editorText, native: .primary) .lineLimit(4) .textSelection(.enabled) HStack(spacing: 4) { diff --git a/TablePro/Views/Compare/CompareStatusStyle.swift b/TablePro/Views/Compare/CompareStatusStyle.swift index 360103f6cc..9501627aea 100644 --- a/TablePro/Views/Compare/CompareStatusStyle.swift +++ b/TablePro/Views/Compare/CompareStatusStyle.swift @@ -2,14 +2,11 @@ // CompareStatusStyle.swift // TablePro // -// One vocabulary for a comparison outcome: a word, a symbol and a tint. -// -// Colour is never the carrier on its own. Every call site pairs the tint with -// the symbol and the word, and drops the tint when the system asks for shapes -// instead of colour. The tints are system colours under every theme: a theme -// colours only the SQL editor and the data grid. +// Colour is never the carrier on its own: every call site pairs the tint with the symbol and the +// word, and drops the tint when the system asks for shapes instead of colour. // +import AppKit import SwiftUI internal enum CompareStatusStyle { @@ -101,20 +98,58 @@ internal enum CompareStatusStyle { } } - /// The tints a data grid with no theme paints an inserted, modified or deleted row with, so a - /// row difference reads the way the same row reads in the default grid. Not the grid's slots: - /// a theme stops at the grid. - internal static func rowTint(for kind: RowDiffKind) -> Color { + // MARK: - Changed lines + + /// The grid's own row tints, so a changed line reads like the same row in the grid under any theme. + internal static func rowTintColor(for kind: RowDiffKind, palette: ThemePalette) -> NSColor? { switch kind { case .insert: - return Color(nsColor: NativeThemeColors.insertedRow) + return palette.color(.gridInserted, otherwise: NativeThemeColors.insertedRow) case .update: - return Color(nsColor: NativeThemeColors.modifiedRow) + return palette.color(.gridModified, otherwise: NativeThemeColors.modifiedRow) case .delete: - return Color(nsColor: NativeThemeColors.deletedRow) + return palette.color(.gridDeleted, otherwise: NativeThemeColors.deletedRow) case .identical, .conflict: - return .clear + return nil + } + } + + internal static func rowTint(for kind: RowDiffKind, palette: ThemePalette) -> Color { + rowTintColor(for: kind, palette: palette).map { Color(nsColor: $0) } ?? .clear + } + + private static func gridSlot(for kind: RowDiffKind) -> ThemeSlot? { + switch kind { + case .insert: return .gridInserted + case .update: return .gridModified + case .delete: return .gridDeleted + case .identical, .conflict: return nil + } + } + + /// For a diff whose own tint differs from the grid's: the theme's tint wins only where it sets one. + @MainActor + internal static func lineTint( + for kind: RowDiffKind, + native: Color, + engine: ThemeEngine = .shared, + contrast: ColorSchemeContrast + ) -> Color { + guard let slot = gridSlot(for: kind) else { return native } + return ThemeShapeStyle.override(slot, engine: engine, contrast: contrast) ?? native + } + + /// A line tinted as deleted takes the grid's deleted-row text, the way that row reads in the grid. + @MainActor + internal static func lineTextOverride( + for kind: RowDiffKind?, + engine: ThemeEngine = .shared, + contrast: ColorSchemeContrast + ) -> Color? { + if kind == .delete, let deleted = ThemeShapeStyle.override(.gridDeletedText, engine: engine, contrast: contrast) { + return deleted } + return ThemeShapeStyle.override(.editorText, engine: engine, contrast: contrast) } // MARK: - Message tints @@ -123,3 +158,28 @@ internal enum CompareStatusStyle { internal static let error = Color.red internal static let success = Color.green } + +private struct DiffLineForeground: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + + let kind: RowDiffKind? + + func body(content: Content) -> some View { + content.foregroundStyle(style) + } + + private var style: AnyShapeStyle { + guard let color = CompareStatusStyle.lineTextOverride(for: kind, engine: themeEngine, contrast: contrast) else { + return AnyShapeStyle(HierarchicalShapeStyle.primary) + } + return AnyShapeStyle(color) + } +} + +internal extension View { + /// Not for a selectable row: a theme color does not flip to white on the emphasized selection. + func diffLineForeground(_ kind: RowDiffKind?) -> some View { + modifier(DiffLineForeground(kind: kind)) + } +} diff --git a/TablePro/Views/Compare/CompareSyncWindowController.swift b/TablePro/Views/Compare/CompareSyncWindowController.swift index e017fe0425..8731704687 100644 --- a/TablePro/Views/Compare/CompareSyncWindowController.swift +++ b/TablePro/Views/Compare/CompareSyncWindowController.swift @@ -51,6 +51,7 @@ internal final class CompareSyncWindowController: NSWindowController, private weak var modeControl: NSSegmentedControl? private weak var searchToolbarItem: NSSearchToolbarItem? private var renderedEndpoints: String? + private let windowBackdrop: ThemeWindowBackdrop internal static func present(prefillSource connectionId: UUID?) { let controller = controllers[connectionId] ?? CompareSyncWindowController(prefillSource: connectionId) @@ -73,6 +74,7 @@ internal final class CompareSyncWindowController: NSWindowController, window.keepsKeyViewLoopCurrent() window.isRestorable = false window.isReleasedWhenClosed = false + windowBackdrop = ThemeWindowBackdrop(window: window) super.init(window: window) applyPrefill(connectionId) diff --git a/TablePro/Views/Components/CodeWell.swift b/TablePro/Views/Components/CodeWell.swift new file mode 100644 index 0000000000..193871a0d2 --- /dev/null +++ b/TablePro/Views/Components/CodeWell.swift @@ -0,0 +1,33 @@ +// +// CodeWell.swift +// TablePro +// + +import SwiftUI + +/// Read-only code or a stored value in a rounded well, with selectable text that scrolls sideways +/// rather than wrap. It wears the editor's background and text while the active theme sets them. +internal struct CodeWell: View { + private let text: String + private let font: Font + + private static let shape = RoundedRectangle(cornerRadius: 6) + + internal init(_ text: String, font: Font) { + self.text = text + self.font = font + } + + internal var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + Text(verbatim: text) + .font(font) + .themeForeground(.editorText, native: .primary) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor), in: Self.shape) + .overlay(Self.shape.stroke(Color(nsColor: .separatorColor), lineWidth: 1)) + } +} diff --git a/TablePro/Views/Components/HighlightedSQLTextView.swift b/TablePro/Views/Components/HighlightedSQLTextView.swift index f2c5e9d4a5..f17a68b71c 100644 --- a/TablePro/Views/Components/HighlightedSQLTextView.swift +++ b/TablePro/Views/Components/HighlightedSQLTextView.swift @@ -2,9 +2,6 @@ // HighlightedSQLTextView.swift // TablePro // -// Read-only NSTextView with regex-based SQL syntax highlighting. -// Used for query previews in the history panel. -// import AppKit import Combine @@ -14,11 +11,19 @@ import TableProPluginKit /// A code view, so it wears the editor theme: background, text and syntax. The colors are the palette's dynamic ones, /// which resolve per appearance when the text view draws. struct HighlightedSQLTextView: NSViewRepresentable { + internal enum LineLayout: Equatable { + case unwrapped + case wrapped + /// Wraps, and is as tall as its text up to `maxLines`; it scrolls vertically past that. + case fitted(maxLines: Int) + } + let sql: String var databaseType: DatabaseType = .mysql /// A SwiftUI `.accessibilityIdentifier` lands on the representable's wrapper, not on the text /// view AppKit publishes, so the only way to name this element is to set it on the text view. var accessibilityIdentifier: String? + var lineLayout: LineLayout = .unwrapped @ObservedObject private var themeEngine = ThemeEngine.shared @@ -37,19 +42,51 @@ struct HighlightedSQLTextView: NSViewRepresentable { textView.textContainerInset = NSSize(width: 12, height: 12) textView.textColor = themeEngine.palette[.editorText] applyBackground(to: scrollView, textView: textView) - - // Disable line wrapping - textView.textContainer?.widthTracksTextView = false - textView.textContainer?.containerSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - textView.isHorizontallyResizable = true + applyLineLayout(to: scrollView, textView: textView) context.coordinator.redisplayOnThemeChange(scrollView) return scrollView } + func sizeThatFits(_ proposal: ProposedViewSize, nsView scrollView: NSScrollView, context: Context) -> CGSize? { + guard case .fitted(let maxLines) = lineLayout, + let width = proposal.width, width.isFinite, + let textView = scrollView.documentView as? NSTextView else { return nil } + let height = Self.fittedHeight( + of: sql, + font: themeEngine.editorFonts.font, + width: width, + inset: textView.textContainerInset, + lineFragmentPadding: textView.textContainer?.lineFragmentPadding ?? 0, + maxLines: maxLines + ) + return CGSize(width: width, height: height) + } + + internal static func fittedHeight( + of text: String, + font: NSFont, + width: CGFloat, + inset: NSSize, + lineFragmentPadding: CGFloat, + maxLines: Int + ) -> CGFloat { + let lineHeight = NSLayoutManager().defaultLineHeight(for: font) + let container = NSTextContainer(size: NSSize( + width: max(width - inset.width * 2, 1), + height: CGFloat.greatestFiniteMagnitude + )) + container.lineFragmentPadding = lineFragmentPadding + let layoutManager = NSLayoutManager() + layoutManager.addTextContainer(container) + let storage = NSTextStorage(string: text, attributes: [.font: font]) + storage.addLayoutManager(layoutManager) + layoutManager.ensureLayout(for: container) + + let textHeight = max(layoutManager.usedRect(for: container).height, lineHeight) + return min(textHeight, CGFloat(max(maxLines, 1)) * lineHeight) + inset.height * 2 + } + func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? NSTextView else { return } @@ -83,6 +120,23 @@ struct HighlightedSQLTextView: NSViewRepresentable { scrollView.backgroundColor = background } + private func applyLineLayout(to scrollView: NSScrollView, textView: NSTextView) { + guard let container = textView.textContainer else { return } + switch lineLayout { + case .unwrapped: + container.widthTracksTextView = false + container.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isHorizontallyResizable = true + case .wrapped, .fitted: + container.widthTracksTextView = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + scrollView.hasHorizontalScroller = false + // A legacy scroller that stays up while the text fits narrows it below the fitted width. + scrollView.autohidesScrollers = true + } + } + private var themeRevision: Int { themeEngine.revision } @@ -110,12 +164,9 @@ struct HighlightedSQLTextView: NSViewRepresentable { // MARK: - Syntax Highlighting - // MARK: - Pre-compiled Syntax Patterns - private static let syntaxPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { var patterns: [(NSRegularExpression, ThemeSlot)] = [] - // SQL Keywords (blue) — single alternation regex for all keywords let keywords = [ "CREATE", "TABLE", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "NOT", "NULL", "DEFAULT", "UNIQUE", "INDEX", "AUTO_INCREMENT", @@ -133,17 +184,14 @@ struct HighlightedSQLTextView: NSViewRepresentable { patterns.append((regex, .syntaxKeyword)) } - // Strings (red) if let regex = try? NSRegularExpression(pattern: "'[^']*'", options: .caseInsensitive) { patterns.append((regex, .syntaxString)) } - // Backticks (orange) if let regex = try? NSRegularExpression(pattern: "`[^`]*`", options: .caseInsensitive) { patterns.append((regex, .syntaxNull)) } - // Numbers (purple) if let regex = try? NSRegularExpression(pattern: "\\b\\d+\\b", options: .caseInsensitive) { patterns.append((regex, .syntaxNumber)) } @@ -151,12 +199,9 @@ struct HighlightedSQLTextView: NSViewRepresentable { return patterns }() - // MARK: - Pre-compiled MQL Syntax Patterns - private static let mqlPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { var patterns: [(NSRegularExpression, ThemeSlot)] = [] - // MongoDB methods (blue) — single alternation regex for all methods let methods = [ "find", "findOne", "insertOne", "insertMany", "updateOne", "updateMany", "deleteOne", "deleteMany", "aggregate", "countDocuments", "estimatedDocumentCount", @@ -168,12 +213,10 @@ struct HighlightedSQLTextView: NSViewRepresentable { patterns.append((regex, .syntaxKeyword)) } - // db. prefix (blue) if let regex = try? NSRegularExpression(pattern: "\\bdb\\.", options: []) { patterns.append((regex, .syntaxKeyword)) } - // MongoDB operators $gt, $lt, $in, etc. (teal) if let regex = try? NSRegularExpression( pattern: "\"\\$(gt|gte|lt|lte|eq|ne|in|nin|and|or|not|nor|exists|type|regex|options|" + "set|unset|inc|push|pull|addToSet|each|match|group|project|sort|limit|skip|" @@ -184,17 +227,14 @@ struct HighlightedSQLTextView: NSViewRepresentable { patterns.append((regex, .syntaxType)) } - // Strings (red) if let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"", options: []) { patterns.append((regex, .syntaxString)) } - // Numbers (purple) if let regex = try? NSRegularExpression(pattern: "\\b\\d+\\.?\\d*\\b", options: []) { patterns.append((regex, .syntaxNumber)) } - // Booleans and null (orange) if let regex = try? NSRegularExpression(pattern: "\\b(true|false|null)\\b", options: []) { patterns.append((regex, .syntaxNull)) } @@ -210,12 +250,10 @@ struct HighlightedSQLTextView: NSViewRepresentable { textStorage.beginEditing() - // Reset to base style let font = ThemeEngine.shared.editorFonts.font textStorage.addAttribute(.font, value: font, range: fullRange) textStorage.addAttribute(.foregroundColor, value: ThemeEngine.shared.palette[.editorText], range: fullRange) - // Apply pre-compiled patterns let activePatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] switch PluginManager.shared.editorLanguage(for: databaseType) { case .javascript: diff --git a/TablePro/Views/Components/MagnifiableCanvasView.swift b/TablePro/Views/Components/MagnifiableCanvasView.swift index 934eb7687e..a474e9151a 100644 --- a/TablePro/Views/Components/MagnifiableCanvasView.swift +++ b/TablePro/Views/Components/MagnifiableCanvasView.swift @@ -29,6 +29,11 @@ final class DiagramViewportController: ObservableObject { scrollView?.documentVisibleRect ?? .zero } + /// What an export renders in, so it shows the colors the canvas shows. + var effectiveAppearance: NSAppearance { + scrollView?.effectiveAppearance ?? NSApp?.effectiveAppearance ?? NSAppearance.currentDrawing() + } + var canZoomIn: Bool { DiagramZoom.canStepUp(from: magnification) } var canZoomOut: Bool { DiagramZoom.canStepDown(from: magnification) } @@ -207,6 +212,7 @@ struct MagnifiableCanvasView: NSViewRepresentable { context.coordinator.document = document context.coordinator.viewport = viewport + context.coordinator.backdrop = DiagramCanvasBackdrop(scrollView: scrollView) viewport.attach(to: scrollView) return scrollView } @@ -230,6 +236,7 @@ struct MagnifiableCanvasView: NSViewRepresentable { coordinator.viewport?.detach(from: scrollView) coordinator.viewport = nil coordinator.document = nil + coordinator.backdrop = nil } } @@ -252,5 +259,48 @@ struct MagnifiableCanvasView: NSViewRepresentable { final class Coordinator { var document: Document? var viewport: DiagramViewportController? + var backdrop: DiagramCanvasBackdrop? + } +} + +/// The canvas draws no background, so the pane behind it shows through. A theme that sets the window +/// background paints it here as well, the same color a diagram export fills behind its tables. +@MainActor +final class DiagramCanvasBackdrop { + private weak var scrollView: NSScrollView? + private var original: (color: NSColor, drawsBackground: Bool)? + private var themeCancellable: AnyCancellable? + + init(scrollView: NSScrollView) { + self.scrollView = scrollView + apply() + themeCancellable = AppEvents.shared.themeChanged + .sink { [weak self] _ in self?.apply() } + } + + var isThemed: Bool { + original != nil + } + + func apply() { + guard let scrollView else { return } + let appearance = scrollView.effectiveAppearance + let theme = ThemeEngine.shared.pair[ThemeAppearance(matching: appearance)] + guard theme.isOverridden(.windowBackdrop, in: appearance) else { + restore(scrollView) + return + } + if original == nil { + original = (scrollView.backgroundColor, scrollView.drawsBackground) + } + scrollView.backgroundColor = ThemeEngine.shared.palette[.windowBackdrop] + scrollView.drawsBackground = true + } + + private func restore(_ scrollView: NSScrollView) { + guard let original else { return } + scrollView.backgroundColor = original.color + scrollView.drawsBackground = original.drawsBackground + self.original = nil } } diff --git a/TablePro/Views/ConnectionForm/Components/StartupCommandsEditor.swift b/TablePro/Views/ConnectionForm/Components/StartupCommandsEditor.swift index 31584fe217..b66c796051 100644 --- a/TablePro/Views/ConnectionForm/Components/StartupCommandsEditor.swift +++ b/TablePro/Views/ConnectionForm/Components/StartupCommandsEditor.swift @@ -14,13 +14,16 @@ import SwiftUI /// inside a form to behave. Startup Commands and Pre-Connect Script trapped Tab; AI Rules did not. struct StartupCommandsEditor: View { @Binding var text: String + /// `.code` for SQL and shell, which wear the editor's colors; prose stays `.plain`. + let style: TextValueEditor.Style var body: some View { TextValueEditor( text: $text, font: .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), borderType: .bezelBorder, - movesFocusOnTab: true + movesFocusOnTab: true, + style: style ) } } diff --git a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift index d0564e7a4b..c8e4058493 100644 --- a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -181,7 +181,7 @@ struct OptionsPaneView: View { private var startupSection: some View { Section { - StartupCommandsEditor(text: $coordinator.advanced.startupCommands) + StartupCommandsEditor(text: $coordinator.advanced.startupCommands, style: .code) .frame(height: 80) .accessibilityIdentifier("connection-form-startup-commands") } header: { @@ -195,7 +195,7 @@ struct OptionsPaneView: View { private var preConnectSection: some View { Section { - StartupCommandsEditor(text: $coordinator.advanced.preConnectScript) + StartupCommandsEditor(text: $coordinator.advanced.preConnectScript, style: .code) .frame(height: 80) } header: { Text(String(localized: "Pre-Connect Script")) @@ -270,7 +270,7 @@ struct OptionsPaneView: View { private var aiRulesSection: some View { Section { - StartupCommandsEditor(text: $coordinator.aiRules.rules) + StartupCommandsEditor(text: $coordinator.aiRules.rules, style: .plain) .frame(height: 120) } header: { Text(String(localized: "AI Rules")) diff --git a/TablePro/Views/DataFiles/DataFileContentView.swift b/TablePro/Views/DataFiles/DataFileContentView.swift index 30aad98130..cb5073255f 100644 --- a/TablePro/Views/DataFiles/DataFileContentView.swift +++ b/TablePro/Views/DataFiles/DataFileContentView.swift @@ -27,8 +27,9 @@ struct DataFileContentView: View { } Divider() DataFileStatusBar(controller: controller) + .modifier(DetailPaneStatusBarMaterial()) } - .background(Color(nsColor: .windowBackgroundColor)) + .themeBackground(.windowBackdrop, native: Color(nsColor: .windowBackgroundColor)) } @ViewBuilder @@ -89,7 +90,7 @@ struct DataFileContentView: View { systemImage: hasRows ? "line.3.horizontal.decrease.circle" : "doc" ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) + .themeBackground(.gridBackground, native: Color(nsColor: .textBackgroundColor)) } private var configuration: DataGridConfiguration { diff --git a/TablePro/Views/DataFiles/DataFileWindowController.swift b/TablePro/Views/DataFiles/DataFileWindowController.swift index 53cb57bc5a..05c5ca60f7 100644 --- a/TablePro/Views/DataFiles/DataFileWindowController.swift +++ b/TablePro/Views/DataFiles/DataFileWindowController.swift @@ -24,6 +24,7 @@ final class DataFileWindowController: NSWindowController, NSWindowDelegate, NSTo private let splitController: DataFileSplitViewController private var searchItem: NSSearchToolbarItem? private var cancellables: Set = [] + private let windowBackdrop: ThemeWindowBackdrop init(document: DataFileDocument) { let window = NSWindow( @@ -41,6 +42,7 @@ final class DataFileWindowController: NSWindowController, NSWindowDelegate, NSTo splitController = DataFileSplitViewController(document: document) dataFileDocument = document + windowBackdrop = ThemeWindowBackdrop(window: window) super.init(window: window) shouldCloseDocument = true window.delegate = self diff --git a/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift b/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift index 5da1809663..c5f2a6dd97 100644 --- a/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift +++ b/TablePro/Views/ERDiagram/ERDiagramEdgeRenderer.swift @@ -17,11 +17,9 @@ enum ERDiagramEdgeRenderer { edges: [EREdge], nodeRects: [UUID: CGRect], nodeIndex: [String: UUID], + strokeColor: CGColor, in context: CGContext ) { - let strokeColor = NSColor.secondaryLabelColor.withAlphaComponent(0.7) - - // Resolve edges to IDs and rects, assign port indices sorted by X to minimize crossings let resolved: [ResolvedEdge] = edges.compactMap { edge -> ResolvedEdge? in guard let fromId = nodeIndex[edge.fromTable], let toId = nodeIndex[edge.toTable], @@ -49,7 +47,7 @@ enum ERDiagramEdgeRenderer { dstCounts[item.toId, default: 0] += 1 } - // Group by source, sort each group by destination X → left dest gets left port + // Ports ordered by the far end's X keep a node's edges from crossing each other. var edgesBySource: [UUID: [ResolvedEdge]] = [:] var edgesByDest: [UUID: [ResolvedEdge]] = [:] for item in pairs { @@ -63,7 +61,6 @@ enum ERDiagramEdgeRenderer { edgesByDest[key]?.sort { $0.fromRect.midX < $1.fromRect.midX } } - // Build port indices from sorted order var srcPortIndex: [String: Int] = [:] var dstPortIndex: [String: Int] = [:] for (_, group) in edgesBySource { @@ -78,7 +75,7 @@ enum ERDiagramEdgeRenderer { } context.saveGState() - context.setStrokeColor(strokeColor.cgColor) + context.setStrokeColor(strokeColor) context.setLineWidth(strokeWidth) context.setLineCap(.round) context.setLineJoin(.round) diff --git a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift index fc2c5e085a..fc1ef84758 100644 --- a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift +++ b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift @@ -45,28 +45,35 @@ enum ERDiagramNodeRenderer { rect: CGRect, isSelected: Bool, clusterColor: NSColor?, + palette: ERDiagramPalette, in context: CGContext ) { let scale = ERDiagramLayout.typeScale let body = CGPath(roundedRect: rect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) context.addPath(body) - context.setFillColor(NSColor.controlBackgroundColor.cgColor) + context.setFillColor(palette.nodeFill) context.fillPath() let headerHeight = ERDiagramLayout.headerHeight let headerRect = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: headerHeight) - let headerTint = clusterColor ?? NSColor.controlAccentColor context.saveGState() context.addPath(body) context.clip() - context.setFillColor(headerTint.withAlphaComponent(clusterColor == nil ? 0.15 : 0.22).cgColor) - context.fill(headerRect) + if let headerFill = palette.headerFill { + context.setFillColor(headerFill) + context.fill(headerRect) + } + // A cluster's hue groups related tables, so it still washes a themed header. + if palette.headerFill == nil || clusterColor != nil { + context.setFillColor(palette.headerWash(cluster: clusterColor)) + context.fill(headerRect) + } context.restoreGState() context.addPath(body) - context.setStrokeColor((isSelected ? NSColor.controlAccentColor : NSColor.tertiaryLabelColor).cgColor) + context.setStrokeColor(isSelected ? palette.selectionRule : palette.rule) context.setLineWidth(isSelected ? 2 : 1) context.strokePath() @@ -76,7 +83,7 @@ enum ERDiagramNodeRenderer { ERDiagramTextRenderer.draw( displayName, font: .monospacedSystemFont(ofSize: headerPointSize * scale, weight: .semibold), - color: .labelColor, + color: palette.headerText, at: CGPoint(x: rect.minX + headerTextXOffset, y: rect.minY + headerHeight / 2), anchor: .leading, in: context @@ -85,13 +92,13 @@ enum ERDiagramNodeRenderer { ERDiagramSymbolRenderer.draw( named: node.isJunctionTable ? "arrow.left.arrow.right" : "tablecells", pointSize: iconPointSize * scale, - color: .secondaryLabelColor, + color: palette.secondaryText, at: CGPoint(x: rect.minX + iconXOffset, y: rect.minY + headerHeight / 2), anchor: .leading ) let dividerY = rect.minY + headerHeight - context.setStrokeColor(NSColor.tertiaryLabelColor.cgColor) + context.setStrokeColor(palette.rule) context.setLineWidth(0.5) context.move(to: CGPoint(x: rect.minX, y: dividerY)) context.addLine(to: CGPoint(x: rect.maxX, y: dividerY)) @@ -100,7 +107,7 @@ enum ERDiagramNodeRenderer { context.saveGState() context.addPath(body) context.clip() - drawColumns(node: node, rect: rect, dividerY: dividerY, scale: scale, in: context) + drawColumns(node: node, rect: rect, dividerY: dividerY, scale: scale, palette: palette, in: context) context.restoreGState() } @@ -109,6 +116,7 @@ enum ERDiagramNodeRenderer { rect: CGRect, dividerY: CGFloat, scale: CGFloat, + palette: ERDiagramPalette, in context: CGContext ) { let rowHeight = ERDiagramLayout.columnRowHeight @@ -140,7 +148,7 @@ enum ERDiagramNodeRenderer { ERDiagramTextRenderer.draw( column.name, font: nameFont, - color: .labelColor, + color: palette.text, at: CGPoint(x: rect.minX + columnNameXOffset, y: rowY), anchor: .leading, maxWidth: widths.name, @@ -150,7 +158,7 @@ enum ERDiagramNodeRenderer { ERDiagramTextRenderer.draw( column.dataType, font: typeFont, - color: .secondaryLabelColor, + color: palette.secondaryText, at: CGPoint(x: rect.maxX - typeRightMargin, y: rowY), anchor: .trailing, maxWidth: widths.type, diff --git a/TablePro/Views/ERDiagram/ERDiagramPalette.swift b/TablePro/Views/ERDiagram/ERDiagramPalette.swift new file mode 100644 index 0000000000..ea35a80962 --- /dev/null +++ b/TablePro/Views/ERDiagram/ERDiagramPalette.swift @@ -0,0 +1,72 @@ +import AppKit + +/// Key, link and cluster hues carry meaning, so they stay system colors under a theme. +@MainActor +internal struct ERDiagramPalette { + internal let appearance: NSAppearance + internal let backdrop: CGColor + internal let nodeFill: CGColor + /// Nil keeps the accent wash a table header has always had. + internal let headerFill: CGColor? + internal let rule: CGColor + internal let selectionRule: CGColor + internal let edge: CGColor + internal let text: NSColor + internal let headerText: NSColor + internal let secondaryText: NSColor + + private let accentWash: CGColor + private let clusterWashes: [NSColor: CGColor] + + private static let accentWashAlpha: CGFloat = 0.15 + private static let clusterWashAlpha: CGFloat = 0.22 + private static let edgeAlpha: CGFloat = 0.7 + + internal init(appearance: NSAppearance) { + self.appearance = appearance + let theme = ThemeEngine.shared.pair[ThemeAppearance(matching: appearance)] + + func color(_ slot: ThemeSlot, otherwise native: NSColor) -> NSColor { + theme.isOverridden(slot, in: appearance) ? theme.color(for: slot) : native + } + + backdrop = Self.bake(color(.windowBackdrop, otherwise: .controlBackgroundColor), in: appearance) + nodeFill = Self.bake(color(.gridBackground, otherwise: .controlBackgroundColor), in: appearance) + headerFill = theme.isOverridden(.gridHeaderBackground, in: appearance) + ? Self.bake(theme.color(for: .gridHeaderBackground), in: appearance) + : nil + rule = Self.bake(color(.gridLine, otherwise: .tertiaryLabelColor), in: appearance) + // The grid outlines its own selection with the focus border, not the row fill. + selectionRule = Self.bake(color(.gridFocusBorder, otherwise: .controlAccentColor), in: appearance) + edge = Self.wash(.secondaryLabelColor, alpha: Self.edgeAlpha, in: appearance) + text = color(.gridText, otherwise: .labelColor) + headerText = color(.gridHeaderText, otherwise: .labelColor) + secondaryText = .secondaryLabelColor + + accentWash = Self.wash(.controlAccentColor, alpha: Self.accentWashAlpha, in: appearance) + var washes: [NSColor: CGColor] = [:] + for cluster in ERClusterPalette.colors { + washes[cluster] = Self.wash(cluster, alpha: Self.clusterWashAlpha, in: appearance) + } + clusterWashes = washes + } + + internal func headerWash(cluster: NSColor?) -> CGColor { + guard let cluster else { return accentWash } + return clusterWashes[cluster] ?? Self.wash(cluster, alpha: Self.clusterWashAlpha, in: appearance) + } + + private static func bake(_ color: NSColor, in appearance: NSAppearance) -> CGColor { + var result: CGColor? + appearance.performAsCurrentDrawingAppearance { + result = color.cgColor + } + return result ?? color.cgColor + } + + /// The alpha goes on the resolved color, so the system color itself is never frozen. + private static func wash(_ color: NSColor, alpha: CGFloat, in appearance: NSAppearance) -> CGColor { + let resolved = bake(color, in: appearance) + return resolved.copy(alpha: alpha) ?? resolved + } +} diff --git a/TablePro/Views/ERDiagram/ERDiagramScene.swift b/TablePro/Views/ERDiagram/ERDiagramScene.swift index 5e489bae54..060344993b 100644 --- a/TablePro/Views/ERDiagram/ERDiagramScene.swift +++ b/TablePro/Views/ERDiagram/ERDiagramScene.swift @@ -41,11 +41,12 @@ enum ERDiagramSceneRenderer { /// The context is expected to be flipped, which is what both callers hand it: the diagram view /// is `isFlipped`, and the export builds a flipped `NSGraphicsContext` over its bitmap. - static func draw(_ scene: ERDiagramScene, dirtyRect: CGRect, in context: CGContext) { + static func draw(_ scene: ERDiagramScene, palette: ERDiagramPalette, dirtyRect: CGRect, in context: CGContext) { ERDiagramEdgeRenderer.drawEdges( edges: scene.edges, nodeRects: scene.nodeRects, nodeIndex: scene.nodeIndex, + strokeColor: palette.edge, in: context ) @@ -56,6 +57,7 @@ enum ERDiagramSceneRenderer { rect: rect, isSelected: scene.selectedNodeId == node.id, clusterColor: scene.clusterColors[node.id], + palette: palette, in: context ) } @@ -64,9 +66,9 @@ enum ERDiagramSceneRenderer { /// Renders at natural scale whatever the viewport is doing, cropped to the nodes rather than to /// the canvas, which is what the export has always promised. /// - /// `NSColor` resolves against the appearance that is current while drawing, and an offscreen - /// bitmap has none of its own, so the caller's appearance is made current for the whole render - /// or a dark window exports light chrome. + /// An offscreen bitmap has no appearance of its own, so the canvas's appearance is made current + /// for the whole render, and the palette is baked in it, or a dark or themed window exports in + /// other colors than the screen shows. static func image(_ scene: ERDiagramScene, appearance: NSAppearance, scale: CGFloat) -> NSImage? { let bounds = drawnBounds(of: scene) let size = bounds.isNull @@ -99,8 +101,9 @@ enum ERDiagramSceneRenderer { cgContext.scaleBy(x: 1, y: -1) NSGraphicsContext.current = NSGraphicsContext(cgContext: cgContext, flipped: true) + let palette = ERDiagramPalette(appearance: appearance) appearance.performAsCurrentDrawingAppearance { - cgContext.setFillColor(NSColor.controlBackgroundColor.cgColor) + cgContext.setFillColor(palette.backdrop) cgContext.fill(CGRect(origin: .zero, size: size)) cgContext.saveGState() @@ -109,7 +112,7 @@ enum ERDiagramSceneRenderer { } var flat = scene flat.selectedNodeId = nil - draw(flat, dirtyRect: bounds.isNull ? .infinite : bounds, in: cgContext) + draw(flat, palette: palette, dirtyRect: bounds.isNull ? .infinite : bounds, in: cgContext) cgContext.restoreGState() } diff --git a/TablePro/Views/ERDiagram/ERDiagramSceneView.swift b/TablePro/Views/ERDiagram/ERDiagramSceneView.swift index 4257b5bccd..f8492f4814 100644 --- a/TablePro/Views/ERDiagram/ERDiagramSceneView.swift +++ b/TablePro/Views/ERDiagram/ERDiagramSceneView.swift @@ -24,6 +24,7 @@ // import AppKit +import Combine /// Everything the canvas can ask of the diagram, in one place, so the view that owns the pointer /// never reaches into a model of its own. @@ -69,6 +70,15 @@ final class ERDiagramSceneView: NSView { private var press: Press? private var isShowingHandCursor = false private let accessibilityTree = ERDiagramAccessibilityTree() + private var bakedPalette: ERDiagramPalette? + private var themeCancellable: AnyCancellable? + + var palette: ERDiagramPalette { + if let bakedPalette { return bakedPalette } + let baked = ERDiagramPalette(appearance: effectiveAppearance) + bakedPalette = baked + return baked + } override var isFlipped: Bool { true } @@ -88,6 +98,8 @@ final class ERDiagramSceneView: NSView { owner: self, userInfo: nil )) + themeCancellable = AppEvents.shared.themeChanged + .sink { [weak self] _ in self?.discardPalette() } } @available(*, unavailable) @@ -97,7 +109,17 @@ final class ERDiagramSceneView: NSView { override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } - ERDiagramSceneRenderer.draw(scene, dirtyRect: dirtyRect, in: context) + ERDiagramSceneRenderer.draw(scene, palette: palette, dirtyRect: dirtyRect, in: context) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + discardPalette() + } + + private func discardPalette() { + bakedPalette = nil + needsDisplay = true } /// An ER diagram tab has nothing else to type into, so the canvas takes focus when nothing holds diff --git a/TablePro/Views/ERDiagram/ERDiagramView.swift b/TablePro/Views/ERDiagram/ERDiagramView.swift index 2688aca228..84901dff99 100644 --- a/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -4,7 +4,6 @@ import SwiftUI struct ERDiagramView: View { @ObservedObject var viewModel: ERDiagramViewModel @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor - @Environment(\.colorScheme) private var colorScheme private var viewport: DiagramViewportController { viewModel.viewport } @@ -116,7 +115,7 @@ struct ERDiagramView: View { private func exportImage() -> NSImage? { ERDiagramSceneRenderer.image( scene, - appearance: NSAppearance(named: colorScheme == .dark ? .darkAqua : .aqua) ?? NSApp.effectiveAppearance, + appearance: viewport.effectiveAppearance, scale: DiagramImageExporter.renderScale ) } diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 1f98bcfc05..79b8a86560 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -31,7 +31,7 @@ struct FilterPanelView: View { filterList } } - .background(Color(nsColor: .windowBackgroundColor)) + .themeBackground(.windowBackdrop, native: Color(nsColor: .windowBackgroundColor)) .focusSection() .onExitCommand { closePanelAndFocusGrid() @@ -102,7 +102,7 @@ struct FilterPanelView: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(Color(nsColor: .controlBackgroundColor)) + .themeBackground(.windowBackdrop, native: Color(nsColor: .controlBackgroundColor)) .contentShape(Rectangle()) .alert(String(localized: "Save Filter Preset"), isPresented: $showSavePresetAlert) { TextField(String(localized: "Preset Name"), text: $newPresetName) diff --git a/TablePro/Views/Filter/SQLPreviewSheet.swift b/TablePro/Views/Filter/SQLPreviewSheet.swift index 0236ba5fc5..f00b755ca3 100644 --- a/TablePro/Views/Filter/SQLPreviewSheet.swift +++ b/TablePro/Views/Filter/SQLPreviewSheet.swift @@ -16,20 +16,13 @@ struct SQLPreviewSheet: View { .font(.body.weight(.semibold)) .frame(maxWidth: .infinity, alignment: .leading) - ScrollView { - Text(sql.isEmpty ? "(no conditions)" : sql) - .font(.system(.callout, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - } - .frame(maxHeight: 180) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) + HighlightedSQLTextView(sql: sql.isEmpty ? "(no conditions)" : sql, lineLayout: .wrapped) + .frame(maxHeight: 180) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) HStack { Button(action: copyToClipboard) { diff --git a/TablePro/Views/Main/Child/GridStandInBackground.swift b/TablePro/Views/Main/Child/GridStandInBackground.swift new file mode 100644 index 0000000000..e101836d2f --- /dev/null +++ b/TablePro/Views/Main/Child/GridStandInBackground.swift @@ -0,0 +1,38 @@ +// +// GridStandInBackground.swift +// TablePro +// + +import SwiftUI + +internal extension QueryResultContent { + /// A diagram, chart, map, tree or structure view draws its own surface. + var standsInForGrid: Bool { + switch self { + case .idle, .executing, .noRows, .statementSucceeded, .serverOutput, .unavailable: + return true + case .structure, .queryPlan, .chart, .map, .json, .grid: + return false + } + } +} + +/// Clear unthemed, so the pane keeps its backdrop; under a theme an empty result keeps the grid's color. +private struct GridStandInBackground: ViewModifier { + let isActive: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if isActive { + content.themeBackground(.gridBackground, native: Color.clear, ignoresSafeAreaEdges: []) + } else { + content + } + } +} + +internal extension View { + func gridStandInBackground(isActive: Bool = true) -> some View { + modifier(GridStandInBackground(isActive: isActive)) + } +} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 0f13063ca8..47a95e2e64 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -32,6 +32,47 @@ internal enum ContainerChangeRerunPolicy { } } +/// Before macOS 26 the toolbar and sidebar keep the system color, so a themed backdrop stops below the toolbar. +@MainActor +internal enum DetailPaneBackdrop { + internal static var extendsUnderToolbar: Bool { + if #available(macOS 26.0, *) { return true } + return false + } + + internal static func isThemed(engine: ThemeEngine = .shared, contrast: ColorSchemeContrast) -> Bool { + ThemeShapeStyle.override(.windowBackdrop, engine: engine, contrast: contrast) != nil + } + + internal static func safeAreaEdges(isThemed: Bool, extendsUnderToolbar: Bool) -> Edge.Set { + isThemed && !extendsUnderToolbar ? Edge.Set.all.subtracting(.top) : .all + } +} + +internal struct DetailPaneBackdropFill: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + internal var extendsUnderToolbar = DetailPaneBackdrop.extendsUnderToolbar + + internal func body(content: Content) -> some View { + let isThemed = DetailPaneBackdrop.isThemed(engine: themeEngine, contrast: contrast) + return content.background( + ThemeShapeStyle.style(.windowBackdrop, native: .background, engine: themeEngine, contrast: contrast), + ignoresSafeAreaEdges: DetailPaneBackdrop.safeAreaEdges(isThemed: isThemed, extendsUnderToolbar: extendsUnderToolbar) + ) + } +} + +/// The bar's material does not follow a custom window color, so over a themed backdrop it draws none. +internal struct DetailPaneStatusBarMaterial: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + + internal func body(content: Content) -> some View { + content.statusBarDrawsMaterial(!DetailPaneBackdrop.isThemed(engine: themeEngine, contrast: contrast)) + } +} + struct MainEditorContentView: View { @ObservedObject private var schemaService = SchemaService.shared @ObservedObject private var licenseManager = LicenseManager.shared @@ -133,7 +174,7 @@ struct MainEditorContentView: View { HistoryPanelView(coordinator: coordinator) } ) - .background(.background) + .modifier(DetailPaneBackdropFill()) .onAppear { if historyState.isVisible { if #available(macOS 14.0, *) { @@ -422,8 +463,7 @@ struct MainEditorContentView: View { } /// Read from the tab's own scope, the same value completion resolves against, so the control - /// and the suggestions can never describe different databases. Sequel Ace ships that - /// divergence: its tab title names one database while the tab queries another (#1396, #1806). + /// and the suggestions can never describe different databases. private func containerName(for tab: QueryTab) -> String { if let scoped = coordinator.scope(for: tab)?.database, !scoped.isEmpty { return scoped } let bound = tab.tableContext.databaseName @@ -751,6 +791,7 @@ struct MainEditorContentView: View { } resultContent(presentation.content, tab: tab, rows: rows) + .gridStandInBackground(isActive: presentation.content.standsInForGrid) if presentation.showsStatusBar { statusBar(tab: tab) @@ -767,7 +808,7 @@ struct MainEditorContentView: View { ) -> some View { switch content { case .idle: - Spacer() + Color.clear case .executing: ExecutingResultPane() case let .structure(tableName): @@ -1150,6 +1191,7 @@ struct MainEditorContentView: View { onStructureAdd: { coordinator.structureActions?.addRow?() }, onStructureRemove: { coordinator.structureActions?.removeRow?() } ) + .modifier(DetailPaneStatusBarMaterial()) } /// Empty unless the resolver says the chooser is on screen, so the bar and the pane agree on diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index 74489347b3..c2be1fd6ae 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -496,14 +496,19 @@ private struct EditorTabStripNewButton: View { } } -private extension View { - /// An opaque tone rather than a wash, for the same reason the system's own track is one: a - /// wash tints whatever is behind it, and the track has to stay below the selected tab whatever - /// that happens to be. The system's own tab bar measures a track of rgb(232) in light against - /// this fill's rgb(220), and rgb(71) in dark against this fill's rgb(70). - func trackSurface(rowCount: Int) -> some View { +/// An opaque tone rather than a wash, for the same reason the system's own track is one: a +/// wash tints whatever is behind it, and the track has to stay below the selected tab whatever +/// that happens to be. The system's own tab bar measures a track of rgb(232) in light against +/// this fill's rgb(220), and rgb(71) in dark against this fill's rgb(70). +internal struct EditorTabStripTrackSurface: ViewModifier { + /// The fill reads the theme's window color, which an edit in place changes without rebuilding the strip. + @ObservedObject private var themeEngine = ThemeEngine.shared + + internal let rowCount: Int + + internal func body(content: Content) -> some View { let shape = EditorTabStripLayout.trackShape(forRowCount: rowCount) - return background( + return content.background( shape .fill(EditorTabStripPalette.trackFill) .overlay( @@ -515,6 +520,12 @@ private extension View { ) ) } +} + +private extension View { + func trackSurface(rowCount: Int) -> some View { + modifier(EditorTabStripTrackSurface(rowCount: rowCount)) + } /// The selected tab is the one pane of glass the system raises out of the track. An unselected /// tab is not inert either: hovering fills it so the pointer has something to land on, and a diff --git a/TablePro/Views/Main/EditorTabStripSurfaces.swift b/TablePro/Views/Main/EditorTabStripSurfaces.swift index a07dc6295d..b3f2ace3a6 100644 --- a/TablePro/Views/Main/EditorTabStripSurfaces.swift +++ b/TablePro/Views/Main/EditorTabStripSurfaces.swift @@ -15,7 +15,22 @@ import SwiftUI internal enum EditorTabStripPalette { /// Measured at rgb(220) light and rgb(70) dark. The system's own tab track measures rgb(232) /// and rgb(71), so this is within a level of it in dark and slightly deeper in light. - internal static var trackFill: Color { Color(nsColor: .unemphasizedSelectedContentBackgroundColor) } + @MainActor internal static var trackFill: Color { + Color(nsColor: trackColor(followsWindowBackdrop: followsWindowBackdrop)) + } + + /// The track sits in the titlebar, which takes the theme's window color only where `ThemeWindowBackdrop` sets it. + internal static var followsWindowBackdrop: Bool { + if #available(macOS 26.0, *) { return true } + return false + } + + @MainActor + internal static func trackColor(followsWindowBackdrop: Bool, engine: ThemeEngine = .shared) -> NSColor { + let native = NSColor.unemphasizedSelectedContentBackgroundColor + guard followsWindowBackdrop else { return native } + return engine.palette.color(.windowBackdrop, otherwise: native) + } /// Opaque white in light, and white at alpha 0.247 in dark. That asymmetry is the whole reason /// the selection cannot invert: in light it is the ceiling, so no track can rise above it, and diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index c972205bdf..d07005c633 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -246,6 +246,7 @@ struct MainContentView: View { operation: operation, tableName: tableName, databaseName: database ?? coordinator.browseDatabaseName, + databaseType: connection.type, preview: { options in coordinator.maintenancePreview( operation: operation, diff --git a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift index ef3028c3a8..863f9c5cb0 100644 --- a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift +++ b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift @@ -185,7 +185,7 @@ struct ObjectSourceTabView: View { case .loading: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) + .themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor)) case .failed(let message): UnavailableStateView { Label("Source Unavailable", systemImage: "exclamationmark.triangle") @@ -196,7 +196,7 @@ struct ObjectSourceTabView: View { Task { await loader.load() } } } - .background(Color(nsColor: .textBackgroundColor)) + .themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor)) case .loaded: ObjectSourceView( source: loader.source, diff --git a/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift b/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift index 707a3e14c6..ff58040dc8 100644 --- a/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift +++ b/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift @@ -19,7 +19,7 @@ struct QueryPlanDetailPane: View { } description: { Text(String(localized: "Select a step in the plan to see what it does.")) } - .background(Color(nsColor: .controlBackgroundColor)) + .themeBackground(.gridBackground, native: Color(nsColor: .controlBackgroundColor)) } } @@ -29,6 +29,7 @@ struct QueryPlanDetailPane: View { HStack(spacing: 6) { Text(node.operation) .font(.headline) + .themeForeground(.gridText, native: .primary) Spacer(minLength: 8) Button { ClipboardService.shared.writeText(QueryPlanNodeSummary.text(for: node)) @@ -47,7 +48,7 @@ struct QueryPlanDetailPane: View { .padding(12) .frame(maxWidth: .infinity, alignment: .leading) } - .background(Color(nsColor: .controlBackgroundColor)) + .themeBackground(.gridBackground, native: Color(nsColor: .controlBackgroundColor)) .accessibilityIdentifier("query-plan-detail-pane") } @@ -98,6 +99,7 @@ struct QueryPlanDetailPane: View { .frame(width: 90, alignment: .trailing) Text(value) .font(.system(.caption, design: .monospaced)) + .themeForeground(.gridText, native: .primary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift index ac59be7f71..9bde2d9607 100644 --- a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift @@ -10,6 +10,7 @@ import SwiftUI struct QueryPlanDiagramView: View { @Binding var selectedNodeId: UUID? + @Environment(\.colorScheme) private var colorScheme /// Owned by the plan's `QueryPlanViewState`, because this view goes away on every mode switch. let viewport: DiagramViewportController @@ -69,9 +70,19 @@ struct QueryPlanDiagramView: View { ) } - /// A non-interactive copy at natural scale, so an export never captures the current zoom, - /// scroll offset or selection. - private var exportCanvas: some View { + private var exportCanvas: QueryPlanDiagramExportCanvas { + QueryPlanDiagramExportCanvas(layout: layout, colorScheme: colorScheme) + } +} + +/// A non-interactive copy at natural scale, so an export never captures the current zoom, scroll +/// offset or selection. `ImageRenderer` draws in the light scheme whatever the window shows, so the +/// canvas's own scheme is passed in. +struct QueryPlanDiagramExportCanvas: View { + let layout: QueryPlanDiagramLayout + let colorScheme: ColorScheme + + var body: some View { ZStack(alignment: .topLeading) { Canvas { context, _ in for arrow in layout.arrows { @@ -91,7 +102,8 @@ struct QueryPlanDiagramView: View { } } .frame(width: layout.canvasSize.width, height: layout.canvasSize.height) - .background(Color(nsColor: .controlBackgroundColor)) + .themeBackground(.windowBackdrop, native: Color(nsColor: .controlBackgroundColor)) + .environment(\.colorScheme, colorScheme) } } @@ -175,6 +187,8 @@ struct QueryPlanArrowsView: View { struct QueryPlanDiagramNodeView: View { @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + @Environment(\.colorSchemeContrast) private var contrast + @ObservedObject private var themeEngine = ThemeEngine.shared let node: QueryPlanNode let isSelected: Bool @@ -190,6 +204,7 @@ struct QueryPlanDiagramNodeView: View { Text(node.operation) .font(.system(.callout, weight: .semibold)) .lineLimit(1) + .themeForeground(.gridText, native: .primary) if let joinType = node.properties["Join Type"] { Text(joinType) .font(.caption2) @@ -229,13 +244,24 @@ struct QueryPlanDiagramNodeView: View { RoundedRectangle(cornerRadius: QueryPlanDiagramMetrics.cornerRadius) .fill(tint.opacity(0.12)) ) + .themeBackground( + .gridBackground, + native: Color.clear, + in: RoundedRectangle(cornerRadius: QueryPlanDiagramMetrics.cornerRadius) + ) .overlay( RoundedRectangle(cornerRadius: QueryPlanDiagramMetrics.cornerRadius) - .stroke(isSelected ? Color.accentColor : tint, lineWidth: isSelected ? 2 : 1) + .stroke(stroke, lineWidth: isSelected ? 2 : 1) ) } private var tint: Color { node.severity?.tint(differentiateWithoutColor: differentiateWithoutColor) ?? .secondary } + + /// The grid outlines its own selection with the focus border, so a selected step does too. + private var stroke: AnyShapeStyle { + guard isSelected else { return AnyShapeStyle(tint) } + return ThemeShapeStyle.style(.gridFocusBorder, native: Color.accentColor, engine: themeEngine, contrast: contrast) + } } diff --git a/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift b/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift index 97a12177b5..533f6a36b2 100644 --- a/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift +++ b/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift @@ -14,6 +14,8 @@ final class QueryPlanOutlineCoordinator: NSObject, NSOutlineViewDataSource, NSOu weak var outlineView: NSOutlineView? + private(set) var themeChrome: ThemeTableChrome? + private(set) var root: QueryPlanOutlineNode? private(set) var metrics: QueryPlanMetricIndex? private var planId: UUID? @@ -101,6 +103,12 @@ final class QueryPlanOutlineCoordinator: NSObject, NSOutlineViewDataSource, NSOu outlineView.collapseItem(root, collapseChildren: true) } + /// Called once the outline sits in its scroll view, which the chrome needs to reach, and before + /// it builds rows. + func attachThemeChrome(to outlineView: NSOutlineView) { + themeChrome = ThemeTableChrome(tableView: outlineView, family: .records) + } + // MARK: - Columns func configureColumns(on outlineView: NSOutlineView) { @@ -242,7 +250,12 @@ final class QueryPlanOutlineCoordinator: NSObject, NSOutlineViewDataSource, NSOu return hostingCell(identifier: identifier, outlineView: outlineView, content: content) } + func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { + themeChrome?.makeRowView(in: outlineView) + } + func outlineViewSelectionDidChange(_ notification: Notification) { + guard themeChrome?.isRebuildingRows != true else { return } onSelect(selectedNodeId) } @@ -301,13 +314,6 @@ final class QueryPlanOutlineCoordinator: NSObject, NSOutlineViewDataSource, NSOu outlineView: NSOutlineView, content: AnyView ) -> NSView { - if let reused = outlineView.makeView(withIdentifier: identifier, owner: self) as? NSHostingView { - reused.rootView = content - return reused - } - let hosting = NSHostingView(rootView: content) - hosting.sizingOptions = [] - hosting.identifier = identifier - return hosting + ThemeTableChrome.HostingCell.make(identifier, in: outlineView, showing: content) } } diff --git a/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift b/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift index 8065912196..3d40c1b7c3 100644 --- a/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift @@ -51,17 +51,18 @@ struct QueryPlanOutlineView: NSViewRepresentable { outlineView.coordinator = context.coordinator outlineView.menu = makeMenu(coordinator: context.coordinator) - context.coordinator.onSelect = { selectedNodeId = $0 } - context.coordinator.update( - plan: plan, metric: metric, differentiateWithoutColor: differentiateWithoutColor - ) - let scrollView = NSScrollView() scrollView.documentView = outlineView scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true scrollView.autohidesScrollers = true scrollView.drawsBackground = false + context.coordinator.attachThemeChrome(to: outlineView) + + context.coordinator.onSelect = { selectedNodeId = $0 } + context.coordinator.update( + plan: plan, metric: metric, differentiateWithoutColor: differentiateWithoutColor + ) return scrollView } diff --git a/TablePro/Views/Results/CellImageWindowController.swift b/TablePro/Views/Results/CellImageWindowController.swift index ceade23f35..c4f885cffb 100644 --- a/TablePro/Views/Results/CellImageWindowController.swift +++ b/TablePro/Views/Results/CellImageWindowController.swift @@ -40,7 +40,6 @@ internal final class CellImageWindowController: ValueViewerWindowController { } private struct CellImageWindowContent: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let data: Data let format: CellImageFormat let sourceKind: CellImageSourceKind @@ -60,10 +59,9 @@ private struct CellImageWindowContent: View { private var source: some View { switch sourceKind { case .markup: - TextValueEditor( + ValueSourceEditor( text: .constant(String(bytes: data, encoding: .utf8) ?? ""), - isEditable: false, - font: themeEngine.valueFont + isEditable: false ) case .hex: HexEditorBody( diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index 6638db272f..796f4f4c89 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -212,12 +212,14 @@ struct HexEditorBody: View { } } -// MARK: - Hex Dump Display View (Read-Only) - private struct HexDumpDisplayView: NSViewRepresentable { let text: String let font: NSFont + func makeCoordinator() -> Coordinator { + Coordinator() + } + func makeNSView(context: Context) -> NSScrollView { let scrollView = NSTextView.scrollableTextView() guard let textView = scrollView.documentView as? NSTextView else { @@ -230,6 +232,10 @@ private struct HexDumpDisplayView: NSViewRepresentable { textView.textContainerInset = NSSize(width: 8, height: 8) textView.backgroundColor = NSColor.textBackgroundColor textView.textColor = NSColor.secondaryLabelColor + context.coordinator.themeStyle = ThemeTextViewStyle( + textView: textView, + natives: ThemeTextViewStyle.Natives(of: textView) + ) textView.string = text return scrollView @@ -244,9 +250,12 @@ private struct HexDumpDisplayView: NSViewRepresentable { textView.string = text } } -} -// MARK: - Hex Input Text View (Editable) + @MainActor + final class Coordinator { + var themeStyle: ThemeTextViewStyle? + } +} private struct HexInputTextView: NSViewRepresentable { @Binding var text: String @@ -268,6 +277,10 @@ private struct HexInputTextView: NSViewRepresentable { textView.textContainerInset = NSSize(width: 8, height: 8) textView.backgroundColor = NSColor.textBackgroundColor textView.textColor = NSColor.labelColor + context.coordinator.themeStyle = ThemeTextViewStyle( + textView: textView, + natives: ThemeTextViewStyle.Natives(of: textView) + ) textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false @@ -294,11 +307,10 @@ private struct HexInputTextView: NSViewRepresentable { } } - // MARK: - Coordinator - final class Coordinator: NSObject, NSTextViewDelegate { var parent: HexInputTextView var isUpdating = false + var themeStyle: ThemeTextViewStyle? init(_ parent: HexInputTextView) { self.parent = parent diff --git a/TablePro/Views/Results/PhpViewerView.swift b/TablePro/Views/Results/PhpViewerView.swift index cde345b1ee..9b744c6efb 100644 --- a/TablePro/Views/Results/PhpViewerView.swift +++ b/TablePro/Views/Results/PhpViewerView.swift @@ -131,10 +131,12 @@ internal struct PhpViewerView: View { ScrollView { Text(rawValue) .font(themeEngine.valueFontSwiftUI) + .themeForeground(.editorText, native: .primary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) } + .themeBackground(.editorBackground, native: Color.clear) } private func errorPlaceholder(title: String, detail: String, systemImage: String) -> some View { diff --git a/TablePro/Views/Results/ResultChartCanvas.swift b/TablePro/Views/Results/ResultChartCanvas.swift index e961fa4ce5..98c2efc5b5 100644 --- a/TablePro/Views/Results/ResultChartCanvas.swift +++ b/TablePro/Views/Results/ResultChartCanvas.swift @@ -22,6 +22,8 @@ struct ResultChartCanvas: View { @State private var selectedCategory: String? @State private var selectedNumber: Double? @State private var selectedDate: Date? + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast init(projection: ResultChartProjection, chartType: ResultChartType) { self.projection = projection @@ -41,9 +43,9 @@ struct ResultChartCanvas: View { .chartYAxis { AxisMarks(position: .leading) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + .foregroundStyle(gridLineStyle) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor)) + .foregroundStyle(tickStyle) AxisValueLabel() .foregroundStyle(Color(nsColor: .secondaryLabelColor)) } @@ -51,17 +53,18 @@ struct ResultChartCanvas: View { .chartXAxis { AxisMarks(values: .automatic(desiredCount: 7)) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) + .foregroundStyle(gridLineStyle) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(Color(nsColor: .separatorColor)) + .foregroundStyle(tickStyle) AxisValueLabel(collisionResolution: .greedy(minimumSpacing: 8)) .foregroundStyle(Color(nsColor: .secondaryLabelColor)) } } .chartPlotStyle { plotArea in plotArea - .background( - Color(nsColor: .controlBackgroundColor).opacity(0.45), + .themeBackground( + .gridBackground, + native: Color(nsColor: .controlBackgroundColor).opacity(0.45), in: .rect(cornerRadius: 8) ) } @@ -70,6 +73,20 @@ struct ResultChartCanvas: View { .accessibilityIdentifier("result-chart") } + /// The chart stands where the grid would, so its plot takes the grid's background and lines. + private var gridLineStyle: AnyShapeStyle { + ThemeShapeStyle.style( + .gridLine, + native: Color(nsColor: .separatorColor).opacity(0.55), + engine: themeEngine, + contrast: contrast + ) + } + + private var tickStyle: AnyShapeStyle { + ThemeShapeStyle.style(.gridLine, native: Color(nsColor: .separatorColor), engine: themeEngine, contrast: contrast) + } + private var categoricalChart: some View { let selection = selectionIndex.selection(forCategory: selectedCategory) return legend(Chart { diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index a754c7aac4..610da9d034 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -12,9 +12,9 @@ import SwiftUI /// centre. Anchoring the readout to the leading edge and the controls to the trailing edge also /// keeps the controls still when a mode change removes one of them. /// -/// The view switcher leads the bar, the way TablePlus and Postico place theirs. It was briefly a -/// strip of its own above the result, which cost a band of height in every mode and left the -/// structure editor with two segmented rows stacked, the child louder than the parent. +/// The view switcher leads the bar. It was briefly a strip of its own above the result, which cost +/// a band of height in every mode and left the structure editor with two segmented rows stacked, +/// the child louder than the parent. /// /// Adding a row still belongs to the toolbar: it changes the document. The structure editor's /// add and remove pair is this bar's trailing cluster while the structure editor is the content, @@ -160,7 +160,7 @@ struct ResultStatusBar: View { /// clusters on either side keep their intrinsic widths. Its three parts are the slots /// `ReadoutZoneLayout` sizes: the sentence, the actions beside it, and the report after them. private var readoutCluster: some View { - ReadoutZoneLayout(spacing: Self.readoutSpacing, sentenceIdealWidth: StatusBarLayoutMetrics.readoutIdealWidth) { + ReadoutZoneLayout(spacing: StatusBarChrome.clusterSpacing, sentenceIdealWidth: StatusBarLayoutMetrics.readoutIdealWidth) { HStack(spacing: Self.readoutSpacing) { if model.controls.showsReadout { readoutSentence @@ -171,13 +171,12 @@ struct ResultStatusBar: View { readoutActions } } - HStack(spacing: Self.readoutSpacing) { + HStack(spacing: StatusBarChrome.clusterSpacing) { if model.controls.showsSelectionSummary { SelectionSummaryReadout( state: selectionSummary, scopeNote: selectionSummaryScopeNote, - isPopoverPresented: $showSelectionSummaryPopover, - leadsWithSeparator: true + isPopoverPresented: $showSelectionSummaryPopover ) } if model.controls.showsReadout { @@ -242,7 +241,6 @@ struct ResultStatusBar: View { @ViewBuilder private var readoutMessage: some View { if let statusMessage = model.statusMessage { - StatusBarSeparator() /// Yields its width before the execution report beside it does, so a wordy driver /// message truncates instead of squeezing anything else out. Which tier the bar draws /// is not its business: `ReadoutZoneLayout` leaves it out of the ideal width, so no @@ -260,13 +258,9 @@ struct ResultStatusBar: View { private var executionReport: some View { let slot = ExecutionSlot( isOffered: model.controls.showsExecution, - followsReadout: model.controls.showsReadout, isRevealed: revealedExecutionTabId == execution.tabId, lastTiming: execution.lastTiming ) - if slot.leadsWithSeparator { - StatusBarSeparator() - } if let report = slot.report { ExecutionIndicatorView( report: report, diff --git a/TablePro/Views/Results/SelectionSummaryReadout.swift b/TablePro/Views/Results/SelectionSummaryReadout.swift index b1d9401ca8..a244ccfdf2 100644 --- a/TablePro/Views/Results/SelectionSummaryReadout.swift +++ b/TablePro/Views/Results/SelectionSummaryReadout.swift @@ -124,14 +124,10 @@ struct SelectionSummaryReadout: View { @ObservedObject var state: SelectionSummaryState let scopeNote: String? @Binding var isPopoverPresented: Bool - var leadsWithSeparator = false var body: some View { if let summary = state.summary { let figures = SelectionSummaryFigures(summary) - if leadsWithSeparator { - StatusBarSeparator() - } Button { isPopoverPresented.toggle() } label: { @@ -156,7 +152,7 @@ struct SelectionSummaryReadout: View { private func label(_ figures: SelectionSummaryFigures) -> some View { ViewThatFits(in: .horizontal) { if let sum = figures.sum, let average = figures.average { - Text("Sum \(sum) · Average \(average) · Count \(figures.valueCount)") + Text("Sum \(sum), Average \(average), Count \(figures.valueCount)") .fixedSize() Text("Sum \(sum)") .fixedSize() diff --git a/TablePro/Views/Results/ServerOutputTextView.swift b/TablePro/Views/Results/ServerOutputTextView.swift index e9d488a1b7..7acd9d1375 100644 --- a/TablePro/Views/Results/ServerOutputTextView.swift +++ b/TablePro/Views/Results/ServerOutputTextView.swift @@ -20,6 +20,10 @@ struct ServerOutputTextView: NSViewRepresentable { textView.textColor = .labelColor textView.backgroundColor = .textBackgroundColor textView.textContainerInset = NSSize(width: 8, height: 8) + context.coordinator.themeStyle = ThemeTextViewStyle( + textView: textView, + natives: ThemeTextViewStyle.Natives(of: textView) + ) textView.setAccessibilityLabel(String(localized: "Server output")) textView.setAccessibilityIdentifier("server-output-text") textView.string = text @@ -30,4 +34,13 @@ struct ServerOutputTextView: NSViewRepresentable { guard let textView = scrollView.documentView as? NSTextView, textView.string != text else { return } textView.string = text } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + @MainActor + final class Coordinator { + var themeStyle: ThemeTextViewStyle? + } } diff --git a/TablePro/Views/Results/StatusBarChrome.swift b/TablePro/Views/Results/StatusBarChrome.swift index 1e3dd824ab..6727132c09 100644 --- a/TablePro/Views/Results/StatusBarChrome.swift +++ b/TablePro/Views/Results/StatusBarChrome.swift @@ -6,37 +6,19 @@ import AppKit import SwiftUI -/// The one owner of what a bottom status bar looks like. -/// -/// Both of the app's status bars used to paint themselves: the result bar with -/// `controlBackgroundColor`, which is the same colour as the data grid above it so the bar did not -/// read as a separate surface, and the inspector bar with `windowBackgroundColor`. Routing both -/// through here also fixes the height, which used to float with whatever the bar happened to contain. +/// The one owner of what a bottom status bar looks like, so every bar has the same height and material. enum StatusBarChrome { /// Measured off Finder's own status bar through the accessibility API. static let height: CGFloat = 28 static let horizontalPadding: CGFloat = 10 static let clusterSpacing: CGFloat = 8 - /// The layout height a control's glyph is given. A small bordered button takes its height from - /// its glyph, measured at 18pt for `eye`, 19pt for the circled filter and 20pt for `eye.slash` - /// when the glyph stands alone, and 20 or 21pt beside a title, so the controls in one bar stood - /// at three heights. One box makes each the height of a text-only control: 12pt beside a title, - /// whose own line sets the height, and 14pt alone. + /// A small bordered button takes its height from its glyph (18 to 21pt), so one box keeps every control in a bar at one height. static func controlIconHeight(besideTitle: Bool) -> CGFloat { besideTitle ? 12 : 14 } } -struct StatusBarSeparator: View { - var body: some View { - Text(verbatim: "·") - .font(.caption) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) - } -} - /// `NSVisualEffectView` rather than a flat colour because a bar is window chrome: AppKit desaturates /// the material when the window stops being key, and a `Color` never does. private struct StatusBarMaterial: NSViewRepresentable { @@ -51,7 +33,14 @@ private struct StatusBarMaterial: NSViewRepresentable { func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} } +extension EnvironmentValues { + /// False where the host paints a custom backdrop under the bar: the material does not follow a custom window color. + @Entry var statusBarDrawsMaterial = true +} + private struct StatusBarChromeModifier: ViewModifier { + @Environment(\.statusBarDrawsMaterial) private var drawsMaterial + func body(content: Content) -> some View { VStack(spacing: 0) { Divider() @@ -60,7 +49,11 @@ private struct StatusBarChromeModifier: ViewModifier { .frame(maxWidth: .infinity, alignment: .leading) .frame(height: StatusBarChrome.height) } - .background(StatusBarMaterial()) + .background { + if drawsMaterial { + StatusBarMaterial() + } + } } } @@ -70,6 +63,10 @@ extension View { modifier(StatusBarChromeModifier()) } + func statusBarDrawsMaterial(_ drawsMaterial: Bool) -> some View { + environment(\.statusBarDrawsMaterial, drawsMaterial) + } + /// Height only: the glyph keeps its size and its width, so no control gets wider or narrower. func statusBarControlIcon(besideTitle: Bool) -> some View { frame(height: StatusBarChrome.controlIconHeight(besideTitle: besideTitle)) diff --git a/TablePro/Views/Results/SvgViewerContentView.swift b/TablePro/Views/Results/SvgViewerContentView.swift index 90d721896d..1e8ed469c7 100644 --- a/TablePro/Views/Results/SvgViewerContentView.swift +++ b/TablePro/Views/Results/SvgViewerContentView.swift @@ -8,7 +8,6 @@ import SwiftUI /// The popover a text cell holding SVG markup opens: the drawing, with the markup one segment away /// and still editable where the cell is. internal struct SvgViewerContentView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let initialValue: String let isEditable: Bool let onDismiss: () -> Void @@ -40,11 +39,7 @@ internal struct SvgViewerContentView: View { sourceKind: .markup, onPopOut: onPopOut.map { popOut in { popOut(text) } } ) { - TextValueEditor( - text: $text, - isEditable: isEditable, - font: themeEngine.valueFont - ) + ValueSourceEditor(text: $text, isEditable: isEditable) } if isEditable, onCommit != nil { diff --git a/TablePro/Views/Results/TextViewerWindowController.swift b/TablePro/Views/Results/TextViewerWindowController.swift index 6b426039b2..85ba2ff21b 100644 --- a/TablePro/Views/Results/TextViewerWindowController.swift +++ b/TablePro/Views/Results/TextViewerWindowController.swift @@ -37,10 +37,7 @@ internal final class TextViewerWindowController: ValueViewerWindowController { } } -// MARK: - Window Content - private struct TextViewerWindowContent: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let isEditable: Bool let onCommit: ((String) -> Void)? let onDismiss: (() -> Void)? @@ -60,10 +57,9 @@ private struct TextViewerWindowContent: View { } var body: some View { - TextValueEditor( + ValueSourceEditor( text: $text, isEditable: isEditable, - font: themeEngine.valueFont, textContainerInset: NSSize(width: 8, height: 10) ) .onChange(of: text) { _ in diff --git a/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift b/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift index 7ad565c076..b526146c13 100644 --- a/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift +++ b/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift @@ -139,11 +139,12 @@ internal final class TreeOutlineCellView: NSTableCellView, NSTextFieldDelegate { return true } - private func applyStyle() { + /// Also run on a theme change: a field redraws only when it is handed a new color. + internal func applyStyle() { guard let content else { return } let isEmphasized = backgroundStyle == .emphasized let selectedText = NSColor.alternateSelectedControlTextColor - let secondary = isEmphasized ? selectedText.withAlphaComponent(0.7) : NSColor.secondaryLabelColor + let secondary = isEmphasized ? selectedText.withAlphaComponent(0.7) : TreeValueTone.muted.color keyField.textColor = isEmphasized ? selectedText : TreeValueTone.key.color separatorField.textColor = secondary diff --git a/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift b/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift index faf1e9200f..fdc1984377 100644 --- a/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift +++ b/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine internal struct TreeOutlineContent { let rootNode: Node @@ -26,6 +27,7 @@ internal final class TreeOutlineCoordinator: NSObject, internal private(set) weak var outlineView: TreeOutlineView? internal private(set) var reloadCount = 0 + internal private(set) var chrome: ThemeTableChrome? private let cache: TreeProjectionCache private var rootNode: Node? @@ -36,12 +38,14 @@ internal final class TreeOutlineCoordinator: NSObject, private var fonts: TreeOutlineFonts? private var isApplyingExpansion = false private var menuRows: [Item] = [] + private var themeCancellable: AnyCancellable? internal init(cache: TreeProjectionCache) { self.cache = cache super.init() } + /// Call once the outline is in its scroll view: a themed background turns the scroll view's own off. internal func attach(_ outlineView: TreeOutlineView) { self.outlineView = outlineView outlineView.dataSource = self @@ -50,6 +54,9 @@ internal final class TreeOutlineCoordinator: NSObject, let menu = NSMenu() menu.delegate = self outlineView.menu = menu + chrome = ThemeTableChrome(tableView: outlineView, family: .code) + themeCancellable = AppEvents.shared.themeChanged + .sink { [weak self] _ in self?.restyleVisibleCells() } } // MARK: - Content @@ -159,6 +166,12 @@ internal final class TreeOutlineCoordinator: NSObject, outlineView.collapseItem(item, collapseChildren: true) } + private func restyleVisibleCells() { + outlineView?.enumerateAvailableRowViews { rowView, _ in + (rowView.view(atColumn: 0) as? TreeOutlineCellView)?.applyStyle() + } + } + private func reconfigureVisibleCells() { guard let outlineView, let fonts else { return } outlineView.enumerateAvailableRowViews { rowView, row in @@ -320,10 +333,17 @@ internal final class TreeOutlineCoordinator: NSObject, } internal func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { - let reused = outlineView.makeView(withIdentifier: TreeOutlineRowView.reuseIdentifier, owner: nil) - if let rowView = reused as? TreeOutlineRowView { return rowView } - let rowView = TreeOutlineRowView() - rowView.identifier = TreeOutlineRowView.reuseIdentifier + guard let chrome, chrome.themesRows else { + let reused = outlineView.makeView(withIdentifier: TreeOutlineRowView.reuseIdentifier, owner: nil) + if let rowView = reused as? TreeOutlineRowView { return rowView } + let rowView = TreeOutlineRowView() + rowView.identifier = TreeOutlineRowView.reuseIdentifier + return rowView + } + let reused = outlineView.makeView(withIdentifier: ThemedTreeOutlineRowView.reuseIdentifier, owner: nil) + let rowView = reused as? ThemedTreeOutlineRowView ?? ThemedTreeOutlineRowView() + rowView.identifier = ThemedTreeOutlineRowView.reuseIdentifier + rowView.chrome = chrome return rowView } @@ -346,7 +366,8 @@ internal final class TreeOutlineCoordinator: NSObject, /// The outline posts these for the expansion applied here too, which is not the user's choice. private func recordDisclosure(_ notification: Notification, isExpanded: Bool) { - guard !isApplyingExpansion, let item = notification.userInfo?["NSObject"] as? Item else { return } + guard !isApplyingExpansion, chrome?.isRebuildingRows != true, + let item = notification.userInfo?["NSObject"] as? Item else { return } if isExpanded { appliedExpansion?.insert(item.path) } else { diff --git a/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift b/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift index a12a08e0ba..8cfa367965 100644 --- a/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift +++ b/TablePro/Views/Results/TreeOutline/TreeOutlineView.swift @@ -106,8 +106,10 @@ internal final class TreeOutlineView: SidebarOutlineView { } /// Unemphasized while its value holds a text selection, which is unreadable on the accent color. -internal final class TreeOutlineRowView: NSTableRowView { - internal static let reuseIdentifier = NSUserInterfaceItemIdentifier("TreeOutlineRow") +internal class TreeOutlineRowView: NSTableRowView { + internal class var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("TreeOutlineRow") + } private var requestedEmphasis: Bool? @@ -139,3 +141,32 @@ internal final class TreeOutlineRowView: NSTableRowView { } } } + +/// The tree row while the theme sets the outline's background or selection. +internal final class ThemedTreeOutlineRowView: TreeOutlineRowView, ThemeTableRowDrawing { + override internal class var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("ThemedTreeOutlineRow") + } + + internal weak var chrome: ThemeTableChrome? + + override internal func drawBackground(in dirtyRect: NSRect) { + guard chrome?.drawBackground(of: self, in: dirtyRect) == true else { + super.drawBackground(in: dirtyRect) + return + } + } + + override internal func drawSelection(in dirtyRect: NSRect) { + guard let chrome else { + super.drawSelection(in: dirtyRect) + return + } + chrome.drawSelection(of: self, in: dirtyRect) { super.drawSelection(in: $0) } + } + + override internal var interiorBackgroundStyle: NSView.BackgroundStyle { + let native = super.interiorBackgroundStyle + return chrome?.interiorBackgroundStyle(of: self, native: native) ?? native + } +} diff --git a/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift b/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift index 9ad837e46d..150d90c9d9 100644 --- a/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift +++ b/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift @@ -5,8 +5,9 @@ import AppKit +@MainActor internal enum TreeValueStyle { - /// Fixed system colors do not adapt on an emphasized row the way the label colors do. + /// A tone does not turn white on an emphasized row the way the label colors do. static func textColor(for tone: TreeValueTone, isEmphasized: Bool) -> NSColor { isEmphasized ? .alternateSelectedControlTextColor : tone.color } diff --git a/TablePro/Views/Results/TreeOutline/TreeValueTone+Theme.swift b/TablePro/Views/Results/TreeOutline/TreeValueTone+Theme.swift new file mode 100644 index 0000000000..c82cd73673 --- /dev/null +++ b/TablePro/Views/Results/TreeOutline/TreeValueTone+Theme.swift @@ -0,0 +1,26 @@ +// +// TreeValueTone+Theme.swift +// TablePro +// + +import AppKit + +internal extension TreeValueTone { + /// The slot the JSON text view draws the same token in: its grammar captures a key as a string, + /// true, false and null as constants (the NULL color), and leaves braces in the text color. + var slot: ThemeSlot { + switch self { + case .key, .string: return .syntaxString + case .number: return .syntaxNumber + case .literal: return .syntaxNull + case .serialized: return .syntaxType + case .reference: return .syntaxComment + case .container: return .editorText + case .muted: return .editorLineNumber + } + } + + @MainActor var color: NSColor { + ThemeEngine.shared.palette[slot] + } +} diff --git a/TablePro/Views/Results/ValueSourceEditor.swift b/TablePro/Views/Results/ValueSourceEditor.swift new file mode 100644 index 0000000000..201dcc2ce5 --- /dev/null +++ b/TablePro/Views/Results/ValueSourceEditor.swift @@ -0,0 +1,24 @@ +// +// ValueSourceEditor.swift +// TablePro +// + +import AppKit +import SwiftUI + +internal struct ValueSourceEditor: View { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Binding var text: String + var isEditable: Bool + var textContainerInset = NSSize(width: 4, height: 6) + + internal var body: some View { + TextValueEditor( + text: $text, + isEditable: isEditable, + font: themeEngine.valueFont, + textContainerInset: textContainerInset, + style: .code + ) + } +} diff --git a/TablePro/Views/Results/ValueViewerWindowController.swift b/TablePro/Views/Results/ValueViewerWindowController.swift index c6341bf2a2..3f72852e28 100644 --- a/TablePro/Views/Results/ValueViewerWindowController.swift +++ b/TablePro/Views/Results/ValueViewerWindowController.swift @@ -16,6 +16,7 @@ internal class ValueViewerWindowController { private static let styleMask: NSWindow.StyleMask = [.titled, .closable, .resizable, .miniaturizable] private var window: NSWindow? + private var windowBackdrop: ThemeWindowBackdrop? private var closeObserver: NSObjectProtocol? /// A popped-out editor commits through the display row it was opened from, so whoever opened @@ -33,6 +34,7 @@ internal class ValueViewerWindowController { let window = Self.makeWindow(identifier: identifier, title: title, content: content) self.window = window + windowBackdrop = ThemeWindowBackdrop(window: window) let key = ObjectIdentifier(self) ValueViewerWindowController.activeWindows[key] = self @@ -47,6 +49,7 @@ internal class ValueViewerWindowController { self?.closeObserver.map { NotificationCenter.default.removeObserver($0) } self?.closeObserver = nil self?.window = nil + self?.windowBackdrop = nil } } diff --git a/TablePro/Views/Rewind/RewindReviewSheet.swift b/TablePro/Views/Rewind/RewindReviewSheet.swift index edf1481423..9f4141d18e 100644 --- a/TablePro/Views/Rewind/RewindReviewSheet.swift +++ b/TablePro/Views/Rewind/RewindReviewSheet.swift @@ -101,22 +101,8 @@ struct RewindReviewSheet: View { } private var statementList: some View { - ScrollView { - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(displayStatements.enumerated()), id: \.offset) { _, statement in - Text(statement) - .font(Font(themeEngine.editorFonts.font)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(12) - } - .frame(height: 140) - } - - private var displayStatements: [String] { - plan.displayStatements + RewindStatementList(statements: plan.displayStatements) + .frame(height: 140) } private var footer: some View { @@ -193,3 +179,24 @@ struct RewindReviewSheet: View { } } } + +internal struct RewindStatementList: View { + @ObservedObject private var themeEngine = ThemeEngine.shared + let statements: [String] + + internal var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(statements.enumerated()), id: \.offset) { _, statement in + Text(statement) + .font(Font(themeEngine.editorFonts.font)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .themeForeground(.editorText, native: .primary) + .padding(12) + } + .themeBackground(.editorBackground, native: Color.clear) + } +} diff --git a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index 1b60064ad2..ea7e086721 100644 --- a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -134,7 +134,6 @@ 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))) + .valueWell() } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift index c3392981ad..39527a0a6c 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift @@ -34,8 +34,7 @@ internal struct ImageFieldView: View { source: { source } ) .frame(height: 220) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .valueWell(clipsContent: true) .task(id: context.value.wrappedValue) { data = context.value.wrappedValue.storedBytes } @@ -55,7 +54,8 @@ internal struct ImageFieldView: View { TextValueEditor( text: context.value, isEditable: !context.isReadOnly, - font: themeEngine.valueFont + font: themeEngine.valueFont, + style: .code ) case .hex: BlobHexEditorView(context: context) diff --git a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift index c591005f92..a53c05b691 100644 --- a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift @@ -45,8 +45,7 @@ internal struct JsonEditorView: View { isEditable: !context.isReadOnly, accessibilityIdentifier: "inspector-json-field" ) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .valueWell(clipsContent: true) .overlay(alignment: .bottomTrailing) { actionButtons } } /// `newValue`, never a re-read of `context.value.wrappedValue`. An `onChange` action diff --git a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift index a895de41a3..3ace38df69 100644 --- a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift @@ -24,10 +24,10 @@ internal struct MultiLineEditorView: View { text: context.value, isEditable: !context.isReadOnly, font: themeEngine.valueFont, - movesFocusOnTab: true + movesFocusOnTab: true, + style: .code ) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .valueWell(clipsContent: true) .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 cf499ad52e..67bd019ec9 100644 --- a/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift @@ -17,7 +17,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))) + .valueWell(clipsContent: true) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift index f960266bb5..29d1652a76 100644 --- a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift @@ -23,13 +23,19 @@ internal struct SingleLineEditorView: View { private var readOnlyValue: some View { let value = context.value.wrappedValue let placeholder = value.isEmpty ? context.emptyStatePlaceholder : nil - return Text(placeholder ?? value) - .foregroundStyle(placeholder == nil ? .primary : .tertiary) - .textSelection(.enabled) - .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))) + return Group { + if let placeholder { + Text(placeholder) + .foregroundStyle(.tertiary) + } else { + Text(value) + .themeForeground(.editorText, native: .primary) + } + } + .textSelection(.enabled) + .frame(maxWidth: .infinity, minHeight: 16, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .valueWell() } } diff --git a/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift b/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift index 33c3b64206..b07733c05f 100644 --- a/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift @@ -92,7 +92,7 @@ struct JSONNodeRowView: View { .lineLimit(1) Text(": ") .font(valueFont) - .foregroundStyle(JSONRowColors.punctuation) + .themeForeground(.editorText, native: JSONRowColors.punctuation) } token status @@ -163,7 +163,7 @@ struct JSONNodeRowView: View { private func punctuation(_ text: String) -> some View { Text(text) .font(valueFont) - .foregroundStyle(JSONRowColors.punctuation) + .themeForeground(.editorText, native: JSONRowColors.punctuation) } private func collapsed(open: String, close: String, count: Int) -> some View { diff --git a/TablePro/Views/RowInspector/JSON/JSONRowColors.swift b/TablePro/Views/RowInspector/JSON/JSONRowColors.swift index 5eecf15439..41170c4fb8 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowColors.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowColors.swift @@ -6,11 +6,12 @@ import SwiftUI /// The JSON inspector draws a value in the same tone as the JSON tree viewer, so the two never -/// disagree and neither follows the editor theme. +/// disagree. +@MainActor internal enum JSONRowColors { - static let key = Color(nsColor: TreeValueTone.key.color) + static var key: Color { Color(nsColor: TreeValueTone.key.color) } static let punctuation = Color.primary - static let placeholder = Color(nsColor: TreeValueTone.muted.color) + static var placeholder: Color { Color(nsColor: TreeValueTone.muted.color) } static func tone(for scalar: JSONScalar) -> TreeValueTone { switch scalar { diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index 22097d0629..fef0888d9b 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -20,6 +20,7 @@ struct JSONRowInspectorView: View { toolbar Divider() tree + .themeBackground(.editorBackground, native: Color.clear, ignoresSafeAreaEdges: []) } else { emptyState } diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index b66dbd8e90..0a11932aae 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -22,7 +22,7 @@ internal struct AppearanceSettingsView: View { themePicker(ThemeSlotChoice(slot: .light, savedId: settings.preferredLightThemeId, themes: catalog.themes)) themePicker(ThemeSlotChoice(slot: .dark, savedId: settings.preferredDarkThemeId, themes: catalog.themes)) } footer: { - Text("Themes color the SQL editor and the data grid. Everything else follows the system appearance.") + Text("Themes color the editor, the data grid, other content views and the window background. Controls, sidebars and Settings follow the system appearance.") } CustomThemesSection(settings: $settings) diff --git a/TablePro/Views/Settings/ThemePreviewCard.swift b/TablePro/Views/Settings/ThemePreviewCard.swift index 48c698b7ab..f7f1ce530b 100644 --- a/TablePro/Views/Settings/ThemePreviewCard.swift +++ b/TablePro/Views/Settings/ThemePreviewCard.swift @@ -1,7 +1,8 @@ import SwiftUI -/// A few lines of SQL and a few grid rows in a theme's colors: the only two surfaces a theme -/// colors. Slots the theme leaves out resolve in the theme's own appearance. +/// A miniature window in a theme's colors: the window background with a secondary caption on it, +/// around a few lines of SQL and a few grid rows. Slots the theme leaves out resolve in the theme's +/// own appearance. internal struct ThemePreviewCard: View { internal let theme: ThemeDefinition @@ -76,11 +77,20 @@ internal struct ThemePreviewCard: View { private static let columnWidths: [CGFloat] = [28, 72, 56] internal var body: some View { - VStack(spacing: 0) { - editor - grid + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: theme.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + VStack(spacing: 0) { + editor + grid + } + .font(.system(size: 11, design: .monospaced)) + .clipShape(RoundedRectangle(cornerRadius: 4)) } - .font(.system(size: 11, design: .monospaced)) + .padding(8) + .background(color(.windowBackdrop)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) diff --git a/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift b/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift index b2bdf7b890..495b73b519 100644 --- a/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift +++ b/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift @@ -12,12 +12,20 @@ import SwiftUI /// `TextEditor` leaves AppKit's quote, dash and text substitutions on with no API to reach them, /// which would rewrite a value on its way to the database. internal struct TextValueEditor: NSViewRepresentable { + internal enum Style { + /// The system text colors. + case plain + /// The editor's colors while the active theme sets them. + case code + } + @Binding var text: String var isEditable: Bool = true var font: NSFont var borderType: NSBorderType = .noBorder var movesFocusOnTab: Bool = false var textContainerInset = NSSize(width: 4, height: 6) + var style: Style = .plain func makeNSView(context: Context) -> NSScrollView { let scrollView = NSTextView.scrollableTextView() @@ -84,6 +92,18 @@ internal struct TextValueEditor: NSViewRepresentable { if !textView.isSelectable { textView.isSelectable = true } + applyStyle(to: textView, coordinator: coordinator) + } + + private func applyStyle(to textView: NSTextView, coordinator: Coordinator) { + switch style { + case .plain: + coordinator.themeStyle?.detach() + coordinator.themeStyle = nil + case .code: + guard coordinator.themeStyle == nil else { return } + coordinator.themeStyle = ThemeTextViewStyle(textView: textView) + } } final class Coordinator: NSObject, NSTextViewDelegate { @@ -93,6 +113,7 @@ internal struct TextValueEditor: NSViewRepresentable { /// moved" without bridging the whole NSString back, which costs 45ms on a 1 MB value and /// would run on every SwiftUI update pass. var lastAppliedText: String = "" + var themeStyle: ThemeTextViewStyle? init(text: Binding, movesFocusOnTab: Bool) { self.text = text diff --git a/TablePro/Views/Shared/FieldEditors/ValueWell.swift b/TablePro/Views/Shared/FieldEditors/ValueWell.swift new file mode 100644 index 0000000000..da933f17c8 --- /dev/null +++ b/TablePro/Views/Shared/FieldEditors/ValueWell.swift @@ -0,0 +1,39 @@ +// +// ValueWell.swift +// TablePro +// + +import SwiftUI + +internal extension View { + /// The rounded, bordered well a stored value sits in, on the editor's background while the active + /// theme sets it. `clipsContent` is for a view that paints its own background, which the well + /// clips to its corners; text drawn straight on the well gets the text background instead. + func valueWell(clipsContent: Bool = false) -> some View { + modifier(ValueWell(clipsContent: clipsContent)) + } +} + +private struct ValueWell: ViewModifier { + let clipsContent: Bool + + private static let shape = RoundedRectangle(cornerRadius: 5) + + @ViewBuilder + func body(content: Content) -> some View { + if clipsContent { + content + .themeBackground(.editorBackground, native: Color.clear, in: Self.shape) + .clipShape(Self.shape) + .overlay(Self.border) + } else { + content + .themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor), in: Self.shape) + .overlay(Self.border) + } + } + + private static var border: some View { + shape.strokeBorder(Color(nsColor: .separatorColor)) + } +} diff --git a/TablePro/Views/Shared/TextDiffView.swift b/TablePro/Views/Shared/TextDiffView.swift index 4c8a5d8946..894928b2ba 100644 --- a/TablePro/Views/Shared/TextDiffView.swift +++ b/TablePro/Views/Shared/TextDiffView.swift @@ -18,6 +18,9 @@ internal struct TextDiffView: View { internal var textFont: Font = .system(.caption, design: .monospaced) @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + @ObservedObject private var themeEngine = ThemeEngine.shared + + private static let outline = RoundedRectangle(cornerRadius: 4) internal var body: some View { switch layout { @@ -41,7 +44,8 @@ internal struct TextDiffView: View { } } } - .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) + .background(Self.outline.stroke(Color.secondary.opacity(0.2))) + .themeBackground(.editorBackground, native: Color.clear, in: Self.outline) } private var unifiedBody: some View { @@ -59,12 +63,14 @@ internal struct TextDiffView: View { } .padding(.horizontal, 6) .padding(.vertical, 1) + .diffLineForeground(Self.lineKind(line.kind)) .background(background(for: line.kind)) .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityLabel(for: line)) } } - .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) + .background(Self.outline.stroke(Color.secondary.opacity(0.2))) + .themeBackground(.editorBackground, native: Color.clear, in: Self.outline) } private func columnHeader(_ title: String) -> some View { @@ -90,6 +96,7 @@ internal struct TextDiffView: View { .padding(.horizontal, 6) .padding(.vertical, 1) .frame(maxWidth: .infinity, alignment: .leading) + .diffLineForeground(Self.lineKind(kind, side: side)) .background(splitBackground(kind: kind, side: side, isEmpty: text == nil)) .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityLabel(for: text, kind: kind, side: side)) @@ -123,21 +130,29 @@ internal struct TextDiffView: View { private func splitBackground(kind: DiffPair.Kind, side: SqlWalkthroughAnchor.Side, isEmpty: Bool) -> Color { guard !differentiateWithoutColor else { return .clear } guard !isEmpty else { return Color.secondary.opacity(0.05) } + guard let lineKind = Self.lineKind(kind, side: side) else { return .clear } + return CompareStatusStyle.rowTint(for: lineKind, palette: themeEngine.palette) + } + + private func background(for kind: DiffUnifiedLine.Kind) -> Color { + guard !differentiateWithoutColor, let lineKind = Self.lineKind(kind) else { return .clear } + return CompareStatusStyle.rowTint(for: lineKind, palette: themeEngine.palette) + } + + internal static func lineKind(_ kind: DiffPair.Kind, side: SqlWalkthroughAnchor.Side) -> RowDiffKind? { switch (kind, side) { - case (.unchanged, _): return .clear - case (.changed, _): return CompareStatusStyle.rowTint(for: .update) - case (.added, .after): return CompareStatusStyle.rowTint(for: .insert) - case (.removed, .before): return CompareStatusStyle.rowTint(for: .delete) - default: return .clear + case (.changed, _): return .update + case (.added, .after): return .insert + case (.removed, .before): return .delete + default: return nil } } - private func background(for kind: DiffUnifiedLine.Kind) -> Color { - guard !differentiateWithoutColor else { return .clear } + internal static func lineKind(_ kind: DiffUnifiedLine.Kind) -> RowDiffKind? { switch kind { - case .context: return .clear - case .added: return CompareStatusStyle.rowTint(for: .insert) - case .removed: return CompareStatusStyle.rowTint(for: .delete) + case .context: return nil + case .added: return .insert + case .removed: return .delete } } } diff --git a/TablePro/Views/Sidebar/FavoriteEditDialog.swift b/TablePro/Views/Sidebar/FavoriteEditDialog.swift index 28647a3383..ed264692d7 100644 --- a/TablePro/Views/Sidebar/FavoriteEditDialog.swift +++ b/TablePro/Views/Sidebar/FavoriteEditDialog.swift @@ -3,6 +3,7 @@ // TablePro // +import AppKit import SwiftUI /// Wrapper for `.sheet(item:)` to ensure the query is passed reliably @@ -11,7 +12,6 @@ internal struct FavoriteDialogQuery: Identifiable { let query: String } -/// Dialog for creating or editing a SQL favorite internal struct FavoriteEditDialog: View { @Environment(\.dismiss) private var dismiss @@ -174,14 +174,18 @@ internal struct FavoriteEditDialog: View { private var querySection: some View { Section { - TextEditor(text: $query) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 180) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(nsColor: .separatorColor)) - ) - .accessibilityLabel(String(localized: "Query")) + TextValueEditor( + text: $query, + font: .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), + textContainerInset: .zero, + style: .code + ) + .frame(minHeight: 180) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor)) + ) + .accessibilityLabel(String(localized: "Query")) if let message = draft.sizeValidation.displayText { Text(message) diff --git a/TablePro/Views/Sidebar/FileConflictDiffSheet.swift b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift index caf9473776..3950d3a45f 100644 --- a/TablePro/Views/Sidebar/FileConflictDiffSheet.swift +++ b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift @@ -95,6 +95,9 @@ private struct DiffColumnView: View { let title: String let rows: [FileConflictRow] + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + var body: some View { VStack(alignment: .leading, spacing: 0) { Text(title) @@ -113,26 +116,41 @@ private struct DiffColumnView: View { HStack(alignment: .top, spacing: 8) { Text(verbatim: "\(row.id + 1)") .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.tertiary) + .themeForeground(.editorLineNumber, native: .tertiary) .frame(width: 32, alignment: .trailing) Text(row.text ?? " ") .font(.system(.body, design: .monospaced)) + .diffLineForeground(row.highlight.lineKind) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 8) .padding(.vertical, 1) - .background(row.highlight.tint) + .background(row.highlight.tint(engine: themeEngine, contrast: contrast)) } } } - .background(Color(nsColor: .textBackgroundColor)) + .themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor)) } } } -private extension FileConflictRowHighlight { - var tint: Color { +internal extension FileConflictRowHighlight { + var lineKind: RowDiffKind? { + switch self { + case .removed: return .delete + case .added: return .insert + case .plain, .filler: return nil + } + } + + @MainActor + func tint(engine: ThemeEngine, contrast: ColorSchemeContrast) -> Color { + guard let lineKind else { return nativeTint } + return CompareStatusStyle.lineTint(for: lineKind, native: nativeTint, engine: engine, contrast: contrast) + } + + private var nativeTint: Color { switch self { case .plain: return .clear case .removed: return .red.opacity(0.18) diff --git a/TablePro/Views/Sidebar/MaintenanceSheet.swift b/TablePro/Views/Sidebar/MaintenanceSheet.swift index 1fb40c5e60..f12667ee96 100644 --- a/TablePro/Views/Sidebar/MaintenanceSheet.swift +++ b/TablePro/Views/Sidebar/MaintenanceSheet.swift @@ -19,6 +19,7 @@ struct MaintenanceSheet: View { let operation: PluginMaintenanceOperation let tableName: String? let databaseName: String? + let databaseType: DatabaseType /// Statements for the given option values. Synchronous and pure, so calling it for every toggle /// while `body` runs costs an array of strings. let preview: ([String: String]) -> [String] @@ -30,12 +31,14 @@ struct MaintenanceSheet: View { operation: PluginMaintenanceOperation, tableName: String?, databaseName: String?, + databaseType: DatabaseType, preview: @escaping ([String: String]) -> [String], onExecute: @escaping ([String: String]) -> Void ) { self.operation = operation self.tableName = tableName self.databaseName = databaseName + self.databaseType = databaseType self.preview = preview self.onExecute = onExecute _values = State(initialValue: operation.defaultOptionValues) @@ -59,13 +62,12 @@ struct MaintenanceSheet: View { Text(String(localized: "SQL Preview")) .font(.caption) .foregroundStyle(.secondary) - Text(sqlPreview) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 4)) + HighlightedSQLTextView( + sql: sqlPreview, + databaseType: databaseType, + lineLayout: .fitted(maxLines: Self.previewMaxLines) + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) } Divider() @@ -143,4 +145,6 @@ struct MaintenanceSheet: View { private var sqlPreview: String { preview(values).joined(separator: ";\n") } + + internal static let previewMaxLines = 8 } diff --git a/TablePro/Views/Structure/ClickHousePartsView.swift b/TablePro/Views/Structure/ClickHousePartsView.swift index e5cb04a5fb..e2545ca3eb 100644 --- a/TablePro/Views/Structure/ClickHousePartsView.swift +++ b/TablePro/Views/Structure/ClickHousePartsView.swift @@ -24,14 +24,17 @@ struct ClickHousePartsView: View { @State private var parts: [ClickHousePartInfo] = [] @State private var isLoading = true @State private var errorMessage: String? - @State private var selection: Set = [] + @State private var columnLayout = ColumnLayoutState() @State private var loadedToken: Int? + @StateObject private var grid = StructureListGridModel(accessibilityLabel: StructureTab.parts.displayName) + @StateObject private var selection = StructureListSelection() var body: some View { VStack(spacing: 0) { if isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() } else if let error = errorMessage { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") @@ -42,6 +45,7 @@ struct ClickHousePartsView: View { .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() } else if parts.isEmpty { VStack(spacing: 8) { Image(systemName: "tray") @@ -52,6 +56,7 @@ struct ClickHousePartsView: View { .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() } else { partsToolbar partsTable @@ -70,13 +75,13 @@ struct ClickHousePartsView: View { Button(action: dropSelectedPartition) { Label(String(localized: "Drop Partition"), systemImage: "trash") } - .disabled(selection.count != 1) + .disabled(selection.ids.count != 1) .help(String(localized: "Drop selected partition")) Button(action: detachSelectedPartition) { Label(String(localized: "Detach Partition"), systemImage: "arrow.down.doc") } - .disabled(selection.count != 1) + .disabled(selection.ids.count != 1) .help(String(localized: "Detach selected partition")) Spacer() @@ -86,29 +91,51 @@ struct ClickHousePartsView: View { } private var partsTable: some View { - Table(parts, selection: $selection) { - TableColumn("Partition", value: \.partition) - .width(min: 80, ideal: 120) - TableColumn("Name", value: \.name) - .width(min: 100, ideal: 200) - TableColumn("Rows") { part in - Text(formatNumber(part.rows)) - .monospacedDigit() - } - .width(min: 60, ideal: 100) - TableColumn("Size") { part in - Text(formatBytes(part.bytesOnDisk)) - .monospacedDigit() - } - .width(min: 60, ideal: 100) - TableColumn("Modified", value: \.modificationTime) - .width(min: 100, ideal: 160) - TableColumn("Active") { part in - Image(systemName: part.active ? "checkmark.circle.fill" : "xmark.circle") - .selectionAwareTint(part.active ? .green : .secondary) - } - .width(min: 50, ideal: 60) + let parts = parts + let selection = selection + var hasher = Hasher() + for part in parts { + hasher.combine(part.id) } + return StructureListGrid( + model: grid, + columns: Self.columnTitles, + defaultWidths: Self.columnWidths, + contentKey: hasher.finalize(), + rows: { parts.map(Self.gridRow(for:)) }, + databaseType: connection.type, + selectedIDs: { selection.ids }, + select: { selection.ids = $0 }, + sortState: nil, + columnLayout: $columnLayout + ) + } + + internal static let columnWidths: [CGFloat] = [120, 200, 100, 100, 160, 60] + + static var columnTitles: [String] { + [ + String(localized: "Partition"), + String(localized: "Name"), + String(localized: "Rows"), + String(localized: "Size"), + String(localized: "Modified"), + String(localized: "Active"), + ] + } + + static func gridRow(for part: ClickHousePartInfo) -> StructureListRow { + StructureListRow( + id: part.id, + values: [ + part.partition, + part.name, + part.rows.formatted(.number.grouping(.automatic)), + ByteSizeFormatting.string(bytes: part.bytesOnDisk), + part.modificationTime, + part.active ? "YES" : "NO", + ] + ) } // MARK: - Actions @@ -150,7 +177,7 @@ struct ClickHousePartsView: View { ) guard await run(sql, description: String(localized: "Drop Partition"), kind: .destructiveQuery) else { return } - selection.removeAll() + selection.ids.removeAll() } } @@ -178,7 +205,7 @@ struct ClickHousePartsView: View { ) guard await run(sql, description: String(localized: "Detach Partition"), kind: .destructiveQuery) else { return } - selection.removeAll() + selection.ids.removeAll() } } @@ -230,7 +257,12 @@ struct ClickHousePartsView: View { } private func selectedPartitionValue() -> String? { - guard let selectedId = selection.first, + Self.partition(of: selection.ids, in: parts) + } + + internal static func partition(of selectedIDs: Set, in parts: [ClickHousePartInfo]) -> String? { + guard selectedIDs.count == 1, + let selectedId = selectedIDs.first, let part = parts.first(where: { $0.id == selectedId }) else { return nil } return part.partition @@ -260,6 +292,7 @@ struct ClickHousePartsView: View { )) } guard !Task.isCancelled else { return } + selection.ids = [] parts = result.rows.compactMap { row -> ClickHousePartInfo? in guard let name = row[safe: 1]?.asText else { return nil } let partition = row[safe: 0]?.asText ?? "" @@ -285,14 +318,4 @@ struct ClickHousePartsView: View { isLoading = false } - - // MARK: - Formatting - - private func formatNumber(_ number: UInt64) -> String { - number.formatted(.number.grouping(.automatic)) - } - - private func formatBytes(_ bytes: UInt64) -> String { - ByteSizeFormatting.string(bytes: bytes) - } } diff --git a/TablePro/Views/Structure/StructureListGrid.swift b/TablePro/Views/Structure/StructureListGrid.swift new file mode 100644 index 0000000000..7a4da1484b --- /dev/null +++ b/TablePro/Views/Structure/StructureListGrid.swift @@ -0,0 +1,181 @@ +// +// StructureListGrid.swift +// TablePro +// + +import AppKit +import Combine +import SwiftUI +import TableProPluginKit + +internal struct StructureListRow: Hashable { + internal let id: ID + internal let values: [String] +} + +/// Selected row ids held by reference: a read made during a SwiftUI update gets a `State` or `Binding` snapshot. +@MainActor +internal final class StructureListSelection: ObservableObject { + @Published internal var ids: Set = [] +} + +/// A read-only Structure list drawn by `DataGridView`. Its configuration names only the database type, +/// so `columnLayoutKey` stays nil and the list never writes into the table's saved column widths. +@MainActor +internal final class StructureListGridModel: ObservableObject, DataGridViewDelegate { + internal let changeManager = AnyChangeManager(DataChangeManager()) + internal var onSortStateChanged: ((SortState) -> Void)? + private let accessibilityLabel: String? + + internal private(set) var tableRows = TableRows() + internal private(set) var rowIDs: [ID] = [] + private var loadedKey: Int? + private weak var coordinator: TableViewCoordinator? + + internal init(accessibilityLabel: String? = nil) { + self.accessibilityLabel = accessibilityLabel + } + + internal func ensureLoaded(key: Int, columns: [String], rows: () -> [StructureListRow]) -> TableRows { + var hasher = Hasher() + hasher.combine(key) + hasher.combine(columns) + let combined = hasher.finalize() + guard loadedKey != combined else { return tableRows } + loadedKey = combined + + let listed = rows() + rowIDs = listed.map(\.id) + tableRows = TableRows.from( + queryRows: listed.map { $0.values.map(PluginCellValue.text) }, + columns: columns, + columnTypes: Array(repeating: .text(rawType: nil), count: columns.count), + columnNullable: Dictionary(columns.map { ($0, false) }, uniquingKeysWith: { first, _ in first }) + ) + return tableRows + } + + internal func rowIndex(forDisplayRow row: Int) -> Int? { + guard let index = DisplayRowMapping.rowIndex(forDisplay: row, displayIDs: coordinator?.displayIDs, in: tableRows), + rowIDs.indices.contains(index) else { return nil } + return index + } + + internal func id(atDisplayRow row: Int) -> ID? { + rowIndex(forDisplayRow: row).map { rowIDs[$0] } + } + + internal func ids(atDisplayRows rows: Set) -> Set { + Set(rows.compactMap { id(atDisplayRow: $0) }) + } + + internal func displayRows(of ids: Set) -> Set { + guard !ids.isEmpty else { return [] } + let displayIDs = coordinator?.displayIDs + return Set(rowIDs.indices.compactMap { index -> Int? in + guard ids.contains(rowIDs[index]), tableRows.rows.indices.contains(index) else { return nil } + return DisplayRowMapping.displayIndex(forRowID: tableRows.rows[index].id, displayIDs: displayIDs, in: tableRows) + }) + } + + /// The grid's row selection kept by row identity, so a sort, a filter or a refresh moves it with its rows. + /// Build it after `ensureLoaded`: the grid reads the positions it held when the binding was built. + internal func selection( + get selectedIDs: @escaping () -> Set, + set select: @escaping (Set) -> Void + ) -> Binding> { + Binding( + get: { self.displayRows(of: selectedIDs()) }, + set: { rows in + // Until the grid puts the selection back after a reload, what it publishes names the rows it showed before. + guard self.coordinator?.hasRestoredSelection != false else { return } + let current = selectedIDs() + guard rows != self.displayRows(of: current) else { return } + let selected = self.ids(atDisplayRows: rows) + guard selected != current else { return } + select(selected) + } + ) + } + + internal func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { + coordinator = tableViewCoordinator + if let accessibilityLabel { + tableViewCoordinator.tableView?.setAccessibilityLabel(accessibilityLabel) + } + // A reload drops the table view's selection without a notification, so the selection kept by identity is put back after each one. + tableViewCoordinator.hasRestoredSelection = false + } + + internal func dataGridSortStateChanged(_ state: SortState) { + onSortStateChanged?(state) + } + + internal func dataGridCopyRows(_ indices: Set) { + let lines = indices.sorted().compactMap { row -> String? in + guard let index = rowIndex(forDisplayRow: row) else { return nil } + return tableRows.rows[index].values.map { $0.asText ?? "" }.joined(separator: "\t") + } + guard !lines.isEmpty else { return } + ClipboardService.shared.writeText(lines.joined(separator: "\n")) + } +} + +/// A nil `sortState` turns header sorting off. `defaultWidths` follows `columns` by index. +internal struct StructureListGrid: View { + internal let model: StructureListGridModel + internal let columns: [String] + internal var defaultWidths: [CGFloat] = [] + internal let contentKey: Int + internal let rows: () -> [StructureListRow] + internal let databaseType: DatabaseType + internal let selectedIDs: () -> Set + internal let select: (Set) -> Void + internal var sortState: Binding? + @Binding internal var columnLayout: ColumnLayoutState + + internal static func layout( + _ layout: ColumnLayoutState, + columns: [String], + defaultWidths: [CGFloat] + ) -> ColumnLayoutState { + var filled = layout + for (column, width) in zip(columns, defaultWidths) where filled.columnWidths[column] == nil { + filled.columnWidths[column] = width + } + return filled + } + + internal var body: some View { + let model = model + let columns = columns + let defaultWidths = defaultWidths + let sortState = sortState + let tableRows = model.ensureLoaded(key: contentKey, columns: columns, rows: rows) + let storedLayout = $columnLayout + let layout = Binding( + get: { Self.layout(storedLayout.wrappedValue, columns: columns, defaultWidths: defaultWidths) }, + set: { storedLayout.wrappedValue = $0 } + ) + return DataGridView( + tableRowsProvider: { tableRows }, + changeManager: model.changeManager, + isEditable: false, + configuration: DataGridConfiguration( + databaseType: databaseType, + supportsColumnCommands: false, + supportsValueFilter: false, + supportsColumnSort: sortState != nil + ), + delegate: model, + selectedRowIndices: model.selection(get: selectedIDs, set: select), + sortState: sortState ?? .constant(SortState()), + columnLayout: layout, + contentRevision: contentKey + ) + .onAppear { + guard let sortState else { return } + model.onSortStateChanged = { sortState.wrappedValue = $0 } + } + } +} diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index f9e1df3463..322949a5ea 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -368,6 +368,7 @@ struct TableStructureView: View { private var contentArea: some View { if let error = errorMessage { errorView(error) + .gridStandInBackground() } else { tabContent } @@ -382,6 +383,7 @@ struct TableStructureView: View { Group { if shouldShowIndexesEmptyState { EmptyStateView.indexes { gridDelegate.dataGridAddRow() } + .gridStandInBackground() } else { structureGrid } @@ -392,12 +394,14 @@ struct TableStructureView: View { case .foreignKeys: if shouldShowForeignKeysEmptyState { EmptyStateView.foreignKeys { gridDelegate.dataGridAddRow() } + .gridStandInBackground() } else { structureGrid } case .checkConstraints: if shouldShowCheckConstraintsEmptyState { EmptyStateView.checkConstraints { gridDelegate.dataGridAddRow() } + .gridStandInBackground() } else { structureGrid } diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index bf880a1214..0fa09b5fba 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -8,30 +8,84 @@ import Combine import SwiftUI +@MainActor final class TriggerInspectorState: ObservableObject { @Published var searchText = "" - @Published var sortOrder: [KeyPathComparator] = [KeyPathComparator(\.name)] - @Published var selectedID: TriggerInfo.ID? + @Published var sortState = TriggerListColumns.defaultSort + @Published var selectedIDs: Set = [] func displayed(_ triggers: [TriggerInfo]) -> [TriggerInfo] { let filtered = searchText.isEmpty ? triggers : triggers.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - return filtered.sorted(using: sortOrder) + return TriggerListColumns.sorted(filtered, by: sortState) } func selectedTrigger(_ triggers: [TriggerInfo]) -> TriggerInfo? { let list = displayed(triggers) - if let id = selectedID, let match = list.first(where: { $0.id == id }) { - return match - } - return list.first + return list.first { selectedIDs.contains($0.id) } ?? list.first + } + + /// Edit and Delete act on one trigger, so they have no target while several rows are selected. + func actionTarget(_ triggers: [TriggerInfo]) -> TriggerInfo? { + let list = displayed(triggers) + let selected = list.filter { selectedIDs.contains($0.id) } + guard selected.count <= 1 else { return nil } + return selected.first ?? list.first + } + + func select(_ ids: Set) { + guard ids != selectedIDs else { return } + selectedIDs = ids } func ensureSelection(_ triggers: [TriggerInfo]) { - if selectedID == nil || !triggers.contains(where: { $0.id == selectedID }) { - selectedID = triggers.first?.id + let kept = selectedIDs.intersection(triggers.map(\.id)) + select(kept.isEmpty ? Set(triggers.first.map { [$0.id] } ?? []) : kept) + } +} + +enum TriggerListColumns { + static let nameColumn = 0 + + static var defaultSort: SortState { + SortState( + columns: [SortColumn(columnIndex: nameColumn, direction: .ascending, columnName: String(localized: "Name"))], + source: .user + ) + } + + static func titles(showsEnabled: Bool) -> [String] { + let base = [String(localized: "Name"), String(localized: "Timing"), String(localized: "Event")] + return showsEnabled ? base + [String(localized: "Enabled")] : base + } + + internal static func widths(showsEnabled: Bool) -> [CGFloat] { + showsEnabled ? [220, 90, 150, 70] : [240, 90, 160] + } + + static func values(for trigger: TriggerInfo, showsEnabled: Bool) -> [String] { + let base = [trigger.name, trigger.timing, trigger.event] + guard showsEnabled else { return base } + return base + [trigger.enabled.map { $0 ? "YES" : "NO" } ?? ""] + } + + static func sorted(_ triggers: [TriggerInfo], by state: SortState) -> [TriggerInfo] { + guard !state.columns.isEmpty else { return triggers } + let keyed = triggers.enumerated().map { offset, trigger in + (offset: offset, trigger: trigger, values: values(for: trigger, showsEnabled: true)) + } + return keyed.sorted { lhs, rhs in + for column in state.columns where lhs.values.indices.contains(column.columnIndex) { + let comparison = lhs.values[column.columnIndex].localizedStandardCompare(rhs.values[column.columnIndex]) + guard comparison != .orderedSame else { continue } + return column.direction == .ascending + ? comparison == .orderedAscending + : comparison == .orderedDescending + } + return lhs.offset < rhs.offset } + .map(\.trigger) } } @@ -59,6 +113,7 @@ struct TriggerDetailView: View { if isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() } else if triggers.isEmpty { emptyState } else { @@ -74,6 +129,7 @@ struct TriggerDetailView: View { } EmptyStateView.triggers() .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() } .sheet(item: $editorSheet, content: makeEditorSheet(for:)) } @@ -90,7 +146,7 @@ struct TriggerDetailView: View { primaryMinimum: 120, secondaryMinimum: 180 ) { - TriggerListPane(triggers: triggers, state: state) + TriggerListPane(triggers: triggers, databaseType: connection.type, state: state) } secondary: { TriggerDetailPane(triggers: triggers, state: state, databaseType: connection.type, onOpenInEditor: onOpenInEditor) } @@ -188,23 +244,23 @@ private struct TriggerActionBar: View { let onDelete: (TriggerInfo) -> Void var body: some View { - let selected = state.selectedTrigger(triggers) + let target = state.actionTarget(triggers) HStack(spacing: 8) { Button(action: onNew) { Label("New Trigger", systemImage: "plus") } Button { - if let selected { onEdit(selected) } + if let target { onEdit(target) } } label: { Label("Edit", systemImage: "pencil") } - .disabled(selected == nil) + .disabled(target == nil) Button { - if let selected { onDelete(selected) } + if let target { onDelete(target) } } label: { Label("Delete", systemImage: "trash") } - .disabled(selected == nil) + .disabled(target == nil) Spacer() } .buttonStyle(.borderless) @@ -213,13 +269,19 @@ private struct TriggerActionBar: View { } } -private struct TriggerListPane: View { - let triggers: [TriggerInfo] - @ObservedObject var state: TriggerInspectorState +internal struct TriggerListPane: View { + internal let triggers: [TriggerInfo] + internal let databaseType: DatabaseType + @ObservedObject internal var state: TriggerInspectorState - private var showEnabled: Bool { triggers.contains { $0.enabled != nil } } + @StateObject private var grid = StructureListGridModel( + accessibilityLabel: StructureTab.triggers.displayName + ) + @State private var columnLayout = ColumnLayoutState() - var body: some View { + private var showsEnabled: Bool { triggers.contains { $0.enabled != nil } } + + internal var body: some View { VStack(spacing: 0) { NativeSearchField(text: $state.searchText, placeholder: String(localized: "Filter")) .padding(.horizontal, 8) @@ -229,40 +291,26 @@ private struct TriggerListPane: View { } } - @ViewBuilder private var table: some View { - if showEnabled { - Table(state.displayed(triggers), selection: $state.selectedID, sortOrder: $state.sortOrder) { - TableColumn(String(localized: "Name"), value: \.name) - .width(min: 140, ideal: 220) - TableColumn(String(localized: "Timing"), value: \.timing) - .width(min: 70, ideal: 90) - TableColumn(String(localized: "Event"), value: \.event) - .width(min: 90, ideal: 150) - TableColumn(String(localized: "Enabled")) { trigger in - enabledIndicator(trigger) - } - .width(min: 60, ideal: 70) - } - } else { - Table(state.displayed(triggers), selection: $state.selectedID, sortOrder: $state.sortOrder) { - TableColumn(String(localized: "Name"), value: \.name) - .width(min: 140, ideal: 240) - TableColumn(String(localized: "Timing"), value: \.timing) - .width(min: 70, ideal: 90) - TableColumn(String(localized: "Event"), value: \.event) - .width(min: 90, ideal: 160) - } - } - } - - @ViewBuilder - private func enabledIndicator(_ trigger: TriggerInfo) -> some View { - if let enabled = trigger.enabled { - Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") - .selectionAwareTint(enabled ? .green : .secondary) - .accessibilityLabel(enabled ? String(localized: "Enabled") : String(localized: "Disabled")) + let showsEnabled = showsEnabled + let inspector = state + let rows = inspector.displayed(triggers).map { trigger in + StructureListRow(id: trigger.id, values: TriggerListColumns.values(for: trigger, showsEnabled: showsEnabled)) } + var hasher = Hasher() + hasher.combine(rows) + return StructureListGrid( + model: grid, + columns: TriggerListColumns.titles(showsEnabled: showsEnabled), + defaultWidths: TriggerListColumns.widths(showsEnabled: showsEnabled), + contentKey: hasher.finalize(), + rows: { rows }, + databaseType: databaseType, + selectedIDs: { inspector.selectedIDs }, + select: { inspector.select($0) }, + sortState: $state.sortState, + columnLayout: $columnLayout + ) } } diff --git a/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift index 785c6983aa..04e1953609 100644 --- a/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift +++ b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift @@ -82,7 +82,7 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, // MARK: - Expansion func outlineViewItemWillExpand(_ notification: Notification) { - guard !isRestoringExpansion, + guard !isRestoringExpansion, !isRebuildingRows, let node = notification.userInfo?["NSObject"] as? PrivilegeNode, !node.hasLoadedChildren, !node.isLoading else { return } @@ -100,13 +100,13 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, } func outlineViewItemDidExpand(_ notification: Notification) { - guard !isRestoringExpansion, + guard !isRestoringExpansion, !isRebuildingRows, let node = notification.userInfo?["NSObject"] as? PrivilegeNode else { return } viewModel.expansionStore.insert(node.persistentKey) } func outlineViewItemDidCollapse(_ notification: Notification) { - guard !isRestoringExpansion, + guard !isRestoringExpansion, !isRebuildingRows, let node = notification.userInfo?["NSObject"] as? PrivilegeNode else { return } pendingExpansions.remove(node.persistentKey) viewModel.expansionStore.remove(node.persistentKey) @@ -141,7 +141,7 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, // MARK: - Selection func outlineViewSelectionDidChange(_ notification: Notification) { - guard let outlineView else { return } + guard let outlineView, !isRebuildingRows else { return } let scopes = outlineView.selectedRowIndexes.compactMap { (outlineView.item(atRow: $0) as? PrivilegeNode)?.scope @@ -159,26 +159,20 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, guard let tableColumn, let node = item as? PrivilegeNode else { return nil } if tableColumn.identifier == Self.scopeColumn { - return hostingCell( - identifier: Self.scopeColumn, - outlineView: outlineView, - content: AnyView( - PrivilegeScopeRowView( - title: node.title, - symbolName: node.symbolName, - isLoading: node.isLoading, - loadError: node.loadError, - isRestricted: node.childrenAvailability == .restrictedToCurrentDatabase - ) + return cell( + Self.scopeColumn, + in: outlineView, + showing: PrivilegeScopeRowView( + title: node.title, + symbolName: node.symbolName, + isLoading: node.isLoading, + loadError: node.loadError, + isRestricted: node.childrenAvailability == .restrictedToCurrentDatabase ) ) } - return hostingCell( - identifier: Self.summaryColumn, - outlineView: outlineView, - content: AnyView(ScopeSummaryView(summary: summary(for: node))) - ) + return cell(Self.summaryColumn, in: outlineView, showing: ScopeSummaryView(summary: summary(for: node))) } private func summary(for node: PrivilegeNode) -> ScopeSummary { @@ -190,19 +184,8 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, ) } - private func hostingCell( - identifier: NSUserInterfaceItemIdentifier, - outlineView: NSOutlineView, - content: AnyView - ) -> NSView { - if let reused = outlineView.makeView(withIdentifier: identifier, owner: self) as? NSHostingView { - reused.rootView = content - return reused - } - let hosting = NSHostingView(rootView: content) - hosting.sizingOptions = [] - hosting.identifier = identifier - return hosting + private var isRebuildingRows: Bool { + (outlineView as? PrivilegeScopeOutline)?.isRebuildingRows ?? false } func refreshVisibleSummaries() { diff --git a/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift index 636508186e..496d9e80c7 100644 --- a/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift +++ b/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift @@ -14,9 +14,13 @@ struct PrivilegeScopeOutlineView: NSViewRepresentable { } func makeNSView(context: Context) -> NSScrollView { - let outlineView = NSOutlineView() - outlineView.dataSource = context.coordinator - outlineView.delegate = context.coordinator + Self.makeScrollView(coordinator: context.coordinator) + } + + static func makeScrollView(coordinator: PrivilegeScopeOutlineCoordinator) -> NSScrollView { + let outlineView = PrivilegeScopeOutline() + outlineView.dataSource = coordinator + outlineView.delegate = coordinator outlineView.style = .fullWidth outlineView.rowSizeStyle = .custom outlineView.rowHeight = 24 @@ -31,9 +35,9 @@ struct PrivilegeScopeOutlineView: NSViewRepresentable { outlineView.autosaveExpandedItems = false outlineView.setAccessibilityIdentifier("usersroles-scope-tree") - context.coordinator.configureColumns(on: outlineView) + coordinator.configureColumns(on: outlineView) outlineView.outlineTableColumn = outlineView.tableColumns.first - context.coordinator.outlineView = outlineView + coordinator.outlineView = outlineView let scrollView = NSScrollView() scrollView.documentView = outlineView @@ -41,6 +45,8 @@ struct PrivilegeScopeOutlineView: NSViewRepresentable { scrollView.hasHorizontalScroller = true scrollView.autohidesScrollers = true scrollView.drawsBackground = false + // Attached last: the chrome records the scroll view's background to give it back. + outlineView.themeChrome = ThemeTableChrome(tableView: outlineView, family: .records) return scrollView } @@ -65,3 +71,21 @@ struct PrivilegeScopeOutlineView: NSViewRepresentable { } } } + +internal final class PrivilegeScopeOutline: NSOutlineView { + internal var themeChrome: ThemeTableChrome? + + internal var isRebuildingRows: Bool { + themeChrome?.isRebuildingRows ?? false + } +} + +internal extension PrivilegeScopeOutlineCoordinator { + func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { + (outlineView as? PrivilegeScopeOutline)?.themeChrome?.makeRowView(in: outlineView) + } + + func cell(_ identifier: NSUserInterfaceItemIdentifier, in outlineView: NSOutlineView, showing content: some View) -> NSView { + ThemeTableChrome.HostingCell.make(identifier, in: outlineView, showing: content) + } +} diff --git a/TablePro/Views/VersionHistory/VersionHistoryTabView.swift b/TablePro/Views/VersionHistory/VersionHistoryTabView.swift index c0f1ec1289..06957b146f 100644 --- a/TablePro/Views/VersionHistory/VersionHistoryTabView.swift +++ b/TablePro/Views/VersionHistory/VersionHistoryTabView.swift @@ -31,7 +31,7 @@ internal struct VersionHistoryTabView: View { var body: some View { content .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) + .themeBackground(.windowBackdrop, native: Color(nsColor: .textBackgroundColor)) .task { await viewModel.loadList() } .onReceive(AppEvents.shared.versionHistoryRefreshRequested) { requestedTabId in guard requestedTabId == tabId else { return } diff --git a/TableProTests/Helpers/ThemeTestFixture.swift b/TableProTests/Helpers/ThemeTestFixture.swift new file mode 100644 index 0000000000..b4dfea8012 --- /dev/null +++ b/TableProTests/Helpers/ThemeTestFixture.swift @@ -0,0 +1,269 @@ +// +// ThemeTestFixture.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +/// Synchronous and main-actor, so nothing else on the main actor runs between an adopt and its restore. +@MainActor +internal enum ThemeTestFixture {} + +// MARK: - Engine + +internal extension ThemeTestFixture { + static func preservingEngine(_ body: () throws -> Result) rethrows -> Result { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + return try body() + } + + /// Only inside `preservingEngine` or `adopting`, which put the incoming pair back. + static func adopt(_ pair: ThemePair, appearance: ThemeAppearance) { + ThemeEngine.shared.adopt(ThemeSelection(pair: pair, effectiveAppearance: appearance)) + } + + static func adopt(_ overrides: [ThemeSlot: String], appearance: ThemeAppearance) throws { + adopt(try pair(overrides, on: appearance), appearance: appearance) + } + + static func adopting( + _ pair: ThemePair, + appearance: ThemeAppearance, + _ body: () throws -> Result + ) rethrows -> Result { + try preservingEngine { + adopt(pair, appearance: appearance) + return try body() + } + } + + static func adopting( + _ overrides: [ThemeSlot: String], + appearance: ThemeAppearance, + _ body: () throws -> Result + ) throws -> Result { + try adopting(try pair(overrides, on: appearance), appearance: appearance, body) + } + + /// Keeps the incoming appearance; a surface that still reads any slot then draws magenta. + static func withEverySlotOverridden(_ body: () throws -> Result) throws -> Result { + let everySlot = Dictionary(uniqueKeysWithValues: ThemeSlot.allCases.map { ($0, "#FF00FF") }) + return try adopting(try pair(everySlot), appearance: ThemeEngine.shared.effectiveAppearance, body) + } +} + +// MARK: - Themes + +internal extension ThemeTestFixture { + static let windowHex = "#282A36" + static let editorHex = "#1B3A2C" + static let gridHex = "#3A1E4F" + + /// A theme that keeps a default id ignores its syntax overrides, so an overridden one takes a test id. + static func theme(_ appearance: ThemeAppearance, _ overrides: [ThemeSlot: String]) throws -> ThemeDefinition { + var theme = BuiltInThemes.default(for: appearance) + guard !overrides.isEmpty else { return theme } + theme.id = "user.test.\(appearance.rawValue)-\(overrides.keys.map(\.rawValue).sorted().joined(separator: "+"))" + for (slot, hex) in overrides { + theme.overrides[slot] = try ThemeColor(validating: hex) + } + return theme + } + + /// The built-in pair with `overrides` on one appearance's theme; an empty set is the built-in pair. + static func pair(_ overrides: [ThemeSlot: String], on appearance: ThemeAppearance) throws -> ThemePair { + let themed = try theme(appearance, overrides) + switch appearance { + case .light: return ThemePair(light: themed, dark: BuiltInThemes.dark) + case .dark: return ThemePair(light: BuiltInThemes.light, dark: themed) + } + } + + /// Both themes set `overrides`, so a surface draws them in either appearance. + static func pair(_ overrides: [ThemeSlot: String]) throws -> ThemePair { + ThemePair(light: try theme(.light, overrides), dark: try theme(.dark, overrides)) + } +} + +// MARK: - Rendering + +internal extension ThemeTestFixture { + /// Not `ImageRenderer.cgImage`: it reuses an uncleared buffer, so a view that draws nothing reads back the last render. + static func render(_ view: some View, colorScheme: ColorScheme, scale: CGFloat = 1) -> NSBitmapImageRep? { + let renderer = ImageRenderer(content: view.environment(\.colorScheme, colorScheme)) + var image: CGImage? + renderer.render(rasterizationScale: scale) { size, draw in + let width = Int((size.width * scale).rounded(.up)) + let height = Int((size.height * scale).rounded(.up)) + guard width > 0, height > 0, + let space = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return } + context.clear(CGRect(x: 0, y: 0, width: width, height: height)) + context.scaleBy(x: scale, y: scale) + draw(context) + image = context.makeImage() + } + return image.map(NSBitmapImageRep.init(cgImage:)) + } + + static func bitmap(of view: some View, colorScheme: ColorScheme) throws -> NSBitmapImageRep { + try #require(render(view, colorScheme: colorScheme)) + } + + static func pixel(_ bitmap: NSBitmapImageRep, x: Int, y: Int) -> NSColor? { + bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) + } + + /// `point` is a fraction of the rendered size, measured from the top leading corner. + static func color(of view: some View, at point: CGPoint, colorScheme: ColorScheme) throws -> NSColor { + let rep = try bitmap(of: view, colorScheme: colorScheme) + let x = min(rep.pixelsWide - 1, Int(CGFloat(rep.pixelsWide) * point.x)) + let y = min(rep.pixelsHigh - 1, Int(CGFloat(rep.pixelsHigh) * point.y)) + return try #require(pixel(rep, x: x, y: y)) + } + + /// Rendered the same way as the surface, so the renderer's color management cancels out. + static func filled(_ style: some ShapeStyle, colorScheme: ColorScheme) throws -> NSColor { + try color(of: Rectangle().fill(style).frame(width: 24, height: 24), at: CGPoint(x: 0.5, y: 0.5), colorScheme: colorScheme) + } + + static func filled(_ color: NSColor, colorScheme: ColorScheme) throws -> NSColor { + try filled(Color(nsColor: color), colorScheme: colorScheme) + } + + static func filled(hex: String, colorScheme: ColorScheme) throws -> NSColor { + try filled(HexColor.color(hex), colorScheme: colorScheme) + } + + static func matches(_ lhs: NSColor, _ rhs: NSColor, tolerance: CGFloat = 0.02) -> Bool { + abs(lhs.redComponent - rhs.redComponent) <= tolerance + && abs(lhs.greenComponent - rhs.greenComponent) <= tolerance + && abs(lhs.blueComponent - rhs.blueComponent) <= tolerance + && abs(lhs.alphaComponent - rhs.alphaComponent) <= tolerance + } + + static func count(in bitmap: NSBitmapImageRep, rows: Range? = nil, where predicate: (NSColor) -> Bool) -> Int { + var count = 0 + for y in (rows ?? 0 ..< bitmap.pixelsHigh).clamped(to: 0 ..< bitmap.pixelsHigh) { + for x in 0 ..< bitmap.pixelsWide { + guard let color = pixel(bitmap, x: x, y: y) else { continue } + if predicate(color) { count += 1 } + } + } + return count + } + + static func isMagenta(_ color: NSColor) -> Bool { + color.alphaComponent > 0.5 + && color.redComponent > 0.75 + && color.greenComponent < 0.45 + && color.blueComponent > 0.75 + } + + static func sameBytes(_ lhs: NSBitmapImageRep, _ rhs: NSBitmapImageRep) -> Bool { + guard lhs.pixelsWide == rhs.pixelsWide, lhs.pixelsHigh == rhs.pixelsHigh, + let left = lhs.representation(using: .png, properties: [:]), + let right = rhs.representation(using: .png, properties: [:]) else { return false } + return left == right + } +} + +// MARK: - Hosting + +internal extension ThemeTestFixture { + /// Never ordered front and never closed: `close()` on a test window over-releases it. + static func host(_ view: some View, size: NSSize) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentView = NSHostingView(rootView: view.frame(width: size.width, height: size.height)) + window.contentView?.layoutSubtreeIfNeeded() + return window + } + + /// In a borderless Dark Aqua window, never ordered front and never closed. + static func hostingView(_ view: some View, size: NSSize) -> NSHostingView { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .darkAqua) + let host = NSHostingView(rootView: AnyView(view.frame(width: size.width, height: size.height))) + window.contentView = host + return host + } + + /// Every text view in the window except a field editor, in subview order. + static func textViews(in window: NSWindow) -> [NSTextView] { + textViews(in: window.contentView) + } + + private static func textViews(in view: NSView?) -> [NSTextView] { + guard let view else { return [] } + if let textView = view as? NSTextView { + return textView.isFieldEditor ? [] : [textView] + } + return view.subviews.flatMap { textViews(in: $0) } + } + + /// `ImageRenderer` leaves a `ScrollView`'s content out; caching the hosting view draws it. + static func snapshot(_ window: NSWindow) -> NSBitmapImageRep? { + guard let view = window.contentView, + let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return nil } + view.cacheDisplay(in: view.bounds, to: bitmap) + return bitmap + } +} + +// MARK: - Colors + +internal extension ThemeTestFixture { + /// A dynamic color resolved the way a view in that appearance draws it. + static func hex(_ color: NSColor?, in name: NSAppearance.Name = .aqua) -> String? { + guard let color, let appearance = NSAppearance(named: name) else { return nil } + var hex = "" + appearance.performAsCurrentDrawingAppearance { + hex = HexColor.string(from: color) + } + return hex + } + + static func hex(_ color: CGColor?) -> String? { + guard let color, let nsColor = NSColor(cgColor: color) else { return nil } + return HexColor.string(from: nsColor) + } + + static func baked(_ color: NSColor, in appearance: NSAppearance) -> CGColor { + resolved(in: appearance) { color.cgColor } + } + + /// `withAlphaComponent` outside the appearance freezes a system color in the test host's appearance. + static func resolved(in appearance: NSAppearance, _ make: () -> CGColor) -> CGColor { + var result: CGColor? + appearance.performAsCurrentDrawingAppearance { + result = make() + } + return result ?? make() + } +} diff --git a/TableProTests/Models/ExecutionSlotTests.swift b/TableProTests/Models/ExecutionSlotTests.swift index fbcdcb5cec..b0865e4b7f 100644 --- a/TableProTests/Models/ExecutionSlotTests.swift +++ b/TableProTests/Models/ExecutionSlotTests.swift @@ -11,39 +11,32 @@ import Testing internal struct ExecutionSlotTests { private static let timing = PluginQueryTiming(total: 0.6) - @Test("A tab that never ran draws no report and no separator, with or without a readout") + @Test("A tab that never ran draws no report") func neverRunTabDrawsNothing() { - for followsReadout in [false, true] { - let slot = ExecutionSlot(isOffered: true, followsReadout: followsReadout, isRevealed: false, lastTiming: nil) - #expect(slot.report == nil) - #expect(!slot.leadsWithSeparator) - } + let slot = ExecutionSlot(isOffered: true, isRevealed: false, lastTiming: nil) + #expect(slot.report == nil) } - @Test("A revealed run draws the spinner, set off by a separator only after a readout") + @Test("A revealed run draws the spinner, even over an earlier duration") func revealedRunDrawsTheSpinner() { - let alone = ExecutionSlot(isOffered: true, followsReadout: false, isRevealed: true, lastTiming: Self.timing) - let afterReadout = ExecutionSlot(isOffered: true, followsReadout: true, isRevealed: true, lastTiming: nil) + let afterEarlierRun = ExecutionSlot(isOffered: true, isRevealed: true, lastTiming: Self.timing) + let firstRun = ExecutionSlot(isOffered: true, isRevealed: true, lastTiming: nil) - #expect(alone.report == .running) - #expect(!alone.leadsWithSeparator) - #expect(afterReadout.report == .running) - #expect(afterReadout.leadsWithSeparator) + #expect(afterEarlierRun.report == .running) + #expect(firstRun.report == .running) } @Test("A finished run keeps its duration until the next run is revealed") func finishedRunDrawsItsDuration() { - let slot = ExecutionSlot(isOffered: true, followsReadout: true, isRevealed: false, lastTiming: Self.timing) + let slot = ExecutionSlot(isOffered: true, isRevealed: false, lastTiming: Self.timing) #expect(slot.report == .lastRun(Self.timing)) - #expect(slot.leadsWithSeparator) } @Test("A mode that does not offer the slot draws nothing, even while a run is revealed") func unofferedSlotDrawsNothing() { - let slot = ExecutionSlot(isOffered: false, followsReadout: true, isRevealed: true, lastTiming: Self.timing) + let slot = ExecutionSlot(isOffered: false, isRevealed: true, lastTiming: Self.timing) #expect(slot.report == nil) - #expect(!slot.leadsWithSeparator) } } diff --git a/TableProTests/Models/UI/JSONTreeParserTests.swift b/TableProTests/Models/UI/JSONTreeParserTests.swift index 67038374d1..70ca41b05b 100644 --- a/TableProTests/Models/UI/JSONTreeParserTests.swift +++ b/TableProTests/Models/UI/JSONTreeParserTests.swift @@ -316,29 +316,49 @@ struct JSONTreeParserTests { #expect(marker.decoration == .none) } - @Test("each tone keeps the color its values were drawn in") - func tonesKeepTheirColors() { - expectSystemTones() + @MainActor + @Test("Under the default pair each tone draws the default syntax color of its token", arguments: [ + NSAppearance.Name.aqua, .darkAqua + ]) + func tonesUseTheDefaultSyntaxColors(appearance: NSAppearance.Name) throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let theme = appearance == .aqua ? BuiltInThemes.light : BuiltInThemes.dark + @MainActor func drawn(_ tone: TreeValueTone) -> String? { ThemeTestFixture.hex(tone.color, in: appearance) } + + #expect(drawn(.key) == theme.overrides[.syntaxString]?.hex) + #expect(drawn(.string) == theme.overrides[.syntaxString]?.hex) + #expect(drawn(.number) == theme.overrides[.syntaxNumber]?.hex) + #expect(drawn(.literal) == theme.overrides[.syntaxNull]?.hex) + #expect(drawn(.serialized) == theme.overrides[.syntaxType]?.hex) + #expect(drawn(.reference) == theme.overrides[.syntaxComment]?.hex) + #expect(drawn(.container) == ThemeTestFixture.hex(.textColor, in: appearance)) + } } @MainActor - @Test("A theme that overrides every slot leaves each tone on its system color") - func tonesIgnoreTheTheme() throws { - try PaneThemeFixture.withEverySlotOverridden { - #expect(ThemeEngine.shared.resolved.isOverridden(.syntaxKeyword)) - expectSystemTones() + @Test("Under the default pair muted text keeps the secondary label color it was drawn in", arguments: [ + NSAppearance.Name.aqua, .darkAqua + ]) + func mutedToneStaysNative(appearance: NSAppearance.Name) throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let muted = ThemeTestFixture.hex(TreeValueTone.muted.color, in: appearance) + #expect(muted == ThemeTestFixture.hex(.secondaryLabelColor, in: appearance)) } } - private func expectSystemTones() { - #expect(TreeValueTone.key.color == .systemBlue) - #expect(TreeValueTone.container.color == .systemBlue) - #expect(TreeValueTone.string.color == .systemRed) - #expect(TreeValueTone.number.color == .systemPurple) - #expect(TreeValueTone.literal.color == .systemOrange) - #expect(TreeValueTone.serialized.color == .systemTeal) - #expect(TreeValueTone.reference.color == .systemGray) - #expect(TreeValueTone.muted.color == .secondaryLabelColor) + @MainActor + @Test("A theme's syntax colors reach every tone") + func tonesFollowTheTheme() throws { + try ThemeTestFixture.adopting(TreeOutlineFixture.tones, appearance: .light) { + let expected: [(TreeValueTone, ThemeSlot)] = [ + (.key, .syntaxString), (.string, .syntaxString), (.number, .syntaxNumber), + (.literal, .syntaxNull), (.serialized, .syntaxType), (.reference, .syntaxComment), + (.container, .editorText), (.muted, .editorLineNumber) + ] + for (tone, slot) in expected { + #expect(ThemeTestFixture.hex(tone.color) == TreeOutlineFixture.tones[slot], "\(tone)") + } + } } // MARK: - Search diff --git a/TableProTests/Services/ContentWindowBackdropTests.swift b/TableProTests/Services/ContentWindowBackdropTests.swift new file mode 100644 index 0000000000..f5c0233fd7 --- /dev/null +++ b/TableProTests/Services/ContentWindowBackdropTests.swift @@ -0,0 +1,105 @@ +// +// ContentWindowBackdropTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +/// The real controllers, built offscreen: never ordered front and never closed. +@Suite("Content windows carry the theme's window backdrop", .serialized) +@MainActor +struct ContentWindowBackdropTests { + private var drawsBackdrop: Bool { + if #available(macOS 26.0, *) { return true } + return false + } + + private func hex(of window: NSWindow) -> String { + var hex = "" + window.effectiveAppearance.performAsCurrentDrawingAppearance { + hex = HexColor.string(from: window.backgroundColor) + } + return hex + } + + /// The controller must hold the backdrop: a theme switched after it was built still has to reach the window. + private func expectFollowsTheTheme(_ window: NSWindow) throws { + window.appearance = NSAppearance(named: .darkAqua) + let original = window.backgroundColor + #expect(original == NSColor.windowBackgroundColor, "the default pair touched the window") + + try ThemeTestFixture.adopt([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + if drawsBackdrop { + #expect(hex(of: window) == ThemeTestFixture.windowHex) + } else { + #expect(window.backgroundColor === original) + } + + try ThemeTestFixture.adopt([.editorBackground: ThemeTestFixture.editorHex], appearance: .dark) + #expect(window.backgroundColor === original, "a content color reached the window") + + ThemeTestFixture.adopt(.builtIn, appearance: .dark) + #expect(window.backgroundColor === original) + } + + @Test("The main window takes a theme's window color and hands its own back") + func mainWindowFollowsTheTheme() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let fixture = try OffscreenConnectionWindow(size: CGSize(width: 1_200, height: 800)) + defer { fixture.tearDown() } + try expectFollowsTheTheme(fixture.window) + } + } + + @Test("A data file window takes a theme's window color and hands its own back") + func dataFileWindowFollowsTheTheme() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let controller = DataFileWindowController(document: DataFileDocument()) + let window = try #require(controller.window) + defer { window.delegate = nil } + try expectFollowsTheTheme(window) + } + } + + private static let contentWindowControllers = [ + "TablePro/Core/Services/Infrastructure/TabWindowController.swift", + "TablePro/Views/DataFiles/DataFileWindowController.swift", + "TablePro/Views/Compare/CompareSyncWindowController.swift", + "TablePro/Views/Results/ValueViewerWindowController.swift", + ] + + private static let systemWindowControllers = [ + "TablePro/Views/Settings/SettingsWindowController.swift", + "TablePro/Views/Welcome/WelcomeWindowController.swift", + "TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift", + ] + + private func code(of path: String) throws -> String { + let url = try ThemeSourceScan.repositoryRoot().appendingPathComponent(path) + let source = try String(contentsOf: url, encoding: .utf8) + return ThemeSourceScan.codeLines(of: source, path: path).map(\.text).joined(separator: "\n") + } + + /// A backdrop held only by a local stops answering theme changes as soon as `init` returns. + @Test("Each content window controller stores the backdrop it attaches") + func contentWindowsStoreTheirBackdrop() throws { + let stored = try ThemeSourceScan.Rule("stored backdrop", #"(let|var)\s+\w+\s*:\s*ThemeWindowBackdrop\b"#) + let attached = try ThemeSourceScan.Rule("attached backdrop", #"\w+\s*=\s*ThemeWindowBackdrop\(window:"#) + for path in Self.contentWindowControllers { + let code = try code(of: path) + #expect(stored.matches(code), "\(path) does not store a ThemeWindowBackdrop") + #expect(attached.matches(code), "\(path) does not attach a ThemeWindowBackdrop") + } + } + + @Test("Settings, Welcome and the connection form keep the system window") + func systemWindowsHaveNoBackdrop() throws { + for path in Self.systemWindowControllers { + let code = try code(of: path) + #expect(!code.contains("ThemeWindowBackdrop"), "\(path) attaches a window backdrop") + } + } +} diff --git a/TableProTests/Theme/NativeDefaultsTests.swift b/TableProTests/Theme/NativeDefaultsTests.swift index 397f8ad5e4..39a3102029 100644 --- a/TableProTests/Theme/NativeDefaultsTests.swift +++ b/TableProTests/Theme/NativeDefaultsTests.swift @@ -10,6 +10,7 @@ import AppKit import Foundation +import SwiftUI @testable import TablePro import Testing @@ -52,6 +53,7 @@ struct NativeDefaultsTests { @Test("Each surface's native color is the one AppKit draws there") func nativeColorsAreAppKitDefaults() { for theme in BuiltInThemes.all { + #expect(theme.color(for: .windowBackdrop) === NSColor.windowBackgroundColor) #expect(theme.color(for: .editorBackground) === NSColor.textBackgroundColor) #expect(theme.color(for: .editorText) === NSColor.textColor) #expect(theme.color(for: .editorSelection) === NSColor.selectedTextBackgroundColor) @@ -199,4 +201,150 @@ struct NativeDefaultsTests { #expect(Self.hex(palette[.gridSelection]) == Self.hex(NSColor.selectedContentBackgroundColor)) } } + + // MARK: - Window background + + @Test("Default Light and Default Dark leave the window background to the system") + func defaultsLeaveTheWindowBackgroundUnset() { + for theme in BuiltInThemes.all { + #expect(!theme.isOverridden(.windowBackdrop), "\(theme.id)") + #expect(theme.overrides[.windowBackdrop] == nil, "\(theme.id)") + } + } + + @Test("Dracula and Nord set the window background in Dark Aqua") + func bundledThemesSetTheWindowBackground() throws { + let expectations: [(id: String, hex: String)] = [("tablepro.dracula", "#282A36"), ("tablepro.nord", "#2E3440")] + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + + for expectation in expectations { + let theme = try Self.bundledTheme(expectation.id) + #expect(theme.overrides[.windowBackdrop]?.hex == expectation.hex, "\(expectation.id)") + darkAqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(theme.color(for: .windowBackdrop, in: darkAqua)) == expectation.hex, "\(expectation.id)") + } + } + } + + // MARK: - Fallback colors + + /// The fallback is not the slot's own native color, so a match proves the fallback is what draws. + @Test("Under the default pair the palette fallback draws the fallback in every appearance") + func paletteFallbackUnderTheDefaultPair() throws { + let palette = ThemePalette(revision: 910_004, source: ThemeSource()) + + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + appearance.performAsCurrentDrawingAppearance { + for slot in Self.surroundSlots { + #expect( + Self.hex(palette.color(slot, otherwise: .systemRed)) == Self.hex(NSColor.systemRed), + "\(slot.rawValue) in \(name.rawValue)" + ) + } + } + } + } + + @Test("The palette fallback gives way to the theme only in the appearance whose theme sets the slot") + func paletteFallbackUnderAnOverride() throws { + let source = ThemeSource() + source.update(ThemePair(light: BuiltInThemes.light, dark: try Self.bundledTheme("tablepro.dracula"))) + let palette = ThemePalette(revision: 910_005, source: source) + let aqua = try #require(NSAppearance(named: .aqua)) + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + + darkAqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(palette.color(.windowBackdrop, otherwise: .systemRed)) == "#282A36") + #expect(Self.hex(palette.color(.editorBackground, otherwise: .systemRed)) == "#282A36") + } + aqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(palette.color(.windowBackdrop, otherwise: .systemRed)) == Self.hex(NSColor.systemRed)) + } + } + + /// `NSAppearance(named:)` hands back plain Dark Aqua for the high-contrast names; only the system + /// setting produces one, so the rule is held here rather than through a drawing appearance. + @Test("Under Increase Contrast the window background yields to the system and content slots keep the theme") + func windowBackgroundYieldsToIncreasedContrast() throws { + let dracula = try Self.bundledTheme("tablepro.dracula") + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + + #expect(!dracula.isOverridden(.windowBackdrop, highContrast: true)) + #expect(dracula.isOverridden(.windowBackdrop, highContrast: false)) + #expect(dracula.isOverridden(.editorBackground, highContrast: true)) + #expect(dracula.isOverridden(.gridBackground, highContrast: true)) + #expect(dracula.isOverridden(.windowBackdrop, in: darkAqua)) + } + + @Test("ResolvedTheme hands back the fallback object itself for a slot the theme leaves out") + func resolvedFallbackIsTheObject() throws { + for theme in BuiltInThemes.all { + let resolved = ResolvedTheme(definition: theme) + for slot in Self.surroundSlots { + #expect(resolved.color(slot, otherwise: .systemRed) === NSColor.systemRed, "\(slot.rawValue)") + } + } + + let dracula = ResolvedTheme(definition: try Self.bundledTheme("tablepro.dracula")) + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + darkAqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(dracula.color(.windowBackdrop, otherwise: .systemRed)) == "#282A36") + #expect(Self.hex(dracula.color(.gridBackground, otherwise: .systemRed)) == "#282A36") + } + } + + @Test("cgColor with a fallback resolves the fallback inside the given appearance") + func cgColorFallbackResolvesInTheGivenAppearance() throws { + let resolved = ResolvedTheme(definition: BuiltInThemes.light) + var components: [NSAppearance.Name: [CGFloat]] = [:] + + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + var expected: [CGFloat] = [] + appearance.performAsCurrentDrawingAppearance { + expected = NSColor.underPageBackgroundColor.cgColor.components ?? [] + } + let actual = resolved.cgColor(.windowBackdrop, otherwise: .underPageBackgroundColor, in: appearance).components ?? [] + + #expect(!actual.isEmpty) + #expect(actual == expected, "\(name.rawValue)") + components[name] = actual + } + + #expect(components[.aqua] != components[.darkAqua]) + } + + // MARK: - SwiftUI surfaces + + private static func centerColor(of view: some View) throws -> NSColor { + try ThemeTestFixture.color(of: view, at: CGPoint(x: 0.5, y: 0.5), colorScheme: .dark) + } + + @Test("themeBackground draws the native style as passed until the active theme sets the slot") + func themeBackgroundNativePath() throws { + let swatch = Color.clear + .frame(width: 24, height: 24) + .themeBackground(.windowBackdrop, native: Color(nsColor: .systemRed)) + + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + #expect(ThemeShapeStyle.override(.windowBackdrop, contrast: .standard) == nil) + #expect(ThemeShapeStyle.override(.editorBackground, contrast: .standard) == nil) + let drawn = try Self.centerColor(of: swatch) + let native = try ThemeTestFixture.filled(.systemRed, colorScheme: .dark) + #expect(ThemeTestFixture.matches(drawn, native), "drew \(drawn), native \(native)") + } + + let dracula = ThemePair(light: BuiltInThemes.light, dark: try Self.bundledTheme("tablepro.dracula")) + try ThemeTestFixture.adopting(dracula, appearance: .dark) { + #expect(ThemeShapeStyle.override(.windowBackdrop, contrast: .standard) != nil) + #expect(ThemeShapeStyle.override(.windowBackdrop, contrast: .increased) == nil) + #expect(ThemeShapeStyle.override(.editorBackground, contrast: .increased) != nil) + let drawn = try Self.centerColor(of: swatch) + let themed = try ThemeTestFixture.filled(hex: "#282A36", colorScheme: .dark) + let native = try ThemeTestFixture.filled(.systemRed, colorScheme: .dark) + #expect(ThemeTestFixture.matches(drawn, themed), "drew \(drawn), theme \(themed)") + #expect(!ThemeTestFixture.matches(drawn, native)) + } + } } diff --git a/TableProTests/Theme/PaneBackgroundGuardTests.swift b/TableProTests/Theme/PaneBackgroundGuardTests.swift index b4c5cc4aa3..3d3fa29792 100644 --- a/TableProTests/Theme/PaneBackgroundGuardTests.swift +++ b/TableProTests/Theme/PaneBackgroundGuardTests.swift @@ -3,21 +3,89 @@ // TableProTests // // A List, Form or ScrollView draws its own background, and that background follows the system. -// Hiding it to paint another one, or wrapping a pane in the old `themedContent` surface, is how -// panes stopped matching an unthemed Mac app. +// Hiding it, painting list rows, or wrapping a pane in the old `themedContent` surface is how panes +// stopped matching an unthemed Mac app. A window's color comes only from ThemeWindowBackdrop. // import Foundation import Testing struct PaneBackgroundGuardTests { + private static let windowBackdropOwner = "TablePro/Theme/ThemeWindowBackdrop.swift" + + // A clear backdrop is how a borderless panel takes its own shape; it fills nothing. + private static let notClear = #"(?!\s*(?:NSColor)?\.clear\b)"# + static func rules() throws -> [ThemeSourceScan.Rule] { [ try ThemeSourceScan.Rule("hidden scroll content background", #"\.scrollContentBackground\(\s*\.hidden\s*\)"#), try ThemeSourceScan.Rule("themed content surface", #"\b[Tt]hemedContent"#), + try ThemeSourceScan.Rule("list row background", #"\.listRowBackground\("#), ] } + struct WindowRules { + let write: ThemeSourceScan.Rule + let named: ThemeSourceScan.Rule + let own: ThemeSourceScan.Rule + let subclass: ThemeSourceScan.Rule + let local: ThemeSourceScan.Rule + } + + static func windowRules() throws -> WindowRules { + WindowRules( + write: try ThemeSourceScan.Rule("background color write", #"\bbackgroundColor\s*=(?!=)"#), + named: try ThemeSourceScan.Rule( + "window background color", + #"(?:\b\w*(?:[Ww]indow|[Pp]anel|[Ss]heet)\w*|\b(?:w|win|wnd))[?!]?\.backgroundColor\s*=(?!=)"# + notClear + ), + own: try ThemeSourceScan.Rule( + "window subclass background color", + #"(?:\bself\.|(? Set { + var names: Set = [] + for line in lines { + let text = line.text as NSString + for match in rule.expression.matches(in: line.text, range: NSRange(location: 0, length: text.length)) { + for group in 1 ..< match.numberOfRanges where match.range(at: group).location != NSNotFound { + names.insert(text.substring(with: match.range(at: group))) + } + } + } + return names + } + + /// A bare `backgroundColor =` is the window's own only in a file that subclasses a window or panel. + static func windowBackgroundWrites(in lines: [ThemeSourceScan.Line]) throws -> [String] { + let rules = try windowRules() + let candidates = lines.filter { $0.path != windowBackdropOwner } + let windowFiles = Set(candidates.filter { rules.subclass.matches($0.text) }.map(\.path)) + var offenders = ThemeSourceScan.violations(in: candidates, rules: [rules.named]) + + ThemeSourceScan.violations(in: candidates.filter { windowFiles.contains($0.path) }, rules: [rules.own]) + + for (_, file) in Dictionary(grouping: candidates, by: \.path) { + let locals = windowLocals(in: file, rule: rules.local) + guard !locals.isEmpty else { continue } + let write = try ThemeSourceScan.Rule( + "window local background color", + #"(? 1_000, "Scanned only \(tree.fileCount) files; the tree did not resolve") + #expect( + offenders.isEmpty, + """ + Hold a ThemeWindowBackdrop instead; it sets the window color only while a theme sets \ + window.background and restores the system one otherwise: \(offenders.joined(separator: "\n")) """ ) } - @Test("The guard flags a hidden background and the themed surface") + @Test("The guard flags a hidden background, painted rows and the themed surface") func matcherCatchesAViolation() throws { let sample = """ Form { content } @@ -44,9 +129,88 @@ struct PaneBackgroundGuardTests { ScrollView { rows } .scrollContentBackground(.visible) struct ThemedContentSurface: ViewModifier {} + Text(name).listRowBackground(Color.clear) + // .listRowBackground(fill) was removed """ let lines = ThemeSourceScan.flaggedLines(in: sample, rules: try Self.rules()) - #expect(lines == [2, 3, 7]) + #expect(lines == [2, 3, 7, 8]) + } + + @Test("The guard flags a window color set outside the backdrop") + func matcherCatchesAWindowColor() throws { + let window = """ + final class SheetPanel: NSPanel { + func configure() { + backgroundColor = palette[.windowBackdrop] + self.backgroundColor = .windowBackgroundColor + backgroundColor = .clear + layer?.backgroundColor = fill + // backgroundColor = .red + if backgroundColor == .clear {} + } + } + """ + let controller = """ + final class PaneController: NSViewController { + func configure() { + backgroundColor = fill + view.window?.backgroundColor = palette[.windowBackdrop] + editorWindow.backgroundColor = .textBackgroundColor + panel.backgroundColor = NSColor.clear + scrollView.backgroundColor = fill + textView.backgroundColor = fill + sheet.backgroundColor = fill + sheetController.window?.backgroundColor = fill + win.backgroundColor = .clear + } + } + """ + let locals = """ + func restyle(_ target: NSWindow, view: NSView) { + target.backgroundColor = fill + let w = view.window + w?.backgroundColor = fill + guard let host = view.window else { return } + host.backgroundColor = fill + let content = windowContent.scrollView + content.backgroundColor = fill + host.contentView?.backgroundColor = fill + } + """ + let subclass = """ + final class ToolWindow: EditorWindow { + func configure() { + backgroundColor = fill + } + } + """ + let windowController = """ + final class ToolController: NSWindowController { + func configure() { + backgroundColor = fill + } + } + """ + let lines = ThemeSourceScan.codeLines(of: window, path: "SheetPanel.swift") + + ThemeSourceScan.codeLines(of: controller, path: "PaneController.swift") + + ThemeSourceScan.codeLines(of: locals, path: "Restyle.swift") + + ThemeSourceScan.codeLines(of: subclass, path: "ToolWindow.swift") + + ThemeSourceScan.codeLines(of: windowController, path: "ToolController.swift") + + ThemeSourceScan.codeLines(of: window, path: "TablePro/Theme/ThemeWindowBackdrop.swift") + let flagged = try Self.windowBackgroundWrites(in: lines).map { $0.components(separatedBy: ": ")[0] } + + #expect(flagged.sorted() == [ + "PaneController.swift:10", + "PaneController.swift:4", + "PaneController.swift:5", + "PaneController.swift:9", + "Restyle.swift:2", + "Restyle.swift:4", + "Restyle.swift:6", + "SheetPanel.swift:3", + "SheetPanel.swift:4", + "ToolWindow.swift:3", + ]) } } diff --git a/TableProTests/Theme/ThemeColorReadGuardTests.swift b/TableProTests/Theme/ThemeColorReadGuardTests.swift index 9b2b551727..ef65236f46 100644 --- a/TableProTests/Theme/ThemeColorReadGuardTests.swift +++ b/TableProTests/Theme/ThemeColorReadGuardTests.swift @@ -2,9 +2,9 @@ // ThemeColorReadGuardTests.swift // TableProTests // -// A theme colors the SQL editor, the other code views and the data grid, and nothing else. When -// 41 other files read theme slots, picking Dracula repainted History, the Row Inspector, the -// Server Dashboard and every banner in theme colors instead of the system's. +// A theme colors content and the window backdrop: the code views, the data grid and the closed list of +// content surfaces below. Chrome, controls, Settings and forms stay system, so a theme read or theming +// helper in any other file fails here. // import Foundation @@ -13,44 +13,103 @@ import Testing struct ThemeColorReadGuardTests { private static let allowedDirectories = [ - // The theme itself, and its editor in Settings. "TablePro/Theme/", "TablePro/Views/Settings/Appearance/", - // The SQL editor, and the data grid's cells and selection. - "TablePro/Views/Editor/", "TablePro/Views/Results/Cells/", "TablePro/Views/Results/Selection/", ] - // The query history drawer is a list pane, not a code view, so it draws in system colors. - private static let deniedDirectories = [ - "TablePro/Views/Editor/History/", - ] - // The data grid's own files under TablePro/Views/Results/; the satellites around it are panes. private static let gridFilePrefixes = ["DataGrid", "SortableHeader", "CellOverlay"] - // Code views that live outside the editor directory, and the theme preview in Settings. - private static let allowedFiles = [ + private static let codeViewFiles = [ + "SQLEditorView", + "TableProEditorTheme", "HighlightedSQLTextView", "SQLStatementPreview", "SQLCodePreview", "JSONCodeEditor", "AIChatCodeBlockView", + "AIChatToolUseBlockView", + "AIChatToolResultBlockView", "DDLTextView", "TriggerEditorView", "RewindReviewSheet", "AgentProposedStatementsView", - "SQLCompletionItem", "VimCursorManager", - "GhostTextRenderer", + "FoldPreviewView", + "CodeWell", + "ValueWell", + "TextValueEditor", + "ValueSourceEditor", + "TextViewerWindowController", + "SvgViewerContentView", + "CellImageWindowController", + "ServerOutputTextView", + "HexEditorContentView", + "PhpViewerView", + "FavoriteEditDialog", + "OptionsPaneView", + "SQLPreviewSheet", + "MaintenanceSheet", + "HistoryDetailPane", + ] + + private static let contentWindowFiles = [ + "TabWindowController", + "DataFileWindowController", + "CompareSyncWindowController", + "ValueViewerWindowController", + ] + + private static let contentSurfaceFiles = [ + "MainEditorContentView", + "GridStandInBackground", + "EditorTabStrip", + "EditorTabStripSurfaces", + "VersionHistoryTabView", + "ObjectSourceTabView", + "DataFileContentView", + "FilterPanelView", + "TableStructureView", + "TriggerDetailView", + "ClickHousePartsView", + "PrivilegeScopeOutlineView", + "TreeOutlineView", + "TreeOutlineCoordinator", + "TreeOutlineCellView", + "TreeValueStyle", + "TreeValueTone+Theme", + "JSONRowInspectorView", + "JSONNodeRowView", + "JSONRowColors", + "SingleLineEditorView", + "FieldEditorContent", + "MultiLineEditorView", + "JsonEditorView", + "ImageFieldView", + "PhpSerializedFieldView", + "CompareStatusStyle", + "TextDiffView", + "FileConflictDiffSheet", + "AIChatWalkthroughBlockView", + "ERDiagramPalette", + "ERDiagramNodeRenderer", + "ERDiagramScene", + "ERDiagramSceneView", + "MagnifiableCanvasView", + "QueryPlanDiagramView", + "QueryPlanDetailPane", + "QueryPlanOutlineCoordinator", + "ResultChartCanvas", "ThemePreviewCard", ] + private static var allowedFiles: [String] { + codeViewFiles + contentWindowFiles + contentSurfaceFiles + } + static func isAllowed(_ path: String) -> Bool { - if deniedDirectories.contains(where: { path.hasPrefix($0) }) { - return false - } if allowedDirectories.contains(where: { path.hasPrefix($0) }) { return true } @@ -69,11 +128,44 @@ struct ThemeColorReadGuardTests { try ThemeSourceScan.Rule("resolved color", #"\bresolved\.(?:color|cgColor|isOverridden)\("#), try ThemeSourceScan.Rule("ThemeSlot", #"\bThemeSlot\b"#), try ThemeSourceScan.Rule("theme slot", ThemeSourceScan.slotTokenPattern()), + try ThemeSourceScan.Rule( + "theme surface", + #"\btheme(?:Background|Foreground)\(|\bvalueWell\(|\bCodeWell\b|\bstyle:\s*\.code\b"# + ), + try ThemeSourceScan.Rule( + "theme chrome", + #"\bTheme(?:ShapeStyle|TextViewStyle|TableChrome|TableRowView|TableRowDrawing|WindowBackdrop)\b"# + ), + try ThemeSourceScan.Rule("theme helper", Self.helperPattern), ] } - @Test("Only code views and the data grid read theme colors") - func themeColorsStayInCodeViewsAndTheGrid() throws { + private static let helpers = ["ValueSourceEditor", "ERDiagramPalette", "DiagramCanvasBackdrop", "HighlightedSQLTextView"] + + private static var helperPattern: String { + #"\b(?:gridStandInBackground|diffLineForeground|makeEditorTheme)\(|\bCompareStatusStyle\.(?:lineTint|rowTint)"# + + #"|\bTableProEditorTheme\.make\(|\bEditorTabStripPalette\.trackFill\b"# + + #"|\b(?:TreeValueTone\.\w+|tone(?:\(for: \w+\))?)\.color\b"# + + #"|\b(?:"# + helpers.joined(separator: "|") + #")\b"# + } + + /// The files whose code reads the theme, by name without the extension. Declaring a helper is + /// not reading the theme through it. + static func readers(in lines: [ThemeSourceScan.Line], rules: [ThemeSourceScan.Rule]) throws -> Set { + let names = (helpers + ["makeEditorTheme"]).joined(separator: "|") + let declaration = try ThemeSourceScan.Rule( + "helper declaration", + #"\b(?:struct|enum|class|protocol|extension|typealias|func|var|let)\s+(?:"# + names + #")\b"# + ) + var readers: Set = [] + for line in lines where rules.contains(where: { $0.matches(line.text) }) && !declaration.matches(line.text) { + readers.insert(((line.path as NSString).lastPathComponent as NSString).deletingPathExtension) + } + return readers + } + + @Test("Only content surfaces and the window backdrop read the theme") + func themeStaysOnContentSurfaces() throws { let rules = try Self.rules() let tree = try ThemeSourceScan.scan(["TablePro"], rules: rules) { !Self.isAllowed($0) } let offenders = ThemeSourceScan.violations(in: tree.lines, rules: rules) @@ -82,8 +174,9 @@ struct ThemeColorReadGuardTests { #expect( offenders.isEmpty, """ - These read a theme color outside the code views and the data grid. Use a system color \ - (.primary, .secondary, Color(nsColor: .separatorColor), NSColor.labelColor, .systemRed): + These theme a surface outside the content files this suite lists. Chrome, controls, Settings \ + and forms use a system color (.primary, .secondary, Color(nsColor: .separatorColor), \ + NSColor.labelColor, .systemRed); a new content surface goes on the list: \(offenders.joined(separator: "\n")) """ ) @@ -102,6 +195,32 @@ struct ThemeColorReadGuardTests { } } + @Test("A file that stops reading the theme leaves the list") + func entriesStillReadTheTheme() throws { + let rules = try Self.rules() + let names = Set(Self.allowedFiles) + let tree = try ThemeSourceScan.scan(["TablePro"], rules: rules) { path in + names.contains(((path as NSString).lastPathComponent as NSString).deletingPathExtension) + } + let readers = try Self.readers(in: tree.lines, rules: rules) + + #expect(names.subtracting(readers).isEmpty, "Read no theme API: \(names.subtracting(readers).sorted())") + } + + @Test("Declaring a helper does not count as reading the theme, and calling it does") + func helperDeclarationIsNotARead() throws { + let rules = try Self.rules() + let declares = ThemeSourceScan.codeLines( + of: "final class DiagramCanvasBackdrop {\nstruct HighlightedSQLTextView: NSViewRepresentable {", + path: "Helper.swift" + ) + let calls = ThemeSourceScan.codeLines(of: "backdrop = DiagramCanvasBackdrop(scrollView: scrollView)", path: "Caller.swift") + + #expect(ThemeSourceScan.violations(in: declares, rules: rules).count == 2) + #expect(try Self.readers(in: declares, rules: rules).isEmpty) + #expect(try Self.readers(in: calls, rules: rules) == ["Caller"]) + } + @Test("The guard flags a theme read and passes plain code") func matcherCatchesAViolation() throws { let sample = """ @@ -111,10 +230,28 @@ struct ThemeColorReadGuardTests { let local = resolved[fieldId] let request = self.editorText let tint = activeTheme.color(for: .syntaxKeyword) + content.themeBackground(slot, native: .background) + let chrome = ThemeTableChrome(tableView: outline, family: .records) + CodeWell(sql, font: font) + TextValueEditor(text: $text, style: .code) + field.valueWell(clipsContent: true) + let style: Kind = .codeBlock + placeholder.gridStandInBackground() + // CodeWell and .valueWell() named in a comment + content.background(.background) + keyField.textColor = TreeValueTone.key.color + Color(nsColor: tone(for: scalar).color) + isEmphasized ? .alternateSelectedControlTextColor : tone.color + .fill(EditorTabStripPalette.trackFill) + backdrop = DiagramCanvasBackdrop(scrollView: scrollView) + HighlightedSQLTextView(sql: sql, lineLayout: .wrapped) + theme: TableProEditorTheme.make(), + final class ThemedRow: NSTableRowView, ThemeTableRowDrawing {} + swatch.color = decoration.color """ let lines = ThemeSourceScan.flaggedLines(in: sample, rules: try Self.rules()) - #expect(lines == [1, 3, 6]) + #expect(lines == [1, 3, 6, 7, 8, 9, 10, 11, 13, 16, 17, 18, 19, 20, 21, 22, 23]) let messages = ThemeSourceScan.violations( in: ThemeSourceScan.codeLines(of: sample, path: "Pane.swift"), @@ -123,19 +260,30 @@ struct ThemeColorReadGuardTests { #expect(messages.first?.hasPrefix("Pane.swift:1: ") == true, "\(messages)") } - @Test("The allowlist covers the grid and code views and nothing beside them") + @Test("The allowlist covers content surfaces and nothing beside them") func allowlistShape() { #expect(Self.isAllowed("TablePro/Views/Results/DataGridRowView.swift")) #expect(Self.isAllowed("TablePro/Views/Results/Extensions/DataGridView+Selection.swift")) #expect(Self.isAllowed("TablePro/Views/Results/Cells/DataGridCellPalette.swift")) #expect(Self.isAllowed("TablePro/Views/Editor/SQLEditorView.swift")) #expect(Self.isAllowed("TablePro/Views/Structure/DDLTextView.swift")) + #expect(Self.isAllowed("TablePro/Views/Structure/TriggerDetailView.swift")) + #expect(Self.isAllowed("TablePro/Views/Results/TreeOutline/TreeValueTone+Theme.swift")) + #expect(Self.isAllowed("TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift")) + #expect(Self.isAllowed("TablePro/Views/RowInspector/JSON/JSONRowColors.swift")) + #expect(Self.isAllowed("TablePro/Views/Editor/History/HistoryDetailPane.swift")) + #expect(Self.isAllowed("TablePro/Core/Services/Infrastructure/TabWindowController.swift")) #expect(Self.isAllowed("TablePro/Theme/ThemeEngine+EditorTheme.swift")) - #expect(!Self.isAllowed("TablePro/Views/Editor/History/HistoryDetailPane.swift")) - #expect(!Self.isAllowed("TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift")) + #expect(!Self.isAllowed("TablePro/Views/Editor/QueryEditorBar.swift")) + #expect(!Self.isAllowed("TablePro/Views/Editor/History/HistoryListPane.swift")) + #expect(!Self.isAllowed("TablePro/Views/Results/TreeOutline/TreeOutlineMenu.swift")) #expect(!Self.isAllowed("TablePro/Views/Results/InlineErrorBanner.swift")) - #expect(!Self.isAllowed("TablePro/Views/RowInspector/JSON/JSONRowColors.swift")) - #expect(!Self.isAllowed("TablePro/Views/Structure/TriggerDetailView.swift")) + #expect(!Self.isAllowed("TablePro/Views/RowInspector/InspectorFieldListView.swift")) + #expect(!Self.isAllowed("TablePro/Views/UsersRoles/PrivilegeScopeRowView.swift")) + #expect(!Self.isAllowed("TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift")) + #expect(!Self.isAllowed("TablePro/Views/Settings/GeneralSettingsView.swift")) + #expect(!Self.isAllowed("TablePro/Views/Welcome/WelcomeOutlineView.swift")) + #expect(!Self.isAllowed("TablePro/Views/ConnectionForm/ConnectionFormWindowController.swift")) } } diff --git a/TableProTests/Theme/ThemeContrastTests.swift b/TableProTests/Theme/ThemeContrastTests.swift new file mode 100644 index 0000000000..4a9114b4da --- /dev/null +++ b/TableProTests/Theme/ThemeContrastTests.swift @@ -0,0 +1,61 @@ +// +// ThemeContrastTests.swift +// TableProTests +// +// Sidebar sections, status lines and empty states put the system's secondary label on the window +// background, so a bundled theme that sets one has to keep that label legible. +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct ThemeContrastTests { + private static func bundledThemes() throws -> [ThemeDefinition] { + let directory = try ThemeSourceScan.repositoryRoot().appendingPathComponent("TablePro/Resources/Themes") + return try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + .map { try ThemeDocument(data: try Data(contentsOf: $0)).definition() } + } + + /// `secondaryLabelColor` is translucent, so what the eye reads is the label over the fill. + private static func composite(_ foreground: NSColor, over background: NSColor) -> NSColor { + let alpha = foreground.alphaComponent + return NSColor( + srgbRed: foreground.redComponent * alpha + background.redComponent * (1 - alpha), + green: foreground.greenComponent * alpha + background.greenComponent * (1 - alpha), + blue: foreground.blueComponent * alpha + background.blueComponent * (1 - alpha), + alpha: 1 + ) + } + + private static func contrast(_ lhs: NSColor, _ rhs: NSColor) -> CGFloat { + let first = lhs.relativeLuminance + let second = rhs.relativeLuminance + return (max(first, second) + 0.05) / (min(first, second) + 0.05) + } + + @Test("The secondary label stays legible on every bundled window background") + func secondaryLabelOnWindowBackground() throws { + let themed = try Self.bundledThemes().filter { $0.isOverridden(.windowBackdrop) } + #expect(themed.count >= 2, "Expected Dracula and Nord to set a window background") + + for theme in themed { + let appearance = try #require(NSAppearance(named: theme.appearance == .dark ? .darkAqua : .aqua)) + var label: NSColor? + var background: NSColor? + appearance.performAsCurrentDrawingAppearance { + label = NSColor.secondaryLabelColor.usingColorSpace(.sRGB) + background = theme.color(for: .windowBackdrop, in: appearance).usingColorSpace(.sRGB) + } + let fill = try #require(background) + let text = Self.composite(try #require(label), over: fill) + let ratio = Self.contrast(text, fill) + + #expect(ratio >= Color.minimumLabelContrast, "\(theme.id): \(ratio)") + } + } +} diff --git a/TableProTests/Theme/ThemeDocumentTests.swift b/TableProTests/Theme/ThemeDocumentTests.swift index 62d04dbfc2..36c2a7638e 100644 --- a/TableProTests/Theme/ThemeDocumentTests.swift +++ b/TableProTests/Theme/ThemeDocumentTests.swift @@ -241,9 +241,26 @@ struct ThemeDocumentTests { let editor = try #require(root["editor"] as? [String: Any]) #expect(root["dataGrid"] == nil) + #expect(root["window"] == nil) #expect(Set(editor.keys) == ["syntax"]) } + @Test("The window background loads from the window group and round-trips") + func windowBackgroundRoundTrips() throws { + let document = try Self.document(Self.withColor("#282a36", at: ["window", "background"])) + #expect(document.colors.mapValues(\.hex) == [.windowBackdrop: "#282A36"]) + + let theme = document.definition() + let object = try JSONSerialization.jsonObject(with: try ThemeEncoder.data(for: theme)) + let root = try #require(object as? [String: Any]) + #expect(root["window"] as? [String: String] == ["background": "#282A36"]) + #expect(root["editor"] == nil) + + let reread = try ThemeDocument(data: try ThemeEncoder.data(for: theme)).definition() + #expect(reread == theme) + #expect(reread.overrides[.windowBackdrop]?.hex == "#282A36") + } + @Test("A theme round-trips through the encoder") func encoderRoundTrips() throws { var overrides: [ThemeSlot: ThemeColor] = [:] diff --git a/TableProTests/Theme/ThemeSlotCoverageTests.swift b/TableProTests/Theme/ThemeSlotCoverageTests.swift index 373fe4d372..97c4bca1cd 100644 --- a/TableProTests/Theme/ThemeSlotCoverageTests.swift +++ b/TableProTests/Theme/ThemeSlotCoverageTests.swift @@ -7,6 +7,7 @@ // `.editorText` through `self.editorText` and for `.gridSelection` through `.gridSelectionText`. // +import AppKit import Foundation @testable import TablePro import Testing @@ -71,6 +72,8 @@ struct ThemeSlotCoverageTests { for slot in ThemeSlot.allCases { switch slot.group { + case .window: + #expect(slot.rawValue.hasPrefix("window."), "\(slot.rawValue)") case .editor: #expect(slot.rawValue.hasPrefix("editor."), "\(slot.rawValue)") #expect(!slot.rawValue.hasPrefix("editor.syntax."), "\(slot.rawValue)") @@ -89,6 +92,16 @@ struct ThemeSlotCoverageTests { #expect(grouped.count == ThemeSlot.allCases.count) } + /// The theme editor lists one section per group in this order, so the window leads the sheet. + @Test("The window group comes first and holds only the window background") + func windowGroupLeads() { + #expect(ThemeSlotGroup.allCases.first == .window) + #expect(ThemeSlotGroup.window.slots == [.windowBackdrop]) + #expect(ThemeSlot.windowBackdrop.rawValue == "window.background") + #expect(ThemeSlot.windowBackdrop.nativeColor === NSColor.windowBackgroundColor) + #expect(ThemeSlot.allCases.filter(\.yieldsToIncreasedContrast) == [.windowBackdrop]) + } + // MARK: - Bundled themes /// The files on disk are the list, so a third bundled theme is decoded here the day it lands. diff --git a/TableProTests/Theme/ThemeTableChromeTests.swift b/TableProTests/Theme/ThemeTableChromeTests.swift new file mode 100644 index 0000000000..a4af5d8eb7 --- /dev/null +++ b/TableProTests/Theme/ThemeTableChromeTests.swift @@ -0,0 +1,596 @@ +// +// ThemeTableChromeTests.swift +// TableProTests +// +// A content table under a default theme has to stay AppKit's own: its background, stripes, grid +// color, scroll view material, row view class and selection. Under a theme it takes the theme's +// colors, its selection keeps the shape the table style gives it, and its records cells take the +// theme's text. +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +private final class ChromeTableSource: NSObject, NSTableViewDataSource, NSTableViewDelegate { + static let cellIdentifier = NSUserInterfaceItemIdentifier("value") + + weak var chrome: ThemeTableChrome? + var rowCount = 3 + var vendsCells = false + + func numberOfRows(in tableView: NSTableView) -> Int { rowCount } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + chrome?.makeRowView(in: tableView) + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard vendsCells else { return nil } + return ThemeTableChrome.HostingCell.make(Self.cellIdentifier, in: tableView, showing: Text("Row \(row)")) + } +} + +/// The table every content table was before the theme reached it: no row view delegate at all. +@MainActor +private final class StockTableSource: NSObject, NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { 3 } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { nil } +} + +/// The themed row recolors what `drawSelection(in:)` draws, so the selection's shape is held to that +/// method's own output. A row without the override draws through its layer, which a bitmap cache +/// captures at full width. +@MainActor +private final class DrawPathTableRowView: NSTableRowView { + // swiftlint:disable:next unneeded_override + override func drawSelection(in dirtyRect: NSRect) { + super.drawSelection(in: dirtyRect) + } +} + +@MainActor +private final class DrawPathTableSource: NSObject, NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { 3 } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { DrawPathTableRowView() } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { nil } +} + +/// A bitmap cache converts through its own color space, so a theme color is held to the same color +/// drawn by a plain fill into the same kind of cache. +@MainActor +private final class FillView: NSView { + let color: NSColor + + init(color: NSColor) { + self.color = color + super.init(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + override func draw(_ dirtyRect: NSRect) { + color.setFill() + dirtyRect.fill() + } +} + +/// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are +/// synchronous and `@MainActor`, so nothing else on the main actor runs between the two. +@Suite("Content tables follow the theme", .serialized) +@MainActor +struct ThemeTableChromeTests { + private struct Table { + let window: NSWindow + let scrollView: NSScrollView + let tableView: NSTableView + let delegate: AnyObject + + var source: ChromeTableSource? { delegate as? ChromeTableSource } + } + + private static let rowFrame = NSRect(x: 0, y: 0, width: 160, height: 24) + + /// Never ordered front and never closed: `close()` on a test window over-releases it. + private func makeTable( + style: NSTableView.Style = .inset, + alternates: Bool = true, + source: (NSObject & NSTableViewDataSource & NSTableViewDelegate)? = nil + ) -> Table { + let tableView = NSTableView(frame: NSRect(x: 0, y: 0, width: 240, height: 120)) + tableView.style = style + tableView.headerView = nil + tableView.usesAlternatingRowBackgroundColors = alternates + tableView.rowHeight = 24 + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("value")) + column.width = 200 + tableView.addTableColumn(column) + + let source = source ?? ChromeTableSource() + tableView.dataSource = source + tableView.delegate = source + + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 240, height: 120)) + scrollView.documentView = tableView + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 240, height: 120), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentView = scrollView + return Table(window: window, scrollView: scrollView, tableView: tableView, delegate: source) + } + + private func attach(_ family: ThemeTableChrome.Family, to table: Table) -> ThemeTableChrome { + let chrome = ThemeTableChrome(tableView: table.tableView, family: family) + table.source?.chrome = chrome + table.tableView.reloadData() + table.tableView.layoutSubtreeIfNeeded() + return chrome + } + + private func hex(_ color: NSColor?) throws -> String { + let color = try #require(color) + var hex = "" + NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { + hex = HexColor.string(from: color) + } + return hex + } + + private func pixels(of view: NSView) throws -> NSBitmapImageRep { + let rep = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: rep) + return rep + } + + private func color(in rep: NSBitmapImageRep, x: Int, y: Int) throws -> NSColor { + let color = try #require(rep.colorAt(x: x, y: y)) + return try #require(color.usingColorSpace(.sRGB)) + } + + private func cached(_ fill: NSColor) throws -> NSColor { + let view = FillView(color: fill) + view.appearance = NSAppearance(named: .aqua) + let rep = try pixels(of: view) + return try color(in: rep, x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2) + } + + private func matches(_ lhs: NSColor, _ rhs: NSColor, tolerance: CGFloat = 0.03) -> Bool { + abs(lhs.redComponent - rhs.redComponent) <= tolerance + && abs(lhs.greenComponent - rhs.greenComponent) <= tolerance + && abs(lhs.blueComponent - rhs.blueComponent) <= tolerance + } + + private func selected(_ row: NSTableRowView) -> NSTableRowView { + row.appearance = NSAppearance(named: .aqua) + row.wantsLayer = true + row.isSelected = true + row.isEmphasized = true + return row + } + + // MARK: - Table properties + + @Test("Under the default theme the table, its stripes and its scroll view stay AppKit's own") + func defaultThemeTouchesNothing() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + for family in [ThemeTableChrome.Family.records, .code] { + let table = makeTable() + let background = table.tableView.backgroundColor + let gridColor = table.tableView.gridColor + let drawsBackground = table.scrollView.drawsBackground + let chrome = attach(family, to: table) + + #expect(table.tableView.backgroundColor === background) + #expect(table.tableView.gridColor === gridColor) + #expect(table.tableView.usesAlternatingRowBackgroundColors) + #expect(table.scrollView.drawsBackground == drawsBackground) + #expect(!chrome.isBackgroundThemed) + #expect(chrome.rowBackgroundColor(forRow: 1) == nil) + #expect(chrome.selectionColor(emphasized: true) == nil) + #expect(chrome.selectionColor(emphasized: false) == nil) + } + } + } + + @Test("A records table takes the grid's background, stripes and grid line, and gets its own back") + func recordsFamilyThemesAndRestores() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let table = makeTable() + let background = table.tableView.backgroundColor + let gridColor = table.tableView.gridColor + let drawsBackground = table.scrollView.drawsBackground + let chrome = attach(.records, to: table) + + try ThemeTestFixture.adopt([ + .gridBackground: "#FAFAFA", + .gridAlternateRow: "#F0F0F0", + .gridLine: "#DDDDDD", + .gridSelection: "#C8E1FF", + .gridInactiveSelection: "#E0E0E0", + ], appearance: .light) + #expect(try hex(table.tableView.backgroundColor) == "#FAFAFA") + #expect(try hex(table.tableView.gridColor) == "#DDDDDD") + #expect(!table.tableView.usesAlternatingRowBackgroundColors) + #expect(!table.scrollView.drawsBackground) + #expect(try hex(chrome.rowBackgroundColor(forRow: 0)) == "#FAFAFA") + #expect(try hex(chrome.rowBackgroundColor(forRow: 1)) == "#F0F0F0") + #expect(try hex(chrome.selectionColor(emphasized: true)) == "#C8E1FF") + #expect(try hex(chrome.selectionColor(emphasized: false)) == "#E0E0E0") + + try ThemeTestFixture.adopt([:], appearance: .light) + #expect(table.tableView.backgroundColor == background) + #expect(table.tableView.gridColor == gridColor) + #expect(table.tableView.usesAlternatingRowBackgroundColors) + #expect(table.scrollView.drawsBackground == drawsBackground) + #expect(chrome.rowBackgroundColor(forRow: 1) == nil) + } + } + + @Test("A code table takes the editor's background without stripes, and the editor's selection") + func codeFamilyUsesEditorSlots() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([.editorBackground: "#FDF6E3", .editorSelection: "#EEE8D5", .gridLine: "#DDDDDD"], appearance: .light) + let table = makeTable() + let gridColor = table.tableView.gridColor + let chrome = attach(.code, to: table) + + #expect(try hex(table.tableView.backgroundColor) == "#FDF6E3") + #expect(!table.tableView.usesAlternatingRowBackgroundColors) + #expect(table.tableView.gridColor === gridColor) + #expect(try hex(chrome.rowBackgroundColor(forRow: 0)) == "#FDF6E3") + #expect(try hex(chrome.rowBackgroundColor(forRow: 1)) == "#FDF6E3") + #expect(try hex(chrome.selectionColor(emphasized: true)) == "#EEE8D5") + #expect(try hex(chrome.selectionColor(emphasized: false)) == "#EEE8D5") + } + } + + @Test("A table that did not stripe stays unstriped under a theme") + func unstripedTableStaysSolid() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([.gridBackground: "#FAFAFA", .gridAlternateRow: "#F0F0F0"], appearance: .light) + let table = makeTable(alternates: false) + let chrome = attach(.records, to: table) + + #expect(try hex(chrome.rowBackgroundColor(forRow: 1)) == "#FAFAFA") + try ThemeTestFixture.adopt([:], appearance: .light) + #expect(!table.tableView.usesAlternatingRowBackgroundColors) + } + } + + // MARK: - Row drawing + + @Test("Under the default theme the rows are AppKit's own row view and draw what a stock table draws") + func defaultRowsAreNative() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let table = makeTable(alternates: false) + let chrome = attach(.records, to: table) + let stock = makeTable(alternates: false, source: StockTableSource()) + stock.tableView.reloadData() + stock.tableView.layoutSubtreeIfNeeded() + + #expect(!chrome.themesRows) + #expect(chrome.makeRowView(in: table.tableView) == nil) + let row = try #require(table.tableView.rowView(atRow: 0, makeIfNecessary: true)) + let stockRow = try #require(stock.tableView.rowView(atRow: 0, makeIfNecessary: true)) + #expect(type(of: row) == NSTableRowView.self) + #expect(type(of: row) == type(of: stockRow)) + + let themed = try selectedRowPixels(in: table) + let native = try selectedRowPixels(in: stock) + let x = native.pixelsWide / 2 + let y = native.pixelsHigh / 2 + #expect(matches(try color(in: themed, x: x, y: y), try color(in: native, x: x, y: y))) + #expect(row.interiorBackgroundStyle == stockRow.interiorBackgroundStyle) + } + } + + @Test("A theme that draws the rows swaps in the themed row class, and a default theme swaps AppKit's back") + func rowClassFollowsTheTheme() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let source = ChromeTableSource() + source.rowCount = 40 + let table = makeTable(source: source) + let chrome = attach(.records, to: table) + table.tableView.selectRowIndexes(IndexSet(integer: 12), byExtendingSelection: false) + table.tableView.scroll(NSPoint(x: 0, y: 240)) + table.tableView.layoutSubtreeIfNeeded() + let origin = table.scrollView.contentView.bounds.origin + #expect(origin.y > 0) + #expect(type(of: try #require(table.tableView.rowView(atRow: 12, makeIfNecessary: true))) == NSTableRowView.self) + + for overrides in [[ThemeSlot.gridSelection: "#C8E1FF"], [.gridInactiveSelection: "#E0E0E0"], [.gridBackground: "#FAFAFA"]] { + try ThemeTestFixture.adopt(overrides, appearance: .light) + #expect(chrome.themesRows) + let themedRow = try #require(table.tableView.rowView(atRow: 12, makeIfNecessary: true) as? ThemeTableRowView) + #expect(themedRow.chrome === chrome) + #expect(table.tableView.selectedRowIndexes == IndexSet(integer: 12)) + #expect(table.scrollView.contentView.bounds.origin == origin) + + try ThemeTestFixture.adopt([:], appearance: .light) + #expect(!chrome.themesRows) + #expect(type(of: try #require(table.tableView.rowView(atRow: 12, makeIfNecessary: true))) == NSTableRowView.self) + #expect(table.tableView.selectedRowIndexes == IndexSet(integer: 12)) + #expect(table.scrollView.contentView.bounds.origin == origin) + } + } + } + + @Test("A theme that only colors the grid text or lines keeps AppKit's row view") + func textOnlyThemeKeepsNativeRows() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let table = makeTable() + let chrome = attach(.records, to: table) + + try ThemeTestFixture.adopt([.gridText: "#112233", .gridLine: "#DDDDDD", .gridSelectionText: "#FFFFFF"], appearance: .light) + #expect(!chrome.themesRows) + #expect(type(of: try #require(table.tableView.rowView(atRow: 0, makeIfNecessary: true))) == NSTableRowView.self) + } + } + + @Test("A theme's selection fills the row and keeps the row's text unflipped") + func themedSelectionDraws() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([.gridSelection: "#FF00FF"], appearance: .light) + let table = makeTable() + let chrome = attach(.records, to: table) + let row = ThemeTableRowView(frame: Self.rowFrame) + row.chrome = chrome + + let rep = try pixels(of: selected(row)) + let center = try color(in: rep, x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2) + let magenta = try cached(HexColor.color("#FF00FF")) + #expect(matches(center, magenta), "drew \(center), theme \(magenta)") + #expect(row.interiorBackgroundStyle == .normal) + withExtendedLifetime(chrome) {} + } + } + + /// The theme recolors AppKit's selection rather than replacing it, so an inset table's rounded, + /// inset selection covers the same pixels with either color. + @Test("A themed selection covers the same pixels as AppKit's in an inset table") + func themedSelectionKeepsTheInsetShape() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let nativeTable = makeTable(style: .inset, alternates: false, source: DrawPathTableSource()) + nativeTable.tableView.reloadData() + nativeTable.tableView.layoutSubtreeIfNeeded() + let native = try selectedRowPixels(in: nativeTable) + let accent = try color(in: native, x: native.pixelsWide / 2, y: native.pixelsHigh / 2) + + try ThemeTestFixture.adopt([.gridSelection: "#FF00FF"], appearance: .light) + let themedTable = makeTable(style: .inset, alternates: false) + let themedChrome = attach(.records, to: themedTable) + let themed = try selectedRowPixels(in: themedTable) + withExtendedLifetime(themedChrome) {} + let magenta = try color(in: themed, x: themed.pixelsWide / 2, y: themed.pixelsHigh / 2) + #expect(!matches(magenta, accent)) + #expect(magenta.redComponent > 0.8 && magenta.greenComponent < 0.5 && magenta.blueComponent > 0.8, "\(magenta)") + + let middle = native.pixelsHigh / 2 + var nativeCovered = 0 + var themedCovered = 0 + for x in 0.. 0) + #expect(abs(nativeCovered - themedCovered) <= 2, "native \(nativeCovered), themed \(themedCovered)") + #expect( + matches(try color(in: native, x: 0, y: middle), accent) == matches(try color(in: themed, x: 0, y: middle), magenta) + ) + } + } + + private func selectedRowPixels(in table: Table) throws -> NSBitmapImageRep { + table.tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + let row = try #require(table.tableView.rowView(atRow: 0, makeIfNecessary: true)) + row.isEmphasized = true + row.layoutSubtreeIfNeeded() + return try pixels(of: row) + } + + // MARK: - Records cell text + + private static let swatchID = NSUserInterfaceItemIdentifier("swatch") + + /// A shape fills with the foreground style, so its pixels are the text color without glyph edges. + private func swatchCell(in table: Table) -> ThemeTableChrome.HostingCell { + ThemeTableChrome.HostingCell.make(Self.swatchID, in: table.tableView, showing: Rectangle().frame(width: 8, height: 8)) + } + + private func center(_ view: some View) throws -> NSColor { + try ThemeTestFixture.color(of: view, at: CGPoint(x: 0.5, y: 0.5), colorScheme: .light) + } + + private func reference(_ hex: String) throws -> NSColor { + try center(Rectangle().fill(Color(nsColor: HexColor.color(hex))).frame(width: 8, height: 8)) + } + + private var plainSwatch: some View { + Rectangle().frame(width: 8, height: 8) + } + + @Test("Text slots resolve: grid text by default, selection text on a theme's selection, none on AppKit's") + func recordsTextSlotFollowsTheRow() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + #expect(ThemeTableChrome.recordsTextSlot(onThemedSelection: false, onEmphasizedSelection: false, highContrast: false) == nil) + #expect(ThemeTableChrome.recordsTextSlot(onThemedSelection: true, onEmphasizedSelection: false, highContrast: false) == nil) + + try ThemeTestFixture.adopt([.gridText: "#FF0000"], appearance: .light) + #expect(ThemeTableChrome.recordsTextSlot(onThemedSelection: false, onEmphasizedSelection: false, highContrast: false) == .gridText) + #expect(ThemeTableChrome.recordsTextSlot(onThemedSelection: true, onEmphasizedSelection: false, highContrast: false) == .gridText) + #expect(ThemeTableChrome.recordsTextSlot(onThemedSelection: false, onEmphasizedSelection: true, highContrast: false) == nil) + + try ThemeTestFixture.adopt([.gridText: "#FF0000", .gridSelectionText: "#00AA00"], appearance: .light) + #expect(ThemeTableChrome.recordsTextSlot(onThemedSelection: true, onEmphasizedSelection: false, highContrast: false) == .gridSelectionText) + #expect(ThemeTableChrome.recordsTextSlot(onThemedSelection: false, onEmphasizedSelection: false, highContrast: false) == .gridText) + #expect(ThemeTableChrome.recordsTextSlot(onThemedSelection: false, onEmphasizedSelection: true, highContrast: false) == nil) + } + } + + @Test("A records cell draws exactly its own text under the default theme") + func defaultCellTextIsUntouched() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let cell = swatchCell(in: makeTable()) + #expect(matches(try center(cell.rootView), try center(plainSwatch))) + + cell.isOnThemedSelection = true + #expect(matches(try center(cell.rootView), try center(plainSwatch))) + } + } + + @Test("A records cell takes the theme's grid text, and its selection text on the theme's selection") + func cellTextFollowsTheTheme() throws { + guard #available(macOS 14, *) else { return } + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt(ThemeTestFixture.pair([.gridText: "#FF0000", .gridSelection: "#C8E1FF", .gridSelectionText: "#00AA00"]), appearance: .light) + let cell = swatchCell(in: makeTable()) + let red = try reference("#FF0000") + let green = try reference("#00AA00") + + let unselected = try center(cell.rootView) + #expect(matches(unselected, red), "drew \(unselected)") + + cell.isOnThemedSelection = true + let selected = try center(cell.rootView) + #expect(matches(selected, green), "drew \(selected)") + + cell.isOnThemedSelection = false + #expect(matches(try center(cell.rootView), red)) + } + } + + @Test("A secondary style inside a records cell stays a level of the theme's grid text") + func secondaryTextFollowsTheThemeHue() throws { + guard #available(macOS 14, *) else { return } + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt(ThemeTestFixture.pair([.gridText: "#FF0000"]), appearance: .light) + let cell = ThemeTableChrome.HostingCell.make( + Self.swatchID, + in: makeTable().tableView, + showing: Rectangle().fill(.secondary).frame(width: 8, height: 8) + ) + let drawn = try center(cell.rootView) + #expect(matches(drawn, try reference("#FF0000"), tolerance: 0.05), "drew \(drawn)") + #expect(drawn.alphaComponent < 0.9, "drew \(drawn)") + } + } + + @Test("Without a selection text a theme's selection keeps the grid text") + func themedSelectionWithoutSelectionTextKeepsGridText() throws { + guard #available(macOS 14, *) else { return } + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt(ThemeTestFixture.pair([.gridText: "#FF0000", .gridSelection: "#C8E1FF"]), appearance: .light) + let cell = swatchCell(in: makeTable()) + cell.isOnThemedSelection = true + #expect(matches(try center(cell.rootView), try reference("#FF0000"))) + } + } + + /// AppKit's emphasized selection is the accent color, which a theme's text is not chosen to sit on. + @Test("On AppKit's emphasized selection a records cell keeps its own text, which flips with the row") + func nativeEmphasizedSelectionKeepsTheFlip() throws { + guard #available(macOS 14, *) else { return } + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt(ThemeTestFixture.pair([.gridText: "#FF0000", .gridSelectionText: "#00AA00"]), appearance: .light) + let cell = swatchCell(in: makeTable()) + + let flipped = try center(cell.rootView.environment(\.backgroundProminence, .increased)) + let native = try center(plainSwatch.environment(\.backgroundProminence, .increased)) + #expect(matches(flipped, native), "drew \(flipped), native \(native)") + #expect(!matches(flipped, try reference("#FF0000"))) + } + } + + @Test("A themed row tells its records cells when they sit on the theme's selection") + func themedRowPublishesItsSelection() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([.gridSelection: "#C8E1FF"], appearance: .light) + let source = ChromeTableSource() + source.vendsCells = true + let table = makeTable(source: source) + let chrome = attach(.records, to: table) + defer { withExtendedLifetime(chrome) {} } + + table.tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + let row = try #require(table.tableView.rowView(atRow: 0, makeIfNecessary: true) as? ThemeTableRowView) + row.isEmphasized = true + let cell = try #require(table.tableView.view(atColumn: 0, row: 0, makeIfNecessary: true) as? ThemeTableChrome.HostingCell) + let other = try #require(table.tableView.view(atColumn: 0, row: 1, makeIfNecessary: true) as? ThemeTableChrome.HostingCell) + #expect(cell.isOnThemedSelection) + #expect(!other.isOnThemedSelection) + + row.isEmphasized = false + #expect(!cell.isOnThemedSelection) + + row.isEmphasized = true + table.tableView.deselectAll(nil) + #expect(!cell.isOnThemedSelection) + } + } + + @Test("A records cell reused into AppKit's row view after the theme stops forgets the theme's selection") + func reusedCellForgetsTheThemedSelection() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([.gridSelection: "#C8E1FF"], appearance: .light) + let source = ChromeTableSource() + source.vendsCells = true + let table = makeTable(source: source) + let chrome = attach(.records, to: table) + defer { withExtendedLifetime(chrome) {} } + table.tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + let row = try #require(table.tableView.rowView(atRow: 0, makeIfNecessary: true) as? ThemeTableRowView) + row.isEmphasized = true + let themedCell = try #require(table.tableView.view(atColumn: 0, row: 0, makeIfNecessary: true) as? ThemeTableChrome.HostingCell) + #expect(themedCell.isOnThemedSelection) + + try ThemeTestFixture.adopt([.gridText: "#FF0000", .gridSelectionText: "#00AA00"], appearance: .light) + let nativeRow = try #require(table.tableView.rowView(atRow: 0, makeIfNecessary: true)) + nativeRow.isEmphasized = true + #expect(type(of: nativeRow) == NSTableRowView.self) + #expect(table.tableView.selectedRowIndexes == IndexSet(integer: 0)) + for row in 0 ..< table.tableView.numberOfRows { + let cell = try #require(table.tableView.view(atColumn: 0, row: row, makeIfNecessary: true) as? ThemeTableChrome.HostingCell) + #expect(!cell.isOnThemedSelection) + } + #expect(!themedCell.isOnThemedSelection) + } + } + + @Test("Under the default theme no records cell is told it sits on a theme's selection") + func defaultRowsNeverMarkCells() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let source = ChromeTableSource() + source.vendsCells = true + let table = makeTable(source: source) + let chrome = attach(.records, to: table) + defer { withExtendedLifetime(chrome) {} } + + table.tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + let row = try #require(table.tableView.rowView(atRow: 0, makeIfNecessary: true)) + row.isEmphasized = true + let cell = try #require(table.tableView.view(atColumn: 0, row: 0, makeIfNecessary: true) as? ThemeTableChrome.HostingCell) + #expect(type(of: row) == NSTableRowView.self) + #expect(!cell.isOnThemedSelection) + } + } +} diff --git a/TableProTests/Theme/ThemeTextViewStyleTests.swift b/TableProTests/Theme/ThemeTextViewStyleTests.swift new file mode 100644 index 0000000000..77928451d5 --- /dev/null +++ b/TableProTests/Theme/ThemeTextViewStyleTests.swift @@ -0,0 +1,153 @@ +// +// ThemeTextViewStyleTests.swift +// TableProTests +// +// A value text view under a default theme has to stay exactly what AppKit configured, down to the +// color objects: a copy of the selection color loses the inactive-selection gray, and a copy of +// the text color stops following Increase Contrast. +// + +import AppKit +@testable import TablePro +import Testing + +/// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are +/// synchronous and `@MainActor`, so nothing else on the main actor runs between the two. +@Suite("Text views follow the editor theme", .serialized) +@MainActor +struct ThemeTextViewStyleTests { + private func makeTextView() throws -> NSTextView { + let textView = try #require(NSTextView.scrollableTextView().documentView as? NSTextView) + textView.appearance = NSAppearance(named: .aqua) + return textView + } + + private func hex(_ color: NSColor?) throws -> String { + let color = try #require(color) + var hex = "" + NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { + hex = HexColor.string(from: color) + } + return hex + } + + private func selectionBackground(of textView: NSTextView) -> NSColor? { + textView.selectedTextAttributes[.backgroundColor] as? NSColor + } + + private static let everyEditorSlot: [ThemeSlot: String] = [ + .editorBackground: "#FDF6E3", + .editorText: "#586E75", + .editorCursor: "#D33682", + .editorSelection: "#EEE8D5", + ] + + @Test("Under the default theme nothing is written and the system color objects stay") + func defaultThemeWritesNothing() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let textView = try makeTextView() + let style = ThemeTextViewStyle(textView: textView) + + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(textView.textColor === NSColor.textColor) + #expect(textView.insertionPointColor === ThemeSlot.editorCursor.nativeColor) + #expect(selectionBackground(of: textView) === NSColor.selectedTextBackgroundColor) + withExtendedLifetime(style) {} + } + } + + @Test("A theme that sets the editor slots colors the text view, and switching back restores each object") + func themeColorsAndRestores() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let textView = try makeTextView() + let selectedForeground = textView.selectedTextAttributes[.foregroundColor] as? NSColor + let style = ThemeTextViewStyle(textView: textView) + + try ThemeTestFixture.adopt(Self.everyEditorSlot, appearance: .light) + #expect(try hex(textView.backgroundColor) == "#FDF6E3") + #expect(try hex(textView.textColor) == "#586E75") + #expect(try hex(textView.insertionPointColor) == "#D33682") + #expect(try hex(selectionBackground(of: textView)) == "#EEE8D5") + #expect(textView.selectedTextAttributes[.foregroundColor] as? NSColor === selectedForeground) + + try ThemeTestFixture.adopt([:], appearance: .light) + #expect(textView.backgroundColor == NSColor.textBackgroundColor) + #expect(textView.textColor == NSColor.textColor) + #expect(textView.insertionPointColor == ThemeSlot.editorCursor.nativeColor) + #expect(selectionBackground(of: textView) == NSColor.selectedTextBackgroundColor) + withExtendedLifetime(style) {} + } + } + + @Test("A theme that sets only the background leaves the other properties native") + func partialThemeKeepsTheRestNative() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([.editorBackground: "#FDF6E3"], appearance: .light) + let textView = try makeTextView() + let style = ThemeTextViewStyle(textView: textView) + + #expect(try hex(textView.backgroundColor) == "#FDF6E3") + #expect(textView.textColor === NSColor.textColor) + #expect(selectionBackground(of: textView) === NSColor.selectedTextBackgroundColor) + withExtendedLifetime(style) {} + } + } + + @Test("The natives a caller passes are what a property returns to") + func givenNativesAreRestored() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let textView = try makeTextView() + textView.textColor = .labelColor + let natives = ThemeTextViewStyle.Natives( + background: .controlBackgroundColor, + text: .labelColor, + insertionPoint: .labelColor, + selectedTextBackground: .selectedTextBackgroundColor + ) + let style = ThemeTextViewStyle(textView: textView, natives: natives) + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + + try ThemeTestFixture.adopt(Self.everyEditorSlot, appearance: .light) + try ThemeTestFixture.adopt([:], appearance: .light) + #expect(textView.backgroundColor == NSColor.controlBackgroundColor) + #expect(textView.textColor == NSColor.labelColor) + #expect(textView.insertionPointColor == NSColor.labelColor) + withExtendedLifetime(style) {} + } + } + + @Test("Text set after the theme keeps the theme's text color") + func replacedTextKeepsTheColor() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt(Self.everyEditorSlot, appearance: .light) + let textView = try makeTextView() + let style = ThemeTextViewStyle(textView: textView) + + textView.string = "SELECT 1" + #expect(try hex(textView.textColor) == "#586E75") + let attributes = textView.textStorage?.attributes(at: 0, effectiveRange: nil) + #expect(try hex(attributes?[.foregroundColor] as? NSColor) == "#586E75") + withExtendedLifetime(style) {} + } + } + + @Test("Detaching hands every property back and stops following the theme") + func detachRestores() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt(Self.everyEditorSlot, appearance: .light) + let textView = try makeTextView() + let style = ThemeTextViewStyle(textView: textView) + #expect(try hex(textView.backgroundColor) == "#FDF6E3") + + style.detach() + #expect(textView.backgroundColor == NSColor.textBackgroundColor) + #expect(textView.textColor == NSColor.textColor) + + try ThemeTestFixture.adopt([.editorBackground: "#002B36"], appearance: .light) + #expect(textView.backgroundColor == NSColor.textBackgroundColor) + } + } +} diff --git a/TableProTests/Theme/ThemeWindowBackdropTests.swift b/TableProTests/Theme/ThemeWindowBackdropTests.swift new file mode 100644 index 0000000000..78381bcc65 --- /dev/null +++ b/TableProTests/Theme/ThemeWindowBackdropTests.swift @@ -0,0 +1,119 @@ +// +// ThemeWindowBackdropTests.swift +// TableProTests +// +// Any window color other than a system one replaces the window's own content-background material, +// so the backdrop may touch the window only while the active theme sets a window background, and +// has to hand back the exact original color when it stops. +// + +import AppKit +@testable import TablePro +import Testing + +/// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are +/// synchronous and `@MainActor`, so nothing else on the main actor runs between the two. +@Suite("The window backdrop follows the theme", .serialized) +@MainActor +struct ThemeWindowBackdropTests { + private static let windowHex = "#282A36" + + /// Never ordered front and never closed: `close()` on a test window over-releases it. + private func makeWindow(_ appearance: NSAppearance.Name = .darkAqua) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 240, height: 160), + styleMask: [.titled], + backing: .buffered, + defer: true + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: appearance) + return window + } + + private func theme(_ appearance: ThemeAppearance, window hex: String?) throws -> ThemeDefinition { + var theme = BuiltInThemes.default(for: appearance) + guard let hex else { return theme } + theme.id = "user.test.backdrop-\(appearance.rawValue)" + theme.overrides[.windowBackdrop] = try ThemeColor(validating: hex) + return theme + } + + private func adopt(light: ThemeDefinition = BuiltInThemes.light, dark: ThemeDefinition, effective: ThemeAppearance = .dark) { + ThemeTestFixture.adopt(ThemePair(light: light, dark: dark), appearance: effective) + } + + private func hex(of color: NSColor?, in window: NSWindow) throws -> String { + let color = try #require(color) + var hex = "" + window.effectiveAppearance.performAsCurrentDrawingAppearance { + hex = HexColor.string(from: color) + } + return hex + } + + private var drawsBackdrop: Bool { + if #available(macOS 26.0, *) { return true } + return false + } + + @Test("The default pair leaves the window's own background untouched") + func defaultPairLeavesTheWindowAlone() throws { + try ThemeTestFixture.preservingEngine { + adopt(dark: BuiltInThemes.dark) + let window = makeWindow() + let original = window.backgroundColor + + let backdrop = ThemeWindowBackdrop(window: window) + #expect(window.backgroundColor === original) + #expect(window.backgroundColor == NSColor.windowBackgroundColor) + + adopt(dark: BuiltInThemes.dark, effective: .light) + backdrop.apply() + #expect(window.backgroundColor === original) + } + } + + @Test("A theme that sets the window background fills the window, and switching back restores the original") + func overrideFillsAndRestores() throws { + try ThemeTestFixture.preservingEngine { + adopt(dark: BuiltInThemes.dark) + let window = makeWindow() + let original = window.backgroundColor + let backdrop = ThemeWindowBackdrop(window: window) + + adopt(dark: try theme(.dark, window: Self.windowHex)) + if drawsBackdrop { + #expect(try hex(of: window.backgroundColor, in: window) == Self.windowHex) + } else { + #expect(window.backgroundColor === original) + } + + adopt(dark: BuiltInThemes.dark) + #expect(window.backgroundColor == original) + #expect(window.backgroundColor == NSColor.windowBackgroundColor) + withExtendedLifetime(backdrop) {} + } + } + + @Test("A light-only window background leaves a dark window alone, and the flip to light fills it") + func onlyTheActiveThemeCounts() throws { + try ThemeTestFixture.preservingEngine { + let light = try theme(.light, window: "#F0E8D8") + adopt(light: light, dark: BuiltInThemes.dark, effective: .dark) + let window = makeWindow(.darkAqua) + let original = window.backgroundColor + let backdrop = ThemeWindowBackdrop(window: window) + #expect(window.backgroundColor === original) + + window.appearance = NSAppearance(named: .aqua) + adopt(light: light, dark: BuiltInThemes.dark, effective: .light) + if drawsBackdrop { + #expect(try hex(of: window.backgroundColor, in: window) == "#F0E8D8") + } else { + #expect(window.backgroundColor === original) + } + withExtendedLifetime(backdrop) {} + } + } +} diff --git a/TableProTests/Theme/ValueFontTests.swift b/TableProTests/Theme/ValueFontTests.swift index 363cf91d00..f8f1dd6944 100644 --- a/TableProTests/Theme/ValueFontTests.swift +++ b/TableProTests/Theme/ValueFontTests.swift @@ -104,10 +104,9 @@ struct ValueFontTests { "TablePro/Views/RowInspector/FieldEditors/SetPickerView.swift", "TablePro/Views/Results/CellOverlayEditor.swift", "TablePro/Views/Results/CellOverlayViewer.swift", - "TablePro/Views/Results/TextViewerWindowController.swift", + /// The text viewer, SVG source and cell image markup draw through this editor. + "TablePro/Views/Results/ValueSourceEditor.swift", "TablePro/Views/Results/HexEditorContentView.swift", - "TablePro/Views/Results/SvgViewerContentView.swift", - "TablePro/Views/Results/CellImageWindowController.swift", "TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift", /// Reached from `.blobHex`, which inherits the value font from the row, and from /// `.image`, which does not, so it has to name the font itself or the same dump renders @@ -138,6 +137,24 @@ struct ValueFontTests { #expect(offenders.isEmpty, "These render a stored value and must resolve its font: \(offenders)") } + @Test("The value source viewers draw their text through the value source editor") + func sourceViewersUseTheValueSourceEditor() throws { + let paths = [ + "TablePro/Views/Results/TextViewerWindowController.swift", + "TablePro/Views/Results/SvgViewerContentView.swift", + "TablePro/Views/Results/CellImageWindowController.swift", + ] + var offenders: [String] = [] + for path in paths { + let text = try source(of: path) + if !text.contains("ValueSourceEditor("), !text.contains("ThemeEngine.shared.valueFont"), + !text.contains("themeEngine.valueFont") { + offenders.append(path) + } + } + #expect(offenders.isEmpty, "These render a stored value and must resolve its font: \(offenders)") + } + /// Naming a font inside a field editor overrides what the subtree set, which is how the defect /// looked in the first place. `.subheadline` was on the single-line, multi-line and schema editors, /// and `preferredFont(forTextStyle:)` was what the multi-line editor handed AppKit. The remaining diff --git a/TableProTests/Views/AIChat/AIChatCodeThemeTests.swift b/TableProTests/Views/AIChat/AIChatCodeThemeTests.swift new file mode 100644 index 0000000000..704c1f4ccf --- /dev/null +++ b/TableProTests/Views/AIChat/AIChatCodeThemeTests.swift @@ -0,0 +1,90 @@ +// +// AIChatCodeThemeTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct AIChatCodeThemeTests { + private static let json = "{\n \"database\" : \"app\",\n \"query\" : \"select * from users\"\n}" + + private static let statement = ProposedStatement( + id: "1", + sql: "update users set active = 0 where id = 7", + toolName: "execute_sql", + state: .waiting + ) + + /// The row as it was drawn before the theme reached it. + private static func systemRow(_ statement: ProposedStatement) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(statement.sql) + .font(ThemeEngine.shared.valueFontSwiftUI) + .lineLimit(4) + .textSelection(.enabled) + HStack(spacing: 4) { + Image(systemName: statement.state.symbolName) + .font(.caption) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(statement.state.title) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @Test("The tool-use input and tool-result wells keep the font they drew before", arguments: [ColorScheme.light, .dark]) + func toolWellFontsMatchTheirFormerText(colorScheme: ColorScheme) throws { + let former = try ThemeTestFixture.bitmap( + of: Text(Self.json).font(.caption).fontDesign(.monospaced), + colorScheme: colorScheme + ) + let input = try ThemeTestFixture.bitmap( + of: Text(Self.json).font(AIChatToolUseBlockView.inputFont), + colorScheme: colorScheme + ) + let result = try ThemeTestFixture.bitmap( + of: Text(Self.json).font(AIChatToolResultBlockView.contentFont), + colorScheme: colorScheme + ) + + #expect(ThemeTestFixture.sameBytes(former, input)) + #expect(ThemeTestFixture.sameBytes(former, result)) + } + + @Test("Under the default pair a proposed statement draws exactly what it drew before", arguments: [ColorScheme.light, .dark]) + func proposedStatementIsNativeUnderTheDefaultPair(colorScheme: ColorScheme) throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let themed = try ThemeTestFixture.bitmap( + of: AgentProposedStatementRow(statement: Self.statement).frame(width: 320), + colorScheme: colorScheme + ) + let system = try ThemeTestFixture.bitmap( + of: Self.systemRow(Self.statement).frame(width: 320), + colorScheme: colorScheme + ) + #expect(ThemeTestFixture.sameBytes(themed, system)) + } + } + + @Test("A proposed statement wears the editor's text while the theme sets it") + func proposedStatementFollowsTheEditorText() throws { + let pair = try ThemeTestFixture.pair([.editorText: "#FF00FF"]) + try ThemeTestFixture.adopting(pair, appearance: .dark) { + let row = try ThemeTestFixture.bitmap(of: AgentProposedStatementRow(statement: Self.statement).frame(width: 320), colorScheme: .dark) + #expect(ThemeTestFixture.count(in: row, where: ThemeTestFixture.isMagenta) > 0) + } + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let row = try ThemeTestFixture.bitmap(of: AgentProposedStatementRow(statement: Self.statement).frame(width: 320), colorScheme: .dark) + #expect(ThemeTestFixture.count(in: row, where: ThemeTestFixture.isMagenta) == 0) + } + } +} diff --git a/TableProTests/Views/Compare/CompareStatusStyleTests.swift b/TableProTests/Views/Compare/CompareStatusStyleTests.swift index 5dfc1a8b96..06a138346d 100644 --- a/TableProTests/Views/Compare/CompareStatusStyleTests.swift +++ b/TableProTests/Views/Compare/CompareStatusStyleTests.swift @@ -10,9 +10,19 @@ import Testing @MainActor struct CompareStatusStyleTests { + private static let changedKinds: [(RowDiffKind, ThemeSlot, NSColor)] = [ + (.insert, .gridInserted, NativeThemeColors.insertedRow), + (.update, .gridModified, NativeThemeColors.modifiedRow), + (.delete, .gridDeleted, NativeThemeColors.deletedRow), + ] + + private static func hex(_ color: NSColor) -> String { + HexColor.string(from: color) + } + @Test("Object and row statuses use the system green, orange and red under any theme") func statusTintsAreSystemColors() throws { - try PaneThemeFixture.withEverySlotOverridden { + try ThemeTestFixture.withEverySlotOverridden { #expect(CompareStatusStyle.tint(for: TableDiffStatus.onlyInSource) == .green) #expect(CompareStatusStyle.tint(for: TableDiffStatus.onlyInTarget) == .red) #expect(CompareStatusStyle.tint(for: TableDiffStatus.differs) == .orange) @@ -30,17 +40,62 @@ struct CompareStatusStyleTests { } } - /// The text diff in Compare and in Version History paints its lines with these, so a theme that - /// recolors the grid must not reach them. - @Test("A row difference takes the default grid's tint, not the theme's") - func rowTintsAreTheNativeGridTints() throws { - try PaneThemeFixture.withEverySlotOverridden { - #expect(ThemeEngine.shared.resolved.isOverridden(.gridInserted)) - #expect(CompareStatusStyle.rowTint(for: .insert) == Color(nsColor: NativeThemeColors.insertedRow)) - #expect(CompareStatusStyle.rowTint(for: .update) == Color(nsColor: NativeThemeColors.modifiedRow)) - #expect(CompareStatusStyle.rowTint(for: .delete) == Color(nsColor: NativeThemeColors.deletedRow)) - #expect(CompareStatusStyle.rowTint(for: .identical) == .clear) - #expect(CompareStatusStyle.rowTint(for: .conflict) == .clear) + @Test("Under the default pair a changed line draws the grid's native tint in both appearances") + func rowTintsAreNativeUnderTheDefaultPair() throws { + let palette = ThemePalette(revision: 920_001, source: ThemeSource()) + + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + for (kind, _, native) in Self.changedKinds { + let tint = try #require(CompareStatusStyle.rowTintColor(for: kind, palette: palette)) + appearance.performAsCurrentDrawingAppearance { + #expect(Self.hex(tint) == Self.hex(native), "\(kind) in \(name.rawValue)") + } + } + } + #expect(CompareStatusStyle.rowTintColor(for: .identical, palette: palette) == nil) + #expect(CompareStatusStyle.rowTintColor(for: .conflict, palette: palette) == nil) + #expect(CompareStatusStyle.rowTint(for: .identical, palette: palette) == .clear) + #expect(CompareStatusStyle.rowTint(for: .conflict, palette: palette) == .clear) + } + + @Test("A changed line takes the theme's grid tint in the appearance whose theme sets it") + func rowTintsFollowTheThemeWhenOverridden() throws { + let dark = try ThemeTestFixture.theme(.dark, [ + .gridInserted: "#00FF0040", + .gridModified: "#FFFF0040", + .gridDeleted: "#FF000040", + ]) + let source = ThemeSource() + source.update(ThemePair(light: BuiltInThemes.light, dark: dark)) + let palette = ThemePalette(revision: 920_002, source: source) + let aqua = try #require(NSAppearance(named: .aqua)) + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + + for (kind, slot, native) in Self.changedKinds { + let tint = try #require(CompareStatusStyle.rowTintColor(for: kind, palette: palette)) + darkAqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(tint) == dark.overrides[slot]?.hex, "\(kind) in Dark Aqua") + } + aqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(tint) == Self.hex(native), "\(kind) in Aqua") + } + } + } + + @Test("The text diff reads the live theme through the shared engine") + func rowTintsReadTheActiveTheme() throws { + try ThemeTestFixture.withEverySlotOverridden { + let palette = ThemeEngine.shared.palette + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + for (kind, _, _) in Self.changedKinds { + let tint = try #require(CompareStatusStyle.rowTintColor(for: kind, palette: palette)) + appearance.performAsCurrentDrawingAppearance { + #expect(Self.hex(tint) == "#FF00FF", "\(kind) in \(name.rawValue)") + } + } + } } } @@ -67,4 +122,76 @@ struct CompareStatusStyleTests { } } } + + // MARK: - Diffs with their own native tint + + @Test("A diff with its own tint keeps it under the default pair") + func lineTintKeepsTheNativeUnderTheDefaultPair() { + let native = Color.green.opacity(0.16) + ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let engine = ThemeEngine.shared + for kind in [RowDiffKind.insert, .update, .delete, .identical, .conflict] { + #expect(CompareStatusStyle.lineTint(for: kind, native: native, engine: engine, contrast: .standard) == native) + } + } + } + + @Test("A diff with its own tint takes the grid's tint where the theme sets one") + func lineTintFollowsTheTheme() throws { + let native = Color.red.opacity(0.16) + let pair = try ThemeTestFixture.pair([.gridDeleted: "#FF000040"]) + ThemeTestFixture.adopting(pair, appearance: .dark) { + let engine = ThemeEngine.shared + let themed = Color(nsColor: engine.palette[.gridDeleted]) + #expect(CompareStatusStyle.lineTint(for: .delete, native: native, engine: engine, contrast: .standard) == themed) + #expect(CompareStatusStyle.lineTint(for: .delete, native: native, engine: engine, contrast: .increased) == themed) + #expect(CompareStatusStyle.lineTint(for: .insert, native: native, engine: engine, contrast: .standard) == native) + #expect(CompareStatusStyle.lineTint(for: .identical, native: native, engine: engine, contrast: .standard) == native) + } + } + + // MARK: - Line text + + @Test("Diff text stays the system's own under the default pair") + func lineTextIsNativeUnderTheDefaultPair() { + ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let engine = ThemeEngine.shared + for kind in [RowDiffKind?.none, .insert, .update, .delete] { + #expect(CompareStatusStyle.lineTextOverride(for: kind, engine: engine, contrast: .standard) == nil) + } + } + } + + @Test("A deleted line takes the grid's deleted text and every other line the editor's text") + func lineTextFollowsTheTheme() throws { + let pair = try ThemeTestFixture.pair([.editorText: "#E0E0E0", .gridDeletedText: "#FF555580"]) + ThemeTestFixture.adopting(pair, appearance: .dark) { + let engine = ThemeEngine.shared + let editorText = Color(nsColor: engine.palette[.editorText]) + let deletedText = Color(nsColor: engine.palette[.gridDeletedText]) + #expect(CompareStatusStyle.lineTextOverride(for: .delete, engine: engine, contrast: .standard) == deletedText) + #expect(CompareStatusStyle.lineTextOverride(for: .insert, engine: engine, contrast: .standard) == editorText) + #expect(CompareStatusStyle.lineTextOverride(for: nil, engine: engine, contrast: .standard) == editorText) + } + } + + @Test("A deleted line falls back to the editor's text when the theme leaves deleted text out") + func deletedLineFallsBackToEditorText() throws { + let pair = try ThemeTestFixture.pair([.editorText: "#E0E0E0"]) + ThemeTestFixture.adopting(pair, appearance: .dark) { + let engine = ThemeEngine.shared + let editorText = Color(nsColor: engine.palette[.editorText]) + #expect(CompareStatusStyle.lineTextOverride(for: .delete, engine: engine, contrast: .standard) == editorText) + } + + let deletedOnly = try ThemeTestFixture.pair([.gridDeletedText: "#FF555580"]) + ThemeTestFixture.adopting(deletedOnly, appearance: .dark) { + let engine = ThemeEngine.shared + #expect(CompareStatusStyle.lineTextOverride(for: .insert, engine: engine, contrast: .standard) == nil) + #expect( + CompareStatusStyle.lineTextOverride(for: .delete, engine: engine, contrast: .standard) + == Color(nsColor: engine.palette[.gridDeletedText]) + ) + } + } } diff --git a/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift b/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift new file mode 100644 index 0000000000..5f4edd7364 --- /dev/null +++ b/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift @@ -0,0 +1,150 @@ +// +// HighlightedSQLTextViewLayoutTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct HighlightedSQLTextViewLayoutTests { + private static let longClause = (1 ... 4) + .map { "customer_name_\($0) LIKE '%some fairly long fragment \($0)%'" } + .joined(separator: " AND ") + + private static let longStatement = #"VACUUM (FULL, FREEZE, VERBOSE, ANALYZE) "public"."orders_with_a_very_long_table_name_for_testing""# + + private static let maintenanceSize = NSSize(width: 420, height: 520) + + private var lineHeight: CGFloat { + NSLayoutManager().defaultLineHeight(for: ThemeEngine.shared.editorFonts.font) + } + + private func maintenanceSheet(_ statements: [String], databaseType: DatabaseType = .postgresql) -> MaintenanceSheet { + MaintenanceSheet( + operation: PluginMaintenanceOperation(name: "VACUUM", appliesTo: [.table], scope: .object), + tableName: "orders", + databaseName: "app", + databaseType: databaseType, + preview: { _ in statements }, + onExecute: { _ in } + ) + } + + /// TextKit 2 lays out lazily, so the whole document is laid out before its size is read. + private func usedSize(of textView: NSTextView) -> NSSize { + if let layoutManager = textView.textLayoutManager { + layoutManager.ensureLayout(for: layoutManager.documentRange) + return layoutManager.usageBoundsForTextContainer.size + } + guard let layoutManager = textView.layoutManager, let container = textView.textContainer else { return .zero } + layoutManager.ensureLayout(for: container) + return layoutManager.usedRect(for: container).size + } + + private func textWidth(in scrollView: NSScrollView, textView: NSTextView) -> CGFloat { + scrollView.contentSize.width - textView.textContainerInset.width * 2 + } + + // MARK: - Filter preview + + @Test("A long filter clause wraps inside the preview instead of running past its edge") + func filterPreviewWraps() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host(SQLPreviewSheet(sql: Self.longClause), size: NSSize(width: 480, height: 300)) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + let scrollView = try #require(textView.enclosingScrollView) + + #expect(textView.textContainer?.widthTracksTextView == true) + #expect(!textView.isHorizontallyResizable) + #expect(!scrollView.hasHorizontalScroller) + #expect(textView.frame.width <= scrollView.contentSize.width + 0.5) + + let used = usedSize(of: textView) + #expect(used.width <= textWidth(in: scrollView, textView: textView) + 0.5) + #expect(used.height > lineHeight * 2) + withExtendedLifetime(window) {} + } + } + + // MARK: - Maintenance preview + + @Test("A long maintenance statement wraps and the preview grows to show all of it") + func maintenancePreviewFitsWrappedStatement() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host(maintenanceSheet([Self.longStatement]), size: Self.maintenanceSize) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + let scrollView = try #require(textView.enclosingScrollView) + let insets = textView.textContainerInset.height * 2 + + #expect(textView.textContainer?.widthTracksTextView == true) + #expect(!scrollView.hasHorizontalScroller) + #expect(scrollView.frame.height > lineHeight * 2 + insets) + #expect(scrollView.frame.height < lineHeight * CGFloat(MaintenanceSheet.previewMaxLines) + insets) + + let used = usedSize(of: textView) + #expect(used.width <= textWidth(in: scrollView, textView: textView) + 0.5) + #expect(used.height + insets <= scrollView.contentSize.height + 0.5) + withExtendedLifetime(window) {} + } + } + + @Test("A preview taller than its cap stops at the cap and scrolls vertically") + func maintenancePreviewCapsAndScrolls() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let statements = Array(repeating: Self.longStatement, count: 6) + let window = ThemeTestFixture.host(maintenanceSheet(statements), size: Self.maintenanceSize) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + let scrollView = try #require(textView.enclosingScrollView) + let insets = textView.textContainerInset.height * 2 + let cap = lineHeight * CGFloat(MaintenanceSheet.previewMaxLines) + insets + + #expect(abs(scrollView.frame.height - cap) < 1) + #expect(scrollView.hasVerticalScroller) + + let used = usedSize(of: textView) + #expect(used.width <= textWidth(in: scrollView, textView: textView) + 0.5) + #expect(used.height + insets > scrollView.contentSize.height) + withExtendedLifetime(window) {} + } + } + + @Test("The maintenance preview highlights in the connection's query language") + func maintenancePreviewUsesConnectionLanguage() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host( + maintenanceSheet(["db.orders.reIndex()"], databaseType: .mongodb), + size: Self.maintenanceSize + ) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + let color = textView.textStorage?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor + let palette = ThemeEngine.shared.palette + + #expect(ThemeTestFixture.hex(color) == ThemeTestFixture.hex(palette[.syntaxKeyword])) + #expect(ThemeTestFixture.hex(color) != ThemeTestFixture.hex(palette[.editorText])) + withExtendedLifetime(window) {} + } + } + + // MARK: - Other callers + + @Test("Without a line layout, long lines stay unwrapped and the view fills the space it is given") + func defaultLayoutUnchanged() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let size = NSSize(width: 300, height: 200) + let window = ThemeTestFixture.host(HighlightedSQLTextView(sql: Self.longClause), size: size) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + let scrollView = try #require(textView.enclosingScrollView) + + #expect(textView.textContainer?.widthTracksTextView == false) + #expect(textView.isHorizontallyResizable) + #expect(!scrollView.autohidesScrollers) + #expect(scrollView.frame.size == size) + #expect(usedSize(of: textView).height < lineHeight * 1.5) + withExtendedLifetime(window) {} + } + } +} diff --git a/TableProTests/Views/DetailSurfaceThemeTests.swift b/TableProTests/Views/DetailSurfaceThemeTests.swift new file mode 100644 index 0000000000..c6c35e1356 --- /dev/null +++ b/TableProTests/Views/DetailSurfaceThemeTests.swift @@ -0,0 +1,117 @@ +// +// DetailSurfaceThemeTests.swift +// TableProTests +// +// Each surface keeps the system color it drew before themes reached it, and takes the theme +// color only for the slot it stands for. +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@Suite("Detail surfaces keep their system color until the theme sets their slot", .serialized) +@MainActor +struct DetailSurfaceThemeTests { + private static let size = CGSize(width: 320, height: 240) + + private static let slotColors: [ThemeSlot: String] = [ + .windowBackdrop: ThemeTestFixture.windowHex, + .editorBackground: ThemeTestFixture.editorHex, + .gridBackground: ThemeTestFixture.gridHex, + ] + + /// Under the default pair the surface draws `native`; under its own slot it draws the theme; under every other slot it still draws `native`. + private func assertSurface(_ view: some View, at point: CGPoint, slot: ThemeSlot, native: NSColor) throws { + let hex = try #require(Self.slotColors[slot]) + let others = Self.slotColors.filter { $0.key != slot } + + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let expected = try ThemeTestFixture.filled(native, colorScheme: .dark) + let drawn = try ThemeTestFixture.color(of: view, at: point, colorScheme: .dark) + #expect(ThemeTestFixture.matches(drawn, expected), "default pair drew \(drawn), native \(expected)") + + try ThemeTestFixture.adopt([slot: hex], appearance: .dark) + let themed = try ThemeTestFixture.color(of: view, at: point, colorScheme: .dark) + let theme = try ThemeTestFixture.filled(hex: hex, colorScheme: .dark) + #expect(ThemeTestFixture.matches(themed, theme), "\(slot.rawValue) drew \(themed), theme \(theme)") + + try ThemeTestFixture.adopt(others, appearance: .dark) + let unrelated = try ThemeTestFixture.color(of: view, at: point, colorScheme: .dark) + #expect(ThemeTestFixture.matches(unrelated, expected), "another slot drew \(unrelated), native \(expected)") + } + } + + @Test("Version history fills the window backdrop, over its own text background") + func versionHistoryRoot() throws { + let view = VersionHistoryTabView( + tabId: UUID(), + subject: .savedQuery(id: UUID()), + databaseType: .mysql, + exportFileName: "query.sql", + onOpenInEditor: { _ in } + ) + .frame(width: Self.size.width, height: Self.size.height) + try assertSurface(view, at: CGPoint(x: 0.03, y: 0.05), slot: .windowBackdrop, native: .textBackgroundColor) + } + + @Test("An object source that is still loading sits on the editor background") + func objectSourceLoading() throws { + let view = ObjectSourceTabView( + connectionId: UUID(), + databaseType: .mysql, + objectRef: DatabaseObjectRef(kind: .view, name: "active_orders", database: "shop"), + onOpenInEditor: { _ in } + ) + .frame(width: Self.size.width, height: Self.size.height) + try assertSurface(view, at: CGPoint(x: 0.03, y: 0.95), slot: .editorBackground, native: .textBackgroundColor) + } + + @Test("A data file window's content fills the window backdrop") + func dataFileRoot() throws { + let controller = DataFileController() + let view = DataFileContentView(controller: controller, gridDelegate: DataFileGridDelegate(controller: controller)) + .frame(width: Self.size.width, height: Self.size.height) + try assertSurface(view, at: CGPoint(x: 0.03, y: 0.05), slot: .windowBackdrop, native: .windowBackgroundColor) + } + + @Test("A data file with no rows covers the grid in the grid's own background") + func dataFileEmptyRows() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("DetailSurfaceThemeTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("empty.csv") + try Data("name,age\n".utf8).write(to: url) + + let controller = DataFileController() + controller.load(url: url, kind: try #require(DataFileKind.classify(url))) + await controller.waitForPendingWork() + #expect(controller.loadState == .loaded) + #expect(controller.tableRows.rows.isEmpty) + + let view = DataFileContentView(controller: controller, gridDelegate: DataFileGridDelegate(controller: controller)) + .frame(width: Self.size.width, height: Self.size.height) + try assertSurface(view, at: CGPoint(x: 0.03, y: 0.05), slot: .gridBackground, native: .textBackgroundColor) + } + + @Test("The filter panel header keeps its control background until the theme colors the window") + func filterPanelHeader() throws { + let view = FilterPanelView( + state: .constant(TabFilterState()), + configuration: FilterPanelConfiguration( + columns: [], + primaryKeyColumn: nil, + offersRawFilter: false, + caseMatching: .inMemory, + sqlPreview: nil, + presetStore: nil + ), + actions: DataFileController() + ) + .frame(width: 480) + try assertSurface(view, at: CGPoint(x: 0.004, y: 0.1), slot: .windowBackdrop, native: .controlBackgroundColor) + } +} diff --git a/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift b/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift new file mode 100644 index 0000000000..f7b385067d --- /dev/null +++ b/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift @@ -0,0 +1,290 @@ +// +// ERDiagramThemeTests.swift +// TableProTests +// +// Tables on the ER canvas take the data grid's slots and the canvas takes the window background, +// while the default pair draws exactly the system colors the diagram drew before themes reached it. +// An export renders what the canvas shows. +// + +import AppKit +@testable import TablePro +import Testing + +/// A bitmap cache converts through its own color space, so a theme color is held to the same color +/// drawn by a plain fill into the same kind of cache. +@MainActor +private final class ThemeFillView: NSView { + let color: NSColor + + init(color: NSColor) { + self.color = color + super.init(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + override func draw(_ dirtyRect: NSRect) { + color.setFill() + dirtyRect.fill() + } +} + +@Suite("The ER diagram follows the theme", .serialized) +@MainActor +struct ERDiagramThemeTests { + private typealias Fixture = ThemeTestFixture + + private static let themed: [ThemeSlot: String] = [ + .windowBackdrop: "#1E2030", + .gridBackground: "#2A6F4E", + .gridHeaderBackground: "#7A3B8F", + .gridHeaderText: "#F8F8F2", + .gridText: "#E0E0E0", + .gridLine: "#6272A4", + .gridFocusBorder: "#FF79C6", + ] + + private func appearance(_ name: NSAppearance.Name) throws -> NSAppearance { + try #require(NSAppearance(named: name)) + } + + private func scene(clusterColor: NSColor? = nil) -> (ERDiagramScene, CGRect) { + let node = ERTableNode(id: UUID(), tableName: "t", columns: [], displayColumns: [], clusterId: nil) + let rect = CGRect( + x: 0, y: 0, + width: ERDiagramLayout.nodeWidth, + height: ERDiagramLayout.estimateHeight(columnCount: 3) + ) + let scene = ERDiagramScene( + nodes: [node], + edges: [], + nodeRects: [node.id: rect], + nodeIndex: [node.tableName: node.id], + clusterColors: clusterColor.map { [node.id: $0] } ?? [:], + selectedNodeId: nil, + size: CGSize(width: rect.maxX + 20, height: rect.maxY + 20) + ) + return (scene, rect) + } + + // MARK: - Palette + + @Test("Under the default pair a table draws the system colors it always has", arguments: [ + NSAppearance.Name.aqua, .darkAqua, + ]) + func defaultPairDrawsSystemColors(_ name: NSAppearance.Name) throws { + try Fixture.preservingEngine { + try Fixture.adopt([:], appearance: .light) + let appearance = try appearance(name) + let palette = ERDiagramPalette(appearance: appearance) + + #expect(palette.backdrop == Fixture.baked(.controlBackgroundColor, in: appearance)) + #expect(palette.nodeFill == Fixture.baked(.controlBackgroundColor, in: appearance)) + #expect(palette.headerFill == nil) + #expect(palette.headerWash(cluster: nil) == mainAccentWash(in: appearance)) + #expect(palette.headerWash(cluster: .systemGreen) == mainClusterWash(.systemGreen, in: appearance)) + #expect(palette.rule == Fixture.baked(.tertiaryLabelColor, in: appearance)) + #expect(palette.selectionRule == Fixture.baked(.controlAccentColor, in: appearance)) + #expect(palette.edge == mainEdge(in: appearance)) + #expect(palette.text == .labelColor) + #expect(palette.headerText == .labelColor) + #expect(palette.secondaryText == .secondaryLabelColor) + } + } + + @Test("A theme colors the tables from the grid's slots and keeps meaning hues and connectors system") + func themeColorsTablesFromGridSlots() throws { + try Fixture.preservingEngine { + try Fixture.adopt(Self.themed, appearance: .light) + let appearance = try appearance(.aqua) + let palette = ERDiagramPalette(appearance: appearance) + + #expect(Fixture.hex(palette.backdrop) == "#1E2030") + #expect(Fixture.hex(palette.nodeFill) == "#2A6F4E") + #expect(Fixture.hex(palette.headerFill) == "#7A3B8F") + #expect(Fixture.hex(palette.rule) == "#6272A4") + #expect(Fixture.hex(palette.selectionRule) == "#FF79C6") + #expect(HexColor.string(from: palette.text) == "#E0E0E0") + #expect(HexColor.string(from: palette.headerText) == "#F8F8F2") + #expect(palette.secondaryText == .secondaryLabelColor) + #expect(palette.edge == mainEdge(in: appearance)) + #expect(palette.headerWash(cluster: .systemGreen) == mainClusterWash(.systemGreen, in: appearance)) + } + } + + @Test("A canvas bakes the theme of its own appearance, not the active one") + func paletteFollowsTheCanvasAppearance() throws { + try Fixture.preservingEngine { + try Fixture.adopt(Self.themed, appearance: .dark) + let light = try appearance(.aqua) + let dark = try appearance(.darkAqua) + + #expect(Fixture.hex(ERDiagramPalette(appearance: dark).nodeFill) == "#2A6F4E") + #expect(Fixture.hex(ERDiagramPalette(appearance: dark).backdrop) == "#1E2030") + #expect(ERDiagramPalette(appearance: light).nodeFill == Fixture.baked(.controlBackgroundColor, in: light)) + #expect(ERDiagramPalette(appearance: light).backdrop == Fixture.baked(.controlBackgroundColor, in: light)) + } + } + + // MARK: - What main drew, evaluated inside the appearance as its `draw` did + + private func mainAccentWash(in appearance: NSAppearance) -> CGColor { + Fixture.resolved(in: appearance) { NSColor.controlAccentColor.withAlphaComponent(0.15).cgColor } + } + + private func mainClusterWash(_ cluster: NSColor, in appearance: NSAppearance) -> CGColor { + Fixture.resolved(in: appearance) { cluster.withAlphaComponent(0.22).cgColor } + } + + private func mainEdge(in appearance: NSAppearance) -> CGColor { + Fixture.resolved(in: appearance) { NSColor.secondaryLabelColor.withAlphaComponent(0.7).cgColor } + } + + // MARK: - Canvas + + @Test("The canvas re-bakes its colors when the theme changes") + func sceneViewRebakesOnThemeChange() throws { + try Fixture.preservingEngine { + try Fixture.adopt([:], appearance: .light) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 200), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let view = ERDiagramSceneView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + view.appearance = try appearance(.aqua) + window.contentView = view + let before = view.palette.nodeFill + view.needsDisplay = false + + try Fixture.adopt(Self.themed, appearance: .light) + + #expect(view.needsDisplay) + #expect(Fixture.hex(view.palette.nodeFill) == "#2A6F4E") + #expect(view.palette.nodeFill != before) + window.contentView = nil + } + } + + @Test("The canvas draws a table in the theme's grid background") + func sceneViewDrawsThemedTable() throws { + try Fixture.preservingEngine { + try Fixture.adopt(Self.themed, appearance: .light) + let (scene, rect) = scene() + let view = ERDiagramSceneView(frame: NSRect(origin: .zero, size: scene.size)) + view.appearance = try appearance(.aqua) + view.scene = scene + + let rep = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: rep) + let reference = ThemeFillView(color: HexColor.color("#2A6F4E")) + reference.appearance = view.appearance + let referenceRep = try #require(reference.bitmapImageRepForCachingDisplay(in: reference.bounds)) + reference.cacheDisplay(in: reference.bounds, to: referenceRep) + + let scale = CGFloat(rep.pixelsWide) / view.bounds.width + let body = try #require(Fixture.pixel( + rep, x: Int((rect.maxX - 20) * scale), y: Int((rect.maxY - 10) * scale) + )) + let expected = try #require(Fixture.pixel(referenceRep, x: referenceRep.pixelsWide / 2, y: referenceRep.pixelsHigh / 2)) + #expect(Fixture.matches(body, expected), "drew \(body), theme \(expected)") + } + } + + @Test("A theme's window background fills the canvas, and only while the theme sets it") + func canvasBackdropFollowsTheTheme() throws { + try Fixture.preservingEngine { + try Fixture.adopt([:], appearance: .light) + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + scrollView.appearance = try appearance(.aqua) + scrollView.drawsBackground = false + let original = scrollView.backgroundColor + let backdrop = DiagramCanvasBackdrop(scrollView: scrollView) + + #expect(!scrollView.drawsBackground) + #expect(!backdrop.isThemed) + + try Fixture.adopt(Self.themed, appearance: .light) + #expect(scrollView.drawsBackground) + #expect(Fixture.hex(Fixture.baked(scrollView.backgroundColor, in: try appearance(.aqua))) == "#1E2030") + #expect(backdrop.isThemed) + + try Fixture.adopt([:], appearance: .light) + #expect(!scrollView.drawsBackground) + #expect(scrollView.backgroundColor == original) + withExtendedLifetime(backdrop) {} + } + } + + // MARK: - Export + + @Test("An export takes the canvas's appearance rather than one built from the color scheme") + func exportAppearanceIsTheCanvas() throws { + let controller = DiagramViewportController() + let scrollView = DiagramScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + scrollView.appearance = try appearance(.darkAqua) + controller.attach(to: scrollView) + + #expect(controller.effectiveAppearance.name == scrollView.effectiveAppearance.name) + #expect(controller.effectiveAppearance.isDarkTheme) + + scrollView.appearance = try appearance(.aqua) + #expect(!controller.effectiveAppearance.isDarkTheme) + controller.detach(from: scrollView) + } + + @Test("An export fills what the canvas shows: system colors by default, the theme's under one", arguments: [ + false, true, + ]) + func exportRendersWhatTheCanvasShows(themed: Bool) throws { + try Fixture.preservingEngine { + try Fixture.adopt(themed ? Self.themed : [:], appearance: .light) + let appearance = try appearance(.aqua) + let palette = ERDiagramPalette(appearance: appearance) + let (scene, rect) = scene() + let image = try #require(ERDiagramSceneRenderer.image(scene, appearance: appearance, scale: 1)) + let rep = try #require(image.representations.first as? NSBitmapImageRep) + let padding = Int(ERDiagramSceneRenderer.exportPadding) + + let backdrop = try #require(Fixture.pixel(rep, x: 4, y: 4)) + let body = try #require(Fixture.pixel(rep, x: padding + Int(rect.maxX) - 20, y: padding + Int(rect.maxY) - 10)) + let header = try #require(Fixture.pixel( + rep, x: padding + Int(rect.maxX) - 20, y: padding + Int(ERDiagramLayout.headerHeight / 2) + )) + + #expect(Fixture.matches(backdrop, try reference(palette.backdrop))) + #expect(Fixture.matches(body, try reference(palette.nodeFill))) + if themed { + #expect(Fixture.hex(palette.backdrop) == "#1E2030") + #expect(Fixture.matches(header, try reference(try #require(palette.headerFill)))) + } else { + #expect(palette.backdrop == Fixture.baked(.controlBackgroundColor, in: appearance)) + #expect(!Fixture.matches(header, try reference(palette.nodeFill))) + } + } + } + + /// Filled into the same kind of bitmap the export draws into, so the conversion cancels out. + private func reference(_ color: CGColor) throws -> NSColor { + let rep = try #require(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: 4, + pixelsHigh: 4, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + )) + let context = try #require(NSGraphicsContext(bitmapImageRep: rep)).cgContext + context.setFillColor(color) + context.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + return try #require(Fixture.pixel(rep, x: 1, y: 1)) + } +} diff --git a/TableProTests/Views/Hosting/HostingViewSizingOptionsTests.swift b/TableProTests/Views/Hosting/HostingViewSizingOptionsTests.swift index de28bde35c..6271a3cc56 100644 --- a/TableProTests/Views/Hosting/HostingViewSizingOptionsTests.swift +++ b/TableProTests/Views/Hosting/HostingViewSizingOptionsTests.swift @@ -3,7 +3,10 @@ // TableProTests // +import AppKit import Foundation +import SwiftUI +@testable import TablePro import Testing /// `NSHostingView.sizingOptions` defaults to `.standardBounds`, which measures a minimum, an @@ -19,8 +22,6 @@ struct HostingViewSizingOptionsTests { "TablePro/Views/Sidebar/SourceList/SidebarHostingCellView.swift", "TablePro/Views/Shared/FieldDrivenList.swift", "TablePro/Views/DataFiles/DataFileSplitViewController.swift", - "TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift", - "TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift", "TablePro/Views/Components/CheckboxOutlineView.swift", "TablePro/Views/QueryPlan/QueryPlanDiagramCanvasView.swift", ] @@ -42,6 +43,29 @@ struct HostingViewSizingOptionsTests { } } + private static let chromeHostedOutlines = [ + "TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift", + "TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift", + ] + + @Test("The records outlines host every cell through the theme chrome's cell, which states its sizing options") + @MainActor + func chromeHostedCellsDeclareSizingOptions() throws { + let cell = ThemeTableChrome.HostingCell.make( + NSUserInterfaceItemIdentifier("cell"), + in: NSTableView(), + showing: Text(verbatim: "value") + ) + #expect(cell.sizingOptions.isEmpty) + + let root = try repositoryRoot() + for source in Self.chromeHostedOutlines { + let text = try String(contentsOf: root.appendingPathComponent(source), encoding: .utf8) + #expect(text.contains("ThemeTableChrome.HostingCell.make("), "\(source) no longer hosts its cells through ThemeTableChrome.HostingCell") + #expect(!text.contains("NSHostingView("), "\(source) builds an NSHostingView of its own; set `sizingOptions = []` on it") + } + } + private func repositoryRoot(file: StaticString = #filePath) throws -> URL { var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() while directory.path != "/" { diff --git a/TableProTests/Views/Main/DetailPaneBackdropTests.swift b/TableProTests/Views/Main/DetailPaneBackdropTests.swift new file mode 100644 index 0000000000..541cfca040 --- /dev/null +++ b/TableProTests/Views/Main/DetailPaneBackdropTests.swift @@ -0,0 +1,101 @@ +// +// DetailPaneBackdropTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@Suite("The detail pane backdrop stops below the toolbar until the toolbar takes the theme", .serialized) +@MainActor +struct DetailPaneBackdropTests { + private static let insetHeight: CGFloat = 20 + private static let top = CGPoint(x: 0.5, y: 0.1) + private static let body = CGPoint(x: 0.5, y: 0.6) + + /// The inset stands in for the toolbar's top safe area. + private func pane(extendsUnderToolbar: Bool) -> some View { + Color.clear + .frame(width: 80, height: 80) + .modifier(DetailPaneBackdropFill(extendsUnderToolbar: extendsUnderToolbar)) + .safeAreaInset(edge: .top, spacing: 0) { + Color.clear.frame(height: Self.insetHeight) + } + } + + private func nativeBackground() throws -> NSColor { + try ThemeTestFixture.filled(.background, colorScheme: .dark) + } + + @Test("The toolbar takes the theme on macOS 26 and later only") + func gateFollowsTheBackdropRelease() { + let backdropRelease = OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0) + #expect(DetailPaneBackdrop.extendsUnderToolbar == ProcessInfo.processInfo.isOperatingSystemAtLeast(backdropRelease)) + } + + @Test("The native backdrop always reaches every edge") + func nativeReachesEveryEdge() { + #expect(DetailPaneBackdrop.safeAreaEdges(isThemed: false, extendsUnderToolbar: false) == .all) + #expect(DetailPaneBackdrop.safeAreaEdges(isThemed: false, extendsUnderToolbar: true) == .all) + } + + @Test("A themed backdrop reaches under the toolbar only where the toolbar takes the theme") + func themedStopsBelowASystemToolbar() { + #expect(DetailPaneBackdrop.safeAreaEdges(isThemed: true, extendsUnderToolbar: true) == .all) + let edges = DetailPaneBackdrop.safeAreaEdges(isThemed: true, extendsUnderToolbar: false) + #expect(!edges.contains(.top)) + #expect(edges.contains(.bottom) && edges.contains(.leading) && edges.contains(.trailing)) + } + + @Test("Only the window slot themes the backdrop, and Increase Contrast keeps it native") + func themedOnlyByTheWindowSlot() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + #expect(!DetailPaneBackdrop.isThemed(contrast: .standard)) + + try ThemeTestFixture.adopt([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + #expect(DetailPaneBackdrop.isThemed(contrast: .standard)) + #expect(!DetailPaneBackdrop.isThemed(contrast: .increased)) + + try ThemeTestFixture.adopt([ + .editorBackground: ThemeTestFixture.editorHex, + .gridBackground: ThemeTestFixture.gridHex, + ], appearance: .dark) + #expect(!DetailPaneBackdrop.isThemed(contrast: .standard)) + } + } + + @Test("The default pair fills under the toolbar with the system background on both sides of the gate") + func defaultFillsUnderTheToolbar() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let native = try nativeBackground() + for extends in [false, true] { + let top = try ThemeTestFixture.color(of: pane(extendsUnderToolbar: extends), at: Self.top, colorScheme: .dark) + let body = try ThemeTestFixture.color(of: pane(extendsUnderToolbar: extends), at: Self.body, colorScheme: .dark) + #expect(ThemeTestFixture.matches(top, native), "extends \(extends): top drew \(top), native \(native)") + #expect(ThemeTestFixture.matches(body, native), "extends \(extends): body drew \(body), native \(native)") + } + } + } + + @Test("A themed backdrop leaves a system toolbar's safe area unfilled") + func themedLeavesASystemToolbarAlone() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + let themed = try ThemeTestFixture.filled(hex: ThemeTestFixture.windowHex, colorScheme: .dark) + let top = try ThemeTestFixture.color(of: pane(extendsUnderToolbar: false), at: Self.top, colorScheme: .dark) + let body = try ThemeTestFixture.color(of: pane(extendsUnderToolbar: false), at: Self.body, colorScheme: .dark) + #expect(ThemeTestFixture.matches(body, themed), "body drew \(body), theme \(themed)") + #expect(top.alphaComponent < 0.05, "top drew \(top)") + } + } + + @Test("A themed backdrop fills under a toolbar that takes the theme") + func themedFillsUnderAThemedToolbar() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + let themed = try ThemeTestFixture.filled(hex: ThemeTestFixture.windowHex, colorScheme: .dark) + let top = try ThemeTestFixture.color(of: pane(extendsUnderToolbar: true), at: Self.top, colorScheme: .dark) + #expect(ThemeTestFixture.matches(top, themed), "top drew \(top), theme \(themed)") + } + } +} diff --git a/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift b/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift new file mode 100644 index 0000000000..6d4c8303e1 --- /dev/null +++ b/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift @@ -0,0 +1,149 @@ +// +// EditorTabStripTrackThemeTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@Suite("The tab strip track follows a theme's window color", .serialized) +@MainActor +struct EditorTabStripTrackThemeTests { + private func resolved(_ color: Color, in name: NSAppearance.Name) throws -> NSColor { + let appearance = try #require(NSAppearance(named: name)) + var result: NSColor? + appearance.performAsCurrentDrawingAppearance { + result = NSColor(color).usingColorSpace(.sRGB) + } + return try #require(result) + } + + private func native(in name: NSAppearance.Name) throws -> NSColor { + try resolved(Color(nsColor: .unemphasizedSelectedContentBackgroundColor), in: name) + } + + @Test("The default pair keeps the system track in both appearances") + func defaultPairKeepsTheSystemTrack() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + for name in [NSAppearance.Name.aqua, .darkAqua] { + let track = try resolved(EditorTabStripPalette.trackFill, in: name) + let system = try native(in: name) + #expect(ThemeTestFixture.matches(track, system), "\(name.rawValue) drew \(track)") + } + } + } + + private func track(followsWindowBackdrop: Bool) -> Color { + Color(nsColor: EditorTabStripPalette.trackColor(followsWindowBackdrop: followsWindowBackdrop)) + } + + @Test("Where the window takes the theme, a theme that sets the window background takes the track in its own appearance only") + func windowThemeTakesTheTrack() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + let dark = try resolved(track(followsWindowBackdrop: true), in: .darkAqua) + #expect(HexColor.string(from: dark) == ThemeTestFixture.windowHex) + + let light = try resolved(track(followsWindowBackdrop: true), in: .aqua) + let system = try native(in: .aqua) + #expect(ThemeTestFixture.matches(light, system), "light drew \(light)") + } + } + + @Test("Where the window keeps its system color, the track keeps the system fill under a window theme") + func systemWindowKeepsTheTrack() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + for name in [NSAppearance.Name.aqua, .darkAqua] { + let drawn = try resolved(track(followsWindowBackdrop: false), in: name) + let system = try native(in: name) + #expect(ThemeTestFixture.matches(drawn, system), "\(name.rawValue) drew \(drawn)") + } + } + } + + @Test("The track follows the window backdrop on macOS 26 and later only") + func gateFollowsTheBackdropRelease() { + let backdropRelease = OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0) + let expected = ProcessInfo.processInfo.isOperatingSystemAtLeast(backdropRelease) + #expect(EditorTabStripPalette.followsWindowBackdrop == expected) + } + + /// Never ordered front and never closed: `close()` on a test window over-releases it. + @Test("The track takes the theme exactly when a content window's backdrop does") + func trackMatchesTheWindowBackdrop() throws { + let windowTheme = try ThemeTestFixture.pair([.windowBackdrop: ThemeTestFixture.windowHex], on: .dark) + let editorTheme = try ThemeTestFixture.pair([.editorBackground: ThemeTestFixture.editorHex], on: .dark) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + for theme in [ThemePair.builtIn, windowTheme, editorTheme] { + ThemeTestFixture.adopt(theme, appearance: .dark) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 120, height: 40), + styleMask: [.titled], + backing: .buffered, + defer: true + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .darkAqua) + let original = window.backgroundColor + let backdrop = ThemeWindowBackdrop(window: window) + let windowThemed = window.backgroundColor !== original + + let drawn = try resolved(EditorTabStripPalette.trackFill, in: .darkAqua) + let trackThemed = !ThemeTestFixture.matches(drawn, try native(in: .darkAqua)) + #expect(windowThemed == trackThemed, "\(theme.dark.id): window themed \(windowThemed), track drew \(drawn)") + withExtendedLifetime(backdrop) {} + } + } + } + + @Test("Editor and grid colors leave the track alone") + func contentSlotsLeaveTheTrackAlone() throws { + try ThemeTestFixture.adopting([ + .editorBackground: ThemeTestFixture.editorHex, + .gridBackground: ThemeTestFixture.gridHex, + ], appearance: .dark) { + let track = try resolved(EditorTabStripPalette.trackFill, in: .darkAqua) + let system = try native(in: .darkAqua) + #expect(ThemeTestFixture.matches(track, system), "drew \(track)") + } + } + + @Test("A theme change repaints a track already on screen") + func trackRepaintsOnThemeChange() throws { + let track = Self.host(Color.clear.modifier(EditorTabStripTrackSurface(rowCount: 1))) + let reference = try Self.center(of: Self.host(Color(nsColor: HexColor.color(ThemeTestFixture.windowHex)))) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let before = try Self.center(of: track) + #expect(!ThemeTestFixture.matches(before, reference), "default drew \(before)") + try ThemeTestFixture.adopt([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + let after = try Self.center(of: track) + if EditorTabStripPalette.followsWindowBackdrop { + #expect(ThemeTestFixture.matches(after, reference), "themed drew \(after), theme \(reference)") + } else { + #expect(ThemeTestFixture.matches(after, before), "themed drew \(after), system \(before)") + } + } + } + + private static func host(_ view: some View) -> NSHostingView { + ThemeTestFixture.hostingView(view, size: NSSize(width: 120, height: 40)) + } + + private static func center(of host: NSHostingView) throws -> NSColor { + host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let color = try #require(bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)) + return try #require(color.usingColorSpace(.sRGB)) + } + + @Test("The selected tab stays the system's own fill under a theme") + func selectedTabStaysSystem() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + let selected = try resolved(EditorTabStripPalette.selectedFill, in: .darkAqua) + let system = try resolved(Color(nsColor: .controlColor), in: .darkAqua) + #expect(ThemeTestFixture.matches(selected, system)) + } + } +} diff --git a/TableProTests/Views/Main/GridStandInBackgroundTests.swift b/TableProTests/Views/Main/GridStandInBackgroundTests.swift new file mode 100644 index 0000000000..8b35fa0479 --- /dev/null +++ b/TableProTests/Views/Main/GridStandInBackgroundTests.swift @@ -0,0 +1,79 @@ +// +// GridStandInBackgroundTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Result states that stand in for the grid", .serialized) +@MainActor +struct GridStandInBackgroundTests { + private static let center = CGPoint(x: 0.5, y: 0.5) + + @Test("Only the states drawn where the grid sits take the grid's background") + func onlyGridStandInsQualify() { + let output = PluginServerOutput(lines: ["done"], isTruncated: false) + let cases: [(QueryResultContent, Bool)] = [ + (.idle, true), + (.executing, true), + (.noRows(executionTime: 0.1), true), + (.statementSucceeded(rowsAffected: 1, executionTime: nil, statusMessage: nil, serverOutput: .none), true), + (.serverOutput(output), true), + (.unavailable(mode: .chart), true), + (.grid, false), + (.json, false), + (.chart, false), + (.map, false), + (.queryPlan, false), + (.structure(tableName: "orders"), false), + ] + for (content, expected) in cases { + #expect(content.standsInForGrid == expected, "\(content)") + } + } + + private var swatch: some View { + Color.clear.frame(width: 24, height: 24).gridStandInBackground() + } + + @Test("The default pair draws nothing, so the pane's own backdrop shows as it did") + func defaultPairDrawsNothing() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let drawn = try ThemeTestFixture.color(of: swatch, at: Self.center, colorScheme: .dark) + #expect(drawn.alphaComponent < 0.01, "drew \(drawn)") + } + } + + @Test("A theme that sets the grid background fills the stand-in with it") + func gridThemeFillsTheStandIn() throws { + try ThemeTestFixture.adopting([.gridBackground: ThemeTestFixture.gridHex], appearance: .dark) { + let drawn = try ThemeTestFixture.color(of: swatch, at: Self.center, colorScheme: .dark) + let expected = try ThemeTestFixture.filled(hex: ThemeTestFixture.gridHex, colorScheme: .dark) + #expect(ThemeTestFixture.matches(drawn, expected), "drew \(drawn), theme \(expected)") + } + } + + @Test("A window or editor color alone leaves the stand-in clear") + func otherSlotsLeaveItClear() throws { + try ThemeTestFixture.adopting([ + .windowBackdrop: ThemeTestFixture.windowHex, + .editorBackground: ThemeTestFixture.editorHex, + ], appearance: .dark) { + let drawn = try ThemeTestFixture.color(of: swatch, at: Self.center, colorScheme: .dark) + #expect(drawn.alphaComponent < 0.01, "drew \(drawn)") + } + } + + @Test("A region that is not a stand-in stays clear under a grid theme") + func inactiveRegionStaysClear() throws { + try ThemeTestFixture.adopting([.gridBackground: ThemeTestFixture.gridHex], appearance: .dark) { + let region = Color.clear.frame(width: 24, height: 24).gridStandInBackground(isActive: false) + let drawn = try ThemeTestFixture.color(of: region, at: Self.center, colorScheme: .dark) + #expect(drawn.alphaComponent < 0.01, "drew \(drawn)") + } + } +} diff --git a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index aff48f9da1..43e29c2c6c 100644 --- a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -574,8 +574,7 @@ struct ResultStatusBarLayoutTests { let host = NSHostingView(rootView: SelectionSummaryReadout( state: state, scopeNote: nil, - isPopoverPresented: .constant(false), - leadsWithSeparator: true + isPopoverPresented: .constant(false) )) host.layoutSubtreeIfNeeded() return host.fittingSize.width diff --git a/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift b/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift new file mode 100644 index 0000000000..b5d99777ef --- /dev/null +++ b/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift @@ -0,0 +1,226 @@ +// +// QueryPlanThemeTests.swift +// TableProTests +// +// The plan outline is a records table whose rows stay AppKit's own until a theme draws them, its +// detail pane and the diagram's steps sit on the grid's slots, and an export renders the scheme and +// the backdrop the canvas shows. +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@Suite("The query plan follows the theme", .serialized) +@MainActor +struct QueryPlanThemeTests { + private typealias Fixture = ThemeTestFixture + + private func node(_ operation: String, children: [QueryPlanNode] = []) -> QueryPlanNode { + QueryPlanNode( + operation: operation, + relation: nil, schema: nil, alias: nil, + estimatedStartupCost: nil, + estimatedTotalCost: 10, + estimatedRows: 5, + estimatedWidth: nil, + actualStartupTime: nil, + actualTotalTime: nil, + actualRows: nil, + actualLoops: nil, + properties: [:], + children: children + ) + } + + private func plan() -> QueryPlan { + var plan = QueryPlan( + rootNode: node("Hash Join", children: [node("Seq Scan"), node("Index Scan")]), + planningTime: nil, + executionTime: nil, + rawText: "" + ) + plan.computeCostFractions() + return plan + } + + private func outlineView(in view: NSView) -> NSOutlineView? { + if let outline = view as? NSOutlineView { return outline } + for subview in view.subviews { + if let found = outlineView(in: subview) { return found } + } + return nil + } + + // MARK: - Outline + + private struct MountedOutline { + let window: NSWindow + let outline: NSOutlineView + let coordinator: QueryPlanOutlineCoordinator + } + + /// Never ordered front and never closed: `close()` on a test window over-releases it. + private func mountOutline() throws -> MountedOutline { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 240), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let host = NSHostingView(rootView: QueryPlanOutlineView(plan: plan(), metric: nil, selectedNodeId: .constant(nil))) + host.sizingOptions = [] + window.contentView = host + for _ in 0 ..< 5 { + host.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + } + let outline = try #require(outlineView(in: host)) + let coordinator = try #require(outline.delegate as? QueryPlanOutlineCoordinator) + return MountedOutline(window: window, outline: outline, coordinator: coordinator) + } + + @Test("Under the default pair the outline's rows are AppKit's own row view, and its cells are records cells") + func outlineRowsAreNativeByDefault() throws { + try Fixture.preservingEngine { + try Fixture.adopt([:], appearance: .light) + let mounted = try mountOutline() + let chrome = try #require(mounted.coordinator.themeChrome) + #expect(chrome.family == .records) + + let root = try #require(mounted.coordinator.root) + #expect(mounted.coordinator.outlineView(mounted.outline, rowViewForItem: root) == nil) + #expect(mounted.outline.numberOfRows == 3) + for row in 0 ..< mounted.outline.numberOfRows { + let rowView = try #require(mounted.outline.rowView(atRow: row, makeIfNecessary: true)) + #expect(type(of: rowView) == NSTableRowView.self) + #expect(mounted.outline.view(atColumn: 0, row: row, makeIfNecessary: true) is ThemeTableChrome.HostingCell) + } + mounted.window.contentView = nil + } + } + + @Test("A theme's selection swaps in themed rows, tells the selected row's cells, and a default theme swaps back") + func outlineRowClassFollowsTheTheme() throws { + try Fixture.preservingEngine { + try Fixture.adopt([:], appearance: .light) + let mounted = try mountOutline() + let chrome = try #require(mounted.coordinator.themeChrome) + #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 0)) + _ = try #require(mounted.outline.rowView(atRow: 0, makeIfNecessary: true)) + + var selections: [UUID?] = [] + mounted.coordinator.onSelect = { selections.append($0) } + + try Fixture.adopt([.gridSelection: "#2A6F4E", .gridSelectionText: "#FFFFFF"], appearance: .light) + let themed = try #require(mounted.outline.rowView(atRow: 0, makeIfNecessary: true) as? ThemeTableRowView) + #expect(themed.chrome === chrome) + #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 0)) + #expect(mounted.outline.isItemExpanded(try #require(mounted.coordinator.root))) + themed.isEmphasized = true + let cell = try #require(mounted.outline.view(atColumn: 0, row: 0, makeIfNecessary: true) as? ThemeTableChrome.HostingCell) + #expect(cell.isOnThemedSelection) + + try Fixture.adopt([:], appearance: .light) + let native = try #require(mounted.outline.rowView(atRow: 0, makeIfNecessary: true)) + #expect(type(of: native) == NSTableRowView.self) + #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 0)) + #expect(mounted.outline.numberOfRows == 3) + #expect(selections.isEmpty, "the rebuild reported \(selections.count) selection changes") + mounted.window.contentView = nil + } + } + + @Test("Under the default pair the outline stays AppKit's; under a theme it takes the grid's colors") + func outlineFollowsTheGridSlots() throws { + try Fixture.preservingEngine { + try Fixture.adopt([:], appearance: .light) + let outline = NSOutlineView(frame: NSRect(x: 0, y: 0, width: 300, height: 200)) + outline.usesAlternatingRowBackgroundColors = true + let scrollView = NSScrollView(frame: outline.frame) + scrollView.documentView = outline + let background = outline.backgroundColor + let coordinator = QueryPlanOutlineCoordinator() + coordinator.attachThemeChrome(to: outline) + + #expect(outline.backgroundColor === background) + #expect(outline.usesAlternatingRowBackgroundColors) + + try Fixture.adopt([.gridBackground: "#2A6F4E", .gridLine: "#6272A4"], appearance: .light) + let aqua = try #require(NSAppearance(named: .aqua)) + #expect(Fixture.hex(Fixture.baked(outline.backgroundColor, in: aqua)) == "#2A6F4E") + #expect(Fixture.hex(Fixture.baked(outline.gridColor, in: aqua)) == "#6272A4") + + try Fixture.adopt([:], appearance: .light) + #expect(outline.backgroundColor == background) + } + } + + // MARK: - Detail pane + + @Test("The detail pane fills with the grid background, and its system color by default") + func detailPaneTakesTheGridBackground() throws { + try Fixture.preservingEngine { + try Fixture.adopt([:], appearance: .light) + let pane = QueryPlanDetailPane(node: nil).frame(width: 240, height: 160) + let native = try #require(Fixture.render(pane, colorScheme: .light)) + let nativeReference = try Fixture.filled(.controlBackgroundColor, colorScheme: .light) + #expect(Fixture.matches(try #require(Fixture.pixel(native, x: 4, y: 4)), nativeReference)) + + try Fixture.adopt([.gridBackground: "#2A6F4E"], appearance: .light) + let themed = try #require(Fixture.render(pane, colorScheme: .light)) + let themedReference = try Fixture.filled(hex: "#2A6F4E", colorScheme: .light) + #expect(Fixture.matches(try #require(Fixture.pixel(themed, x: 4, y: 4)), themedReference)) + } + } + + // MARK: - Diagram + + @Test("A step sits on the grid background under a theme, and on nothing extra by default") + func diagramStepTakesTheGridBackground() throws { + try Fixture.preservingEngine { + let step = QueryPlanDiagramNodeView(node: node("Seq Scan"), isSelected: false) + let themeColor = try Fixture.filled(hex: "#2A6F4E", colorScheme: .light) + + try Fixture.adopt([:], appearance: .light) + let native = try #require(Fixture.render(step, colorScheme: .light)) + let nativeInterior = try #require(Fixture.pixel(native, x: native.pixelsWide - 6, y: native.pixelsHigh / 2)) + #expect(!Fixture.matches(nativeInterior, themeColor, tolerance: 0.1)) + #expect(nativeInterior.alphaComponent < 0.5) + + try Fixture.adopt([.gridBackground: "#2A6F4E"], appearance: .light) + let themed = try #require(Fixture.render(step, colorScheme: .light)) + let themedInterior = try #require(Fixture.pixel(themed, x: themed.pixelsWide - 6, y: themed.pixelsHigh / 2)) + #expect(Fixture.matches(themedInterior, themeColor, tolerance: 0.1), "drew \(themedInterior)") + } + } + + @Test("An export renders in the canvas's scheme, on the system color by default", arguments: [ + ColorScheme.light, .dark, + ]) + func exportUsesTheCanvasScheme(_ colorScheme: ColorScheme) throws { + try Fixture.preservingEngine { + try Fixture.adopt([:], appearance: .light) + let canvas = QueryPlanDiagramExportCanvas( + layout: QueryPlanDiagramLayout(root: plan().rootNode), + colorScheme: colorScheme + ) + let rep = try #require(Fixture.render(canvas, colorScheme: .light)) + let reference = try Fixture.filled(.controlBackgroundColor, colorScheme: colorScheme) + #expect(Fixture.matches(try #require(Fixture.pixel(rep, x: 2, y: 2)), reference)) + } + } + + @Test("An export fills the theme's window background") + func exportFillsTheWindowBackground() throws { + try Fixture.preservingEngine { + try Fixture.adopt([.windowBackdrop: "#1E2030"], appearance: .dark) + let canvas = QueryPlanDiagramExportCanvas(layout: QueryPlanDiagramLayout(root: plan().rootNode), colorScheme: .dark) + let rep = try #require(Fixture.render(canvas, colorScheme: .light)) + let reference = try Fixture.filled(hex: "#1E2030", colorScheme: .dark) + #expect(Fixture.matches(try #require(Fixture.pixel(rep, x: 2, y: 2)), reference)) + } + } +} diff --git a/TableProTests/Views/Results/ResultChartCanvasTests.swift b/TableProTests/Views/Results/ResultChartCanvasTests.swift index c6e167cf3e..2390e49312 100644 --- a/TableProTests/Views/Results/ResultChartCanvasTests.swift +++ b/TableProTests/Views/Results/ResultChartCanvasTests.swift @@ -178,6 +178,75 @@ struct ResultChartCanvasTests { #expect(widest - narrowest <= 4) } + @Test("The plot stands in for the grid: its background under a theme, its own wash by default") + func plotTakesTheGridBackground() throws { + try ThemeTestFixture.preservingEngine { + let view = ResultChartCanvas(projection: singleSeriesProjection, chartType: .bar) + .frame(width: 640, height: 360) + .accentColor(.red) + let total = 640 * 360 + let theme = try ThemeTestFixture.filled(hex: "#FFD0E0", colorScheme: .light) + let wash = try ThemeTestFixture.filled( + Color(nsColor: .controlBackgroundColor).opacity(0.45), colorScheme: .light + ) + + try ThemeTestFixture.adopt([:], appearance: .light) + let native = try #require(ThemeTestFixture.render(view, colorScheme: .light)) + #expect(ThemeTestFixture.count(in: native, where: { ThemeTestFixture.matches($0, theme) }) == 0) + #expect(ThemeTestFixture.count(in: native, where: { ThemeTestFixture.matches($0, wash) }) > total / 5) + + try ThemeTestFixture.adopt([.gridBackground: "#FFD0E0"], appearance: .light) + let themed = try #require(ThemeTestFixture.render(view, colorScheme: .light)) + #expect(ThemeTestFixture.count(in: themed, where: { ThemeTestFixture.matches($0, theme) }) > total / 5) + #expect(ThemeTestFixture.count(in: themed, where: { ThemeTestFixture.matches($0, wash) }) < total / 50) + } + } + + @Test("The plot's grid lines take the grid line color under a theme") + func gridLinesTakeTheGridLineColor() throws { + try ThemeTestFixture.preservingEngine { + let view = ResultChartCanvas(projection: singleSeriesProjection, chartType: .bar) + .frame(width: 640, height: 360) + .accentColor(.red) + + try ThemeTestFixture.adopt([.gridBackground: "#000000"], appearance: .light) + let native = try #require(ThemeTestFixture.render(view, colorScheme: .light)) + try ThemeTestFixture.adopt([.gridBackground: "#000000", .gridLine: "#0000FF"], appearance: .light) + let themed = try #require(ThemeTestFixture.render(view, colorScheme: .light)) + + #expect(bluePixelCount(in: native) == 0) + #expect(bluePixelCount(in: themed) > 200) + } + } + + private func bluePixelCount(in rep: NSBitmapImageRep) -> Int { + var count = 0 + for y in 0.. 0.3 { count += 1 } + } + } + return count + } + + private var singleSeriesProjection: ResultChartProjection { + ResultChartProjection( + points: [ + bandPoint(index: 0, x: "A", y: 10, group: 0), + bandPoint(index: 1, x: "B", y: 20, group: 1), + bandPoint(index: 2, x: "C", y: 15, group: 2), + ], + xAxisKind: .category, + limits: [], + loadedRowCount: 3, + skippedRowCount: 0, + xAxisLabel: "Category", + yAxisLabel: "Amount", + seriesLabel: nil + ) + } + private func bandPoint(index: Int, x: String, y: Int, group: Int) -> ResultChartProjection.Point { ResultChartProjection.Point( sourceIndex: index, diff --git a/TableProTests/Views/Results/StatusBarChromeTests.swift b/TableProTests/Views/Results/StatusBarChromeTests.swift new file mode 100644 index 0000000000..b1f79996e5 --- /dev/null +++ b/TableProTests/Views/Results/StatusBarChromeTests.swift @@ -0,0 +1,100 @@ +// +// StatusBarChromeTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@Suite("A status bar drops its material only over a themed backdrop", .serialized) +@MainActor +struct StatusBarChromeTests { + private static func host(_ view: some View) -> NSHostingView { + ThemeTestFixture.hostingView(view, size: NSSize(width: 240, height: StatusBarChrome.height + 1)) + } + + private static func drawsMaterial(_ host: NSView) -> Bool { + host.layoutSubtreeIfNeeded() + return containsMaterial(host) + } + + private static func containsMaterial(_ view: NSView) -> Bool { + view.subviews.contains { $0 is NSVisualEffectView || containsMaterial($0) } + } + + private static var bar: some View { + Text(verbatim: "1 row").statusBarChrome() + } + + @Test("A bar draws its material by default") + func materialByDefault() { + #expect(Self.drawsMaterial(Self.host(Self.bar))) + } + + @Test("A bar whose host paints the backdrop draws no material") + func noMaterialOverAPaintedBackdrop() { + #expect(!Self.drawsMaterial(Self.host(Self.bar.statusBarDrawsMaterial(false)))) + } + + @Test("A bar outside the detail pane keeps its material under a window theme") + func otherBarsKeepTheirMaterial() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + #expect(Self.drawsMaterial(Self.host(Self.bar))) + } + } + + @Test("The result bar drops its material while the window slot themes the detail backdrop, and gets it back after") + func resultBarFollowsTheDetailBackdrop() throws { + let window = try ThemeTestFixture.pair([.windowBackdrop: ThemeTestFixture.windowHex], on: .dark) + let content = try ThemeTestFixture.pair([ + .editorBackground: ThemeTestFixture.editorHex, + .gridBackground: ThemeTestFixture.gridHex, + ], on: .dark) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let host = Self.host(Self.bar.modifier(DetailPaneStatusBarMaterial())) + #expect(Self.drawsMaterial(host), "default pair") + + ThemeTestFixture.adopt(window, appearance: .dark) + #expect(!Self.drawsMaterial(host), "window theme") + + ThemeTestFixture.adopt(content, appearance: .dark) + #expect(Self.drawsMaterial(host), "content theme") + + ThemeTestFixture.adopt(.builtIn, appearance: .dark) + #expect(Self.drawsMaterial(host), "default pair again") + } + } + + @Test("The result bar keeps its material under Increase Contrast, where the backdrop stays native") + func increaseContrastKeepsTheMaterial() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + #expect(!DetailPaneBackdrop.isThemed(contrast: .increased)) + } + } + + private static let statusBarSources = [ + "TablePro/Views/Results/StatusBarChrome.swift", + "TablePro/Views/Results/ResultStatusBar.swift", + "TablePro/Views/Results/SelectionSummaryReadout.swift", + "TablePro/Views/Compare/CompareStatusBar.swift", + "TablePro/Views/DataFiles/DataFileStatusBar.swift", + ] + + @Test("No status bar separates its items with a middle dot") + func noMiddleDotSeparators() throws { + let root = try ThemeSourceScan.repositoryRoot() + var offenders: [String] = [] + for path in Self.statusBarSources { + let text = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) + for (index, line) in text.components(separatedBy: .newlines).enumerated() { + let code = line.trimmingCharacters(in: .whitespaces) + guard !code.hasPrefix("//"), code.contains("\u{00B7}") else { continue } + offenders.append("\(path):\(index + 1)") + } + } + #expect(offenders.isEmpty, "Middle dots in status bar text: \(offenders)") + } +} diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift index 3c3344dda4..9c48cb3c36 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift @@ -85,7 +85,7 @@ struct TreeOutlineCellViewTests { #expect(field.isSelectable) #expect(!field.allowsEditingTextAttributes) #expect(field.stringValue == "\"Acme\"") - #expect(field.textColor == .systemRed) + #expect(field.textColor == TreeValueTone.string.color) #expect(field.font == TreeOutlineFixture.fonts.value) #expect(cell.accessibilityCustomActions()?.isEmpty ?? true) } @@ -158,8 +158,8 @@ struct TreeOutlineCellViewTests { #expect(plain.valueField.textColor == .alternateSelectedControlTextColor) #expect(plain.keyField.textColor == .alternateSelectedControlTextColor) plain.backgroundStyle = .normal - #expect(plain.valueField.textColor == .systemPurple) - #expect(plain.keyField.textColor == .systemBlue) + #expect(plain.valueField.textColor == TreeValueTone.number.color) + #expect(plain.keyField.textColor == TreeValueTone.key.color) let link = try cell("site") link.backgroundStyle = .emphasized @@ -168,7 +168,42 @@ struct TreeOutlineCellViewTests { link.backgroundStyle = .normal let normal = link.valueField.attributedStringValue #expect(normal.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor == .linkColor) - #expect(normal.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == .systemRed) + #expect(normal.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == TreeValueTone.string.color) + } + + @Test("Under a theme the key, value, colon and visibility take its colors, and an emphasized row turns them white") + func themedColors() throws { + try ThemeTestFixture.adopting(TreeOutlineFixture.tones, appearance: .light) { + let node = PhpTreeNode( + key: "secret", + keyPath: "$.secret", + path: TreeNodePath.root.appending(.key("secret", occurrence: 0)), + nodeType: .int, + displayValue: "7", + visibilityBadge: "protected" + ) + let cell = configured(node.rowContent) + + #expect(ThemeTestFixture.hex(cell.keyField.textColor) == TreeOutlineFixture.tones[.syntaxString]) + #expect(ThemeTestFixture.hex(cell.valueField.textColor) == TreeOutlineFixture.tones[.syntaxNumber]) + #expect(ThemeTestFixture.hex(cell.visibilityField.textColor) == TreeOutlineFixture.tones[.editorLineNumber]) + + cell.backgroundStyle = .emphasized + #expect(cell.keyField.textColor == .alternateSelectedControlTextColor) + #expect(cell.valueField.textColor == .alternateSelectedControlTextColor) + } + } + + @Test("Under the default pair the colon and visibility keep the secondary label color") + func defaultSecondaryText() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let cell = try cell("count") + #expect(ThemeTestFixture.hex(cell.visibilityField.textColor) == ThemeTestFixture.hex(.secondaryLabelColor)) + #expect( + ThemeTestFixture.hex(cell.visibilityField.textColor, in: .darkAqua) + == ThemeTestFixture.hex(.secondaryLabelColor, in: .darkAqua) + ) + } } @Test("The accessibility action opens the link through the link policy") diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift index ebfbef9ff0..7599f79718 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift @@ -41,19 +41,6 @@ struct TreeOutlineCoordinatorTests { #expect(harness.keys == ["name", "site", "brand", "theme", "ink", "deep", "count"]) } - @Test("Rows built under a theme that overrides every slot keep their system colors") - func rowsIgnoreTheTheme() throws { - try PaneThemeFixture.withEverySlotOverridden { - let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) - let row = try harness.row("count") - let view = harness.outline.view(atColumn: 0, row: row, makeIfNecessary: false) - let cell = try #require(view as? TreeOutlineCellView) - - #expect(cell.keyField.textColor == .systemBlue) - #expect(cell.valueField.textColor == .systemPurple) - } - } - @Test("Applying the same content twice reloads nothing") func unchangedContentDoesNotReload() throws { let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) @@ -422,6 +409,182 @@ struct TreeOutlineCoordinatorTests { #expect(harness.reported.isEmpty) } + // MARK: - Theme + + @Test("Rows built under a theme take its syntax colors") + func rowsFollowTheTheme() throws { + try ThemeTestFixture.adopting(TreeOutlineFixture.tones, appearance: .light) { + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let cell = try harness.cell("count") + + #expect(ThemeTestFixture.hex(cell.keyField.textColor) == TreeOutlineFixture.tones[.syntaxString]) + #expect(ThemeTestFixture.hex(cell.valueField.textColor) == TreeOutlineFixture.tones[.syntaxNumber]) + } + } + + @Test("A theme change recolors the rows on screen without reloading them") + func themeChangeRecolorsVisibleRows() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let cell = try harness.cell("count") + + try ThemeTestFixture.adopt(TreeOutlineFixture.tones, appearance: .light) + + #expect(cell.valueField.textColor == TreeValueTone.number.color) + #expect(cell.keyField.textColor == TreeValueTone.key.color) + #expect(ThemeTestFixture.hex(cell.valueField.textColor) == TreeOutlineFixture.tones[.syntaxNumber]) + #expect(harness.coordinator.reloadCount == 1) + } + } + + @Test("Under the default pair the outline keeps AppKit's background, stripes, scroll view and selection") + func defaultPairKeepsTheNativeTable() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let native = TreeOutlineView.make() + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let chrome = try #require(harness.coordinator.chrome) + + #expect(harness.outline.backgroundColor == native.backgroundColor) + #expect(harness.outline.usesAlternatingRowBackgroundColors) + #expect(harness.scrollView.drawsBackground) + #expect(!chrome.isBackgroundThemed) + #expect(chrome.rowBackgroundColor(forRow: 1) == nil) + #expect(chrome.selectionColor(emphasized: true) == nil) + } + } + + @Test("A theme's editor background replaces the outline's material and stripes, and leaving it brings them back") + func editorBackgroundThemesTheOutline() throws { + try ThemeTestFixture.adopting([.editorBackground: "#FDF6E3"], appearance: .light) { + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let chrome = try #require(harness.coordinator.chrome) + + #expect(chrome.family == .code) + #expect(ThemeTestFixture.hex(harness.outline.backgroundColor) == "#FDF6E3") + #expect(!harness.outline.usesAlternatingRowBackgroundColors) + #expect(!harness.scrollView.drawsBackground) + #expect(ThemeTestFixture.hex(chrome.rowBackgroundColor(forRow: 1)) == "#FDF6E3") + + try ThemeTestFixture.adopt([:], appearance: .light) + #expect(harness.outline.usesAlternatingRowBackgroundColors) + #expect(harness.scrollView.drawsBackground) + } + } + + /// A row class that overrides a drawing method loses AppKit's layer-backed selection, the vibrant + /// material in a popover among them. + private func drawsLikeNSTableRowView(_ rowClass: AnyClass) -> Bool { + let selectors = [ + #selector(NSTableRowView.drawSelection(in:)), + #selector(NSTableRowView.drawBackground(in:)), + #selector(getter: NSTableRowView.interiorBackgroundStyle), + ] + return selectors.allSatisfy { + class_getMethodImplementation(rowClass, $0) == class_getMethodImplementation(NSTableRowView.self, $0) + } + } + + @Test("Under the default pair every row is the plain tree row, which keeps AppKit's own drawing") + func defaultRowsAreThePlainTreeRow() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + + #expect(harness.outline.numberOfRows > 0) + for row in 0 ..< harness.outline.numberOfRows { + let rowView = try #require(harness.outline.rowView(atRow: row, makeIfNecessary: true)) + #expect(type(of: rowView) == TreeOutlineRowView.self) + #expect(drawsLikeNSTableRowView(type(of: rowView))) + } + } + } + + @Test("A theme that draws the rows swaps in the themed row, and the default pair swaps the plain one back") + func rowClassFollowsTheTheme() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let chrome = try #require(harness.coordinator.chrome) + try harness.select("count") + let keys = harness.keys + for row in 0 ..< harness.outline.numberOfRows { + _ = harness.outline.rowView(atRow: row, makeIfNecessary: true) + } + + for overrides in [[ThemeSlot.editorSelection: "#EEE8D5"], [.editorBackground: "#FDF6E3"]] { + try ThemeTestFixture.adopt(overrides, appearance: .light) + for row in 0 ..< harness.outline.numberOfRows { + let rowView = try #require( + harness.outline.rowView(atRow: row, makeIfNecessary: true) as? ThemedTreeOutlineRowView + ) + #expect(rowView.chrome === chrome) + } + #expect(harness.keys == keys) + #expect(harness.selectedKeys == ["count"]) + + try ThemeTestFixture.adopt([:], appearance: .light) + for row in 0 ..< harness.outline.numberOfRows { + let rowView = try #require(harness.outline.rowView(atRow: row, makeIfNecessary: true)) + #expect(type(of: rowView) == TreeOutlineRowView.self) + } + #expect(harness.keys == keys) + #expect(harness.selectedKeys == ["count"]) + } + #expect(harness.reported.isEmpty) + #expect(harness.coordinator.reloadCount == 1) + } + } + + @Test("A theme that colors only the tree's text keeps the plain tree row") + func syntaxOnlyThemeKeepsThePlainRow() throws { + try ThemeTestFixture.adopting(TreeOutlineFixture.tones, appearance: .light) { + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let rowView = try #require(harness.outline.rowView(atRow: 0, makeIfNecessary: true)) + #expect(type(of: rowView) == TreeOutlineRowView.self) + } + } + + private func emphasizedRow(_ key: String, in harness: TreeOutlineHarness) throws -> (TreeOutlineRowView, TreeOutlineCellView) { + try harness.select(key) + let rowView = try #require( + harness.outline.rowView(atRow: try harness.row(key), makeIfNecessary: true) as? TreeOutlineRowView + ) + rowView.isEmphasized = true + rowView.reapplyEmphasis() + return (rowView, try harness.cell(key)) + } + + /// AppKit's selection is the accent color, which a tone is not chosen to sit on. + @Test("On AppKit's emphasized selection the key and value turn white, as the grid's text does") + func nativeSelectionTurnsTheTextWhite() throws { + try ThemeTestFixture.adopting(TreeOutlineFixture.tones.merging([.editorBackground: "#FDF6E3"]) { $1 }, appearance: .light) { + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let (rowView, cell) = try emphasizedRow("count", in: harness) + + #expect(rowView.isSelected) + #expect(rowView.interiorBackgroundStyle == .emphasized) + #expect(cell.backgroundStyle == .emphasized) + #expect(cell.keyField.textColor == .alternateSelectedControlTextColor) + #expect(cell.valueField.textColor == .alternateSelectedControlTextColor) + } + } + + /// The theme picks its selection to sit behind its own syntax colors, as the JSON text view draws it. + @Test("On a theme's own selection the key and value keep their tones") + func themedSelectionKeepsTheTones() throws { + let overrides = TreeOutlineFixture.tones.merging([.editorBackground: "#FDF6E3", .editorSelection: "#EEE8D5"]) { $1 } + try ThemeTestFixture.adopting(overrides, appearance: .light) { + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let (rowView, cell) = try emphasizedRow("count", in: harness) + + let themedRow = try #require(rowView as? ThemedTreeOutlineRowView) + #expect(rowView.isSelected) + #expect(ThemeTestFixture.hex(themedRow.chrome?.selectionColor(emphasized: true)) == "#EEE8D5") + #expect(rowView.interiorBackgroundStyle == .normal) + #expect(cell.backgroundStyle == .normal) + #expect(ThemeTestFixture.hex(cell.keyField.textColor) == TreeOutlineFixture.tones[.syntaxString]) + #expect(ThemeTestFixture.hex(cell.valueField.textColor) == TreeOutlineFixture.tones[.syntaxNumber]) + } + } + // MARK: - Fonts @Test("The row height comes from the fonts, and a font change keeps rows and selection") diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineFixture.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineFixture.swift index 96c2084704..6b58b55e54 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineFixture.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineFixture.swift @@ -42,6 +42,18 @@ internal enum TreeOutlineFixture { "theme":{"ink":"#1e1e1e","deep":{"leaf":1}},"count":42} """ + /// One distinct color for each slot a tree reads. + static let tones: [ThemeSlot: String] = [ + .syntaxString: "#B00001", + .syntaxNumber: "#B00002", + .syntaxNull: "#B00003", + .syntaxType: "#B00004", + .syntaxComment: "#B00005", + .syntaxKeyword: "#B00006", + .editorText: "#B00007", + .editorLineNumber: "#B00008" + ] + static func parse(_ json: String) throws -> JSONTreeNode { try JSONTreeParser.parse(json, formats: .english).get() } @@ -158,6 +170,11 @@ internal final class TreeOutlineHarness { try #require(items.firstIndex { $0.node.key == key }, "no visible row has the key \(key)") } + func cell(_ key: String) throws -> TreeOutlineCellView { + let view = outline.view(atColumn: 0, row: try row(key), makeIfNecessary: false) + return try #require(view as? TreeOutlineCellView) + } + func item(_ key: String) throws -> Item { try #require(items.first { $0.node.key == key }, "no visible row has the key \(key)") } diff --git a/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift b/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift index 8c0a7d01b3..ef6ada1db0 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift @@ -33,8 +33,8 @@ struct TreeValueStyleTests { #expect(text.string == "\"https://example.com/docs\"") #expect(TreeValueStyle.linkRange(in: content) == NSRange(location: 1, length: length - 2)) - #expect(color(text, at: 0) == .systemRed) - #expect(color(text, at: length - 1) == .systemRed) + #expect(color(text, at: 0) == TreeValueTone.string.color) + #expect(color(text, at: length - 1) == TreeValueTone.string.color) #expect(!isUnderlined(text, at: 0)) #expect(!isUnderlined(text, at: length - 1)) for index in 1 ..< length - 1 { @@ -77,14 +77,15 @@ struct TreeValueStyleTests { @Test("A color and a plain value are one run in the tone color") func otherValuesAreOneRun() throws { - for (key, tone) in [("brand", NSColor.systemRed), ("name", .systemRed), ("count", .systemPurple), ("theme", .systemBlue)] { + let tones: [(String, TreeValueTone)] = [("brand", .string), ("name", .string), ("count", .number), ("theme", .container)] + for (key, tone) in tones { let content = try content(key) let text = TreeValueStyle.attributedValue(content, font: font, isEmphasized: false) var range = NSRange() let color = text.attribute(.foregroundColor, at: 0, effectiveRange: &range) as? NSColor #expect(TreeValueStyle.linkRange(in: content) == nil) - #expect(color == tone) + #expect(color == tone.color) #expect(range == NSRange(location: 0, length: text.length)) #expect(!isUnderlined(text, at: 0)) } @@ -99,7 +100,7 @@ struct TreeValueStyleTests { } } #expect(TreeValueStyle.textColor(for: .number, isEmphasized: true) == .alternateSelectedControlTextColor) - #expect(TreeValueStyle.textColor(for: .number, isEmphasized: false) == .systemPurple) + #expect(TreeValueStyle.textColor(for: .number, isEmphasized: false) == TreeValueTone.number.color) } @Test("The value font and tail truncation are set on the whole string") diff --git a/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift b/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift new file mode 100644 index 0000000000..e4a2ffe8ba --- /dev/null +++ b/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift @@ -0,0 +1,93 @@ +// +// TreeValueToneThemeTests.swift +// TableProTests +// +// A tree shows a value in the color the JSON text view one segment away gives the same token, so +// the two never disagree. The text view's color is read through its own path: the grammar's +// capture name, then the editor theme. +// + +import AppKit +import Foundation +@testable import TablePro +import TableProEditorKit +import TableProGrammars +import Testing + +@MainActor +struct TreeValueToneThemeTests { + private func jsonHighlights() throws -> String { + let url = try #require(CodeLanguage.json.queryURL) + return try String(contentsOf: url, encoding: .utf8) + } + + /// What the JSON text view draws a capture in, under the active theme. + private func textViewColor(_ capture: String?) throws -> NSColor { + let controller = TextViewController( + string: "", + language: .json, + configuration: SourceEditorConfiguration( + appearance: .init( + theme: TableProEditorTheme.make(), + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + wrapLines: true + ) + ), + cursorPositions: [], + highlightProviders: [], + coordinators: [] + ) + controller.loadView() + return try #require(controller.attributesFor(CaptureName.fromString(capture))[.foregroundColor] as? NSColor) + } + + @Test("The JSON grammar captures a key as a string, scalars by kind and leaves braces uncaptured") + func grammarCaptures() throws { + let query = try jsonHighlights() + + #expect(query.contains("key: (_) @string.special.key")) + #expect(query.contains("(string) @string")) + #expect(query.contains("(number) @number")) + #expect(query.range(of: #"\[\s*\(null\)\s*\(true\)\s*\(false\)\s*\]\s*@constant\.builtin"#, options: .regularExpression) != nil) + #expect(!query.contains("\"{\"")) + #expect(!query.contains("\"[\"")) + } + + @Test("Each tone is the color the JSON text view draws the same token in", arguments: [false, true]) + func tonesMatchTheTextView(themed: Bool) throws { + try ThemeTestFixture.adopting(themed ? TreeOutlineFixture.tones : [:], appearance: .light) { + let tokens: [(TreeValueTone, String?)] = [ + (.key, "string.special.key"), + (.string, "string"), + (.number, "number"), + (.literal, "constant.builtin"), + (.container, nil) + ] + for (tone, capture) in tokens { + let text = ThemeTestFixture.hex(try textViewColor(capture)) + #expect(ThemeTestFixture.hex(tone.color) == text, "\(tone) against \(capture ?? "uncaptured")") + } + } + } + + /// A PHP class name reads as a type, a reference as a comment beside the value it points at, + /// and muted text as the editor's line numbers. + @Test("PHP kinds take the text view's colors for the closest token", arguments: [false, true]) + func phpKindsFollowTheirAnalogues(themed: Bool) throws { + try ThemeTestFixture.adopting(themed ? TreeOutlineFixture.tones : [:], appearance: .light) { + #expect(ThemeTestFixture.hex(TreeValueTone.serialized.color) == ThemeTestFixture.hex(try textViewColor("type"))) + #expect(ThemeTestFixture.hex(TreeValueTone.reference.color) == ThemeTestFixture.hex(try textViewColor("comment"))) + let lineNumber = try #require(TableProEditorTheme.make().lineNumber) + #expect(ThemeTestFixture.hex(TreeValueTone.muted.color) == ThemeTestFixture.hex(lineNumber)) + } + } + + @Test("The tones are distinct under a theme, so a key never reads as a number") + func tonesStayApart() throws { + try ThemeTestFixture.adopting(TreeOutlineFixture.tones, appearance: .light) { + let tones: [TreeValueTone] = [.string, .number, .literal, .serialized, .reference, .container, .muted] + let colors = Set(tones.compactMap { ThemeTestFixture.hex($0.color) }) + #expect(colors.count == tones.count) + } + } +} diff --git a/TableProTests/Views/Results/ValueTextThemeTests.swift b/TableProTests/Views/Results/ValueTextThemeTests.swift new file mode 100644 index 0000000000..fccb611ebf --- /dev/null +++ b/TableProTests/Views/Results/ValueTextThemeTests.swift @@ -0,0 +1,166 @@ +// +// ValueTextThemeTests.swift +// TableProTests +// +// The text views that show a stored value or server text wear the editor's colors while the active +// theme sets them, and under a default theme draw exactly the system colors they always drew. +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@Suite("Value text views follow the editor theme", .serialized) +@MainActor +struct ValueTextThemeTests { + private static let size = NSSize(width: 480, height: 320) + + @Test("Server output draws label text on the text background until a theme sets the editor colors") + func serverOutput() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host(ServerOutputTextView(text: "NOTICE: done"), size: Self.size) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(textView.textColor == NSColor.labelColor) + + try ThemeTestFixture.withEverySlotOverridden { + #expect(ThemeTestFixture.hex(textView.backgroundColor) == "#FF00FF") + #expect(ThemeTestFixture.hex(textView.textColor) == "#FF00FF") + #expect(ThemeTestFixture.hex(textView.insertionPointColor) == "#FF00FF") + } + + #expect(textView.backgroundColor == NSColor.textBackgroundColor) + #expect(textView.textColor == NSColor.labelColor) + #expect(textView.string == "NOTICE: done") + withExtendedLifetime(window) {} + } + } + + @Test("The hex dump keeps its secondary text and the hex field its label text under a default theme") + func hexDefaults() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host( + HexEditorBody(initialValue: "Hello", isEditable: true, onDismiss: {}), + size: Self.size + ) + let (dump, input) = try hexTextViews(in: window) + #expect(dump.backgroundColor === NSColor.textBackgroundColor) + #expect(dump.textColor == NSColor.secondaryLabelColor) + #expect(input.backgroundColor === NSColor.textBackgroundColor) + #expect(input.textColor == NSColor.labelColor) + withExtendedLifetime(window) {} + } + } + + @Test("Both hex text views take the editor colors from a theme and hand them back after") + func hexFollowsTheTheme() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host( + HexEditorBody(initialValue: "Hello", isEditable: true, onDismiss: {}), + size: Self.size + ) + let (dump, input) = try hexTextViews(in: window) + + try ThemeTestFixture.withEverySlotOverridden { + for textView in [dump, input] { + #expect(ThemeTestFixture.hex(textView.backgroundColor) == "#FF00FF") + #expect(ThemeTestFixture.hex(textView.textColor) == "#FF00FF") + } + } + + #expect(dump.backgroundColor == NSColor.textBackgroundColor) + #expect(dump.textColor == NSColor.secondaryLabelColor) + #expect(input.textColor == NSColor.labelColor) + withExtendedLifetime(window) {} + } + } + + @Test("The text viewer and image source editor follow the editor theme") + func valueSourceEditorFollowsTheTheme() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host( + ValueSourceEditor(text: .constant(""), isEditable: false), + size: Self.size + ) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(textView.font == ThemeEngine.shared.valueFont) + + try ThemeTestFixture.withEverySlotOverridden { + #expect(ThemeTestFixture.hex(textView.backgroundColor) == "#FF00FF") + #expect(ThemeTestFixture.hex(textView.textColor) == "#FF00FF") + } + + #expect(textView.backgroundColor == NSColor.textBackgroundColor) + #expect(textView.textColor == NSColor.textColor) + withExtendedLifetime(window) {} + } + } + + // MARK: - PHP raw text + + /// Long enough to wrap well below the mode picker. + private func phpRaw() -> some View { + let entries = (0 ..< 24).map { #"i:\#($0);s:5:"hello";"# }.joined() + return PhpViewerView(rawValue: "a:24:{\(entries)}", initialViewMode: .raw) + } + + /// Below the mode picker, so a count covers the raw text area only. + private static let pickerFraction = 0.2 + + @Test("PHP raw text draws no background of its own and system text under a default theme") + func phpRawDefault() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let bitmap = try ThemeTestFixture.bitmap( + of: phpRaw().frame(width: Self.size.width, height: Self.size.height), + colorScheme: .light + ) + let corner = try #require(ThemeTestFixture.pixel(bitmap, x: bitmap.pixelsWide - 4, y: bitmap.pixelsHigh - 4)) + #expect(corner.alphaComponent < 0.01) + + let window = ThemeTestFixture.host(phpRaw(), size: Self.size) + let snapshot = try #require(ThemeTestFixture.snapshot(window)) + let rows = Self.rows(of: snapshot, below: Self.pickerFraction) + #expect(ThemeTestFixture.count(in: snapshot, rows: rows, where: ThemeTestFixture.isMagenta) == 0) + withExtendedLifetime(window) {} + } + } + + @Test("PHP raw text sits on the editor background a theme sets") + func phpRawBackground() throws { + try ThemeTestFixture.adopting(ThemeTestFixture.pair([.editorBackground: "#FF00FF"]), appearance: .light) { + let bitmap = try ThemeTestFixture.bitmap( + of: phpRaw().frame(width: Self.size.width, height: Self.size.height), + colorScheme: .light + ) + let corner = try #require(ThemeTestFixture.pixel(bitmap, x: bitmap.pixelsWide - 4, y: bitmap.pixelsHigh - 4)) + #expect(ThemeTestFixture.isMagenta(corner), "drew \(corner)") + } + } + + @Test("PHP raw text takes the editor text color a theme sets") + func phpRawText() throws { + try ThemeTestFixture.adopting(ThemeTestFixture.pair([.editorText: "#FF00FF"]), appearance: .light) { + let window = ThemeTestFixture.host(phpRaw(), size: Self.size) + let snapshot = try #require(ThemeTestFixture.snapshot(window)) + let rows = Self.rows(of: snapshot, below: Self.pickerFraction) + #expect(ThemeTestFixture.count(in: snapshot, rows: rows, where: ThemeTestFixture.isMagenta) > 20) + withExtendedLifetime(window) {} + } + } + + // MARK: - Helpers + + private static func rows(of bitmap: NSBitmapImageRep, below fraction: Double) -> Range { + Int(Double(bitmap.pixelsHigh) * fraction) ..< bitmap.pixelsHigh + } + + private func hexTextViews(in window: NSWindow) throws -> (dump: NSTextView, input: NSTextView) { + let textViews = ThemeTestFixture.textViews(in: window) + #expect(textViews.count == 2) + let dump = try #require(textViews.first { !$0.isEditable }) + let input = try #require(textViews.first { $0.isEditable }) + return (dump, input) + } +} diff --git a/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift b/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift new file mode 100644 index 0000000000..85342fcd7c --- /dev/null +++ b/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift @@ -0,0 +1,134 @@ +// +// InspectorValueThemeTests.swift +// TableProTests +// +// The inspector's value wells and its JSON tab take the editor's background while the theme sets +// it, and draw exactly what they always did under the default pair. +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct InspectorValueThemeTests { + private static let wellSize = CGSize(width: 240, height: 60) + private static let paneSize = CGSize(width: 240, height: 300) + private static let editorBackground = "#FDF6E3" + + private func context(_ value: String, state: FieldValueState? = nil, isReadOnly: Bool = true) -> FieldEditorContext { + FieldEditorContext( + columnName: "note", + columnType: .text(rawType: "TEXT"), + isLongText: false, + value: .constant(value), + originalValue: value, + valueState: state ?? .value(value), + isReadOnly: isReadOnly + ) + } + + private func field(_ value: String, kind: FieldEditorKind, state: FieldValueState? = nil) -> FieldEditorContent { + FieldEditorContent(context: context(value, state: state), kind: kind, databaseType: .sqlite) + } + + private func pixel(of view: some View, size: CGSize, x: Int, fromBottom: Int? = nil) throws -> NSColor { + let rep = try ThemeTestFixture.bitmap(of: view.frame(width: size.width, height: size.height), colorScheme: .light) + let y = fromBottom.map { rep.pixelsHigh - 1 - $0 } ?? rep.pixelsHigh / 2 + return try #require(ThemeTestFixture.pixel(rep, x: x, y: y)) + } + + /// Near the trailing edge of a well, clear of its text and inside its border. + private func wellPixel(_ view: some View) throws -> NSColor { + try pixel(of: view, size: Self.wellSize, x: Int(Self.wellSize.width) - 12) + } + + // MARK: - Wells + + @Test("Under the default pair a read-only value and a pending NULL draw the text background they always did") + func defaultWellsAreNative() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let native = try ThemeTestFixture.filled(.textBackgroundColor, colorScheme: .light) + let value = try wellPixel(field("a", kind: .singleLine)) + let pending = try wellPixel(field("", kind: .singleLine, state: .pendingNull)) + + #expect(ThemeTestFixture.matches(value, native), "\(value) against \(native)") + #expect(ThemeTestFixture.matches(pending, native), "\(pending) against \(native)") + } + } + + @Test("Under a theme a read-only value and a pending NULL sit on the editor background") + func themedWellsTakeTheEditorBackground() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground], appearance: .light) { + let themed = try ThemeTestFixture.filled(hex: Self.editorBackground, colorScheme: .light) + let value = try wellPixel(field("a", kind: .singleLine)) + let pending = try wellPixel(field("", kind: .singleLine, state: .pendingNull)) + + #expect(ThemeTestFixture.matches(value, themed), "\(value) against \(themed)") + #expect(ThemeTestFixture.matches(pending, themed), "\(pending) against \(themed)") + } + } + + // MARK: - Multi-line + + private func hostedTextView(_ view: some View) throws -> (NSWindow, NSTextView) { + let window = ThemeTestFixture.host(view, size: NSSize(width: 240, height: 160)) + return (window, try #require(ThemeTestFixture.textViews(in: window).first)) + } + + @Test("A multi-line value keeps the system text colors under the default pair") + func multiLineDefaultIsNative() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let (window, textView) = try hostedTextView(field("line one\nline two", kind: .multiLine)) + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(textView.textColor === NSColor.textColor) + withExtendedLifetime(window) {} + } + } + + @Test("A multi-line value takes the editor's background and text while the theme sets them") + func multiLineFollowsTheTheme() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground, .editorText: "#657B83"], appearance: .light) { + let (window, textView) = try hostedTextView(field("line one\nline two", kind: .multiLine)) + #expect(ThemeTestFixture.hex(textView.backgroundColor) == Self.editorBackground) + #expect(ThemeTestFixture.hex(textView.textColor) == "#657B83") + withExtendedLifetime(window) {} + } + } + + // MARK: - JSON tab + + private func jsonTab() -> JSONRowInspectorView { + JSONRowInspectorView( + viewModel: JSONRowInspectorViewModel { _, _, _, _ in nil }, + snapshot: JSONRowSnapshot( + rowIdentity: "tab\u{001F}existing(0)", + columns: [], + columnTypes: [], + values: [], + foreignKeys: [:], + scope: DatabaseScope(connectionId: UUID(), database: "main", schema: nil), + databaseType: .sqlite + ), + onOpenReferencedTable: { _, _ in } + ) + } + + @Test("Under the default pair the JSON tab draws no background of its own, so the inspector shows through") + func jsonTabDefaultIsClear() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let corner = try pixel(of: jsonTab(), size: Self.paneSize, x: 4, fromBottom: 4) + #expect(corner.alphaComponent < 0.02, "\(corner)") + } + } + + @Test("Under a theme the JSON tab's tree sits on the editor background") + func jsonTabTakesTheEditorBackground() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground], appearance: .light) { + let themed = try ThemeTestFixture.filled(hex: Self.editorBackground, colorScheme: .light) + let corner = try pixel(of: jsonTab(), size: Self.paneSize, x: 4, fromBottom: 4) + #expect(ThemeTestFixture.matches(corner, themed), "\(corner) against \(themed)") + } + } +} diff --git a/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift b/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift index 617cabac26..ee21d83041 100644 --- a/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift +++ b/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift @@ -69,13 +69,28 @@ struct JSONNodeRowViewTests { #expect(JSONRowColors.tone(for: .null) == .literal) } - @Test("Punctuation is primary text and every tone is a system color under any theme") - func colorsIgnoreTheTheme() throws { - try PaneThemeFixture.withEverySlotOverridden { + @Test("Under the default pair punctuation stays primary and the count keeps the secondary label color") + func defaultPairColors() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let lineNumber = ThemeEngine.shared.palette[.editorLineNumber] #expect(JSONRowColors.punctuation == .primary) - #expect(JSONRowColors.key == Color(nsColor: .systemBlue)) - #expect(JSONRowColors.placeholder == Color(nsColor: .secondaryLabelColor)) - #expect(JSONRowColors.color(for: .number("1")) == Color(nsColor: .systemPurple)) + #expect(JSONRowColors.placeholder == Color(nsColor: lineNumber)) + #expect(ThemeTestFixture.hex(lineNumber) == ThemeTestFixture.hex(.secondaryLabelColor)) + #expect(JSONRowColors.key != Color(nsColor: .systemBlue)) + } + } + + @Test("Every value takes the color the JSON tree gives the same token, which follows the theme") + func colorsFollowTheTheme() throws { + try ThemeTestFixture.adopting(TreeOutlineFixture.tones, appearance: .light) { + let palette = ThemeEngine.shared.palette + #expect(JSONRowColors.key == Color(nsColor: palette[.syntaxString])) + #expect(JSONRowColors.placeholder == Color(nsColor: palette[.editorLineNumber])) + #expect(JSONRowColors.color(for: .string("a")) == Color(nsColor: palette[.syntaxString])) + #expect(JSONRowColors.color(for: .number("1")) == Color(nsColor: palette[.syntaxNumber])) + #expect(JSONRowColors.color(for: .bool(true)) == Color(nsColor: palette[.syntaxNull])) + #expect(JSONRowColors.color(for: .null) == Color(nsColor: palette[.syntaxNull])) + #expect(ThemeTestFixture.hex(palette[.syntaxNumber]) == TreeOutlineFixture.tones[.syntaxNumber]) } } } diff --git a/TableProTests/Views/SQLCodeFieldThemeTests.swift b/TableProTests/Views/SQLCodeFieldThemeTests.swift new file mode 100644 index 0000000000..82f4e3ee9b --- /dev/null +++ b/TableProTests/Views/SQLCodeFieldThemeTests.swift @@ -0,0 +1,253 @@ +// +// SQLCodeFieldThemeTests.swift +// TableProTests +// +// SQL and scripts inside a system sheet or form are code views: they wear the editor's colors while +// the active theme sets them. The sheet or form around them stays on system colors. +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL fields in sheets and forms follow the editor theme", .serialized) +@MainActor +struct SQLCodeFieldThemeTests { + // MARK: - Filter SQL preview + + @Test("The filter's SQL preview is a code view on the editor background") + func filterPreview() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host(SQLPreviewSheet(sql: "id = 1"), size: NSSize(width: 480, height: 300)) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + #expect(textView.string == "id = 1") + #expect(!textView.isEditable) + #expect(textView.isSelectable) + #expect(ThemeTestFixture.hex(textView.backgroundColor) == ThemeTestFixture.hex(.textBackgroundColor)) + + try ThemeTestFixture.withEverySlotOverridden { + #expect(ThemeTestFixture.hex(textView.backgroundColor) == "#FF00FF") + } + withExtendedLifetime(window) {} + } + } + + @Test("An empty filter still says it has no conditions") + func filterPreviewEmpty() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host(SQLPreviewSheet(sql: ""), size: NSSize(width: 480, height: 300)) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + #expect(textView.string == "(no conditions)") + withExtendedLifetime(window) {} + } + } + + // MARK: - Maintenance preview + + private func maintenanceSheet(statements: [String]) -> MaintenanceSheet { + MaintenanceSheet( + operation: PluginMaintenanceOperation(name: "VACUUM", appliesTo: [.table], scope: .object), + tableName: "orders", + databaseName: "app", + databaseType: .postgresql, + preview: { _ in statements }, + onExecute: { _ in } + ) + } + + @Test("The maintenance preview is a code view sized to its statement") + func maintenancePreview() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host( + maintenanceSheet(statements: [#"VACUUM "orders""#]), + size: NSSize(width: 420, height: 320) + ) + let textView = try #require(ThemeTestFixture.textViews(in: window).first) + #expect(textView.string == #"VACUUM "orders""#) + let scrollView = try #require(textView.enclosingScrollView) + let expected = HighlightedSQLTextView.fittedHeight( + of: textView.string, + font: ThemeEngine.shared.editorFonts.font, + width: scrollView.frame.width, + inset: textView.textContainerInset, + lineFragmentPadding: textView.textContainer?.lineFragmentPadding ?? 0, + maxLines: MaintenanceSheet.previewMaxLines + ) + #expect(abs(scrollView.frame.height - expected) < 1) + + try ThemeTestFixture.withEverySlotOverridden { + #expect(ThemeTestFixture.hex(textView.backgroundColor) == "#FF00FF") + } + withExtendedLifetime(window) {} + } + } + + @Test("The preview grows one line per wrapped line, up to a cap") + func maintenancePreviewHeight() { + let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + let line = NSLayoutManager().defaultLineHeight(for: font) + let inset: CGFloat = 24 + func height(_ sql: String, width: CGFloat = 1_000) -> CGFloat { + HighlightedSQLTextView.fittedHeight( + of: sql, + font: font, + width: width, + inset: NSSize(width: 12, height: 12), + lineFragmentPadding: 5, + maxLines: MaintenanceSheet.previewMaxLines + ) + } + + #expect(height("VACUUM") == line + inset) + #expect(height("") == line + inset) + #expect(height("ANALYZE a;\nANALYZE b;\nANALYZE c") == 3 * line + inset) + + let long = Array(repeating: "ANALYZE t", count: 30).joined(separator: ";\n") + let cap = CGFloat(MaintenanceSheet.previewMaxLines) + #expect(height(long) == cap * line + inset) + + let wide = Array(repeating: "ANALYZE", count: 20).joined(separator: " ") + #expect(height(wide, width: 1_000) == 2 * line + inset) + #expect(height(wide, width: 300) >= 4 * line + inset) + #expect(height(wide, width: 300) <= cap * line + inset) + } + + // MARK: - Favorite query + + /// A folder list is passed so the dialog never reads the favorites store. + private func favoriteDialog() -> FavoriteEditDialog { + FavoriteEditDialog( + connectionId: UUID(), + initialQuery: "SELECT 'a'", + folders: [SQLFavoriteFolder(name: "Reports")] + ) + } + + private func favoriteQueryView(in window: NSWindow) throws -> NSTextView { + try #require(ThemeTestFixture.textViews(in: window).first { $0.string == "SELECT 'a'" }) + } + + @Test("The favorite's query keeps the text field look and types straight quotes") + func favoriteQueryDefaults() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host(favoriteDialog(), size: NSSize(width: 560, height: 580)) + let textView = try favoriteQueryView(in: window) + #expect(textView.isEditable) + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(textView.textColor == NSColor.textColor) + #expect(textView.font == NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)) + #expect(textView.textContainerInset == .zero) + #expect(!textView.isAutomaticQuoteSubstitutionEnabled) + #expect(textView.usesFindBar) + withExtendedLifetime(window) {} + } + } + + @Test("The favorite's query takes the editor colors from a theme and hands them back after") + func favoriteQueryFollowsTheTheme() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host(favoriteDialog(), size: NSSize(width: 560, height: 580)) + let textView = try favoriteQueryView(in: window) + + try ThemeTestFixture.withEverySlotOverridden { + #expect(ThemeTestFixture.hex(textView.backgroundColor) == "#FF00FF") + #expect(ThemeTestFixture.hex(textView.textColor) == "#FF00FF") + } + + #expect(textView.backgroundColor == NSColor.textBackgroundColor) + #expect(textView.textColor == NSColor.textColor) + withExtendedLifetime(window) {} + } + } + + // MARK: - Connection form fields + + private func startupEditor(_ style: TextValueEditor.Style) throws -> (NSWindow, NSTextView) { + let window = ThemeTestFixture.host( + StartupCommandsEditor(text: .constant("SET time_zone = 'UTC'"), style: style), + size: NSSize(width: 360, height: 80) + ) + return (window, try #require(ThemeTestFixture.textViews(in: window).first)) + } + + @Test("Startup Commands and Pre-Connect Script follow the editor theme") + func startupCommandsFollowTheTheme() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let (window, textView) = try startupEditor(.code) + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(textView.enclosingScrollView?.borderType == .bezelBorder) + + try ThemeTestFixture.withEverySlotOverridden { + #expect(ThemeTestFixture.hex(textView.backgroundColor) == "#FF00FF") + #expect(ThemeTestFixture.hex(textView.textColor) == "#FF00FF") + } + + #expect(textView.backgroundColor == NSColor.textBackgroundColor) + withExtendedLifetime(window) {} + } + } + + @Test("AI Rules is prose and keeps the system text colors under any theme") + func aiRulesStaysPlain() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let (window, textView) = try startupEditor(.plain) + try ThemeTestFixture.withEverySlotOverridden { + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(textView.textColor === NSColor.textColor) + } + withExtendedLifetime(window) {} + } + } + + // MARK: - Rewind statements + + private static let rewindSize = NSSize(width: 320, height: 140) + + private func rewindStatements() -> RewindStatementList { + RewindStatementList(statements: [ + "UPDATE orders SET status = 'paid' WHERE id = 1;", + "DELETE FROM orders WHERE id = 2;", + ]) + } + + @Test("Rewind statements draw no background of their own and system text under a default theme") + func rewindDefault() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let bitmap = try ThemeTestFixture.bitmap( + of: rewindStatements().frame(width: Self.rewindSize.width, height: Self.rewindSize.height), + colorScheme: .light + ) + let corner = try #require(ThemeTestFixture.pixel(bitmap, x: bitmap.pixelsWide - 4, y: bitmap.pixelsHigh - 4)) + #expect(corner.alphaComponent < 0.01) + + let window = ThemeTestFixture.host(rewindStatements(), size: Self.rewindSize) + let snapshot = try #require(ThemeTestFixture.snapshot(window)) + #expect(ThemeTestFixture.count(in: snapshot, where: ThemeTestFixture.isMagenta) == 0) + withExtendedLifetime(window) {} + } + } + + @Test("Rewind statements sit on the editor background a theme sets") + func rewindBackground() throws { + try ThemeTestFixture.adopting(ThemeTestFixture.pair([.editorBackground: "#FF00FF"]), appearance: .light) { + let bitmap = try ThemeTestFixture.bitmap( + of: rewindStatements().frame(width: Self.rewindSize.width, height: Self.rewindSize.height), + colorScheme: .light + ) + let corner = try #require(ThemeTestFixture.pixel(bitmap, x: bitmap.pixelsWide - 4, y: bitmap.pixelsHigh - 4)) + #expect(ThemeTestFixture.isMagenta(corner), "drew \(corner)") + } + } + + @Test("Rewind statements take the editor text color a theme sets") + func rewindText() throws { + try ThemeTestFixture.adopting(ThemeTestFixture.pair([.editorText: "#FF00FF"]), appearance: .light) { + let window = ThemeTestFixture.host(rewindStatements(), size: Self.rewindSize) + let snapshot = try #require(ThemeTestFixture.snapshot(window)) + #expect(ThemeTestFixture.count(in: snapshot, where: ThemeTestFixture.isMagenta) > 20) + withExtendedLifetime(window) {} + } + } +} diff --git a/TableProTests/Views/Shared/DiffSurfaceThemeTests.swift b/TableProTests/Views/Shared/DiffSurfaceThemeTests.swift new file mode 100644 index 0000000000..707557cb9b --- /dev/null +++ b/TableProTests/Views/Shared/DiffSurfaceThemeTests.swift @@ -0,0 +1,126 @@ +// +// DiffSurfaceThemeTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct DiffSurfaceThemeTests { + private static let width = 200 + + /// One context line, then one added line, each as tall as the other. + private static func unifiedDiff() -> some View { + TextDiffView( + pairs: [ + DiffPair(before: "select 1", after: "select 1", kind: .unchanged), + DiffPair(before: nil, after: "select 2", kind: .added), + ], + beforeLabel: "Before", + afterLabel: "After", + layout: .unified + ) + .frame(width: CGFloat(width)) + } + + private static func rowSamples(_ bitmap: NSBitmapImageRep) throws -> (context: NSColor, added: NSColor) { + let x = bitmap.pixelsWide - 12 + let context = try #require(ThemeTestFixture.pixel(bitmap, x: x, y: bitmap.pixelsHigh / 4)) + let added = try #require(ThemeTestFixture.pixel(bitmap, x: x, y: bitmap.pixelsHigh * 3 / 4)) + return (context, added) + } + + // MARK: - TextDiffView + + @Test("Under the default pair the text diff draws no fill of its own and the native grid tint") + func textDiffDrawsNativelyUnderTheDefaultPair() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let bitmap = try ThemeTestFixture.bitmap(of: Self.unifiedDiff(), colorScheme: .dark) + let samples = try Self.rowSamples(bitmap) + let inserted = try ThemeTestFixture.filled(NativeThemeColors.insertedRow, colorScheme: .dark) + + #expect(samples.context.alphaComponent < 0.01, "context line drew \(samples.context)") + #expect(ThemeTestFixture.matches(samples.added, inserted), "drew \(samples.added), native \(inserted)") + #expect(ThemeTestFixture.count(in: bitmap, where: ThemeTestFixture.isMagenta) == 0) + } + } + + @Test("A theme's editor background, editor text and grid tint reach the text diff") + func textDiffFollowsTheTheme() throws { + let pair = try ThemeTestFixture.pair([ + .editorBackground: "#102030", + .editorText: "#FF00FF", + .gridInserted: "#00FF00", + ]) + try ThemeTestFixture.adopting(pair, appearance: .dark) { + let bitmap = try ThemeTestFixture.bitmap(of: Self.unifiedDiff(), colorScheme: .dark) + let samples = try Self.rowSamples(bitmap) + let background = try ThemeTestFixture.filled(hex: "#102030", colorScheme: .dark) + let inserted = try ThemeTestFixture.filled(hex: "#00FF00", colorScheme: .dark) + + #expect(ThemeTestFixture.matches(samples.context, background), "drew \(samples.context)") + #expect(ThemeTestFixture.matches(samples.added, inserted), "drew \(samples.added)") + #expect(ThemeTestFixture.count(in: bitmap, where: ThemeTestFixture.isMagenta) > 0) + } + } + + @Test("A changed pair maps onto the grid's insert, update and delete tints") + func diffKindsMapOntoGridKinds() { + #expect(TextDiffView.lineKind(.changed, side: .before) == .update) + #expect(TextDiffView.lineKind(.changed, side: .after) == .update) + #expect(TextDiffView.lineKind(.added, side: .after) == .insert) + #expect(TextDiffView.lineKind(.removed, side: .before) == .delete) + #expect(TextDiffView.lineKind(.unchanged, side: .before) == nil) + #expect(TextDiffView.lineKind(.added, side: .before) == nil) + #expect(TextDiffView.lineKind(.removed, side: .after) == nil) + + #expect(TextDiffView.lineKind(DiffUnifiedLine.Kind.added) == .insert) + #expect(TextDiffView.lineKind(DiffUnifiedLine.Kind.removed) == .delete) + #expect(TextDiffView.lineKind(DiffUnifiedLine.Kind.context) == nil) + } + + // MARK: - File conflict sheet + + @Test("The file conflict diff keeps its own tints under the default pair") + func fileConflictTintsAreNativeUnderTheDefaultPair() { + ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let engine = ThemeEngine.shared + #expect(FileConflictRowHighlight.removed.tint(engine: engine, contrast: .standard) == .red.opacity(0.18)) + #expect(FileConflictRowHighlight.added.tint(engine: engine, contrast: .standard) == .green.opacity(0.18)) + #expect(FileConflictRowHighlight.filler.tint(engine: engine, contrast: .standard) == .gray.opacity(0.06)) + #expect(FileConflictRowHighlight.plain.tint(engine: engine, contrast: .standard) == .clear) + } + } + + @Test("The file conflict diff takes the grid's inserted and deleted tints from a theme") + func fileConflictTintsFollowTheTheme() throws { + let pair = try ThemeTestFixture.pair([.gridInserted: "#00FF0040", .gridDeleted: "#FF000040"]) + ThemeTestFixture.adopting(pair, appearance: .dark) { + let engine = ThemeEngine.shared + #expect( + FileConflictRowHighlight.removed.tint(engine: engine, contrast: .standard) + == Color(nsColor: engine.palette[.gridDeleted]) + ) + #expect( + FileConflictRowHighlight.added.tint(engine: engine, contrast: .standard) + == Color(nsColor: engine.palette[.gridInserted]) + ) + #expect(FileConflictRowHighlight.filler.tint(engine: engine, contrast: .standard) == .gray.opacity(0.06)) + #expect(FileConflictRowHighlight.plain.tint(engine: engine, contrast: .standard) == .clear) + } + #expect(FileConflictRowHighlight.removed.lineKind == .delete) + #expect(FileConflictRowHighlight.added.lineKind == .insert) + #expect(FileConflictRowHighlight.filler.lineKind == nil) + } + + // MARK: - AI walkthrough + + @Test("The walkthrough diff keeps its own tints as the native fallback") + func walkthroughNativeTints() { + #expect(AIChatWalkthroughBlockView.nativeTint(for: .insert) == .green.opacity(0.16)) + #expect(AIChatWalkthroughBlockView.nativeTint(for: .delete) == .red.opacity(0.16)) + } +} diff --git a/TableProTests/Views/Shared/PaneThemeFixture.swift b/TableProTests/Views/Shared/PaneThemeFixture.swift deleted file mode 100644 index 351cf3f4a0..0000000000 --- a/TableProTests/Views/Shared/PaneThemeFixture.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// PaneThemeFixture.swift -// TableProTests -// - -import AppKit -@testable import TablePro - -/// A theme that overrides every slot, on the shared engine for the length of `body`. A pane that -/// still reads a slot then takes the override, which is what the native-pane suites look for. -/// -/// Synchronous and main-actor on purpose: nothing else on the main actor runs between adopting the -/// theme and restoring the original, so no suite running in parallel sees it. -@MainActor -internal enum PaneThemeFixture { - static func withEverySlotOverridden(_ body: () throws -> Result) throws -> Result { - let engine = ThemeEngine.shared - let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) - defer { engine.adopt(restore) } - - let pair = ThemePair(light: try theme(.light), dark: try theme(.dark)) - engine.adopt(ThemeSelection(pair: pair, effectiveAppearance: restore.effectiveAppearance)) - return try body() - } - - private static func theme(_ appearance: ThemeAppearance) throws -> ThemeDefinition { - let override = try ThemeColor(validating: "#FF00FF") - var overrides: [ThemeSlot: ThemeColor] = [:] - for slot in ThemeSlot.allCases { - overrides[slot] = override - } - return ThemeDefinition( - id: "user.test.pane-\(appearance.rawValue)", - name: "Pane Test", - author: "", - appearance: appearance, - overrides: overrides - ) - } -} diff --git a/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift b/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift index 43d7cab0be..a51c70dc2e 100644 --- a/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift +++ b/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift @@ -4,6 +4,7 @@ // import AppKit +import SwiftUI @testable import TablePro import Testing @@ -41,4 +42,57 @@ struct TextValueEditorDefaultsTests { TextValueEditor.applyPlainTextDefaults(to: textView) #expect(textView.allowsUndo) } + + // MARK: - Style + + private func hostedTextView(_ editor: TextValueEditor) throws -> (NSWindow, NSTextView) { + let window = ThemeTestFixture.host(editor, size: NSSize(width: 240, height: 120)) + return (window, try #require(ThemeTestFixture.textViews(in: window).first)) + } + + private func editor(_ style: TextValueEditor.Style? = nil) -> TextValueEditor { + let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + guard let style else { return TextValueEditor(text: .constant("value"), font: font) } + return TextValueEditor(text: .constant("value"), font: font, style: style) + } + + @Test("the default style is plain, so every existing caller keeps the system text colors") + func defaultStyleIsPlain() { + #expect(editor().style == .plain) + } + + @Test("a plain editor keeps the system colors under a theme that sets every slot") + func plainIgnoresTheTheme() throws { + try ThemeTestFixture.withEverySlotOverridden { + let (window, textView) = try hostedTextView(editor()) + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(textView.textColor === NSColor.textColor) + withExtendedLifetime(window) {} + } + } + + /// A user theme already active must not change what the editor hands back. + @Test("a code editor takes the editor's colors while the theme sets them, and the system's after", arguments: [false, true]) + func codeFollowsTheTheme(incomingIsThemed: Bool) throws { + let incoming = try incomingIsThemed ? ThemeTestFixture.pair([.editorBackground: "#102030", .editorText: "#E0E0E0"]) : .builtIn + try ThemeTestFixture.adopting(incoming, appearance: .light) { + try expectCodeFollowsTheTheme() + } + } + + private func expectCodeFollowsTheTheme() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + var hosted: (NSWindow, NSTextView)? + try ThemeTestFixture.withEverySlotOverridden { + let pair = try hostedTextView(editor(.code)) + hosted = pair + #expect(HexColor.string(from: pair.1.backgroundColor) == "#FF00FF") + #expect(pair.1.textColor.map(HexColor.string(from:)) == "#FF00FF") + } + let textView = try #require(hosted?.1) + #expect(textView.backgroundColor == NSColor.textBackgroundColor) + #expect(textView.textColor == NSColor.textColor) + withExtendedLifetime(hosted) {} + } + } } diff --git a/TableProTests/Views/Structure/ClickHousePartsGridTests.swift b/TableProTests/Views/Structure/ClickHousePartsGridTests.swift new file mode 100644 index 0000000000..4833f207f7 --- /dev/null +++ b/TableProTests/Views/Structure/ClickHousePartsGridTests.swift @@ -0,0 +1,76 @@ +// +// ClickHousePartsGridTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct ClickHousePartsGridTests { + private let part = ClickHousePartInfo( + partition: "202601", + name: "202601_1_4_1", + rows: 1_234_567, + bytesOnDisk: 5_242_880, + modificationTime: "2026-01-05 10:00:00", + active: true + ) + + @Test("Each part fills the six columns the parts list has always shown") + func rowCarriesEveryColumn() { + let row = ClickHousePartsView.gridRow(for: part) + + #expect(ClickHousePartsView.columnTitles.count == row.values.count) + #expect(row.id == part.id) + #expect(row.values[0] == "202601") + #expect(row.values[1] == "202601_1_4_1") + #expect(row.values[2] == UInt64(1_234_567).formatted(.number.grouping(.automatic))) + #expect(row.values[3] == ByteSizeFormatting.string(bytes: UInt64(5_242_880))) + #expect(row.values[4] == "2026-01-05 10:00:00") + #expect(row.values[5] == "YES") + } + + @Test("An inactive part reads NO") + func inactivePart() { + let inactive = ClickHousePartInfo( + partition: "", name: "p", rows: 0, bytesOnDisk: 0, modificationTime: "", active: false + ) + #expect(ClickHousePartsView.gridRow(for: inactive).values.last == "NO") + } + + @Test("Each column opens at the width the parts list has always given it") + func defaultWidths() { + #expect(ClickHousePartsView.columnWidths == [120, 200, 100, 100, 160, 60]) + #expect(ClickHousePartsView.columnWidths.count == ClickHousePartsView.columnTitles.count) + } + + @Test("Drop and Detach name the one selected part's partition, and none while several are selected") + func selectedPartition() { + let other = ClickHousePartInfo( + partition: "202602", name: "202602_1_1_0", rows: 1, bytesOnDisk: 1, modificationTime: "", active: true + ) + let parts = [part, other] + + #expect(ClickHousePartsView.partition(of: [other.id], in: parts) == "202602") + #expect(ClickHousePartsView.partition(of: [part.id, other.id], in: parts) == nil) + #expect(ClickHousePartsView.partition(of: [], in: parts) == nil) + #expect(ClickHousePartsView.partition(of: [UUID()], in: parts) == nil) + } + + @Test("A selected grid row names its own part") + func selectedRowResolvesToItsPart() { + let other = ClickHousePartInfo( + partition: "202602", name: "202602_1_1_0", rows: 1, bytesOnDisk: 1, modificationTime: "", active: true + ) + let parts = [part, other] + let grid = StructureListGridModel() + _ = grid.ensureLoaded(key: 1, columns: ClickHousePartsView.columnTitles) { + parts.map(ClickHousePartsView.gridRow(for:)) + } + + #expect(grid.id(atDisplayRow: 1) == other.id) + #expect(grid.id(atDisplayRow: 2) == nil) + } +} diff --git a/TableProTests/Views/Structure/StructureListGridModelTests.swift b/TableProTests/Views/Structure/StructureListGridModelTests.swift new file mode 100644 index 0000000000..4020b509f7 --- /dev/null +++ b/TableProTests/Views/Structure/StructureListGridModelTests.swift @@ -0,0 +1,198 @@ +// +// StructureListGridModelTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class ListClipboard: ClipboardProvider { + var text: String? + + func readText() -> String? { text } + func readGridRows() -> GridRowsClipboardPayload? { nil } + func writeText(_ text: String) { self.text = text } + func writeCsv(_ csv: String) { text = csv } + func writeImage(_ image: NSImage) {} + func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { text = tsv } + var hasText: Bool { text != nil } + var hasGridRows: Bool { false } +} + +@MainActor +private final class NoLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +struct StructureListGridModelTests { + private let columns = ["Name", "Timing"] + private let rows = [ + StructureListRow(id: "a", values: ["alpha", "BEFORE"]), + StructureListRow(id: "b", values: ["beta", "AFTER"]), + StructureListRow(id: "c", values: ["gamma", "AFTER"]), + ] + + private func loadedModel(accessibilityLabel: String? = nil) -> StructureListGridModel { + let model = StructureListGridModel(accessibilityLabel: accessibilityLabel) + _ = model.ensureLoaded(key: 1, columns: columns) { rows } + return model + } + + private func makeCoordinator(tableView: NSTableView? = nil) -> TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: false, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: NoLayoutPersister() + ) + coordinator.tableView = tableView + return coordinator + } + + @Test("Rows are built once per content key, in the order given, as text cells") + func buildsRowsOncePerKey() { + let model = StructureListGridModel() + var builds = 0 + let loaded = model.ensureLoaded(key: 1, columns: columns) { + builds += 1 + return rows + } + _ = model.ensureLoaded(key: 1, columns: columns) { + builds += 1 + return rows + } + #expect(builds == 1) + #expect(loaded.columns == columns) + #expect(loaded.rows.map { $0.values.map(\.asText) } == rows.map { $0.values.map(Optional.some) }) + #expect(model.rowIDs == ["a", "b", "c"]) + + let reordered = model.ensureLoaded(key: 2, columns: columns) { + builds += 1 + return rows.reversed() + } + #expect(builds == 2) + #expect(reordered.rows.first?.values.first?.asText == "gamma") + #expect(model.rowIDs == ["c", "b", "a"]) + } + + @Test("A display row and a row id map both ways, and an unknown one maps to nothing") + func mapsDisplayRowsAndIDs() { + let model = loadedModel() + #expect(model.id(atDisplayRow: 1) == "b") + #expect(model.id(atDisplayRow: 3) == nil) + #expect(model.id(atDisplayRow: -1) == nil) + #expect(model.ids(atDisplayRows: [0, 2, 7]) == ["a", "c"]) + #expect(model.displayRows(of: ["c", "z"]) == [2]) + #expect(model.displayRows(of: []).isEmpty) + } + + @Test("Copying rows writes them as tab-separated lines in display order") + func copiesRowsAsTabSeparatedText() { + let clipboard = ListClipboard() + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + + loadedModel().dataGridCopyRows([2, 0, 9]) + + #expect(clipboard.text == "alpha\tBEFORE\ngamma\tAFTER") + } + + @Test("Copying no row leaves the clipboard alone") + func copyingNothingWritesNothing() { + let clipboard = ListClipboard() + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + + loadedModel().dataGridCopyRows([]) + + #expect(clipboard.text == nil) + } + + @Test("A header click reaches the owner of the rows") + func forwardsSortToOwner() { + let model = loadedModel() + var received: SortState? + model.onSortStateChanged = { received = $0 } + let state = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) + + model.dataGridSortStateChanged(state) + + #expect(received == state) + } + + @Test("Every reload has the selection put back by identity") + func rearmsSelectionRestoreOnAttach() { + let coordinator = makeCoordinator() + coordinator.hasRestoredSelection = true + loadedModel().dataGridAttach(tableViewCoordinator: coordinator) + #expect(!coordinator.hasRestoredSelection) + } + + @Test("The selection binding maps rows to ids and back") + func selectionBindingMapsBothWays() { + let model = loadedModel() + var ids: Set = ["b"] + let selection = model.selection(get: { ids }, set: { ids = $0 }) + + #expect(selection.wrappedValue == [1]) + selection.wrappedValue = [0, 2] + #expect(ids == ["a", "c"]) + } + + @Test("Rows published before the grid puts the selection back after a reload leave the ids alone") + func dropsPublicationsDuringReload() { + let model = loadedModel() + let coordinator = makeCoordinator() + model.dataGridAttach(tableViewCoordinator: coordinator) + var ids: Set = ["a", "b"] + let selection = model.selection(get: { ids }, set: { ids = $0 }) + + selection.wrappedValue = [2] + #expect(ids == ["a", "b"]) + + coordinator.hasRestoredSelection = true + selection.wrappedValue = [2] + #expect(ids == ["c"]) + } + + @Test("Republishing the rows the selection already shows keeps the ids a filter hides") + func echoKeepsHiddenIDs() { + let model = loadedModel() + var ids: Set = ["a", "hidden"] + let selection = model.selection(get: { ids }, set: { ids = $0 }) + + selection.wrappedValue = [0] + #expect(ids == ["a", "hidden"]) + } + + @Test("Default widths fill only the columns the layout has no width for") + func defaultWidthsFillMissingColumns() { + var stored = ColumnLayoutState() + stored.columnWidths["Timing"] = 300 + + let layout = StructureListGrid.layout(stored, columns: columns, defaultWidths: [220, 90]) + + #expect(layout.columnWidths == ["Name": 220, "Timing": 300]) + #expect(StructureListGrid.layout(stored, columns: columns, defaultWidths: []) == stored) + } + + @Test("The grid is named for the list it shows") + func namesTheGrid() { + let tableView = NSTableView() + tableView.setAccessibilityLabel(String(localized: "Data grid")) + let coordinator = makeCoordinator(tableView: tableView) + + loadedModel(accessibilityLabel: StructureTab.triggers.displayName).dataGridAttach(tableViewCoordinator: coordinator) + + #expect(tableView.accessibilityLabel() == StructureTab.triggers.displayName) + } +} diff --git a/TableProTests/Views/Structure/StructureListGridSelectionTests.swift b/TableProTests/Views/Structure/StructureListGridSelectionTests.swift new file mode 100644 index 0000000000..e995b41a88 --- /dev/null +++ b/TableProTests/Views/Structure/StructureListGridSelectionTests.swift @@ -0,0 +1,239 @@ +// +// StructureListGridSelectionTests.swift +// TableProTests +// +// Driven through a real NSHostingView, because the reload that lost the selection runs in +// DataGridView.updateNSView, which only SwiftUI calls. +// + +import AppKit +import Combine +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +private final class HostedList { + private let window: NSWindow + private let host: NSHostingView + + init(_ content: Content) { + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + host = NSHostingView(rootView: content) + window.contentView = host + settle() + } + + func show(_ content: Content) { + host.rootView = content + settle() + } + + var tableView: KeyHandlingTableView? { + Self.firstTableView(in: host) + } + + func click(row: Int) { + tableView?.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + settle() + } + + func shiftDown() { + guard let tableView, + let scalar = UnicodeScalar(NSDownArrowFunctionKey), + let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: .shift, + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + characters: String(Character(scalar)), + charactersIgnoringModifiers: String(Character(scalar)), + isARepeat: false, + keyCode: KeyCode.downArrow.rawValue + ) else { return } + tableView.keyDown(with: event) + settle() + } + + func width(ofColumnTitled title: String) -> CGFloat? { + tableView?.tableColumns.first { !$0.isHidden && $0.headerCell.stringValue == title }?.width + } + + func settle() { + for _ in 0 ..< 10 { + host.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.02)) + } + } + + private static func firstTableView(in view: NSView) -> KeyHandlingTableView? { + if let tableView = view as? KeyHandlingTableView { return tableView } + for subview in view.subviews { + if let found = firstTableView(in: subview) { return found } + } + return nil + } +} + +@MainActor +private final class ListState: ObservableObject { + @Published var rows: [StructureListRow] + @Published var selectedIDs: Set = [] + + init(rows: [StructureListRow]) { + self.rows = rows + } +} + +private struct ListHarness: View { + let grid: StructureListGridModel + @ObservedObject var state: ListState + @State private var columnLayout = ColumnLayoutState() + + var body: some View { + let state = state + let rows = state.rows + var hasher = Hasher() + hasher.combine(rows) + return StructureListGrid( + model: grid, + columns: ["Name", "Timing"], + contentKey: hasher.finalize(), + rows: { rows }, + databaseType: .mysql, + selectedIDs: { state.selectedIDs }, + select: { state.selectedIDs = $0 }, + sortState: nil, + columnLayout: $columnLayout + ) + } +} + +@MainActor +struct StructureListGridSelectionTests { + private let triggers = [ + TriggerInfo(name: "trg_b", timing: "BEFORE", event: "UPDATE", statement: "", enabled: true), + TriggerInfo(name: "trg_a", timing: "AFTER", event: "INSERT", statement: "", enabled: false), + TriggerInfo(name: "trg_c", timing: "AFTER", event: "DELETE", statement: ""), + ] + + private func id(_ name: String) -> TriggerInfo.ID { + triggers.first { $0.name == name }?.id ?? name + } + + @Test("Reversing the sort after Shift+Down keeps both selected triggers") + func sortAfterExtendedSelectionKeepsTheTriggers() throws { + let state = TriggerInspectorState() + let list = HostedList(TriggerListPane(triggers: triggers, databaseType: .mysql, state: state)) + let tableView = try #require(list.tableView) + try #require(tableView.numberOfRows == 3) + + list.click(row: 0) + list.shiftDown() + try #require(state.selectedIDs == [id("trg_a"), id("trg_b")]) + + let reversed = SortState( + columns: [SortColumn(columnIndex: TriggerListColumns.nameColumn, direction: .descending, columnName: "Name")], + source: .user + ) + tableView.coordinator?.delegate?.dataGridSortStateChanged(reversed) + list.settle() + try #require(state.displayed(triggers).map(\.name) == ["trg_c", "trg_b", "trg_a"]) + + #expect(state.selectedIDs == [id("trg_a"), id("trg_b")]) + #expect(tableView.selectedRowIndexes == IndexSet([1, 2])) + #expect(state.actionTarget(triggers) == nil) + } + + @Test("A filter that hides a selected trigger keeps it selected for when the filter clears") + func filterKeepsHiddenSelection() throws { + let state = TriggerInspectorState() + let list = HostedList(TriggerListPane(triggers: triggers, databaseType: .mysql, state: state)) + let tableView = try #require(list.tableView) + list.click(row: 0) + list.shiftDown() + try #require(state.selectedIDs == [id("trg_a"), id("trg_b")]) + + state.searchText = "trg_a" + list.settle() + try #require(tableView.numberOfRows == 1) + #expect(state.selectedIDs == [id("trg_a"), id("trg_b")]) + #expect(state.actionTarget(triggers)?.name == "trg_a") + + state.searchText = "" + list.settle() + #expect(tableView.selectedRowIndexes == IndexSet([0, 1])) + #expect(state.selectedIDs == [id("trg_a"), id("trg_b")]) + } + + @Test("A refresh that reorders the rows keeps a Shift+Down range by identity") + func refreshKeepsRangeByIdentity() throws { + let state = ListState(rows: [ + StructureListRow(id: "a", values: ["alpha", "BEFORE"]), + StructureListRow(id: "b", values: ["beta", "AFTER"]), + StructureListRow(id: "c", values: ["gamma", "AFTER"]), + ]) + let list = HostedList(ListHarness(grid: StructureListGridModel(), state: state)) + let tableView = try #require(list.tableView) + list.click(row: 0) + list.shiftDown() + try #require(state.selectedIDs == ["a", "b"]) + + state.rows.reverse() + list.settle() + + #expect(state.selectedIDs == ["a", "b"]) + #expect(tableView.selectedRowIndexes == IndexSet([1, 2])) + } + + @Test("A refresh that replaces every row selects none of the new ones") + func refreshWithNewRowsSelectsNothing() throws { + let state = ListState(rows: [ + StructureListRow(id: "a", values: ["alpha", "BEFORE"]), + StructureListRow(id: "b", values: ["beta", "AFTER"]), + ]) + let list = HostedList(ListHarness(grid: StructureListGridModel(), state: state)) + let tableView = try #require(list.tableView) + list.click(row: 0) + list.shiftDown() + try #require(state.selectedIDs == ["a", "b"]) + + state.selectedIDs = [] + state.rows = [ + StructureListRow(id: "d", values: ["delta", "BEFORE"]), + StructureListRow(id: "e", values: ["epsilon", "AFTER"]), + ] + list.settle() + + #expect(state.selectedIDs.isEmpty) + #expect(tableView.selectedRowIndexes.isEmpty) + } + + @Test("Trigger columns open at the widths the list has always given them") + func triggerColumnWidths() { + let withEnabled = HostedList(TriggerListPane(triggers: triggers, databaseType: .mysql, state: TriggerInspectorState())) + #expect(withEnabled.width(ofColumnTitled: String(localized: "Name")) == 220) + #expect(withEnabled.width(ofColumnTitled: String(localized: "Timing")) == 90) + #expect(withEnabled.width(ofColumnTitled: String(localized: "Event")) == 150) + #expect(withEnabled.width(ofColumnTitled: String(localized: "Enabled")) == 70) + + let unknownEnabled = triggers.map { + TriggerInfo(name: $0.name, timing: $0.timing, event: $0.event, statement: "") + } + let withoutEnabled = HostedList(TriggerListPane( + triggers: unknownEnabled, databaseType: .mysql, state: TriggerInspectorState() + )) + #expect(withoutEnabled.width(ofColumnTitled: String(localized: "Name")) == 240) + #expect(withoutEnabled.width(ofColumnTitled: String(localized: "Event")) == 160) + #expect(withoutEnabled.width(ofColumnTitled: String(localized: "Enabled")) == nil) + } +} diff --git a/TableProTests/Views/Structure/TriggerListGridTests.swift b/TableProTests/Views/Structure/TriggerListGridTests.swift new file mode 100644 index 0000000000..ad49dc1ea4 --- /dev/null +++ b/TableProTests/Views/Structure/TriggerListGridTests.swift @@ -0,0 +1,138 @@ +// +// TriggerListGridTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct TriggerListGridTests { + private let triggers = [ + TriggerInfo(name: "trg_b", timing: "BEFORE", event: "UPDATE", statement: "", enabled: true), + TriggerInfo(name: "trg_a", timing: "AFTER", event: "INSERT", statement: "", enabled: false), + TriggerInfo(name: "trg_c", timing: "AFTER", event: "DELETE", statement: ""), + ] + + private func sort(_ column: Int, _ direction: SortDirection) -> SortState { + SortState(columns: [SortColumn(columnIndex: column, direction: direction)], source: .user) + } + + private func names(_ list: [TriggerInfo]) -> [String] { + list.map(\.name) + } + + private func load(_ grid: StructureListGridModel, _ state: TriggerInspectorState, key: Int) { + let rows = state.displayed(triggers).map { + StructureListRow(id: $0.id, values: TriggerListColumns.values(for: $0, showsEnabled: true)) + } + _ = grid.ensureLoaded(key: key, columns: TriggerListColumns.titles(showsEnabled: true)) { rows } + } + + @Test("The list opens sorted by name, ascending") + func opensSortedByName() { + let state = TriggerInspectorState() + #expect(names(state.displayed(triggers)) == ["trg_a", "trg_b", "trg_c"]) + } + + @Test("A header sort orders by that column, in either direction, with ties kept in arrival order") + func sortsByColumn() { + let state = TriggerInspectorState() + state.sortState = sort(1, .ascending) + #expect(names(state.displayed(triggers)) == ["trg_a", "trg_c", "trg_b"]) + + state.sortState = sort(2, .descending) + #expect(names(state.displayed(triggers)) == ["trg_b", "trg_a", "trg_c"]) + + state.sortState = SortState(columns: [ + SortColumn(columnIndex: 1, direction: .ascending), + SortColumn(columnIndex: 0, direction: .descending), + ], source: .user) + #expect(names(state.displayed(triggers)) == ["trg_c", "trg_a", "trg_b"]) + } + + @Test("Don't Sort shows the triggers in the order the server listed them") + func clearedSortKeepsArrivalOrder() { + let state = TriggerInspectorState() + state.sortState = SortState(columns: [], source: .user) + #expect(names(state.displayed(triggers)) == ["trg_b", "trg_a", "trg_c"]) + } + + @Test("The filter matches the trigger name only") + func filtersByName() { + let state = TriggerInspectorState() + state.searchText = "_C" + #expect(names(state.displayed(triggers)) == ["trg_c"]) + state.searchText = "DELETE" + #expect(state.displayed(triggers).isEmpty) + } + + @Test("Enabled reads YES or NO, blank where the engine does not say, and only when some trigger says") + func enabledColumn() { + #expect(TriggerListColumns.titles(showsEnabled: false).count == 3) + #expect(TriggerListColumns.titles(showsEnabled: true).count == 4) + #expect(TriggerListColumns.values(for: triggers[0], showsEnabled: true) == ["trg_b", "BEFORE", "UPDATE", "YES"]) + #expect(TriggerListColumns.values(for: triggers[1], showsEnabled: true).last == "NO") + #expect(TriggerListColumns.values(for: triggers[2], showsEnabled: true).last?.isEmpty == true) + #expect(TriggerListColumns.values(for: triggers[0], showsEnabled: false) == ["trg_b", "BEFORE", "UPDATE"]) + } + + @Test("Each column opens at the width the list has always given it") + func defaultWidths() { + #expect(TriggerListColumns.widths(showsEnabled: true) == [220, 90, 150, 70]) + #expect(TriggerListColumns.widths(showsEnabled: false) == [240, 90, 160]) + #expect(TriggerListColumns.widths(showsEnabled: true).count == TriggerListColumns.titles(showsEnabled: true).count) + #expect(TriggerListColumns.widths(showsEnabled: false).count == TriggerListColumns.titles(showsEnabled: false).count) + } + + @Test("A selected trigger keeps its row through a sort") + func selectionFollowsTheTriggerThroughASort() { + let state = TriggerInspectorState() + let grid = StructureListGridModel() + load(grid, state, key: 1) + state.select(grid.ids(atDisplayRows: [1])) + #expect(state.selectedTrigger(triggers)?.name == "trg_b") + + state.sortState = sort(0, .descending) + load(grid, state, key: 2) + + #expect(grid.displayRows(of: state.selectedIDs) == [1]) + state.sortState = sort(2, .ascending) + load(grid, state, key: 3) + #expect(names(state.displayed(triggers)) == ["trg_c", "trg_a", "trg_b"]) + #expect(grid.displayRows(of: state.selectedIDs) == [2]) + #expect(state.selectedTrigger(triggers)?.name == "trg_b") + } + + @Test("Edit and Delete act on one trigger and have none while several are selected") + func actionTargetIsSingle() { + let state = TriggerInspectorState() + #expect(state.actionTarget(triggers)?.name == "trg_a") + + state.select([triggers[2].id]) + #expect(state.actionTarget(triggers)?.name == "trg_c") + + state.select([triggers[0].id, triggers[2].id]) + #expect(state.actionTarget(triggers) == nil) + #expect(state.selectedTrigger(triggers)?.name == "trg_b") + } + + @Test("A refresh keeps the selected triggers that remain and falls back to the first otherwise") + func ensureSelectionPrunesAndFallsBack() { + let state = TriggerInspectorState() + state.ensureSelection(triggers) + #expect(state.selectedIDs == [triggers[0].id]) + + state.select([triggers[1].id, "gone"]) + state.ensureSelection(triggers) + #expect(state.selectedIDs == [triggers[1].id]) + + state.select(["gone"]) + state.ensureSelection(triggers) + #expect(state.selectedIDs == [triggers[0].id]) + + state.ensureSelection([]) + #expect(state.selectedIDs.isEmpty) + } +} diff --git a/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift b/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift new file mode 100644 index 0000000000..cdbb473388 --- /dev/null +++ b/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift @@ -0,0 +1,153 @@ +// +// PrivilegeScopeOutlineThemeTests.swift +// TableProTests +// +// The privilege scope outline is a content table, so it takes the data grid's colors and text under +// a theme and stays exactly AppKit's, row views included, under a default one. +// + +import AppKit +import Combine +@testable import TablePro +import TableProPluginKit +import Testing + +/// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are +/// synchronous and `@MainActor`, so nothing else on the main actor runs between the two. +@Suite("Privilege scope outline follows the theme", .serialized) +@MainActor +struct PrivilegeScopeOutlineThemeTests { + private struct Mounted { + let window: NSWindow + let scrollView: NSScrollView + let outline: PrivilegeScopeOutline + let coordinator: PrivilegeScopeOutlineCoordinator + } + + /// Never ordered front and never closed: `close()` on a test window over-releases it. + private func mount() throws -> Mounted { + let viewModel = UsersRolesViewModel(connectionId: UUID(), databaseType: .postgresql) + viewModel.privilegeTree.showGrantedOnly(scopes: [.database("app"), .database("web")]) + let coordinator = PrivilegeScopeOutlineCoordinator(viewModel: viewModel) + let scrollView = PrivilegeScopeOutlineView.makeScrollView(coordinator: coordinator) + scrollView.frame = NSRect(x: 0, y: 0, width: 320, height: 160) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 160), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentView = scrollView + let outline = try #require(scrollView.documentView as? PrivilegeScopeOutline) + outline.reloadData() + outline.layoutSubtreeIfNeeded() + return Mounted(window: window, scrollView: scrollView, outline: outline, coordinator: coordinator) + } + + private func hex(_ color: NSColor?) throws -> String { + let color = try #require(color) + var hex = "" + NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { + hex = HexColor.string(from: color) + } + return hex + } + + @Test("Under the default theme the outline keeps AppKit's background, stripes and selection") + func defaultThemeTouchesNothing() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let mounted = try mount() + let chrome = try #require(mounted.outline.themeChrome) + + let stock = NSOutlineView() + stock.style = .fullWidth + #expect(mounted.outline.backgroundColor == stock.backgroundColor) + #expect(mounted.outline.usesAlternatingRowBackgroundColors) + #expect(!mounted.scrollView.drawsBackground) + #expect(!chrome.isBackgroundThemed) + #expect(chrome.selectionColor(emphasized: true) == nil) + #expect(mounted.outline.numberOfRows == 2) + for row in 0 ..< mounted.outline.numberOfRows { + let rowView = try #require(mounted.outline.rowView(atRow: row, makeIfNecessary: true)) + #expect(type(of: rowView) == NSTableRowView.self) + } + } + } + + @Test("A theme's selection swaps in themed rows and a default theme swaps AppKit's back, keeping the selection") + func rowClassFollowsTheTheme() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let mounted = try mount() + let chrome = try #require(mounted.outline.themeChrome) + mounted.outline.selectRowIndexes(IndexSet(integer: 1), byExtendingSelection: false) + let selectedScopes = mounted.coordinator.viewModel.selectedScopes + #expect(selectedScopes.count == 1) + _ = try #require(mounted.outline.rowView(atRow: 1, makeIfNecessary: true)) + + var published = 0 + let subscription = mounted.coordinator.viewModel.$selectedScopes.dropFirst().sink { _ in published += 1 } + defer { subscription.cancel() } + + try ThemeTestFixture.adopt([.gridSelection: "#C8E1FF"], appearance: .light) + let themed = try #require(mounted.outline.rowView(atRow: 1, makeIfNecessary: true) as? ThemeTableRowView) + #expect(themed.chrome === chrome) + #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 1)) + + try ThemeTestFixture.adopt([:], appearance: .light) + let native = try #require(mounted.outline.rowView(atRow: 1, makeIfNecessary: true)) + #expect(type(of: native) == NSTableRowView.self) + #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 1)) + #expect(mounted.coordinator.viewModel.selectedScopes == selectedScopes) + #expect(published == 0, "the rebuild republished the selection \(published) times") + } + } + + @Test("Both columns are records cells, told when their row sits on the theme's selection") + func cellsFollowTheThemedSelection() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([.gridSelection: "#C8E1FF"], appearance: .light) + let mounted = try mount() + mounted.outline.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + let row = try #require(mounted.outline.rowView(atRow: 0, makeIfNecessary: true) as? ThemeTableRowView) + row.isEmphasized = true + + for column in 0 ..< mounted.outline.numberOfColumns { + let selected = mounted.outline.view(atColumn: column, row: 0, makeIfNecessary: true) + let other = mounted.outline.view(atColumn: column, row: 1, makeIfNecessary: true) + #expect((selected as? ThemeTableChrome.HostingCell)?.isOnThemedSelection == true) + #expect((other as? ThemeTableChrome.HostingCell)?.isOnThemedSelection == false) + } + } + } + + @Test("Under a theme the outline takes the grid's background, stripes and selection, and gets its own back") + func themeAppliesAndRestores() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let mounted = try mount() + let chrome = try #require(mounted.outline.themeChrome) + let background = mounted.outline.backgroundColor + + try ThemeTestFixture.adopt([ + .gridBackground: "#FAFAFA", + .gridAlternateRow: "#F0F0F0", + .gridSelection: "#C8E1FF", + ], appearance: .light) + #expect(try hex(mounted.outline.backgroundColor) == "#FAFAFA") + #expect(!mounted.outline.usesAlternatingRowBackgroundColors) + #expect(try hex(chrome.rowBackgroundColor(forRow: 0)) == "#FAFAFA") + #expect(try hex(chrome.rowBackgroundColor(forRow: 1)) == "#F0F0F0") + #expect(try hex(chrome.selectionColor(emphasized: true)) == "#C8E1FF") + + try ThemeTestFixture.adopt([:], appearance: .light) + #expect(mounted.outline.backgroundColor == background) + #expect(mounted.outline.usesAlternatingRowBackgroundColors) + #expect(!mounted.scrollView.drawsBackground) + #expect(chrome.rowBackgroundColor(forRow: 1) == nil) + } + } +}