diff --git a/.claude/rules/data-grid.md b/.claude/rules/data-grid.md index 07ff0e3f56..5bfab98701 100644 --- a/.claude/rules/data-grid.md +++ b/.claude/rules/data-grid.md @@ -11,7 +11,11 @@ paths: - **Cells are drawn, not mounted.** `tableView(_:viewFor:row:)` returns nil for data columns and `DataGridRowView` draws the visible cells, and every column stays attached. Never hide off-screen columns with `NSTableColumn.isHidden`: each write costs O(attached columns). - **Find data columns through `DataGridColumnPool.presentsColumn` and `firstPresentedColumnIndex`**, never a fixed index into `tableColumns` (the row-number column and pool slots sit there too). - **The header draws all of its own chrome** through `SortableHeaderChrome`: the cell never calls `super.draw`, and `highlightedTableColumn` stays unset, because AppKit paints a fixed-height band that does not fit the taller header. Sort state goes through `SortableHeaderView.applySortState(_:schema:)`, which also sets each header cell's accessibility sort direction. -- **Row selection is `NSTableRowView`'s own** unless the theme overrides `gridSelection` or `gridInactiveSelection`, so the default theme follows Accent and focus. The gutter and a selected column heading read `DataGridBodyChrome.selectionColor(emphasized:)` so they match the row. A theme change repaints visible rows; it never reloads data. +- **A themed header draws its own cells.** `NSTableHeaderView.draw` paints AppKit's band over the header fill before the cells, so while `dataGrid.headerBackground` is themed `SortableHeaderView` draws each column's header cell itself, the dragged one included; an unthemed header keeps `super.draw`. +- **Row selection is `NSTableRowView`'s own** unless the theme overrides `gridSelection` or `gridInactiveSelection`, so the default theme follows Accent and focus. The gutter and a selected column heading read `DataGridBodyChrome.selectionColor(emphasized:scope:)` so they match the row. A theme change repaints visible rows; it never reloads data. +- **A translucent theme selection is flattened once.** The row, the gutter and a heading sit on three different bases, so `DataGridBodyChrome.themedSelectionColor(emphasized:scope:)` lays the selection over the grid background and all three paint that opaque color with any alpha. +- **A range outline stays apart from its fill.** Natively a multi-cell range is outlined in the selection color at 70%. Under a themed `dataGrid.selection` that outline would vanish into the opaque fill, so `GridSelectionOverlay.rangeBorderColor(_:)` takes the active cell's border color instead (`dataGrid.focusBorder`, else Accent) at 70%. +- **The grid reads its colors through its `ThemeScope`.** `DataGridView(themeScope:)` hands it to `TableViewCoordinator`, and every grid color read goes through `coordinator.themeScope`, `KeyHandlingTableView.themeScope` and `SortableHeaderView.themeScope` included. `ThemeEngine.shared` supplies only the fonts and the cache counters that follow a system color change. A new scope repaints in place with no reload. Read `scope.resolved` once per draw, not once per cell. Scopes: `.claude/rules/theme.md`. - **`gridStyleMask` stays empty; `DataGridBodyChrome` draws column separators and the stripes.** AppKit's vertical grid lines add one subview per column and make every layout pass quadratic. Take separator positions from `presentsColumn` and `rect(ofColumn:)`. - **Scroll the grid with `NSView.scroll(_:)`**, never `clipView.scroll(to:)` plus `reflectScrolledClipView`, which leaves the header behind. - **Accessibility goes through `DataGridCellAccessibilityView`**, mounted for on-screen rows once `DataGridAccessibility.isActive`; `NSTableView` builds its cell tree from views only. Keep that path working whenever row drawing changes. diff --git a/.claude/rules/data-sync-storage.md b/.claude/rules/data-sync-storage.md index 0e2b1de1a6..f6a31d2270 100644 --- a/.claude/rules/data-sync-storage.md +++ b/.claude/rules/data-sync-storage.md @@ -26,6 +26,9 @@ This path holds user data. A silent wrong answer here looks exactly like an empt | Connection passwords | Keychain | `ConnectionStorage` | | Preferences | UserDefaults | `AppSettingsStorage`, `AppSettingsManager` | | Editor and grid fonts | UserDefaults, device-local, never synced | `TypographySettings` through `AppSettingsManager` | +| Appearance mode (Auto, Light, Dark) | UserDefaults, device-local, never synced | `AppSettingsStorage` through `AppSettingsManager.appearanceMode` | +| Light and dark theme ids (iCloud-synced, category `appearance`) | UserDefaults | `AppearanceSettings` through `AppSettingsManager` | +| User themes (iCloud-synced, one `AppSettings` record `theme.` each) | JSON, one file per theme | `ThemeCatalog` (sync rules: `.claude/rules/theme.md`, Sync) | | Query history | SQLite FTS5 | `QueryHistoryStorage` | | Tab state | JSON | `TabPersistenceCoordinator`, `TabDiskActor` | | Filter defaults and presets | UserDefaults | `FilterSettingsStorage`, `FilterPresetStorage` | diff --git a/.claude/rules/editor.md b/.claude/rules/editor.md index 79b3868a3f..073dbdcc35 100644 --- a/.claude/rules/editor.md +++ b/.claude/rules/editor.md @@ -8,7 +8,9 @@ paths: # Editor -- **`ThemeEngine.makeEditorTheme()` builds the `TableProEditorTheme` adapter** the editor draws with. Theme colors and their scope: `.claude/rules/theme.md`. +- **`EditorThemeFactory` builds the `EditorTheme` the editor draws with.** `make(resolved:highlightsCurrentLine:highlightsCurrentStatement:)` is pure over a `ResolvedTheme`. `ThemeEngine.makeEditorTheme()` and `TableProEditorTheme.make()` call it with the live theme and the editor settings; the theme editor preview calls it with a fixed scope's `resolved`. Theme colors and their scope: `.claude/rules/theme.md`. +- **Aliases and parameters take `editor.text`.** The `variables` capture (aliases, parameters, properties) is a name the user wrote, so it draws in the text color; `attributes` and `values` keep `editor.syntax.null`. Keywords draw bold. +- **A static code view highlights like the editor.** `HighlightedSQLTextView` and chat code blocks color `CodeBlockHighlighter.captures(in:language:)`, the editor's grammar, through the same `EditorTheme`, so a statement reads the same in a preview, a chat block and the editor. Only whole lines before its 10k-character cap get colors. Never add a second highlighter. - **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 index 697e8e263b..cd68b0cde5 100644 --- a/.claude/rules/theme.md +++ b/.claude/rules/theme.md @@ -3,20 +3,81 @@ paths: - "TablePro/Theme/**/*" - "TablePro/Views/**/*" - "TablePro/Core/Services/Infrastructure/*Window*" + - "Packages/TableProCore/Sources/TableProTheme/**/*" --- # 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. Nothing checks for a slot no view reads, so a change that removes a slot's last reader removes the slot. 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. +## What a theme colors + +- **A theme colors content and the window backdrop, never chrome.** Content is every code view, the data grid, value text and wells, value trees, diffs, record tables, diagrams, charts, and the states that stand in for any of them. Chrome is controls, Accent, Highlight, the sidebar, inspector frame, toolbar, titlebar, tab strip and bars, Settings, Welcome, the connection form, sheet and popover chrome, and SwiftUI `List`, `Form` and `Table` backgrounds. `NSApp.appearance` is the only lever on chrome. +- **Chrome never reads a slot.** It asks `ThemeBackdropState.isThemed(in:scope:)` or `isThemed(highContrast:scope:)` whether the backdrop behind it is themed, and answers with a system style: + - A bar over a pane takes `.paneBarBackground(_:ignoresSafeAreaEdges:)` with the native style it drew before (`.bar` by default, or a flat `controlBackgroundColor` or `windowBackgroundColor`). It draws that style while the backdrop is unthemed and nothing while it is themed, because a material over a themed pane reads as a system band. No edges by default, so a top bar never reaches under the toolbar. + - A bottom status bar drops its material through `DetailPaneStatusBarMaterial`, which sets `statusBarDrawsMaterial(_:)` for `StatusBarChrome`. + - The editor tab strip track draws `secondarySystemFill` over a themed backdrop on macOS 26 and later, where the track sits on the theme's window color, and its unthemed fill otherwise. Never a palette color. +- **A code field takes the editor theme wherever it appears.** Startup Commands, Pre-Connect Script, a favorite's query, Rewind, Agent SQL, the deep-link Startup SQL and the tunnel command preview wear `editor.*`; the form or sheet around them stays system. - **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.** The SwiftLint rule `theme_read_outside_content` names, in its `excluded:` list, 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 that list in the same change; chrome never does. +- **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 model: `TableProTheme` + +- **`TableProTheme` (`Packages/TableProCore/Sources/TableProTheme`) is the theme model, Foundation only**: `ThemeSlot`, `ThemeColor` (RGBA8 with compositing, mixing and WCAG contrast), `ThemeDefinition`, `ThemeFileFormat`, `ThemeLoadError`, `ThemeResolver`, `ThemeOrdering`, `DefaultSyntaxPalette` and `ThemeFileImporter`. AppKit and SwiftUI stay in `TablePro/Theme`, so the iOS app can adopt the package. Its tests are `TableProThemeTests`, under `swift test`. +- **`ThemeSlot` is the one registry.** The decoder, the encoder, the theme editor and the VS Code mapping enumerate it. Each slot declares `group` (its file key), `section` (where the theme editor lists it), `label`, `accessibilityLabel` (unique, one whole localized phrase), `yieldsToIncreasedContrast`, `requiresOpaque`, `offersOpacity` and `fallback`, each an exhaustive switch, so a new slot cannot ship without every decision. A slot exists only with a reader: nothing checks for one no view reads, so removing a slot's last reader removes the slot. +- **Every slot is optional, and `fallback` decides what an unset one draws.** `ThemeDefinition.value(for:highContrast:)` returns `.themed`, `.system(slot)` or `.defaultSyntax(slot)`: + - `.system`: the slot's native color, `ThemeSlot.nativeColor`. + - `.defaultSyntax`: the same appearance's `DefaultSyntaxPalette` color (Xcode's Default, with its high-contrast variant). Syntax has no system color. + - `.slot(other)`: the other slot's theme color, else native. The operator follows `editor.text`; the bools and the header text follow `dataGrid.text`. + - `.derived(base:rule:)`: built from the base while the theme sets it, else native. The alternate row is the grid background mixed 4% toward the grid text, or toward black in a light theme and white in a dark one when the text is unset. The header background equals the grid background. + - `.paired(with:fallback:)`: `dataGrid.selectionText` draws a theme color only over a themed `dataGrid.selection`, its own color or else the grid text. Under AppKit's selection it stays native, white text included. + - A rule names only slots that fall back to the system themselves, so resolution never chains. +- **`isOverridden(_:)` means the slot draws a theme color, set or derived.** Every carrier asks it before it swaps a native drawing path. Only the theme editor asks `hasExplicitColor(_:)`, which means the file writes the slot. +- **Default Light and Default Dark set no slot.** They draw a native app plus the default syntax palette. A duplicate of one carries no overrides either, so it keeps the high-contrast syntax. +- **Opaque slots.** `requiresOpaque` holds `window.background`, `editor.background`, `dataGrid.background` and `dataGrid.headerBackground`: the codec and `value(for:highContrast:)` drop their alpha, so no fill shows the desktop through the window. `offersOpacity` marks the washes drawn over content (selections, current line and statement, invisibles, alternate row, change tints); only their wells offer opacity. No slot has both. +- **Schema policy.** Adding a slot bumps `ThemeSchema.current`. The decoder keeps reading every format from 3 up and refuses a newer one, and an unknown key stays an error: a file is refused rather than loaded without colors its author wrote. The bump also renames the sync content kind (Sync, below). +- **Import.** `ThemeFileImporter.importTheme(at:)` checks the size before it reads a byte, reads a TablePro theme (a numeric `schema`) or a VS Code color theme (`colors`, `tokenColors`, `include`, `settings` or VS Code's `$schema`), and mints a fresh id every time: a file's id says nothing about which theme on this Mac it is. A VS Code `include` never leaves the chosen file's folder. `ThemeSlot.vscodeSources` is an exhaustive switch, and a slot the fallback rules derive the way VS Code paints it stays unset. + +## Reading colors in the app + +- **`ThemeScope` is where a carrier reads.** `ThemeScope.live` forwards to `ThemeEngine.shared` and `AppEvents.shared.themeChanged`. `ThemeScope.fixed(_:)` draws one definition in both appearances on its own `ThemeSource` and never reports a change; a new definition is a new scope. Only the theme editor preview passes a fixed scope. A scoped carrier reads `activeTheme`, `palette`, `resolved` and `changes` through its scope, never through `ThemeEngine.shared`. The SwiftUI modifiers read the engine. +- **Two color tiers.** `palette[slot]` and `palette.color(_:otherwise:)` are dynamic colors over the scope's 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. Each `ThemeSource` names its colors apart, so a fixed scope's color never equals a live one. `resolved[slot]` is the active theme's color 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)`, `.gridStandInBackground(isActive:)`, `.valueStandInBackground()` (a state that takes the place of a value's tree or text, on `editor.background`) and `.geometryMapWell()` (the map pane's well). AppKit, each taking a `scope:`: `ThemeTextViewStyle` on an `NSTextView`; `ThemeTableChrome` (`.records` or `.code`) on a content `NSTableView` or `NSOutlineView`, never a source list, with `ThemeTableRowView` rows and `ThemeTableChrome.HostingCell` cells; `DataGridView(themeScope:)`. Window: `ThemeWindowBackdrop` and `DetailColumnBackdrop` (Windows, below). +- **A themed records header is drawn by the theme.** `NSTableHeaderView` paints its own band over anything beneath it, so while `dataGrid.headerBackground` or `headerText` is themed, `ThemeTableChrome` swaps in `ThemeTableHeaderView` and `ThemeTableCornerView`, and puts the table's own back once the theme stops. The data grid's header: `.claude/rules/data-grid.md`. - **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. +- **A theme change has to reach the view.** `AppEvents.shared.themeChanged` fires when the active pair changes (a selection, a catalog edit or sync, a draft preview), on a light/dark flip, an Accent or Highlight change, an Increase Contrast toggle and a font change. A scoped carrier subscribes to its scope's `changes`. 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; `ThemeTableChrome.recordsTextSlot(onThemedSelection:onEmphasizedSelection:highContrast:scope:)` picks a records cell's slot. - **The window backdrop yields to Increase Contrast** and draws native there, since the format has no increased-contrast variant. Content slots keep the theme. + +## Windows and the backdrop + +- **The four content windows are `ContentWindow`s**: main (`TabWindowController`), Data File, Compare & Sync and the value viewers. Each holds a `ThemeWindowBackdrop`, which on macOS 26 and later sets `NSWindow.backgroundColor` to `window.background` only while the theme sets it and puts the window's own color back when it stops; the sidebar, inspector and toolbar glass take their tone from it. Before macOS 26 the window keeps its own color. +- **One titlebar policy, in `ContentWindow`.** On macOS 26 and later the titlebar is transparent, so the backdrop reaches the toolbar; in full screen with the toolbar hidden it keeps its background, since it then slides down over the content. Before macOS 26 it stays opaque: a transparent titlebar there swaps its material for a flat fill, which fills chrome. A new content window subclasses `ContentWindow`. Toolbar separators stay each window's own. +- **The detail column sits on `window.background` on every macOS version.** `MainSplitViewController` installs `DetailColumnBackdrop` under the detail panes through `WorkspacePaneHost.installBackdrop(_:)`, so Connecting, Unavailable, the agent and every other state get it. Before macOS 26 it stops below the toolbar, which keeps the system color. The backdrop passes clicks through. `DetailPaneBackdropFill` goes only there; a pane that draws nothing shows it. The Compare & Sync and Data File roots fill with `.themeBackground(.windowBackdrop, native:)`. - **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. SwiftLint's `pane_hidden_background` bans `.scrollContentBackground(.hidden)` and `.listRowBackground(` in app code, and `window_background_outside_backdrop` bans a window color write outside `ThemeWindowBackdrop`. + +## Engine and catalog + +- **`ThemeEngine` owns the selection**: `mode`, `lightThemeId` and `darkThemeId`, set through `apply(mode:)`, `select(lightThemeId:darkThemeId:)` and `apply(mode:lightThemeId:darkThemeId:)`. It never writes settings. It resolves with `ThemeResolver` over the catalog's themes in `ThemeOrdering` order and follows `ThemeCatalog.$themes` itself, so a save, trash, import, sync or file changed on disk repaints with no view involved; a view never asks it to reapply. A burst of catalog or draft changes costs one resolve per run loop turn, in the common modes, because a color well drag runs the event-tracking mode. +- **`ThemeResolver` is pure.** A selected theme that is missing, rejected or of the other appearance draws that slot's default, and the saved id stays, so the theme comes back when its file does. +- **Draft overlay.** `beginPreview(_:)` stands a draft in for the theme with its id until `endPreview(id:)`, so the theme editor repaints the real windows on every change with no file write. A draft no pair slot selects draws nothing. +- **`ThemeCatalog` owns the files.** Built-ins ship as JSON in `TablePro/Resources/Themes` (ids `tablepro.*`, read-only), besides Default Light and Default Dark from the package. User themes are JSON files in Application Support's `TablePro/Themes` folder, resolved through `AppStorageEnvironment`, with ids from `ThemeIdentifier.generated()`. A user theme is its JSON `id`, in whatever file it loaded from. The catalog reloads whenever TablePro becomes active. +- **A file changed on disk wins.** A save, rename or trash of a theme whose file changed or vanished since it was read throws `changedOnDisk` or `deletedOnDisk` and reloads; it never overwrites the file or recreates it under another name. `trash(id:)` moves the file to the Trash, or into the storage environment's own folder when that is isolated, and returns a `ThemeTrashReceipt` that `restore(_:)` takes back. `rename(id:to:)` refuses a taken name (`nameTaken`, case-insensitive); duplicate and import number theirs. Two files with one name show as "Name 2" in the list only, never in the file, so a display fix never syncs. +- **A built-in theme carries its source's license**: an entry in `TablePro/Resources/ThirdPartyLicenses/licenses.yml` and its text in `TablePro/Resources/ThirdPartyLicenses/texts`, checked by `scripts/ci/check-license-inventory.py`. + +## The theme editor + +- **Settings > Appearance is chrome.** It holds the mode popup on `AppSettingsManager.appearanceMode` ("Applies to this Mac."), the light and dark theme popups, and the themes list: `ThemeLibrarySection` over `ThemeLibraryModel`, which owns every list action and returns typed results. +- **One Theme Editor window per theme.** `ThemeEditorWindows` keeps them, and opening a theme again focuses its window. `ThemeEditorWindowController` is chrome; only its preview wears the theme. `ThemeEditorModel` owns the draft: undo on the window's undo manager with one step per settled change, Revert, a save about 750 ms after a change settles and on close, and the catalog's conflict errors as alerts. A built-in opens read-only, with Duplicate. +- **The preview is the real views.** A read-only `SourceEditor` themed through `EditorThemeFactory.make(resolved:highlightsCurrentLine:highlightsCurrentStatement:)` and a `DataGridView` with `themeScope:`, both on the draft's `ThemeScope.fixed(_:)`, drawn in the draft's appearance. It never takes focus: its editor and grid answer Undo with histories of their own. +- **Set the window's delegate before its content.** A window whose undo manager was read while it had no delegate keeps its own manager and never asks `windowWillReturnUndoManager(_:)`. +- **A slot row** is a `ColorPicker` with `supportsOpacity: slot.offersOpacity` and the slot's `accessibilityLabel`. Its reset shows only while `hasExplicitColor(_:)` is true. + +## Sync + +- **Each user theme syncs as an `AppSettings` record, category `theme.`**, its payload the canonical bytes from `ThemeFileFormat.encode(_:)`. The catalog marks it dirty on save, rename, duplicate, import and restore, and marks it deleted once the trash has landed (persist, then notify). A load or migration marks nothing. Built-ins never sync. +- **A synced theme is untrusted input.** `ThemeCatalog.applyRemote(category:data:)` checks, in order, the category id (never a built-in id), a 64 KB cap, the nesting depth, a full decode (a newer schema fails here), and the file's id against the category's, and never writes over bytes it has not read. A validation failure is a logged skip. Only a disk failure returns `.failed`, so the coordinator withholds the change token and the pull replays. A remote deletion moves the local file to the Trash through `removeWithoutSync(ids:)`. +- **The theme ids sync; the appearance mode does not.** `AppearanceSettings` (category `appearance`) holds only the light and dark theme ids. `AppSettingsManager.appearanceMode` is device-local (`AppSettingsCategory.deviceLocal`, its own defaults key), and a received `appearance` record never changes it. Fonts are device-local too (`.claude/rules/editor.md`). +- **`SyncCoordinator.themeContentKind` names `ThemeSchema.current`**, so a Mac that upgrades past a schema bump fetches the zone once and picks up the theme records its older build skipped. After the first full pull, only local themes the server lacks are marked dirty, once. + +## The lint rule + +- **The file list is closed.** SwiftLint's `theme_read_outside_content` names, in its `excluded:` list, every file that may read the theme: a slot, a `palette` or `resolved` read, `activeTheme`, a theme pair, a `ThemeScope`, a carrier, or a helper that reads it (`TreeValueTone.color`, `HighlightedSQLTextView`, `TableProEditorTheme.make()`, `EditorThemeFactory.make(resolved:highlightsCurrentLine:highlightsCurrentStatement:)`, `DetailPaneBackdropFill`, `DetailPaneStatusBarMaterial`). A new content surface joins the list in the same change, and a surface that becomes chrome leaves it. `ThemeBackdropState` and `.paneBarBackground(_:ignoresSafeAreaEdges:)` stay out of the rule's regex, because they are how chrome asks. diff --git a/.github/hub-ratchet-baseline.json b/.github/hub-ratchet-baseline.json index b128509ac8..f0a7d87efd 100644 --- a/.github/hub-ratchet-baseline.json +++ b/.github/hub-ratchet-baseline.json @@ -8,7 +8,7 @@ "LaunchIntent cases": 15, "shell extensions named for a module or engine": 11, "MainContentCoordinator+ files": 67, - "app types AppDelegate names": 65, + "app types AppDelegate names": 64, "files under TablePro/ named for BigQuery": 0, "files under TablePro/ named for Beancount": 0, "files under TablePro/ named for Cassandra": 0, diff --git a/.swiftlint.yml b/.swiftlint.yml index 2a961ebde0..2dd9b2fdce 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -475,14 +475,14 @@ custom_rules: severity: error theme_read_outside_content: name: "Theme Read Outside Content" - regex: '\bpalette\[|\bpalette\.color\(|\.resolved\[|\bresolved\[\s*\.|\bresolved\.(?:color|cgColor|isOverridden)\(|\bThemeSlot\b|\btheme(?:Background|Foreground)\(|\bvalueWell\(|\bCodeWell\b|\bstyle:\s*\.code\b|\bTheme(?:ShapeStyle|TextViewStyle|TableChrome|TableRowView|TableRowDrawing|WindowBackdrop)\b|\b(?:gridStandInBackground|diffLineForeground|makeEditorTheme)\(|\bCompareStatusStyle\.(?:lineTint|rowTint)|\bTableProEditorTheme\.make\(|\bEditorTabStripPalette\.trackFill\b|\b(?:TreeValueTone\.\w+|tone(?:\(for: \w+\))?)\.color\b|\b(?:ValueSourceEditor|ERDiagramPalette|DiagramCanvasBackdrop|HighlightedSQLTextView)\b' + regex: '\bpalette\[|\bpalette\.color\(|\.resolved\[|\bresolved\[\s*\.|\bresolved\.(?:color|cgColor|isOverridden)\(|\bactiveTheme\b|\.pair\b|\.color\(for:\s*\.|\bThemeSlot\b|\bThemeScope\b|\btheme(?:Background|Foreground)\(|\bvalueWell\(|\bCodeWell\b|\bstyle:\s*\.code\b|\bTheme(?:ShapeStyle|TextViewStyle|TableChrome|TableRowView|TableRowDrawing|WindowBackdrop)\b|\b(?:gridStandInBackground|valueStandInBackground|geometryMapWell|diffLineForeground|makeEditorTheme)\(|\bDetailPane(?:BackdropFill|StatusBarMaterial)\b|\bCompareStatusStyle\.(?:lineTint|lineTextOverride|rowTint)|\b(?:TableProEditorTheme|EditorThemeFactory)\.make\(|\b(?:TreeValueTone\.\w+|tone(?:\(for: \w+\))?)\.color\b|\b(?:ValueSourceEditor|ERDiagramPalette|DiagramCanvasBackdrop|HighlightedSQLTextView)\b' excluded: - '.*/(Packages|Plugins)/' - '.*/TablePro/(Theme|Views/Settings/Appearance|Views/Results/Cells|Views/Results/Selection)/' - '.*/TablePro/Views/Results/(DataGrid|SortableHeader|CellOverlay)[^/]*\.swift$' - - '.*/(SQLEditorView|TableProEditorTheme|HighlightedSQLTextView|SQLStatementPreview|SQLCodePreview|JSONCodeEditor|AIChatCodeBlockView|CodeBlockHighlighter|AIChatToolUseBlockView|AIChatToolResultBlockView|DDLTextView|TriggerEditorView|RewindReviewSheet|AgentProposedStatementsView|VimCursorManager|FoldPreviewView|CodeWell|ValueWell|TextValueEditor|ValueSourceEditor|TextViewerWindowController|SvgViewerContentView|CellImageWindowController|ServerOutputTextView|HexEditorContentView|PhpViewerView|FavoriteEditDialog|OptionsPaneView|SQLPreviewSheet|MaintenanceSheet|HistoryDetailPane)(\+[^/]*)?\.swift$' - - '.*/(TabWindowController|DataFileWindowController|CompareSyncWindowController|ValueViewerWindowController)(\+[^/]*)?\.swift$' - - '.*/(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)(\+[^/]*)?\.swift$' + - '.*/(SQLEditorView|TableProEditorTheme|HighlightedSQLTextView|SQLStatementPreview|SQLCodePreview|JSONCodeEditor|AIChatCodeBlockView|CodeBlockHighlighter|AIChatToolUseBlockView|AIChatToolResultBlockView|DDLTextView|TriggerEditorView|RewindReviewSheet|AgentProposedStatementsView|VimCursorManager|FoldPreviewView|CodeWell|ValueWell|TextValueEditor|ValueSourceEditor|TextViewerWindowController|SvgViewerContentView|CellImageWindowController|ServerOutputTextView|HexEditorContentView|PhpViewerView|FavoriteEditDialog|OptionsPaneView|SQLPreviewSheet|MaintenanceSheet|HistoryDetailPane|DeeplinkImportSheet|TunnelCommandTransportSections)(\+[^/]*)?\.swift$' + - '.*/(TabWindowController|DataFileWindowController|CompareSyncWindowController|ValueViewerWindowController|DetailColumnBackdrop)(\+[^/]*)?\.swift$' + - '.*/(MainEditorContentView|GridStandInBackground|AgentResultRowsView|VersionHistoryTabView|ObjectSourceTabView|DataFileContentView|TableStructureView|TriggerDetailView|ClickHousePartsView|CreateTableView|CreateTableFormPreview|PrivilegeScopeOutlineView|SlowQueryListView|QueryInsightsActivityChart|KeyHandlingTableView|TreeOutlineView|TreeOutlineCoordinator|TreeOutlineCellView|TreeValueStyle|TreeValueTone\+Theme|FilterableTreeView|JSONViewerView|ResultsJsonView|ArrayValueEditorView|ArrayJsonElementEditor|JSONRowInspectorView|JSONNodeRowView|JSONRowColors|SingleLineEditorView|FieldEditorContent|MultiLineEditorView|JsonEditorView|ImageFieldView|BlobHexEditorView|GeometryFieldView|PhpSerializedFieldView|CompareStatusStyle|CompareWindowContentView|CompareRowDiffPane|TextDiffView|FileConflictDiffSheet|AIChatWalkthroughBlockView|ERDiagramPalette|ERDiagramNodeRenderer|ERDiagramScene|ERDiagramSceneView|MagnifiableCanvasView|QueryPlanDiagramView|QueryPlanDetailPane|QueryPlanOutlineCoordinator|ResultChartCanvas)(\+[^/]*)?\.swift$' excluded_match_kinds: [comment, doccomment] message: "A theme colors content and the window backdrop only. Chrome uses a system color; a new content surface joins this rule's excluded list." severity: error diff --git a/AGENTS.md b/AGENTS.md index 9b1503b92f..02c732db65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Every capability is core, a module or a plugin. The first rule that fits decides - **Plugins.** `PluginManager` loads `DriverPlugin` bundles; `DatabaseDriverFactory` finds one by `pluginTypeId`; `PluginDriverAdapter` bridges its `PluginDatabaseDriver` to `DatabaseDriver`; `DatabaseManager` owns sessions. A new requirement gets a default implementation, then the adapter (`.claude/rules/plugin-system.md`). - **Main window.** `MainContentCoordinator`, split into `Views/Main/Extensions/`; one `ConnectionWorkspace` per connection (`.claude/rules/connection-window.md`). - **Change tracking.** `DataChangeManager` holds edits behind `AnyChangeManager`; `SQLStatementGenerator` writes them on Save. Undo: `StructureChangeManager`'s `UndoManager`, `ConnectionWorkspace.undoManager`. -- **Editor and theme.** `ThemeCatalog` lists themes, `ThemeResolver` picks the light and dark pair, `ThemeEngine` holds the palette and font caches; fonts come from `TypographySettings`. A theme colors content and window backdrop, never chrome (`.claude/rules/theme.md`). `QueryCompletionAdapter` bridges `CompletionEngine`; `EditorTabStrip` draws tabs (`.claude/rules/editor.md`). +- **Editor and theme.** `TableProTheme` holds slots, fallbacks, the file format and `ThemeResolver` (picks the light and dark pair). `ThemeCatalog` owns and syncs the theme files; `ThemeEngine` owns the mode and theme ids, follows the catalog, and holds the palette and font caches; fonts come from `TypographySettings`. A theme colors content and window backdrop, never chrome (`.claude/rules/theme.md`). `QueryCompletionAdapter` bridges `CompletionEngine`; `EditorTabStrip` draws tabs (`.claude/rules/editor.md`). - **Storage.** Passwords in the Keychain, preferences in UserDefaults, query history in SQLite FTS5, tab state as JSON (`.claude/rules/data-sync-storage.md`). ## Commands @@ -143,7 +143,7 @@ Claude Code loads a rule when it reads or edits a file the rule covers. Under Co | `redis-driver.md` | `Plugins/RedisDriverPlugin`, `Packages/TableProCore/Sources/TableProRedisCore` | | `split-views.md` | `TablePro/**/*Split*.swift`, `TablePro/Core/Services/Infrastructure/WorkspacePane*.swift` | | `tests.md` | `TableProTests`, `TableProUITests`, `Plugins/*Tests`, `Packages/*/Tests`, `TableProMobile/TableProMobileTests`, `TestPlans`, `scripts/ci/check-test-plans.py`, `scripts/clean-test-residue.sh` | -| `theme.md` | `TablePro/Theme`, `TablePro/Views`, `TablePro/Core/Services/Infrastructure/*Window*` | +| `theme.md` | `TablePro/Theme`, `TablePro/Views`, `TablePro/Core/Services/Infrastructure/*Window*`, `Packages/TableProCore/Sources/TableProTheme` | | `ui-lifecycle.md` | `TablePro/Views`, `TablePro/ViewModels`, `TablePro/Core/Services/Infrastructure`, `Packages/TableProEditor` | | `ui-tests.md` | `TableProUITests`, `TestPlans/UI.xctestplan` | | `welcome-library.md` | `TablePro/Views/Welcome`, `TablePro/ViewModels/WelcomeViewModel*.swift`, `Packages/TableProCore/Sources/TableProConnectionLibrary` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 752ac8721a..8de81a340d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Generate UUID and Generate UUIDv7 in Set Value, the row inspector and Fill Column, a new value for each cell. (#3443) - Edit > Set Value in the menu bar, with rows in Settings > Keyboard. (#3443) - New UUID in a UUID primary key the server does not fill, on Add Row, Duplicate Row and Paste. (#3443) +- Solarized, GitHub, Catppuccin, Gruvbox and Tokyo Night themes in light and dark, plus One Dark and One Light. +- VS Code color theme import in Settings > Appearance, from the theme's `.json` file. +- Theme Editor window with a live preview, undo and Revert Changes. Open windows show each edit as you make it. +- iCloud sync of custom themes and the chosen light and dark themes, with Settings on in Settings > Sync. +- Theme import by dropping files on the Themes list, and export by dragging a theme to Finder. ### Changed @@ -23,6 +28,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Default only on new rows on SQLite, libSQL, Turso, D1, ClickHouse, Databend, Snowflake, Trino, Cassandra. (#3443) - Pending SQL functions in the data grid drawn like a pending default. (#3443) - PluginKit ABI 39, additive: existing plugins keep loading. (#3443) +- Appearance (System, Light or Dark) kept per Mac instead of syncing. Older versions that sync settings switch to System. +- Data Files, Compare & Sync and Open in Window value windows show the theme in the titlebar on macOS 26 and later. +- SQL aliases and parameters drawn in the editor text color instead of the NULL color. +- SQL previews in history, filters, maintenance and review sheets highlighted as in the editor, in the editor font. +- Server Dashboard sessions and slow queries shown as data grids, with Cancel Query and Terminate Session in the header. +- Terminate Session and Cancel Query confirmations name the session's process ID and user. +- Compare & Sync status bar shows the target connection's icon instead of a colored dot. +- Themes move to the Trash instead of being deleted. Undo reverts a trash, rename, duplicate or import. +- Editor tab strip over a themed window on macOS 26 and later drawn in a system fill instead of the theme color. ### Fixed @@ -45,6 +59,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Date functions and a date picker offered on Cassandra and ScyllaDB `timeuuid` columns. (#3443) - MariaDB `uuid` columns read as `CHAR`. (#3443) - Empty field in the multi-row inspector after the selected rows were given different values. (#3443) +- Inline cell editor, the array editor's Edit as Text, and the inspector's hex dump and map pane ignoring the theme. +- No-match, invalid and NULL states in the JSON, PHP and array viewers drawn without the theme's background. +- Agent result states and proposed SQL ignoring the theme. +- Connecting and Not connected panes, and Agent mode, in the system window color under a theme before macOS 26. +- Selected text in SQL previews, such as query history, ignoring the theme's selection color. +- Server Dashboard lists and the Query Insights chart ignoring the theme. +- Compare & Sync row states and Data Files loading and failure states drawn without the theme's grid background. +- Structure and Create Table code placeholders, Startup SQL in a link and the tunnel command preview ignoring the theme. +- Bars in query, history, filter, structure, plan, compare and other panes drawing a system band over a themed window. +- System header band on the data grid and the Query Plan and Users & Roles trees under a theme with header colors. +- System-colored alternate rows, header and booleans in a theme that sets only the grid background and text. +- Selected row text in white over a theme's selection color, or in the theme's color over the system selection. +- Translucent theme selection, as in Dracula and Nord, in a different shade on rows, row numbers and column headers. +- No outline around a selected cell range in Dracula and Nord. +- Shapes in the result Map view keeping the previous Accent color after it changes. +- Edit as Text in the array editor turning straight quotes and dashes into curly quotes and long dashes. +- Theme editor bringing back a theme file deleted in Finder on the next change. +- Changes to a theme file made outside TablePro not shown until Settings > Appearance was opened. +- Theme files with a JSON syntax error reported without the line and column. +- Two themes with the same name impossible to tell apart in the theme pickers. ## [0.81.0] - 2026-10-16 diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index de9540bd79..668794c9fb 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -18,6 +18,7 @@ let package = Package( products: [ .library(name: "TableProCoreTypes", targets: ["TableProCoreTypes"]), .library(name: "TableProGeometry", targets: ["TableProGeometry"]), + .library(name: "TableProTheme", targets: ["TableProTheme"]), .library(name: "TableProPluginKit", targets: ["TableProPluginKit"]), .library(name: "TableProModels", targets: ["TableProModels"]), .library(name: "TableProImport", targets: ["TableProImport"]), @@ -84,6 +85,11 @@ let package = Package( dependencies: [], path: "Sources/TableProGeometry" ), + .target( + name: "TableProTheme", + dependencies: [], + path: "Sources/TableProTheme" + ), .target( name: "TableProPluginKit", dependencies: [], @@ -278,6 +284,11 @@ let package = Package( dependencies: ["TableProGeometry"], path: "Tests/TableProGeometryTests" ), + .testTarget( + name: "TableProThemeTests", + dependencies: ["TableProTheme"], + path: "Tests/TableProThemeTests" + ), .testTarget( name: "TableProNumberFormattingTests", dependencies: ["TableProNumberFormatting"], diff --git a/Packages/TableProCore/Sources/TableProTheme/DefaultThemes.swift b/Packages/TableProCore/Sources/TableProTheme/DefaultThemes.swift new file mode 100644 index 0000000000..63b4c99e7e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/DefaultThemes.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Default Light and Default Dark set no color at all: every slot draws its fallback, so a copy of +/// one keeps following Increase Contrast. They are values, not files, because every other theme +/// falls back to them and they cannot themselves fail to load. +public extension ThemeDefinition { + static let defaultLight = ThemeDefinition( + id: "tablepro.default-light", + name: String(localized: "Default Light"), + author: "TablePro", + appearance: .light, + overrides: [:] + ) + + static let defaultDark = ThemeDefinition( + id: "tablepro.default-dark", + name: String(localized: "Default Dark"), + author: "TablePro", + appearance: .dark, + overrides: [:] + ) + + static func `default`(for appearance: ThemeAppearance) -> ThemeDefinition { + switch appearance { + case .light: return defaultLight + case .dark: return defaultDark + } + } + + static func defaultId(for appearance: ThemeAppearance) -> String { + self.default(for: appearance).id + } + + static func isDefault(_ id: String) -> Bool { + id == defaultLight.id || id == defaultDark.id + } +} + +/// Xcode's Default and High Contrast syntax colors, for the syntax slots a theme leaves out. SQL +/// `NULL` takes the keyword color the way Swift's `nil` does; built-in functions and types take +/// Xcode's colors for system-provided ones. Operator has no entry: it follows the editor text. +public enum DefaultSyntaxPalette { + public static func colors(for appearance: ThemeAppearance, highContrast: Bool) -> [ThemeSlot: ThemeColor] { + switch (appearance, highContrast) { + case (.light, false): return standardLight + case (.light, true): return highContrastLight + case (.dark, false): return standardDark + case (.dark, true): return highContrastDark + } + } + + public static func color(for slot: ThemeSlot, appearance: ThemeAppearance, highContrast: Bool) -> ThemeColor? { + colors(for: appearance, highContrast: highContrast)[slot] + } + + private static let standardLight = syntax( + keyword: 0x9B2393, string: 0xC41A16, number: 0x1C00CF, comment: 0x5D6C79, function: 0x6C36A9, type: 0x3900A0 + ) + + private static let highContrastLight = syntax( + keyword: 0x88007E, string: 0x9B0509, number: 0x1C02CF, comment: 0x1A5301, function: 0x5C2699, type: 0x330090 + ) + + private static let standardDark = syntax( + keyword: 0xFC5FA3, string: 0xFC6A5D, number: 0xD0BF69, comment: 0x6C7986, function: 0xA167E6, type: 0xD0A8FF + ) + + private static let highContrastDark = syntax( + keyword: 0xFC6BAA, string: 0xFC7467, number: 0xD0BC56, comment: 0x7CB554, function: 0xC08AFF, type: 0xDEC1FF + ) + + private static func syntax( + keyword: UInt32, + string: UInt32, + number: UInt32, + comment: UInt32, + function: UInt32, + type: UInt32 + ) -> [ThemeSlot: ThemeColor] { + [ + .syntaxKeyword: rgb(keyword), + .syntaxNull: rgb(keyword), + .syntaxString: rgb(string), + .syntaxNumber: rgb(number), + .syntaxComment: rgb(comment), + .syntaxFunction: rgb(function), + .syntaxType: rgb(type), + ] + } + + private static func rgb(_ value: UInt32) -> ThemeColor { + ThemeColor(red: UInt8(value >> 16 & 0xFF), green: UInt8(value >> 8 & 0xFF), blue: UInt8(value & 0xFF)) + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/Import/TextMateThemeResolver.swift b/Packages/TableProCore/Sources/TableProTheme/Import/TextMateThemeResolver.swift new file mode 100644 index 0000000000..598472b32b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/Import/TextMateThemeResolver.swift @@ -0,0 +1,369 @@ +import Foundation + +// A port of microsoft/vscode-textmate src/theme.ts at 4e96221f20fb3edab61860bac174423952839255 +// (parseTheme, resolveParsedThemeRules, ColorMap, ThemeTrieElement, Theme.match) and of the per-scope +// merge in grammar.ts AttributedScopeStack, the engine that colors a .sql file in VS Code. Only the +// foreground is carried: the TablePro format has no font style or token background. Strings compare +// by UTF-16 code unit, as JavaScript does. + +internal struct TextMateRule: Equatable, Sendable { + internal enum Scope: Equatable, Sendable { + case unscoped + case selector(String) + case selectors([String]) + } + + internal var scope: Scope + internal var foreground: String? +} + +internal struct TextMateThemeResolver { + // A real theme has at most a few hundred rules. Inserting is quadratic in rules that share a node, + // so an untrusted file is capped well before that costs seconds. + internal static let maximumRuleCount = 10_000 + + // Far above what a theme writes. Each scope part is a trie node, so the part cap bounds the nodes + // a rule adds; the length cap bounds the parent scopes a rule carries. + internal static let maximumSelectorLength = 4_096 + internal static let maximumScopeParts = 32 + + internal let defaultForeground: String + private let colorMap: TextMateColorMap + private let root: TextMateTrieElement + + internal init(rules: [TextMateRule]) throws { + let parsed = try Self.parse(rules).sorted { lhs, rhs in + let scopeOrder = TextMateText.compare(lhs.scope, rhs.scope) + if scopeOrder != 0 { return scopeOrder < 0 } + let parentOrder = TextMateText.compare(lhs.parentScopes, rhs.parentScopes) + if parentOrder != 0 { return parentOrder < 0 } + return lhs.index < rhs.index + } + + var remaining = parsed[...] + var defaultForeground = "#000000" + while let first = remaining.first, first.scope.isEmpty { + remaining = remaining.dropFirst() + if let foreground = first.foreground { + defaultForeground = foreground + } + } + + let colorMap = TextMateColorMap() + self.defaultForeground = colorMap.color(for: colorMap.id(for: defaultForeground)) + + let root = TextMateTrieElement(mainRule: TextMateTrieRule(scopeDepth: 0, parentScopes: nil, foreground: 0)) + for rule in remaining { + root.insert(scope: rule.scope, parentScopes: rule.parentScopes, foreground: colorMap.id(for: rule.foreground)) + } + self.colorMap = colorMap + self.root = root + } + + // `scopes` runs from the grammar's root scope to the token's own, the order a grammar pushes them. + // Nil when no rule sets a foreground, so the token keeps the default. + internal func foreground(forScopes scopes: [String]) -> String? { + var foreground = 0 + for depth in scopes.indices { + let parentPath = Array(scopes[.. TextMateTrieRule? { + root.match(scopeName).first { Self.scopePath(parentPath, matchesParentScopes: $0.parentScopes) } + } + + // `path` is innermost first. `>` is the child combinator; `-` has no meaning here. + private static func scopePath(_ path: [String], matchesParentScopes parentScopes: [String]) -> Bool { + guard !parentScopes.isEmpty else { return true } + + var position = 0 + var index = 0 + while index < parentScopes.count { + var pattern = parentScopes[index] + var mustMatch = false + if pattern == ">" { + guard index < parentScopes.count - 1 else { return false } + index += 1 + pattern = parentScopes[index] + mustMatch = true + } + + while position < path.count { + if TextMateText.matchesScope(path[position], pattern: pattern) { + break + } + if mustMatch { + return false + } + position += 1 + } + guard position < path.count else { return false } + position += 1 + index += 1 + } + return true + } + + private struct ParsedRule { + let scope: String + let parentScopes: [String]? + let index: Int + let foreground: String? + } + + private static func parse(_ rules: [TextMateRule]) throws -> [ParsedRule] { + var result: [ParsedRule] = [] + for (index, rule) in rules.enumerated() { + let selectors: [String] + switch rule.scope { + case let .selector(selector): + selectors = TextMateText.trimmingCommas(selector).components(separatedBy: ",") + case let .selectors(list): + selectors = list + case .unscoped: + selectors = [""] + } + + let foreground = rule.foreground.flatMap { VSCodeColorSyntax.normalized($0) == nil ? nil : $0 } + + for selector in selectors { + let trimmed = selector.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.utf16.count <= maximumSelectorLength else { + throw ThemeLoadError.tokenScopeTooLong(limit: maximumSelectorLength) + } + let segments = trimmed.components(separatedBy: " ") + guard segments.allSatisfy({ TextMateText.partCount($0) <= maximumScopeParts }) else { + throw ThemeLoadError.tokenScopeHasTooManyParts(limit: maximumScopeParts) + } + let parentScopes = segments.count > 1 ? Array(segments.dropLast().reversed()) : nil + result.append( + ParsedRule(scope: segments.last ?? "", parentScopes: parentScopes, index: index, foreground: foreground) + ) + guard result.count <= maximumRuleCount else { + throw ThemeLoadError.tooManyTokenRules(limit: maximumRuleCount) + } + } + } + return result + } +} + +private final class TextMateColorMap { + private var colors: [String] = [""] + private var ids: [String: Int] = [:] + + func id(for color: String?) -> Int { + guard let color else { return 0 } + let key = color.uppercased() + if let existing = ids[key] { + return existing + } + colors.append(key) + ids[key] = colors.count - 1 + return colors.count - 1 + } + + func color(for id: Int) -> String { + colors[id] + } +} + +// A value, where vscode-textmate has an object it clones: a child node starts as a copy of its +// parent's rules, and copy-on-write defers that copy until a rule lands on the child itself. +private struct TextMateTrieRule { + var scopeDepth: Int + let parentScopes: [String] + var foreground: Int + + init(scopeDepth: Int, parentScopes: [String]?, foreground: Int) { + self.scopeDepth = scopeDepth + self.parentScopes = parentScopes ?? [] + self.foreground = foreground + } + + mutating func acceptOverwrite(scopeDepth: Int, foreground: Int) { + if self.scopeDepth <= scopeDepth { + self.scopeDepth = scopeDepth + } + if foreground != 0 { + self.foreground = foreground + } + } +} + +// vscode-textmate walks the trie by recursion, one call per scope part. Here both walks are loops, +// so a file's scope sets no stack depth. +private final class TextMateTrieElement { + private var mainRule: TextMateTrieRule + private var rulesWithParentScopes: [TextMateTrieRule] + private var children: [String: TextMateTrieElement] = [:] + + init(mainRule: TextMateTrieRule, rulesWithParentScopes: [TextMateTrieRule] = []) { + self.mainRule = mainRule + self.rulesWithParentScopes = rulesWithParentScopes + } + + func match(_ scope: String) -> [TextMateTrieRule] { + var node = self + var remaining = Substring(scope) + while !remaining.isEmpty { + let (head, tail) = TextMateText.splitFirstSegment(remaining) + guard let child = node.children[String(head)] else { break } + node = child + remaining = tail + } + return node.rulesBySpecificity() + } + + func insert(scope: String, parentScopes: [String]?, foreground: Int) { + var node = self + var scopeDepth = 0 + var remaining = Substring(scope) + while !remaining.isEmpty { + let (head, tail) = TextMateText.splitFirstSegment(remaining) + node = node.child(named: String(head)) + scopeDepth += 1 + remaining = tail + } + node.insertHere(scopeDepth: scopeDepth, parentScopes: parentScopes, foreground: foreground) + } + + // Array.prototype.sort is stable, so equally specific rules keep their insertion order. + private func rulesBySpecificity() -> [TextMateTrieRule] { + (rulesWithParentScopes + [mainRule]) + .enumerated() + .sorted { lhs, rhs in + let order = Self.compareBySpecificity(lhs.element, rhs.element) + return order != 0 ? order < 0 : lhs.offset < rhs.offset + } + .map(\.element) + } + + private func child(named name: String) -> TextMateTrieElement { + if let existing = children[name] { + return existing + } + let child = TextMateTrieElement(mainRule: mainRule, rulesWithParentScopes: rulesWithParentScopes) + children[name] = child + return child + } + + private func insertHere(scopeDepth: Int, parentScopes: [String]?, foreground: Int) { + guard let parentScopes else { + mainRule.acceptOverwrite(scopeDepth: scopeDepth, foreground: foreground) + return + } + + if let index = rulesWithParentScopes.firstIndex(where: { TextMateText.compare($0.parentScopes, parentScopes) == 0 }) { + rulesWithParentScopes[index].acceptOverwrite(scopeDepth: scopeDepth, foreground: foreground) + return + } + + rulesWithParentScopes.append( + TextMateTrieRule( + scopeDepth: scopeDepth, + parentScopes: parentScopes, + foreground: foreground == 0 ? mainRule.foreground : foreground + ) + ) + } + + // Deeper scope first, then the longer parent at each level from the innermost, then more parents. + private static func compareBySpecificity(_ lhs: TextMateTrieRule, _ rhs: TextMateTrieRule) -> Int { + if lhs.scopeDepth != rhs.scopeDepth { + return rhs.scopeDepth - lhs.scopeDepth + } + + var lhsIndex = 0 + var rhsIndex = 0 + while true { + if lhsIndex < lhs.parentScopes.count, lhs.parentScopes[lhsIndex] == ">" { + lhsIndex += 1 + } + if rhsIndex < rhs.parentScopes.count, rhs.parentScopes[rhsIndex] == ">" { + rhsIndex += 1 + } + guard lhsIndex < lhs.parentScopes.count, rhsIndex < rhs.parentScopes.count else { break } + + let lengthDifference = rhs.parentScopes[rhsIndex].utf16.count - lhs.parentScopes[lhsIndex].utf16.count + if lengthDifference != 0 { + return lengthDifference + } + lhsIndex += 1 + rhsIndex += 1 + } + return rhs.parentScopes.count - lhs.parentScopes.count + } +} + +private enum TextMateText { + // utils.ts strcmp on UTF-16 code units. + static func compare(_ lhs: String, _ rhs: String) -> Int { + var left = lhs.utf16.makeIterator() + var right = rhs.utf16.makeIterator() + while true { + switch (left.next(), right.next()) { + case (nil, nil): return 0 + case (nil, _): return -1 + case (_, nil): return 1 + case let (leftUnit?, rightUnit?) where leftUnit != rightUnit: return leftUnit < rightUnit ? -1 : 1 + default: continue + } + } + } + + // utils.ts strArrCmp: nil first, then the shorter list, then element by element. + static func compare(_ lhs: [String]?, _ rhs: [String]?) -> Int { + switch (lhs, rhs) { + case (nil, nil): + return 0 + case (nil, _): + return -1 + case (_, nil): + return 1 + case let (left?, right?): + guard left.count == right.count else { return left.count - right.count } + for (leftItem, rightItem) in zip(left, right) { + let order = compare(leftItem, rightItem) + if order != 0 { + return order + } + } + return 0 + } + } + + // `keyword` matches `keyword` and `keyword.control`, never `keywords`. + static func matchesScope(_ scopeName: String, pattern: String) -> Bool { + guard scopeName.utf16.starts(with: pattern.utf16) else { return false } + let remainder = scopeName.utf16.dropFirst(pattern.utf16.count) + return remainder.isEmpty || remainder.first == UInt16(UInt8(ascii: ".")) + } + + // indexOf('.') finds a dot a combining mark follows, which a Character search would not. + static func splitFirstSegment(_ scope: Substring) -> (head: Substring, tail: Substring) { + guard let dot = scope.utf8.firstIndex(of: dotByte) else { return (scope, scope[scope.endIndex...]) } + return (scope[.. Int { + scope.utf8.lazy.filter { $0 == dotByte }.count + 1 + } + + private static let dotByte = UInt8(ascii: ".") + + static func trimmingCommas(_ selector: String) -> String { + var trimmed = Substring(selector) + while trimmed.first == "," { + trimmed = trimmed.dropFirst() + } + while trimmed.last == "," { + trimmed = trimmed.dropLast() + } + return String(trimmed) + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeColorSyntax.swift b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeColorSyntax.swift new file mode 100644 index 0000000000..b74ecba18e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeColorSyntax.swift @@ -0,0 +1,44 @@ +import Foundation + +// VS Code writes `#RGB`, `#RGBA`, `#RRGGBB` and `#RRGGBBAA`; the TablePro format only the long forms. +internal enum VSCodeColorSyntax { + // colorThemeData.ts normalizeColor: uppercase, short forms doubled, an FF alpha dropped. + internal static func normalized(_ raw: String) -> String? { + let digits = Array(raw.utf8.dropFirst()) + guard raw.utf8.first == UInt8(ascii: "#"), [3, 4, 6, 8].contains(digits.count) else { return nil } + + var result = "#" + for byte in digits { + guard let digit = uppercaseHexDigit(byte) else { return nil } + result.append(digit) + if digits.count <= 4 { + result.append(digit) + } + } + if result.utf8.count == 9, result.hasSuffix("FF") { + result.removeLast(2) + } + return result + } + + internal static func color(_ raw: String) -> ThemeColor? { + normalized(raw).flatMap(ThemeColor.init(hex:)) + } + + // colorUtils.ts transparent(): the alpha scaled, the color kept. + internal static func transparent(_ color: ThemeColor, factor: Double) -> ThemeColor { + let alpha = (Double(color.alpha) * factor).rounded() + return ThemeColor(red: color.red, green: color.green, blue: color.blue, alpha: UInt8(min(max(alpha, 0), 255))) + } + + private static func uppercaseHexDigit(_ byte: UInt8) -> Character? { + switch byte { + case UInt8(ascii: "0") ... UInt8(ascii: "9"), UInt8(ascii: "A") ... UInt8(ascii: "F"): + return Character(Unicode.Scalar(byte)) + case UInt8(ascii: "a") ... UInt8(ascii: "f"): + return Character(Unicode.Scalar(byte - 32)) + default: + return nil + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeImporter.swift b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeImporter.swift new file mode 100644 index 0000000000..1e9b13c5b3 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeImporter.swift @@ -0,0 +1,180 @@ +import Foundation + +// Turns a VS Code color theme into a TablePro theme. Only the result is kept: the catalog saves it +// in the TablePro format, and the VS Code file is never read again. +internal struct VSCodeThemeImporter { + private static let themeFileSuffix = "-color-theme" + + private let fileURL: URL + + internal init(fileURL: URL) { + self.fileURL = fileURL + } + + internal func makeTheme(from root: [String: VSCodeThemeValue], id: String) throws -> ThemeDefinition { + var source = try VSCodeThemeLoader(fileURL: fileURL).load(root: root) + let type = VSCodeThemeType(typeName: source.typeName, editorBackground: source.colors["editor.background"]) + + // Everything else is flattened over the editor background, which itself sits on nothing themed. + if let background = source.colors["editor.background"], !background.isOpaque { + source.colors["editor.background"] = background.composited(over: type.defaultEditorBackground) + } + let backdrop = source.colors["editor.background"] ?? type.defaultEditorBackground + let defaultTokenForeground = source.colors["editor.foreground"] ?? type.defaultEditorForeground + + var evaluator = VSCodeSlotEvaluator( + colors: source.colors, + resolver: try TextMateThemeResolver(rules: Self.textMateRules(source.tokenRules, defaultForeground: defaultTokenForeground)) + ) + + var overrides: [ThemeSlot: ThemeColor] = [:] + var hasThemeColor = false + for slot in ThemeSlot.allCases { + guard let resolution = evaluator.resolution(for: slot) else { continue } + let color = resolution.color + overrides[slot] = slot.requiresOpaque && !color.isOpaque ? color.composited(over: backdrop) : color + hasThemeColor = hasThemeColor || resolution.isFromTheme + } + guard hasThemeColor else { throw ThemeLoadError.noUsableColors } + + let theme = ThemeDefinition( + id: id, + name: try themeName(source.name), + author: source.author.flatMap(Self.singleLine) ?? "", + appearance: type.appearance, + overrides: overrides + ) + return theme.withoutDerivableColors() + } + + // colorThemeData.ts tokenColors: the editor's own colors as the default rule, then every rule with + // a scope and settings, foregrounds normalized. An unscoped rule in a JSON theme is dropped. + private static func textMateRules(_ values: [VSCodeThemeValue], defaultForeground: ThemeColor) -> [TextMateRule] { + var rules = [TextMateRule(scope: .unscoped, foreground: defaultForeground.hex)] + for value in values { + guard + let entry = value.objectValue, + let scope = entry["scope"], scope.isTruthy, + let settings = entry["settings"], settings.isTruthy + else { continue } + + let foreground = settings.objectValue?["foreground"]?.stringValue.flatMap(VSCodeColorSyntax.normalized) + rules.append(TextMateRule(scope: textMateScope(scope), foreground: foreground)) + } + return rules + } + + private static func textMateScope(_ value: VSCodeThemeValue) -> TextMateRule.Scope { + switch value { + case let .string(selector): + return .selector(selector) + case let .array(items): + return .selectors(items.compactMap(\.stringValue)) + default: + return .unscoped + } + } + + // VS Code labels a theme from its package.json and falls back to the file name. A lone file has + // only its own `name`, so the file name comes next, without `.json` and the `-color-theme` habit. + private func themeName(_ declared: String?) throws -> String { + var stem = fileURL.pathExtension.lowercased() == "json" + ? fileURL.deletingPathExtension().lastPathComponent + : fileURL.lastPathComponent + if let suffix = stem.range(of: Self.themeFileSuffix, options: [.caseInsensitive, .anchored, .backwards]) { + stem.removeSubrange(suffix) + } + + for candidate in [declared, stem, fileURL.lastPathComponent] { + if let name = candidate.flatMap(Self.singleLine) { + return name + } + } + throw ThemeLoadError.invalidName + } + + // The format takes one line of at most `maximumTextLength` characters, so line breaks and control + // characters become spaces and a long name is cut rather than refused. + private static func singleLine(_ text: String) -> String? { + let separators = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) + let words = text.components(separatedBy: separators).filter { !$0.isEmpty } + let line = String(words.joined(separator: " ").prefix(ThemeFileFormat.maximumTextLength)) + .trimmingCharacters(in: .whitespaces) + return line.isEmpty ? nil : line + } +} + +private struct VSCodeSlotResolution { + let color: ThemeColor + // False for VS Code's own default token color, which says nothing about this theme. + let isFromTheme: Bool +} + +private struct VSCodeSlotEvaluator { + let colors: [String: ThemeColor] + let resolver: TextMateThemeResolver + private var resolved: [ThemeSlot: VSCodeSlotResolution?] = [:] + private var visiting: Set = [] + + init(colors: [String: ThemeColor], resolver: TextMateThemeResolver) { + self.colors = colors + self.resolver = resolver + } + + mutating func resolution(for slot: ThemeSlot) -> VSCodeSlotResolution? { + if let cached = resolved[slot] { + return cached + } + guard visiting.insert(slot).inserted else { return nil } + defer { visiting.remove(slot) } + + var result: VSCodeSlotResolution? + for source in slot.vscodeSources { + result = resolution(of: source) + if result != nil { + break + } + } + resolved[slot] = .some(result) + return result + } + + private mutating func resolution(of source: VSCodeColorSource) -> VSCodeSlotResolution? { + switch source { + case let .color(key): + return colors[key].map { VSCodeSlotResolution(color: $0, isFromTheme: true) } + case let .derived(key, alpha): + return colors[key].map { VSCodeSlotResolution(color: VSCodeColorSyntax.transparent($0, factor: alpha), isFromTheme: true) } + case let .token(scopes): + return resolver.foreground(forScopes: scopes) + .flatMap(ThemeColor.init(hex:)) + .map { VSCodeSlotResolution(color: $0, isFromTheme: true) } + case let .slot(other): + return resolution(for: other) + case .defaultTokenForeground: + return ThemeColor(hex: resolver.defaultForeground).map { VSCodeSlotResolution(color: $0, isFromTheme: false) } + } + } +} + +private extension ThemeDefinition { + // A slot whose fallback already draws the imported color stays unset, so in the editor it keeps + // following the slot it derives from. Syntax is always written: an unset syntax slot draws + // Xcode's color, not the theme's text. + func withoutDerivableColors() -> ThemeDefinition { + var result = self + for slot in ThemeSlot.allCases where slot.section != .syntax && result.hasExplicitColor(slot) { + switch slot.fallback { + case .system, .defaultSyntax: + continue + case .slot, .derived, .paired: + var unset = result + unset.overrides[slot] = nil + if unset.value(for: slot) == result.value(for: slot) { + result = unset + } + } + } + return result + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeLoader.swift b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeLoader.swift new file mode 100644 index 0000000000..57f6380c96 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeLoader.swift @@ -0,0 +1,183 @@ +import Foundation + +internal struct VSCodeThemeSource: Equatable, Sendable { + internal var name: String? + internal var author: String? + internal var typeName: String? + internal var colors: [String: ThemeColor] = [:] + // In file order, includes first, as VS Code collects them. Filtering happens where they are read. + internal var tokenRules: [VSCodeThemeValue] = [] +} + +// colorThemeData.ts _loadColorTheme and _loadSyntaxTokens, and themeCompatibility.ts convertSettings, +// plus the limits an untrusted file needs. VS Code has none of them because it trusts extensions. +// Every file the theme names must sit in the chosen file's folder, after symlinks are resolved. +internal struct VSCodeThemeLoader { + // Real themes nest at most 3 levels: Dark Modern, Dark+, Dark (Visual Studio). + internal static let maximumIncludeDepth = 8 + + // themeCompatibility.ts settingToColorIdMapping, for the colors a slot reads. + private static let globalSettingColors: [String: String] = [ + "background": "editor.background", + "foreground": "editor.foreground", + "selection": "editor.selectionBackground", + "lineHighlight": "editor.lineHighlightBackground", + "rangeHighlight": "editor.rangeHighlightBackground", + "caret": "editorCursor.foreground", + "invisibles": "editorWhitespace.foreground", + ] + + private let fileURL: URL + private let folder: URL + + internal init(fileURL: URL) { + self.fileURL = fileURL + folder = fileURL.deletingLastPathComponent().resolvingSymlinksInPath() + } + + internal func load(root: [String: VSCodeThemeValue]) throws -> VSCodeThemeSource { + var source = VSCodeThemeSource( + name: root["name"]?.stringValue, + author: root["author"]?.stringValue, + typeName: root["type"]?.stringValue + ) + var visited: Set = [fileURL.resolvingSymlinksInPath().path] + try apply(root, directory: folder, depth: 0, visited: &visited, to: &source) + return source + } + + private func apply( + _ theme: [String: VSCodeThemeValue], + directory: URL, + depth: Int, + visited: inout Set, + to source: inout VSCodeThemeSource + ) throws { + if let include = theme["include"], include.isTruthy { + guard let path = include.stringValue else { throw ThemeLoadError.notATheme } + try loadInclude(path, from: directory, depth: depth + 1, visited: &visited, into: &source) + } + + // The legacy shape: a tmTheme's settings inline, and nothing else in the file is read. + if let settings = theme["settings"]?.arrayValue { + convertSettings(settings, into: &source) + return + } + + if let colors = theme["colors"], colors.isTruthy { + switch colors { + case let .object(entries): + merge(entries, into: &source) + case .array: + // JavaScript accepts an array as an object here; no color id is an index. + break + default: + throw ThemeLoadError.invalidColorMap + } + } + + if let tokenColors = theme["tokenColors"], tokenColors.isTruthy { + switch tokenColors { + case let .array(rules): + source.tokenRules.append(contentsOf: rules) + case let .string(path): + try loadTextMateTheme(at: try containedFile(path, from: directory), named: path, into: &source) + default: + throw ThemeLoadError.invalidTokenColors + } + } + } + + private func merge(_ entries: [String: VSCodeThemeValue], into source: inout VSCodeThemeSource) { + for (key, value) in entries { + guard let raw = value.stringValue else { continue } + // "default" drops what an include set. VS Code paints an unparseable color red over the + // included one; here the slot moves on to its next source instead. + source.colors[key] = raw == "default" ? nil : VSCodeColorSyntax.color(raw) + } + } + + private func loadInclude( + _ path: String, + from directory: URL, + depth: Int, + visited: inout Set, + into source: inout VSCodeThemeSource + ) throws { + guard depth <= Self.maximumIncludeDepth else { + throw ThemeLoadError.includeTooDeep(limit: Self.maximumIncludeDepth) + } + let url = try containedFile(path, from: directory) + guard visited.insert(url.path).inserted else { throw ThemeLoadError.includeCycle(path) } + + guard url.pathExtension.lowercased() == "json" else { + try loadTextMateTheme(at: url, named: path, into: &source) + return + } + + let value: VSCodeThemeValue + do { + value = try VSCodeThemeValue.parse(try ThemeFileImporter.contents(of: url)) + } catch let ThemeLoadError.malformedJSON(detail) { + throw ThemeLoadError.malformedJSON(detail: [path, detail].compactMap { $0 }.joined(separator: ": ")) + } + guard let theme = value.objectValue else { throw ThemeLoadError.notATheme } + try apply(theme, directory: url.deletingLastPathComponent(), depth: depth, visited: &visited, to: &source) + } + + // XML only, as VS Code reads it: a binary property list can share one container between many + // parents, so a few hundred bytes can stand for a tree of billions of values. + private func loadTextMateTheme(at url: URL, named path: String, into source: inout VSCodeThemeSource) throws { + let data = try ThemeFileImporter.contents(of: url) + var format = PropertyListSerialization.PropertyListFormat.binary + guard + let propertyList = try? PropertyListSerialization.propertyList(from: data, options: [], format: &format), + format == .xml, + let settings = VSCodeThemeValue(propertyList: propertyList)?.objectValue?["settings"]?.arrayValue + else { + throw ThemeLoadError.invalidTextMateTheme(path) + } + convertSettings(settings, into: &source) + } + + // A rule without a scope carries the editor's own colors in a tmTheme. It stays in the rule list, + // where the tokenColors filter drops it, as VS Code does. + private func convertSettings(_ settings: [VSCodeThemeValue], into source: inout VSCodeThemeSource) { + for rule in settings { + source.tokenRules.append(rule) + guard + let entry = rule.objectValue, + !(entry["scope"]?.isTruthy ?? false), + let global = entry["settings"]?.objectValue + else { continue } + + for (key, value) in global { + guard let colorKey = Self.globalSettingColors[key], let raw = value.stringValue else { continue } + source.colors[colorKey] = VSCodeColorSyntax.color(raw) + } + } + } + + // Lexical first, so `../` is refused even when the target is missing; then again after symlinks, + // so a link inside the folder cannot point out of it. + private func containedFile(_ path: String, from directory: URL) throws -> URL { + guard !path.hasPrefix("/"), !path.hasPrefix("~") else { throw ThemeLoadError.includeOutsideFolder(path) } + + let candidate = directory.appendingPathComponent(path).standardizedFileURL + guard isInsideFolder(candidate) else { throw ThemeLoadError.includeOutsideFolder(path) } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + throw ThemeLoadError.includeMissing(path) + } + + let resolved = candidate.resolvingSymlinksInPath() + guard isInsideFolder(resolved) else { throw ThemeLoadError.includeOutsideFolder(path) } + return resolved + } + + private func isInsideFolder(_ url: URL) -> Bool { + let base = folder.path.hasSuffix("/") ? folder.path : folder.path + "/" + return url.path.hasPrefix(base) + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift new file mode 100644 index 0000000000..f59fdb5a22 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift @@ -0,0 +1,123 @@ +import Foundation + +// The first source a theme answers wins. +internal enum VSCodeColorSource: Equatable, Sendable { + case color(String) + // VS Code's own default for the job is this color through `transparent()`. + case derived(String, alpha: Double) + // Root scope first, the order a grammar pushes them. + case token([String]) + case slot(ThemeSlot) + // What VS Code paints a token no rule colors: `editor.foreground`, or its registry default. + case defaultTokenForeground +} + +// The scope stacks VS Code's own SQL grammar pushes (extensions/sql/syntaxes/sql.tmLanguage.json). +// Function and type take the order of VS Code's token groups instead: the type slot paints every +// table name, and `storage.type` resolves to the keyword color in most themes. +private extension VSCodeColorSource { + static func sqlToken(_ scope: String) -> VSCodeColorSource { + .token(["source.sql", scope]) + } +} + +internal extension ThemeSlot { + // An empty list leaves the slot to its fallback. So does a source list that stops short: the + // alternate row, the header, the header text, the bools and the selection text are derived by + // `fallback` from the background, text and selection, which is what VS Code paints there too. + var vscodeSources: [VSCodeColorSource] { + switch self { + case .windowBackdrop: + // The region behind the editor. Not the side bar: GitHub Dark's is darker than its editor. + return [.color("editorPane.background"), .color("editor.background")] + case .editorBackground: + return [.color("editor.background")] + case .editorText: + return [.color("editor.foreground")] + case .editorCursor: + return [.color("editorCursor.foreground")] + case .editorSelection: + return [.color("editor.selectionBackground")] + case .editorCurrentLine: + // Dracula draws its current line only as a border. + return [.color("editor.lineHighlightBackground"), .color("editor.lineHighlightBorder")] + case .editorCurrentStatement: + // VS Code has no statement band; this is its band for highlighted ranges. + return [.color("editor.rangeHighlightBackground")] + case .editorLineNumber: + return [.color("editorLineNumber.foreground")] + case .editorInvisibles: + return [.color("editorWhitespace.foreground")] + + case .syntaxKeyword: + return [.sqlToken("keyword.other.DML.sql"), .defaultTokenForeground] + case .syntaxString: + return [.sqlToken("string.quoted.single.sql"), .defaultTokenForeground] + case .syntaxNumber: + return [.sqlToken("constant.numeric.sql"), .defaultTokenForeground] + case .syntaxComment: + return [.sqlToken("comment.line.double-dash.sql"), .defaultTokenForeground] + case .syntaxNull: + // The SQL grammar scopes NULL as a keyword, and TablePro's defaults draw it in the keyword color. + return [.sqlToken("constant.language.null.sql"), .slot(.syntaxKeyword)] + case .syntaxOperator: + return [.sqlToken("keyword.operator.comparison.sql"), .defaultTokenForeground] + case .syntaxFunction: + return [ + .sqlToken("entity.name.function.sql"), + .sqlToken("support.function.aggregate.sql"), + .defaultTokenForeground, + ] + case .syntaxType: + return [ + .sqlToken("entity.name.type.sql"), + .sqlToken("entity.name.class.sql"), + .sqlToken("support.type.sql"), + .sqlToken("support.class.sql"), + .sqlToken("storage.type.sql"), + .defaultTokenForeground, + ] + + case .gridBackground: + return [.color("editor.background")] + case .gridText: + return [.color("editor.foreground")] + case .gridAlternateRow: + return [.color("keybindingTable.rowsBackground"), .color("tree.tableOddRowsBackground")] + case .gridHeaderBackground: + return [.color("keybindingTable.headerBackground"), .color("tree.tableOddRowsBackground")] + case .gridHeaderText: + return [.color("foreground")] + case .gridLine: + return [.color("tree.tableColumnsBorder")] + case .gridSelection: + return [.color("list.activeSelectionBackground")] + case .gridSelectionText: + return [.color("list.activeSelectionForeground")] + case .gridInactiveSelection: + return [.color("list.inactiveSelectionBackground")] + case .gridFocusBorder: + return [.color("list.focusOutline"), .color("focusBorder")] + case .gridNullValue: + // The grid draws NULL as placeholder text. `descriptionForeground` equals the text color in + // One Dark Pro, so NULL would not read as absent. + return [.color("input.placeholderForeground"), .derived("foreground", alpha: 0.5)] + case .gridBoolTrue: + return [.sqlToken("constant.language.boolean.true.sql")] + case .gridBoolFalse: + return [.sqlToken("constant.language.boolean.false.sql")] + case .gridRowNumber: + return [.color("editorLineNumber.foreground")] + case .gridModified: + // VS Code has no modified-row fill, so the theme's modified hue takes the alpha of TablePro's own. + return [.derived("gitDecoration.modifiedResourceForeground", alpha: 0.3)] + case .gridInserted: + return [.color("diffEditor.insertedLineBackground"), .color("diffEditor.insertedTextBackground")] + case .gridDeleted: + return [.color("diffEditor.removedLineBackground"), .color("diffEditor.removedTextBackground")] + case .gridDeletedText: + // As modified: the hue of a deleted file, the alpha of TablePro's own deleted text. + return [.derived("gitDecoration.deletedResourceForeground", alpha: 0.5)] + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeType.swift b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeType.swift new file mode 100644 index 0000000000..5dabdf9208 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeType.swift @@ -0,0 +1,60 @@ +import Foundation + +// VS Code takes the base theme from the extension's package.json `uiTheme`, which a lone theme file +// does not carry. A `type` key, which "Generate Color Theme From Current Settings" writes, is the +// best hint, then the editor background's luminance, then VS Code's own default, dark. +internal enum VSCodeThemeType: Equatable, Sendable { + case light + case dark + case highContrastLight + case highContrastDark + + // The point where a background contrasts equally with black and white text. + private static let lightLuminanceThreshold = 0.179 + + internal init?(name: String) { + switch name.lowercased() { + case "light", "vs": self = .light + case "dark", "vs-dark": self = .dark + case "hclight", "hc-light": self = .highContrastLight + case "hcdark", "hc", "hc-black": self = .highContrastDark + default: return nil + } + } + + internal init(typeName: String?, editorBackground: ThemeColor?) { + if let typeName, let named = VSCodeThemeType(name: typeName) { + self = named + } else if let editorBackground { + self = editorBackground.relativeLuminance > Self.lightLuminanceThreshold ? .light : .dark + } else { + self = .dark + } + } + + internal var appearance: ThemeAppearance { + switch self { + case .light, .highContrastLight: return .light + case .dark, .highContrastDark: return .dark + } + } + + // colorRegistry `editor.background` defaults: what a translucent editor background sits on. + internal var defaultEditorBackground: ThemeColor { + switch self { + case .light, .highContrastLight: return .white + case .dark: return ThemeColor(red: 0x1E, green: 0x1E, blue: 0x1E) + case .highContrastDark: return .black + } + } + + // colorRegistry `editor.foreground` defaults: the token color a theme without one paints. + internal var defaultEditorForeground: ThemeColor { + switch self { + case .light: return ThemeColor(red: 0x33, green: 0x33, blue: 0x33) + case .dark: return ThemeColor(red: 0xBB, green: 0xBB, blue: 0xBB) + case .highContrastLight: return ThemeColor(red: 0x29, green: 0x29, blue: 0x29) + case .highContrastDark: return .white + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeValue.swift b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeValue.swift new file mode 100644 index 0000000000..957b7cfcee --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeValue.swift @@ -0,0 +1,152 @@ +import Foundation + +// A value from a file another editor wrote: a VS Code theme (JSON with comments) or a TextMate +// theme (a property list). The import reads it leniently, the way VS Code does. +internal indirect enum VSCodeThemeValue: Equatable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case null + case array([VSCodeThemeValue]) + case object([String: VSCodeThemeValue]) + + // Arrays and objects, counting the outermost. A real theme nests about 5. Conversion recurses once + // per level, and Foundation's own cap of 512 overflowed a 512 KB thread stack in a debug build. + internal static let maximumDepth = 32 + + // A tmTheme within the rule cap holds well under this many values. The budget also stops a graph + // whose containers repeat, which converts into a tree exponentially larger than the file. + internal static let maximumPropertyListValues = 100_000 + + // JSON5 is a superset of the JSONC VS Code reads, and the mode does not depend on the OS release. + internal static func parse(_ data: Data) throws -> VSCodeThemeValue { + let decoder = JSONDecoder() + decoder.allowsJSON5 = true + do { + return try decoder.decode(VSCodeThemeValue.self, from: data) + } catch let error as ThemeLoadError { + throw error + } catch let DecodingError.dataCorrupted(context) { + let underlying = context.underlyingError as NSError? + throw ThemeLoadError.malformedJSON(detail: underlying?.userInfo[NSDebugDescriptionErrorKey] as? String) + } catch { + throw ThemeLoadError.malformedJSON(detail: nil) + } + } + + // Nil past the depth cap or the value budget. + internal init?(propertyList value: Any) { + var remainingValues = Self.maximumPropertyListValues + guard let converted = Self.converted(propertyList: value, depth: 0, remainingValues: &remainingValues) else { + return nil + } + self = converted + } + + private static func converted(propertyList value: Any, depth: Int, remainingValues: inout Int) -> VSCodeThemeValue? { + guard remainingValues > 0 else { return nil } + remainingValues -= 1 + + if let string = value as? String { + return .string(string) + } + if let dictionary = value as? [String: Any] { + guard depth < maximumDepth else { return nil } + var object: [String: VSCodeThemeValue] = [:] + for (key, item) in dictionary { + guard let child = converted(propertyList: item, depth: depth + 1, remainingValues: &remainingValues) else { + return nil + } + object[key] = child + } + return .object(object) + } + if let array = value as? [Any] { + guard depth < maximumDepth else { return nil } + var items: [VSCodeThemeValue] = [] + for item in array { + guard let child = converted(propertyList: item, depth: depth + 1, remainingValues: &remainingValues) else { + return nil + } + items.append(child) + } + return .array(items) + } + if let number = value as? NSNumber { + return .number(number.doubleValue) + } + return .null + } + + internal var stringValue: String? { + guard case let .string(value) = self else { return nil } + return value + } + + internal var objectValue: [String: VSCodeThemeValue]? { + guard case let .object(value) = self else { return nil } + return value + } + + internal var arrayValue: [VSCodeThemeValue]? { + guard case let .array(value) = self else { return nil } + return value + } + + // VS Code tests most fields with JavaScript truthiness, so an empty string or a zero means unset. + internal var isTruthy: Bool { + switch self { + case let .string(value): return !value.isEmpty + case let .number(value): return value != 0 && !value.isNaN + case let .bool(value): return value + case .null: return false + case .array, .object: return true + } + } +} + +extension VSCodeThemeValue: Decodable { + // Elements decode with `try`, not `try?`, so a depth refusal deep inside reaches the caller rather + // than turning into a type mismatch one level up. + internal init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if decoder.codingPath.count >= Self.maximumDepth { + throw ThemeLoadError.nestingTooDeep(limit: Self.maximumDepth) + } else if var elements = try? decoder.unkeyedContainer() { + var items: [VSCodeThemeValue] = [] + while !elements.isAtEnd { + items.append(try elements.decode(VSCodeThemeValue.self)) + } + self = .array(items) + } else { + let members = try decoder.container(keyedBy: VSCodeThemeKey.self) + var object: [String: VSCodeThemeValue] = [:] + for key in members.allKeys { + object[key.stringValue] = try members.decode(VSCodeThemeValue.self, forKey: key) + } + self = .object(object) + } + } +} + +private struct VSCodeThemeKey: CodingKey { + let stringValue: String + + var intValue: Int? { nil } + + init(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + nil + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeAppearance.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeAppearance.swift new file mode 100644 index 0000000000..ed9dd981d5 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeAppearance.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum ThemeAppearance: String, Codable, CaseIterable, Sendable { + case light + case dark +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeColor.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeColor.swift new file mode 100644 index 0000000000..0510569795 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeColor.swift @@ -0,0 +1,129 @@ +import Foundation + +/// One color a theme declares, as 8-bit sRGB with alpha. It is a fixed value by design: a theme that +/// wants the system color leaves the slot out. +public struct ThemeColor: Hashable, Sendable { + public let red: UInt8 + public let green: UInt8 + public let blue: UInt8 + public let alpha: UInt8 + + public static let black = ThemeColor(red: 0, green: 0, blue: 0) + public static let white = ThemeColor(red: 255, green: 255, blue: 255) + + public init(red: UInt8, green: UInt8, blue: UInt8, alpha: UInt8 = 255) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + /// `#RRGGBB` or `#RRGGBBAA` and nothing else. Only ASCII digits: `Character.isHexDigit` is also + /// true for the fullwidth forms. + public init?(hex raw: String) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let bytes = Array(trimmed.utf8) + guard bytes.first == UInt8(ascii: "#"), bytes.count == 7 || bytes.count == 9 else { return nil } + + var channels: [UInt8] = [] + var index = 1 + while index < bytes.count { + guard let high = Self.nibble(bytes[index]), let low = Self.nibble(bytes[index + 1]) else { return nil } + channels.append(high << 4 | low) + index += 2 + } + self.init(red: channels[0], green: channels[1], blue: channels[2], alpha: channels.count == 4 ? channels[3] : 255) + } + + public init(validating raw: String) throws { + guard let color = ThemeColor(hex: raw) else { throw ThemeLoadError.invalidColor(raw) } + self = color + } + + /// Uppercase, and six digits whenever the color is opaque, so one color has one spelling. + public var hex: String { + let rgb = Self.hexDigits(red) + Self.hexDigits(green) + Self.hexDigits(blue) + return "#" + (isOpaque ? rgb : rgb + Self.hexDigits(alpha)) + } + + public var isOpaque: Bool { + alpha == 255 + } + + public var opaque: ThemeColor { + ThemeColor(red: red, green: green, blue: blue) + } + + /// Source-over in sRGB, the way a translucent fill lands on what is under it. + public func composited(over base: ThemeColor) -> ThemeColor { + let sourceAlpha = Double(alpha) / 255 + let baseAlpha = Double(base.alpha) / 255 + let resultAlpha = sourceAlpha + baseAlpha * (1 - sourceAlpha) + guard resultAlpha > 0 else { return ThemeColor(red: 0, green: 0, blue: 0, alpha: 0) } + + func channel(_ source: UInt8, _ under: UInt8) -> UInt8 { + let value = (Double(source) * sourceAlpha + Double(under) * baseAlpha * (1 - sourceAlpha)) / resultAlpha + return Self.byte(value) + } + return ThemeColor( + red: channel(red, base.red), + green: channel(green, base.green), + blue: channel(blue, base.blue), + alpha: base.isOpaque ? 255 : Self.byte(resultAlpha * 255) + ) + } + + /// Each color component moved `fraction` of the way toward `other`. The alpha stays this color's. + public func mixed(with other: ThemeColor, fraction: Double) -> ThemeColor { + let amount = min(max(fraction, 0), 1) + func channel(_ from: UInt8, _ to: UInt8) -> UInt8 { + Self.byte(Double(from) + (Double(to) - Double(from)) * amount) + } + return ThemeColor( + red: channel(red, other.red), + green: channel(green, other.green), + blue: channel(blue, other.blue), + alpha: alpha + ) + } + + /// WCAG 2.x relative luminance of the color components. Alpha is ignored: composite first. + public var relativeLuminance: Double { + func linear(_ component: UInt8) -> Double { + let value = Double(component) / 255 + return value <= 0.03928 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue) + } + + /// WCAG 2.x contrast ratio, from 1 to 21, the same whichever color is first. + public func contrastRatio(with other: ThemeColor) -> Double { + let first = relativeLuminance + let second = other.relativeLuminance + return (max(first, second) + 0.05) / (min(first, second) + 0.05) + } + + private static func nibble(_ byte: UInt8) -> UInt8? { + switch byte { + case UInt8(ascii: "0") ... UInt8(ascii: "9"): return byte - UInt8(ascii: "0") + case UInt8(ascii: "A") ... UInt8(ascii: "F"): return byte - UInt8(ascii: "A") + 10 + case UInt8(ascii: "a") ... UInt8(ascii: "f"): return byte - UInt8(ascii: "a") + 10 + default: return nil + } + } + + private static func hexDigits(_ value: UInt8) -> String { + let digits = Array("0123456789ABCDEF") + return String([digits[Int(value >> 4)], digits[Int(value & 0x0F)]]) + } + + private static func byte(_ value: Double) -> UInt8 { + UInt8(min(max(value.rounded(), 0), 255)) + } +} + +extension ThemeColor: CustomStringConvertible { + public var description: String { + hex + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeDefinition.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeDefinition.swift new file mode 100644 index 0000000000..cb71b8ac5a --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeDefinition.swift @@ -0,0 +1,99 @@ +import Foundation + +/// A theme is a set of colors for the content views and the window backdrop. A slot it leaves out +/// draws what its `ThemeSlot.fallback` says, so Default Light and Default Dark declare nothing and +/// render exactly like a native app, following Accent, Highlight and Increase Contrast. +public struct ThemeDefinition: Identifiable, Equatable, Sendable { + public var id: String + public var name: String + public var author: String + public var appearance: ThemeAppearance + public var overrides: [ThemeSlot: ThemeColor] + + public static let builtInPrefix = "tablepro." + public static let userPrefix = "user." + + public init( + id: String, + name: String, + author: String, + appearance: ThemeAppearance, + overrides: [ThemeSlot: ThemeColor] + ) { + self.id = id + self.name = name + self.author = author + self.appearance = appearance + self.overrides = overrides + } + + public var isBuiltIn: Bool { + id.hasPrefix(Self.builtInPrefix) + } + + public var isEditable: Bool { + !isBuiltIn + } + + /// The file sets this slot. Only the theme editor asks: a derived slot draws a theme color the + /// file never wrote, and an explicit selection text draws nothing without a selection. + public func hasExplicitColor(_ slot: ThemeSlot) -> Bool { + overrides[slot] != nil + } + + /// The format has no increased-contrast variant, so a slot that yields to Increase Contrast draws + /// native there even where the theme sets it. + public func value(for slot: ThemeSlot, highContrast: Bool = false) -> ThemeSlotValue { + if slot.yieldsToIncreasedContrast, highContrast { + return .system(slot) + } + if let explicit = overrides[slot], allowsExplicitColor(for: slot, highContrast: highContrast) { + return .themed(slot.requiresOpaque ? explicit.opaque : explicit) + } + return fallbackValue(for: slot, highContrast: highContrast) + } + + /// The slot draws a theme color, set or derived. Every surface that swaps a native drawing path + /// for the theme's asks this. + public func isOverridden(_ slot: ThemeSlot, highContrast: Bool) -> Bool { + value(for: slot, highContrast: highContrast).isThemed + } + + public func isOverridden(_ slot: ThemeSlot) -> Bool { + isOverridden(slot, highContrast: false) + } + + private func allowsExplicitColor(for slot: ThemeSlot, highContrast: Bool) -> Bool { + guard case let .paired(partner, _) = slot.fallback else { return true } + return value(for: partner, highContrast: highContrast).isThemed + } + + private func fallbackValue(for slot: ThemeSlot, highContrast: Bool) -> ThemeSlotValue { + switch slot.fallback { + case .system: + return .system(slot) + case .defaultSyntax: + return .defaultSyntax(slot) + case let .slot(source): + guard let color = value(for: source, highContrast: highContrast).themedColor else { return .system(slot) } + return .themed(color) + case let .derived(base, rule): + guard let baseColor = value(for: base, highContrast: highContrast).themedColor else { return .system(slot) } + return .themed(derive(rule, from: baseColor, highContrast: highContrast)) + case let .paired(partner, fallback): + guard value(for: partner, highContrast: highContrast).isThemed else { return .system(slot) } + return value(for: fallback, highContrast: highContrast) + } + } + + private func derive(_ rule: ThemeDerivationRule, from base: ThemeColor, highContrast: Bool) -> ThemeColor { + switch rule { + case .same: + return base + case let .stripe(toward): + let target = value(for: toward, highContrast: highContrast).themedColor + ?? (appearance == .dark ? .white : .black) + return base.mixed(with: target, fraction: ThemeDerivationRule.stripeFraction).opaque + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift new file mode 100644 index 0000000000..26f6da07cf --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift @@ -0,0 +1,269 @@ +import Foundation + +/// Format 3 dropped the pane and status groups, made every color optional and removed `system:` +/// values. Format 2 never shipped in a release, and format 1 files carried fonts the app now keeps in +/// settings. +/// +/// Adding a slot bumps `current`. The decoder keeps reading every older format, and an unknown key +/// stays an error: a file is refused rather than loaded without colors its author wrote. +public enum ThemeSchema { + public static let current = 3 +} + +public enum ThemeIdentifier { + public static let maximumLength = 128 + + private static let pattern = #"^[A-Za-z0-9._-]+$"# + + /// Plain enough to be a file name on every file system TablePro writes to. + public static func isValid(_ id: String) -> Bool { + id.utf8.count <= maximumLength + && id.range(of: pattern, options: .regularExpression) != nil + && !id.contains("..") + } + + public static func generated() -> String { + ThemeDefinition.userPrefix + UUID().uuidString.lowercased().prefix(8) + } +} + +/// The theme file format, and the only place a file can be rejected. Every color is optional: a +/// slot the file leaves out draws its fallback. What is refused is anything that would otherwise +/// load as something the author did not write: an unknown key, an unparseable color, missing +/// metadata, another format. +/// +/// Foundation keeps the first of two equal keys at one level, so that duplicate cannot be seen. +public enum ThemeFileFormat { + public static let maximumTextLength = 100 + + /// What `decode` does with a name or author that is not one line of at most `maximumTextLength`. + public enum TextPolicy: Sendable { + /// Refuses it: a file someone picked, and every record another Mac sent. + case strict + /// Folds it onto one line and cuts it at the cap. TablePro 0.81 wrote names and authors with + /// neither limit, and a theme it saved still loads; the next save writes the repaired text. + case repaired + } + + private enum MetadataKey: String, CaseIterable { + case schema + case id + case name + case author + case appearance + } + + public static func decode(_ data: Data, text policy: TextPolicy = .strict) throws -> ThemeDefinition { + guard case let .object(root) = try parse(data) else { throw ThemeLoadError.missingSchema } + + guard let schema = root[MetadataKey.schema.rawValue]?.intValue else { + if let legacy = root["version"]?.intValue { + throw ThemeLoadError.schemaTooOld(found: legacy, supported: ThemeSchema.current) + } + throw ThemeLoadError.missingSchema + } + guard schema >= ThemeSchema.current else { + throw ThemeLoadError.schemaTooOld(found: schema, supported: ThemeSchema.current) + } + guard schema <= ThemeSchema.current else { + throw ThemeLoadError.schemaTooNew(found: schema, supported: ThemeSchema.current) + } + + guard let id = root[MetadataKey.id.rawValue]?.stringValue, !id.isEmpty else { + throw ThemeLoadError.missingField(MetadataKey.id.rawValue) + } + guard ThemeIdentifier.isValid(id) else { throw ThemeLoadError.invalidIdentifier(id) } + + guard let rawName = root[MetadataKey.name.rawValue]?.stringValue, !trimmed(rawName).isEmpty else { + throw ThemeLoadError.missingField(MetadataKey.name.rawValue) + } + let name = try validatedName(rawName, policy: policy) + + guard + let appearanceRaw = root[MetadataKey.appearance.rawValue]?.stringValue, + let appearance = ThemeAppearance(rawValue: appearanceRaw) + else { + throw ThemeLoadError.missingField(MetadataKey.appearance.rawValue) + } + + let author = try validatedAuthor(root[MetadataKey.author.rawValue]?.stringValue ?? "", policy: policy) + + return ThemeDefinition( + id: id, + name: name, + author: author, + appearance: appearance, + overrides: try colors(in: root) + ) + } + + /// Writes what `decode` reads back unchanged: a trimmed name and author, and opaque fills. + public static func encode(_ theme: ThemeDefinition) throws -> Data { + guard ThemeIdentifier.isValid(theme.id) else { throw ThemeLoadError.invalidIdentifier(theme.id) } + let name = try validatedName(theme.name, policy: .strict) + + var root: [String: Any] = [ + MetadataKey.schema.rawValue: ThemeSchema.current, + MetadataKey.id.rawValue: theme.id, + MetadataKey.name.rawValue: name, + MetadataKey.author.rawValue: try validatedAuthor(theme.author, policy: .strict), + MetadataKey.appearance.rawValue: theme.appearance.rawValue, + ] + + for slot in ThemeSlot.allCases { + guard let color = theme.overrides[slot] else { continue } + let stored = slot.requiresOpaque ? color.opaque : color + insert(stored.hex, at: slot.rawValue.components(separatedBy: "."), into: &root) + } + + return try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + } + + // MARK: - Reading + + /// `JSONDecoder` rather than `JSONSerialization`: its underlying error names the character and + /// where it is, which is what a person fixing the file by hand needs. + private static func parse(_ data: Data) throws -> ThemeJSONNode { + do { + return try JSONDecoder().decode(ThemeJSONNode.self, from: data) + } catch let DecodingError.dataCorrupted(context) { + let underlying = context.underlyingError as NSError? + throw ThemeLoadError.malformedJSON(detail: underlying?.userInfo[NSDebugDescriptionErrorKey] as? String) + } catch { + throw ThemeLoadError.malformedJSON(detail: nil) + } + } + + private static func colors(in root: [String: ThemeJSONNode]) throws -> [ThemeSlot: ThemeColor] { + let leaves = root + .filter { MetadataKey(rawValue: $0.key) == nil } + .flatMap { key, node in node.leaves(prefix: key) } + .sorted { $0.path < $1.path } + + var parsed: [ThemeSlot: ThemeColor] = [:] + var unknown: [String] = [] + + for leaf in leaves { + guard let slot = ThemeSlot(rawValue: leaf.path) else { + unknown.append(leaf.path) + continue + } + guard parsed[slot] == nil else { throw ThemeLoadError.duplicateKey(leaf.path) } + guard let raw = leaf.value else { throw ThemeLoadError.invalidColor(leaf.path) } + let color = try ThemeColor(validating: raw) + parsed[slot] = slot.requiresOpaque ? color.opaque : color + } + + guard unknown.isEmpty else { throw ThemeLoadError.unknownKeys(unknown) } + return parsed + } + + // MARK: - Metadata + + private static func validatedName(_ raw: String, policy: TextPolicy) throws -> String { + guard let name = validatedText(raw, policy: policy), !name.isEmpty else { throw ThemeLoadError.invalidName } + return name + } + + private static func validatedAuthor(_ raw: String, policy: TextPolicy) throws -> String { + guard let author = validatedText(raw, policy: policy) else { throw ThemeLoadError.invalidAuthor } + return author + } + + private static func validatedText(_ raw: String, policy: TextPolicy) -> String? { + let text = trimmed(raw) + if isSingleLine(text), text.count <= maximumTextLength { + return text + } + guard policy == .repaired else { return nil } + return repaired(text) + } + + /// Every scalar `isSingleLine` refuses splits words, so the next save's strict check passes. + private static func repaired(_ text: String) -> String { + let separators = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) + let words = text.components(separatedBy: separators).filter { !$0.isEmpty } + return String(words.joined(separator: " ").prefix(maximumTextLength)) + .trimmingCharacters(in: .whitespaces) + } + + private static func trimmed(_ text: String) -> String { + text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// `controlCharacters` holds the ASCII line breaks; `newlines` adds the Unicode line and paragraph + /// separators. + private static func isSingleLine(_ text: String) -> Bool { + !text.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) || CharacterSet.newlines.contains($0) } + } + + // MARK: - Writing + + private static func insert(_ value: String, at path: [String], into container: inout [String: Any]) { + guard let head = path.first else { return } + + guard path.count > 1 else { + container[head] = value + return + } + + var child = container[head] as? [String: Any] ?? [:] + insert(value, at: Array(path.dropFirst()), into: &child) + container[head] = child + } +} + +/// A JSON value as far as the format cares. A number is kept only as an integer, which is all the +/// schema field holds; anything else that is not a string or an object is `other`, so the decoder +/// can name it rather than drop it. +private indirect enum ThemeJSONNode: Decodable { + case string(String) + case number(Int) + case object([String: ThemeJSONNode]) + case other + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + if let value = try? container.decode(Int.self) { + self = .number(value) + return + } + if let value = try? container.decode([String: ThemeJSONNode].self) { + self = .object(value) + return + } + self = .other + } + + var stringValue: String? { + guard case let .string(value) = self else { return nil } + return value + } + + var intValue: Int? { + guard case let .number(value) = self else { return nil } + return value + } + + /// Every leaf with its dotted path. A leaf that is not a string is kept as `nil`. + func leaves(prefix: String) -> [(path: String, value: String?)] { + switch self { + case let .string(value): + return [(prefix, value)] + case .number, .other: + return [(prefix, nil)] + case let .object(children): + return children.flatMap { key, child in + child.leaves(prefix: prefix.isEmpty ? key : "\(prefix).\(key)") + } + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeFileImporter.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeFileImporter.swift new file mode 100644 index 0000000000..e77e89f58f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeFileImporter.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Reads a theme file someone picked or dropped: a TablePro theme or a VS Code color theme. The file +/// is untrusted, so its size is checked before a byte is read, and every import mints a new id: the +/// id in a file says nothing about which theme on this Mac it is. +public enum ThemeFileImporter { + public static let maximumFileSize = 2 * 1_024 * 1_024 + + private static let vsCodeSchema = "vscode://schemas/color-theme" + private static let vsCodeKeys = ["colors", "tokenColors", "include", "settings"] + + private enum Format { + case tablePro + case vsCode([String: VSCodeThemeValue]) + } + + public static func importTheme(at url: URL) throws -> ThemeDefinition { + let data = try contents(of: url) + let id = ThemeIdentifier.generated() + switch try detectFormat(of: data) { + case .tablePro: + var theme = try ThemeFileFormat.decode(data) + theme.id = id + return theme + case let .vsCode(root): + return try VSCodeThemeImporter(fileURL: url).makeTheme(from: root, id: id) + } + } + + // JSON5 reads both formats, so its error points at the real fault in either. A TablePro theme then + // goes through the strict decoder, which refuses the comments VS Code allows. + private static func detectFormat(of data: Data) throws -> Format { + let root: VSCodeThemeValue + do { + root = try VSCodeThemeValue.parse(data) + } catch { + // A file that starts like neither a JSON object nor a comment is not a theme in any format. + guard let first = firstSignificantByte(of: data), first == UInt8(ascii: "{") || first == UInt8(ascii: "/") else { + throw ThemeLoadError.notATheme + } + throw error + } + + guard let object = root.objectValue else { throw ThemeLoadError.notATheme } + if case .number = object["schema"] { + return .tablePro + } + if object["$schema"]?.stringValue == vsCodeSchema || vsCodeKeys.contains(where: { object[$0] != nil }) { + return .vsCode(object) + } + // A TablePro file with a malformed or legacy version keeps the decoder's error about it. + if object["schema"] != nil || object["version"] != nil { + return .tablePro + } + throw ThemeLoadError.notATheme + } + + private static let byteOrderMark: [UInt8] = [0xEF, 0xBB, 0xBF] + private static let whitespace: Set = [0x20, 0x09, 0x0A, 0x0D] + + private static func firstSignificantByte(of data: Data) -> UInt8? { + let body = data.starts(with: byteOrderMark) ? data.dropFirst(byteOrderMark.count) : data[...] + return body.first { !whitespace.contains($0) } + } + + // Also reads every file a VS Code theme includes, each under the same cap. + internal static func contents(of url: URL) throws -> Data { + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size <= maximumFileSize else { throw ThemeLoadError.fileTooLarge(limit: maximumFileSize) } + + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + // The file can grow between the size check and the read. + let data = try handle.read(upToCount: maximumFileSize + 1) ?? Data() + guard data.count <= maximumFileSize else { throw ThemeLoadError.fileTooLarge(limit: maximumFileSize) } + return data + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeLoadError.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeLoadError.swift new file mode 100644 index 0000000000..5acd16d076 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeLoadError.swift @@ -0,0 +1,129 @@ +import Foundation + +public enum ThemeLoadError: LocalizedError, Equatable, Sendable { + case malformedJSON(detail: String?) + case missingSchema + case schemaTooOld(found: Int, supported: Int) + case schemaTooNew(found: Int, supported: Int) + case missingField(String) + case invalidName + case invalidAuthor + case invalidColor(String) + case unknownKeys([String]) + case duplicateKey(String) + case reservedIdentifier(String) + case invalidIdentifier(String) + case duplicateIdentifier(String, existingFile: String) + case changedOnDisk(String) + case deletedOnDisk(String) + case nameTaken(String) + case fileTooLarge(limit: Int) + case notATheme + case includeOutsideFolder(String) + case includeMissing(String) + case includeCycle(String) + case includeTooDeep(limit: Int) + case invalidColorMap + case invalidTokenColors + case invalidTextMateTheme(String) + case tooManyTokenRules(limit: Int) + case tokenScopeTooLong(limit: Int) + case tokenScopeHasTooManyParts(limit: Int) + case nestingTooDeep(limit: Int) + case noUsableColors + + public var errorDescription: String? { + switch self { + case let .malformedJSON(detail): + let message = String(localized: "The file is not valid JSON.") + guard let detail, !detail.isEmpty else { return message } + return message + "\n" + detail + case .missingSchema: + return String(localized: "The file does not declare a theme format version.") + case let .schemaTooOld(found, supported): + return String( + format: String(localized: "Theme format %1$d is no longer supported. This version of TablePro reads format %2$d."), + found, + supported + ) + case let .schemaTooNew(found, supported): + return String( + format: String(localized: "Theme format %1$d was made for a newer TablePro. This version reads format %2$d."), + found, + supported + ) + case let .missingField(name): + return String(format: String(localized: "The theme is missing the required field \"%@\"."), name) + case .invalidName: + return String( + format: String(localized: "A theme name must be one line of at most %d characters."), + ThemeFileFormat.maximumTextLength + ) + case .invalidAuthor: + return String( + format: String(localized: "A theme author must be one line of at most %d characters."), + ThemeFileFormat.maximumTextLength + ) + case let .invalidColor(value): + return String(format: String(localized: "\"%@\" is not a valid color. Use #RRGGBB or #RRGGBBAA."), value) + case let .unknownKeys(names): + let shown = ListFormatter.localizedString(byJoining: names.prefix(3).map { "\"\($0)\"" }) + return String(format: String(localized: "TablePro does not use these theme keys: %@."), shown) + case let .duplicateKey(path): + return String(format: String(localized: "The theme sets \"%@\" more than once."), path) + case let .reservedIdentifier(identifier): + return String(format: String(localized: "\"%@\" is a reserved theme identifier."), identifier) + case let .invalidIdentifier(identifier): + return String(format: String(localized: "\"%@\" is not a valid theme identifier."), identifier) + case let .duplicateIdentifier(identifier, existingFile): + return String( + format: String(localized: "The theme identifier \"%1$@\" is already used by %2$@."), + identifier, + existingFile + ) + case let .changedOnDisk(fileName): + return String( + format: String(localized: "%@ changed on disk since it was opened. The newer file was kept and reloaded."), + fileName + ) + case let .deletedOnDisk(fileName): + return String(format: String(localized: "%@ was deleted outside TablePro."), fileName) + case let .nameTaken(name): + return String(format: String(localized: "A theme named “%@” already exists."), name) + case let .fileTooLarge(limit): + let size = ByteCountFormatter.string(fromByteCount: Int64(limit), countStyle: .memory) + return String(format: String(localized: "The file is too large. A theme file can be at most %@."), size) + case .notATheme: + return String(localized: "This file is not a TablePro theme or a VS Code color theme.") + case let .includeOutsideFolder(path): + return String( + format: String( + localized: "The theme includes \"%@\", which is outside its folder. Put that file next to the theme and import again." + ), + path + ) + case let .includeMissing(path): + return String(format: String(localized: "The theme includes \"%@\", which is not next to it."), path) + case let .includeCycle(path): + return String(format: String(localized: "The theme includes itself through \"%@\"."), path) + case let .includeTooDeep(limit): + return String(format: String(localized: "The theme nests includes more than %d levels deep."), limit) + case .invalidColorMap: + return String(localized: "\"colors\" in the theme is not a list of colors.") + case .invalidTokenColors: + return String(localized: "\"tokenColors\" must be a list of rules or the path to a .tmTheme file.") + case let .invalidTextMateTheme(fileName): + return String(format: String(localized: "\"%@\" is not a TextMate theme."), fileName) + case let .tooManyTokenRules(limit): + return String(format: String(localized: "The theme has more than %@ token color rules."), limit.formatted()) + case let .tokenScopeTooLong(limit): + return String(format: String(localized: "A token color scope in the theme is longer than %@ characters."), limit.formatted()) + case let .tokenScopeHasTooManyParts(limit): + return String(format: String(localized: "A token color scope in the theme has more than %d dot-separated parts."), limit) + case let .nestingTooDeep(limit): + return String(format: String(localized: "The file nests arrays and objects more than %d levels deep."), limit) + case .noUsableColors: + return String(localized: "The theme has no colors TablePro can use.") + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeOrdering.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeOrdering.swift new file mode 100644 index 0000000000..47bd438939 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeOrdering.swift @@ -0,0 +1,27 @@ +import Foundation + +/// The one order every theme list uses: Default Light, Default Dark, the other built-ins by name, +/// then the user's themes by name. Two themes with one name keep a stable order by id. +public enum ThemeOrdering { + public static func areInIncreasingOrder(_ lhs: ThemeDefinition, _ rhs: ThemeDefinition) -> Bool { + let left = rank(of: lhs) + let right = rank(of: rhs) + guard left == right else { return left < right } + + switch lhs.name.localizedStandardCompare(rhs.name) { + case .orderedAscending: return true + case .orderedDescending: return false + case .orderedSame: return lhs.id < rhs.id + } + } + + public static func sorted(_ themes: [ThemeDefinition]) -> [ThemeDefinition] { + themes.sorted(by: areInIncreasingOrder) + } + + private static func rank(of theme: ThemeDefinition) -> Int { + if theme.id == ThemeDefinition.defaultLight.id { return 0 } + if theme.id == ThemeDefinition.defaultDark.id { return 1 } + return theme.isBuiltIn ? 2 : 3 + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeResolver.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeResolver.swift new file mode 100644 index 0000000000..f0bc9fb0e5 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeResolver.swift @@ -0,0 +1,77 @@ +import Foundation + +public struct ThemePair: Equatable, Sendable { + public var light: ThemeDefinition + public var dark: ThemeDefinition + + public static let builtIn = ThemePair(light: .defaultLight, dark: .defaultDark) + + public init(light: ThemeDefinition, dark: ThemeDefinition) { + self.light = light + self.dark = dark + } + + public subscript(appearance: ThemeAppearance) -> ThemeDefinition { + switch appearance { + case .light: return light + case .dark: return dark + } + } +} + +public struct ThemeSelection: Equatable, Sendable { + public let pair: ThemePair + public let effectiveAppearance: ThemeAppearance + + public init(pair: ThemePair, effectiveAppearance: ThemeAppearance) { + self.pair = pair + self.effectiveAppearance = effectiveAppearance + } + + public var active: ThemeDefinition { + pair[effectiveAppearance] + } +} + +/// Pure. It never writes settings: a settings pane that rewrote the saved theme id to make its own +/// filter true silently replaced a theme the user had chosen and then hid it from the list they +/// would have used to put it back. A slot whose theme is missing, rejected, or of the wrong +/// appearance falls back to that slot's own default, so a broken dark theme never paints Default +/// Light inside dark chrome. +public enum ThemeResolver { + public static func resolve( + lightThemeId: String, + darkThemeId: String, + themes: [ThemeDefinition], + effectiveAppearance: ThemeAppearance + ) -> ThemeSelection { + let pair = ThemePair( + light: theme(id: lightThemeId, slot: .light, in: themes), + dark: theme(id: darkThemeId, slot: .dark, in: themes) + ) + return ThemeSelection(pair: pair, effectiveAppearance: effectiveAppearance) + } + + private static func theme(id: String, slot: ThemeAppearance, in themes: [ThemeDefinition]) -> ThemeDefinition { + guard let found = themes.first(where: { $0.id == id }), found.appearance == slot else { + return .default(for: slot) + } + return found + } +} + +/// The list a slot offers. A theme only fits the slot whose appearance it declares, and the theme +/// the slot already holds stays listed so the user can always see and re-pick their own choice. +public enum ThemeSlotValidation { + public static func fits(_ appearance: ThemeAppearance, slot: ThemeAppearance) -> Bool { + appearance == slot + } + + public static func eligibleThemes( + _ themes: [ThemeDefinition], + slot: ThemeAppearance, + keeping selectedId: String? + ) -> [ThemeDefinition] { + themes.filter { fits($0.appearance, slot: slot) || $0.id == selectedId } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift new file mode 100644 index 0000000000..266db47654 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift @@ -0,0 +1,202 @@ +import Foundation + +/// The single registry every slot goes through. A slot exists here because a call site reads it. +/// The decoder, the encoder and the theme editor all enumerate this list rather than keeping their +/// own copies. +public enum ThemeSlot: String, CaseIterable, Sendable { + case windowBackdrop = "window.background" + + case editorBackground = "editor.background" + case editorText = "editor.text" + case editorCursor = "editor.cursor" + case editorSelection = "editor.selection" + case editorCurrentLine = "editor.currentLine" + case editorCurrentStatement = "editor.currentStatement" + case editorLineNumber = "editor.lineNumber" + case editorInvisibles = "editor.invisibles" + + case syntaxKeyword = "editor.syntax.keyword" + case syntaxString = "editor.syntax.string" + case syntaxNumber = "editor.syntax.number" + case syntaxComment = "editor.syntax.comment" + case syntaxNull = "editor.syntax.null" + case syntaxOperator = "editor.syntax.operator" + case syntaxFunction = "editor.syntax.function" + case syntaxType = "editor.syntax.type" + + case gridBackground = "dataGrid.background" + case gridText = "dataGrid.text" + case gridAlternateRow = "dataGrid.alternateRow" + case gridHeaderBackground = "dataGrid.headerBackground" + case gridHeaderText = "dataGrid.headerText" + case gridLine = "dataGrid.gridLine" + case gridSelection = "dataGrid.selection" + case gridSelectionText = "dataGrid.selectionText" + case gridInactiveSelection = "dataGrid.inactiveSelection" + case gridFocusBorder = "dataGrid.focusBorder" + case gridNullValue = "dataGrid.nullValue" + case gridBoolTrue = "dataGrid.boolTrue" + case gridBoolFalse = "dataGrid.boolFalse" + case gridRowNumber = "dataGrid.rowNumber" + case gridModified = "dataGrid.modified" + case gridInserted = "dataGrid.inserted" + case gridDeleted = "dataGrid.deleted" + case gridDeletedText = "dataGrid.deletedText" + + /// The group of the file format: the slot's dotted path starts with the group's key. + public var group: ThemeSlotGroup { + switch self { + case .windowBackdrop: + return .window + case .editorBackground, .editorText, .editorCursor, .editorSelection, + .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles: + return .editor + case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, + .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType: + return .syntax + case .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, + .gridLine, .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder, + .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, + .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: + return .dataGrid + } + } + + /// Where the theme editor lists the slot. + public var section: ThemeSlotSection { + switch self { + case .windowBackdrop: + return .window + case .editorBackground, .editorText, .editorCursor, .editorSelection, + .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles: + return .editor + case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, + .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType: + return .syntax + case .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, + .gridLine, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber: + return .dataGrid + case .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder: + return .selection + case .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: + return .changes + } + } + + /// Only the backdrop: content slots keep the theme under Increase Contrast, as they always have. + public var yieldsToIncreasedContrast: Bool { + group == .window + } + + /// Fills that sit under everything else. A translucent window color shows the desktop through the + /// window, so these never carry alpha. + public var requiresOpaque: Bool { + switch self { + case .windowBackdrop, .editorBackground, .gridBackground, .gridHeaderBackground: + return true + case .editorText, .editorCursor, .editorSelection, .editorCurrentLine, .editorCurrentStatement, + .editorLineNumber, .editorInvisibles, + .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, + .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType, + .gridText, .gridAlternateRow, .gridHeaderText, .gridLine, .gridSelection, .gridSelectionText, + .gridInactiveSelection, .gridFocusBorder, .gridNullValue, .gridBoolTrue, .gridBoolFalse, + .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: + return false + } + } + + /// Washes drawn over content, where an alpha channel is the point. + public var offersOpacity: Bool { + switch self { + case .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorInvisibles, + .gridAlternateRow, .gridSelection, .gridInactiveSelection, + .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: + return true + case .windowBackdrop, .editorBackground, .editorText, .editorCursor, .editorLineNumber, + .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, + .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType, + .gridBackground, .gridText, .gridHeaderBackground, .gridHeaderText, .gridLine, + .gridSelectionText, .gridFocusBorder, .gridNullValue, .gridBoolTrue, .gridBoolFalse, + .gridRowNumber: + return false + } + } + + public var label: String { + switch self { + 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") + case .editorCurrentLine: return String(localized: "Current Line") + case .editorCurrentStatement: return String(localized: "Current Statement") + case .editorLineNumber: return String(localized: "Line Number") + case .editorInvisibles: return String(localized: "Invisibles") + case .syntaxKeyword: return String(localized: "Keyword") + case .syntaxString: return String(localized: "String") + case .syntaxNumber: return String(localized: "Number") + case .syntaxComment: return String(localized: "Comment") + case .syntaxNull: return String(localized: "NULL") + case .syntaxOperator: return String(localized: "Operator") + case .syntaxFunction: return String(localized: "Function") + case .syntaxType: return String(localized: "Type") + case .gridAlternateRow: return String(localized: "Alternate Row") + case .gridHeaderBackground: return String(localized: "Header Background") + case .gridHeaderText: return String(localized: "Header Text") + case .gridLine: return String(localized: "Grid Line") + case .gridSelectionText: return String(localized: "Selected Text") + case .gridInactiveSelection: return String(localized: "Inactive Selection") + case .gridFocusBorder: return String(localized: "Focus Border") + case .gridNullValue: return String(localized: "NULL Value") + case .gridBoolTrue: return String(localized: "Bool True") + case .gridBoolFalse: return String(localized: "Bool False") + case .gridRowNumber: return String(localized: "Row Number") + case .gridModified: return String(localized: "Modified") + case .gridInserted: return String(localized: "Inserted") + case .gridDeleted: return String(localized: "Deleted") + case .gridDeletedText: return String(localized: "Deleted Text") + } + } + + /// `label` repeats across groups ("Background" three times), so VoiceOver gets the group too. + /// Whole phrases, because word order differs between languages. + public var accessibilityLabel: String { + switch self { + case .windowBackdrop: return String(localized: "Window Background") + case .editorBackground: return String(localized: "Editor Background") + case .editorText: return String(localized: "Editor Text") + case .editorCursor: return String(localized: "Editor Cursor") + case .editorSelection: return String(localized: "Editor Selection") + case .editorCurrentLine: return String(localized: "Editor Current Line") + case .editorCurrentStatement: return String(localized: "Editor Current Statement") + case .editorLineNumber: return String(localized: "Editor Line Number") + case .editorInvisibles: return String(localized: "Editor Invisibles") + case .syntaxKeyword: return String(localized: "Syntax Keyword") + case .syntaxString: return String(localized: "Syntax String") + case .syntaxNumber: return String(localized: "Syntax Number") + case .syntaxComment: return String(localized: "Syntax Comment") + case .syntaxNull: return String(localized: "Syntax NULL") + case .syntaxOperator: return String(localized: "Syntax Operator") + case .syntaxFunction: return String(localized: "Syntax Function") + case .syntaxType: return String(localized: "Syntax Type") + case .gridBackground: return String(localized: "Data Grid Background") + case .gridText: return String(localized: "Data Grid Text") + case .gridAlternateRow: return String(localized: "Data Grid Alternate Row") + case .gridHeaderBackground: return String(localized: "Data Grid Header Background") + case .gridHeaderText: return String(localized: "Data Grid Header Text") + case .gridLine: return String(localized: "Data Grid Line") + case .gridSelection: return String(localized: "Data Grid Selection") + case .gridSelectionText: return String(localized: "Data Grid Selected Text") + case .gridInactiveSelection: return String(localized: "Data Grid Inactive Selection") + case .gridFocusBorder: return String(localized: "Data Grid Focus Border") + case .gridNullValue: return String(localized: "Data Grid NULL Value") + case .gridBoolTrue: return String(localized: "Data Grid Bool True") + case .gridBoolFalse: return String(localized: "Data Grid Bool False") + case .gridRowNumber: return String(localized: "Data Grid Row Number") + case .gridModified: return String(localized: "Data Grid Modified") + case .gridInserted: return String(localized: "Data Grid Inserted") + case .gridDeleted: return String(localized: "Data Grid Deleted") + case .gridDeletedText: return String(localized: "Data Grid Deleted Text") + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift new file mode 100644 index 0000000000..1bcb69f835 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift @@ -0,0 +1,59 @@ +import Foundation + +/// What a slot draws when the theme leaves it out. +public enum ThemeSlotFallback: Equatable, Sendable { + /// The slot's native color. + case system + /// The same-appearance default's syntax color: syntax has no system color. + case defaultSyntax + /// The other slot's theme color, else the slot's native color. + case slot(ThemeSlot) + /// Built from `base` while the theme sets it, else the slot's native color. + case derived(base: ThemeSlot, rule: ThemeDerivationRule) + /// Only while the theme sets `with`: the slot's own color, else `fallback`'s value. Otherwise the + /// slot's native color, whatever the theme sets. + case paired(with: ThemeSlot, fallback: ThemeSlot) +} + +public enum ThemeDerivationRule: Equatable, Sendable { + /// The base moved `stripeFraction` toward `toward`'s theme color, or toward black in a light theme + /// and white in a dark one. + case stripe(toward: ThemeSlot) + /// The base itself. + case same + + /// AppKit's alternate stripe sits about 4% from its row background in both appearances. + public static let stripeFraction = 0.04 +} + +public extension ThemeSlot { + /// Exhaustive, so a new slot cannot ship without a rule. A rule only names slots that fall back to + /// the system themselves, so resolution never chains. + var fallback: ThemeSlotFallback { + switch self { + case .windowBackdrop, + .editorBackground, .editorText, .editorCursor, .editorSelection, + .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles, + .gridBackground, .gridText, .gridLine, .gridSelection, .gridInactiveSelection, .gridFocusBorder, + .gridNullValue, .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: + return .system + case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, + .syntaxNull, .syntaxFunction, .syntaxType: + return .defaultSyntax + case .syntaxOperator: + return .slot(.editorText) + // A bool or a header label in labelColor next to themed cell text reads as a different table. + case .gridBoolTrue, .gridBoolFalse, .gridHeaderText: + return .slot(.gridText) + case .gridAlternateRow: + return .derived(base: .gridBackground, rule: .stripe(toward: .gridText)) + // Natively the header and the rows share controlBackgroundColor. + case .gridHeaderBackground: + return .derived(base: .gridBackground, rule: .same) + // AppKit's own selection keeps its white text, so a theme's selection text only means + // something over the theme's selection. + case .gridSelectionText: + return .paired(with: .gridSelection, fallback: .gridText) + } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeSlotGroup.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeSlotGroup.swift new file mode 100644 index 0000000000..7ae3801c2f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeSlotGroup.swift @@ -0,0 +1,21 @@ +import Foundation + +public enum ThemeSlotGroup: String, CaseIterable, Sendable { + case window + case editor + case syntax + case dataGrid + + public 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") + } + } + + public var slots: [ThemeSlot] { + ThemeSlot.allCases.filter { $0.group == self } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift new file mode 100644 index 0000000000..b1e2fc3606 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift @@ -0,0 +1,27 @@ +import Foundation + +/// The theme editor's grouping. It splits the data grid's file-format group so selection and row +/// change colors, which are chosen together, sit together. +public enum ThemeSlotSection: String, CaseIterable, Sendable { + case window + case editor + case syntax + case dataGrid + case selection + case changes + + public 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") + case .selection: return String(localized: "Selection") + case .changes: return String(localized: "Changes") + } + } + + public var slots: [ThemeSlot] { + ThemeSlot.allCases.filter { $0.section == self } + } +} diff --git a/Packages/TableProCore/Sources/TableProTheme/ThemeSlotValue.swift b/Packages/TableProCore/Sources/TableProTheme/ThemeSlotValue.swift new file mode 100644 index 0000000000..0fca95cab2 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTheme/ThemeSlotValue.swift @@ -0,0 +1,20 @@ +import Foundation + +/// What a slot draws under a theme. Only `themed` is a fixed color; the other two are resolved by +/// the platform, so they keep following the system. +public enum ThemeSlotValue: Equatable, Sendable { + case themed(ThemeColor) + /// The native color of the slot named, which can be another slot than the one asked for. + case system(ThemeSlot) + /// The same-appearance default's syntax color, which has a high-contrast variant. + case defaultSyntax(ThemeSlot) + + public var themedColor: ThemeColor? { + guard case let .themed(color) = self else { return nil } + return color + } + + public var isThemed: Bool { + themedColor != nil + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/DefaultThemesTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/DefaultThemesTests.swift new file mode 100644 index 0000000000..395b9a03b1 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/DefaultThemesTests.swift @@ -0,0 +1,106 @@ +import TableProTheme +import Testing + +struct DefaultThemesTests { + @Test("The defaults have fixed ids, names and appearances, and set no color") + func defaultsAreEmpty() { + #expect(ThemeDefinition.defaultLight.id == "tablepro.default-light") + #expect(ThemeDefinition.defaultDark.id == "tablepro.default-dark") + #expect(ThemeDefinition.defaultLight.name == "Default Light") + #expect(ThemeDefinition.defaultDark.name == "Default Dark") + #expect(ThemeDefinition.defaultLight.author == "TablePro") + #expect(ThemeDefinition.defaultLight.appearance == .light) + #expect(ThemeDefinition.defaultDark.appearance == .dark) + #expect(ThemeDefinition.defaultLight.overrides.isEmpty) + #expect(ThemeDefinition.defaultDark.overrides.isEmpty) + #expect(ThemeDefinition.defaultLight.isBuiltIn) + } + + @Test("Each appearance has its own default") + func defaultPerAppearance() { + #expect(ThemeDefinition.default(for: .light) == .defaultLight) + #expect(ThemeDefinition.default(for: .dark) == .defaultDark) + #expect(ThemeDefinition.defaultId(for: .dark) == "tablepro.default-dark") + #expect(ThemeDefinition.isDefault("tablepro.default-light")) + #expect(ThemeDefinition.isDefault("tablepro.default-dark")) + #expect(!ThemeDefinition.isDefault("tablepro.dracula")) + } + + private static func hexes(_ appearance: ThemeAppearance, highContrast: Bool) -> [ThemeSlot: String] { + DefaultSyntaxPalette.colors(for: appearance, highContrast: highContrast).mapValues(\.hex) + } + + /// Xcode 27.1 `Default (Light).xccolortheme`: keyword, string, number, comment, system function + /// and system type. + @Test("Default Light syntax is Xcode's Default (Light)") + func standardLight() { + #expect(Self.hexes(.light, highContrast: false) == [ + .syntaxKeyword: "#9B2393", + .syntaxString: "#C41A16", + .syntaxNumber: "#1C00CF", + .syntaxComment: "#5D6C79", + .syntaxFunction: "#6C36A9", + .syntaxType: "#3900A0", + .syntaxNull: "#9B2393", + ]) + } + + @Test("Default Dark syntax is Xcode's Default (Dark)") + func standardDark() { + #expect(Self.hexes(.dark, highContrast: false) == [ + .syntaxKeyword: "#FC5FA3", + .syntaxString: "#FC6A5D", + .syntaxNumber: "#D0BF69", + .syntaxComment: "#6C7986", + .syntaxFunction: "#A167E6", + .syntaxType: "#D0A8FF", + .syntaxNull: "#FC5FA3", + ]) + } + + @Test("Under Increase Contrast the syntax is Xcode's High Contrast palette") + func highContrast() { + #expect(Self.hexes(.light, highContrast: true) == [ + .syntaxKeyword: "#88007E", + .syntaxString: "#9B0509", + .syntaxNumber: "#1C02CF", + .syntaxComment: "#1A5301", + .syntaxFunction: "#5C2699", + .syntaxType: "#330090", + .syntaxNull: "#88007E", + ]) + #expect(Self.hexes(.dark, highContrast: true) == [ + .syntaxKeyword: "#FC6BAA", + .syntaxString: "#FC7467", + .syntaxNumber: "#D0BC56", + .syntaxComment: "#7CB554", + .syntaxFunction: "#C08AFF", + .syntaxType: "#DEC1FF", + .syntaxNull: "#FC6BAA", + ]) + } + + @Test("The palette covers exactly the slots that fall back to the default syntax") + func paletteCoversDefaultSyntaxSlots() { + let expected = Set(ThemeSlot.allCases.filter { $0.fallback == .defaultSyntax }) + for appearance in ThemeAppearance.allCases { + for highContrast in [false, true] { + let covered = Set(DefaultSyntaxPalette.colors(for: appearance, highContrast: highContrast).keys) + #expect(covered == expected, "\(appearance) high contrast \(highContrast)") + } + } + #expect(DefaultSyntaxPalette.color(for: .syntaxOperator, appearance: .dark, highContrast: false) == nil) + #expect(DefaultSyntaxPalette.color(for: .syntaxKeyword, appearance: .dark, highContrast: false)?.hex == "#FC5FA3") + } + + /// The old defaults carried their syntax as overrides, so a duplicate froze them under Increase + /// Contrast. An empty default keeps a copy on the adaptive palette. + @Test("A copy of a default keeps every syntax slot on the default palette") + func copyOfADefaultStaysAdaptive() { + var copy = ThemeDefinition.defaultDark + copy.id = "user.copy" + for slot in ThemeSlotGroup.syntax.slots where slot != .syntaxOperator { + #expect(copy.value(for: slot) == .defaultSyntax(slot), "\(slot.rawValue)") + } + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/Import/TextMateThemeResolverTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/Import/TextMateThemeResolverTests.swift new file mode 100644 index 0000000000..eee994172d --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/Import/TextMateThemeResolverTests.swift @@ -0,0 +1,176 @@ +import Foundation +@testable import TableProTheme +import Testing + +struct TextMateThemeResolverTests { + private static let sqlKeyword = ["source.sql", "keyword.other.DML.sql"] + + private static func resolver(_ rules: [(String, String?)]) throws -> TextMateThemeResolver { + try TextMateThemeResolver( + rules: [TextMateRule(scope: .unscoped, foreground: "#EEEEEE")] + + rules.map { TextMateRule(scope: .selector($0.0), foreground: $0.1) } + ) + } + + @Test("The deepest dotted prefix wins") + func deeperScopeWins() throws { + let resolver = try Self.resolver([("keyword", "#111111"), ("keyword.other", "#222222")]) + + #expect(resolver.foreground(forScopes: Self.sqlKeyword) == "#222222") + #expect(resolver.foreground(forScopes: ["source.sql", "keyword.operator.sql"]) == "#111111") + } + + @Test("Of two rules for one scope, the later wins") + func laterRuleWins() throws { + let resolver = try Self.resolver([("keyword", "#111111"), ("constant", "#555555"), ("keyword", "#333333")]) + + #expect(resolver.foreground(forScopes: Self.sqlKeyword) == "#333333") + } + + @Test("A rule with a matching parent scope beats a deeper rule without one at the same node") + func parentSelectorIsMoreSpecific() throws { + let resolver = try Self.resolver([ + ("keyword.other", "#111111"), + ("source.sql keyword.other", "#222222"), + ("source.js keyword.other", "#333333"), + ]) + + #expect(resolver.foreground(forScopes: Self.sqlKeyword) == "#222222") + #expect(resolver.foreground(forScopes: ["source.go", "keyword.other.go"]) == "#111111") + } + + @Test("Of two matching parents, the longer innermost one is more specific, then the rule with more") + func parentSpecificityOrder() throws { + let stack = ["source.sql", "meta.statement.sql", "keyword.other.DML.sql"] + let byLength = try Self.resolver([("meta keyword", "#AAAAAA"), ("source.sql keyword", "#BBBBBB")]) + let byCount = try Self.resolver([("meta.statement keyword", "#AAAAAA"), ("source.sql meta.statement keyword", "#BBBBBB")]) + + #expect(byLength.foreground(forScopes: stack) == "#BBBBBB") + #expect(byCount.foreground(forScopes: stack) == "#BBBBBB") + } + + @Test("> requires the direct parent") + func childCombinator() throws { + let resolver = try Self.resolver([("keyword", "#111111"), ("source.sql > keyword", "#222222")]) + + #expect(resolver.foreground(forScopes: Self.sqlKeyword) == "#222222") + #expect(resolver.foreground(forScopes: ["source.sql", "meta.x", "keyword.other"]) == "#111111") + } + + @Test("- is not an exclusion: the selector parses as parents and never matches") + func minusIsIgnored() throws { + let resolver = try Self.resolver([("keyword", "#111111"), ("keyword - keyword.operator", "#666666")]) + + #expect(resolver.foreground(forScopes: ["source.sql", "keyword.operator.comparison.sql"]) == "#111111") + #expect(resolver.foreground(forScopes: Self.sqlKeyword) == "#111111") + } + + @Test("A rule that sets no color keeps the parent's") + func ruleWithoutColorInherits() throws { + let resolver = try Self.resolver([("keyword", "#111111"), ("keyword.operator", nil), ("keyword.other", "not a color")]) + + #expect(resolver.foreground(forScopes: ["source.sql", "keyword.operator.comparison.sql"]) == "#111111") + #expect(resolver.foreground(forScopes: Self.sqlKeyword) == "#111111") + } + + @Test("A selector list splits on commas, trimmed, and an array scope is taken element by element") + func selectorLists() throws { + let resolver = try TextMateThemeResolver(rules: [ + TextMateRule(scope: .selector(",string , comment,"), foreground: "#123456"), + TextMateRule(scope: .selectors(["entity.name.type", "support.type, storage"]), foreground: "#654321"), + ]) + + #expect(resolver.foreground(forScopes: ["source.sql", "string.quoted.single.sql"]) == "#123456") + #expect(resolver.foreground(forScopes: ["source.sql", "comment.line.sql"]) == "#123456") + #expect(resolver.foreground(forScopes: ["source.sql", "entity.name.type.sql"]) == "#654321") + #expect(resolver.foreground(forScopes: ["source.sql", "storage.type.sql"]) == nil) + } + + @Test("A prefix matches only at a dot") + func prefixMatchesAtADot() throws { + let resolver = try Self.resolver([("keyword", "#111111")]) + + #expect(resolver.foreground(forScopes: ["source.sql", "keywords.sql"]) == nil) + #expect(resolver.foreground(forScopes: ["source.sql", "keyword"]) == "#111111") + } + + @Test("Each scope level merges over the one above, so a root rule colors what nothing deeper does") + func perLevelMerge() throws { + let resolver = try Self.resolver([("source.sql", "#999999"), ("string", "#123123")]) + + #expect(resolver.foreground(forScopes: Self.sqlKeyword) == "#999999") + #expect(resolver.foreground(forScopes: ["source.sql", "string.quoted.sql"]) == "#123123") + } + + @Test("The unscoped rules set the default, and no match leaves the token on it") + func defaults() throws { + let resolver = try TextMateThemeResolver(rules: [ + TextMateRule(scope: .unscoped, foreground: "#eeeeee"), + TextMateRule(scope: .unscoped, foreground: "#dddddd"), + TextMateRule(scope: .selector("comment"), foreground: "#888888"), + ]) + + #expect(resolver.defaultForeground == "#DDDDDD") + #expect(resolver.foreground(forScopes: Self.sqlKeyword) == nil) + } + + @Test("Rules past the cap, counted after selectors split, are refused") + func ruleCap() throws { + let fits = (0 ..< TextMateThemeResolver.maximumRuleCount).map { "scope.n\($0)" } + + #expect(throws: Never.self) { + try TextMateThemeResolver(rules: [TextMateRule(scope: .selectors(fits), foreground: "#111111")]) + } + #expect(throws: ThemeLoadError.tooManyTokenRules(limit: TextMateThemeResolver.maximumRuleCount)) { + try TextMateThemeResolver(rules: [TextMateRule(scope: .selectors(fits + ["one.more"]), foreground: "#111111")]) + } + } + + @Test("A selector is capped in length after commas split it and whitespace is trimmed") + func selectorLengthCap() throws { + let limit = TextMateThemeResolver.maximumSelectorLength + let fits = String(repeating: "a", count: limit) + let over = String(repeating: "a", count: limit + 1) + + #expect(throws: Never.self) { try Self.resolver([(" \(fits) ,\(fits)", "#111111")]) } + #expect(throws: ThemeLoadError.tokenScopeTooLong(limit: limit)) { try Self.resolver([("keyword, \(over)", "#111111")]) } + #expect(throws: ThemeLoadError.tokenScopeTooLong(limit: limit)) { + try TextMateThemeResolver(rules: [TextMateRule(scope: .selectors(["keyword", over]), foreground: "#111111")]) + } + } + + @Test("Every scope in a selector, parents too, has at most 32 dot-separated parts") + func scopePartCap() throws { + let limit = TextMateThemeResolver.maximumScopeParts + let fits = Array(repeating: "a", count: limit).joined(separator: ".") + let over = fits + ".a" + + #expect(throws: Never.self) { try Self.resolver([("\(fits) \(fits)", "#111111")]) } + #expect(throws: ThemeLoadError.tokenScopeHasTooManyParts(limit: limit)) { try Self.resolver([(over, "#111111")]) } + #expect(throws: ThemeLoadError.tokenScopeHasTooManyParts(limit: limit)) { try Self.resolver([("\(over) keyword", "#111111")]) } + #expect(throws: ThemeLoadError.tokenScopeHasTooManyParts(limit: limit)) { try Self.resolver([(String(repeating: ".", count: limit), "#111111")]) } + } + + @Test("The deepest scope the cap allows resolves to its own rule") + func deepestScopeResolves() throws { + let scope = Array(repeating: "a", count: TextMateThemeResolver.maximumScopeParts).joined(separator: ".") + let resolver = try Self.resolver([("a", "#111111"), (scope, "#222222")]) + + #expect(resolver.foreground(forScopes: [scope + ".sql"]) == "#222222") + #expect(resolver.foreground(forScopes: ["a.b"]) == "#111111") + } + + @Test("A rule that lands on a child changes the child's copy of a parent-scoped rule, never the parent's") + func childRulesStayOnTheChild() throws { + let resolver = try Self.resolver([ + ("source.sql keyword", "#111111"), + ("source.sql keyword.other", "#222222"), + ("meta.block keyword.other", "#333333"), + ]) + + #expect(resolver.foreground(forScopes: ["source.sql", "keyword.control.sql"]) == "#111111") + #expect(resolver.foreground(forScopes: ["source.sql", "keyword.other.sql"]) == "#222222") + #expect(resolver.foreground(forScopes: ["meta.block", "keyword.other.sql"]) == "#333333") + #expect(resolver.foreground(forScopes: ["meta.block", "keyword.control.sql"]) == nil) + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/Import/ThemeImportFolder.swift b/Packages/TableProCore/Tests/TableProThemeTests/Import/ThemeImportFolder.swift new file mode 100644 index 0000000000..03a732dfe1 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/Import/ThemeImportFolder.swift @@ -0,0 +1,53 @@ +import Foundation +import TableProTheme + +// A temporary folder holding one theme and the files it names, removed by the test that made it. +struct ThemeImportFolder { + let url: URL + + init() throws { + url = FileManager.default.temporaryDirectory + .appendingPathComponent("ThemeImportTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + @discardableResult + func write(_ text: String, as path: String) throws -> URL { + try write(Data(text.utf8), as: path) + } + + @discardableResult + func write(_ data: Data, as path: String) throws -> URL { + let file = url.appendingPathComponent(path) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: file) + return file + } + + func link(_ path: String, to destination: URL) throws { + try FileManager.default.createSymbolicLink(at: url.appendingPathComponent(path), withDestinationURL: destination) + } + + func remove() { + try? FileManager.default.removeItem(at: url) + } + + func importTheme(_ path: String) throws -> ThemeDefinition { + try ThemeFileImporter.importTheme(at: url.appendingPathComponent(path)) + } + + func refusal(importing path: String) -> ThemeLoadError? { + do { + _ = try importTheme(path) + return nil + } catch { + return error as? ThemeLoadError + } + } +} + +extension ThemeDefinition { + func hex(_ slot: ThemeSlot) -> String? { + overrides[slot]?.hex + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeFileTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeFileTests.swift new file mode 100644 index 0000000000..cff6abe8ae --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeFileTests.swift @@ -0,0 +1,448 @@ +import Foundation +import TableProTheme +import Testing + +struct VSCodeThemeFileTests { + private static let textMateTheme = """ + + + nameMinisettings + settings + background#272822foreground#F8F8F2 + caret#F8F8F0selection#49483E + lineHighlight#3E3D32invisibles#3B3A32 + + scopekeywordsettingsforeground#F92672 + scopestringsettingsforeground#E6DB74 + scopeentity.name.function, support.function + settingsforeground#A6E22E + + """ + + @Test("JSONC comments, trailing commas and a byte order mark parse, and strings keep their slashes") + func jsoncParses() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write( + """ + \u{FEFF}// Generated by VS Code + { + "name": "Slashes // and /* stars */", // a comment after a value + /* a block comment */ "type": "dark", + "colors": { + "editor.background": "#101820", + //"editor.foreground": "#000000", + "editor.foreground": "#E0E0E0", + }, + "tokenColors": [ + {"scope": "string", "settings": {"foreground": "#A0C0E0",},}, + ], + } + """, + as: "commented.json" + ) + + let theme = try folder.importTheme("commented.json") + + #expect(theme.name == "Slashes // and /* stars */") + #expect(theme.hex(.editorBackground) == "#101820") + #expect(theme.hex(.editorText) == "#E0E0E0") + #expect(theme.hex(.syntaxString) == "#A0C0E0") + } + + @Test("An include loads first, so the including file's colors and rules win, and \"default\" drops a color") + func includeOrderAndDefault() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write( + """ + {"colors": {"editor.background": "#111111", "editor.foreground": "#222222", "editorLineNumber.foreground": "#333333"}, + "tokenColors": [{"scope": "keyword", "settings": {"foreground": "#444444"}}]} + """, + as: "base.json" + ) + try folder.write( + """ + {"include": "./base.json", + "colors": {"editor.background": "#AAAAAA", "editorLineNumber.foreground": "default"}, + "tokenColors": [{"scope": "keyword", "settings": {"foreground": "#BBBBBB"}}]} + """, + as: "main.json" + ) + + let theme = try folder.importTheme("main.json") + + #expect(theme.hex(.editorBackground) == "#AAAAAA") + #expect(theme.hex(.editorText) == "#222222") + #expect(theme.hex(.editorLineNumber) == nil) + #expect(theme.hex(.gridRowNumber) == nil) + #expect(theme.hex(.syntaxKeyword) == "#BBBBBB") + } + + @Test("A nested include resolves from its own folder and may reach back into the chosen one") + func nestedIncludeResolvesFromItsFolder() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(##"{"colors": {"editor.background": "#123456"}}"##, as: "base.json") + try folder.write(##"{"include": "../base.json", "colors": {"editor.foreground": "#654321"}}"##, as: "parts/middle.json") + try folder.write(##"{"include": "parts/middle.json"}"##, as: "main.json") + + let theme = try folder.importTheme("main.json") + + #expect(theme.hex(.editorBackground) == "#123456") + #expect(theme.hex(.editorText) == "#654321") + } + + @Test("A path out of the theme's folder is refused, by .., an absolute path, ~ or a symlink") + func pathsOutsideTheFolderAreRefused() throws { + let outside = try ThemeImportFolder() + defer { outside.remove() } + let secret = try outside.write(##"{"colors": {"editor.background": "#000000"}}"##, as: "secret.json") + + let folder = try ThemeImportFolder() + defer { folder.remove() } + let traversal = "../\(outside.url.lastPathComponent)/secret.json" + try folder.write(##"{"include": "\##(traversal)"}"##, as: "traversal.json") + try folder.write(##"{"include": "\##(secret.path)"}"##, as: "absolute.json") + try folder.write(##"{"include": "~/secret.json"}"##, as: "home.json") + try folder.link("link.json", to: secret) + try folder.write(##"{"include": "./link.json"}"##, as: "symlink.json") + try folder.write(##"{"colors": {"editor.background": "#101010"}, "tokenColors": "\##(traversal)"}"##, as: "tokens.json") + + #expect(folder.refusal(importing: "traversal.json") == .includeOutsideFolder(traversal)) + #expect(folder.refusal(importing: "absolute.json") == .includeOutsideFolder(secret.path)) + #expect(folder.refusal(importing: "home.json") == .includeOutsideFolder("~/secret.json")) + #expect(folder.refusal(importing: "symlink.json") == .includeOutsideFolder("./link.json")) + #expect(folder.refusal(importing: "tokens.json") == .includeOutsideFolder(traversal)) + } + + @Test("A symlink that stays inside the folder is followed") + func symlinkInsideTheFolderIsFollowed() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + let target = try folder.write(##"{"colors": {"editor.background": "#0A0B0C"}}"##, as: "real/base.json") + try folder.link("base.json", to: target) + try folder.write(##"{"include": "base.json"}"##, as: "main.json") + + #expect(try folder.importTheme("main.json").hex(.editorBackground) == "#0A0B0C") + } + + @Test("A theme that includes itself, directly or through another file, is refused") + func cyclesAreRefused() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(##"{"include": "./b.json", "colors": {"editor.background": "#111111"}}"##, as: "a.json") + try folder.write(##"{"include": "./a.json"}"##, as: "b.json") + try folder.write(##"{"include": "self.json"}"##, as: "self.json") + + #expect(folder.refusal(importing: "a.json") == .includeCycle("./a.json")) + #expect(folder.refusal(importing: "self.json") == .includeCycle("self.json")) + } + + @Test("Includes nest at most eight levels deep") + func includeDepthIsCapped() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + for level in 0 ..< 9 { + try folder.write(##"{"include": "level\##(level + 1).json"}"##, as: "level\(level).json") + } + try folder.write(##"{"colors": {"editor.background": "#202020"}}"##, as: "level9.json") + + #expect(folder.refusal(importing: "level0.json") == .includeTooDeep(limit: 8)) + #expect(try folder.importTheme("level1.json").hex(.editorBackground) == "#202020") + } + + @Test("A missing include is named, and a folder is not a file") + func missingIncludeIsNamed() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try FileManager.default.createDirectory( + at: folder.url.appendingPathComponent("folder.json"), + withIntermediateDirectories: true + ) + try folder.write(##"{"include": "./dark_vs.json", "colors": {"editor.background": "#111111"}}"##, as: "missing.json") + try folder.write(##"{"include": "folder.json"}"##, as: "directory.json") + try folder.write(##"{"colors": {"editor.background": "#111111"}, "tokenColors": "absent.tmTheme"}"##, as: "tokens.json") + + #expect(folder.refusal(importing: "missing.json") == .includeMissing("./dark_vs.json")) + #expect(folder.refusal(importing: "directory.json") == .includeMissing("folder.json")) + #expect(folder.refusal(importing: "tokens.json") == .includeMissing("absent.tmTheme")) + } + + @Test("An included file over the size cap is refused") + func includeSizeCap() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(Data(count: ThemeFileImporter.maximumFileSize + 1), as: "huge.json") + try folder.write(##"{"include": "huge.json"}"##, as: "main.json") + + #expect(folder.refusal(importing: "main.json") == .fileTooLarge(limit: ThemeFileImporter.maximumFileSize)) + } + + @Test("An included file that is not valid JSON names itself and where it breaks") + func malformedIncludeNamesItself() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write("{\n \"colors\": {\n \"editor.background\" \"#111111\"\n }\n}", as: "broken.json") + try folder.write(##"{"include": "broken.json"}"##, as: "main.json") + + guard case let .malformedJSON(detail) = folder.refusal(importing: "main.json") else { + Issue.record("expected malformedJSON") + return + } + #expect(detail?.hasPrefix("broken.json: ") == true, "\(detail ?? "nil")") + #expect(detail?.contains("line 3") == true, "\(detail ?? "nil")") + } + + @Test("A tmTheme named by tokenColors supplies rules, and its global colors win over the JSON's") + func textMateThemePath() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(Self.textMateTheme, as: "mini.tmTheme") + try folder.write(##"{"name": "TM", "colors": {"editor.background": "#000000"}, "tokenColors": "./mini.tmTheme"}"##, as: "tm.json") + + let theme = try folder.importTheme("tm.json") + + #expect(theme.hex(.editorBackground) == "#272822") + #expect(theme.hex(.editorText) == "#F8F8F2") + #expect(theme.hex(.editorCursor) == "#F8F8F0") + #expect(theme.hex(.editorSelection) == "#49483E") + #expect(theme.hex(.editorCurrentLine) == "#3E3D32") + #expect(theme.hex(.editorInvisibles) == "#3B3A32") + #expect(theme.hex(.syntaxKeyword) == "#F92672") + #expect(theme.hex(.syntaxString) == "#E6DB74") + #expect(theme.hex(.syntaxFunction) == "#A6E22E") + #expect(theme.appearance == .dark) + } + + @Test("An include that is not JSON is read as a tmTheme") + func textMateThemeInclude() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(Self.textMateTheme, as: "mini.tmTheme") + try folder.write(##"{"include": "mini.tmTheme", "colors": {"editor.foreground": "#FFFFFF"}}"##, as: "main.json") + + let theme = try folder.importTheme("main.json") + + #expect(theme.hex(.editorBackground) == "#272822") + #expect(theme.hex(.editorText) == "#FFFFFF") + #expect(theme.hex(.syntaxKeyword) == "#F92672") + } + + @Test("A legacy theme with inline tmTheme settings imports") + func legacySettingsArray() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write( + """ + {"name": "Legacy", "settings": [ + {"settings": {"background": "#1D1F21", "foreground": "#C5C8C6"}}, + {"scope": "comment", "settings": {"foreground": "#969896"}} + ], "colors": {"editor.background": "#FF0000"}} + """, + as: "legacy.json" + ) + + let theme = try folder.importTheme("legacy.json") + + #expect(theme.hex(.editorBackground) == "#1D1F21") + #expect(theme.hex(.syntaxComment) == "#969896") + } + + @Test("A file that is not a property list with settings is not a TextMate theme") + func invalidTextMateTheme() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write("not a plist", as: "bad.tmTheme") + try folder.write(##"{"tokenColors": "bad.tmTheme"}"##, as: "main.json") + + #expect(folder.refusal(importing: "main.json") == .invalidTextMateTheme("bad.tmTheme")) + } + + @Test("A binary tmTheme is not read, even with valid settings") + func binaryTextMateTheme() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + let propertyList = try PropertyListSerialization.propertyList(from: Data(Self.textMateTheme.utf8), options: [], format: nil) + try folder.write( + try PropertyListSerialization.data(fromPropertyList: propertyList, format: .binary, options: 0), + as: "mini.tmTheme" + ) + try folder.write(##"{"colors": {"editor.background": "#000000"}, "tokenColors": "./mini.tmTheme"}"##, as: "tm.json") + try folder.write(##"{"include": "mini.tmTheme", "colors": {"editor.background": "#000000"}}"##, as: "include.json") + + #expect(folder.refusal(importing: "tm.json") == .invalidTextMateTheme("./mini.tmTheme")) + #expect(folder.refusal(importing: "include.json") == .invalidTextMateTheme("mini.tmTheme")) + } + + @Test("A 220-byte binary tmTheme whose arrays share their children is refused at once", .timeLimit(.minutes(1))) + func sharedReferenceTextMateTheme() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + let data = SharedReferencePropertyList.make(levels: 40) + var format = PropertyListSerialization.PropertyListFormat.xml + _ = try PropertyListSerialization.propertyList(from: data, options: [], format: &format) + try folder.write(data, as: "x.tmTheme") + try folder.write(##"{"tokenColors": "./x.tmTheme"}"##, as: "x-color-theme.json") + + #expect(format == .binary) + #expect(data.count == 220) + #expect(folder.refusal(importing: "x-color-theme.json") == .invalidTextMateTheme("./x.tmTheme")) + } + + @Test("Short colors expand, an FF alpha drops, and an invalid color is skipped") + func colorSyntax() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(##"{"colors": {"editor.background": "#202020", "editorLineNumber.foreground": "#123456"}}"##, as: "base.json") + try folder.write( + """ + {"include": "base.json", "colors": { + "editorCursor.foreground": "#ABC", + "editor.selectionBackground": "#abcd", + "editorWhitespace.foreground": "#334455FF", + "editor.foreground": "red", + "editor.lineHighlightBackground": "#12345", + "editor.rangeHighlightBackground": "#GGGGGG", + "editorLineNumber.foreground": "rgb(1, 2, 3)", + "editorPane.background": 12 + }} + """, + as: "main.json" + ) + + let theme = try folder.importTheme("main.json") + + #expect(theme.hex(.editorCursor) == "#AABBCC") + #expect(theme.hex(.editorSelection) == "#AABBCCDD") + #expect(theme.hex(.editorInvisibles) == "#334455") + #expect(theme.hex(.editorText) == nil) + #expect(theme.hex(.editorCurrentLine) == nil) + #expect(theme.hex(.editorCurrentStatement) == nil) + #expect(theme.hex(.editorLineNumber) == nil) + #expect(theme.hex(.windowBackdrop) == "#202020") + } + + @Test("A token rule with an invalid color keeps its place but colors nothing") + func invalidTokenColorFallsThrough() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write( + """ + {"colors": {"editor.foreground": "#D0D0D0"}, "tokenColors": [ + {"scope": "keyword", "settings": {"foreground": "#FF0000"}}, + {"scope": "keyword.other", "settings": {"foreground": "rgb(1, 2, 3)"}}, + {"scope": "", "settings": {"foreground": "#00FF00"}}, + {"settings": {"foreground": "#0000FF"}}, + {"scope": "comment", "settings": {"foreground": "#888"}} + ]} + """, + as: "main.json" + ) + + let theme = try folder.importTheme("main.json") + + #expect(theme.hex(.syntaxKeyword) == "#FF0000") + #expect(theme.hex(.syntaxComment) == "#888888") + #expect(theme.hex(.syntaxString) == "#D0D0D0") + } + + @Test("colors that is not an object, tokenColors that is neither a list nor a path, and a non-string include are refused") + func malformedFieldsAreRefused() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(##"{"colors": "#FFFFFF"}"##, as: "colors.json") + try folder.write(##"{"colors": {"editor.background": "#111111"}, "tokenColors": 7}"##, as: "tokens.json") + try folder.write(##"{"include": 5, "colors": {"editor.background": "#111111"}}"##, as: "include.json") + + #expect(folder.refusal(importing: "colors.json") == .invalidColorMap) + #expect(folder.refusal(importing: "tokens.json") == .invalidTokenColors) + #expect(folder.refusal(importing: "include.json") == .notATheme) + } + + @Test("More token rules than the cap are refused") + func tokenRuleCap() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + let selectors = (0 ... 10_000).map { "\"scope.n\($0)\"" }.joined(separator: ",") + try folder.write( + ##"{"colors": {"editor.background": "#111111"}, "tokenColors": [{"scope": [\##(selectors)], "settings": {}}]}"##, + as: "many.json" + ) + + #expect(folder.refusal(importing: "many.json") == .tooManyTokenRules(limit: 10_000)) + } + + @Test("A 50,000-part token scope is refused, not walked one stack frame per part", .timeLimit(.minutes(1))) + func dottedScopeIsRefused() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + let scope = Array(repeating: "a", count: 50_000).joined(separator: ".") + try folder.write( + ##"{"colors": {"editor.background": "#101010"}, "tokenColors": [{"scope": "\##(scope)", "settings": {"foreground": "#FFFFFF"}}]}"##, + as: "evil-color-theme.json" + ) + + #expect(folder.refusal(importing: "evil-color-theme.json") == .tokenScopeTooLong(limit: 4_096)) + } + + @Test("A token scope of more than 32 parts is refused, within the length cap") + func scopePartCap() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + for (parts, name) in [(32, "fits.json"), (33, "over.json")] { + let scope = Array(repeating: "a", count: parts).joined(separator: ".") + try folder.write( + ##"{"colors": {"editor.background": "#101010"}, "tokenColors": [{"scope": "source.sql \##(scope)", "settings": {"foreground": "#FFFFFF"}}]}"##, + as: name + ) + } + + #expect(folder.refusal(importing: "fits.json") == nil) + #expect(folder.refusal(importing: "over.json") == .tokenScopeHasTooManyParts(limit: 32)) + } + + @Test("A theme that nests arrays or objects past 32 levels is refused", .timeLimit(.minutes(1))) + func deepNestingIsRefused() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + let nested = String(repeating: "[", count: 40) + String(repeating: "]", count: 40) + try folder.write(##"{"colors": {"editor.background": "#101010"}, "tokenColors": \##(nested)}"##, as: "deep.json") + try folder.write(String(repeating: "[", count: 100_000) + String(repeating: "]", count: 100_000), as: "deeper.json") + + #expect(folder.refusal(importing: "deep.json") == .nestingTooDeep(limit: 32)) + #expect(folder.refusal(importing: "deeper.json") == .notATheme) + } +} + +// A binary property list whose array at each level holds the next level twice, so `levels` levels +// stand for 2^levels leaves. PropertyListSerialization writes every path out, so it is built by hand. +enum SharedReferencePropertyList { + static func make(levels: Int) -> Data { + var objects: [[UInt8]] = [ + [0xD1, 1, 2], + [0x58] + Array("settings".utf8), + [0xA1, 3], + ] + for level in 0 ..< levels { + let next = UInt8(4 + level) + objects.append([0xA2, next, next]) + } + objects.append([0x51] + Array("x".utf8)) + + var data = Data("bplist00".utf8) + var offsets: [UInt8] = [] + for object in objects { + offsets.append(UInt8(data.count)) + data.append(contentsOf: object) + } + let offsetTableOffset = data.count + data.append(contentsOf: offsets) + // Trailer: six unused bytes, one-byte offsets and references, then three big-endian UInt64s. + data.append(contentsOf: [0, 0, 0, 0, 0, 0, 1, 1]) + for field in [objects.count, 0, offsetTableOffset] { + data.append(contentsOf: (0 ..< 8).reversed().map { UInt8(truncatingIfNeeded: field >> ($0 * 8)) }) + } + return data + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift new file mode 100644 index 0000000000..5e4172abb3 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift @@ -0,0 +1,216 @@ +import Foundation +import TableProTheme +import Testing + +struct VSCodeThemeImportTests { + private static func theme(_ json: String, named fileName: String = "theme.json") throws -> ThemeDefinition { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(json, as: fileName) + return try folder.importTheme(fileName) + } + + private static func refusal(_ json: String) throws -> ThemeLoadError? { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(json, as: "theme.json") + return folder.refusal(importing: "theme.json") + } + + @Test("A VS Code theme is recognized by its keys or its $schema, and gets a new user id every time") + func routingAndFreshId() throws { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(##"{"$schema": "vscode://schemas/color-theme", "colors": {"editor.background": "#1E1E1E"}}"##, as: "a.json") + + let first = try folder.importTheme("a.json") + let second = try folder.importTheme("a.json") + + #expect(first.id != second.id) + #expect(first.id.hasPrefix(ThemeDefinition.userPrefix)) + #expect(ThemeIdentifier.isValid(first.id)) + #expect(first.isEditable) + for key in ["colors", "tokenColors", "include", "settings"] { + #expect(try Self.refusal(##"{"\##(key)": null}"##) != .notATheme, "\(key)") + } + #expect(try Self.refusal(##"{"$schema": "vscode://schemas/color-theme"}"##) == .noUsableColors) + #expect(try Self.refusal(##"{"$schema": "https://json-schema.org/draft-07/schema", "name": "x"}"##) == .notATheme) + } + + @Test("An integer schema routes to the TablePro format, whose strict decoder refuses comments") + func integerSchemaRoutesToTablePro() throws { + let error = try Self.refusal( + """ + {"schema": 3, // a comment + "id": "user.a", "name": "x", "appearance": "dark", "colors": {"editor.background": "#000000"}} + """ + ) + + guard case .malformedJSON = error else { + Issue.record("expected the TablePro decoder's malformedJSON, got \(String(describing: error))") + return + } + } + + @Test("A theme with nothing TablePro reads is refused") + func nothingUsable() throws { + #expect(try Self.refusal(##"{"name": "x", "colors": {}}"##) == .noUsableColors) + #expect(try Self.refusal(##"{"colors": {"activityBar.background": "#123456"}}"##) == .noUsableColors) + #expect( + try Self.refusal(##"{"tokenColors": [{"scope": "markup.heading", "settings": {"foreground": "#FF0000"}}]}"##) + == .noUsableColors + ) + } + + @Test("Appearance comes from type, then the editor background's luminance, then dark") + func appearanceInference() throws { + let darkBackground = ##""colors": {"editor.background": "#1E1E1E"}"## + let lightBackground = ##""colors": {"editor.background": "#FAFAFA"}"## + let tokens = ##""tokenColors": [{"scope": "keyword", "settings": {"foreground": "#AA00AA"}}]"## + + #expect(try Self.theme(##"{"type": "light", \##(darkBackground)}"##).appearance == .light) + #expect(try Self.theme(##"{"type": "VS", \##(darkBackground)}"##).appearance == .light) + #expect(try Self.theme(##"{"type": "hcLight", \##(darkBackground)}"##).appearance == .light) + #expect(try Self.theme(##"{"type": "hc-light", \##(darkBackground)}"##).appearance == .light) + #expect(try Self.theme(##"{"type": "dark", \##(lightBackground)}"##).appearance == .dark) + #expect(try Self.theme(##"{"type": "vs-dark", \##(lightBackground)}"##).appearance == .dark) + #expect(try Self.theme(##"{"type": "hc-black", \##(lightBackground)}"##).appearance == .dark) + #expect(try Self.theme(##"{"type": "hcDark", \##(lightBackground)}"##).appearance == .dark) + #expect(try Self.theme(##"{"type": "sepia", \##(lightBackground)}"##).appearance == .light) + #expect(try Self.theme(##"{\##(lightBackground)}"##).appearance == .light) + #expect(try Self.theme(##"{\##(darkBackground)}"##).appearance == .dark) + #expect(try Self.theme(##"{\##(tokens)}"##).appearance == .dark) + } + + @Test("The name is the file's own, else the file name without .json and -color-theme") + func names() throws { + let colors = ##""colors": {"editor.background": "#1E1E1E"}"## + + #expect(try Self.theme(##"{"name": " Night Owl ", \##(colors)}"##).name == "Night Owl") + #expect(try Self.theme(##"{\##(colors)}"##, named: "dracula-color-theme.json").name == "dracula") + #expect(try Self.theme(##"{\##(colors)}"##, named: "Solarized Dark-Color-Theme.JSON").name == "Solarized Dark") + #expect(try Self.theme(##"{"name": 7, \##(colors)}"##, named: "Plain.json").name == "Plain") + #expect(try Self.theme(##"{"name": " ", \##(colors)}"##, named: "Blank.json").name == "Blank") + #expect(try Self.theme(##"{"name": "Two\nLines\t Here", \##(colors)}"##).name == "Two Lines Here") + + let long = try Self.theme(##"{"name": "\##(String(repeating: "x", count: 150))", \##(colors)}"##) + #expect(long.name == String(repeating: "x", count: ThemeFileFormat.maximumTextLength)) + } + + @Test("The author is kept when it is a string") + func author() throws { + let colors = ##""colors": {"editor.background": "#1E1E1E"}"## + + #expect(try Self.theme(##"{"author": "Zeno Rocha", \##(colors)}"##).author == "Zeno Rocha") + #expect(try Self.theme(##"{"author": {"name": "x"}, \##(colors)}"##).author.isEmpty) + #expect(try Self.theme(##"{\##(colors)}"##).author.isEmpty) + } + + @Test("A syntax slot no rule colors takes editor.foreground, as VS Code paints it, and NULL takes the keyword") + func syntaxFallsBackToEditorForeground() throws { + let theme = try Self.theme( + """ + {"colors": {"editor.background": "#1E1E1E", "editor.foreground": "#ABCDEF"}, + "tokenColors": [{"scope": "keyword", "settings": {"foreground": "#FF00FF"}}]} + """ + ) + + #expect(theme.hex(.syntaxKeyword) == "#FF00FF") + #expect(theme.hex(.syntaxNull) == "#FF00FF") + #expect(theme.hex(.syntaxOperator) == "#FF00FF") + for slot in [ThemeSlot.syntaxString, .syntaxNumber, .syntaxComment, .syntaxFunction, .syntaxType] { + #expect(theme.hex(slot) == "#ABCDEF", "\(slot)") + } + } + + @Test("Without editor.foreground, syntax takes VS Code's default text color for the theme's type") + func syntaxFallsBackToTheRegistryDefault() throws { + let dark = try Self.theme(##"{"colors": {"editor.background": "#101010"}}"##) + let light = try Self.theme(##"{"type": "light", "colors": {"editor.background": "#FFFFFF"}}"##) + let highContrast = try Self.theme(##"{"type": "hc-black", "colors": {"editor.background": "#000000"}}"##) + + #expect(dark.hex(.syntaxKeyword) == "#BBBBBB") + #expect(light.hex(.syntaxKeyword) == "#333333") + #expect(highContrast.hex(.syntaxKeyword) == "#FFFFFF") + #expect(dark.hex(.editorText) == nil) + } + + @Test("The four fills nothing themed sits under are flattened; washes keep their alpha") + func opaqueSlotsAreFlattened() throws { + let theme = try Self.theme( + """ + {"type": "dark", "colors": { + "editor.background": "#20406080", + "editorPane.background": "#FFFFFF33", + "keybindingTable.headerBackground": "#FFFFFF1A", + "editor.selectionBackground": "#3050A080", + "keybindingTable.rowsBackground": "#FFFFFF0A" + }} + """ + ) + let editor = ThemeColor(red: 0x20, green: 0x40, blue: 0x60, alpha: 0x80) + .composited(over: ThemeColor(red: 0x1E, green: 0x1E, blue: 0x1E)) + + #expect(theme.overrides[.editorBackground] == editor) + #expect(theme.overrides[.gridBackground] == editor) + #expect(theme.overrides[.windowBackdrop] == ThemeColor(red: 255, green: 255, blue: 255, alpha: 0x33).composited(over: editor)) + #expect(theme.overrides[.gridHeaderBackground] == ThemeColor(red: 255, green: 255, blue: 255, alpha: 0x1A).composited(over: editor)) + for slot in ThemeSlot.allCases where slot.requiresOpaque { + #expect(theme.overrides[slot]?.isOpaque == true, "\(slot)") + } + #expect(theme.hex(.editorSelection) == "#3050A080") + #expect(theme.hex(.gridAlternateRow) == "#FFFFFF0A") + } + + @Test("A slot the fallback rules already derive from what the theme sets stays unset") + func derivableSlotsStayUnset() throws { + let theme = try Self.theme( + """ + {"colors": { + "editor.background": "#202020", + "editor.foreground": "#E0E0E0", + "foreground": "#E0E0E0", + "keybindingTable.headerBackground": "#202020", + "list.activeSelectionForeground": "#FFFFFF" + }, "tokenColors": [{"scope": "constant.language.boolean", "settings": {"foreground": "#E0E0E0"}}]} + """ + ) + + #expect(theme.hex(.gridHeaderText) == nil) + #expect(theme.hex(.gridHeaderBackground) == nil) + #expect(theme.hex(.gridSelectionText) == nil) + #expect(theme.hex(.gridBoolTrue) == nil) + #expect(theme.hex(.gridBoolFalse) == nil) + #expect(theme.value(for: .gridHeaderText) == .themed(ThemeColor(red: 0xE0, green: 0xE0, blue: 0xE0))) + #expect(theme.value(for: .gridHeaderBackground) == .themed(ThemeColor(red: 0x20, green: 0x20, blue: 0x20))) + } + + @Test("Selection text is kept while the theme also sets the selection and the two differ from the text") + func pairedSelectionTextIsKept() throws { + let theme = try Self.theme( + """ + {"colors": {"editor.background": "#202020", "editor.foreground": "#E0E0E0", + "list.activeSelectionBackground": "#0050A0", "list.activeSelectionForeground": "#FFFFFF"}} + """ + ) + + #expect(theme.hex(.gridSelection) == "#0050A0") + #expect(theme.hex(.gridSelectionText) == "#FFFFFF") + } + + @Test("An imported theme survives the TablePro format unchanged") + func roundTripsThroughTheFormat() throws { + let theme = try Self.theme( + """ + {"name": "Round Trip", "author": "Someone", "type": "light", "colors": { + "editor.background": "#FDF6E3", "editor.foreground": "#657B83", "editor.selectionBackground": "#EEE8D5AA", + "list.activeSelectionBackground": "#DFCA88", "diffEditor.insertedLineBackground": "#9BB95533" + }, "tokenColors": [{"scope": "comment", "settings": {"foreground": "#93A1A1", "fontStyle": "italic"}}]} + """ + ) + + let decoded = try ThemeFileFormat.decode(try ThemeFileFormat.encode(theme)) + + #expect(decoded == theme) + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift new file mode 100644 index 0000000000..2e7f5fa335 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift @@ -0,0 +1,194 @@ +import Foundation +@testable import TableProTheme +import Testing + +// Two fixtures, each read through an exhaustive switch, so a new slot cannot ship without a decision +// about what a VS Code theme gives it. +struct VSCodeThemeMappingTests { + // Every slot's first source answers, each with its own color. + private static let firstSources = """ + {"type": "dark", "colors": { + "editorPane.background": "#101010", + "editor.background": "#202020", + "editor.foreground": "#E0E0E0", + "editorCursor.foreground": "#F0A000", + "editor.selectionBackground": "#3050A080", + "editor.lineHighlightBackground": "#FFFFFF10", + "editor.lineHighlightBorder": "#999999", + "editor.rangeHighlightBackground": "#FFFF0020", + "editorLineNumber.foreground": "#808080", + "editorWhitespace.foreground": "#404040", + "keybindingTable.rowsBackground": "#252525", + "keybindingTable.headerBackground": "#2A2A2A", + "tree.tableOddRowsBackground": "#999999", + "foreground": "#C0C0C0", + "tree.tableColumnsBorder": "#303030", + "list.activeSelectionBackground": "#0050A0", + "list.activeSelectionForeground": "#FFFFFF", + "list.inactiveSelectionBackground": "#00305080", + "list.focusOutline": "#0080FF", + "focusBorder": "#999999", + "input.placeholderForeground": "#707070", + "gitDecoration.modifiedResourceForeground": "#E0C000", + "diffEditor.insertedLineBackground": "#00FF0020", + "diffEditor.insertedTextBackground": "#999999", + "diffEditor.removedLineBackground": "#FF000020", + "diffEditor.removedTextBackground": "#999999", + "gitDecoration.deletedResourceForeground": "#FF4040" + }, "tokenColors": [ + {"scope": "keyword.other.DML", "settings": {"foreground": "#C00001"}}, + {"scope": "string.quoted.single", "settings": {"foreground": "#C00002"}}, + {"scope": "constant.numeric", "settings": {"foreground": "#C00003"}}, + {"scope": "comment.line.double-dash", "settings": {"foreground": "#C00004"}}, + {"scope": "constant.language.null", "settings": {"foreground": "#C00005"}}, + {"scope": "keyword.operator.comparison", "settings": {"foreground": "#C00006"}}, + {"scope": "entity.name.function", "settings": {"foreground": "#C00007"}}, + {"scope": "support.function.aggregate", "settings": {"foreground": "#999999"}}, + {"scope": "entity.name.type", "settings": {"foreground": "#C00008"}}, + {"scope": "storage.type", "settings": {"foreground": "#999999"}}, + {"scope": "constant.language.boolean.true", "settings": {"foreground": "#C00009"}}, + {"scope": "constant.language.boolean.false", "settings": {"foreground": "#C0000A"}} + ]} + """ + + private static func expectedFromFirstSources(_ slot: ThemeSlot) -> String? { + switch slot { + case .windowBackdrop: return "#101010" + case .editorBackground: return "#202020" + case .editorText: return "#E0E0E0" + case .editorCursor: return "#F0A000" + case .editorSelection: return "#3050A080" + case .editorCurrentLine: return "#FFFFFF10" + case .editorCurrentStatement: return "#FFFF0020" + case .editorLineNumber: return "#808080" + case .editorInvisibles: return "#404040" + case .syntaxKeyword: return "#C00001" + case .syntaxString: return "#C00002" + case .syntaxNumber: return "#C00003" + case .syntaxComment: return "#C00004" + case .syntaxNull: return "#C00005" + case .syntaxOperator: return "#C00006" + case .syntaxFunction: return "#C00007" + case .syntaxType: return "#C00008" + case .gridBackground: return "#202020" + case .gridText: return "#E0E0E0" + case .gridAlternateRow: return "#252525" + case .gridHeaderBackground: return "#2A2A2A" + case .gridHeaderText: return "#C0C0C0" + case .gridLine: return "#303030" + case .gridSelection: return "#0050A0" + case .gridSelectionText: return "#FFFFFF" + case .gridInactiveSelection: return "#00305080" + case .gridFocusBorder: return "#0080FF" + case .gridNullValue: return "#707070" + case .gridBoolTrue: return "#C00009" + case .gridBoolFalse: return "#C0000A" + case .gridRowNumber: return "#808080" + // 0xFF scaled by 0.3 and by 0.5, rounded. + case .gridModified: return "#E0C0004D" + case .gridInserted: return "#00FF0020" + case .gridDeleted: return "#FF000020" + case .gridDeletedText: return "#FF404080" + } + } + + // Only the later sources answer; slots with none stay unset and draw their fallback. + private static let laterSources = """ + {"colors": { + "editor.background": "#F8F8F8", + "editor.foreground": "#202020", + "editor.lineHighlightBorder": "#E0E0E0", + "tree.tableOddRowsBackground": "#00000008", + "foreground": "#404040", + "focusBorder": "#3366FF", + "list.activeSelectionForeground": "#FFFFFF", + "diffEditor.insertedTextBackground": "#00AA0033", + "diffEditor.removedTextBackground": "#AA000033" + }, "tokenColors": [ + {"scope": "support.function.aggregate", "settings": {"foreground": "#900001"}}, + {"scope": "storage.type", "settings": {"foreground": "#900002"}} + ]} + """ + + private static func expectedFromLaterSources(_ slot: ThemeSlot) -> String? { + switch slot { + case .windowBackdrop: return "#F8F8F8" + case .editorBackground: return "#F8F8F8" + case .editorText: return "#202020" + case .editorCursor: return nil + case .editorSelection: return nil + case .editorCurrentLine: return "#E0E0E0" + case .editorCurrentStatement: return nil + case .editorLineNumber: return nil + case .editorInvisibles: return nil + case .syntaxKeyword: return "#202020" + case .syntaxString: return "#202020" + case .syntaxNumber: return "#202020" + case .syntaxComment: return "#202020" + case .syntaxNull: return "#202020" + case .syntaxOperator: return "#202020" + case .syntaxFunction: return "#900001" + case .syntaxType: return "#900002" + case .gridBackground: return "#F8F8F8" + case .gridText: return "#202020" + case .gridAlternateRow: return "#00000008" + // #00000008 flattened over #F8F8F8. + case .gridHeaderBackground: return "#F0F0F0" + case .gridHeaderText: return "#404040" + case .gridLine: return nil + case .gridSelection: return nil + // Without a theme selection AppKit keeps its own white text, so the color is dropped. + case .gridSelectionText: return nil + case .gridInactiveSelection: return nil + case .gridFocusBorder: return "#3366FF" + case .gridNullValue: return "#40404080" + case .gridBoolTrue: return nil + case .gridBoolFalse: return nil + case .gridRowNumber: return nil + case .gridModified: return nil + case .gridInserted: return "#00AA0033" + case .gridDeleted: return "#AA000033" + case .gridDeletedText: return nil + } + } + + private static func importTheme(_ json: String) throws -> ThemeDefinition { + let folder = try ThemeImportFolder() + defer { folder.remove() } + try folder.write(json, as: "fixture.json") + return try folder.importTheme("fixture.json") + } + + @Test("Every slot takes its first VS Code source", arguments: ThemeSlot.allCases) + func firstSourceWins(_ slot: ThemeSlot) throws { + let theme = try Self.importTheme(Self.firstSources) + + #expect(theme.hex(slot) == Self.expectedFromFirstSources(slot)) + } + + @Test("Every slot falls through to its later VS Code sources, else to its fallback", arguments: ThemeSlot.allCases) + func laterSourcesApply(_ slot: ThemeSlot) throws { + let theme = try Self.importTheme(Self.laterSources) + + #expect(theme.hex(slot) == Self.expectedFromLaterSources(slot)) + #expect(theme.appearance == .light) + } + + @Test("A syntax slot's sources always end in a color, and slot references never loop", arguments: ThemeSlot.allCases) + func sourceListsAreWellFormed(_ slot: ThemeSlot) { + var visited: Set = [slot] + var current = slot + while case let .slot(next) = current.vscodeSources.last { + #expect(visited.insert(next).inserted, "\(slot) loops through \(next)") + guard visited.count <= ThemeSlot.allCases.count else { return } + current = next + } + + if slot.section == .syntax { + #expect(current.vscodeSources.last == .defaultTokenForeground, "\(slot)") + } + for case let .token(stack) in slot.vscodeSources { + #expect(stack.first == "source.sql", "\(slot)") + } + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeValueTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeValueTests.swift new file mode 100644 index 0000000000..36d6a5ea5c --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeValueTests.swift @@ -0,0 +1,78 @@ +import Foundation +@testable import TableProTheme +import Testing + +struct VSCodeThemeValueTests { + private static func nestedArrays(_ levels: Int) -> String { + String(repeating: "[", count: levels) + String(repeating: "]", count: levels) + } + + private static func nestedPropertyList(_ levels: Int) -> Any { + var value: Any = "leaf" + for _ in 0 ..< levels { + value = [value] + } + return value + } + + @Test("JSON nests at most 32 arrays and objects, and the refusal is not lost as a type mismatch") + func jsonDepthCap() throws { + let objects = String(repeating: #"{"a": "#, count: 33) + "1" + String(repeating: "}", count: 33) + + #expect(throws: Never.self) { try VSCodeThemeValue.parse(Data(Self.nestedArrays(32).utf8)) } + #expect(throws: ThemeLoadError.nestingTooDeep(limit: 32)) { try VSCodeThemeValue.parse(Data(Self.nestedArrays(33).utf8)) } + #expect(throws: ThemeLoadError.nestingTooDeep(limit: 32)) { try VSCodeThemeValue.parse(Data(objects.utf8)) } + } + + @Test("JSON nested 100,000 levels is refused, not decoded one stack frame per level", .timeLimit(.minutes(1))) + func jsonFarPastTheCap() { + #expect(throws: ThemeLoadError.self) { try VSCodeThemeValue.parse(Data(Self.nestedArrays(100_000).utf8)) } + } + + @Test("Objects and arrays keep their values and keys after the element-wise decode") + func jsonShapes() throws { + let value = try VSCodeThemeValue.parse(Data(#"{"a": [1, "b", true, null], "c": {"d": "e"}}"#.utf8)) + + #expect(value == .object([ + "a": .array([.number(1), .string("b"), .bool(true), .null]), + "c": .object(["d": .string("e")]), + ])) + } + + @Test("A property list nests at most 32 arrays and dictionaries") + func propertyListDepthCap() { + #expect(VSCodeThemeValue(propertyList: Self.nestedPropertyList(32)) != nil) + #expect(VSCodeThemeValue(propertyList: Self.nestedPropertyList(33)) == nil) + } + + @Test("A property list graph that repeats its containers stops at the value budget", .timeLimit(.minutes(1))) + func propertyListBudget() { + var shared: Any = "leaf" + for _ in 0 ..< 30 { + shared = [shared, shared] + } + let fits = Array(repeating: "x", count: VSCodeThemeValue.maximumPropertyListValues - 1) + + #expect(VSCodeThemeValue(propertyList: shared) == nil) + #expect(VSCodeThemeValue(propertyList: fits) != nil) + #expect(VSCodeThemeValue(propertyList: fits + ["one more"]) == nil) + } + + @Test("A binary property list read back keeps its sharing, and the conversion still stops", .timeLimit(.minutes(1))) + func parsedSharedGraph() throws { + let propertyList = try PropertyListSerialization.propertyList( + from: SharedReferencePropertyList.make(levels: 30), + options: [], + format: nil + ) + + #expect(VSCodeThemeValue(propertyList: propertyList) == nil) + } + + @Test("A property list converts strings, numbers, booleans, arrays and dictionaries") + func propertyListShapes() { + let value = VSCodeThemeValue(propertyList: ["a": ["b", 2, true] as [Any], "c": Date()] as [String: Any]) + + #expect(value == .object(["a": .array([.string("b"), .number(2), .number(1)]), "c": .null])) + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/ThemeColorTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/ThemeColorTests.swift new file mode 100644 index 0000000000..a3e728e711 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/ThemeColorTests.swift @@ -0,0 +1,119 @@ +import Foundation +import TableProTheme +import Testing + +struct ThemeColorTests { + // MARK: - Parsing + + @Test("Six and eight digit colors parse, trimmed, in either case") + func acceptedForms() throws { + let opaque = try #require(ThemeColor(hex: " #ff79c6\n")) + #expect(opaque == ThemeColor(red: 0xFF, green: 0x79, blue: 0xC6)) + #expect(opaque.hex == "#FF79C6") + + let translucent = try #require(ThemeColor(hex: "#ff79c680")) + #expect(translucent.alpha == 0x80) + #expect(translucent.hex == "#FF79C680") + } + + @Test("Anything other than ASCII #RRGGBB or #RRGGBBAA is refused") + func refusedForms() { + let rejected = [ + "#FF79CG", + "FF0000", + "#FFF", + "#FFFF", + "#FF00000", + "#FF0000000", + "", + "#", + "system:label", + "#\u{FF26}\u{FF26}0000", + "# FF0000", + ] + for value in rejected { + #expect(ThemeColor(hex: value) == nil, "\(value)") + #expect(throws: ThemeLoadError.invalidColor(value)) { try ThemeColor(validating: value) } + } + } + + @Test("An opaque eight digit color has the six digit spelling") + func canonicalHex() throws { + #expect(try #require(ThemeColor(hex: "#112233FF")).hex == "#112233") + #expect(try #require(ThemeColor(hex: "#11223300")).hex == "#11223300") + #expect(ThemeColor(hex: "#112233FF") == ThemeColor(hex: "#112233")) + } + + @Test("Equality and hashing are on the components") + func equalityOnComponents() { + let lower = ThemeColor(hex: "#abcdef") + let upper = ThemeColor(hex: "#ABCDEF") + #expect(lower == upper) + #expect(Set([lower, upper]).count == 1) + #expect(ThemeColor(hex: "#ABCDEF") != ThemeColor(hex: "#ABCDEF80")) + } + + @Test("opaque keeps the color and drops the alpha") + func opaqueDropsAlpha() throws { + let color = try #require(ThemeColor(hex: "#28AA3680")) + #expect(!color.isOpaque) + #expect(color.opaque == ThemeColor(red: 0x28, green: 0xAA, blue: 0x36)) + #expect(color.opaque.isOpaque) + } + + // MARK: - Math + + @Test("Source-over compositing") + func composite() throws { + let halfRed = try #require(ThemeColor(hex: "#FF000080")) + #expect(halfRed.composited(over: .white).hex == "#FF7F7F") + #expect(ThemeColor.black.composited(over: .white) == .black) + #expect(ThemeColor(red: 9, green: 9, blue: 9, alpha: 0).composited(over: .white) == .white) + + let halfBlack = try #require(ThemeColor(hex: "#00000080")) + let halfWhite = try #require(ThemeColor(hex: "#FFFFFF80")) + #expect(halfBlack.composited(over: halfWhite).hex == "#555555C0") + } + + @Test("Over an opaque base the result is opaque") + func compositeOverOpaqueIsOpaque() throws { + let wash = try #require(ThemeColor(hex: "#44475A26")) + let base = try #require(ThemeColor(hex: "#282A36")) + #expect(wash.composited(over: base).isOpaque) + } + + @Test("A mix moves each component and keeps the alpha") + func mix() throws { + #expect(ThemeColor.black.mixed(with: .white, fraction: 0.5).hex == "#808080") + #expect(ThemeColor.black.mixed(with: .white, fraction: 0) == .black) + #expect(ThemeColor.black.mixed(with: .white, fraction: 1) == .white) + #expect(ThemeColor.black.mixed(with: .white, fraction: 2) == .white) + + let translucent = try #require(ThemeColor(hex: "#20406080")) + #expect(translucent.mixed(with: .white, fraction: 0.25).hex == "#58708880") + } + + @Test("Relative luminance matches WCAG") + func luminance() throws { + #expect(ThemeColor.black.relativeLuminance == 0) + #expect(abs(ThemeColor.white.relativeLuminance - 1) < 1e-9) + let grey = try #require(ThemeColor(hex: "#767676")) + #expect(abs(grey.relativeLuminance - 0.1812) < 0.0005) + } + + @Test("Contrast ratios of known WCAG pairs") + func contrast() throws { + #expect(abs(ThemeColor.black.contrastRatio(with: .white) - 21) < 1e-9) + #expect(ThemeColor.white.contrastRatio(with: .white) == 1) + + let aaGrey = try #require(ThemeColor(hex: "#767676")) + #expect(abs(aaGrey.contrastRatio(with: .white) - 4.54) < 0.01) + #expect(aaGrey.contrastRatio(with: .white) == ThemeColor.white.contrastRatio(with: aaGrey)) + + let failingGrey = try #require(ThemeColor(hex: "#777777")) + #expect(failingGrey.contrastRatio(with: .white) < 4.5) + + let aaaGrey = try #require(ThemeColor(hex: "#595959")) + #expect(abs(aaaGrey.contrastRatio(with: .white) - 7.0) < 0.01) + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift new file mode 100644 index 0000000000..6b0c0dc2a7 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift @@ -0,0 +1,447 @@ +import Foundation +import TableProTheme +import Testing + +/// Format 3: every color is optional, a slot left out draws its fallback, and the file is refused for +/// anything that would otherwise load as something its author did not write. +struct ThemeFileFormatTests { + private static func data(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object) + } + + private static func metadata( + id: String = "user.test", + appearance: ThemeAppearance = .dark, + schema: Any = 3 + ) -> [String: Any] { + ["schema": schema, "id": id, "name": "Test", "appearance": appearance.rawValue] + } + + private static func decode(_ object: [String: Any]) throws -> ThemeDefinition { + try ThemeFileFormat.decode(try data(object)) + } + + private static func withColor(_ value: String, at path: [String]) -> [String: Any] { + var root = metadata() + insert(value, at: path, into: &root) + return root + } + + private static func insert(_ value: Any, at path: [String], into container: inout [String: Any]) { + guard let head = path.first else { return } + guard path.count > 1 else { + container[head] = value + return + } + var child = container[head] as? [String: Any] ?? [:] + insert(value, at: Array(path.dropFirst()), into: &child) + container[head] = child + } + + private static func loads(_ object: [String: Any]) -> Bool { + (try? decode(object)) != nil + } + + private static func refusal(_ object: [String: Any]) throws -> ThemeLoadError? { + try refusal(data(object)) + } + + private static func refusal(_ data: Data) -> ThemeLoadError? { + do { + _ = try ThemeFileFormat.decode(data) + return nil + } catch { + return error as? ThemeLoadError + } + } + + // MARK: - What loads + + @Test("The current format is 3") + func currentFormatIsThree() { + #expect(ThemeSchema.current == 3) + } + + @Test("A file with only metadata loads and sets nothing") + func metadataOnlyLoads() throws { + let theme = try Self.decode(Self.metadata()) + + #expect(theme.id == "user.test") + #expect(theme.name == "Test") + #expect(theme.author.isEmpty) + #expect(theme.appearance == .dark) + #expect(theme.overrides.isEmpty) + for slot in ThemeSlot.allCases { + #expect(!theme.hasExplicitColor(slot), "\(slot.rawValue)") + } + } + + @Test("A file sets only the slots it names") + func slotsAreOptional() throws { + var root = Self.metadata() + Self.insert("#FF0000", at: ["editor", "syntax", "keyword"], into: &root) + Self.insert("#00000080", at: ["dataGrid", "selection"], into: &root) + + let colors = try Self.decode(root).overrides.mapValues(\.hex) + + #expect(colors == [.syntaxKeyword: "#FF0000", .gridSelection: "#00000080"]) + } + + @Test("Every slot path is accepted") + func everySlotPathLoads() throws { + var root = Self.metadata() + for slot in ThemeSlot.allCases { + Self.insert("#123456", at: slot.rawValue.components(separatedBy: "."), into: &root) + } + + #expect(try Self.decode(root).overrides.count == ThemeSlot.allCases.count) + } + + @Test("An 8-digit color keeps its alpha on a slot that offers opacity") + func alphaColorIsAccepted() throws { + let colors = try Self.decode(Self.withColor("#ff79c680", at: ["editor", "selection"])).overrides + let color = try #require(colors[.editorSelection]) + + #expect(color.hex == "#FF79C680") + #expect(color.alpha == 0x80) + } + + @Test("A fill that must be opaque loads without its alpha, and is written without it", arguments: [ + ThemeSlot.windowBackdrop, .editorBackground, .gridBackground, .gridHeaderBackground, + ]) + func opaqueSlotsDropAlphaOnLoad(slot: ThemeSlot) throws { + let theme = try Self.decode(Self.withColor("#282A3680", at: slot.rawValue.components(separatedBy: "."))) + #expect(theme.overrides[slot]?.hex == "#282A36") + + var translucent = theme + translucent.overrides[slot] = ThemeColor(hex: "#282A3640") + let reread = try ThemeFileFormat.decode(try ThemeFileFormat.encode(translucent)) + #expect(reread.overrides[slot]?.hex == "#282A36") + } + + @Test("Name and author are trimmed") + func nameAndAuthorAreTrimmed() throws { + var root = Self.metadata() + root["name"] = " Ocean " + root["author"] = "\tSomeone \n" + + let theme = try Self.decode(root) + + #expect(theme.name == "Ocean") + #expect(theme.author == "Someone") + } + + // MARK: - What is refused + + @Test("A malformed file says where it breaks") + func malformedJSONCarriesTheLocation() throws { + let text = "{\n \"schema\": 3,\n \"id\": \"user.a\"\n \"name\": \"x\"\n}" + let error = try #require(Self.refusal(Data(text.utf8))) + + guard case let .malformedJSON(detail) = error else { + Issue.record("expected malformedJSON, got \(error)") + return + } + let location = try #require(detail) + #expect(location.contains("line 4"), "\(location)") + #expect(location.contains("column"), "\(location)") + + let message = try #require(error.errorDescription) + #expect(message.hasPrefix("The file is not valid JSON.")) + #expect(message.components(separatedBy: "\n").last == location) + } + + @Test("Empty data and a comment are refused as malformed JSON") + func notJSONIsMalformed() { + for text in ["", "{\"schema\": 3, // note\n}", "not json"] { + guard case .malformedJSON = Self.refusal(Data(text.utf8)) else { + Issue.record("\(text.debugDescription) was not refused as malformed JSON") + continue + } + } + } + + @Test("A JSON value that is not an object declares no format") + func nonObjectRootIsRefused() { + #expect(Self.refusal(Data("[1, 2]".utf8)) == .missingSchema) + #expect(Self.refusal(Data("\"text\"".utf8)) == .missingSchema) + } + + @Test("A key TablePro does not use is refused, and the message names it") + func unknownKeyIsRefused() throws { + var root = Self.metadata() + Self.insert("#000000", at: ["editor", "sidebar"], into: &root) + Self.insert("#000000", at: ["panel", "text"], into: &root) + + let error = try Self.refusal(root) + + #expect(error == .unknownKeys(["editor.sidebar", "panel.text"])) + #expect(error?.localizedDescription.contains("editor.sidebar") == true) + } + + /// The pane and status groups of format 2 are gone, so a file that still carries them is + /// refused rather than loaded with those colors dropped. + @Test("Format 2 slot groups are refused") + func formerGroupsAreRefused() throws { + for path in [["panel", "background"], ["status", "warning"], ["content", "editor", "background"]] { + let error = try Self.refusal(Self.withColor("#000000", at: path)) + #expect(error == .unknownKeys([path.joined(separator: ".")]), "\(path)") + } + } + + /// A value that is not a string would otherwise be dropped silently, which loads the slot as + /// native under a key the author did write. + @Test("A key whose value is not a color string is refused") + func nonStringValueIsRefused() { + var unknownNumber = Self.metadata() + unknownNumber["version"] = 1 + #expect(!Self.loads(unknownNumber), "an unknown numeric key loaded") + + var numericColor = Self.metadata() + Self.insert(16_777_215, at: ["editor", "background"], into: &numericColor) + #expect(!Self.loads(numericColor), "a numeric color loaded") + } + + @Test("A slot written twice, nested and dotted, is refused") + func duplicateSlotIsRefused() throws { + var root = Self.withColor("#000000", at: ["editor", "background"]) + root["editor.background"] = "#FFFFFF" + + #expect(try Self.refusal(root) == .duplicateKey("editor.background")) + } + + @Test("A color that is not ASCII #RRGGBB or #RRGGBBAA is refused") + func malformedColorsAreRefused() throws { + let rejected = ["#FF79CG", "FF0000", "#FFF", "#FF00000", "", "system:label", "#\u{FF26}\u{FF26}0000"] + + for value in rejected { + let error = try Self.refusal(Self.withColor(value, at: ["editor", "background"])) + #expect(error == .invalidColor(value), "\(value)") + } + } + + @Test("Format 2 and a file with no format are refused") + func otherFormatsAreRefused() throws { + #expect(try Self.refusal(Self.metadata(schema: 2)) == .schemaTooOld(found: 2, supported: 3)) + #expect(try Self.refusal(Self.metadata(schema: 4)) == .schemaTooNew(found: 4, supported: 3)) + #expect(try Self.refusal(Self.metadata(schema: "3")) == .missingSchema) + + var unversioned = Self.metadata() + unversioned.removeValue(forKey: "schema") + #expect(try Self.refusal(unversioned) == .missingSchema) + #expect(try Self.refusal([:]) == .missingSchema) + + var legacy = Self.metadata() + legacy.removeValue(forKey: "schema") + legacy["version"] = 1 + #expect(try Self.refusal(legacy) == .schemaTooOld(found: 1, supported: 3)) + } + + @Test("Missing metadata is refused by name") + func missingMetadataIsRefused() throws { + for key in ["id", "name", "appearance"] { + var root = Self.metadata() + root.removeValue(forKey: key) + #expect(try Self.refusal(root) == .missingField(key), "\(key)") + } + + var blankName = Self.metadata() + blankName["name"] = " " + #expect(try Self.refusal(blankName) == .missingField("name")) + + var unknownAppearance = Self.metadata() + unknownAppearance["appearance"] = "sepia" + #expect(try Self.refusal(unknownAppearance) == .missingField("appearance")) + } + + @Test("A name or author on more than one line, with a control character, or too long is refused") + func nameAndAuthorLimits() throws { + let long = String(repeating: "a", count: ThemeFileFormat.maximumTextLength + 1) + let fits = String(repeating: "a", count: ThemeFileFormat.maximumTextLength) + + for bad in ["Two\nLines", "Tab\u{0009}Inside", "Bell\u{0007}", "Line\u{2028}Separator", long] { + var name = Self.metadata() + name["name"] = bad + #expect(try Self.refusal(name) == .invalidName, "\(bad.debugDescription)") + + var author = Self.metadata() + author["author"] = bad + #expect(try Self.refusal(author) == .invalidAuthor, "\(bad.debugDescription)") + } + + var longest = Self.metadata() + longest["name"] = fits + longest["author"] = fits + #expect(try Self.decode(longest).name == fits) + } + + @Test("A repaired decode folds a name or author onto one line and cuts it at the cap, and the result encodes") + func repairedDecodeFitsTheCap() throws { + let cap = ThemeFileFormat.maximumTextLength + let cases: [(raw: String, repaired: String)] = [ + (String(repeating: "N", count: cap + 50), String(repeating: "N", count: cap)), + ("Two\nLines", "Two Lines"), + ("Tab\u{0009}Inside and\u{2028}Separator", "Tab Inside and Separator"), + ("Bell\u{0007}", "Bell"), + (String(repeating: "ab ", count: cap), String(repeating: "ab ", count: cap / 3) + "a"), + (" Kept as is ", "Kept as is"), + ] + + for (raw, expected) in cases { + var root = Self.metadata() + root["name"] = raw + root["author"] = raw + let data = try Self.data(root) + + let theme = try ThemeFileFormat.decode(data, text: .repaired) + + #expect(theme.name == expected, "\(raw.debugDescription)") + #expect(theme.author == expected, "\(raw.debugDescription)") + #expect(try ThemeFileFormat.decode(try ThemeFileFormat.encode(theme)) == theme, "\(raw.debugDescription)") + } + } + + @Test("A repaired decode still refuses a name with nothing left to show") + func repairedDecodeRefusesAnEmptyName() throws { + var root = Self.metadata() + root["name"] = "\u{0007}\u{0008}" + + #expect(throws: ThemeLoadError.invalidName) { try ThemeFileFormat.decode(try Self.data(root), text: .repaired) } + } + + @Test("An id that is not a plain file-safe name is refused") + func invalidIdIsRefused() throws { + let long = "user." + String(repeating: "a", count: ThemeIdentifier.maximumLength) + for id in ["user/evil", "../escape", "user..x", "user x", "user.\u{00E9}", long] { + #expect(try Self.refusal(Self.metadata(id: id)) == .invalidIdentifier(id), "\(id)") + } + } + + // MARK: - Identifiers + + @Test("An id is capped at 128 characters") + func identifierCap() { + let fits = String(repeating: "a", count: ThemeIdentifier.maximumLength) + #expect(ThemeIdentifier.maximumLength == 128) + #expect(ThemeIdentifier.isValid(fits)) + #expect(!ThemeIdentifier.isValid(fits + "a")) + } + + @Test("A generated id is a valid user id, different each time") + func generatedIdentifiers() { + let first = ThemeIdentifier.generated() + let second = ThemeIdentifier.generated() + + #expect(first != second) + #expect(first.hasPrefix(ThemeDefinition.userPrefix)) + #expect(ThemeIdentifier.isValid(first)) + } + + // MARK: - Encoder + + @Test("The encoder writes metadata and the colors set, nothing else") + func encoderWritesOnlyOverrides() throws { + let theme = ThemeDefinition( + id: "user.sparse", + name: "Sparse", + author: "Tests", + appearance: .light, + overrides: [ + .syntaxKeyword: try ThemeColor(validating: "#AA0000"), + .gridBackground: try ThemeColor(validating: "#FAFAFA"), + ] + ) + + let object = try JSONSerialization.jsonObject(with: try ThemeFileFormat.encode(theme)) + let root = try #require(object as? [String: Any]) + let editor = try #require(root["editor"] as? [String: Any]) + let syntax = try #require(editor["syntax"] as? [String: String]) + let grid = try #require(root["dataGrid"] as? [String: String]) + + #expect(Set(root.keys) == ["schema", "id", "name", "author", "appearance", "editor", "dataGrid"]) + #expect(Set(editor.keys) == ["syntax"]) + #expect(syntax == ["keyword": "#AA0000"]) + #expect(grid == ["background": "#FAFAFA"]) + #expect(root["schema"] as? Int == 3) + } + + @Test("A default theme encodes as metadata only") + func defaultThemeEncodesNoColors() throws { + let object = try JSONSerialization.jsonObject(with: try ThemeFileFormat.encode(.defaultLight)) + let root = try #require(object as? [String: Any]) + + #expect(Set(root.keys) == ["schema", "id", "name", "author", "appearance"]) + } + + @Test("The encoder refuses what the decoder would refuse") + func encoderRefusesInvalidMetadata() throws { + var theme = ThemeDefinition(id: "user.ok", name: "Ok", author: "", appearance: .dark, overrides: [:]) + + theme.name = "Two\nLines" + #expect(throws: ThemeLoadError.invalidName) { try ThemeFileFormat.encode(theme) } + theme.name = " " + #expect(throws: ThemeLoadError.invalidName) { try ThemeFileFormat.encode(theme) } + theme.name = "Ok" + theme.author = "Bell\u{0007}" + #expect(throws: ThemeLoadError.invalidAuthor) { try ThemeFileFormat.encode(theme) } + theme.author = "" + theme.id = "../escape" + #expect(throws: ThemeLoadError.invalidIdentifier("../escape")) { try ThemeFileFormat.encode(theme) } + } + + @Test("The window background loads from the window group and round-trips") + func windowBackgroundRoundTrips() throws { + let theme = try Self.decode(Self.withColor("#282a36", at: ["window", "background"])) + #expect(theme.overrides.mapValues(\.hex) == [.windowBackdrop: "#282A36"]) + + let object = try JSONSerialization.jsonObject(with: try ThemeFileFormat.encode(theme)) + let root = try #require(object as? [String: Any]) + #expect(root["window"] as? [String: String] == ["background": "#282A36"]) + #expect(root["editor"] == nil) + + #expect(try ThemeFileFormat.decode(try ThemeFileFormat.encode(theme)) == theme) + } + + @Test("A theme round-trips through the encoder") + func encoderRoundTrips() throws { + var overrides: [ThemeSlot: ThemeColor] = [:] + for (index, slot) in ThemeSlot.allCases.enumerated() { + overrides[slot] = try ThemeColor(validating: String(format: "#%02X%02X%02X", index, 255 - index, index * 3)) + } + let full = ThemeDefinition(id: "user.full", name: "Full", author: "Tests", appearance: .dark, overrides: overrides) + let sparse = ThemeDefinition( + id: "user.sparse", + name: "Sparse", + author: "", + appearance: .light, + overrides: [.editorCursor: try ThemeColor(validating: "#11223344")] + ) + + for theme in [full, sparse, .defaultLight, .defaultDark] { + #expect(try ThemeFileFormat.decode(try ThemeFileFormat.encode(theme)) == theme, "\(theme.id)") + } + } + + // MARK: - Messages + + @Test("Every error has a message, and the ones with an argument name it") + func errorMessages() { + let errors: [ThemeLoadError] = [ + .malformedJSON(detail: nil), .missingSchema, .schemaTooOld(found: 2, supported: 3), + .schemaTooNew(found: 4, supported: 3), .missingField("name"), .invalidName, .invalidAuthor, + .invalidColor("#XYZ"), .unknownKeys(["a.b"]), .duplicateKey("a.b"), .reservedIdentifier("tablepro.x"), + .invalidIdentifier("x/y"), .duplicateIdentifier("user.a", existingFile: "a.json"), + .changedOnDisk("a.json"), .deletedOnDisk("a.json"), .nameTaken("Ocean"), + .fileTooLarge(limit: ThemeFileImporter.maximumFileSize), .notATheme, + ] + for error in errors { + #expect(!(error.errorDescription ?? "").isEmpty, "\(error)") + } + #expect(ThemeLoadError.malformedJSON(detail: nil).errorDescription == "The file is not valid JSON.") + #expect(ThemeLoadError.deletedOnDisk("Ocean.json").errorDescription == "Ocean.json was deleted outside TablePro.") + #expect(ThemeLoadError.nameTaken("Ocean").errorDescription == "A theme named “Ocean” already exists.") + #expect(ThemeLoadError.notATheme.errorDescription == "This file is not a TablePro theme or a VS Code color theme.") + #expect(ThemeLoadError.invalidName.errorDescription?.contains("100") == true) + #expect(ThemeLoadError.fileTooLarge(limit: ThemeFileImporter.maximumFileSize).errorDescription?.contains("2") == true) + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileImporterTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileImporterTests.swift new file mode 100644 index 0000000000..ccec91c0d0 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileImporterTests.swift @@ -0,0 +1,133 @@ +import Foundation +import TableProTheme +import Testing + +struct ThemeFileImporterTests { + private struct Folder { + let url: URL + + init() throws { + url = FileManager.default.temporaryDirectory + .appendingPathComponent("ThemeFileImporterTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + func write(_ data: Data, as name: String) throws -> URL { + let file = url.appendingPathComponent(name) + try data.write(to: file) + return file + } + + func write(_ text: String, as name: String) throws -> URL { + try write(Data(text.utf8), as: name) + } + + func remove() { + try? FileManager.default.removeItem(at: url) + } + } + + private static let ocean = ThemeDefinition( + id: "user.ocean", + name: "Ocean", + author: "Someone", + appearance: .dark, + overrides: [.editorBackground: ThemeColor(red: 0x10, green: 0x20, blue: 0x30)] + ) + + private static func refusal(importing url: URL) -> ThemeLoadError? { + do { + _ = try ThemeFileImporter.importTheme(at: url) + return nil + } catch { + return error as? ThemeLoadError + } + } + + @Test("Import reads a TablePro theme and gives it a new id every time") + func importMintsAFreshId() throws { + let folder = try Folder() + defer { folder.remove() } + let url = try folder.write(try ThemeFileFormat.encode(Self.ocean), as: "Ocean.json") + + let first = try ThemeFileImporter.importTheme(at: url) + let second = try ThemeFileImporter.importTheme(at: url) + + #expect(first.id != Self.ocean.id) + #expect(second.id != first.id) + #expect(first.id.hasPrefix(ThemeDefinition.userPrefix)) + #expect(ThemeIdentifier.isValid(first.id)) + #expect(first.name == "Ocean") + #expect(first.author == "Someone") + #expect(first.appearance == .dark) + #expect(first.overrides == Self.ocean.overrides) + } + + @Test("A built-in theme file imports as an editable user theme") + func builtInImportsAsUserTheme() throws { + let folder = try Folder() + defer { folder.remove() } + var builtIn = Self.ocean + builtIn.id = "tablepro.ocean" + let url = try folder.write(try ThemeFileFormat.encode(builtIn), as: "tablepro.ocean.json") + + let imported = try ThemeFileImporter.importTheme(at: url) + + #expect(imported.isEditable) + } + + @Test("A file over the size cap is refused before it is read") + func sizeCap() throws { + let folder = try Folder() + defer { folder.remove() } + let fits = try folder.write(Data(count: ThemeFileImporter.maximumFileSize), as: "fits.json") + let over = try folder.write(Data(count: ThemeFileImporter.maximumFileSize + 1), as: "over.json") + + #expect(ThemeFileImporter.maximumFileSize == 2 * 1_024 * 1_024) + #expect(Self.refusal(importing: over) == .fileTooLarge(limit: ThemeFileImporter.maximumFileSize)) + #expect(Self.refusal(importing: fits) != .fileTooLarge(limit: ThemeFileImporter.maximumFileSize)) + } + + @Test("A file that is not a theme in any known format says so") + func notATheme() throws { + let folder = try Folder() + defer { folder.remove() } + + for (index, text) in ["hello", "[1, 2]", "", "{\"name\": \"x\", \"editor\": {}}"].enumerated() { + let url = try folder.write(text, as: "file\(index).json") + #expect(Self.refusal(importing: url) == .notATheme, "\(text.debugDescription)") + } + } + + @Test("A TablePro theme with a syntax error reports where it breaks") + func malformedTheme() throws { + let folder = try Folder() + defer { folder.remove() } + let url = try folder.write("{\n \"schema\": 3,\n \"id\": \"user.a\"\n \"name\": \"x\"\n}", as: "broken.json") + + guard case let .malformedJSON(detail) = Self.refusal(importing: url) else { + Issue.record("expected malformedJSON") + return + } + #expect(detail?.contains("line 4") == true, "\(detail ?? "nil")") + } + + @Test("A TablePro theme in an old format keeps its format error") + func oldFormatKeepsItsError() throws { + let folder = try Folder() + defer { folder.remove() } + let url = try folder.write("{\"version\": 1, \"id\": \"user.a\", \"name\": \"x\"}", as: "old.json") + + #expect(Self.refusal(importing: url) == .schemaTooOld(found: 1, supported: ThemeSchema.current)) + } + + @Test("A missing file throws the file system's error") + func missingFile() throws { + let folder = try Folder() + defer { folder.remove() } + + #expect(throws: (any Error).self) { + try ThemeFileImporter.importTheme(at: folder.url.appendingPathComponent("absent.json")) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/ThemeOrderingTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/ThemeOrderingTests.swift new file mode 100644 index 0000000000..8b8f037c90 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/ThemeOrderingTests.swift @@ -0,0 +1,55 @@ +import TableProTheme +import Testing + +struct ThemeOrderingTests { + private static func theme(_ id: String, _ name: String, _ appearance: ThemeAppearance = .dark) -> ThemeDefinition { + ThemeDefinition(id: id, name: name, author: "", appearance: appearance, overrides: [:]) + } + + @Test("Defaults first, then built-ins by name, then user themes by name") + func order() { + let shuffled = [ + Self.theme("user.b", "Zebra", .light), + Self.theme("tablepro.solarized-light", "Solarized Light", .light), + Self.theme("user.a", "Amber"), + ThemeDefinition.defaultDark, + Self.theme("tablepro.dracula", "Dracula"), + Self.theme("tablepro.nord", "Nord"), + ThemeDefinition.defaultLight, + Self.theme("tablepro.catppuccin-mocha", "Catppuccin Mocha"), + ] + + #expect(ThemeOrdering.sorted(shuffled).map(\.id) == [ + ThemeDefinition.defaultLight.id, + ThemeDefinition.defaultDark.id, + "tablepro.catppuccin-mocha", + "tablepro.dracula", + "tablepro.nord", + "tablepro.solarized-light", + "user.a", + "user.b", + ]) + } + + @Test("A user theme named like a built-in still sorts after every built-in") + func userThemesFollowBuiltIns() { + let sorted = ThemeOrdering.sorted([Self.theme("user.x", "Aardvark"), Self.theme("tablepro.nord", "Nord")]) + #expect(sorted.map(\.id) == ["tablepro.nord", "user.x"]) + } + + @Test("Names compare the way Finder does, numbers by value") + func namesCompareNaturally() { + let sorted = ThemeOrdering.sorted([Self.theme("user.10", "Theme 10"), Self.theme("user.9", "Theme 9")]) + #expect(sorted.map(\.name) == ["Theme 9", "Theme 10"]) + } + + @Test("Two themes with one name order by id, whichever comes first") + func tiesOrderById() { + let first = Self.theme("user.aaaa", "Ocean") + let second = Self.theme("user.bbbb", "Ocean") + + #expect(ThemeOrdering.sorted([second, first]).map(\.id) == ["user.aaaa", "user.bbbb"]) + #expect(ThemeOrdering.sorted([first, second]).map(\.id) == ["user.aaaa", "user.bbbb"]) + #expect(!ThemeOrdering.areInIncreasingOrder(first, first)) + } +} diff --git a/TableProTests/Theme/ThemeSlotValidationTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/ThemeResolverTests.swift similarity index 53% rename from TableProTests/Theme/ThemeSlotValidationTests.swift rename to Packages/TableProCore/Tests/TableProThemeTests/ThemeResolverTests.swift index cc20b9e56c..3824f782b1 100644 --- a/TableProTests/Theme/ThemeSlotValidationTests.swift +++ b/Packages/TableProCore/Tests/TableProThemeTests/ThemeResolverTests.swift @@ -1,14 +1,9 @@ -// -// ThemeSlotValidationTests.swift -// TableProTests -// - -@testable import TablePro +import TableProTheme import Testing -struct ThemeSlotValidationTests { +struct ThemeResolverTests { private static func theme(_ id: String, _ appearance: ThemeAppearance) -> ThemeDefinition { - var copy = BuiltInThemes.default(for: appearance) + var copy = ThemeDefinition.default(for: appearance) copy.id = id copy.name = id return copy @@ -16,21 +11,16 @@ struct ThemeSlotValidationTests { private static let sample = [theme("user.light", .light), theme("user.dark", .dark)] - private static var catalog: [ThemeDefinition] { BuiltInThemes.all + sample } + private static var catalog: [ThemeDefinition] { + [.defaultLight, .defaultDark] + sample + } private static func resolve( - mode: AppAppearanceMode = .auto, - light: String = BuiltInThemes.defaultLightId, - dark: String = BuiltInThemes.defaultDarkId, - systemIsDark: Bool = false + light: String = ThemeDefinition.defaultLight.id, + dark: String = ThemeDefinition.defaultDark.id, + appearance: ThemeAppearance = .light ) -> ThemeSelection { - ThemeResolver.resolve( - mode: mode, - lightThemeId: light, - darkThemeId: dark, - themes: catalog, - systemIsDark: systemIsDark - ) + ThemeResolver.resolve(lightThemeId: light, darkThemeId: dark, themes: catalog, effectiveAppearance: appearance) } // MARK: - Resolver @@ -42,46 +32,41 @@ struct ThemeSlotValidationTests { #expect(selection.pair.dark.id == "user.dark") } - /// A slot whose theme is missing falls back to that slot's own built-in. Falling back to - /// Default Light whichever slot asked painted a white editor inside dark chrome. - @Test("A missing theme falls back to its own slot's built-in") + /// Falling back to Default Light whichever slot asked painted a white editor inside dark chrome. + @Test("A missing theme falls back to its own slot's default") func missingThemeFallsBackPerSlot() { let selection = Self.resolve(light: "user.deleted", dark: "user.deleted") - #expect(selection.pair.light.id == BuiltInThemes.defaultLightId) - #expect(selection.pair.dark.id == BuiltInThemes.defaultDarkId) + #expect(selection.pair.light == .defaultLight) + #expect(selection.pair.dark == .defaultDark) } - @Test("A theme of the wrong appearance falls back to that slot's built-in") + @Test("A theme of the wrong appearance falls back to that slot's default") func misfitThemeFallsBackPerSlot() { let selection = Self.resolve(light: "user.dark", dark: "user.light") - #expect(selection.pair.light.id == BuiltInThemes.defaultLightId) - #expect(selection.pair.dark.id == BuiltInThemes.defaultDarkId) + #expect(selection.pair.light.id == ThemeDefinition.defaultLight.id) + #expect(selection.pair.dark.id == ThemeDefinition.defaultDark.id) - let swappedDefaults = Self.resolve(light: BuiltInThemes.defaultDarkId, dark: BuiltInThemes.defaultLightId) - #expect(swappedDefaults.pair.light.id == BuiltInThemes.defaultLightId) - #expect(swappedDefaults.pair.dark.id == BuiltInThemes.defaultDarkId) + let swapped = Self.resolve(light: ThemeDefinition.defaultDark.id, dark: ThemeDefinition.defaultLight.id) + #expect(swapped.pair.light.id == ThemeDefinition.defaultLight.id) + #expect(swapped.pair.dark.id == ThemeDefinition.defaultDark.id) } - @Test("Auto follows the system appearance, forced modes ignore it") - func autoFollowsSystem() { - #expect(ThemeResolver.effectiveAppearance(mode: .auto, systemIsDark: true) == .dark) - #expect(ThemeResolver.effectiveAppearance(mode: .auto, systemIsDark: false) == .light) - #expect(ThemeResolver.effectiveAppearance(mode: .light, systemIsDark: true) == .light) - #expect(ThemeResolver.effectiveAppearance(mode: .dark, systemIsDark: false) == .dark) - } - - @Test("The active theme is the pair's theme for the effective appearance") + @Test("The active theme is the pair's theme for the appearance given") func activeThemeFollowsTheEffectiveAppearance() { - let dark = Self.resolve(mode: .auto, light: "user.light", dark: "user.dark", systemIsDark: true) + let dark = Self.resolve(light: "user.light", dark: "user.dark", appearance: .dark) #expect(dark.effectiveAppearance == .dark) #expect(dark.active.id == "user.dark") - let light = Self.resolve(mode: .auto, light: "user.light", dark: "user.dark", systemIsDark: false) + let light = Self.resolve(light: "user.light", dark: "user.dark", appearance: .light) #expect(light.effectiveAppearance == .light) #expect(light.active.id == "user.light") + } - let forced = Self.resolve(mode: .light, light: "user.light", dark: "user.dark", systemIsDark: true) - #expect(forced.active.id == "user.light") + @Test("The built-in pair is the two defaults") + func builtInPair() { + #expect(ThemePair.builtIn.light == .defaultLight) + #expect(ThemePair.builtIn.dark == .defaultDark) + #expect(ThemePair.builtIn[.dark] == .defaultDark) } // MARK: - Eligible themes @@ -97,7 +82,7 @@ struct ThemeSlotValidationTests { @Test("Only fitting themes are listed") func listIsFiltered() { let eligible = ThemeSlotValidation.eligibleThemes(Self.catalog, slot: .light, keeping: nil) - #expect(eligible.map(\.id) == [BuiltInThemes.defaultLightId, "user.light"]) + #expect(eligible.map(\.id) == [ThemeDefinition.defaultLight.id, "user.light"]) } /// The row the user is standing on is never filtered away, because the alternative was to diff --git a/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift new file mode 100644 index 0000000000..34baeefc82 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift @@ -0,0 +1,246 @@ +import Foundation +import TableProTheme +import Testing + +struct ThemeSlotFallbackTests { + /// A typo gives a color no theme draws, so the expectation it sits in fails. + private static func color(_ hex: String) -> ThemeColor { + ThemeColor(hex: hex) ?? ThemeColor(red: 1, green: 2, blue: 3, alpha: 4) + } + + private static func theme( + _ appearance: ThemeAppearance, + _ overrides: [ThemeSlot: String] + ) throws -> ThemeDefinition { + ThemeDefinition( + id: "user.fallback", + name: "Fallback", + author: "", + appearance: appearance, + overrides: try overrides.mapValues { try ThemeColor(validating: $0) } + ) + } + + // MARK: - The table + + private static let expected: [ThemeSlot: ThemeSlotFallback] = [ + .windowBackdrop: .system, + .editorBackground: .system, + .editorText: .system, + .editorCursor: .system, + .editorSelection: .system, + .editorCurrentLine: .system, + .editorCurrentStatement: .system, + .editorLineNumber: .system, + .editorInvisibles: .system, + .syntaxKeyword: .defaultSyntax, + .syntaxString: .defaultSyntax, + .syntaxNumber: .defaultSyntax, + .syntaxComment: .defaultSyntax, + .syntaxNull: .defaultSyntax, + .syntaxOperator: .slot(.editorText), + .syntaxFunction: .defaultSyntax, + .syntaxType: .defaultSyntax, + .gridBackground: .system, + .gridText: .system, + .gridAlternateRow: .derived(base: .gridBackground, rule: .stripe(toward: .gridText)), + .gridHeaderBackground: .derived(base: .gridBackground, rule: .same), + .gridHeaderText: .slot(.gridText), + .gridLine: .system, + .gridSelection: .system, + .gridSelectionText: .paired(with: .gridSelection, fallback: .gridText), + .gridInactiveSelection: .system, + .gridFocusBorder: .system, + .gridNullValue: .system, + .gridBoolTrue: .slot(.gridText), + .gridBoolFalse: .slot(.gridText), + .gridRowNumber: .system, + .gridModified: .system, + .gridInserted: .system, + .gridDeleted: .system, + .gridDeletedText: .system, + ] + + @Test("Every slot has its fallback rule", arguments: ThemeSlot.allCases) + func fallbackTable(slot: ThemeSlot) { + #expect(slot.fallback == Self.expected[slot]) + } + + @Test("The table covers every slot") + func tableCoversEverySlot() { + #expect(Set(Self.expected.keys) == Set(ThemeSlot.allCases)) + } + + /// A rule that named another dependent slot would chain, and two such rules could loop. + @Test("A rule only names slots that fall back to the system themselves") + func rulesNeverChain() { + for slot in ThemeSlot.allCases { + let named: [ThemeSlot] + switch slot.fallback { + case .system, .defaultSyntax: named = [] + case let .slot(source): named = [source] + case let .derived(base, rule): + if case let .stripe(toward) = rule { named = [base, toward] } else { named = [base] } + case let .paired(partner, fallback): named = [partner, fallback] + } + for other in named { + #expect(other.fallback == .system, "\(slot.rawValue) names \(other.rawValue)") + } + } + } + + // MARK: - Under no theme + + @Test("Under a default theme every slot draws the system or the default syntax", arguments: ThemeAppearance.allCases) + func defaultsDrawNative(appearance: ThemeAppearance) { + let theme = ThemeDefinition.default(for: appearance) + for slot in ThemeSlot.allCases { + let value = theme.value(for: slot) + switch slot.fallback { + case .defaultSyntax: + #expect(value == .defaultSyntax(slot), "\(slot.rawValue)") + default: + #expect(value == .system(slot), "\(slot.rawValue)") + } + #expect(!theme.isOverridden(slot), "\(slot.rawValue)") + #expect(!theme.isOverridden(slot, highContrast: true), "\(slot.rawValue)") + } + } + + @Test("An explicit color draws as set wherever its rule allows it") + func explicitColorDraws() throws { + let theme = try Self.theme(.dark, [.syntaxKeyword: "#FF79C6", .gridAlternateRow: "#21222C80", .editorCursor: "#F8F8F2"]) + #expect(theme.value(for: .syntaxKeyword) == .themed(Self.color("#FF79C6"))) + #expect(theme.value(for: .gridAlternateRow) == .themed(Self.color("#21222C80"))) + #expect(theme.value(for: .editorCursor) == .themed(Self.color("#F8F8F2"))) + #expect(theme.hasExplicitColor(.syntaxKeyword)) + #expect(!theme.hasExplicitColor(.syntaxString)) + } + + // MARK: - Derived slots + + @Test("A light background-only theme stripes and heads the grid from its own background") + func backgroundOnlyLight() throws { + let theme = try Self.theme(.light, [.gridBackground: "#FDF6E3"]) + + #expect(theme.value(for: .gridAlternateRow) == .themed(Self.color("#F3ECDA"))) + #expect(theme.value(for: .gridHeaderBackground) == .themed(Self.color("#FDF6E3"))) + #expect(theme.isOverridden(.gridAlternateRow)) + #expect(theme.isOverridden(.gridHeaderBackground)) + #expect(!theme.hasExplicitColor(.gridAlternateRow)) + #expect(theme.value(for: .gridText) == .system(.gridText)) + #expect(theme.value(for: .gridSelection) == .system(.gridSelection)) + } + + @Test("A dark background-only theme stripes toward white") + func backgroundOnlyDark() throws { + let theme = try Self.theme(.dark, [.gridBackground: "#282A36"]) + + #expect(theme.value(for: .gridAlternateRow) == .themed(Self.color("#31333E"))) + #expect(theme.value(for: .gridHeaderBackground) == .themed(Self.color("#282A36"))) + } + + @Test("The stripe moves toward the theme's own grid text when it sets one") + func stripeFollowsThemedText() throws { + let theme = try Self.theme(.dark, [.gridBackground: "#282A36", .gridText: "#F8F8F2"]) + let stripe = try #require(theme.value(for: .gridAlternateRow).themedColor) + + #expect(stripe == Self.color("#30323E")) + #expect(stripe.isOpaque) + } + + @Test("Without a themed background the derived slots stay native, even with themed text") + func derivedNeedsTheBase() throws { + let theme = try Self.theme(.light, [.gridText: "#112233"]) + #expect(theme.value(for: .gridAlternateRow) == .system(.gridAlternateRow)) + #expect(theme.value(for: .gridHeaderBackground) == .system(.gridHeaderBackground)) + } + + @Test("An explicit alternate row and header win over the derived ones") + func explicitBeatsDerived() throws { + let theme = try Self.theme(.light, [.gridBackground: "#FDF6E3", .gridAlternateRow: "#EEE8D5", .gridHeaderBackground: "#E0DAC8"]) + #expect(theme.value(for: .gridAlternateRow) == .themed(Self.color("#EEE8D5"))) + #expect(theme.value(for: .gridHeaderBackground) == .themed(Self.color("#E0DAC8"))) + } + + // MARK: - Selection text + + @Test("Selection text without a themed selection stays native, ignoring the file") + func selectionTextNeedsASelection() throws { + let theme = try Self.theme(.dark, [.gridSelectionText: "#00FF00", .gridText: "#FF0000"]) + #expect(theme.value(for: .gridSelectionText) == .system(.gridSelectionText)) + #expect(!theme.isOverridden(.gridSelectionText)) + #expect(theme.hasExplicitColor(.gridSelectionText)) + } + + @Test("Selection text over a themed selection is the file's, else the grid text's value") + func selectionTextPairsWithTheSelection() throws { + let explicit = try Self.theme(.dark, [.gridSelection: "#44475A", .gridSelectionText: "#00FF00"]) + #expect(explicit.value(for: .gridSelectionText) == .themed(Self.color("#00FF00"))) + + let textOnly = try Self.theme(.dark, [.gridSelection: "#44475A", .gridText: "#F8F8F2"]) + #expect(textOnly.value(for: .gridSelectionText) == .themed(Self.color("#F8F8F2"))) + + let nativeText = try Self.theme(.dark, [.gridSelection: "#44475A"]) + #expect(nativeText.value(for: .gridSelectionText) == .system(.gridText)) + #expect(!nativeText.isOverridden(.gridSelectionText)) + } + + // MARK: - Slot fallbacks + + @Test("Bools and the header text follow themed grid text, and stay native without it") + func gridTextFollowers() throws { + let themed = try Self.theme(.dark, [.gridText: "#F8F8F2"]) + let text = Self.color("#F8F8F2") + for slot in [ThemeSlot.gridBoolTrue, .gridBoolFalse, .gridHeaderText] { + #expect(themed.value(for: slot) == .themed(text), "\(slot.rawValue)") + #expect(ThemeDefinition.defaultDark.value(for: slot) == .system(slot), "\(slot.rawValue)") + } + + let ownBool = try Self.theme(.dark, [.gridText: "#F8F8F2", .gridBoolTrue: "#50FA7B"]) + #expect(ownBool.value(for: .gridBoolTrue) == .themed(Self.color("#50FA7B"))) + #expect(ownBool.value(for: .gridBoolFalse) == .themed(text)) + } + + @Test("Operator falls back to the editor text, themed or native") + func operatorFollowsEditorText() throws { + #expect(ThemeDefinition.defaultLight.value(for: .syntaxOperator) == .system(.syntaxOperator)) + + let textOnly = try Self.theme(.dark, [.editorText: "#112233"]) + #expect(textOnly.value(for: .syntaxOperator) == .themed(Self.color("#112233"))) + + let both = try Self.theme(.dark, [.editorText: "#112233", .syntaxOperator: "#445566"]) + #expect(both.value(for: .syntaxOperator) == .themed(Self.color("#445566"))) + } + + @Test("A syntax slot the theme leaves out takes the default syntax, even when the theme has a default's id") + func syntaxFallsBackToTheDefault() throws { + var copy = ThemeDefinition.defaultDark + copy.overrides[.syntaxKeyword] = Self.color("#FF0000") + + #expect(copy.value(for: .syntaxKeyword) == .themed(Self.color("#FF0000"))) + #expect(copy.value(for: .syntaxString) == .defaultSyntax(.syntaxString)) + } + + // MARK: - Increase Contrast and opacity + + @Test("Under Increase Contrast only the window background yields to the system") + func windowYieldsToIncreasedContrast() throws { + let theme = try Self.theme(.dark, [.windowBackdrop: "#282A36", .editorBackground: "#282A36", .gridBackground: "#282A36"]) + + #expect(theme.value(for: .windowBackdrop, highContrast: true) == .system(.windowBackdrop)) + #expect(theme.value(for: .windowBackdrop, highContrast: false) == .themed(Self.color("#282A36"))) + #expect(!theme.isOverridden(.windowBackdrop, highContrast: true)) + #expect(theme.isOverridden(.windowBackdrop)) + #expect(theme.isOverridden(.editorBackground, highContrast: true)) + #expect(theme.isOverridden(.gridBackground, highContrast: true)) + #expect(theme.isOverridden(.gridAlternateRow, highContrast: true)) + } + + @Test("A fill that must be opaque draws without the alpha it was given") + func opaqueSlotsDropAlpha() throws { + let theme = try Self.theme(.dark, [.windowBackdrop: "#282A3680", .editorSelection: "#44475A80"]) + #expect(theme.value(for: .windowBackdrop) == .themed(Self.color("#282A36"))) + #expect(theme.value(for: .editorSelection) == .themed(Self.color("#44475A80"))) + } +} diff --git a/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift b/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift new file mode 100644 index 0000000000..ffa4e3f785 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift @@ -0,0 +1,103 @@ +import Foundation +import TableProTheme +import Testing + +struct ThemeSlotTests { + @Test("Slot paths are unique and sit under their group's key") + func slotPathsAreWellFormed() { + let paths = ThemeSlot.allCases.map(\.rawValue) + #expect(Set(paths).count == paths.count) + + 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)") + case .syntax: + #expect(slot.rawValue.hasPrefix("editor.syntax."), "\(slot.rawValue)") + case .dataGrid: + #expect(slot.rawValue.hasPrefix("dataGrid."), "\(slot.rawValue)") + } + } + } + + @Test("The registry holds 35 slots") + func slotCount() { + #expect(ThemeSlot.allCases.count == 35) + } + + @Test("Every slot belongs to exactly one group, listed in registry order") + func groupsPartitionTheSlots() { + let grouped = ThemeSlotGroup.allCases.flatMap(\.slots) + #expect(Set(grouped) == Set(ThemeSlot.allCases)) + #expect(grouped.count == ThemeSlot.allCases.count) + let gridPaths = ThemeSlot.allCases.filter { $0.rawValue.hasPrefix("dataGrid.") } + #expect(ThemeSlotGroup.dataGrid.slots == gridPaths) + } + + @Test("Every slot belongs to exactly one section") + func sectionsPartitionTheSlots() { + let sectioned = ThemeSlotSection.allCases.flatMap(\.slots) + #expect(Set(sectioned) == Set(ThemeSlot.allCases)) + #expect(sectioned.count == ThemeSlot.allCases.count) + } + + @Test("The editor sections split the data grid into rows, selection and changes") + func sectionsSplitTheGrid() { + #expect(ThemeSlotSection.window.slots == [.windowBackdrop]) + #expect(ThemeSlotSection.editor.slots == ThemeSlotGroup.editor.slots) + #expect(ThemeSlotSection.syntax.slots == ThemeSlotGroup.syntax.slots) + #expect(ThemeSlotSection.dataGrid.slots == [ + .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, + .gridLine, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, + ]) + #expect(ThemeSlotSection.selection.slots == [.gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder]) + #expect(ThemeSlotSection.changes.slots == [.gridModified, .gridInserted, .gridDeleted, .gridDeletedText]) + } + + /// The theme editor lists one section per group in this order, so the window leads it. + @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") + let yielding = ThemeSlot.allCases.filter { $0.yieldsToIncreasedContrast } + #expect(yielding == [.windowBackdrop]) + } + + @Test("Every slot has a label and an accessibility label no other slot has") + func accessibilityLabelsAreUnique() { + let labels = ThemeSlot.allCases.map(\.accessibilityLabel) + #expect(Set(labels).count == labels.count) + for slot in ThemeSlot.allCases { + #expect(!slot.label.isEmpty, "\(slot.rawValue)") + #expect(slot.accessibilityLabel.contains(slot.label), "\(slot.rawValue)") + } + #expect(ThemeSlot.editorBackground.accessibilityLabel == "Editor Background") + #expect(ThemeSlot.gridSelection.accessibilityLabel == "Data Grid Selection") + #expect(ThemeSlot.windowBackdrop.accessibilityLabel == "Window Background") + } + + @Test("Only the fills under everything else require an opaque color") + func opaqueSlots() { + let opaque = Set(ThemeSlot.allCases.filter { $0.requiresOpaque }) + #expect(opaque == [ + .windowBackdrop, .editorBackground, .gridBackground, .gridHeaderBackground, + ]) + } + + @Test("Opacity is offered on the washes and never on a slot that must be opaque") + func opacityOffers() { + let translucent = Set(ThemeSlot.allCases.filter { $0.offersOpacity }) + #expect(translucent == [ + .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorInvisibles, + .gridAlternateRow, .gridSelection, .gridInactiveSelection, + .gridModified, .gridInserted, .gridDeleted, .gridDeletedText, + ]) + for slot in ThemeSlot.allCases { + #expect(!(slot.offersOpacity && slot.requiresOpaque), "\(slot.rawValue)") + } + } +} diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 82352b80f4..3ed4e10eb0 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -159,36 +159,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - if CompareSyncRunRegistry.shared.isApplying { - let alert = NSAlert() - alert.messageText = String(localized: "A sync is still running") - alert.informativeText = String( - format: String(localized: "Quitting stops the run against %@. Statements that already ran stay applied."), - CompareSyncRunRegistry.shared.applyingTargetNames.joined(separator: ", ") - ) - alert.alertStyle = .critical - alert.addButton(withTitle: String(localized: "Keep Running")) - alert.addButton(withTitle: String(localized: "Stop and Quit")) - alert.buttons[1].hasDestructiveAction = true - guard alert.runModal() == .alertSecondButtonReturn else { return .terminateCancel } - } - - let hasUnsaved = MainContentCoordinator.hasAnyUnsavedChanges() - if hasUnsaved { - /// Quitting can be asked for from outside the app, so this alert has to come forward on - /// its own: it blocks termination in a nested modal loop, and a background process has - /// no Dock icon to reach it by. - AppActivationPolicyController.shared.activate(ignoringOtherApps: true) - let alert = NSAlert() - alert.messageText = String(localized: "You have unsaved changes") - alert.informativeText = String(localized: "Some tabs have unsaved edits. Quitting will discard these changes.") - alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "Cancel")) - alert.addButton(withTitle: String(localized: "Quit Anyway")) - alert.buttons[1].hasDestructiveAction = true - let response = alert.runModal() - guard response == .alertSecondButtonReturn else { return .terminateCancel } - } + guard QuitApproval.approve() else { return .terminateCancel } Task { await MCPServerManager.shared.stop() diff --git a/TablePro/Core/Database/DatabaseEndpoint.swift b/TablePro/Core/Database/DatabaseEndpoint.swift index 940305e41a..57a82a382e 100644 --- a/TablePro/Core/Database/DatabaseEndpoint.swift +++ b/TablePro/Core/Database/DatabaseEndpoint.swift @@ -22,6 +22,7 @@ internal struct DatabaseEndpoint: Hashable, Identifiable, Sendable { internal let databaseType: DatabaseType internal let safeModeLevel: SafeModeLevel internal let color: ConnectionColor + internal let iconName: String? /// What a person reads where the database is named. A file-based engine's database *is* an /// absolute path, so spelling it out put `/Users/…/Library/Application Support/…/Chinook.sqlite` @@ -36,6 +37,7 @@ internal struct DatabaseEndpoint: Hashable, Identifiable, Sendable { databaseType: DatabaseType, safeModeLevel: SafeModeLevel, color: ConnectionColor, + iconName: String? = nil, databaseLabel: String? = nil ) { self.scope = scope @@ -43,6 +45,7 @@ internal struct DatabaseEndpoint: Hashable, Identifiable, Sendable { self.databaseType = databaseType self.safeModeLevel = safeModeLevel self.color = color + self.iconName = iconName self.databaseLabel = databaseLabel ?? scope.database } @@ -93,6 +96,7 @@ internal struct DatabaseEndpoint: Hashable, Identifiable, Sendable { databaseType: databaseType, safeModeLevel: safeModeLevel, color: color, + iconName: iconName, databaseLabel: label ?? database ) } @@ -104,6 +108,7 @@ internal struct DatabaseEndpoint: Hashable, Identifiable, Sendable { databaseType: databaseType, safeModeLevel: safeModeLevel, color: color, + iconName: iconName, databaseLabel: databaseLabel ) } @@ -123,6 +128,7 @@ internal extension DatabaseEndpoint { databaseType: connection.type, safeModeLevel: connection.safeModeLevel, color: connection.color, + iconName: connection.iconName, databaseLabel: label(for: resolved, type: connection.type) ) } diff --git a/TablePro/Core/Services/Infrastructure/ContentWindow.swift b/TablePro/Core/Services/Infrastructure/ContentWindow.swift new file mode 100644 index 0000000000..d3e275cb40 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/ContentWindow.swift @@ -0,0 +1,61 @@ +// +// ContentWindow.swift +// TablePro +// + +import AppKit + +/// The window class of the four content windows: main, Data File, Compare & Sync and the value +/// viewers. On macOS 26 and later the theme reaches a toolbar and titlebar only through +/// `backgroundColor`, and only while the titlebar draws no background of its own. +@MainActor +internal class ContentWindow: NSWindow { + override internal init( + contentRect: NSRect, + styleMask style: NSWindow.StyleMask, + backing backingStoreType: NSWindow.BackingStoreType, + defer flag: Bool + ) { + super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) + applyTitlebarPolicy() + let center = NotificationCenter.default + center.addObserver( + self, selector: #selector(willEnterFullScreen(_:)), + name: NSWindow.willEnterFullScreenNotification, object: self + ) + center.addObserver( + self, selector: #selector(didExitFullScreen(_:)), + name: NSWindow.didExitFullScreenNotification, object: self + ) + } + + // Hiding the toolbar drops the content's top safe area, so the titlebar is decided again. + override internal func toggleToolbarShown(_ sender: Any?) { + super.toggleToolbarShown(sender) + applyTitlebarPolicy() + } + + // The full-screen hooks run either side of the style mask flipping, so they name the state they move to. + internal func applyTitlebarPolicy(isFullScreen: Bool? = nil) { + // Before macOS 26 a transparent titlebar swaps its material for a flat fill, which would fill chrome. + guard #available(macOS 26.0, *) else { return } + titlebarAppearsTransparent = Self.titlebarIsTransparent( + isFullScreen: isFullScreen ?? styleMask.contains(.fullScreen), + toolbarVisible: toolbar?.isVisible ?? false + ) + } + + // In full screen with no toolbar the content reaches the screen edge, and the titlebar that + // slides down over it needs its own background to stay legible. + internal static func titlebarIsTransparent(isFullScreen: Bool, toolbarVisible: Bool) -> Bool { + isFullScreen ? toolbarVisible : true + } + + @objc private func willEnterFullScreen(_ notification: Notification) { + applyTitlebarPolicy(isFullScreen: true) + } + + @objc private func didExitFullScreen(_ notification: Notification) { + applyTitlebarPolicy(isFullScreen: false) + } +} diff --git a/TablePro/Core/Services/Infrastructure/DetailColumnBackdrop.swift b/TablePro/Core/Services/Infrastructure/DetailColumnBackdrop.swift new file mode 100644 index 0000000000..4da50b45af --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/DetailColumnBackdrop.swift @@ -0,0 +1,34 @@ +// +// DetailColumnBackdrop.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// `window.background` under whatever the detail column shows, on every macOS version: Connecting, +/// Unavailable and the agent conversation draw no backdrop of their own, and before macOS 26 the +/// window color does not take the theme. +@MainActor +internal enum DetailColumnBackdrop { + internal static func makeView() -> NSView { + let view = DetailColumnBackdropView(rootView: DetailColumnBackdropSurface()) + view.sizingOptions = [] + return view + } +} + +private struct DetailColumnBackdropSurface: View { + var body: some View { + Color.clear + .modifier(DetailPaneBackdropFill()) + .accessibilityHidden(true) + } +} + +// A point the pane above leaves unclaimed still reaches the window, as it did with no backdrop. +private final class DetailColumnBackdropView: NSHostingView { + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 9457e45599..ae9278dc52 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -329,6 +329,7 @@ internal final class MainSplitViewController: NSSplitViewController { addSplitViewItem(sidebarSplitItem) detailPaneHost = WorkspacePaneHost() + detailPaneHost.installBackdrop(DetailColumnBackdrop.makeView()) detailSplitItem = NSSplitViewItem(viewController: detailPaneHost) detailSplitItem.minimumThickness = Self.resolveDetailMinimumThickness( for: payload?.tabType, @@ -526,7 +527,7 @@ internal final class MainSplitViewController: NSSplitViewController { /// The transparency decision reads `window.toolbar?.isVisible`, so a window that gains its /// toolbar after that decision was taken keeps the opaque titlebar chosen for a /// toolbar-less one, over content that is no longer inset below it. - TabWindowController.applyTitlebarChrome(to: window) + (window as? ContentWindow)?.applyTitlebarPolicy() } /// The window's toolbar goes too. Dropping only the owner left the built `NSToolbar` on the diff --git a/TablePro/Core/Services/Infrastructure/QuitApproval.swift b/TablePro/Core/Services/Infrastructure/QuitApproval.swift new file mode 100644 index 0000000000..c8e1c39e4f --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/QuitApproval.swift @@ -0,0 +1,43 @@ +import AppKit + +/// Everything that can still stop a quit, asked from `applicationShouldTerminate`. The termination +/// notification comes after AppKit is told yes, too late for any of these to cancel it. +@MainActor +internal enum QuitApproval { + internal static func approve() -> Bool { + guard approvesStoppingSync() else { return false } + guard ThemeEditorWindows.shared.prepareToTerminate() else { return false } + return approvesDiscardingUnsavedTabs() + } + + private static func approvesStoppingSync() -> Bool { + guard CompareSyncRunRegistry.shared.isApplying else { return true } + let alert = NSAlert() + alert.messageText = String(localized: "A sync is still running") + alert.informativeText = String( + format: String(localized: "Quitting stops the run against %@. Statements that already ran stay applied."), + CompareSyncRunRegistry.shared.applyingTargetNames.joined(separator: ", ") + ) + alert.alertStyle = .critical + alert.addButton(withTitle: String(localized: "Keep Running")) + alert.addButton(withTitle: String(localized: "Stop and Quit")) + alert.buttons[1].hasDestructiveAction = true + return alert.runModal() == .alertSecondButtonReturn + } + + private static func approvesDiscardingUnsavedTabs() -> Bool { + guard MainContentCoordinator.hasAnyUnsavedChanges() else { return true } + // Quitting can be asked for from outside the app, so this alert has to come forward on its + // own: it blocks termination in a nested modal loop, and a background process has no Dock + // icon to reach it by. + AppActivationPolicyController.shared.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = String(localized: "You have unsaved changes") + alert.informativeText = String(localized: "Some tabs have unsaved edits. Quitting will discard these changes.") + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "Cancel")) + alert.addButton(withTitle: String(localized: "Quit Anyway")) + alert.buttons[1].hasDestructiveAction = true + return alert.runModal() == .alertSecondButtonReturn + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 8dd3a14d7f..75af788f41 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -11,7 +11,7 @@ import SwiftUI /// `NSDraggingDestination`, so without it Swift emits no selector for these and AppKit, which /// dispatches a drag by selector, runs `NSWindow`'s own refusal instead. @MainActor -private final class EditorWindow: NSWindow, NSDraggingDestination { +private final class EditorWindow: ContentWindow, NSDraggingDestination { /// A window that draws its own tabs says so by redefining the close command, which is the /// mechanism AppKit gives native tabbed windows for free. The editor's meaning of Close lives /// in one place behind it, so the menu, the keyboard and any other sender cannot disagree. @@ -31,13 +31,6 @@ private final class EditorWindow: NSWindow, NSDraggingDestination { } } - /// Hiding the toolbar is what drops the content pane's top safe area, so the titlebar has to be - /// reconsidered every time the user sends this from View > Show Toolbar. - override func toggleToolbarShown(_ sender: Any?) { - super.toggleToolbarShown(sender) - TabWindowController.applyTitlebarChrome(to: self) - } - /// Deciding what a drag carries reads the head of each file, and `draggingUpdated:` fires on /// every pointer move. A drag session's pasteboard cannot change while it is in flight. private var dragSessionOperation: NSDragOperation? @@ -183,7 +176,9 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.keepsKeyViewLoopCurrent() window.toolbarStyle = .unified window.titleVisibility = .visible - applyTitlebarChrome(to: window) + // A toolbar and content of one color get a half-width rule separating nothing. From macOS 26 + // only the transparent titlebar removes it; this property no longer reaches it there. + window.titlebarSeparatorStyle = .none /// `.automatic` is AppKit reading the user's own tabbing preference. Hard-coding /// `.preferred` overrode that setting, which an app that draws its own editor tabs has no /// reason to do. @@ -193,33 +188,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { return window } - /// AppKit rules a line between the toolbar and the content pane, and stops it at the sidebar - /// divider, so a window whose toolbar and content are the same colour gets a half-width rule - /// separating nothing. `titlebarSeparatorStyle` is the property for that, and it stopped - /// reaching the rule in macOS 26: the content section's titlebar background now draws it - /// through a scroll pocket, and `.none`, `.line` and `.shadow` all render identically there. - /// - /// `isFullScreen` is passed in for the transition hooks, which run either side of the style - /// mask changing rather than at the moment it does. - internal static func applyTitlebarChrome(to window: NSWindow, isFullScreen: Bool? = nil) { - window.titlebarSeparatorStyle = .none - guard #available(macOS 26.0, *) else { return } - window.titlebarAppearsTransparent = titlebarIsTransparent( - isFullScreen: isFullScreen ?? window.styleMask.contains(.fullScreen), - toolbarVisible: window.toolbar?.isVisible ?? false - ) - } - - /// A transparent titlebar is what removes that rule, and it is free while the content pane is - /// inset below the titlebar, which it is in every state but one. A full-screen window with its - /// toolbar hidden has a top safe area of zero, so its content owns the whole screen, and the - /// titlebar AppKit slides back down over that content needs a background of its own to stay - /// legible. Nothing is lost by making it opaque there, because a titlebar that only appears on - /// demand has no rule to suppress. - internal static func titlebarIsTransparent(isFullScreen: Bool, toolbarVisible: Bool) -> Bool { - isFullScreen ? toolbarVisible : true - } - // MARK: - NSWindowDelegate internal func windowDidResize(_ notification: Notification) { @@ -238,19 +206,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.saveFrame(usingAutosaveName: Self.frameAutosaveName) } - /// Both hooks name the state they are moving to, because neither runs at the moment the style - /// mask flips: `willEnter` still reads windowed, and `didExit` is the first point that reads - /// windowed again. - internal func windowWillEnterFullScreen(_ notification: Notification) { - guard let window = notification.object as? NSWindow else { return } - Self.applyTitlebarChrome(to: window, isFullScreen: true) - } - - internal func windowDidExitFullScreen(_ notification: Notification) { - guard let window = notification.object as? NSWindow else { return } - Self.applyTitlebarChrome(to: window, isFullScreen: false) - } - internal func windowDidBecomeKey(_ notification: Notification) { let seq = MainContentCoordinator.nextSwitchSeq() let t0 = Date() diff --git a/TablePro/Core/Services/Infrastructure/WorkspacePaneHost.swift b/TablePro/Core/Services/Infrastructure/WorkspacePaneHost.swift index 289adecb23..0d9e87b10a 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspacePaneHost.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspacePaneHost.swift @@ -35,6 +35,18 @@ internal final class WorkspacePaneHost: NSViewController { preferredContentSize = viewController.preferredContentSize } + // Below every pane the container shows, so a swap between panes never uncovers the window. + internal func installBackdrop(_ backdrop: NSView) { + backdrop.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(backdrop, positioned: .below, relativeTo: nil) + NSLayoutConstraint.activate([ + backdrop.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backdrop.trailingAnchor.constraint(equalTo: view.trailingAnchor), + backdrop.topAnchor.constraint(equalTo: view.topAnchor), + backdrop.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + internal func show(_ controller: NSViewController?) { guard shown !== controller else { return } diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 88bfda0f9d..9306c4145a 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -25,8 +25,7 @@ final class AppSettingsManager: ObservableObject { @Published var appearance: AppearanceSettings { didSet { storage.saveAppearance(appearance) - themeEngine.apply( - mode: appearance.appearanceMode, + themeEngine.select( lightThemeId: appearance.preferredLightThemeId, darkThemeId: appearance.preferredDarkThemeId ) @@ -34,6 +33,15 @@ final class AppSettingsManager: ObservableObject { } } + // Device-local, like typography: each Mac picks Auto, Light or Dark for itself. + @Published var appearanceMode: AppAppearanceMode { + didSet { + guard appearanceMode != oldValue else { return } + storage.saveAppearanceMode(appearanceMode) + themeEngine.apply(mode: appearanceMode) + } + } + @Published var editor: EditorSettings { didSet { storage.saveEditor(editor) @@ -197,6 +205,7 @@ final class AppSettingsManager: ObservableObject { self.general = storage.loadGeneral() self.appearance = storage.loadAppearance() + self.appearanceMode = storage.loadAppearanceMode() self.editor = storage.loadEditor() self.dataGrid = storage.loadDataGrid() self.history = storage.loadHistory() @@ -212,7 +221,7 @@ final class AppSettingsManager: ObservableObject { themeEngine.apply(typography: typography) themeEngine.apply( - mode: appearance.appearanceMode, + mode: appearanceMode, lightThemeId: appearance.preferredLightThemeId, darkThemeId: appearance.preferredDarkThemeId ) @@ -245,6 +254,7 @@ final class AppSettingsManager: ObservableObject { func resetToDefaults() { general = .default appearance = .default + appearanceMode = .auto editor = .default dataGrid = .default history = .default diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index 3ba55fa3ef..b602cd435c 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -23,6 +23,7 @@ final class AppSettingsStorage: Sendable { private enum Keys { static let general = "com.TablePro.settings.general" static let appearance = "com.TablePro.settings.appearance" + static let appearanceMode = "com.TablePro.settings.appearanceMode" static let editor = "com.TablePro.settings.editor" static let dataGrid = "com.TablePro.settings.dataGrid" static let history = "com.TablePro.settings.history" @@ -86,6 +87,44 @@ final class AppSettingsStorage: Sendable { load(key: Keys.appearance, default: .default) } + func saveAppearance(_ settings: AppearanceSettings) { + save(settings, key: Keys.appearance) + } + + // Older builds kept the mode inside the synced appearance blob. The first load carries it to its + // own key and writes it there, because the next save of the blob drops the field. + func loadAppearanceMode() -> AppAppearanceMode { + if let stored = defaults.string(forKey: Keys.appearanceMode) { + guard let mode = AppAppearanceMode(rawValue: stored) else { + Self.logger.error("Unknown appearance mode \(stored, privacy: .public)") + return .auto + } + return mode + } + + let carried = legacyAppearanceMode() ?? .auto + saveAppearanceMode(carried) + return carried + } + + func saveAppearanceMode(_ mode: AppAppearanceMode) { + defaults.set(mode.rawValue, forKey: Keys.appearanceMode) + } + + private struct LegacyAppearanceModeField: Decodable { + var appearanceMode: AppAppearanceMode? + } + + private func legacyAppearanceMode() -> AppAppearanceMode? { + guard let data = defaults.data(forKey: Keys.appearance) else { return nil } + do { + return try decoder.decode(LegacyAppearanceModeField.self, from: data).appearanceMode + } catch { + Self.logger.error("Failed to read the appearance mode from the appearance settings: \(error)") + return nil + } + } + /// The first launch with no typography setting lifts the fonts out of the old theme files once, /// so an upgrade does not return everyone to System Mono 13. func loadTypography( @@ -103,6 +142,7 @@ final class AppSettingsStorage: Sendable { let carried = LegacyThemeFonts.read( appearance: loadAppearance(), + mode: loadAppearanceMode(), systemIsDark: systemIsDark, themesDirectory: legacyThemesDirectory ) @@ -114,10 +154,6 @@ final class AppSettingsStorage: Sendable { save(settings, key: Keys.typography) } - func saveAppearance(_ settings: AppearanceSettings) { - save(settings, key: Keys.appearance) - } - // MARK: - Editor Settings func loadEditor() -> EditorSettings { @@ -251,6 +287,7 @@ final class AppSettingsStorage: Sendable { func resetToDefaults() { saveGeneral(.default) saveAppearance(.default) + saveAppearanceMode(.auto) saveEditor(.default) saveDataGrid(.default) saveHistory(.default) diff --git a/TablePro/Core/Storage/AppStorageEnvironment.swift b/TablePro/Core/Storage/AppStorageEnvironment.swift index 2404606c7b..457b88cd7f 100644 --- a/TablePro/Core/Storage/AppStorageEnvironment.swift +++ b/TablePro/Core/Storage/AppStorageEnvironment.swift @@ -24,6 +24,12 @@ internal final class AppStorageEnvironment: @unchecked Sendable { applicationSupportRoot.appendingPathComponent("TablePro", isDirectory: true) } + // Nil means the user's Trash. A sandboxed run keeps what it trashes inside the sandbox, so a + // test never leaves files in the Trash of the Mac running it. + internal var trashDirectory: URL? { + isIsolated ? supportDirectory.appendingPathComponent("Trash", isDirectory: true) : nil + } + private static let logger = Logger(subsystem: "com.TablePro", category: "AppStorageEnvironment") internal static let uiTestingVariable = "TABLEPRO_UI_TESTING" diff --git a/TablePro/Core/Sync/Extensions/SyncCoordinator+RemoteDeletions.swift b/TablePro/Core/Sync/Extensions/SyncCoordinator+RemoteDeletions.swift index 3f2f3262d8..4b19103f50 100644 --- a/TablePro/Core/Sync/Extensions/SyncCoordinator+RemoteDeletions.swift +++ b/TablePro/Core/Sync/Extensions/SyncCoordinator+RemoteDeletions.swift @@ -85,7 +85,8 @@ extension SyncCoordinator { applyRemoteTagDeletions(pending.tags), applyRemoteSSHProfileDeletions(pending.sshProfiles), applyRemoteCredentialProfileDeletions(pending.credentialProfiles), - applyRemoteColumnLayoutDeletions(pending.settingsRecordNames) + applyRemoteColumnLayoutDeletions(pending.settingsRecordNames), + applyRemoteThemeDeletions(pending.settingsRecordNames) ] effects.persistenceFailed = persisted.contains(false) applyRemoteTableFolderDeletions(folderIds: pending.tableFolders, itemSyncIds: pending.tableFolderItems) diff --git a/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift b/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift index fec376d2f0..1d32e3e4f1 100644 --- a/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift +++ b/TablePro/Core/Sync/Extensions/SyncCoordinator+Settings.swift @@ -1,5 +1,8 @@ +import CloudKit import Foundation import os +import TableProSyncTransport +import TableProTheme extension SyncCoordinator { func settingsData(for category: String) -> Data? { @@ -23,6 +26,8 @@ extension SyncCoordinator { return columnLayouts().rawData( forStorageKey: String(category.dropFirst(FileColumnLayoutPersister.syncCategoryPrefix.count)) ) + case let category where category.hasPrefix(ThemeCatalog.syncCategoryPrefix): + return themeCatalog.syncPayload(forCategory: category) default: return nil } } catch { @@ -62,3 +67,54 @@ extension SyncCoordinator { } } } + +// MARK: - Themes + +extension SyncCoordinator { + // A build that reads a newer theme format names a new kind, so it fetches the zone once and + // picks up the theme records an older build skipped. + static let themeContentKind = "\(SyncRecordType.settings.rawValue)/theme.v\(ThemeSchema.current)" + + static var readableContent: Set { + Set(SyncRecordType.verifiedInProduction.map(\.rawValue)).union([Self.themeContentKind]) + } + + func applyRemoteThemeDeletions(_ settingsRecordNames: Set) -> Bool { + let prefix = SyncRecordType.settings.recordNamePrefix + ThemeCatalog.syncCategoryPrefix + let ids = Set(settingsRecordNames.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) }) + guard !ids.isEmpty else { return true } + return themeCatalog.removeWithoutSync(ids: ids) + } + + // Themes made before theme sync were never marked, and loads never mark. They wait for the + // first full pull, because push runs first in a cycle and seeding earlier would overwrite a newer + // copy another Mac already uploaded. + func scheduleThemeSeedIfNeeded() { + guard metadataStorage.themeSeedState == nil else { return } + metadataStorage.themeSeedState = .pending + } + + // 0.81 synced the slot ids but not the files, so two Macs can hold different themes under one + // imported id. Until the seed runs, the local one may never have reached the server. + var remoteThemeMerge: ThemeRemoteMerge { + metadataStorage.themeSeedState == .pending ? .keepBoth : .replace + } + + // Only themes the server lacks: a full fetch has no deletions, so it cannot tell a theme deleted + // elsewhere from one never uploaded, and that is why this runs once rather than after every + // full fetch. + func seedLocalThemesIfPending(after result: PullResult) { + guard metadataStorage.themeSeedState == .pending else { return } + let held = Set(result.changedRecords.compactMap { record -> String? in + guard record.recordType == SyncRecordType.settings.rawValue, + let category = SyncRecordMapper.settingsCategory(from: record), + ThemeCatalog.themeId(forSyncCategory: category) != nil + else { return nil } + return category + }) + let missing = themeCatalog.syncCategories.filter { !held.contains($0) } + changeTracker.markDirty(.settings, ids: missing) + metadataStorage.themeSeedState = .done + Self.logger.info("Seeded \(missing.count, privacy: .public) themes the server did not hold") + } +} diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 1a98482192..95d58a89ca 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -28,6 +28,7 @@ final class SyncCoordinator: ObservableObject { let metadataStorage: SyncMetadataStorage let recordCache: SyncRecordCache let columnLayouts: () -> FileColumnLayoutPersister + let themeCatalog: ThemeCatalog private let makeNetworkMonitor: () -> SyncNetworkMonitor? /// Why the license keeps sync off, or nil while it allows sync. private let licenseGate: @MainActor () -> DisableReason? @@ -65,6 +66,7 @@ final class SyncCoordinator: ObservableObject { transport: @escaping @autoclosure () -> any SyncTransport = CloudKitSyncEngine(), makeNetworkMonitor: @escaping () -> SyncNetworkMonitor? = { SyncNetworkMonitor() }, columnLayouts: @escaping @autoclosure () -> FileColumnLayoutPersister = .shared, + themeCatalog: ThemeCatalog = .shared, licenseGate: (@MainActor () -> DisableReason?)? = nil ) { self.services = services @@ -74,6 +76,7 @@ final class SyncCoordinator: ObservableObject { self.metadataStorage = services.syncMetadataStorage self.recordCache = recordCache self.columnLayouts = columnLayouts + self.themeCatalog = themeCatalog let licenseManager = services.licenseManager self.licenseGate = licenseGate ?? { guard !licenseManager.isFeatureAvailable(.iCloudSync) else { return nil } @@ -461,7 +464,8 @@ final class SyncCoordinator: ObservableObject { let settingsCategories = AppSettingsCategory.synced + [CustomSlashCommandStorage.syncCategory] let columnLayoutCategories = columnLayouts().customizedStorageKeys() .map { FileColumnLayoutPersister.syncCategory(for: $0) } - changeTracker.markDirty(.settings, ids: settingsCategories + columnLayoutCategories) + let themeCategories = themeCatalog.syncCategories + changeTracker.markDirty(.settings, ids: settingsCategories + columnLayoutCategories + themeCategories) let summary = [ "connections=\(connections.count)", @@ -470,7 +474,8 @@ final class SyncCoordinator: ObservableObject { "sshProfiles=\(sshProfiles.count)", "favoriteTables=\(favoriteTables.count)", "tableFolders=\(tableFolderIds.folders.count)", - "settings=\(AppSettingsCategory.synced.count + 1)" + "settings=\(AppSettingsCategory.synced.count + 1)", + "themes=\(themeCategories.count)" ].joined(separator: ", ") Self.logger.info("Marked all local data dirty: \(summary, privacy: .public)") } @@ -663,13 +668,13 @@ final class SyncCoordinator: ObservableObject { do { let result = try await transport.pull(since: token) - return await applyPullResult(result, echoGuard: echoGuard) ? nil : .pullNotSaved + return await applyPull(result, isFullFetch: token == nil, echoGuard: echoGuard) ? nil : .pullNotSaved } catch let error where Self.isTokenExpired(error) { Self.logger.warning("Change token expired, clearing and retrying with full fetch") metadataStorage.saveToken(nil) do { let result = try await transport.pull(since: nil) - return await applyPullResult(result, echoGuard: echoGuard) ? nil : .pullNotSaved + return await applyPull(result, isFullFetch: true, echoGuard: echoGuard) ? nil : .pullNotSaved } catch { Self.logger.error("Full fetch after token expiry failed: \(error.localizedDescription)") return SyncStepFailure(error) @@ -680,6 +685,14 @@ final class SyncCoordinator: ObservableObject { } } + private func applyPull(_ result: PullResult, isFullFetch: Bool, echoGuard: SyncEchoGuard?) async -> Bool { + guard await applyPullResult(result, echoGuard: echoGuard) else { return false } + if isFullFetch { + seedLocalThemesIfPending(after: result) + } + return true + } + @discardableResult internal func applyPullResult(_ result: PullResult, echoGuard: SyncEchoGuard? = nil) async -> Bool { let settings = services.appSettingsStorage.loadSync() @@ -801,7 +814,9 @@ final class SyncCoordinator: ObservableObject { persistenceFailed = true } case .settings: - applyRemoteSettings(record, tombstoneIds: settingsTombstoneIds) + if applyRemoteSettings(record, tombstoneIds: settingsTombstoneIds) == .failed { + persistenceFailed = true + } case .tableFavorite: if let favorite = remoteTableFavorite(record, tombstoneIds: tableFavoriteTombstoneIds) { tableFavorites.append(favorite) @@ -1015,18 +1030,23 @@ final class SyncCoordinator: ObservableObject { services.sshProfileStorage.refreshLinkedConnections(with: remoteProfile) } - private func applyRemoteSettings(_ record: CKRecord, tombstoneIds: Set) { + private func applyRemoteSettings(_ record: CKRecord, tombstoneIds: Set) -> RemoteApplyOutcome { guard let category = SyncRecordMapper.settingsCategory(from: record), !tombstoneIds.contains(category), let data = SyncRecordMapper.settingsData(from: record) - else { return } + else { return .skipped } + if ThemeCatalog.themeId(forSyncCategory: category) != nil { + return themeCatalog.applyRemote(category: category, data: data, merge: remoteThemeMerge) + } do { try applySettingsData(data, for: category) + return .applied } catch { let recordName = record.recordID.recordName Self.logger.error( "Skipping remote settings \(recordName, privacy: .private(mask: .hash)) (\(category, privacy: .private(mask: .hash))): \(error.publicLogShape, privacy: .public) \(error.localizedDescription, privacy: .private)" ) + return .skipped } } @@ -1089,12 +1109,14 @@ final class SyncCoordinator: ObservableObject { /// did not sync were passed over for good. The first run of a build that syncs more types /// fetches the zone again, which is how table folders made on an upgraded Mac reach one that /// upgrades later. Counted over the types verified in Production, because an unverified type - /// has no records on the server to fetch. Checked on every turn to on, because a sync turned - /// on mid-session after an upgrade would otherwise keep a token from before it. - if metadataStorage.adoptReadableRecordTypes(Set(SyncRecordType.verifiedInProduction.map(\.rawValue))) { + /// has no records on the server to fetch, plus the content kinds an older build skipped + /// inside a type it read (`readableContent`). Checked on every turn to on, because a sync + /// turned on mid-session after an upgrade would otherwise keep a token from before it. + if metadataStorage.adoptReadableRecordTypes(Self.readableContent) { metadataStorage.saveToken(nil) Self.logger.info("This build reads record types the last one did not: cleared sync token for full fetch") } + scheduleThemeSeedIfNeeded() } /// The run that follows reads the account and adopts its id, so a sign-out, a switch to another diff --git a/TablePro/Core/Sync/SyncMetadataStorage+AppStorage.swift b/TablePro/Core/Sync/SyncMetadataStorage+AppStorage.swift index b0d2026808..7d00805cf3 100644 --- a/TablePro/Core/Sync/SyncMetadataStorage+AppStorage.swift +++ b/TablePro/Core/Sync/SyncMetadataStorage+AppStorage.swift @@ -6,6 +6,18 @@ import Foundation import TableProSyncTransport +internal enum ThemeSeedState: String { + case pending + case done +} + internal extension SyncMetadataStorage { static let appDefault = SyncMetadataStorage(userDefaults: AppStorageEnvironment.shared.defaults) + + private static let themeSeedKey = "com.TablePro.sync.themeSeed" + + var themeSeedState: ThemeSeedState? { + get { userDefaults.string(forKey: Self.themeSeedKey).flatMap(ThemeSeedState.init(rawValue:)) } + set { userDefaults.set(newValue?.rawValue, forKey: Self.themeSeedKey) } + } } diff --git a/TablePro/Core/Vim/VimCursorManager.swift b/TablePro/Core/Vim/VimCursorManager.swift index 80ffa36e1f..1865b51496 100644 --- a/TablePro/Core/Vim/VimCursorManager.swift +++ b/TablePro/Core/Vim/VimCursorManager.swift @@ -16,6 +16,7 @@ import AppKit import Combine import os import TableProTextEngine +import TableProTheme /// Manages Vim-style block cursor rendering on the text view @MainActor diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index f74336bd35..c21e93ab70 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -11,41 +11,27 @@ import SwiftUI // MARK: - Appearance Settings -/// Controls which appearance the app uses: forced light, forced dark, or follow system. enum AppAppearanceMode: String, Codable, CaseIterable { case light case dark case auto - - var displayName: String { - switch self { - case .light: return String(localized: "Light") - case .dark: return String(localized: "Dark") - case .auto: return String(localized: "Auto") - } - } } -/// Appearance settings — couples appearance mode with theme selection. -/// Each appearance (light/dark) has its own preferred theme so the active theme -/// always matches the window chrome. +// The appearance mode is not here: it is per Mac (`AppSettingsManager.appearanceMode`), and this +// struct is the synced `appearance` category. struct AppearanceSettings: Codable, Equatable { - var appearanceMode: AppAppearanceMode var preferredLightThemeId: String var preferredDarkThemeId: String static let `default` = AppearanceSettings( - appearanceMode: .auto, - preferredLightThemeId: "tablepro.default-light", - preferredDarkThemeId: "tablepro.default-dark" + preferredLightThemeId: BuiltInThemes.defaultLightId, + preferredDarkThemeId: BuiltInThemes.defaultDarkId ) init( - appearanceMode: AppAppearanceMode = .auto, - preferredLightThemeId: String = "tablepro.default-light", - preferredDarkThemeId: String = "tablepro.default-dark" + preferredLightThemeId: String = BuiltInThemes.defaultLightId, + preferredDarkThemeId: String = BuiltInThemes.defaultDarkId ) { - self.appearanceMode = appearanceMode self.preferredLightThemeId = preferredLightThemeId self.preferredDarkThemeId = preferredDarkThemeId } @@ -53,13 +39,12 @@ struct AppearanceSettings: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let fallback = Self.default - appearanceMode = container.decodeSetting(.appearanceMode, default: fallback.appearanceMode) preferredLightThemeId = container.decodeSetting(.preferredLightThemeId, default: fallback.preferredLightThemeId) preferredDarkThemeId = container.decodeSetting(.preferredDarkThemeId, default: fallback.preferredDarkThemeId) } private enum CodingKeys: String, CodingKey { - case appearanceMode, preferredLightThemeId, preferredDarkThemeId + case preferredLightThemeId, preferredDarkThemeId } } diff --git a/TablePro/Models/Settings/AppSettingsCategory.swift b/TablePro/Models/Settings/AppSettingsCategory.swift index d2e4f30ee4..e4826e3f1e 100644 --- a/TablePro/Models/Settings/AppSettingsCategory.swift +++ b/TablePro/Models/Settings/AppSettingsCategory.swift @@ -5,14 +5,9 @@ import Foundation -/// The identity every settings category syncs under, in one place. -/// -/// These strings used to be spelled out separately in four lists that nothing forced to agree: the -/// `markDirty` call in each `didSet`, the seed list that runs when sync is switched on, and the -/// encode and decode switches. A category present in the first and missing from the others is -/// marked dirty forever, never encodes, never clears, and never reaches the user's other Mac, with -/// nothing anywhere reporting a problem. `mcp` and `sync` were in exactly that state, which is why -/// they are now named as device-local rather than left to look like an oversight. +// One home for the ids every settings category syncs under. They used to be spelled out in four +// lists nothing forced to agree, and a category missing from one stayed dirty forever without ever +// reaching the other Mac. internal enum AppSettingsCategory { internal static let general = "general" internal static let appearance = "appearance" @@ -23,10 +18,11 @@ internal enum AppSettingsCategory { internal static let keyboard = "keyboard" internal static let ai = "ai" internal static let notifications = "notifications" + internal static let appearanceMode = "appearanceMode" - /// Never synced, and deliberately so. `sync` carries the switch that turns syncing on, and - /// `mcp` carries a port and local server configuration that belongs to one machine. - internal static let deviceLocal: Set = ["sync", "mcp"] + // `sync` holds the switch that turns syncing on, `mcp` a port and server setup for one machine, + // and `appearanceMode` the Auto, Light or Dark choice each Mac makes for itself. + internal static let deviceLocal: Set = ["sync", "mcp", appearanceMode] internal static let synced: [String] = [ general, appearance, editor, dataGrid, history, tabs, keyboard, ai, notifications diff --git a/TablePro/Models/Settings/LegacyThemeFonts.swift b/TablePro/Models/Settings/LegacyThemeFonts.swift index 5e54a41ec6..8550008089 100644 --- a/TablePro/Models/Settings/LegacyThemeFonts.swift +++ b/TablePro/Models/Settings/LegacyThemeFonts.swift @@ -1,5 +1,6 @@ import Foundation import os +import TableProTheme /// Reads the `fonts` object out of the theme files TablePro 0.80 wrote, once, on the first launch /// with no typography setting. It never writes a theme file. @@ -25,11 +26,12 @@ internal enum LegacyThemeFonts { internal static func read( appearance: AppearanceSettings, + mode: AppAppearanceMode, systemIsDark: Bool, themesDirectory: URL ) -> TypographySettings { let fontsById = loadFonts(in: themesDirectory) - for id in candidateIds(appearance: appearance, systemIsDark: systemIsDark) { + for id in candidateIds(appearance: appearance, mode: mode, systemIsDark: systemIsDark) { guard let fonts = fontsById[id] else { continue } return settings(from: fonts) } @@ -38,8 +40,12 @@ internal enum LegacyThemeFonts { /// 0.80 applied the fonts of the theme on screen, so the slot of the appearance in effect /// comes first and the other slot only fills in when that theme carried none. - internal static func candidateIds(appearance: AppearanceSettings, systemIsDark: Bool) -> [String] { - let effective = ThemeResolver.effectiveAppearance(mode: appearance.appearanceMode, systemIsDark: systemIsDark) + internal static func candidateIds( + appearance: AppearanceSettings, + mode: AppAppearanceMode, + systemIsDark: Bool + ) -> [String] { + let effective = mode.effectiveAppearance(systemIsDark: systemIsDark) switch effective { case .light: return [appearance.preferredLightThemeId, appearance.preferredDarkThemeId] case .dark: return [appearance.preferredDarkThemeId, appearance.preferredLightThemeId] diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 72cea8f928..5694038390 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -737,8 +737,12 @@ } } }, - "\"%@\" is a reserved theme identifier." : { + "“%@” Changed on Disk" : { + }, + "\"%@\" is a reserved theme identifier." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "“%@” is compressed." : { "localizations" : { @@ -877,6 +881,10 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "\"%@\" is not a TextMate theme." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "\"%@\" is not a URL. Enter an endpoint such as http://localhost:8000." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -904,7 +912,8 @@ } }, "\"%@\" is not a valid color. Use #RRGGBB or #RRGGBBAA." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "\"%@\" is not a valid entry point name." : { "extractionState" : "manual", @@ -939,7 +948,8 @@ } }, "\"%@\" is not a valid theme identifier." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "\"%@\" is not an app profile ID." : { "extractionState" : "manual", @@ -1076,6 +1086,9 @@ } } } + }, + "“%@” Was Deleted" : { + }, "\"%@\" was deleted or moved." : { "localizations" : { @@ -1533,6 +1546,14 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "\"colors\" in the theme is not a list of colors." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "\"tokenColors\" must be a list of rules or the path to a .tmTheme file." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "(%@)" : { "localizations" : { "fr" : { @@ -2383,6 +2404,7 @@ } }, "%@ %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2969,7 +2991,8 @@ } }, "%@ changed on disk since it was opened. The newer file was kept and reloaded." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "%@ changed while its documents were being checked, so nothing was changed. Save again." : { "extractionState" : "manual", @@ -5275,6 +5298,10 @@ } } }, + "%@ was deleted outside TablePro." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%@ was given arguments it cannot use." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -6619,6 +6646,9 @@ } } } + }, + "%1$@ could not be read: %2$@" : { + }, "%1$@ could not represent %2$@ and other characters. Each was written as \"?\"." : { "extractionState" : "manual", @@ -10583,6 +10613,7 @@ } }, "%lld of %lld selected" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11260,6 +11291,12 @@ } } } + }, + "%lld statement had already run and was kept, because a statement in the file, such as COMMIT, CREATE, ALTER or DROP, committed the import's transaction as it ran." : { + + }, + "%lld statement had already run and was rolled back." : { + }, "%lld statements" : { "localizations" : { @@ -11397,6 +11434,12 @@ } } } + }, + "%lld statements had already run and were rolled back." : { + + }, + "%lld statements had already run. Some were kept, because a statement in the file, such as COMMIT, CREATE, ALTER or DROP, committed the import's transaction as it ran." : { + }, "%lld suggestions available, press Down Arrow to browse" : { "comment" : "Announced when the completion list opens under a filter field. The argument is the number of suggestions.", @@ -14974,6 +15017,7 @@ } }, "A Data Pump job for %1$@ was started, writing to %2$@. Watch DBA_DATAPUMP_JOBS for its progress." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -15048,6 +15092,7 @@ } }, "A different file already exists at %@." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -15356,6 +15401,7 @@ } }, "A gs:// prefix. BigQuery shards its output, so several files are written under it." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -16652,6 +16698,26 @@ } } }, + "A theme author must be one line of at most %d characters." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "A theme name must be one line of at most %d characters." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "A theme named “%@” already exists." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "A token color scope in the theme has more than %d dot-separated parts." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "A token color scope in the theme is longer than %@ characters." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A transaction cannot be combined with skip and continue: together they would leave the target half applied." : { "localizations" : { "fr" : { @@ -18600,6 +18666,7 @@ } }, "Add a label at the end" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -19034,6 +19101,7 @@ } }, "Add Enum Label" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -19438,6 +19506,7 @@ } }, "Add Label" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -19448,6 +19517,7 @@ } }, "Add Label After…" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -19458,6 +19528,7 @@ } }, "Add Label Before…" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -20811,6 +20882,7 @@ } }, "AI and MCP queries" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -22715,6 +22787,7 @@ } }, "Allowing is remembered until TablePro quits." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -22821,6 +22894,8 @@ }, "Alternate Row" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -23661,6 +23736,7 @@ } }, "An external link wants to apply a filter:\n\n%@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -23837,6 +23913,7 @@ } }, "An internal or external stage. The role you are connected as needs write access to it." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -24173,6 +24250,7 @@ } }, "Anonymous (loopback)" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -24305,6 +24383,9 @@ } } } + }, + "Another file is there" : { + }, "Another foreign key is already named %@." : { "localizations" : { @@ -25041,6 +25122,9 @@ } } } + }, + "Appearance Change" : { + }, "Appearance:" : { "extractionState" : "stale", @@ -25094,6 +25178,7 @@ } }, "AppleScript queries" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -25305,6 +25390,9 @@ }, "Applies to the SQL editor, the JSON viewer's Text mode, and the previews." : { + }, + "Applies to this Mac." : { + }, "Apply" : { "localizations" : { @@ -25644,6 +25732,7 @@ } }, "Apply Filter" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -25684,6 +25773,7 @@ } }, "Apply Filter from Link" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -26304,6 +26394,7 @@ "generatesSymbol" : false }, "Are you sure you want to cancel the running query for this session?" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -26625,6 +26716,7 @@ } }, "Are you sure you want to terminate this session? Any running queries will be aborted." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -28254,8 +28346,15 @@ } } } + }, + "Author" : { + + }, + "Author Change" : { + }, "Auto" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -29486,6 +29585,8 @@ } }, "Background" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -29906,6 +30007,7 @@ } }, "Backups" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -30132,6 +30234,9 @@ "base64 text" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Based on %@" : { + }, "Baseline" : { "localizations" : { @@ -30617,6 +30722,7 @@ } }, "Block Size" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -30790,6 +30896,8 @@ "generatesSymbol" : false }, "Bool False" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -30830,6 +30938,8 @@ } }, "Bool True" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -31555,6 +31665,9 @@ } } } + }, + "Built-in themes can't be changed." : { + }, "Bulk actions" : { "localizations" : { @@ -32054,6 +32167,7 @@ } }, "Cache Size" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -32642,6 +32756,12 @@ } } } + }, + "Cancel the running query for session %@?" : { + + }, + "Cancel the running query for session %1$@ (user %2$@)?" : { + }, "Cancel the running query on a server session, or kill the session outright. Takes a process id from get_server_dashboard. Needs tools:write and the user's approval." : { "localizations" : { @@ -34859,6 +34979,8 @@ } }, "Changes" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -40505,6 +40627,9 @@ } } } + }, + "Close Without Saving" : { + }, "Closes the INSERT at this size or the row count, whichever comes first. MySQL and MariaDB reject a statement larger than max_allowed_packet, and a table of wide rows reaches that long before it reaches the row count." : { "extractionState" : "manual", @@ -40807,6 +40932,7 @@ } }, "Cloud Storage URI" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -42132,6 +42258,9 @@ } } } + }, + "Color Change" : { + }, "Color preview" : { @@ -45974,6 +46103,9 @@ } } } + }, + "Connect Using" : { + }, "Connect via" : { "localizations" : { @@ -48014,6 +48146,9 @@ } } } + }, + "Connects through the socket file on the General tab." : { + }, "Constraint name" : { "extractionState" : "manual", @@ -49821,6 +49956,7 @@ } }, "Copy Label" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -51308,9 +51444,6 @@ } } } - }, - "Could not create the theme" : { - }, "Could not decode image" : { "localizations" : { @@ -51383,9 +51516,6 @@ } } } - }, - "Could not delete the theme" : { - }, "could not derive the SCRAM key" : { "extractionState" : "manual", @@ -51577,6 +51707,7 @@ }, "Could not export the theme" : { "comment" : "Title of an error sheet when exporting a theme fails.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "fr" : { @@ -51818,9 +51949,6 @@ } } } - }, - "Could not import the theme" : { - }, "Could not initialize SQL projection" : { "extractionState" : "manual", @@ -52557,6 +52685,7 @@ } }, "Could not remove %@." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -52595,9 +52724,6 @@ } } } - }, - "Could not rename the theme" : { - }, "Could Not Render This Image" : { "localizations" : { @@ -52740,9 +52866,6 @@ } } } - }, - "Could not save the theme" : { - }, "Could not search the referenced table" : { "localizations" : { @@ -52807,6 +52930,7 @@ } }, "Could not write %@." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -53033,6 +53157,15 @@ } } } + }, + "Couldn't Duplicate the Theme" : { + + }, + "Couldn't Export the Theme" : { + + }, + "Couldn't Import “%@”" : { + }, "Couldn't Move File to Trash" : { "localizations" : { @@ -53073,6 +53206,12 @@ } } } + }, + "Couldn't Move the File to the Trash" : { + + }, + "Couldn't Move the Theme to the Trash" : { + }, "Couldn't publish to the team catalog" : { "localizations" : { @@ -53153,6 +53292,9 @@ } } } + }, + "Couldn't Put the Theme Back" : { + }, "Couldn't reach the plugin registry. Check your connection and try again." : { "localizations" : { @@ -53213,6 +53355,9 @@ } } } + }, + "Couldn't Rename the Theme" : { + }, "Couldn't Restore Version" : { "localizations" : { @@ -53223,6 +53368,9 @@ } } } + }, + "Couldn't Save “%@”" : { + }, "Couldn't Save File" : { "localizations" : { @@ -53924,6 +54072,7 @@ } }, "Create New Group" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -55102,6 +55251,8 @@ } }, "Current Line" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -55263,7 +55414,8 @@ } }, "Current Statement" : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Current value" : { "localizations" : { @@ -55357,6 +55509,8 @@ } }, "Cursor" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -55709,9 +55863,6 @@ } } } - }, - "Custom Themes" : { - }, "Custom…" : { "localizations" : { @@ -56052,6 +56203,7 @@ }, "Dashboard" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -56266,6 +56418,8 @@ } }, "Data Grid" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -56305,6 +56459,34 @@ } } }, + "Data Grid Alternate Row" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Background" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Bool False" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Bool True" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Deleted" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Deleted Text" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Focus Border" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Data Grid Font" : { "extractionState" : "stale", "localizations" : { @@ -56346,6 +56528,50 @@ } } }, + "Data Grid Header Background" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Header Text" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Inactive Selection" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Inserted" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Line" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Modified" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid NULL Value" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Row Number" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Selected Text" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Selection" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Data Grid Text" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Data loss" : { "localizations" : { "fr" : { @@ -59218,6 +59444,10 @@ } }, "Default Dark" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Default Dark color" : { }, "Default entry point" : { @@ -59271,6 +59501,10 @@ } }, "Default Light" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Default Light color" : { }, "Default Operator" : { @@ -59536,6 +59770,7 @@ } }, "Default token" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -61073,6 +61308,7 @@ } }, "Delete Theme" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -61393,6 +61629,8 @@ } }, "Deleted" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -61483,6 +61721,8 @@ } }, "Deleted Text" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -62879,6 +63119,7 @@ } }, "Directory object" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -66640,6 +66881,9 @@ } } } + }, + "Duplicate a theme or import one to customize colors." : { + }, "Duplicate column name: %@" : { "localizations" : { @@ -68385,6 +68629,8 @@ } }, "Editor" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -68464,6 +68710,22 @@ } } }, + "Editor Background" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Editor Current Line" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Editor Current Statement" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Editor Cursor" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Editor Font" : { "extractionState" : "stale", "localizations" : { @@ -68505,6 +68767,18 @@ } } }, + "Editor Invisibles" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Editor Line Number" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Editor Selection" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Editor Tabs" : { "comment" : "A label displayed above the tabs in the editor.", "isCommentAutoGenerated" : true, @@ -68547,6 +68821,10 @@ } } }, + "Editor Text" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Effective" : { "localizations" : { "fr" : { @@ -75029,6 +75307,7 @@ } }, "Export as SQL" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -76134,6 +76413,7 @@ } }, "Exports" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -78871,6 +79151,7 @@ } }, "Fetch all rows" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -81746,7 +82027,8 @@ } }, "Focus Border" : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Focus Editor" : { "localizations" : { @@ -82057,7 +82339,6 @@ } }, "Font" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -84757,6 +85038,9 @@ } } } + }, + "Google Cloud wide-column store for large data" : { + }, "Google did not return a refresh token. Remove TablePro's access in your Google Account settings, then sign in again." : { "localizations" : { @@ -85462,7 +85746,8 @@ "generatesSymbol" : false }, "Grid Line" : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Group" : { "localizations" : { @@ -85984,10 +86269,12 @@ } }, "Header Background" : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Header Text" : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Heart" : { "extractionState" : "manual", @@ -87269,6 +87556,9 @@ } } } + }, + "Host and Port" : { + }, "Host and Port are unused. The SSH tunnel forwards to the socket path set on the Network tab." : { "localizations" : { @@ -88048,6 +88338,7 @@ }, "iCloud Connected" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -89053,6 +89344,7 @@ } }, "Import Connections" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -89998,6 +90290,12 @@ } } } + }, + "Imported %d themes." : { + + }, + "Imported 1 theme." : { + }, "Imported connections ask for a password on first connect." : { "localizations" : { @@ -90354,7 +90652,8 @@ } }, "Inactive Selection" : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Include" : { "localizations" : { @@ -92660,6 +92959,8 @@ } }, "Inserted" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -94373,6 +94674,8 @@ } }, "Invisibles" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -94811,6 +95114,12 @@ } } } + }, + "It was changed outside TablePro and no longer loads. Settings > Appearance lists it under Not Loaded with the reason." : { + + }, + "It was changed outside TablePro. The editor now shows the newer version." : { + }, "Item count is approximate, updated about every six hours" : { "extractionState" : "manual", @@ -94988,6 +95297,7 @@ } }, "Journal Mode" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -95118,6 +95428,9 @@ } } } + }, + "JSON tree database that syncs in real time" : { + }, "JSON Viewer" : { "localizations" : { @@ -95876,6 +96189,9 @@ } } } + }, + "Keep Theme" : { + }, "Keep this plan when history is cleaned up" : { "localizations" : { @@ -96698,6 +97014,8 @@ } }, "Keyword" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -97148,6 +97466,7 @@ } }, "Labels" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -99969,6 +100288,8 @@ } }, "Line Number" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -100053,7 +100374,6 @@ } }, "Linked" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -106285,6 +106605,7 @@ } }, "Memory Limit" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -107696,6 +108017,8 @@ } }, "Modified" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -111827,7 +112150,6 @@ } }, "New Theme" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -111911,6 +112233,7 @@ } }, "New Type…" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -113496,9 +113819,6 @@ } } } - }, - "No Custom Themes" : { - }, "No Data" : { "localizations" : { @@ -117760,6 +118080,9 @@ } } } + }, + "No Themes Imported" : { + }, "No token in response" : { "extractionState" : "manual", @@ -118646,6 +118969,7 @@ } }, "Not Available" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -119247,6 +119571,7 @@ } }, "Not connected." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -119589,6 +119914,9 @@ } } } + }, + "Not linked" : { + }, "Not Loaded" : { @@ -120380,6 +120708,9 @@ } } } + }, + "Nothing is at this path. Start the server, or check the path." : { + }, "Nothing is left to copy once the target is taken into account." : { "localizations" : { @@ -120732,6 +121063,7 @@ } }, "Notify about" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -120893,6 +121225,8 @@ } }, "NULL" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -121216,6 +121550,8 @@ } }, "NULL Value" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -121843,6 +122179,7 @@ } }, "Object copies" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -124615,6 +124952,9 @@ }, "Open Link" : { + }, + "Open MCP Settings" : { + }, "Open or focus a TablePro window for a saved connection. Returns once the window has a tab, with the window id that list_recent_tabs reports." : { "localizations" : { @@ -125903,6 +126243,8 @@ } }, "Operator" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -127439,6 +127781,7 @@ } }, "Page Count" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -127519,6 +127862,7 @@ } }, "Page Size" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -132522,6 +132866,9 @@ } } } + }, + "Preferred leaves a socket connection unencrypted. Required encrypts it." : { + }, "Preferred performs a 2-pass connect: tries TLS first, falls back to plain only on SSL handshake errors. Required by Cloud SQL and Azure MySQL." : { "localizations" : { @@ -133186,9 +133533,6 @@ } } } - }, - "Preview of %@" : { - }, "Preview Query" : { "localizations" : { @@ -138072,6 +138416,9 @@ } } } + }, + "Quit Without Saving" : { + }, "Quitting stops the run against %@. Statements that already ran stay applied." : { "localizations" : { @@ -139615,8 +139962,12 @@ } } } + }, + "Read the live server panels TablePro shows: active sessions, server metrics, and slow queries. Available on PostgreSQL, MySQL, SQL Server, ClickHouse, and Typesense." : { + }, "Read the live server panels TablePro shows: active sessions, server metrics, and slow queries. Available on PostgreSQL, MySQL, SQL Server, ClickHouse, DuckDB, and SQLite." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -144199,6 +144550,7 @@ } }, "Rename Enum Label" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -144257,11 +144609,9 @@ } } } - }, - "Rename Theme" : { - }, "Rename…" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -145077,6 +145427,7 @@ } }, "Require authentication" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -147959,6 +148310,9 @@ } } } + }, + "Revert Changes" : { + }, "review" : { "extractionState" : "stale", @@ -149389,6 +149743,7 @@ } }, "Row edits" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -149609,6 +149964,8 @@ } }, "Row Number" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -154294,6 +154651,7 @@ } }, "Schema SQL" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -157744,9 +158102,12 @@ } }, "Selected Text" : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Selection" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -158900,6 +159261,7 @@ } }, "Server-Side Export" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -158910,6 +159272,7 @@ } }, "Server-Side Export…" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -158918,6 +159281,9 @@ } } } + }, + "Serverless document database from Google" : { + }, "Serverless SQLite at the edge" : { "localizations" : { @@ -162475,6 +162841,7 @@ }, "Showing %lld of %lld" : { "comment" : "A description of the number of visible databases in the sidebar. The first argument is the number of visible databases. The second argument is the number of all databases.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -163220,6 +163587,7 @@ } }, "Sign in to iCloud in System Settings to enable sync." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -163260,6 +163628,7 @@ } }, "Sign in to iCloud to enable sync" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -167221,6 +167590,7 @@ "generatesSymbol" : false }, "SRID %d is a projected coordinate system. Maps places longitude and latitude only, so these shapes cannot be drawn. Query ST_Transform(geom, 4326) to see them." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -168535,6 +168905,7 @@ "generatesSymbol" : false }, "Stage" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -169211,6 +169582,7 @@ } }, "Statement batches" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -170846,6 +171218,7 @@ } }, "Stopping…" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -171086,6 +171459,8 @@ } }, "String" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -171216,6 +171591,7 @@ } }, "Structure changes" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -173560,7 +173936,8 @@ } }, "Syntax" : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Syntax Colors" : { "extractionState" : "stale", @@ -173603,6 +173980,38 @@ } } }, + "Syntax Comment" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Syntax Function" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Syntax Keyword" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Syntax NULL" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Syntax Number" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Syntax Operator" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Syntax String" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Syntax Type" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "System" : { "localizations" : { "fr" : { @@ -173642,6 +174051,9 @@ } } } + }, + "System color" : { + }, "System Databases and Schemas" : { "localizations" : { @@ -175750,6 +176162,7 @@ } }, "TablePro could not do this for you. Run this in Terminal instead:" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -176032,7 +176445,8 @@ }, "TablePro does not use these theme keys: %@." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro doesn't recognize the database type “%@”." : { "localizations" : { @@ -178244,6 +178658,12 @@ } } } + }, + "Terminate session %@? Any running queries will be aborted." : { + + }, + "Terminate session %1$@ (user %2$@)? Any running queries will be aborted." : { + }, "Tertiary Text" : { "extractionState" : "stale", @@ -178501,6 +178921,8 @@ } }, "Text" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -178539,6 +178961,9 @@ } } } + }, + "Text contrast is %@:1. Aim for at least 4.5:1." : { + }, "Text Encoding" : { "localizations" : { @@ -178745,6 +179170,7 @@ } }, "That destination is not valid for this engine." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -180925,6 +181351,7 @@ } }, "The color marks this connection in the connection list and its window." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -182570,7 +182997,8 @@ } }, "The file does not declare a theme format version." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "The file goes back to its last committed version. You can't undo this action." : { "localizations" : { @@ -182694,6 +183122,10 @@ } } }, + "The file is not valid JSON." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The file is too large to read from Git." : { "localizations" : { "fr" : { @@ -182704,6 +183136,23 @@ } } }, + "The file is too large. A theme file can be at most %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The file moves to the Trash. Dark windows switch to %@." : { + + }, + "The file moves to the Trash. Light and dark windows switch to their default themes." : { + + }, + "The file moves to the Trash. Light windows switch to %@." : { + + }, + "The file nests arrays and objects more than %d levels deep." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The file or its staged version changed before it could be written. Nothing was replaced." : { "localizations" : { "fr" : { @@ -182713,6 +183162,9 @@ } } } + }, + "The file was removed outside TablePro, possibly on another Mac." : { + }, "The filter field is not in the toolbar." : { "localizations" : { @@ -183793,6 +184245,9 @@ } } } + }, + "The MCP server is turned off. Turn it on in Settings > MCP." : { + }, "The MCP server started without a port" : { "localizations" : { @@ -183937,6 +184392,7 @@ } }, "The name of a DIRECTORY object, not a path. The DBA who created it chose where it points." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -185565,6 +186021,9 @@ } } } + }, + "The rollback did not undo everything. A statement in the file, such as COMMIT, CREATE, ALTER or DROP, committed the import's transaction as it ran, so some of the statements before the failure were kept." : { + }, "The root of the database" : { "extractionState" : "manual", @@ -186408,6 +186867,7 @@ } }, "The server is writing the file…" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -186592,6 +187052,7 @@ } }, "The server writes the file, so it lands where the server can reach, not on this Mac." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -186602,6 +187063,7 @@ } }, "The server wrote %1$@ to %2$@." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -186620,6 +187082,9 @@ } } } + }, + "The server's socket file on this Mac. SELECT @@socket in the mysql client shows it." : { + }, "The server's TLS certificate could not be verified against any trusted root." : { "extractionState" : "manual", @@ -186728,6 +187193,9 @@ } } } + }, + "The settings it was created with could not be read, so it gets the target's own." : { + }, "The shell drops a field named __proto__ from a new document. Remove the field, then set it on the saved document." : { "extractionState" : "manual", @@ -188555,14 +189023,41 @@ } } }, + "The theme has more than %@ token color rules." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The theme has no colors TablePro can use." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The theme identifier \"%1$@\" is already used by %2$@." : { - + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The theme includes \"%@\", which is not next to it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The theme includes \"%@\", which is outside its folder. Put that file next to the theme and import again." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The theme includes itself through \"%@\"." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "The theme is missing the required field \"%@\"." : { - + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The theme nests includes more than %d levels deep." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "The theme sets \"%@\" more than once." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "the TLS handshake failed" : { "extractionState" : "manual", @@ -189302,9 +189797,14 @@ } }, "Theme format %1$d is no longer supported. This version of TablePro reads format %2$d." : { - + "extractionState" : "manual", + "generatesSymbol" : false }, "Theme format %1$d was made for a newer TablePro. This version reads format %2$d." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Theme Preview" : { }, "Theme:" : { @@ -189347,6 +189847,9 @@ } } } + }, + "Themes" : { + }, "Themes color the editor, the data grid, other content views and the window background. Controls, sidebars and Settings follow the system appearance." : { @@ -189594,6 +190097,7 @@ } }, "These connections open their tunnel by running a command on this Mac, every time they connect:\n\n%@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -190846,6 +191350,7 @@ } }, "This database cannot change an enum's labels" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -192246,6 +192751,13 @@ } } }, + "This file is not a socket." : { + + }, + "This file is not a TablePro theme or a VS Code color theme." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This file is not a valid TablePro export" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -195648,6 +196160,7 @@ } }, "Threads" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -196633,6 +197146,15 @@ } } } + }, + "To add it to your PATH, run this in Terminal:" : { + + }, + "To remove it, run this in Terminal:" : { + + }, + "To replace that file with a link to this app, run this in Terminal:" : { + }, "to view data" : { "localizations" : { @@ -197644,6 +198166,7 @@ } }, "Total Blocks" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -203543,6 +204066,12 @@ } } } + }, + "Use Default Color" : { + + }, + "Use Default Color for %@" : { + }, "Use environment variables in connection fields." : { "localizations" : { @@ -203583,6 +204112,12 @@ } } } + }, + "Use for Dark Appearance" : { + + }, + "Use for Light Appearance" : { + }, "Use IAM database authentication" : { "localizations" : { @@ -203763,6 +204298,12 @@ } } } + }, + "Use System Color" : { + + }, + "Use System Color for %@" : { + }, "Use text mode to enter JSON." : { @@ -205481,6 +206022,7 @@ } }, "Values from %d loaded rows" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -209561,7 +210103,8 @@ } }, "Window Background" : { - "extractionState" : "stale", + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -209840,6 +210383,9 @@ } } } + }, + "With Increase Contrast on, the window uses the system background." : { + }, "With no precision on the source, the column keeps %1$lld digits, %2$lld after the point. A value with more is rounded or refused." : { "localizations" : { diff --git a/TablePro/Resources/Themes/tablepro.catppuccin-latte.json b/TablePro/Resources/Themes/tablepro.catppuccin-latte.json new file mode 100644 index 0000000000..8cf7be7217 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.catppuccin-latte.json @@ -0,0 +1,50 @@ +{ + "appearance": "light", + "author": "Catppuccin", + "dataGrid": { + "alternateRow": "#E6E9EF", + "background": "#EFF1F5", + "boolFalse": "#D20F39", + "boolTrue": "#40A02B", + "deleted": "#D20F3926", + "deletedText": "#D20F3980", + "focusBorder": "#8839EF", + "gridLine": "#BCC0CC", + "headerBackground": "#E6E9EF", + "headerText": "#4C4F69", + "inactiveSelection": "#CCD0DA", + "inserted": "#40A02B26", + "modified": "#DF8E1D26", + "nullValue": "#7C7F93", + "rowNumber": "#8C8FA1", + "selection": "#CCD0DA", + "selectionText": "#4C4F69", + "text": "#4C4F69" + }, + "editor": { + "background": "#EFF1F5", + "currentLine": "#4C4F6912", + "currentStatement": "#4C4F690C", + "cursor": "#E64553", + "invisibles": "#7C7F9366", + "lineNumber": "#8C8FA1", + "selection": "#7C7F934D", + "syntax": { + "comment": "#7C7F93", + "function": "#1E66F5", + "keyword": "#8839EF", + "null": "#D20F39", + "number": "#FE640B", + "operator": "#179299", + "string": "#40A02B", + "type": "#DF8E1D" + }, + "text": "#4C4F69" + }, + "id": "tablepro.catppuccin-latte", + "name": "Catppuccin Latte", + "schema": 3, + "window": { + "background": "#EFF1F5" + } +} diff --git a/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json b/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json new file mode 100644 index 0000000000..2ce5a2d0e8 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json @@ -0,0 +1,50 @@ +{ + "appearance": "dark", + "author": "Catppuccin", + "dataGrid": { + "alternateRow": "#181825", + "background": "#1E1E2E", + "boolFalse": "#F38BA8", + "boolTrue": "#A6E3A1", + "deleted": "#F38BA826", + "deletedText": "#F38BA880", + "focusBorder": "#CBA6F7", + "gridLine": "#45475A", + "headerBackground": "#181825", + "headerText": "#CDD6F4", + "inactiveSelection": "#313244", + "inserted": "#A6E3A126", + "modified": "#F9E2AF26", + "nullValue": "#9399B2", + "rowNumber": "#7F849C", + "selection": "#313244", + "selectionText": "#CDD6F4", + "text": "#CDD6F4" + }, + "editor": { + "background": "#1E1E2E", + "currentLine": "#CDD6F412", + "currentStatement": "#CDD6F40C", + "cursor": "#F5E0DC", + "invisibles": "#9399B266", + "lineNumber": "#7F849C", + "selection": "#9399B240", + "syntax": { + "comment": "#9399B2", + "function": "#89B4FA", + "keyword": "#CBA6F7", + "null": "#F38BA8", + "number": "#FAB387", + "operator": "#94E2D5", + "string": "#A6E3A1", + "type": "#F9E2AF" + }, + "text": "#CDD6F4" + }, + "id": "tablepro.catppuccin-mocha", + "name": "Catppuccin Mocha", + "schema": 3, + "window": { + "background": "#1E1E2E" + } +} diff --git a/TablePro/Resources/Themes/tablepro.dracula.json b/TablePro/Resources/Themes/tablepro.dracula.json index cb383ae0f7..94a1575329 100644 --- a/TablePro/Resources/Themes/tablepro.dracula.json +++ b/TablePro/Resources/Themes/tablepro.dracula.json @@ -1,6 +1,6 @@ { "appearance": "dark", - "author": "TablePro", + "author": "Dracula Theme", "dataGrid": { "alternateRow": "#21222C", "background": "#282A36", diff --git a/TablePro/Resources/Themes/tablepro.github-dark.json b/TablePro/Resources/Themes/tablepro.github-dark.json new file mode 100644 index 0000000000..cb921d592e --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.github-dark.json @@ -0,0 +1,50 @@ +{ + "appearance": "dark", + "author": "Primer", + "dataGrid": { + "alternateRow": "#161B22", + "background": "#0D1117", + "boolFalse": "#F85149", + "boolTrue": "#3FB950", + "deleted": "#F8514926", + "deletedText": "#F8514980", + "focusBorder": "#1F6FEB", + "gridLine": "#30363D", + "headerBackground": "#161B22", + "headerText": "#E6EDF3", + "inactiveSelection": "#343941", + "inserted": "#2EA04326", + "modified": "#BB800926", + "nullValue": "#8B949E", + "rowNumber": "#6E7681", + "selection": "#142744", + "selectionText": "#E6EDF3", + "text": "#E6EDF3" + }, + "editor": { + "background": "#0D1117", + "currentLine": "#6E76811A", + "currentStatement": "#E6EDF30C", + "cursor": "#2F81F7", + "invisibles": "#484F58", + "lineNumber": "#6E7681", + "selection": "#2F81F733", + "syntax": { + "comment": "#8B949E", + "function": "#D2A8FF", + "keyword": "#FF7B72", + "null": "#79C0FF", + "number": "#79C0FF", + "operator": "#FF7B72", + "string": "#A5D6FF", + "type": "#FFA657" + }, + "text": "#E6EDF3" + }, + "id": "tablepro.github-dark", + "name": "GitHub Dark", + "schema": 3, + "window": { + "background": "#0D1117" + } +} diff --git a/TablePro/Resources/Themes/tablepro.github-light.json b/TablePro/Resources/Themes/tablepro.github-light.json new file mode 100644 index 0000000000..b18d295e08 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.github-light.json @@ -0,0 +1,50 @@ +{ + "appearance": "light", + "author": "Primer", + "dataGrid": { + "alternateRow": "#F6F8FA", + "background": "#FFFFFF", + "boolFalse": "#CF222E", + "boolTrue": "#1A7F37", + "deleted": "#FF818226", + "deletedText": "#CF222E80", + "focusBorder": "#0969DA", + "gridLine": "#D0D7DE", + "headerBackground": "#F6F8FA", + "headerText": "#1F2328", + "inactiveSelection": "#EFF1F3", + "inserted": "#4AC26B26", + "modified": "#D4A72C26", + "nullValue": "#6E7781", + "rowNumber": "#8C959F", + "selection": "#CEE1F8", + "selectionText": "#1F2328", + "text": "#1F2328" + }, + "editor": { + "background": "#FFFFFF", + "currentLine": "#EAEEF280", + "currentStatement": "#1F23280C", + "cursor": "#0969DA", + "invisibles": "#AFB8C1", + "lineNumber": "#8C959F", + "selection": "#0969DA33", + "syntax": { + "comment": "#6E7781", + "function": "#8250DF", + "keyword": "#CF222E", + "null": "#0550AE", + "number": "#0550AE", + "operator": "#CF222E", + "string": "#0A3069", + "type": "#953800" + }, + "text": "#1F2328" + }, + "id": "tablepro.github-light", + "name": "GitHub Light", + "schema": 3, + "window": { + "background": "#FFFFFF" + } +} diff --git a/TablePro/Resources/Themes/tablepro.gruvbox-dark.json b/TablePro/Resources/Themes/tablepro.gruvbox-dark.json new file mode 100644 index 0000000000..7cb0d25f12 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.gruvbox-dark.json @@ -0,0 +1,50 @@ +{ + "appearance": "dark", + "author": "JD", + "dataGrid": { + "alternateRow": "#32302F", + "background": "#282828", + "boolFalse": "#FB4934", + "boolTrue": "#B8BB26", + "deleted": "#FB493426", + "deletedText": "#FB493480", + "focusBorder": "#689D6A", + "gridLine": "#504945", + "headerBackground": "#32302F", + "headerText": "#EBDBB2", + "inactiveSelection": "#3C3836", + "inserted": "#B8BB2626", + "modified": "#D7992126", + "nullValue": "#928374", + "rowNumber": "#665C54", + "selection": "#384539", + "selectionText": "#EBDBB2", + "text": "#EBDBB2" + }, + "editor": { + "background": "#282828", + "currentLine": "#3C383660", + "currentStatement": "#EBDBB20C", + "cursor": "#EBDBB2", + "invisibles": "#A8998420", + "lineNumber": "#665C54", + "selection": "#689D6A40", + "syntax": { + "comment": "#928374", + "function": "#FABD2F", + "keyword": "#FB4934", + "null": "#D3869B", + "number": "#D3869B", + "operator": "#8EC07C", + "string": "#B8BB26", + "type": "#FABD2F" + }, + "text": "#EBDBB2" + }, + "id": "tablepro.gruvbox-dark", + "name": "Gruvbox Dark", + "schema": 3, + "window": { + "background": "#282828" + } +} diff --git a/TablePro/Resources/Themes/tablepro.gruvbox-light.json b/TablePro/Resources/Themes/tablepro.gruvbox-light.json new file mode 100644 index 0000000000..2ef6cd9cf9 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.gruvbox-light.json @@ -0,0 +1,50 @@ +{ + "appearance": "light", + "author": "JD", + "dataGrid": { + "alternateRow": "#F2E5BC", + "background": "#FBF1C7", + "boolFalse": "#9D0006", + "boolTrue": "#79740E", + "deleted": "#9D000626", + "deletedText": "#9D000680", + "focusBorder": "#427B58", + "gridLine": "#D5C4A1", + "headerBackground": "#F2E5BC", + "headerText": "#3C3836", + "inactiveSelection": "#EBDBB2", + "inserted": "#79740E26", + "modified": "#D7992126", + "nullValue": "#928374", + "rowNumber": "#BDAE93", + "selection": "#D6DCB0", + "selectionText": "#3C3836", + "text": "#3C3836" + }, + "editor": { + "background": "#FBF1C7", + "currentLine": "#EBDBB260", + "currentStatement": "#3C38360C", + "cursor": "#3C3836", + "invisibles": "#7C6F6420", + "lineNumber": "#BDAE93", + "selection": "#689D6A40", + "syntax": { + "comment": "#928374", + "function": "#B57614", + "keyword": "#9D0006", + "null": "#8F3F71", + "number": "#8F3F71", + "operator": "#427B58", + "string": "#79740E", + "type": "#B57614" + }, + "text": "#3C3836" + }, + "id": "tablepro.gruvbox-light", + "name": "Gruvbox Light", + "schema": 3, + "window": { + "background": "#FBF1C7" + } +} diff --git a/TablePro/Resources/Themes/tablepro.nord.json b/TablePro/Resources/Themes/tablepro.nord.json index 2eb0c33f99..a2b30e1b3b 100644 --- a/TablePro/Resources/Themes/tablepro.nord.json +++ b/TablePro/Resources/Themes/tablepro.nord.json @@ -1,6 +1,6 @@ { "appearance": "dark", - "author": "TablePro", + "author": "Sven Greb", "dataGrid": { "alternateRow": "#3B4252", "background": "#2E3440", diff --git a/TablePro/Resources/Themes/tablepro.one-dark.json b/TablePro/Resources/Themes/tablepro.one-dark.json new file mode 100644 index 0000000000..f9098d550a --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.one-dark.json @@ -0,0 +1,50 @@ +{ + "appearance": "dark", + "author": "GitHub Inc.", + "dataGrid": { + "alternateRow": "#21252B", + "background": "#282C34", + "boolFalse": "#E06C75", + "boolTrue": "#98C379", + "deleted": "#E0525226", + "deletedText": "#E06C7580", + "focusBorder": "#528BFF", + "gridLine": "#3E4452", + "headerBackground": "#21252B", + "headerText": "#ABB2BF", + "inactiveSelection": "#323842", + "inserted": "#43D08A26", + "modified": "#E0C28526", + "nullValue": "#7F848E", + "rowNumber": "#636D83", + "selection": "#3E4451", + "selectionText": "#D7DAE0", + "text": "#ABB2BF" + }, + "editor": { + "background": "#282C34", + "currentLine": "#99BBFF0A", + "currentStatement": "#ABB2BF0C", + "cursor": "#528BFF", + "invisibles": "#FFFFFF1D", + "lineNumber": "#636D83", + "selection": "#3E4451", + "syntax": { + "comment": "#7F848E", + "function": "#61AFEF", + "keyword": "#C678DD", + "null": "#D19A66", + "number": "#D19A66", + "operator": "#56B6C2", + "string": "#98C379", + "type": "#E5C07B" + }, + "text": "#ABB2BF" + }, + "id": "tablepro.one-dark", + "name": "One Dark", + "schema": 3, + "window": { + "background": "#282C34" + } +} diff --git a/TablePro/Resources/Themes/tablepro.one-light.json b/TablePro/Resources/Themes/tablepro.one-light.json new file mode 100644 index 0000000000..0af9b52720 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.one-light.json @@ -0,0 +1,50 @@ +{ + "appearance": "light", + "author": "GitHub Inc.", + "dataGrid": { + "alternateRow": "#EAEAEB", + "background": "#FAFAFA", + "boolFalse": "#E45649", + "boolTrue": "#50A14F", + "deleted": "#FF141426", + "deletedText": "#E4564980", + "focusBorder": "#526FFF", + "gridLine": "#DBDBDC", + "headerBackground": "#EAEAEB", + "headerText": "#383A42", + "inactiveSelection": "#DBDBDC", + "inserted": "#2DB44826", + "modified": "#F2A60D26", + "nullValue": "#A0A1A7", + "rowNumber": "#9D9D9F", + "selection": "#DBDBDC", + "selectionText": "#232324", + "text": "#383A42" + }, + "editor": { + "background": "#FAFAFA", + "currentLine": "#383A420C", + "currentStatement": "#383A420C", + "cursor": "#526FFF", + "invisibles": "#383A4233", + "lineNumber": "#9D9D9F", + "selection": "#E5E5E6", + "syntax": { + "comment": "#A0A1A7", + "function": "#4078F2", + "keyword": "#A626A4", + "null": "#986801", + "number": "#986801", + "operator": "#383A42", + "string": "#50A14F", + "type": "#C18401" + }, + "text": "#383A42" + }, + "id": "tablepro.one-light", + "name": "One Light", + "schema": 3, + "window": { + "background": "#FAFAFA" + } +} diff --git a/TablePro/Resources/Themes/tablepro.solarized-dark.json b/TablePro/Resources/Themes/tablepro.solarized-dark.json new file mode 100644 index 0000000000..7f7374fcab --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.solarized-dark.json @@ -0,0 +1,50 @@ +{ + "appearance": "dark", + "author": "Ethan Schoonover", + "dataGrid": { + "alternateRow": "#00212B", + "background": "#002B36", + "boolFalse": "#DC322F", + "boolTrue": "#859900", + "deleted": "#DC322F26", + "deletedText": "#DC322F80", + "focusBorder": "#2AA198", + "gridLine": "#23464F", + "headerBackground": "#00212B", + "headerText": "#93A1A1", + "inactiveSelection": "#003846", + "inserted": "#85990026", + "modified": "#B5890026", + "nullValue": "#586E75", + "rowNumber": "#586E75", + "selection": "#005A6F", + "selectionText": "#EEE8D5", + "text": "#839496" + }, + "editor": { + "background": "#002B36", + "currentLine": "#073642", + "currentStatement": "#07364280", + "cursor": "#DC322F", + "invisibles": "#93A1A180", + "lineNumber": "#586E75", + "selection": "#274642", + "syntax": { + "comment": "#586E75", + "function": "#268BD2", + "keyword": "#859900", + "null": "#B58900", + "number": "#D33682", + "operator": "#859900", + "string": "#2AA198", + "type": "#CB4B16" + }, + "text": "#839496" + }, + "id": "tablepro.solarized-dark", + "name": "Solarized Dark", + "schema": 3, + "window": { + "background": "#002B36" + } +} diff --git a/TablePro/Resources/Themes/tablepro.solarized-light.json b/TablePro/Resources/Themes/tablepro.solarized-light.json new file mode 100644 index 0000000000..9ff51235cf --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.solarized-light.json @@ -0,0 +1,50 @@ +{ + "appearance": "light", + "author": "Ethan Schoonover", + "dataGrid": { + "alternateRow": "#EEE8D5", + "background": "#FDF6E3", + "boolFalse": "#DC322F", + "boolTrue": "#859900", + "deleted": "#DC322F26", + "deletedText": "#DC322F80", + "focusBorder": "#268BD2", + "gridLine": "#DDD6C1", + "headerBackground": "#EEE8D5", + "headerText": "#586E75", + "inactiveSelection": "#EEE0B5", + "inserted": "#85990026", + "modified": "#B5890026", + "nullValue": "#93A1A1", + "rowNumber": "#93A1A1", + "selection": "#DFCA88", + "selectionText": "#073642", + "text": "#657B83" + }, + "editor": { + "background": "#FDF6E3", + "currentLine": "#EEE8D5", + "currentStatement": "#EEE8D580", + "cursor": "#657B83", + "invisibles": "#586E7580", + "lineNumber": "#93A1A1", + "selection": "#EEE8D5", + "syntax": { + "comment": "#93A1A1", + "function": "#268BD2", + "keyword": "#859900", + "null": "#B58900", + "number": "#D33682", + "operator": "#859900", + "string": "#2AA198", + "type": "#CB4B16" + }, + "text": "#657B83" + }, + "id": "tablepro.solarized-light", + "name": "Solarized Light", + "schema": 3, + "window": { + "background": "#FDF6E3" + } +} diff --git a/TablePro/Resources/Themes/tablepro.tokyo-night-light.json b/TablePro/Resources/Themes/tablepro.tokyo-night-light.json new file mode 100644 index 0000000000..02342393c3 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.tokyo-night-light.json @@ -0,0 +1,50 @@ +{ + "appearance": "light", + "author": "Enkia", + "dataGrid": { + "alternateRow": "#D6D8DF", + "background": "#E6E7ED", + "boolFalse": "#8C4351", + "boolTrue": "#385F0D", + "deleted": "#E8686826", + "deletedText": "#8C435180", + "focusBorder": "#2959AA", + "gridLine": "#C1C2C7", + "headerBackground": "#D6D8DF", + "headerText": "#343B59", + "inactiveSelection": "#C9CBD6", + "inserted": "#2D9C9126", + "modified": "#8F5E1526", + "nullValue": "#888B94", + "rowNumber": "#9DA0AB", + "selection": "#ACB0BF", + "selectionText": "#343B59", + "text": "#343B59" + }, + "editor": { + "background": "#E6E7ED", + "currentLine": "#DCDEE3", + "currentStatement": "#DCDEE380", + "cursor": "#363C4D", + "invisibles": "#C1C2C7", + "lineNumber": "#9DA0AB", + "selection": "#ACB0BF40", + "syntax": { + "comment": "#888B94", + "function": "#2959AA", + "keyword": "#0F4B6E", + "null": "#965027", + "number": "#965027", + "operator": "#65359D", + "string": "#385F0D", + "type": "#343B58" + }, + "text": "#343B59" + }, + "id": "tablepro.tokyo-night-light", + "name": "Tokyo Night Light", + "schema": 3, + "window": { + "background": "#E6E7ED" + } +} diff --git a/TablePro/Resources/Themes/tablepro.tokyo-night.json b/TablePro/Resources/Themes/tablepro.tokyo-night.json new file mode 100644 index 0000000000..3fa07811f3 --- /dev/null +++ b/TablePro/Resources/Themes/tablepro.tokyo-night.json @@ -0,0 +1,50 @@ +{ + "appearance": "dark", + "author": "Enkia", + "dataGrid": { + "alternateRow": "#16161E", + "background": "#1A1B26", + "boolFalse": "#F7768E", + "boolTrue": "#9ECE6A", + "deleted": "#DB4B4B26", + "deletedText": "#F7768E80", + "focusBorder": "#7AA2F7", + "gridLine": "#232433", + "headerBackground": "#16161E", + "headerText": "#A9B1D6", + "inactiveSelection": "#222433", + "inserted": "#41A6B526", + "modified": "#E0AF6826", + "nullValue": "#51597D", + "rowNumber": "#363B54", + "selection": "#2B2F41", + "selectionText": "#A9B1D6", + "text": "#A9B1D6" + }, + "editor": { + "background": "#1A1B26", + "currentLine": "#1E202E", + "currentStatement": "#1E202E80", + "cursor": "#C0CAF5", + "invisibles": "#363B54", + "lineNumber": "#363B54", + "selection": "#515C7E4D", + "syntax": { + "comment": "#51597D", + "function": "#7AA2F7", + "keyword": "#7DCFFF", + "null": "#FF9E64", + "number": "#FF9E64", + "operator": "#BB9AF7", + "string": "#9ECE6A", + "type": "#C0CAF5" + }, + "text": "#A9B1D6" + }, + "id": "tablepro.tokyo-night", + "name": "Tokyo Night", + "schema": 3, + "window": { + "background": "#1A1B26" + } +} diff --git a/TablePro/Resources/ThirdPartyLicenses/licenses.yml b/TablePro/Resources/ThirdPartyLicenses/licenses.yml index 4bfaf8ac32..0cda2cacf6 100644 --- a/TablePro/Resources/ThirdPartyLicenses/licenses.yml +++ b/TablePro/Resources/ThirdPartyLicenses/licenses.yml @@ -11,6 +11,20 @@ # The iOS app reads TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json, the # projection of every ios entry. Run scripts/generate-ios-acknowledgements.py after editing an # ios entry; scripts/ci/check-license-inventory.py fails while that file is stale. +- id: vscode-theme-onelight + name: Atom One Light Theme + version: vendored at 5866e900d, 2023-10-07 + spdx: MIT + copyrights: + - Copyright (c) 2015 Mahmoud Ali + homepageURL: https://github.com/akamud/vscode-theme-onelight + licenseTextURL: https://github.com/akamud/vscode-theme-onelight/blob/5866e900db932d580e978a58db42f65cde07998b/LICENSE + textFile: texts/vscode-theme-onelight.txt + source: manual + patched: false + platforms: [macos] + notes: Color values only, for the built-in One Light theme (TablePro/Resources/Themes/tablepro.one-light.json), + read from themes/OneLight.json (package version 2.3.0). No code or file from the project ships. - id: bigint name: BigInt version: 5.7.0 @@ -27,6 +41,23 @@ diacritics (Karoly Lorentey, with o-acute and o-double-acute) in the original file; reproduce it from texts/bigint.LICENSE.txt rather than from this YAML, which is ASCII-folded. Transitive via oracle-nio. Zero `$s6BigInt` symbols in the app dylib. +- id: catppuccin-vscode + name: Catppuccin for VSCode + version: 3.18.1 + spdx: MIT + copyrights: + - Copyright (c) 2021 Catppuccin + homepageURL: https://github.com/catppuccin/vscode + licenseTextURL: https://github.com/catppuccin/vscode/blob/155479e33394c54eab2bcc40c2d8de9c17ab4599/LICENSE + textFile: texts/catppuccin-vscode.txt + source: manual + patched: false + platforms: [macos] + notes: Color values only, for the built-in Catppuccin Mocha and Catppuccin Latte themes + (TablePro/Resources/Themes/tablepro.catppuccin-mocha.json and tablepro.catppuccin-latte.json), read + from themes/mocha.json and themes/latte.json in the npm package @catppuccin/vscode 3.18.1 (tag + @catppuccin/vscode-v3.18.1 at 155479e33394). The package carries the same LICENSE as the repository. + No code or file from the project ships. - id: chinook name: Chinook Database version: vendored at ac32dbc3d, 2025-10-04 @@ -95,6 +126,21 @@ edit made inside the script, not a file under scripts/patches/, so patched is false but the tree is not byte-identical to upstream. The build uses Homebrew openssl@3 (build-cassandra.sh:99), not the pinned OpenSSL.' +- id: dracula-visual-studio-code + name: Dracula Theme Official + version: vendored at a08a206f2, 2026-09-14 + spdx: MIT + copyrights: + - Copyright (c) 2016 Dracula Theme + homepageURL: https://github.com/dracula/visual-studio-code + licenseTextURL: https://github.com/dracula/visual-studio-code/blob/a08a206f2c8420ba3c05f0e8d01d43b2f933fdf8/LICENSE + textFile: texts/dracula-visual-studio-code.txt + source: manual + patched: false + platforms: [macos] + notes: Color values only, for the built-in Dracula theme (TablePro/Resources/Themes/tablepro.dracula.json). + Every value in that file is in src/dracula.yml (package version 2.25.1). No code or file from the + project ships. - id: duckdb name: DuckDB version: v1.5.2 @@ -149,6 +195,25 @@ sources, so the modified library stays LGPL-2.0-or-later and the modification must be offered alongside. Static-linked LGPL: same relinking obligation as MariaDB Connector/C. One source tree serves the macOS and both iOS slices, so the patch covers all of them.' +- id: github-vscode-theme + name: GitHub Theme + version: 6.3.5 + spdx: MIT + copyrights: + - Copyright (c) 2020 Primer + homepageURL: https://github.com/primer/github-vscode-theme + licenseTextURL: https://github.com/primer/github-vscode-theme/blob/v6.3.5/LICENSE + textFile: texts/github-vscode-theme.txt + source: manual + patched: false + platforms: [macos] + notes: 'Color values only, for the built-in GitHub Light and GitHub Dark themes + (TablePro/Resources/Themes/tablepro.github-light.json and tablepro.github-dark.json), read from + themes/light-default.json and themes/dark-default.json in the 6.3.5 VSIX on Open VSX (SHA-256 + a85236a58ad490b1600ec5a0fd0cea7eec56fb9d33660a1ac82c6da639ad3af4; tag v6.3.5 at ad3f17519f2f). The + built files carry no editor.selectionBackground: src/theme.js sets alpha(accent.fg, 0.2) at line 188 + and overwrites it at line 198, so TablePro uses the line 188 value. No code or file from the project + ships.' - id: go name: Go (runtime and standard library) version: 1.27.1 @@ -206,6 +271,26 @@ LICENSE at the tag is byte-identical to the Go distribution''s LICENSE, but this is a separate module with its own version, so it has its own entry. Reaches users only through the registry-only SAP HANA plugin.' +- id: vscode-theme-gruvbox + name: Gruvbox Theme + version: 1.29.1 + spdx: MIT + copyrights: + - Copyright (c) 2017 JD + homepageURL: https://github.com/jdinhify/vscode-theme-gruvbox + licenseTextURL: https://github.com/jdinhify/vscode-theme-gruvbox/blob/v1.29.1/LICENSE + textFile: texts/vscode-theme-gruvbox.txt + source: manual + patched: false + platforms: [macos] + notes: 'Color values only, for the built-in Gruvbox Dark and Gruvbox Light themes + (TablePro/Resources/Themes/tablepro.gruvbox-dark.json and tablepro.gruvbox-light.json), read from + themes/gruvbox-dark-medium.json and themes/gruvbox-light-medium.json in the 1.29.1 VSIX on Open VSX + (jdinhlife.gruvbox, SHA-256 8fd3166439d3bdc421ebc3d3f6d33f33985e173cb57d396c231daaaeb7e09240) and the + palette in src/shared.ts at tag v1.29.1. The original Gruvbox palette, morhetz/gruvbox, publishes no + license file, so this port is the source of record. The holder line uses the copyright sign in the + original file; reproduce it from texts/vscode-theme-gruvbox.txt rather than from this YAML, which is + ASCII-folded. No code or file from the project ships.' - id: hiredis name: hiredis version: 1.2.0 @@ -329,6 +414,68 @@ grant), mongoc.css_t, zlib, common-md5.c and utf8proc. The full text is saved in the extra text file. Tarball is the GitHub release archive for 1.28.1 (build-libmongoc.sh:74), SHA-256 pinned at line 42. The script writes the flat Libs/libmongoc.a and Libs/libbson.a itself (build-libmongoc.sh:219).' +- id: nord-visual-studio-code + name: Nord Visual Studio Code + version: vendored at 8ead09822, 2023-05-03 + spdx: MIT + copyrights: + - Copyright (c) 2016-present Sven Greb (https://www.svengreb.de) + homepageURL: https://github.com/nordtheme/visual-studio-code + licenseTextURL: https://github.com/nordtheme/visual-studio-code/blob/8ead09822c02d0d49d0f764104505e5a34d3689f/license + textFile: texts/nord-visual-studio-code.txt + source: manual + patched: false + platforms: [macos] + notes: Color values only, for the built-in Nord theme (TablePro/Resources/Themes/tablepro.nord.json). Every + value in that file is in themes/nord-color-theme.json (package version 0.19.0). The palette repository, + nordtheme/nord, carries the same license and holder. No code or file from the project ships. +- id: one-dark-pro + name: One Dark Pro + version: 3.20.3 + spdx: MIT + copyrights: + - Copyright (c) 2013-2022 Binaryify + homepageURL: https://github.com/Binaryify/OneDark-Pro + licenseTextURL: https://github.com/Binaryify/OneDark-Pro/blob/3.20.3/LICENSE.txt + textFile: texts/one-dark-pro.txt + source: manual + patched: false + platforms: [macos] + notes: Color values only, for the built-in One Dark theme (TablePro/Resources/Themes/tablepro.one-dark.json), + read from themes/OneDark-Pro.json at tag 3.20.3 (ce79c5f71166). The selection, gutter, current line + and change colors come from Atom's One Dark Syntax instead. No code or file from the project ships. +- id: one-dark-syntax + name: One Dark Syntax + version: vendored at 9c96f4454, 2018-09-05 + spdx: MIT + copyrights: + - Copyright (c) 2016 GitHub Inc. + homepageURL: https://github.com/atom/one-dark-syntax + licenseTextURL: https://github.com/atom/one-dark-syntax/blob/9c96f4454362267ac45322063e193ccf9d2debb1/LICENSE.md + textFile: texts/one-dark-syntax.txt + source: manual + patched: false + platforms: [macos] + notes: Color values only, for the built-in One Dark theme (TablePro/Resources/Themes/tablepro.one-dark.json). + Atom's original One Dark palette (styles/colors.less and styles/syntax-variables.less, package version + 1.8.4; the repository is archived) supplies its selection, gutter, current line and git colors. No code + or file from the project ships. +- id: one-light-syntax + name: One Light Syntax + version: vendored at d84579027, 2018-09-06 + spdx: MIT + copyrights: + - Copyright (c) 2016 GitHub Inc. + homepageURL: https://github.com/atom/one-light-syntax + licenseTextURL: https://github.com/atom/one-light-syntax/blob/d84579027410c576086dfca14d934c4bd74b0438/LICENSE.md + textFile: texts/one-light-syntax.txt + source: manual + patched: false + platforms: [macos] + notes: Color values only, for the built-in One Light theme (TablePro/Resources/Themes/tablepro.one-light.json). + Atom's original One Light palette (styles/colors.less and styles/syntax-variables.less, package version + 1.8.4; the repository is archived) supplies its git colors and confirms the editor values. No code or + file from the project ships. - id: openssl name: OpenSSL version: 3.4.3 @@ -413,6 +560,41 @@ notes: 'Transitive (via TextStory / TextFormation). LICENSE is 1518 bytes, 5 bytes longer than the other Chime files, because the holder is "Chime Systems Inc." rather than "Chime". Confirmed shipping: 544 `$s9Rearrange` symbols.' +- id: solarized + name: Solarized + version: vendored at 62f656a02, 2018-04-02 + spdx: MIT + copyrights: + - Copyright (c) 2011 Ethan Schoonover + homepageURL: https://github.com/altercation/solarized + licenseTextURL: https://github.com/altercation/solarized/blob/62f656a02f93c5190a8753159e34b385588d5ff3/LICENSE + textFile: texts/solarized.txt + source: manual + patched: false + platforms: [macos] + notes: Color values only. The sixteen-color palette in README.md is the base of the built-in Solarized Light + and Solarized Dark themes (TablePro/Resources/Themes/tablepro.solarized-light.json and + tablepro.solarized-dark.json), including the line number and change colors the VS Code port leaves + out. No code or file from the project ships. +- id: vscode-solarized + name: Solarized for Visual Studio Code + version: vendored at f93fdeb45, 2026-10-01 + spdx: MIT + copyrights: + - Copyright (c) 2015 - present Microsoft Corporation + homepageURL: https://github.com/microsoft/vscode/tree/main/extensions/theme-solarized-dark + licenseTextURL: https://github.com/microsoft/vscode/blob/f93fdeb454a0f0fafe5f310408cec48dd3b6f58a/LICENSE.txt + textFile: texts/vscode-solarized.txt + source: manual + patched: false + platforms: [macos] + notes: 'Color values only, for the built-in Solarized Light and Solarized Dark themes, read from + extensions/theme-solarized-light/themes/solarized-light-color-theme.json (last changed 0132168df99a) + and extensions/theme-solarized-dark/themes/solarized-dark-color-theme.json (f93fdeb454a0) in + microsoft/vscode: the selection, sidebar, list selection and border colors. The cgmanifest.json of + both extensions registers Colorsublime-Themes 0.1.0 (MIT, "Copyright (c) 2015 Colorsublime.com", + reproduced in VS Code''s ThirdPartyNotices.txt) as their upstream. No code or file from the project + ships.' - id: sparkle name: Sparkle version: 2.10.0 @@ -722,6 +904,22 @@ YEAR differs from the other two Chime packages (2020 vs 2021 vs 2022) and the HOLDER STRING differs from Rearrange ("Chime" vs "Chime Systems Inc."), so the four BSD notices must be reproduced separately, not collapsed into one.' +- id: tokyo-night-vscode-theme + name: Tokyo Night + version: vendored at 7c0f11eae, 2025-02-05 + spdx: MIT + copyrights: + - Copyright (c) 2018-present Enkia + homepageURL: https://github.com/enkia/tokyo-night-vscode-theme + licenseTextURL: https://github.com/enkia/tokyo-night-vscode-theme/blob/7c0f11eaef322f293621ca7befe462214b7ea468/LICENSE.txt + textFile: texts/tokyo-night-vscode-theme.txt + source: manual + patched: false + platforms: [macos] + notes: Color values only, for the built-in Tokyo Night and Tokyo Night Light themes + (TablePro/Resources/Themes/tablepro.tokyo-night.json and tablepro.tokyo-night-light.json), read from + themes/tokyo-night-color-theme.json and themes/tokyo-night-light-color-theme.json. No code or file from + the project ships. - id: tree-sitter name: tree-sitter version: 0.25.10 diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/catppuccin-vscode.txt b/TablePro/Resources/ThirdPartyLicenses/texts/catppuccin-vscode.txt new file mode 100644 index 0000000000..006383b861 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/catppuccin-vscode.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Catppuccin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/dracula-visual-studio-code.txt b/TablePro/Resources/ThirdPartyLicenses/texts/dracula-visual-studio-code.txt new file mode 100644 index 0000000000..dcaf6d7f61 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/dracula-visual-studio-code.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Dracula Theme + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/github-vscode-theme.txt b/TablePro/Resources/ThirdPartyLicenses/texts/github-vscode-theme.txt new file mode 100644 index 0000000000..fcc8ec1341 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/github-vscode-theme.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Primer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/nord-visual-studio-code.txt b/TablePro/Resources/ThirdPartyLicenses/texts/nord-visual-studio-code.txt new file mode 100644 index 0000000000..996f8fb612 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/nord-visual-studio-code.txt @@ -0,0 +1,21 @@ +MIT License (MIT) + +Copyright (c) 2016-present Sven Greb (https://www.svengreb.de) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/one-dark-pro.txt b/TablePro/Resources/ThirdPartyLicenses/texts/one-dark-pro.txt new file mode 100644 index 0000000000..92225c7acd --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/one-dark-pro.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013-2022 Binaryify + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/one-dark-syntax.txt b/TablePro/Resources/ThirdPartyLicenses/texts/one-dark-syntax.txt new file mode 100644 index 0000000000..4e47b02a28 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/one-dark-syntax.txt @@ -0,0 +1,20 @@ +Copyright (c) 2016 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/one-light-syntax.txt b/TablePro/Resources/ThirdPartyLicenses/texts/one-light-syntax.txt new file mode 100644 index 0000000000..4e47b02a28 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/one-light-syntax.txt @@ -0,0 +1,20 @@ +Copyright (c) 2016 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/solarized.txt b/TablePro/Resources/ThirdPartyLicenses/texts/solarized.txt new file mode 100644 index 0000000000..ee08d7e44f --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/solarized.txt @@ -0,0 +1,20 @@ +Copyright (c) 2011 Ethan Schoonover + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/tokyo-night-vscode-theme.txt b/TablePro/Resources/ThirdPartyLicenses/texts/tokyo-night-vscode-theme.txt new file mode 100644 index 0000000000..c89cbda467 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/tokyo-night-vscode-theme.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2018-present Enkia + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/vscode-solarized.txt b/TablePro/Resources/ThirdPartyLicenses/texts/vscode-solarized.txt new file mode 100644 index 0000000000..0ac28ee234 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/vscode-solarized.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2015 - present Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/vscode-theme-gruvbox.txt b/TablePro/Resources/ThirdPartyLicenses/texts/vscode-theme-gruvbox.txt new file mode 100644 index 0000000000..58f3e8acaf --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/vscode-theme-gruvbox.txt @@ -0,0 +1,19 @@ +Copyright © 2017 JD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/vscode-theme-onelight.txt b/TablePro/Resources/ThirdPartyLicenses/texts/vscode-theme-onelight.txt new file mode 100644 index 0000000000..53c7722652 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/vscode-theme-onelight.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Mahmoud Ali + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/TablePro/Theme/AppAppearanceMode+Theme.swift b/TablePro/Theme/AppAppearanceMode+Theme.swift new file mode 100644 index 0000000000..c689fbb023 --- /dev/null +++ b/TablePro/Theme/AppAppearanceMode+Theme.swift @@ -0,0 +1,12 @@ +import Foundation +import TableProTheme + +internal extension AppAppearanceMode { + func effectiveAppearance(systemIsDark: Bool) -> ThemeAppearance { + switch self { + case .light: return .light + case .dark: return .dark + case .auto: return systemIsDark ? .dark : .light + } + } +} diff --git a/TablePro/Theme/BuiltInThemes.swift b/TablePro/Theme/BuiltInThemes.swift index 39e006fc4c..3dae809690 100644 --- a/TablePro/Theme/BuiltInThemes.swift +++ b/TablePro/Theme/BuiltInThemes.swift @@ -1,73 +1,29 @@ import AppKit +import TableProTheme -/// Default Light and Default Dark override nothing but syntax, so every other slot draws in the -/// system color. The syntax values are Xcode's Default (Light) and Default (Dark): SQL `NULL` takes -/// the keyword color the way Swift's `nil` does, and built-in functions and types take Xcode's -/// colors for system-provided functions and types. They are Swift, not files, because every other -/// theme falls back to them and they cannot themselves fail to load. +/// The app's names for the package defaults, and the AppKit side of their syntax palette. internal enum BuiltInThemes { - internal static let defaultLightId = "tablepro.default-light" - internal static let defaultDarkId = "tablepro.default-dark" + internal static let defaultLightId = ThemeDefinition.defaultId(for: .light) + internal static let defaultDarkId = ThemeDefinition.defaultId(for: .dark) - internal static let light = ThemeDefinition( - id: defaultLightId, - name: String(localized: "Default Light"), - author: "TablePro", - appearance: .light, - overrides: syntax( - keyword: "#9B2393", - string: "#C41A16", - number: "#1C00CF", - comment: "#5D6C79", - function: "#6C36A9", - type: "#3900A0" - ) - ) - - internal static let dark = ThemeDefinition( - id: defaultDarkId, - name: String(localized: "Default Dark"), - author: "TablePro", - appearance: .dark, - overrides: syntax( - keyword: "#FC5FA3", - string: "#FC6A5D", - number: "#D0BF69", - comment: "#6C7986", - function: "#A167E6", - type: "#D0A8FF" - ) - ) - - internal static let all: [ThemeDefinition] = [light, dark] + internal static var light: ThemeDefinition { .defaultLight } + internal static var dark: ThemeDefinition { .defaultDark } + internal static var all: [ThemeDefinition] { [.defaultLight, .defaultDark] } internal static func isDefault(_ id: String) -> Bool { - id == defaultLightId || id == defaultDarkId + ThemeDefinition.isDefault(id) } - /// Xcode's High Contrast (Light) and High Contrast (Dark) syntax values, drawn when the view's - /// appearance is an increased-contrast one. - private static let highContrastLight = syntax( - keyword: "#88007E", - string: "#9B0509", - number: "#1C02CF", - comment: "#1A5301", - function: "#5C2699", - type: "#330090" - ) - - private static let highContrastDark = syntax( - keyword: "#FC6BAA", - string: "#FC7467", - number: "#D0BC56", - comment: "#7CB554", - function: "#C08AFF", - type: "#DEC1FF" - ) + internal static func `default`(for appearance: ThemeAppearance) -> ThemeDefinition { + .default(for: appearance) + } - private static let adaptiveLight = adaptive(standard: light.overrides, highContrast: highContrastLight) - private static let adaptiveDark = adaptive(standard: dark.overrides, highContrast: highContrastDark) + internal static func defaultId(for appearance: ThemeAppearance) -> String { + ThemeDefinition.defaultId(for: appearance) + } + /// Switches to Xcode's high-contrast palette when the drawing view's appearance is an + /// increased-contrast one. internal static func adaptiveSyntaxColor(_ slot: ThemeSlot, appearance: ThemeAppearance) -> NSColor? { switch appearance { case .light: return adaptiveLight[slot] @@ -75,48 +31,20 @@ internal enum BuiltInThemes { } } - private static func adaptive( - standard: [ThemeSlot: ThemeColor], - highContrast: [ThemeSlot: ThemeColor] - ) -> [ThemeSlot: NSColor] { + private static let adaptiveLight = adaptive(.light) + private static let adaptiveDark = adaptive(.dark) + + private static func adaptive(_ appearance: ThemeAppearance) -> [ThemeSlot: NSColor] { + let standard = DefaultSyntaxPalette.colors(for: appearance, highContrast: false) + let highContrast = DefaultSyntaxPalette.colors(for: appearance, highContrast: true) var colors: [ThemeSlot: NSColor] = [:] for (slot, color) in standard { - let contrast = highContrast[slot] ?? color - colors[slot] = NSColor(name: NSColor.Name("tablepro.syntax.\(slot.rawValue).\(color.hex)")) { appearance in - appearance.isHighContrast ? contrast.nsColor : color.nsColor + let contrast = (highContrast[slot] ?? color).nsColor + let regular = color.nsColor + colors[slot] = NSColor(name: NSColor.Name("tablepro.syntax.\(slot.rawValue).\(color.hex)")) { drawing in + drawing.isHighContrast ? contrast : regular } } return colors } - - internal static func `default`(for appearance: ThemeAppearance) -> ThemeDefinition { - switch appearance { - case .light: return light - case .dark: return dark - } - } - - internal static func defaultId(for appearance: ThemeAppearance) -> String { - `default`(for: appearance).id - } - - private static func syntax( - keyword: String, - string: String, - number: String, - comment: String, - function: String, - type: String - ) -> [ThemeSlot: ThemeColor] { - let values: [ThemeSlot: String] = [ - .syntaxKeyword: keyword, - .syntaxNull: keyword, - .syntaxString: string, - .syntaxNumber: number, - .syntaxComment: comment, - .syntaxFunction: function, - .syntaxType: type, - ] - return values.compactMapValues { try? ThemeColor(validating: $0) } - } } diff --git a/TablePro/Theme/DetailPaneBackdrop.swift b/TablePro/Theme/DetailPaneBackdrop.swift new file mode 100644 index 0000000000..b371f2d6cc --- /dev/null +++ b/TablePro/Theme/DetailPaneBackdrop.swift @@ -0,0 +1,43 @@ +import SwiftUI +import TableProTheme + +/// 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(contrast: ColorSchemeContrast) -> Bool { + ThemeBackdropState.isThemed(highContrast: contrast == .increased) + } + + 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(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(contrast: contrast)) + } +} diff --git a/TablePro/Theme/EditorThemeFactory.swift b/TablePro/Theme/EditorThemeFactory.swift new file mode 100644 index 0000000000..c1f8d61c57 --- /dev/null +++ b/TablePro/Theme/EditorThemeFactory.swift @@ -0,0 +1,48 @@ +import AppKit +import TableProEditorKit +import TableProTheme + +/// A slot the theme leaves out arrives as the system color object itself, which the editor resolves +/// when it draws. An Accent, Highlight or contrast change therefore builds an equal `EditorTheme`, +/// and the editor keeps its highlighting rather than restyling the whole document. +internal enum EditorThemeFactory { + internal static func make( + resolved: ResolvedTheme, + highlightsCurrentLine: Bool, + highlightsCurrentStatement: Bool + ) -> EditorTheme { + let text = EditorTheme.Attribute(color: resolved[.editorText]) + let comment = EditorTheme.Attribute(color: resolved[.syntaxComment]) + let keyword = EditorTheme.Attribute(color: resolved[.syntaxKeyword], bold: true) + let string = EditorTheme.Attribute(color: resolved[.syntaxString]) + let number = EditorTheme.Attribute(color: resolved[.syntaxNumber]) + let constant = EditorTheme.Attribute(color: resolved[.syntaxNull]) + let type = EditorTheme.Attribute(color: resolved[.syntaxType]) + let operatorAttribute = EditorTheme.Attribute(color: resolved[.syntaxOperator]) + let function = EditorTheme.Attribute(color: resolved[.syntaxFunction]) + + return EditorTheme( + text: text, + insertionPoint: resolved.isOverridden(.editorCursor) ? resolved[.editorCursor] : nil, + invisibles: EditorTheme.Attribute(color: resolved[.editorInvisibles]), + background: resolved[.editorBackground], + lineHighlight: highlightsCurrentLine ? resolved[.editorCurrentLine] : .clear, + statementHighlight: highlightsCurrentStatement ? resolved[.editorCurrentStatement] : .clear, + selection: resolved[.editorSelection], + lineNumber: resolved[.editorLineNumber], + keywords: keyword, + commands: keyword, + types: type, + attributes: constant, + // Aliases, parameters and properties are names the user wrote, so they read as text. + variables: text, + values: constant, + numbers: number, + strings: string, + characters: string, + comments: comment, + operators: operatorAttribute, + functions: function + ) + } +} diff --git a/TablePro/Theme/PaneBarBackground.swift b/TablePro/Theme/PaneBarBackground.swift new file mode 100644 index 0000000000..3873506830 --- /dev/null +++ b/TablePro/Theme/PaneBarBackground.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// A bar's material is drawn for the system window color, so over a themed backdrop it reads as a +/// system band. The bar then draws nothing and shows the pane's theme through it. +private struct PaneBarBackground: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + + let native: Native + let edges: Edge.Set + + func body(content: Content) -> some View { + let isThemed = ThemeBackdropState.isThemed(highContrast: contrast == .increased) + return content.background( + isThemed ? AnyShapeStyle(Color.clear) : AnyShapeStyle(native), + ignoresSafeAreaEdges: edges + ) + } +} + +internal extension View { + /// No edges by default: a bar at the top of a pane would otherwise reach under the toolbar and + /// pull its glass toward the bar's material. + func paneBarBackground(_ native: some ShapeStyle = .bar, ignoresSafeAreaEdges edges: Edge.Set = []) -> some View { + modifier(PaneBarBackground(native: native, edges: edges)) + } +} diff --git a/TablePro/Theme/ThemeBackdropState.swift b/TablePro/Theme/ThemeBackdropState.swift new file mode 100644 index 0000000000..61cf007d6f --- /dev/null +++ b/TablePro/Theme/ThemeBackdropState.swift @@ -0,0 +1,16 @@ +import AppKit +import TableProTheme + +/// Whether the window backdrop draws the theme's color. Chrome over the backdrop asks this rather +/// than reading a slot: a bar keeps its material until the theme colors what is behind it. +@MainActor +internal enum ThemeBackdropState { + internal static func isThemed(in appearance: NSAppearance, scope: ThemeScope = .live) -> Bool { + isThemed(highContrast: appearance.isHighContrast, scope: scope) + } + + /// For SwiftUI, which knows the contrast from its environment rather than from an appearance. + internal static func isThemed(highContrast: Bool, scope: ThemeScope = .live) -> Bool { + scope.activeTheme.isOverridden(.windowBackdrop, highContrast: highContrast) + } +} diff --git a/TablePro/Theme/ThemeCatalog.swift b/TablePro/Theme/ThemeCatalog.swift index 6dac78568f..e53c4be15e 100644 --- a/TablePro/Theme/ThemeCatalog.swift +++ b/TablePro/Theme/ThemeCatalog.swift @@ -1,6 +1,9 @@ +import AppKit import Combine import Foundation import os +import TableProSyncTransport +import TableProTheme internal struct ThemeCatalogContents: Equatable, Sendable { internal var themes: [ThemeDefinition] @@ -10,8 +13,8 @@ internal struct ThemeCatalogContents: Equatable, Sendable { internal static let empty = ThemeCatalogContents(themes: BuiltInThemes.all, rejected: [], userFiles: [:]) } -/// Where a user theme was read from and the bytes read, so a save or delete can tell that the -/// file changed on disk since and leave the newer version alone. +/// Where a user theme was read from and the bytes read, so a save or a move to the Trash can tell +/// that the file changed on disk since and leave the newer version alone. internal struct ThemeFile: Equatable, Sendable { internal let url: URL internal let contents: Data @@ -25,15 +28,45 @@ internal struct RejectedThemeRecord: Equatable, Sendable, Identifiable { internal var id: String { path } } -internal enum ThemeOrigin: Sendable { - case bundle - case user +// `themeId` is nil for a file that never loaded as a theme, which never synced either. +internal struct ThemeTrashReceipt: Equatable, Sendable { + internal let themeId: String? + internal let originalURL: URL + internal let trashedURL: URL +} + +internal struct ThemeFileTrash: Sendable { + internal let moveToTrash: @Sendable (URL) throws -> URL + + internal static let system = ThemeFileTrash { url in + var resulting: NSURL? + try FileManager.default.trashItem(at: url, resultingItemURL: &resulting) + guard let trashed = resulting as URL? else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: url]) } + return trashed + } + + // One folder per item, so two trashed files with one name never collide. + internal static func folder(_ directory: URL) -> ThemeFileTrash { + ThemeFileTrash { url in + let holder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: holder, withIntermediateDirectories: true) + let trashed = holder.appendingPathComponent(url.lastPathComponent, isDirectory: false) + try FileManager.default.moveItem(at: url, to: trashed) + return trashed + } + } + + // A sandboxed run trashes inside its sandbox, never into the user's Trash. + internal static func resolved(in environment: AppStorageEnvironment) -> ThemeFileTrash { + environment.trashDirectory.map(folder) ?? .system + } } /// Owns the list. A user theme is identified by its JSON `id` but lives in whatever file it was -/// loaded from, so a save, rename or delete acts on that file rather than on `.json`; the two -/// differ for any file the user named themselves. Every mutation updates the in-memory list -/// synchronously, so the activation that follows never reads a stale copy. +/// loaded from, so a save, rename or move to the Trash acts on that file rather than on `.json`; +/// the two differ for any file the user named themselves. Every mutation updates the in-memory list +/// synchronously, and the list reloads whenever TablePro becomes active, so a theme fixed, dropped +/// or removed in Finder is picked up without a view asking. @MainActor internal final class ThemeCatalog: ObservableObject { internal static let shared = ThemeCatalog() @@ -41,34 +74,49 @@ internal final class ThemeCatalog: ObservableObject { @Published internal private(set) var themes: [ThemeDefinition] = BuiltInThemes.all @Published internal private(set) var rejected: [RejectedThemeRecord] = [] + // What the files hold. `themes` is this with colliding names told apart for display. + private var loaded: [ThemeDefinition] = BuiltInThemes.all private var userFiles: [String: ThemeFile] = [:] + /// Themes whose file went away since it was read. A save of one is refused rather than writing a + /// new file, which would bring back a theme deleted in Finder or on another Mac. + private var vanished: [String: URL] = [:] private let userDirectory: URL private let bundleDirectory: URL? + private let fileTrash: ThemeFileTrash + private let syncTracker: SyncChangeTracker + private var activationCancellable: AnyCancellable? nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeCatalog") - nonisolated private static let bundledOrder = [ - BuiltInThemes.defaultLightId, - BuiltInThemes.defaultDarkId, - "tablepro.dracula", - "tablepro.nord", - ] - /// Loaded before the first activation, so a user theme in a slot is in the list the launch /// activation resolves against. internal init( userDirectory: URL = ThemeCatalog.defaultUserDirectory, - bundleDirectory: URL? = Bundle.main.resourceURL + bundleDirectory: URL? = Bundle.main.resourceURL, + notificationCenter: NotificationCenter = .default, + fileTrash: ThemeFileTrash = .resolved(in: .shared), + syncTracker: SyncChangeTracker = .shared ) { self.userDirectory = userDirectory self.bundleDirectory = bundleDirectory + self.fileTrash = fileTrash + self.syncTracker = syncTracker reloadSynchronously() + activationCancellable = notificationCenter + .publisher(for: NSApplication.didBecomeActiveNotification) + .sink { [weak self] _ in self?.reloadSynchronously() } } internal func theme(id: String) -> ThemeDefinition? { themes.first { $0.id == id } } + // As its file holds it: `theme(id:)` can carry a name changed to tell two themes apart, which + // an editor must not write back. + internal func storedTheme(id: String) -> ThemeDefinition? { + loaded.first { $0.id == id } + } + internal func fileURL(for id: String) -> URL? { userFiles[id]?.url } @@ -77,7 +125,14 @@ internal final class ThemeCatalog: ObservableObject { adopt(Self.loadContents(userDirectory: userDirectory, bundleDirectory: bundleDirectory)) } + /// Writes the theme to the file it came from, or a new theme to `.json`. The list then holds + /// what the file holds, which the format normalizes. internal func save(_ theme: ThemeDefinition) throws { + try persist(theme) + syncTracker.markDirty(.settings, id: Self.syncCategory(for: theme.id)) + } + + private func persist(_ theme: ThemeDefinition) throws { guard theme.isEditable else { throw ThemeLoadError.reservedIdentifier(theme.id) } guard ThemeIdentifier.isValid(theme.id) else { throw ThemeLoadError.invalidIdentifier(theme.id) } @@ -86,29 +141,59 @@ internal final class ThemeCatalog: ObservableObject { try ensureUnchanged(file) url = file.url } else { + try ensureNotVanished(theme.id) url = availableURL(for: theme.id) } - let data = try ThemeEncoder.data(for: theme) + let data = try ThemeFileFormat.encode(theme) try Self.write(data, to: url) userFiles[theme.id] = ThemeFile(url: url, contents: data) - upsert(theme) + upsert(try ThemeFileFormat.decode(data)) } - internal func delete(id: String) throws { + @discardableResult + internal func trash(id: String) throws -> ThemeTrashReceipt { guard !id.hasPrefix(ThemeDefinition.builtInPrefix) else { throw ThemeLoadError.reservedIdentifier(id) } - if let file = userFiles[id], FileManager.default.fileExists(atPath: file.url.path) { - try ensureUnchanged(file) - try FileManager.default.removeItem(at: file.url) + guard let file = userFiles[id] else { + try ensureNotVanished(id) + throw CocoaError(.fileNoSuchFile) } + try ensureUnchanged(file) + let trashed = try fileTrash.moveToTrash(file.url) userFiles[id] = nil - themes.removeAll { $0.id == id } + vanished[id] = file.url + publish(loaded.filter { $0.id != id }) + syncTracker.markDeleted(.settings, id: Self.syncCategory(for: id)) + return ThemeTrashReceipt(themeId: id, originalURL: file.url, trashedURL: trashed) } + @discardableResult + internal func trashRejected(path: String) throws -> ThemeTrashReceipt { + guard let record = rejected.first(where: { $0.path == path }) else { throw CocoaError(.fileNoSuchFile) } + let url = URL(fileURLWithPath: record.path) + let trashed = try fileTrash.moveToTrash(url) + reloadSynchronously() + return ThemeTrashReceipt(themeId: nil, originalURL: url, trashedURL: trashed) + } + + internal func restore(_ receipt: ThemeTrashReceipt) throws { + try FileManager.default.moveItem(at: receipt.trashedURL, to: receipt.originalURL) + reloadSynchronously() + guard let id = receipt.themeId, userFiles[id] != nil else { return } + syncTracker.markDirty(.settings, id: Self.syncCategory(for: id)) + } + + /// Two themes with one name cannot be told apart in a popup, so a taken name is refused rather + /// than numbered behind the user's back. internal func rename(id: String, to name: String) throws { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, var theme = theme(id: id) else { return } + guard var theme = storedTheme(id: id) else { + try ensureNotVanished(id) + return + } + guard trimmed != theme.name else { return } + guard !isNameTaken(trimmed, byOtherThan: id) else { throw ThemeLoadError.nameTaken(trimmed) } theme.name = trimmed try save(theme) } @@ -116,42 +201,66 @@ internal final class ThemeCatalog: ObservableObject { internal func duplicate(_ source: ThemeDefinition) throws -> ThemeDefinition { var copy = source copy.id = ThemeIdentifier.generated() - copy.name = uniqueName( - String(format: String(localized: "%@ Copy"), source.name) - ) + copy.name = firstFreeName { reserved in Self.copyName(of: source.name, reserving: reserved) } copy.author = "" try save(copy) - return copy + return theme(id: copy.id) ?? copy } internal func importTheme(from url: URL) throws -> ThemeDefinition { - let document = try ThemeDocument(data: try Data(contentsOf: url)) - var theme = document.definition() - - if !theme.isEditable || containsTheme(id: theme.id) { - theme.id = ThemeIdentifier.generated() - } - if themes.contains(where: { $0.name == theme.name }) { - theme.name = uniqueName(theme.name) - } - - try save(theme) - return theme + var imported = try ThemeFileImporter.importTheme(at: url) + imported.name = uniqueName(imported.name) + try save(imported) + return theme(id: imported.id) ?? imported } internal func exportTheme(_ theme: ThemeDefinition, to url: URL) throws { - try ThemeEncoder.data(for: theme).write(to: url, options: .atomic) + try ThemeFileFormat.encode(theme).write(to: url, options: .atomic) } - /// "Name", then "Name 2", "Name 3", the way Finder numbers copies. + // "Name", then "Name 2", "Name 3", the way Finder numbers copies. internal func uniqueName(_ base: String) -> String { - let taken = Set(themes.map(\.name)) - guard taken.contains(base) else { return base } + let trimmed = base.trimmingCharacters(in: .whitespacesAndNewlines) + return firstFreeName { reserved in Self.shortened(trimmed, toFit: reserved) } + } + + private func firstFreeName(_ name: (_ reserved: Int) -> String) -> String { + firstFreeName(isTaken: { isNameTaken($0, byOtherThan: nil) }, name) + } + + private func firstFreeName(isTaken: (String) -> Bool, _ name: (_ reserved: Int) -> String) -> String { + let first = name(0) + guard isTaken(first) else { return first } var index = 2 - while taken.contains("\(base) \(index)") { + while true { + let suffix = " \(index)" + let candidate = name(suffix.count) + suffix + guard isTaken(candidate) else { return candidate } index += 1 } - return "\(base) \(index)" + } + + // The format caps a name, so a number or "Copy" shortens the name itself instead of making one + // the save then refuses. + nonisolated private static func shortened(_ name: String, toFit reserved: Int) -> String { + let room = ThemeFileFormat.maximumTextLength - reserved + guard name.count > room else { return name } + return String(name.prefix(max(room, 0))).trimmingCharacters(in: .whitespacesAndNewlines) + } + + nonisolated private static func copyName(of name: String, reserving reserved: Int) -> String { + let format = String(localized: "%@ Copy") + let overhead = String(format: format, "").count + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + return String(format: format, shortened(trimmed, toFit: overhead + reserved)) + } + + private func isNameTaken(_ name: String, byOtherThan id: String?) -> Bool { + themes.contains { theme in + theme.id != id + && theme.name.trimmingCharacters(in: .whitespacesAndNewlines) + .compare(name, options: .caseInsensitive) == .orderedSame + } } /// `.json`, unless that name already holds another theme or a file that failed to load, @@ -170,31 +279,76 @@ internal final class ThemeCatalog: ObservableObject { return candidate } - /// A file edited or replaced outside TablePro since it was read is not overwritten or removed: - /// the catalog reloads it and the caller reports the conflict. + /// A file edited, replaced or removed outside TablePro since it was read is not overwritten, + /// moved or recreated: the catalog reloads and the caller reports the conflict. private func ensureUnchanged(_ file: ThemeFile) throws { - guard let current = try? Data(contentsOf: file.url), current != file.contents else { return } + let current: Data + do { + current = try Data(contentsOf: file.url) + } catch { + reloadSynchronously() + throw ThemeLoadError.deletedOnDisk(file.url.lastPathComponent) + } + guard current != file.contents else { return } reloadSynchronously() throw ThemeLoadError.changedOnDisk(file.url.lastPathComponent) } + /// A file that is still there no longer loads as this theme, so it changed rather than went away. + private func ensureNotVanished(_ id: String) throws { + guard let url = vanished[id] else { return } + guard FileManager.default.fileExists(atPath: url.path) else { + throw ThemeLoadError.deletedOnDisk(url.lastPathComponent) + } + throw ThemeLoadError.changedOnDisk(url.lastPathComponent) + } + private func adopt(_ contents: ThemeCatalogContents) { - themes = contents.themes - rejected = contents.rejected + for (id, file) in userFiles where contents.userFiles[id] == nil { + vanished[id] = file.url + } + for id in contents.userFiles.keys { + vanished[id] = nil + } userFiles = contents.userFiles + publish(contents.themes) + if rejected != contents.rejected { + rejected = contents.rejected + } } - private func containsTheme(id: String) -> Bool { - themes.contains { $0.id == id } + private func upsert(_ theme: ThemeDefinition) { + publish(loaded.filter { $0.id != theme.id } + [theme]) } - private func upsert(_ theme: ThemeDefinition) { - if let index = themes.firstIndex(where: { $0.id == theme.id }) { - themes[index] = theme - return + // `loaded` first: `$themes` subscribers read `storedTheme(id:)` while the list is published. + private func publish(_ list: [ThemeDefinition]) { + loaded = ThemeOrdering.sorted(list) + let shown = Self.distinguishingNames(in: loaded) + if themes != shown { + themes = shown + } + } + + // Two themes with one name cannot be told apart in a popup, and sync makes that routine: two + // Macs each make an "Ocean". The later one in display order shows "Ocean 2". Only the list + // changes, never the file, so a display fix is never written, synced and fixed again elsewhere. + nonisolated private static func distinguishingNames(in themes: [ThemeDefinition]) -> [ThemeDefinition] { + var taken: Set = [] + return themes.map { theme in + let base = theme.name.trimmingCharacters(in: .whitespacesAndNewlines) + var name = base + var index = 2 + while taken.contains(name.folding(options: .caseInsensitive, locale: nil)) { + name = "\(base) \(index)" + index += 1 + } + taken.insert(name.folding(options: .caseInsensitive, locale: nil)) + guard name != theme.name else { return theme } + var shown = theme + shown.name = name + return shown } - themes.append(theme) - themes.sort(by: Self.displayOrder) } // MARK: - Locations @@ -207,13 +361,6 @@ internal final class ThemeCatalog: ObservableObject { // MARK: - Disk - nonisolated private static func displayOrder(_ lhs: ThemeDefinition, _ rhs: ThemeDefinition) -> Bool { - let left = bundledOrder.firstIndex(of: lhs.id) ?? Int.max - let right = bundledOrder.firstIndex(of: rhs.id) ?? Int.max - guard left == right else { return left < right } - return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending - } - nonisolated private static func loadContents(userDirectory: URL, bundleDirectory: URL?) -> ThemeCatalogContents { var themes = BuiltInThemes.all var rejected: [RejectedThemeRecord] = [] @@ -224,9 +371,9 @@ internal final class ThemeCatalog: ObservableObject { if let bundle = bundleDirectory { for file in jsonFiles(in: bundle) where file.lastPathComponent.hasPrefix(ThemeDefinition.builtInPrefix) { do { - let document = try ThemeDocument(data: try Data(contentsOf: file)) - guard !themes.contains(where: { $0.id == document.id }) else { continue } - themes.append(document.definition()) + let theme = try ThemeFileFormat.decode(try Data(contentsOf: file)) + guard !themes.contains(where: { $0.id == theme.id }) else { continue } + themes.append(theme) } catch { logger.error("Bundled theme \(file.lastPathComponent) rejected: \(error.publicLogShape, privacy: .public)") } @@ -239,15 +386,15 @@ internal final class ThemeCatalog: ObservableObject { for file in files { do { let data = try Data(contentsOf: file) - let document = try ThemeDocument(data: data) - guard !document.id.hasPrefix(ThemeDefinition.builtInPrefix) else { - throw ThemeLoadError.reservedIdentifier(document.id) + let theme = try ThemeFileFormat.decode(data, text: .repaired) + guard !theme.id.hasPrefix(ThemeDefinition.builtInPrefix) else { + throw ThemeLoadError.reservedIdentifier(theme.id) } - if let existing = userFiles[document.id] { - throw ThemeLoadError.duplicateIdentifier(document.id, existingFile: existing.url.lastPathComponent) + if let existing = userFiles[theme.id] { + throw ThemeLoadError.duplicateIdentifier(theme.id, existingFile: existing.url.lastPathComponent) } - userFiles[document.id] = ThemeFile(url: file, contents: data) - themes.append(document.definition()) + userFiles[theme.id] = ThemeFile(url: file, contents: data) + themes.append(theme) } catch { rejected.append( RejectedThemeRecord( @@ -259,8 +406,7 @@ internal final class ThemeCatalog: ObservableObject { } } - themes.sort(by: displayOrder) - return ThemeCatalogContents(themes: themes, rejected: rejected, userFiles: userFiles) + return ThemeCatalogContents(themes: ThemeOrdering.sorted(themes), rejected: rejected, userFiles: userFiles) } nonisolated private static func jsonFiles(in directory: URL) -> [URL] { @@ -290,3 +436,231 @@ internal final class ThemeCatalog: ObservableObject { } } } + +// MARK: - Sync + +// How a synced theme meets a local file that holds its id. +internal enum ThemeRemoteMerge: Sendable { + // The local file is a synced copy, so the record replaces it. + case replace + // The local file may never have reached the server, so a version that differs stays as its own theme. + case keepBoth +} + +// Each user theme syncs as one settings record, `theme.`, whose payload is the canonical file. +internal extension ThemeCatalog { + nonisolated static let syncCategoryPrefix = "theme." + + // Themes are about 1.2 KB, and a slot path nests three containers deep, so anything past these + // limits is not a theme and only costs parse time. + nonisolated static let maximumSyncPayloadSize = 64 * 1_024 + nonisolated static let maximumSyncPayloadDepth = 4 + + nonisolated static func syncCategory(for id: String) -> String { + syncCategoryPrefix + id + } + + nonisolated static func themeId(forSyncCategory category: String) -> String? { + guard category.hasPrefix(syncCategoryPrefix) else { return nil } + return String(category.dropFirst(syncCategoryPrefix.count)) + } + + var syncCategories: [String] { + userFiles.keys.sorted().map(Self.syncCategory(for:)) + } + + // The file's own name, never the one the list shows to tell two themes apart. + func syncPayload(forCategory category: String) -> Data? { + guard let id = Self.themeId(forSyncCategory: category), + userFiles[id] != nil, + let theme = storedTheme(id: id) + else { return nil } + do { + return try ThemeFileFormat.encode(theme) + } catch { + Self.logger.error("Could not encode a theme for sync: \(error.publicLogShape, privacy: .public)") + return nil + } + } + + // A synced theme is untrusted input from another device, so it is validated before anything + // touches disk. A record that fails validation is skipped, since the same bytes would fail + // again; only a disk failure is `.failed`, so the pull is not acknowledged and replays. + func applyRemote(category: String, data: Data, merge: ThemeRemoteMerge = .replace) -> RemoteApplyOutcome { + let theme: ThemeDefinition + do { + theme = try Self.validatedRemoteTheme(category: category, data: data) + } catch { + Self.logger.error( + "Skipped synced theme \(category, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)" + ) + return .skipped + } + + do { + return try writeRemote(theme, merge: merge) + } catch { + Self.logger.error( + "Could not write synced theme \(category, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)" + ) + return .failed + } + } + + // The other Mac moved its copy to the Trash, so this one does too rather than deleting for good. + @discardableResult + func removeWithoutSync(ids: Set) -> Bool { + var isPersisted = true + var removed: Set = [] + for id in ids.sorted() { + // A file replaced since the last load may now hold another theme, which nothing deleted. + reloadIfChangedOnDisk(id) + guard let file = userFiles[id] else { continue } + do { + _ = try fileTrash.moveToTrash(file.url) + } catch where !FileManager.default.fileExists(atPath: file.url.path) { + Self.logger.info("A theme deleted on another Mac was already gone here") + } catch { + Self.logger.error("Could not remove a theme deleted on another Mac: \(error.publicLogShape, privacy: .public)") + isPersisted = false + continue + } + userFiles[id] = nil + vanished[id] = file.url + removed.insert(id) + } + if !removed.isEmpty { + publish(loaded.filter { !removed.contains($0.id) }) + } + syncTracker.discardDirty(.settings, ids: removed.sorted().map(Self.syncCategory(for:))) + return isPersisted + } + + private func writeRemote(_ theme: ThemeDefinition, merge: ThemeRemoteMerge) throws -> RemoteApplyOutcome { + reloadIfChangedOnDisk(theme.id) + let data = try ThemeFileFormat.encode(theme) + let url: URL + var keptCopy: ThemeDefinition? + if let file = userFiles[theme.id] { + guard file.contents != data else { return .skipped } + // Compared as themes: 0.81 wrote the same theme with other bytes. + if merge == .keepBoth, let local = storedTheme(id: theme.id), local != theme { + keptCopy = try keepCopy(of: local, besides: theme) + } + url = file.url + } else { + url = availableURL(for: theme.id) + } + do { + try Self.write(data, to: url) + } catch { + if let keptCopy { + discardCopy(keptCopy) + } + throw error + } + vanished[theme.id] = nil + userFiles[theme.id] = ThemeFile(url: url, contents: data) + upsert(try ThemeFileFormat.decode(data)) + // A pull suppresses this mark; the seed that follows it uploads the copy, which the server lacks. + if let keptCopy { + syncTracker.markDirty(.settings, id: Self.syncCategory(for: keptCopy.id)) + } + return .applied + } + + // A new id, so the record and the version only this Mac held both stay. The copy keeps its name + // unless the arriving theme or another one shows it. + private func keepCopy(of local: ThemeDefinition, besides incoming: ThemeDefinition) throws -> ThemeDefinition { + var copy = local + copy.id = ThemeIdentifier.generated() + copy.name = firstFreeName( + isTaken: { candidate in + candidate.compare(incoming.name, options: .caseInsensitive) == .orderedSame + || isNameTaken(candidate, byOtherThan: incoming.id) + }, + { reserved in Self.shortened(local.name, toFit: reserved) } + ) + try persist(copy) + return copy + } + + // The record did not land, so it arrives again; a copy left behind would be made again with it. + private func discardCopy(_ copy: ThemeDefinition) { + guard let file = userFiles[copy.id] else { return } + do { + try FileManager.default.removeItem(at: file.url) + } catch { + Self.logger.error("Could not remove a kept theme copy: \(error.publicLogShape, privacy: .public)") + return + } + userFiles[copy.id] = nil + publish(loaded.filter { $0.id != copy.id }) + } + + // The write never lands on bytes the catalog has not read: a file changed outside TablePro is + // read again first, and one that no longer loads as this theme is never a target. + private func reloadIfChangedOnDisk(_ id: String) { + guard let file = userFiles[id], (try? Data(contentsOf: file.url)) != file.contents else { return } + reloadSynchronously() + } + + nonisolated private static func validatedRemoteTheme(category: String, data: Data) throws -> ThemeDefinition { + guard let id = themeId(forSyncCategory: category), + ThemeIdentifier.isValid(id), + !id.hasPrefix(ThemeDefinition.builtInPrefix) + else { throw ThemeSyncRejection.invalidCategory } + guard data.count <= maximumSyncPayloadSize else { throw ThemeSyncRejection.tooLarge(data.count) } + guard JSONNesting.depth(of: data, stoppingAfter: maximumSyncPayloadDepth) <= maximumSyncPayloadDepth else { + throw ThemeSyncRejection.tooDeep + } + // The decoder refuses a newer schema, an unknown key, a bad color, and a name or author with a + // control character or past the length cap. + let theme = try ThemeFileFormat.decode(data) + guard theme.id == id else { throw ThemeSyncRejection.mismatchedIdentifier } + return theme + } +} + +private enum ThemeSyncRejection: Error { + case invalidCategory + case tooLarge(Int) + case tooDeep + case mismatchedIdentifier +} + +private enum JSONNesting { + // Counts container nesting outside strings, and stops once past `limit` so a hostile payload + // costs no more than that. + static func depth(of data: Data, stoppingAfter limit: Int) -> Int { + var depth = 0 + var deepest = 0 + var isInString = false + var isEscaped = false + for byte in data { + if isInString { + if isEscaped { + isEscaped = false + } else if byte == UInt8(ascii: "\\") { + isEscaped = true + } else if byte == UInt8(ascii: "\"") { + isInString = false + } + continue + } + switch byte { + case UInt8(ascii: "\""): + isInString = true + case UInt8(ascii: "{"), UInt8(ascii: "["): + depth += 1 + deepest = max(deepest, depth) + if deepest > limit { return deepest } + case UInt8(ascii: "}"), UInt8(ascii: "]"): + depth -= 1 + default: + break + } + } + return deepest + } +} diff --git a/TablePro/Theme/ThemeColor.swift b/TablePro/Theme/ThemeColor.swift index 0c50791542..fcaeeb5cc9 100644 --- a/TablePro/Theme/ThemeColor.swift +++ b/TablePro/Theme/ThemeColor.swift @@ -1,93 +1,47 @@ import AppKit import SwiftUI - -/// One color a theme declares, held as canonical `#RRGGBB` or `#RRGGBBAA` in sRGB. It is a fixed -/// value by design: a theme that wants the system color leaves the slot out. -internal struct ThemeColor: Hashable, Sendable { - internal let hex: String - internal let nsColor: NSColor - - internal init(validating raw: String) throws { - guard let canonical = HexColor.canonicalize(raw) else { - throw ThemeLoadError.invalidColor(raw) - } - hex = canonical - nsColor = HexColor.color(canonical) - } - - internal init(_ color: NSColor) { - hex = HexColor.string(from: color) - nsColor = HexColor.color(hex) +import TableProTheme + +internal extension ThemeColor { + var nsColor: NSColor { + NSColor( + srgbRed: CGFloat(red) / 255, + green: CGFloat(green) / 255, + blue: CGFloat(blue) / 255, + alpha: CGFloat(alpha) / 255 + ) } - internal static func == (lhs: ThemeColor, rhs: ThemeColor) -> Bool { - lhs.hex == rhs.hex + /// A color that cannot convert to sRGB, a pattern for one, becomes mid grey rather than failing. + init(_ color: NSColor) { + guard let converted = color.usingColorSpace(.sRGB) else { + self.init(red: 0x80, green: 0x80, blue: 0x80) + return + } + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + converted.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + self.init(red: Self.byte(red), green: Self.byte(green), blue: Self.byte(blue), alpha: Self.byte(alpha)) } - internal func hash(into hasher: inout Hasher) { - hasher.combine(hex) + var swiftUIColor: Color { + Color(nsColor: nsColor) } - internal var swiftUIColor: Color { - Color(nsColor: nsColor) + private static func byte(_ component: CGFloat) -> UInt8 { + UInt8(min(max((component * 255).rounded(), 0), 255)) } } -/// The parser rejects any string it does not consume whole, and only ASCII hex digits: -/// `Character.isHexDigit` is also true for the fullwidth forms, which `Scanner` then refuses. internal enum HexColor { - private static let digits = Set("0123456789ABCDEFabcdef") - - internal static func canonicalize(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("#") else { return nil } - - let body = trimmed.dropFirst() - guard body.count == 6 || body.count == 8 else { return nil } - guard body.allSatisfy({ digits.contains($0) }) else { return nil } - - return "#" + body.uppercased() - } - - internal static func color(_ canonical: String) -> NSColor { - let body = canonical.dropFirst() - var value: UInt64 = 0 - guard Scanner(string: String(body)).scanHexInt64(&value) else { return .labelColor } - - if body.count == 8 { - return NSColor( - srgbRed: CGFloat((value >> 24) & 0xFF) / 255, - green: CGFloat((value >> 16) & 0xFF) / 255, - blue: CGFloat((value >> 8) & 0xFF) / 255, - alpha: CGFloat(value & 0xFF) / 255 - ) - } - - return NSColor( - srgbRed: CGFloat((value >> 16) & 0xFF) / 255, - green: CGFloat((value >> 8) & 0xFF) / 255, - blue: CGFloat(value & 0xFF) / 255, - alpha: 1 - ) - } - internal static func string(from color: NSColor) -> String { - guard let converted = color.usingColorSpace(.sRGB) else { return "#808080" } - - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - converted.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - - let redValue = Int(round(red * 255)) - let greenValue = Int(round(green * 255)) - let blueValue = Int(round(blue * 255)) + ThemeColor(color).hex + } - guard alpha < 1 else { - return String(format: "#%02X%02X%02X", redValue, greenValue, blueValue) - } - return String(format: "#%02X%02X%02X%02X", redValue, greenValue, blueValue, Int(round(alpha * 255))) + internal static func color(_ hex: String) -> NSColor { + ThemeColor(hex: hex)?.nsColor ?? .labelColor } } diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift index 227eaea001..029c2fd122 100644 --- a/TablePro/Theme/ThemeDefinition.swift +++ b/TablePro/Theme/ThemeDefinition.swift @@ -1,190 +1,31 @@ import AppKit +import TableProTheme -internal enum ThemeAppearance: String, Codable, CaseIterable, Sendable { - case light - case dark -} - -/// 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 - var author: String - var appearance: ThemeAppearance - var overrides: [ThemeSlot: ThemeColor] - - internal static let builtInPrefix = "tablepro." - internal static let userPrefix = "user." - - internal var isBuiltIn: Bool { id.hasPrefix(Self.builtInPrefix) } - internal var isEditable: Bool { !isBuiltIn } - - internal func isOverridden(_ slot: ThemeSlot) -> Bool { - overrides[slot] != nil - } - +internal extension ThemeDefinition { /// 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 { + 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) + func color(for slot: ThemeSlot, in appearance: NSAppearance) -> NSColor { + nsColor(of: value(for: slot, highContrast: appearance.isHighContrast)) } - internal func color(for slot: ThemeSlot, in appearance: NSAppearance) -> NSColor { - guard slot.yieldsToIncreasedContrast, appearance.isHighContrast else { return color(for: slot) } - return slot.nativeColor + /// A slot that falls back to the system hands back the system color object itself, which + /// resolves at draw time against the drawing view's appearance, accent and contrast. + func color(for slot: ThemeSlot) -> NSColor { + nsColor(of: value(for: slot, highContrast: false)) } - /// 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. - internal func color(for slot: ThemeSlot) -> NSColor { - if slot.group == .syntax, BuiltInThemes.isDefault(id) || overrides[slot] == nil, - let adaptive = BuiltInThemes.adaptiveSyntaxColor(slot, appearance: appearance) { - return adaptive - } - if let override = overrides[slot] { - return override.nsColor + private func nsColor(of value: ThemeSlotValue) -> NSColor { + switch value { + case let .themed(color): + return color.nsColor + case let .system(slot): + return slot.nativeColor + case let .defaultSyntax(slot): + return BuiltInThemes.adaptiveSyntaxColor(slot, appearance: appearance) ?? slot.nativeColor } - if slot == .syntaxOperator { - return color(for: .editorText) - } - return slot.nativeColor } } - -/// The single registry every slot goes through. A slot exists here because a call site reads it. -/// 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" - case editorSelection = "editor.selection" - case editorCurrentLine = "editor.currentLine" - case editorCurrentStatement = "editor.currentStatement" - case editorLineNumber = "editor.lineNumber" - case editorInvisibles = "editor.invisibles" - - case syntaxKeyword = "editor.syntax.keyword" - case syntaxString = "editor.syntax.string" - case syntaxNumber = "editor.syntax.number" - case syntaxComment = "editor.syntax.comment" - case syntaxNull = "editor.syntax.null" - case syntaxOperator = "editor.syntax.operator" - case syntaxFunction = "editor.syntax.function" - case syntaxType = "editor.syntax.type" - - case gridBackground = "dataGrid.background" - case gridText = "dataGrid.text" - case gridAlternateRow = "dataGrid.alternateRow" - case gridHeaderBackground = "dataGrid.headerBackground" - case gridHeaderText = "dataGrid.headerText" - case gridLine = "dataGrid.gridLine" - case gridSelection = "dataGrid.selection" - case gridSelectionText = "dataGrid.selectionText" - case gridInactiveSelection = "dataGrid.inactiveSelection" - case gridFocusBorder = "dataGrid.focusBorder" - case gridNullValue = "dataGrid.nullValue" - case gridBoolTrue = "dataGrid.boolTrue" - case gridBoolFalse = "dataGrid.boolFalse" - case gridRowNumber = "dataGrid.rowNumber" - case gridModified = "dataGrid.modified" - case gridInserted = "dataGrid.inserted" - case gridDeleted = "dataGrid.deleted" - case gridDeletedText = "dataGrid.deletedText" - - internal var group: ThemeSlotGroup { - switch self { - case .windowBackdrop: - return .window - case .editorBackground, .editorText, .editorCursor, .editorSelection, - .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles: - return .editor - case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, - .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType: - return .syntax - case .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, - .gridLine, .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder, - .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, - .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: - return .dataGrid - } - } - - /// 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 .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") - case .editorCurrentLine: return String(localized: "Current Line") - case .editorCurrentStatement: return String(localized: "Current Statement") - case .editorLineNumber: return String(localized: "Line Number") - case .editorInvisibles: return String(localized: "Invisibles") - case .syntaxKeyword: return String(localized: "Keyword") - case .syntaxString: return String(localized: "String") - case .syntaxNumber: return String(localized: "Number") - case .syntaxComment: return String(localized: "Comment") - case .syntaxNull: return String(localized: "NULL") - case .syntaxOperator: return String(localized: "Operator") - case .syntaxFunction: return String(localized: "Function") - case .syntaxType: return String(localized: "Type") - case .gridAlternateRow: return String(localized: "Alternate Row") - case .gridHeaderBackground: return String(localized: "Header Background") - case .gridHeaderText: return String(localized: "Header Text") - case .gridLine: return String(localized: "Grid Line") - case .gridSelectionText: return String(localized: "Selected Text") - case .gridInactiveSelection: return String(localized: "Inactive Selection") - case .gridFocusBorder: return String(localized: "Focus Border") - case .gridNullValue: return String(localized: "NULL Value") - case .gridBoolTrue: return String(localized: "Bool True") - case .gridBoolFalse: return String(localized: "Bool False") - case .gridRowNumber: return String(localized: "Row Number") - case .gridModified: return String(localized: "Modified") - case .gridInserted: return String(localized: "Inserted") - case .gridDeleted: return String(localized: "Deleted") - case .gridDeletedText: return String(localized: "Deleted Text") - } - } -} - -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") - } - } - - internal var slots: [ThemeSlot] { - ThemeSlot.allCases.filter { $0.group == self } - } -} - -internal enum ThemeSchema { - /// Format 3 dropped the pane and status groups, made every color optional and removed - /// `system:` values. Format 2 never shipped in a release, and format 1 files carried fonts the - /// app now keeps in settings. - internal static let current = 3 -} diff --git a/TablePro/Theme/ThemeDocument.swift b/TablePro/Theme/ThemeDocument.swift deleted file mode 100644 index ddd939fe82..0000000000 --- a/TablePro/Theme/ThemeDocument.swift +++ /dev/null @@ -1,193 +0,0 @@ -import Foundation - -internal indirect enum ThemeJSONNode: Decodable { - case string(String) - case number(Int) - case object([String: ThemeJSONNode]) - case other - - internal init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if let value = try? container.decode(String.self) { - self = .string(value) - return - } - if let value = try? container.decode(Int.self) { - self = .number(value) - return - } - if let value = try? container.decode([String: ThemeJSONNode].self) { - self = .object(value) - return - } - self = .other - } - - internal var stringValue: String? { - if case let .string(value) = self { return value } - return nil - } - - internal var intValue: Int? { - if case let .number(value) = self { return value } - return nil - } - - /// Every leaf with its dotted path. A leaf that is not a string is kept as `nil`, so the - /// decoder can report it instead of dropping it. - internal func leaves(prefix: String = "") -> [(path: String, value: String?)] { - switch self { - case let .string(value): - return [(prefix, value)] - case .number, .other: - return [(prefix, nil)] - case let .object(children): - return children.flatMap { key, child in - child.leaves(prefix: prefix.isEmpty ? key : "\(prefix).\(key)") - } - } - } -} - -/// The theme file format, and the only place a file can be rejected. Every color is optional: a -/// slot the file leaves out draws in the system color, or for syntax in the same-appearance -/// default's color. What is refused is anything that would otherwise load as something the author -/// did not write: an unknown key, an unparseable color, missing metadata, another format. -internal struct ThemeDocument { - internal let id: String - internal let name: String - internal let author: String - internal let appearance: ThemeAppearance - internal let colors: [ThemeSlot: ThemeColor] - - private enum MetadataKey: String, CaseIterable { - case schema - case id - case name - case author - case appearance - } - - internal init(data: Data) throws { - let root = try JSONDecoder().decode([String: ThemeJSONNode].self, from: data) - - guard let schema = root[MetadataKey.schema.rawValue]?.intValue else { - if let legacy = root["version"]?.intValue { - throw ThemeLoadError.schemaTooOld(found: legacy, supported: ThemeSchema.current) - } - throw ThemeLoadError.missingSchema - } - guard schema >= ThemeSchema.current else { - throw ThemeLoadError.schemaTooOld(found: schema, supported: ThemeSchema.current) - } - guard schema <= ThemeSchema.current else { - throw ThemeLoadError.schemaTooNew(found: schema, supported: ThemeSchema.current) - } - - guard let id = root[MetadataKey.id.rawValue]?.stringValue, !id.isEmpty else { - throw ThemeLoadError.missingField(MetadataKey.id.rawValue) - } - guard ThemeIdentifier.isValid(id) else { - throw ThemeLoadError.invalidIdentifier(id) - } - self.id = id - - guard let name = root[MetadataKey.name.rawValue]?.stringValue, - !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - throw ThemeLoadError.missingField(MetadataKey.name.rawValue) - } - self.name = name - - guard - let appearanceRaw = root[MetadataKey.appearance.rawValue]?.stringValue, - let appearance = ThemeAppearance(rawValue: appearanceRaw) - else { - throw ThemeLoadError.missingField(MetadataKey.appearance.rawValue) - } - self.appearance = appearance - - author = root[MetadataKey.author.rawValue]?.stringValue ?? "" - - let leaves = root - .filter { MetadataKey(rawValue: $0.key) == nil } - .flatMap { key, node in node.leaves(prefix: key) } - .sorted { $0.path < $1.path } - - var parsed: [ThemeSlot: ThemeColor] = [:] - var unknown: [String] = [] - - for leaf in leaves { - guard let slot = ThemeSlot(rawValue: leaf.path) else { - unknown.append(leaf.path) - continue - } - guard parsed[slot] == nil else { - throw ThemeLoadError.duplicateKey(leaf.path) - } - guard let raw = leaf.value else { - throw ThemeLoadError.invalidColor(leaf.path) - } - parsed[slot] = try ThemeColor(validating: raw) - } - - guard unknown.isEmpty else { - throw ThemeLoadError.unknownKeys(unknown) - } - - colors = parsed - } - - internal func definition() -> ThemeDefinition { - ThemeDefinition(id: id, name: name, author: author, appearance: appearance, overrides: colors) - } -} - -internal enum ThemeIdentifier { - private static let pattern = #"^[A-Za-z0-9._-]+$"# - - internal static func isValid(_ id: String) -> Bool { - id.range(of: pattern, options: .regularExpression) != nil - && !id.contains("..") - } - - internal static func generated() -> String { - ThemeDefinition.userPrefix + UUID().uuidString.lowercased().prefix(8) - } -} - -internal enum ThemeEncoder { - internal static func data(for theme: ThemeDefinition) throws -> Data { - var root: [String: Any] = [ - "schema": ThemeSchema.current, - "id": theme.id, - "name": theme.name, - "author": theme.author, - "appearance": theme.appearance.rawValue, - ] - - for slot in ThemeSlot.allCases { - guard let color = theme.overrides[slot] else { continue } - insert(color.hex, at: slot.rawValue.components(separatedBy: "."), into: &root) - } - - return try JSONSerialization.data( - withJSONObject: root, - options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - ) - } - - private static func insert(_ value: String, at path: [String], into container: inout [String: Any]) { - guard let head = path.first else { return } - - guard path.count > 1 else { - container[head] = value - return - } - - var child = container[head] as? [String: Any] ?? [:] - insert(value, at: Array(path.dropFirst()), into: &child) - container[head] = child - } -} diff --git a/TablePro/Theme/ThemeEngine+EditorTheme.swift b/TablePro/Theme/ThemeEngine+EditorTheme.swift index 0cc57cec08..8ccc4929bc 100644 --- a/TablePro/Theme/ThemeEngine+EditorTheme.swift +++ b/TablePro/Theme/ThemeEngine+EditorTheme.swift @@ -2,42 +2,12 @@ import AppKit import TableProEditorKit internal extension ThemeEngine { - /// A slot the theme leaves out arrives as the system color object itself, which the editor resolves when it draws. - /// An Accent, Highlight or contrast change therefore builds an equal `EditorTheme`, and the editor keeps its - /// highlighting rather than restyling the whole document. func makeEditorTheme() -> EditorTheme { let editorSettings = AppSettingsManager.shared.editor - let text = EditorTheme.Attribute(color: resolved[.editorText]) - let comment = EditorTheme.Attribute(color: resolved[.syntaxComment]) - let keyword = EditorTheme.Attribute(color: resolved[.syntaxKeyword], bold: true) - let string = EditorTheme.Attribute(color: resolved[.syntaxString]) - let number = EditorTheme.Attribute(color: resolved[.syntaxNumber]) - let variable = EditorTheme.Attribute(color: resolved[.syntaxNull]) - let type = EditorTheme.Attribute(color: resolved[.syntaxType]) - let operatorAttribute = EditorTheme.Attribute(color: resolved[.syntaxOperator]) - let function = EditorTheme.Attribute(color: resolved[.syntaxFunction]) - - return EditorTheme( - text: text, - insertionPoint: resolved.isOverridden(.editorCursor) ? resolved[.editorCursor] : nil, - invisibles: EditorTheme.Attribute(color: resolved[.editorInvisibles]), - background: resolved[.editorBackground], - lineHighlight: editorSettings.highlightCurrentLine ? resolved[.editorCurrentLine] : .clear, - statementHighlight: editorSettings.highlightCurrentStatement ? resolved[.editorCurrentStatement] : .clear, - selection: resolved[.editorSelection], - lineNumber: resolved[.editorLineNumber], - keywords: keyword, - commands: keyword, - types: type, - attributes: variable, - variables: variable, - values: variable, - numbers: number, - strings: string, - characters: string, - comments: comment, - operators: operatorAttribute, - functions: function + return EditorThemeFactory.make( + resolved: resolved, + highlightsCurrentLine: editorSettings.highlightCurrentLine, + highlightsCurrentStatement: editorSettings.highlightCurrentStatement ) } } diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 09cde4af70..dc60337e31 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -4,6 +4,7 @@ import Foundation import os import SwiftUI import TableProEditorKit +import TableProTheme internal enum DataGridFontVariant { static let regular = 0 @@ -51,12 +52,13 @@ internal struct DataGridFontCache: Equatable { } } -/// Owns the active palette and the font caches, and nothing else: the catalog is `ThemeCatalog`, -/// the choice is `ThemeResolver`, and the fonts come from settings. It never writes settings back, -/// so the flow is one way. +/// Owns the selection (mode and the two theme ids), the active palette and the font caches. The +/// catalog is `ThemeCatalog`, the choice is `ThemeResolver`, and the fonts come from settings. It +/// never writes settings back, so the flow is one way, and it follows the catalog itself, so a save, +/// an import or a file changed on disk repaints with no view involved. @MainActor internal final class ThemeEngine: ObservableObject { - internal static let shared = ThemeEngine() + internal static let shared = ThemeEngine(catalog: .shared, source: .shared, changes: AppEvents.shared.themeChanged) @Published internal private(set) var pair: ThemePair @Published internal private(set) var effectiveAppearance: ThemeAppearance @@ -66,6 +68,10 @@ internal final class ThemeEngine: ObservableObject { @Published internal private(set) var editorFonts: EditorFontCache @Published internal private(set) var dataGridFonts: DataGridFontCache + internal private(set) var mode: AppAppearanceMode = .auto + internal private(set) var lightThemeId = BuiltInThemes.defaultLightId + internal private(set) var darkThemeId = BuiltInThemes.defaultDarkId + internal var activeTheme: ThemeDefinition { pair[effectiveAppearance] } internal var change: ThemeChange { @@ -80,7 +86,12 @@ internal final class ThemeEngine: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEngine") - private var mode: AppAppearanceMode = .auto + private let catalog: ThemeCatalog + private let source: ThemeSource + private let changes: PassthroughSubject + private var drafts: [String: ThemeDefinition] = [:] + private var isResolveScheduled = false + private var catalogCancellable: AnyCancellable? private var typography: TypographySettings = .default private var generation = 0 private var systemFingerprint: SystemColorFingerprint? @@ -89,15 +100,25 @@ internal final class ThemeEngine: ObservableObject { private var accessibilityObserver: NSObjectProtocol? private var lastAccessibilityScale: CGFloat = 1 - private init() { + /// The shared engine draws the app. Another one, on its own source and change subject, touches + /// nothing the app draws with. + internal init( + catalog: ThemeCatalog, + source: ThemeSource = ThemeSource(), + changes: PassthroughSubject = PassthroughSubject() + ) { + self.catalog = catalog + self.source = source + self.changes = changes pair = .builtIn effectiveAppearance = .light revision = 1 - palette = ThemePalette(revision: 1) - resolved = ResolvedTheme(definition: BuiltInThemes.light) + palette = ThemePalette(revision: 1, source: source) + resolved = ResolvedTheme(definition: BuiltInThemes.light, source: source) editorFonts = EditorFontCache(from: .default) dataGridFonts = DataGridFontCache(from: .default) + observeCatalog() observeSystemColorChanges() observeAccessibilityChanges() } @@ -132,19 +153,22 @@ internal final class ThemeEngine: ObservableObject { // MARK: - Settings entry points internal func apply(mode: AppAppearanceMode, lightThemeId: String, darkThemeId: String) { + self.lightThemeId = lightThemeId + self.darkThemeId = darkThemeId + apply(mode: mode) + } + + internal func apply(mode: AppAppearanceMode) { self.mode = mode applyApplicationAppearance(mode) observeApplicationAppearance() + resolve() + } - let selection = ThemeResolver.resolve( - mode: mode, - lightThemeId: lightThemeId, - darkThemeId: darkThemeId, - themes: ThemeCatalog.shared.themes, - systemIsDark: Self.systemIsDark() - ) - - adopt(selection) + internal func select(lightThemeId: String, darkThemeId: String) { + self.lightThemeId = lightThemeId + self.darkThemeId = darkThemeId + resolve() } internal func apply(typography: TypographySettings) { @@ -156,14 +180,25 @@ internal final class ThemeEngine: ObservableObject { publishChange() } - /// Called when the catalog changes under a selection that is already live, so a saved edit is - /// visible without a settings round trip. - internal func reapply(lightThemeId: String, darkThemeId: String) { - apply(mode: mode, lightThemeId: lightThemeId, darkThemeId: darkThemeId) + // MARK: - Draft preview + + /// Stands in for the theme with the draft's id until `endPreview(id:)`, so an editor repaints the + /// real windows on every change without a file write. A draft no pair slot selects draws nothing. + internal func beginPreview(_ draft: ThemeDefinition) { + guard drafts[draft.id] != draft else { return } + drafts[draft.id] = draft + scheduleResolve() + } + + internal func endPreview(id: String) { + guard drafts.removeValue(forKey: id) != nil else { return } + scheduleResolve() } - /// The resolver's output applied to the engine. Every entry point above funnels here, and it - /// is the seam a test uses to put a known pair in front of the grid without touching settings. + // MARK: - Resolution + + /// The resolver's output applied to the engine. Every entry point funnels here, and it is the + /// seam a test uses to put a known pair in front of the grid without touching settings. internal func adopt(_ selection: ThemeSelection) { let appearanceChanged = selection.effectiveAppearance != effectiveAppearance let pairChanged = selection.pair != pair @@ -172,38 +207,76 @@ internal final class ThemeEngine: ObservableObject { pair = selection.pair effectiveAppearance = selection.effectiveAppearance - ThemeSource.shared.update(selection.pair) + source.update(selection.pair) if pairChanged { bumpRevision() } - resolved = ResolvedTheme(definition: activeTheme) + resolved = ResolvedTheme(definition: activeTheme, source: source) publishChange() Self.logger.info("Theme \(self.activeTheme.id, privacy: .public) revision \(self.revision)") } + private func resolve() { + adopt(ThemeResolver.resolve( + lightThemeId: lightThemeId, + darkThemeId: darkThemeId, + themes: resolverInput(), + effectiveAppearance: mode.effectiveAppearance(systemIsDark: Self.systemIsDark()) + )) + } + + private func resolverInput() -> [ThemeDefinition] { + var themes = catalog.themes.map { drafts[$0.id] ?? $0 } + let listed = Set(themes.map(\.id)) + themes += drafts.values.filter { !listed.contains($0.id) } + return ThemeOrdering.sorted(themes) + } + + /// A color well reports every drag tick, and one catalog reload can publish more than once, so a + /// burst costs one resolution. Common modes, because a well drag runs the event-tracking mode. + private func scheduleResolve() { + guard !isResolveScheduled else { return } + isResolveScheduled = true + RunLoop.main.perform(inModes: [.common]) { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.isResolveScheduled = false + self.resolve() + } + } + } + + /// `@Published` sends before the property changes, so the scheduled resolution reads the new list. + private func observeCatalog() { + catalogCancellable = catalog.$themes + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in self?.scheduleResolve() } + } + private func bumpRevision() { revision += 1 - palette = ThemePalette(revision: revision) + palette = ThemePalette(revision: revision, source: source) } private func publishChange() { generation += 1 systemFingerprint = SystemColorFingerprint.current() - AppEvents.shared.themeChanged.send(change) + changes.send(change) } /// A system color change gets new palette objects, because SwiftUI caches a resolved /// `Color(nsColor:)` by object identity and would keep the old accent otherwise. private func systemColorsMayHaveChanged() { - let appearance = ThemeResolver.effectiveAppearance(mode: mode, systemIsDark: Self.systemIsDark()) + let appearance = mode.effectiveAppearance(systemIsDark: Self.systemIsDark()) let appearanceChanged = appearance != effectiveAppearance guard appearanceChanged || SystemColorFingerprint.current() != systemFingerprint else { return } if appearanceChanged { effectiveAppearance = appearance - resolved = ResolvedTheme(definition: activeTheme) + resolved = ResolvedTheme(definition: activeTheme, source: source) } bumpRevision() publishChange() diff --git a/TablePro/Theme/ThemeLoadError.swift b/TablePro/Theme/ThemeLoadError.swift deleted file mode 100644 index 8f9382bf94..0000000000 --- a/TablePro/Theme/ThemeLoadError.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation - -internal enum ThemeLoadError: LocalizedError, Equatable { - case missingSchema - case schemaTooOld(found: Int, supported: Int) - case schemaTooNew(found: Int, supported: Int) - case missingField(String) - case invalidColor(String) - case unknownKeys([String]) - case duplicateKey(String) - case reservedIdentifier(String) - case invalidIdentifier(String) - case duplicateIdentifier(String, existingFile: String) - case changedOnDisk(String) - - internal var errorDescription: String? { - switch self { - case .missingSchema: - return String(localized: "The file does not declare a theme format version.") - case let .schemaTooOld(found, supported): - return String( - format: String(localized: "Theme format %1$d is no longer supported. This version of TablePro reads format %2$d."), - found, - supported - ) - case let .schemaTooNew(found, supported): - return String( - format: String(localized: "Theme format %1$d was made for a newer TablePro. This version reads format %2$d."), - found, - supported - ) - case let .missingField(name): - return String(format: String(localized: "The theme is missing the required field \"%@\"."), name) - case let .invalidColor(value): - return String(format: String(localized: "\"%@\" is not a valid color. Use #RRGGBB or #RRGGBBAA."), value) - case let .unknownKeys(names): - let shown = ListFormatter.localizedString(byJoining: names.prefix(3).map { "\"\($0)\"" }) - return String(format: String(localized: "TablePro does not use these theme keys: %@."), shown) - case let .duplicateKey(path): - return String(format: String(localized: "The theme sets \"%@\" more than once."), path) - case let .reservedIdentifier(identifier): - return String(format: String(localized: "\"%@\" is a reserved theme identifier."), identifier) - case let .invalidIdentifier(identifier): - return String(format: String(localized: "\"%@\" is not a valid theme identifier."), identifier) - case let .changedOnDisk(fileName): - return String( - format: String(localized: "%@ changed on disk since it was opened. The newer file was kept and reloaded."), - fileName - ) - case let .duplicateIdentifier(identifier, existingFile): - return String( - format: String(localized: "The theme identifier \"%1$@\" is already used by %2$@."), - identifier, - existingFile - ) - } - } -} diff --git a/TablePro/Theme/ThemePalette.swift b/TablePro/Theme/ThemePalette.swift index f9792d7441..b0cc9320bd 100644 --- a/TablePro/Theme/ThemePalette.swift +++ b/TablePro/Theme/ThemePalette.swift @@ -1,29 +1,29 @@ import AppKit import os import SwiftUI - -internal struct ThemePair: Equatable, Sendable { - var light: ThemeDefinition - var dark: ThemeDefinition - - internal subscript(appearance: ThemeAppearance) -> ThemeDefinition { - switch appearance { - case .light: return light - case .dark: return dark - } - } - - internal static let builtIn = ThemePair(light: BuiltInThemes.light, dark: BuiltInThemes.dark) -} +import TableProTheme /// The live pair every dynamic slot colour reads through. A colour object an AppKit view is /// already holding therefore answers for the theme in effect now, the way `NSColor.labelColor` /// does, so marking that view for display is enough. Capturing the pair inside the provider /// instead would freeze each colour at the revision that built it. internal final class ThemeSource: Sendable { - internal static let shared = ThemeSource() + internal static let shared = ThemeSource(colorNamespace: "tablepro") + + /// Two dynamic colors with one name compare equal whatever they draw, so each source names its + /// palette apart from every other. + internal let colorNamespace: String - private let state = OSAllocatedUnfairLock(initialState: ThemePair.builtIn) + private let state: OSAllocatedUnfairLock + + internal convenience init(pair: ThemePair = .builtIn) { + self.init(pair: pair, colorNamespace: "tablepro.\(UUID().uuidString)") + } + + private init(pair: ThemePair = .builtIn, colorNamespace: String) { + state = OSAllocatedUnfairLock(initialState: pair) + self.colorNamespace = colorNamespace + } internal var pair: ThemePair { state.withLock { $0 } @@ -54,7 +54,7 @@ internal struct ThemePalette { built.reserveCapacity(ThemeSlot.allCases.count) for slot in ThemeSlot.allCases { - let name = NSColor.Name("tablepro.\(slot.rawValue).\(revision)") + let name = NSColor.Name("\(source.colorNamespace).\(slot.rawValue).\(revision)") built[slot] = NSColor(name: name) { appearance in source.pair[ThemeAppearance(matching: appearance)].color(for: slot, in: appearance) } @@ -84,7 +84,7 @@ internal struct ThemePalette { } /// The active theme's colors for code that holds on to them: the editor theme, a `CALayer`, the -/// grid's per-cell drawing. A slot the theme overrides is a fixed sRGB color; a slot it leaves out +/// grid's per-cell drawing. A slot the theme colors is a fixed sRGB color; a slot it leaves out /// is the system color object itself, never a flattened copy, so text drawn with it still resolves /// at draw time against the view's real appearance. Turning one into a `CGColor` happens in the /// drawing view's appearance (`cgColor(_:in:)`), never against a hand-built `NSAppearance(named:)`, @@ -93,9 +93,11 @@ internal struct ResolvedTheme: Equatable { internal let definition: ThemeDefinition private let colors: [ThemeSlot: NSColor] + private let source: ThemeSource - internal init(definition: ThemeDefinition) { + internal init(definition: ThemeDefinition, source: ThemeSource = .shared) { self.definition = definition + self.source = source var built: [ThemeSlot: NSColor] = [:] built.reserveCapacity(ThemeSlot.allCases.count) @@ -106,7 +108,7 @@ internal struct ResolvedTheme: Equatable { } internal static func == (lhs: ResolvedTheme, rhs: ResolvedTheme) -> Bool { - lhs.definition == rhs.definition + lhs.definition == rhs.definition && lhs.source === rhs.source } internal var appearance: ThemeAppearance { definition.appearance } @@ -123,7 +125,7 @@ internal struct ResolvedTheme: Equatable { Color(nsColor: self[slot]) } - /// The override when the active theme sets the slot, else `native` itself. + /// The theme's color when the active theme colors 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] } @@ -134,18 +136,18 @@ internal struct ResolvedTheme: Equatable { } /// 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. + /// takes that slot's theme from the same source, 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) + : source.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)] + : source.pair[ThemeAppearance(matching: appearance)] return Self.cgColor(theme.isOverridden(slot, in: appearance) ? theme.color(for: slot) : native, in: appearance) } diff --git a/TablePro/Theme/ThemeResolver.swift b/TablePro/Theme/ThemeResolver.swift deleted file mode 100644 index 084548c3ad..0000000000 --- a/TablePro/Theme/ThemeResolver.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation - -internal struct ThemeSelection: Equatable, Sendable { - internal let pair: ThemePair - internal let effectiveAppearance: ThemeAppearance - - internal var active: ThemeDefinition { pair[effectiveAppearance] } -} - -/// Pure. It never writes settings: a settings pane that rewrote the saved theme id to make its own -/// filter true silently replaced a theme the user had chosen and then hid it from the list they -/// would have used to put it back. A slot whose theme is missing, rejected, or of the wrong -/// appearance falls back to that slot's own built-in, so a broken dark theme never paints Default -/// Light inside dark chrome. -internal enum ThemeResolver { - internal static func resolve( - mode: AppAppearanceMode, - lightThemeId: String, - darkThemeId: String, - themes: [ThemeDefinition], - systemIsDark: Bool - ) -> ThemeSelection { - let pair = ThemePair( - light: theme(id: lightThemeId, slot: .light, in: themes), - dark: theme(id: darkThemeId, slot: .dark, in: themes) - ) - - return ThemeSelection( - pair: pair, - effectiveAppearance: effectiveAppearance(mode: mode, systemIsDark: systemIsDark) - ) - } - - internal static func effectiveAppearance(mode: AppAppearanceMode, systemIsDark: Bool) -> ThemeAppearance { - switch mode { - case .light: return .light - case .dark: return .dark - case .auto: return systemIsDark ? .dark : .light - } - } - - private static func theme( - id: String, - slot: ThemeAppearance, - in themes: [ThemeDefinition] - ) -> ThemeDefinition { - guard let found = themes.first(where: { $0.id == id }), found.appearance == slot else { - return BuiltInThemes.default(for: slot) - } - return found - } -} - -/// The list a slot offers. A theme only fits the slot whose appearance it declares, and the theme -/// the slot already holds stays listed so the user can always see and re-pick their own choice. -internal enum ThemeSlotValidation { - internal static func fits(_ appearance: ThemeAppearance, slot: ThemeAppearance) -> Bool { - appearance == slot - } - - internal static func eligibleThemes( - _ themes: [ThemeDefinition], - slot: ThemeAppearance, - keeping selectedId: String? - ) -> [ThemeDefinition] { - themes.filter { fits($0.appearance, slot: slot) || $0.id == selectedId } - } -} diff --git a/TablePro/Theme/ThemeScope.swift b/TablePro/Theme/ThemeScope.swift new file mode 100644 index 0000000000..4de69cb03b --- /dev/null +++ b/TablePro/Theme/ThemeScope.swift @@ -0,0 +1,60 @@ +import Combine +import Foundation +import TableProTheme + +/// Where a carrier reads its colors. `live` is the engine, which is what every surface in the app +/// draws with. `fixed` draws one definition in both appearances and never changes, so the theme +/// editor can render a draft through the real carriers without making it the active theme. +@MainActor +internal final class ThemeScope { + internal static let live = ThemeScope(storage: .live) + + private enum Storage { + case live + case fixed(definition: ThemeDefinition, palette: ThemePalette, resolved: ResolvedTheme) + } + + private let storage: Storage + + private init(storage: Storage) { + self.storage = storage + } + + /// A new definition is a new scope, so this one never reports a change. + internal static func fixed(_ definition: ThemeDefinition) -> ThemeScope { + let source = ThemeSource(pair: ThemePair(light: definition, dark: definition)) + return ThemeScope(storage: .fixed( + definition: definition, + palette: ThemePalette(revision: 1, source: source), + resolved: ResolvedTheme(definition: definition, source: source) + )) + } + + internal var activeTheme: ThemeDefinition { + switch storage { + case .live: return ThemeEngine.shared.activeTheme + case let .fixed(definition, _, _): return definition + } + } + + internal var palette: ThemePalette { + switch storage { + case .live: return ThemeEngine.shared.palette + case let .fixed(_, palette, _): return palette + } + } + + internal var resolved: ResolvedTheme { + switch storage { + case .live: return ThemeEngine.shared.resolved + case let .fixed(_, _, resolved): return resolved + } + } + + internal var changes: AnyPublisher { + switch storage { + case .live: return AppEvents.shared.themeChanged.eraseToAnyPublisher() + case .fixed: return Empty(completeImmediately: false).eraseToAnyPublisher() + } + } +} diff --git a/TablePro/Theme/ThemeShapeStyle.swift b/TablePro/Theme/ThemeShapeStyle.swift index b201851527..f63fe01faa 100644 --- a/TablePro/Theme/ThemeShapeStyle.swift +++ b/TablePro/Theme/ThemeShapeStyle.swift @@ -1,4 +1,5 @@ import SwiftUI +import TableProTheme /// 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 diff --git a/TablePro/Theme/ThemeTableChrome.swift b/TablePro/Theme/ThemeTableChrome.swift index 2e8de4fd46..2b62a0d772 100644 --- a/TablePro/Theme/ThemeTableChrome.swift +++ b/TablePro/Theme/ThemeTableChrome.swift @@ -1,6 +1,7 @@ import AppKit import Combine import SwiftUI +import TableProTheme /// 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. @@ -39,6 +40,14 @@ internal final class ThemeTableChrome { } } + /// The data grid's header slots. A code table's header, where it has one, stays AppKit's. + internal var headerSlots: [ThemeSlot] { + switch self { + case .records: return [.gridHeaderBackground, .gridHeaderText] + case .code: return [] + } + } + internal func selection(emphasized: Bool) -> ThemeSlot { switch self { case .records: return emphasized ? .gridSelection : .gridInactiveSelection @@ -53,6 +62,7 @@ internal final class ThemeTableChrome { } internal let family: Family + internal let scope: ThemeScope /// 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. @@ -67,14 +77,17 @@ internal final class ThemeTableChrome { private weak var themedScrollView: NSScrollView? private var scrollViewDrewBackground = true private var originalGridColor: NSColor? + private var originalHeaderView: NSTableHeaderView? + private var originalCornerView: NSView? 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) { + internal init(tableView: NSTableView, family: Family, scope: ThemeScope = .live) { self.tableView = tableView self.family = family + self.scope = scope restyle() - themeCancellable = AppEvents.shared.themeChanged + themeCancellable = scope.changes .sink { [weak self] _ in self?.apply() } } @@ -82,6 +95,11 @@ internal final class ThemeTableChrome { backgroundOriginals != nil } + /// True while the table's header is a `ThemeTableHeaderView` in place of the one its owner gave it. + internal var isHeaderThemed: Bool { + originalHeaderView != nil + } + internal func apply() { restyle() guard let tableView, hasStaleRows(in: tableView) else { return } @@ -102,7 +120,7 @@ internal final class ThemeTableChrome { /// 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 + let palette = scope.palette guard originals.alternates, let alternate = family.alternateRow, !row.isMultiple(of: 2) else { return palette[family.background] } @@ -112,7 +130,7 @@ internal final class ThemeTableChrome { /// 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 resolved = scope.resolved let slot = family.selection(emphasized: emphasized) return resolved.isOverridden(slot) ? resolved[slot] : nil } @@ -123,10 +141,11 @@ internal final class ThemeTableChrome { private func restyle() { guard let tableView else { return } - let resolved = ThemeEngine.shared.resolved + let resolved = scope.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) + applyHeader(to: tableView, themed: family.headerSlots.contains { resolved.isOverridden($0) }) let selectionSlots = [family.selection(emphasized: true), family.selection(emphasized: false)] themesRows = isBackgroundThemed || selectionSlots.contains { resolved.isOverridden($0) } tableView.needsDisplay = true @@ -194,7 +213,7 @@ internal final class ThemeTableChrome { alternates: tableView.usesAlternatingRowBackgroundColors ) } - tableView.backgroundColor = ThemeEngine.shared.palette[family.background] + tableView.backgroundColor = scope.palette[family.background] tableView.usesAlternatingRowBackgroundColors = false if let scrollView = tableView.enclosingScrollView, scrollView !== themedScrollView { @@ -230,7 +249,112 @@ internal final class ThemeTableChrome { if originalGridColor == nil { originalGridColor = tableView.gridColor } - tableView.gridColor = ThemeEngine.shared.palette[slot] + tableView.gridColor = scope.palette[slot] + } + + /// AppKit's header paints its own band over anything beneath it, so a themed header is ours and + /// the owner's comes back when the theme stops coloring it. + private func applyHeader(to tableView: NSTableView, themed: Bool) { + guard themed else { + restoreHeader(of: tableView) + return + } + if let header = tableView.headerView as? ThemeTableHeaderView, header.chrome === self { + header.needsDisplay = true + tableView.cornerView?.needsDisplay = true + return + } + guard let original = tableView.headerView else { return } + originalHeaderView = original + originalCornerView = tableView.cornerView + + let header = ThemeTableHeaderView(frame: original.frame) + header.chrome = self + header.menu = original.menu + tableView.headerView = header + let corner = ThemeTableCornerView(frame: originalCornerView?.frame ?? .zero) + corner.chrome = self + tableView.cornerView = corner + } + + private func restoreHeader(of tableView: NSTableView) { + guard let original = originalHeaderView else { return } + tableView.headerView = original + tableView.cornerView = originalCornerView + originalHeaderView = nil + originalCornerView = nil + } +} + +// MARK: - Header + +/// Drawn like the data grid's header. Each title goes through its cell's interior drawing, which +/// keeps AppKit's alignment and truncation. The sort indicator is drawn here because AppKit draws it +/// from the header view's own pass, which this replaces. +internal final class ThemeTableHeaderView: NSTableHeaderView { + internal weak var chrome: ThemeTableChrome? + + override internal func draw(_ dirtyRect: NSRect) { + guard let chrome, let tableView else { + super.draw(dirtyRect) + return + } + let scope = chrome.scope + SortableHeaderChrome.fillBackground(dirtyRect, scope: scope) + let textColor = scope.resolved[.gridHeaderText] + let dragged = draggedColumn + for index in tableView.tableColumns.indices where index != dragged { + drawHeading(ofColumn: index, at: headerRect(ofColumn: index), in: dirtyRect, textColor: textColor, scope: scope) + } + // A column being dragged follows the pointer over its neighbours, as AppKit's header draws it. + if dragged >= 0, dragged < tableView.tableColumns.count { + let floating = headerRect(ofColumn: dragged).offsetBy(dx: draggedDistance, dy: 0) + if floating.intersects(dirtyRect) { + SortableHeaderChrome.fillBackground(floating, scope: scope) + } + drawHeading(ofColumn: dragged, at: floating, in: dirtyRect, textColor: textColor, scope: scope) + } + SortableHeaderChrome.drawBottomSeparator(in: bounds, scope: scope) + } + + private func drawHeading(ofColumn index: Int, at heading: NSRect, in dirtyRect: NSRect, textColor: NSColor, scope: ThemeScope) { + guard let tableView, heading.width > 0, heading.intersects(dirtyRect) else { return } + let column = tableView.tableColumns[index] + let cell = column.headerCell + let nativeTextColor = cell.textColor + cell.textColor = textColor + if let sort = sortState(of: column, in: tableView) { + var title = heading + title.size.width = max(0, cell.sortIndicatorRect(forBounds: heading).minX - heading.minX) + cell.drawInterior(withFrame: title, in: self) + cell.drawSortIndicator(withFrame: heading, in: self, ascending: sort.ascending, priority: sort.priority) + } else { + cell.drawInterior(withFrame: heading, in: self) + } + cell.textColor = nativeTextColor + SortableHeaderChrome.drawColumnDivider(in: heading, scope: scope) + } + + /// AppKit shows only the primary sort key's indicator. + private func sortState(of column: NSTableColumn, in tableView: NSTableView) -> (ascending: Bool, priority: Int)? { + guard let key = column.sortDescriptorPrototype?.key, + let primary = tableView.sortDescriptors.first, primary.key == key + else { return nil } + return (primary.ascending, 0) + } +} + +/// The square over the vertical scroller beside a themed header, which AppKit would paint in its own +/// header band. +internal final class ThemeTableCornerView: NSView { + internal weak var chrome: ThemeTableChrome? + + override internal var isFlipped: Bool { true } + + override internal func draw(_ dirtyRect: NSRect) { + let scope = chrome?.scope ?? .live + SortableHeaderChrome.fillBackground(dirtyRect, scope: scope) + SortableHeaderChrome.drawBottomSeparator(in: bounds, scope: scope) } } @@ -337,10 +461,10 @@ internal extension ThemeTableChrome { onThemedSelection: Bool, onEmphasizedSelection: Bool, highContrast: Bool, - engine: ThemeEngine = .shared + scope: ThemeScope = .live ) -> ThemeSlot? { guard !onEmphasizedSelection else { return nil } - let theme = engine.activeTheme + let theme = scope.activeTheme if onThemedSelection, theme.isOverridden(.gridSelectionText, highContrast: highContrast) { return .gridSelectionText } @@ -351,6 +475,7 @@ internal extension ThemeTableChrome { /// while its `ThemeTableRowView` sits on the theme's selection. final class HostingCell: NSHostingView { private let selection = RecordsCellSelection() + private var scope = ThemeScope.live internal var isOnThemedSelection: Bool { get { selection.isThemed } @@ -360,13 +485,16 @@ internal extension ThemeTableChrome { } } + /// `scope` is the one the table's chrome reads, so the text matches the rows. internal static func make( _ identifier: NSUserInterfaceItemIdentifier, in tableView: NSTableView, - showing content: some View + showing content: some View, + scope: ThemeScope = .live ) -> HostingCell { let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? HostingCell ?? HostingCell(identifier: identifier) + cell.scope = scope cell.show(content) return cell } @@ -378,7 +506,7 @@ internal extension ThemeTableChrome { } internal func show(_ content: some View) { - rootView = AnyView(content.modifier(RecordsCellText(selection: selection))) + rootView = AnyView(content.modifier(RecordsCellText(selection: selection, scope: scope))) } /// A reused cell can land in another row, or in AppKit's own row view once the theme stops. @@ -399,12 +527,14 @@ private struct RecordsCellText: ViewModifier { @ObservedObject private var themeEngine = ThemeEngine.shared @Environment(\.colorSchemeContrast) private var contrast + let scope: ThemeScope + func body(content: Content) -> some View { if #available(macOS 14, *) { content.modifier( ProminenceAwareRecordsCellText( isOnThemedSelection: selection.isThemed, - engine: themeEngine, + scope: scope, contrast: contrast ) ) @@ -421,7 +551,7 @@ private struct ProminenceAwareRecordsCellText: ViewModifier { @Environment(\.backgroundProminence) private var prominence let isOnThemedSelection: Bool - let engine: ThemeEngine + let scope: ThemeScope let contrast: ColorSchemeContrast func body(content: Content) -> some View { @@ -429,10 +559,10 @@ private struct ProminenceAwareRecordsCellText: ViewModifier { onThemedSelection: isOnThemedSelection, onEmphasizedSelection: prominence == .increased, highContrast: contrast == .increased, - engine: engine + scope: scope ) - if let slot, let color = ThemeShapeStyle.override(slot, engine: engine, contrast: contrast) { - content.foregroundStyle(color) + if let slot { + content.foregroundStyle(Color(nsColor: scope.palette[slot])) } else { content } diff --git a/TablePro/Theme/ThemeTextViewStyle.swift b/TablePro/Theme/ThemeTextViewStyle.swift index 0c3571c1d4..8043d57f7a 100644 --- a/TablePro/Theme/ThemeTextViewStyle.swift +++ b/TablePro/Theme/ThemeTextViewStyle.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import TableProTheme /// 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 @@ -44,6 +45,8 @@ internal final class ThemeTextViewStyle { } } + internal let scope: ThemeScope + private weak var textView: NSTextView? private let givenNatives: Natives? private var natives: Natives? @@ -51,17 +54,18 @@ internal final class ThemeTextViewStyle { 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) { + internal init(textView: NSTextView, natives: Natives? = nil, scope: ThemeScope = .live) { self.textView = textView givenNatives = natives + self.scope = scope apply() - themeCancellable = AppEvents.shared.themeChanged + themeCancellable = scope.changes .sink { [weak self] _ in self?.apply() } } internal func apply() { guard let textView else { return } - let resolved = ThemeEngine.shared.resolved + let resolved = scope.resolved let wanted = Set(Property.allCases.filter { resolved.isOverridden($0.slot) }) guard !wanted.isEmpty || !themed.isEmpty else { return } @@ -70,7 +74,7 @@ internal final class ThemeTextViewStyle { } guard let natives else { return } - let palette = ThemeEngine.shared.palette + let palette = scope.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) } diff --git a/TablePro/Theme/ThemeWindowBackdrop.swift b/TablePro/Theme/ThemeWindowBackdrop.swift index ed93e370b7..a3ebe621eb 100644 --- a/TablePro/Theme/ThemeWindowBackdrop.swift +++ b/TablePro/Theme/ThemeWindowBackdrop.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import TableProTheme /// 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 @@ -23,15 +24,14 @@ internal final class ThemeWindowBackdrop { 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 { + guard ThemeBackdropState.isThemed(in: window.effectiveAppearance) else { restore(window) return } if original == nil { original = window.backgroundColor } - window.backgroundColor = engine.palette[.windowBackdrop] + window.backgroundColor = ThemeEngine.shared.palette[.windowBackdrop] } private func restore(_ window: NSWindow) { diff --git a/TablePro/ViewModels/ServerDashboardViewModel.swift b/TablePro/ViewModels/ServerDashboardViewModel.swift index 004f2a68ff..7d19709fdb 100644 --- a/TablePro/ViewModels/ServerDashboardViewModel.swift +++ b/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -14,7 +14,9 @@ final class ServerDashboardViewModel: ObservableObject { // MARK: - Data - @Published var sessions: [DashboardSession] = [] + @Published var sessions: [DashboardSession] = [] { + didSet { dropEndedSessionsFromSelection() } + } @Published var metrics: [DashboardMetric] = [] @Published var slowQueries: [DashboardSlowQuery] = [] @@ -43,11 +45,16 @@ final class ServerDashboardViewModel: ObservableObject { // MARK: - Kill / Cancel Confirmation @Published var showKillConfirmation: Bool = false - @Published var pendingKillProcessId: String? + @Published var pendingKillSession: DashboardSession? @Published var showCancelConfirmation: Bool = false - @Published var pendingCancelProcessId: String? + @Published var pendingCancelSession: DashboardSession? @Published var actionError: String? + // MARK: - Selection + + /// Kept here, not in the sessions pane, so the refresh that stops listing a session also drops it. + let sessionSelection = StructureListSelection() + // MARK: - Private nonisolated(unsafe) private var refreshTask: Task? @@ -73,6 +80,13 @@ final class ServerDashboardViewModel: ObservableObject { provider?.canCancel(session) ?? false } + // A server hands a freed session id to its next connection, so a kept id would select that one. + private func dropEndedSessionsFromSelection() { + let kept = sessionSelection.ids.intersection(sessions.map(\.id)) + guard kept != sessionSelection.ids else { return } + sessionSelection.ids = kept + } + // MARK: - Initialization init(connectionId: UUID, databaseType: DatabaseType, services: AppServices = .live) { @@ -188,14 +202,25 @@ final class ServerDashboardViewModel: ObservableObject { // MARK: - Kill Session - func confirmKillSession(processId: String) { - pendingKillProcessId = processId + func confirmKillSession(_ session: DashboardSession) { + pendingKillSession = session showKillConfirmation = true } + static func killConfirmationMessage(for session: DashboardSession) -> String { + guard !session.user.isEmpty else { + return String(format: String(localized: "Terminate session %@? Any running queries will be aborted."), session.id) + } + return String( + format: String(localized: "Terminate session %1$@ (user %2$@)? Any running queries will be aborted."), + session.id, + session.user + ) + } + func executeKillSession() async { - guard let processId = pendingKillProcessId else { return } - pendingKillProcessId = nil + guard let processId = pendingKillSession?.id else { return } + pendingKillSession = nil showKillConfirmation = false guard let sql = provider?.killSessionSQL(processId: processId) else { return } @@ -217,14 +242,25 @@ final class ServerDashboardViewModel: ObservableObject { // MARK: - Cancel Query - func confirmCancelQuery(processId: String) { - pendingCancelProcessId = processId + func confirmCancelQuery(_ session: DashboardSession) { + pendingCancelSession = session showCancelConfirmation = true } + static func cancelConfirmationMessage(for session: DashboardSession) -> String { + guard !session.user.isEmpty else { + return String(format: String(localized: "Cancel the running query for session %@?"), session.id) + } + return String( + format: String(localized: "Cancel the running query for session %1$@ (user %2$@)?"), + session.id, + session.user + ) + } + func executeCancelQuery() async { - guard let processId = pendingCancelProcessId else { return } - pendingCancelProcessId = nil + guard let processId = pendingCancelSession?.id else { return } + pendingCancelSession = nil showCancelConfirmation = false guard let sql = provider?.cancelQuerySQL(processId: processId) else { return } diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index 291f07508e..8e8d41165a 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI import TableProGrammars +import TableProTheme /// A fenced code block in a chat reply: selectable text in the editor's font and colors, as tall as its code. /// diff --git a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index 95083bf8f7..f334fa115a 100644 --- a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme struct AIChatWalkthroughBlockView: View { @ObservedObject var block: ChatContentBlock diff --git a/TablePro/Views/AIChat/CodeBlockHighlighter.swift b/TablePro/Views/AIChat/CodeBlockHighlighter.swift index 1a18c37f18..357b415ca1 100644 --- a/TablePro/Views/AIChat/CodeBlockHighlighter.swift +++ b/TablePro/Views/AIChat/CodeBlockHighlighter.swift @@ -88,17 +88,22 @@ internal enum CodeBlockHighlighter { let specialCharacters: [ClassifiedSpecialCharacter] } - @concurrent - nonisolated private static func markup(of code: String, language: CodeLanguage) async -> Markup { + /// The grammar's captures over the whole lines before the cap. `HighlightedSQLTextView` colors with these too, + /// so a statement reads the same in a chat block, a preview and the editor. + nonisolated internal static func captures(in code: String, language: CodeLanguage) -> [HighlightRange] { let nsCode = code as NSString let highlightedLength = nsCode.length > maximumHighlightedLength ? nsCode.lineRange(for: NSRange(location: maximumHighlightedLength, length: 0)).location : nsCode.length - var highlights: [HighlightRange] = [] - if highlightedLength > 0, language.id != CodeLanguage.default.id { - let source = highlightedLength == nsCode.length ? code : nsCode.substring(to: highlightedLength) - highlights = TreeSitterClient.highlightRanges(in: source, language: language) ?? [] - } + guard highlightedLength > 0, language.id != CodeLanguage.default.id else { return [] } + let source = highlightedLength == nsCode.length ? code : nsCode.substring(to: highlightedLength) + return TreeSitterClient.highlightRanges(in: source, language: language) ?? [] + } + + @concurrent + nonisolated private static func markup(of code: String, language: CodeLanguage) async -> Markup { + let nsCode = code as NSString + let highlights = captures(in: code, language: language) var specialCharacters: [ClassifiedSpecialCharacter] = [] var index = 0 while index < nsCode.length { diff --git a/TablePro/Views/Agent/AgentProposedStatementsView.swift b/TablePro/Views/Agent/AgentProposedStatementsView.swift index e97f181b79..5eeb8644ff 100644 --- a/TablePro/Views/Agent/AgentProposedStatementsView.swift +++ b/TablePro/Views/Agent/AgentProposedStatementsView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme /// Every statement the session proposed, in order, with what became of it. /// @@ -35,20 +36,23 @@ internal struct AgentProposedStatementsView: View { } } -/// 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. +/// Statement at the data-grid value font, metadata at `.caption`: a stored value drawn at a system +/// text style looks right only until the user changes one of the two font settings. internal struct AgentProposedStatementRow: View { + /// `ProposedStatement` is equatable, so without this the row would skip a font change. + @ObservedObject private var themeEngine = ThemeEngine.shared + internal let statement: ProposedStatement var body: some View { VStack(alignment: .leading, spacing: 3) { Text(statement.sql) - .font(ThemeEngine.shared.valueFontSwiftUI) + .font(themeEngine.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) + .modifier(ProposedStatementWell()) HStack(spacing: 4) { Image(systemName: statement.state.symbolName) .font(.caption) @@ -67,3 +71,22 @@ internal struct AgentProposedStatementRow: View { .accessibilityValue(statement.state.title) } } + +/// The well a chat `CodeWell` draws, shown only while the theme sets the editor background, so under a +/// theme that leaves it alone the row draws what it always has. One chain rather than a branch, so a +/// theme switch keeps the text and its selection. +private struct ProposedStatementWell: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + + private static let shape = RoundedRectangle(cornerRadius: 6) + + func body(content: Content) -> some View { + let background = ThemeShapeStyle.override(.editorBackground, engine: themeEngine, contrast: contrast) + return content + .padding(background == nil ? 0 : 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(background ?? .clear, in: Self.shape) + .overlay(Self.shape.stroke(background == nil ? Color.clear : Color(nsColor: .separatorColor), lineWidth: 1)) + } +} diff --git a/TablePro/Views/Agent/AgentResultRowsView.swift b/TablePro/Views/Agent/AgentResultRowsView.swift index acba44463a..df61f16a8c 100644 --- a/TablePro/Views/Agent/AgentResultRowsView.swift +++ b/TablePro/Views/Agent/AgentResultRowsView.swift @@ -43,6 +43,7 @@ internal struct AgentResultRowsView: View { description: Text(String(localized: "Rows the session reads appear here.")) ) .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() } else { let run = runs.first { $0.id == selectedRunId } ?? runs[runs.count - 1] VStack(spacing: 0) { @@ -127,9 +128,11 @@ internal struct AgentResultRowsView: View { } } + /// On the grid's background, so the pane keeps one color between a run with rows and one without. private func state(title: String, systemImage: String, description: String) -> some View { UnavailableStateView(title, systemImage: systemImage, description: Text(description)) .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() } /// Grouped, because a count is read at a glance and "1,204" is legible where "1204" has to be diff --git a/TablePro/Views/Compare/CompareDataPlansView.swift b/TablePro/Views/Compare/CompareDataPlansView.swift index 6db59ad448..72ef721687 100644 --- a/TablePro/Views/Compare/CompareDataPlansView.swift +++ b/TablePro/Views/Compare/CompareDataPlansView.swift @@ -195,7 +195,7 @@ if #available(macOS 14.0, *) { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(.bar) + .paneBarBackground() } private var enabledPlanCount: Int { diff --git a/TablePro/Views/Compare/CompareResultsView.swift b/TablePro/Views/Compare/CompareResultsView.swift index fa6a0e99ba..81157f2cd4 100644 --- a/TablePro/Views/Compare/CompareResultsView.swift +++ b/TablePro/Views/Compare/CompareResultsView.swift @@ -68,7 +68,7 @@ internal struct CompareResultsView: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(.bar) + .paneBarBackground() } /// What the results pane is showing, so Select follows the search field and the identical diff --git a/TablePro/Views/Compare/CompareRowDiffPane.swift b/TablePro/Views/Compare/CompareRowDiffPane.swift index 828cd18105..325a195137 100644 --- a/TablePro/Views/Compare/CompareRowDiffPane.swift +++ b/TablePro/Views/Compare/CompareRowDiffPane.swift @@ -277,20 +277,12 @@ internal struct CompareRowDiffPane: View { if let summary = plan.summary { let entries = filter.entries(in: summary) if entries.isEmpty { - UnavailableStateView { - Label("No Rows Match", systemImage: "line.3.horizontal.decrease.circle") - } description: { - Text("Change the filter to see the other rows.") - } + CompareRowsStandIn(reason: .noRowsMatch) } else { CompareRowGrid(session: session, plan: plan, filter: filter, entries: entries) } } else { - UnavailableStateView { - Label("Not Compared Yet", systemImage: "arrow.clockwise") - } description: { - Text(notComparedDescription(for: plan)) - } + CompareRowsStandIn(reason: .notCompared(notComparedDescription(for: plan))) } } @@ -303,3 +295,33 @@ internal struct CompareRowDiffPane: View { ?? String(localized: "Include this table and compare again to see its rows.") } } + +// Stands in for the row grid, so under a theme it keeps the grid's background. +internal struct CompareRowsStandIn: View { + internal enum Reason: Equatable { + case noRowsMatch + case notCompared(String) + } + + internal let reason: Reason + + internal var body: some View { + Group { + switch reason { + case .noRowsMatch: + UnavailableStateView { + Label("No Rows Match", systemImage: "line.3.horizontal.decrease.circle") + } description: { + Text("Change the filter to see the other rows.") + } + case .notCompared(let description): + UnavailableStateView { + Label("Not Compared Yet", systemImage: "arrow.clockwise") + } description: { + Text(description) + } + } + } + .gridStandInBackground() + } +} diff --git a/TablePro/Views/Compare/CompareStatusStyle.swift b/TablePro/Views/Compare/CompareStatusStyle.swift index 9501627aea..46e641e82e 100644 --- a/TablePro/Views/Compare/CompareStatusStyle.swift +++ b/TablePro/Views/Compare/CompareStatusStyle.swift @@ -8,6 +8,7 @@ import AppKit import SwiftUI +import TableProTheme internal enum CompareStatusStyle { internal static let notComparedTitle = String(localized: "Not compared") diff --git a/TablePro/Views/Compare/CompareSyncWindowController.swift b/TablePro/Views/Compare/CompareSyncWindowController.swift index 8731704687..26e514086e 100644 --- a/TablePro/Views/Compare/CompareSyncWindowController.swift +++ b/TablePro/Views/Compare/CompareSyncWindowController.swift @@ -62,18 +62,7 @@ internal final class CompareSyncWindowController: NSWindowController, } private init(prefillSource connectionId: UUID?) { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 1_100, height: 700), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false - ) - window.minSize = NSSize(width: 720, height: 460) - window.title = String(localized: "Compare & Sync") - window.identifier = NSUserInterfaceItemIdentifier(WindowIdentifier.compareSync) - window.keepsKeyViewLoopCurrent() - window.isRestorable = false - window.isReleasedWhenClosed = false + let window = Self.makeWindow() windowBackdrop = ThemeWindowBackdrop(window: window) super.init(window: window) @@ -94,6 +83,22 @@ internal final class CompareSyncWindowController: NSWindowController, fatalError("init(coder:) not supported") } + internal static func makeWindow() -> NSWindow { + let window = ContentWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_100, height: 700), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.minSize = NSSize(width: 720, height: 460) + window.title = String(localized: "Compare & Sync") + window.identifier = NSUserInterfaceItemIdentifier(WindowIdentifier.compareSync) + window.keepsKeyViewLoopCurrent() + window.isRestorable = false + window.isReleasedWhenClosed = false + return window + } + private func makeContentController() -> NSViewController { let content = CompareWindowContentView( session: session, diff --git a/TablePro/Views/Compare/CompareWindowContentView.swift b/TablePro/Views/Compare/CompareWindowContentView.swift index fb9f825d0f..adbbe82dda 100644 --- a/TablePro/Views/Compare/CompareWindowContentView.swift +++ b/TablePro/Views/Compare/CompareWindowContentView.swift @@ -11,6 +11,7 @@ // import SwiftUI +import TableProTheme internal struct CompareWindowContentView: View { @ObservedObject internal var session: CompareSyncSession @@ -40,7 +41,11 @@ internal struct CompareWindowContentView: View { /// total number of items in a window, the number of selected items". .safeAreaInset(edge: .bottom, spacing: 0) { CompareStatusBar(session: session) + .modifier(DetailPaneStatusBarMaterial()) } + // Behind the panes on every macOS version, as the main window's detail pane is: before + // macOS 26 the window color does not take the theme. + .themeBackground(.windowBackdrop, native: Color.clear) .frame(minWidth: 720, minHeight: 460) } @@ -74,10 +79,12 @@ internal struct CompareStatusStrip: View { if let target = session.target { Divider().frame(height: 14) HStack(spacing: 6) { - Circle() - .fill(target.color.indicatorColor ?? Color.secondary.opacity(0.35)) - .frame(width: 9, height: 9) - .accessibilityHidden(true) + ConnectionTile( + type: target.databaseType, + iconName: target.iconName, + identityColor: target.color.isDefault ? nil : target.color, + size: 16 + ) Text(String(format: String(localized: "Target: %@"), target.qualifiedDescription)) .font(.caption) .foregroundStyle(.secondary) diff --git a/TablePro/Views/Components/CodeWell.swift b/TablePro/Views/Components/CodeWell.swift index 193871a0d2..d7b9c2209b 100644 --- a/TablePro/Views/Components/CodeWell.swift +++ b/TablePro/Views/Components/CodeWell.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme /// 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. diff --git a/TablePro/Views/Components/HighlightedSQLTextView.swift b/TablePro/Views/Components/HighlightedSQLTextView.swift index e92117ef8d..16611ae0f3 100644 --- a/TablePro/Views/Components/HighlightedSQLTextView.swift +++ b/TablePro/Views/Components/HighlightedSQLTextView.swift @@ -6,10 +6,14 @@ import AppKit import Combine import SwiftUI +import TableProEditorKit +import TableProGrammars import TableProPluginKit +import TableProTheme -/// 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. +/// A code view: `ThemeTextViewStyle` puts it on the editor's background, text, caret and selection, and its +/// syntax comes from the editor's own grammar and `EditorTheme`, so every token draws in the slot the SQL +/// editor gives it. struct HighlightedSQLTextView: NSViewRepresentable { internal enum LineLayout: Equatable { case unwrapped @@ -46,11 +50,11 @@ struct HighlightedSQLTextView: NSViewRepresentable { textView.isSelectable = true textView.font = themeEngine.editorFonts.font textView.textContainerInset = NSSize(width: 12, height: 12) - textView.textColor = themeEngine.palette[.editorText] - applyBackground(to: scrollView, textView: textView) + textView.backgroundColor = .textBackgroundColor + textView.textColor = .textColor applyLineLayout(to: scrollView, textView: textView) - context.coordinator.redisplayOnThemeChange(scrollView) + context.coordinator.attach(to: scrollView, textView: textView) return scrollView } @@ -95,40 +99,36 @@ struct HighlightedSQLTextView: NSViewRepresentable { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? NSTextView else { return } + let coordinator = context.coordinator + // Compared with the coordinator's copy, since `textView.font` answers with the first character's font, + // which is bold when the text starts with a keyword. let editorFont = themeEngine.editorFonts.font - let fontChanged = textView.font != editorFont + let fontChanged = coordinator.font != editorFont let textChanged = textView.string != sql - if fontChanged { + coordinator.font = editorFont textView.font = editorFont } - applyBackground(to: scrollView, textView: textView) - if textChanged { textView.string = sql } + // A short query would otherwise show the scroll view's own color below its last line. + if scrollView.backgroundColor !== textView.backgroundColor { + scrollView.backgroundColor = textView.backgroundColor + } - /// The highlighting bakes a colour per range, so it is applied again whenever the theme, the - /// editor font or the highlighting moves, not only when the query does. - let highlightingChanged = context.coordinator.highlighting != highlighting + // The style rewrites the text color of every character when the theme changes, so the syntax is applied + // again on every change, after the style has run. + let change = themeEngine.change guard !sql.isEmpty, - textChanged || fontChanged || highlightingChanged || context.coordinator.revision != themeRevision else { - return - } - context.coordinator.revision = themeRevision - context.coordinator.highlighting = highlighting + textChanged || fontChanged || coordinator.highlighting != highlighting || coordinator.change != change + else { return } + coordinator.highlighting = highlighting + coordinator.change = change applyHighlighting(to: textView) } - /// The scroll view paints the same color, so a short query never shows another background below its last line. - private func applyBackground(to scrollView: NSScrollView, textView: NSTextView) { - let background = themeEngine.palette[.editorBackground] - guard textView.backgroundColor != background else { return } - textView.backgroundColor = background - scrollView.backgroundColor = background - } - private func applyLineLayout(to scrollView: NSScrollView, textView: NSTextView) { guard let container = textView.textContainer else { return } switch lineLayout { @@ -146,23 +146,22 @@ struct HighlightedSQLTextView: NSViewRepresentable { } } - private var themeRevision: Int { - themeEngine.revision - } - func makeCoordinator() -> Coordinator { Coordinator() } @MainActor final class Coordinator { - var revision = -1 - var highlighting: Highlighting? + fileprivate var font: NSFont? + fileprivate var highlighting: Highlighting? + fileprivate var change: ThemeChange? + private var themeStyle: ThemeTextViewStyle? private var themeCancellable: AnyCancellable? - /// An Accent, Highlight or contrast change moves no published value, so nothing would otherwise redraw the - /// system colors the text view holds. - func redisplayOnThemeChange(_ scrollView: NSScrollView) { + fileprivate func attach(to scrollView: NSScrollView, textView: NSTextView) { + themeStyle = ThemeTextViewStyle(textView: textView, natives: ThemeTextViewStyle.Natives(of: textView)) + scrollView.backgroundColor = textView.backgroundColor + // An Accent, Highlight or contrast change rewrites none of the colors the text view holds. themeCancellable = AppEvents.shared.themeChanged .receive(on: RunLoop.main) .sink { [weak scrollView] _ in @@ -174,135 +173,36 @@ struct HighlightedSQLTextView: NSViewRepresentable { // MARK: - Syntax Highlighting - private static let syntaxPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { - var patterns: [(NSRegularExpression, ThemeSlot)] = [] - - let keywords = [ - "CREATE", "TABLE", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", - "NOT", "NULL", "DEFAULT", "UNIQUE", "INDEX", "AUTO_INCREMENT", - "ON", "DELETE", "UPDATE", "CASCADE", "RESTRICT", "SET", - "INT", "INTEGER", "VARCHAR", "CHAR", "TEXT", "TIMESTAMP", "DATETIME", - "SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", - "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET", "INSERT", "INTO", - "VALUES", "DROP", "ALTER", "ADD", "COLUMN", "IF", "EXISTS", "AS", - "AND", "OR", "IN", "LIKE", "BETWEEN", "IS", "DISTINCT", "COUNT", - "SUM", "AVG", "MIN", "MAX", "CASE", "WHEN", "THEN", "ELSE", "END", - "UNION", "ALL", "WITH", "RECURSIVE" - ] - let keywordPattern = "\\b(" + keywords.joined(separator: "|") + ")\\b" - if let regex = try? NSRegularExpression(pattern: keywordPattern, options: .caseInsensitive) { - patterns.append((regex, .syntaxKeyword)) - } - - if let regex = try? NSRegularExpression(pattern: "'[^']*'", options: .caseInsensitive) { - patterns.append((regex, .syntaxString)) - } - - if let regex = try? NSRegularExpression(pattern: "`[^`]*`", options: .caseInsensitive) { - patterns.append((regex, .syntaxNull)) - } - - if let regex = try? NSRegularExpression(pattern: "\\b\\d+\\b", options: .caseInsensitive) { - patterns.append((regex, .syntaxNumber)) - } - - return patterns - }() - - private static let mqlPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { - var patterns: [(NSRegularExpression, ThemeSlot)] = [] - - let methods = [ - "find", "findOne", "insertOne", "insertMany", "updateOne", "updateMany", - "deleteOne", "deleteMany", "aggregate", "countDocuments", "estimatedDocumentCount", - "distinct", "createIndex", "dropIndex", "sort", "limit", "skip", "project", - "match", "group", "unwind", "lookup", "replaceOne", "drop" - ] - let methodPattern = "\\.(" + methods.joined(separator: "|") + ")\\s*\\(" - if let regex = try? NSRegularExpression(pattern: methodPattern, options: []) { - patterns.append((regex, .syntaxKeyword)) - } - - if let regex = try? NSRegularExpression(pattern: "\\bdb\\.", options: []) { - patterns.append((regex, .syntaxKeyword)) - } - - 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|" - + "unwind|lookup|count|sum|avg|min|max|first|last|dateToString|toString|toInt|" - + "oid|numberInt|numberLong|numberDouble|date|binary|timestamp|numberDecimal)\"", - options: [] - ) { - patterns.append((regex, .syntaxType)) - } - - if let regex = try? NSRegularExpression(pattern: "\"[^\"]*\"", options: []) { - patterns.append((regex, .syntaxString)) - } - - if let regex = try? NSRegularExpression(pattern: "\\b\\d+\\.?\\d*\\b", options: []) { - patterns.append((regex, .syntaxNumber)) - } - - if let regex = try? NSRegularExpression(pattern: "\\b(true|false|null)\\b", options: []) { - patterns.append((regex, .syntaxNull)) - } - - return patterns - }() - - /// The JSON editor's colors: keys and values both as strings. Strings go last so a digit or a literal inside one stays string-colored. - private static let jsonPatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] = { - let sources: [(pattern: String, slot: ThemeSlot)] = [ - (#"-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b"#, .syntaxNumber), - (#"\b(?:true|false|null)\b"#, .syntaxNull), - (#""(?:[^"\\]|\\.)*""#, .syntaxString), - ] - return sources.compactMap { source in - (try? NSRegularExpression(pattern: source.pattern)).map { (regex: $0, slot: source.slot) } - } - }() - - private var activePatterns: [(regex: NSRegularExpression, slot: ThemeSlot)] { - if highlighting == .json { return Self.jsonPatterns } - switch PluginManager.shared.editorLanguage(for: databaseType) { - case .javascript: - return Self.mqlPatterns - default: - return Self.syntaxPatterns + private var language: CodeLanguage { + switch highlighting { + case .json: + return .json + case .engine: + return PluginManager.shared.editorLanguage(for: databaseType).treeSitterLanguage } } private func applyHighlighting(to textView: NSTextView) { - guard let textStorage = textView.textStorage else { return } - guard textStorage.length > 0 else { return } + guard let textStorage = textView.textStorage, textStorage.length > 0 else { return } - let fullRange = NSRange(location: 0, length: textStorage.length) + let theme = themeEngine.makeEditorTheme() + let font = themeEngine.editorFonts.font + let captures = CodeBlockHighlighter.captures(in: textStorage.string, language: language) textStorage.beginEditing() - - let font = ThemeEngine.shared.editorFonts.font - textStorage.addAttribute(.font, value: font, range: fullRange) - textStorage.addAttribute(.foregroundColor, value: ThemeEngine.shared.palette[.editorText], range: fullRange) - - let text = textStorage.string - let maxHighlightLength = 10_000 - let highlightRange: NSRange - if textStorage.length > maxHighlightLength { - highlightRange = NSRange(location: 0, length: maxHighlightLength) - } else { - highlightRange = fullRange - } - let palette = ThemeEngine.shared.palette - - for (regex, slot) in activePatterns { - let matches = regex.matches(in: text, options: [], range: highlightRange) - for match in matches { - textStorage.addAttribute(.foregroundColor, value: palette[slot], range: match.range) - } + textStorage.addAttributes( + [.font: font, .foregroundColor: theme.text.color], + range: NSRange(location: 0, length: textStorage.length) + ) + for capture in captures { + textStorage.addAttributes( + [ + .font: theme.fontFor(for: capture.capture, from: font), + .foregroundColor: theme.colorFor(capture.capture), + ], + range: capture.range + ) } - textStorage.endEditing() } } diff --git a/TablePro/Views/Components/MagnifiableCanvasView.swift b/TablePro/Views/Components/MagnifiableCanvasView.swift index a474e9151a..73941228d3 100644 --- a/TablePro/Views/Components/MagnifiableCanvasView.swift +++ b/TablePro/Views/Components/MagnifiableCanvasView.swift @@ -11,6 +11,7 @@ import AppKit import Combine import SwiftUI +import TableProTheme @MainActor final class DiagramViewportController: ObservableObject { diff --git a/TablePro/Views/Components/SQLStatementPreview.swift b/TablePro/Views/Components/SQLStatementPreview.swift index dab19a944c..afe9e11f9a 100644 --- a/TablePro/Views/Components/SQLStatementPreview.swift +++ b/TablePro/Views/Components/SQLStatementPreview.swift @@ -9,12 +9,8 @@ import TableProEditorKit import TableProGrammars import TableProPluginKit -/// Read-only statement text, rendered the one way the app renders statement text. -/// -/// Extracted from `SQLReviewSheet` rather than copied, because the choice between the tree-sitter -/// editor and the plain highlighted text view is a policy about statement length and language, not -/// a detail of the review sheet: a second surface that picked differently would show the same -/// `CREATE SCHEMA` two ways. +/// One owner for the choice between the editor and the plain text view, so two surfaces never show the +/// same statement two ways. struct SQLStatementPreview: View { @ObservedObject private var settingsManager = AppSettingsManager.shared let prepared: SQLReviewSheet.Prepared @@ -57,10 +53,7 @@ struct SQLStatementPreview: View { ) } - /// A text view rather than a `Text` in a `ScrollView`: this path carries everything past the - /// tree-sitter cutoff, which for a confirmation is the whole statement however long it is, and - /// a single `Text` that size lays out for seconds. It wears the editor's own background, text - /// colour and syntax palette, which is what a hand-themed `Text` here was reaching for. + /// A text view, since this path carries a statement of any length and a `Text` that size lays out for seconds. private func plainTextEditor(_ text: String) -> some View { HighlightedSQLTextView(sql: text, databaseType: databaseType) .clipShape(RoundedRectangle(cornerRadius: 6)) @@ -74,7 +67,7 @@ struct SQLStatementPreview: View { SourceEditorConfiguration( appearance: .init( theme: TableProEditorTheme.make(), - font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular), + font: ThemeEngine.shared.editorFonts.font, wrapLines: true ), behavior: .init(isEditable: false), diff --git a/TablePro/Views/Connection/DeeplinkImportSheet.swift b/TablePro/Views/Connection/DeeplinkImportSheet.swift index f303c5820f..a900a07544 100644 --- a/TablePro/Views/Connection/DeeplinkImportSheet.swift +++ b/TablePro/Views/Connection/DeeplinkImportSheet.swift @@ -194,10 +194,7 @@ struct DeeplinkImportSheet: View { .foregroundStyle(.orange) .font(.callout) - Text(startupCommands) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + CodeWell(startupCommands, font: .system(.caption, design: .monospaced)) } header: { Text("Startup SQL") } diff --git a/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift b/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift index 4d1ac80635..f289ead127 100644 --- a/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/Transports/TunnelCommandTransportSections.swift @@ -136,10 +136,7 @@ struct TunnelCommandTransportSections: View { private var previewSection: some View { if let preview = coordinator.tunnelCommandPreview { Section { - Text(verbatim: preview) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + CodeWell(preview, font: .system(.caption, design: .monospaced)) } header: { Text("Will Run") } diff --git a/TablePro/Views/DataFiles/DataFileContentView.swift b/TablePro/Views/DataFiles/DataFileContentView.swift index cb5073255f..7aa19bd116 100644 --- a/TablePro/Views/DataFiles/DataFileContentView.swift +++ b/TablePro/Views/DataFiles/DataFileContentView.swift @@ -5,6 +5,7 @@ import SwiftUI import TableProTabular +import TableProTheme struct DataFileContentView: View { @ObservedObject var controller: DataFileController @@ -37,9 +38,11 @@ struct DataFileContentView: View { switch controller.loadState { case .idle, .loading: loadingView + .gridStandInBackground() case .failed(let message): UnavailableStateView(String(localized: "This file can’t be opened"), systemImage: "exclamationmark.triangle", description: Text(message)) .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() case .loaded: ZStack { grid diff --git a/TablePro/Views/DataFiles/DataFileWindowController.swift b/TablePro/Views/DataFiles/DataFileWindowController.swift index 05c5ca60f7..d558fda84b 100644 --- a/TablePro/Views/DataFiles/DataFileWindowController.swift +++ b/TablePro/Views/DataFiles/DataFileWindowController.swift @@ -27,7 +27,7 @@ final class DataFileWindowController: NSWindowController, NSWindowDelegate, NSTo private let windowBackdrop: ThemeWindowBackdrop init(document: DataFileDocument) { - let window = NSWindow( + let window = ContentWindow( contentRect: NSRect(x: 0, y: 0, width: 1_100, height: 680), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, diff --git a/TablePro/Views/ERDiagram/ERDiagramPalette.swift b/TablePro/Views/ERDiagram/ERDiagramPalette.swift index ea35a80962..ea6b9006cf 100644 --- a/TablePro/Views/ERDiagram/ERDiagramPalette.swift +++ b/TablePro/Views/ERDiagram/ERDiagramPalette.swift @@ -1,4 +1,5 @@ import AppKit +import TableProTheme /// Key, link and cluster hues carry meaning, so they stay system colors under a theme. @MainActor @@ -6,7 +7,8 @@ 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. + /// Nil keeps the accent wash a table header has always had, also while the theme draws the + /// header in the body's color, as the derived header does. internal let headerFill: CGColor? internal let rule: CGColor internal let selectionRule: CGColor @@ -32,9 +34,7 @@ internal struct ERDiagramPalette { 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 + headerFill = Self.headerFill(of: theme, in: appearance) 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) @@ -56,6 +56,13 @@ internal struct ERDiagramPalette { return clusterWashes[cluster] ?? Self.wash(cluster, alpha: Self.clusterWashAlpha, in: appearance) } + private static func headerFill(of theme: ThemeDefinition, in appearance: NSAppearance) -> CGColor? { + let highContrast = appearance.isHighContrast + guard let header = theme.value(for: .gridHeaderBackground, highContrast: highContrast).themedColor, + header != theme.value(for: .gridBackground, highContrast: highContrast).themedColor else { return nil } + return bake(header.nsColor, in: appearance) + } + private static func bake(_ color: NSColor, in appearance: NSAppearance) -> CGColor { var result: CGColor? appearance.performAsCurrentDrawingAppearance { diff --git a/TablePro/Views/Editor/History/HistoryListPane.swift b/TablePro/Views/Editor/History/HistoryListPane.swift index 59702920c7..c1321c5b52 100644 --- a/TablePro/Views/Editor/History/HistoryListPane.swift +++ b/TablePro/Views/Editor/History/HistoryListPane.swift @@ -96,7 +96,7 @@ struct HistoryListPane: View { } .padding(.vertical, 6) } - .background(.bar) + .paneBarBackground() } private var emptyState: some View { diff --git a/TablePro/Views/Editor/History/HistoryPanelToolbar.swift b/TablePro/Views/Editor/History/HistoryPanelToolbar.swift index c91130a305..5eeb4551eb 100644 --- a/TablePro/Views/Editor/History/HistoryPanelToolbar.swift +++ b/TablePro/Views/Editor/History/HistoryPanelToolbar.swift @@ -34,7 +34,7 @@ struct HistoryPanelToolbar: View { Divider() } - .background(.bar) + .paneBarBackground() .onChange(of: state.searchText) { _ in viewModel.scheduleSearchReload() } diff --git a/TablePro/Views/Editor/QueryEditorBar.swift b/TablePro/Views/Editor/QueryEditorBar.swift index c4dbdc57a5..7418150171 100644 --- a/TablePro/Views/Editor/QueryEditorBar.swift +++ b/TablePro/Views/Editor/QueryEditorBar.swift @@ -73,7 +73,7 @@ struct QueryEditorBar: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(.bar) + .paneBarBackground() .accessibilityIdentifier("query-editor-bar") } diff --git a/TablePro/Views/Editor/QueryParameterPanelView.swift b/TablePro/Views/Editor/QueryParameterPanelView.swift index 0d1aec9ce0..67aea4cb07 100644 --- a/TablePro/Views/Editor/QueryParameterPanelView.swift +++ b/TablePro/Views/Editor/QueryParameterPanelView.swift @@ -34,7 +34,7 @@ struct QueryParameterPanelView: View { parameterList } } - .background(Color(nsColor: .windowBackgroundColor)) + .paneBarBackground(Color(nsColor: .windowBackgroundColor)) } private var panelHeader: some View { @@ -62,7 +62,7 @@ struct QueryParameterPanelView: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } private var parameterRows: some View { diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index a90f61c2b2..e69efe1545 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -31,7 +31,7 @@ struct FilterPanelView: View { filterList } } - .themeBackground(.windowBackdrop, native: Color(nsColor: .windowBackgroundColor)) + .paneBarBackground(Color(nsColor: .windowBackgroundColor), ignoresSafeAreaEdges: .all) .focusSection() .onExitCommand { closePanelAndFocusGrid() @@ -104,7 +104,7 @@ struct FilterPanelView: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .themeBackground(.windowBackdrop, native: Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor), ignoresSafeAreaEdges: .all) .contentShape(Rectangle()) .alert(String(localized: "Save Filter Preset"), isPresented: $showSavePresetAlert) { TextField(String(localized: "Preset Name"), text: $newPresetName) diff --git a/TablePro/Views/Main/Child/GridStandInBackground.swift b/TablePro/Views/Main/Child/GridStandInBackground.swift index e101836d2f..0132b9b2dc 100644 --- a/TablePro/Views/Main/Child/GridStandInBackground.swift +++ b/TablePro/Views/Main/Child/GridStandInBackground.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme internal extension QueryResultContent { /// A diagram, chart, map, tree or structure view draws its own surface. diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 883499d108..a8b3b024e7 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -32,47 +32,6 @@ 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 @@ -174,7 +133,6 @@ struct MainEditorContentView: View { HistoryPanelView(coordinator: coordinator) } ) - .modifier(DetailPaneBackdropFill()) .onAppear { if historyState.isVisible { if #available(macOS 14.0, *) { diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index f6c2842faf..139e1badf3 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -496,21 +496,21 @@ private struct EditorTabStripNewButton: 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). +/// Opaque over the system window, so the track stays below the selected tab whatever is behind the window. Over a +/// themed window it is a translucent system fill, since the color behind it is then the theme's and known. internal struct EditorTabStripTrackSurface: ViewModifier { - /// The fill reads the theme's window color, which an edit in place changes without rebuilding the strip. + /// Whether the window color is the theme's changes with an edit in place, which does not rebuild the strip. @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast internal let rowCount: Int internal func body(content: Content) -> some View { let shape = EditorTabStripLayout.trackShape(forRowCount: rowCount) + let isOverThemedBackdrop = EditorTabStripPalette.isOverThemedBackdrop(highContrast: contrast == .increased) return content.background( shape - .fill(EditorTabStripPalette.trackFill) + .fill(EditorTabStripPalette.trackFill(overThemedBackdrop: isOverThemedBackdrop)) .overlay( shape .stroke( diff --git a/TablePro/Views/Main/EditorTabStripSurfaces.swift b/TablePro/Views/Main/EditorTabStripSurfaces.swift index b3f2ace3a6..db8ee85625 100644 --- a/TablePro/Views/Main/EditorTabStripSurfaces.swift +++ b/TablePro/Views/Main/EditorTabStripSurfaces.swift @@ -13,23 +13,30 @@ import SwiftUI /// measures a track of rgb(232) and a selected tab of rgb(253) in light, and rgb(71) and rgb(74) /// in dark. These resolve to rgb(220) and rgb(255) in light, and rgb(70) and rgb(116) in dark. 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. - @MainActor internal static var trackFill: Color { - Color(nsColor: trackColor(followsWindowBackdrop: followsWindowBackdrop)) + internal static func trackFill(overThemedBackdrop: Bool) -> Color { + Color(nsColor: trackColor(overThemedBackdrop: overThemedBackdrop)) } - /// The track sits in the titlebar, which takes the theme's window color only where `ThemeWindowBackdrop` sets it. + /// The track sits in the titlebar, which shows 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 { + internal static func isOverThemedBackdrop(highContrast: Bool) -> Bool { + followsWindowBackdrop && ThemeBackdropState.isThemed(highContrast: highContrast) + } + + /// The system track measures rgb(220) light and rgb(70) dark against `NSTabBar`'s rgb(232) and rgb(71). Over a + /// themed window that opaque gray cuts a slab into the theme, while the window color itself leaves the selected + /// tab a step of 1.00 to 1.08 under a light theme. A translucent system fill keeps the theme's hue and a step of + /// 1.19 to 1.29 in light and 2.1 in dark, the closest of the system fills to the unthemed 1.37 and 2.02. + internal static func trackColor(overThemedBackdrop: Bool) -> NSColor { let native = NSColor.unemphasizedSelectedContentBackgroundColor - guard followsWindowBackdrop else { return native } - return engine.palette.color(.windowBackdrop, otherwise: native) + guard overThemedBackdrop else { return native } + if #available(macOS 14.0, *) { return .secondarySystemFill } + return native } /// Opaque white in light, and white at alpha 0.247 in dark. That asymmetry is the whole reason diff --git a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift index 863f9c5cb0..5b15cb7d46 100644 --- a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift +++ b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift @@ -8,6 +8,7 @@ import Combine import SwiftUI import TableProPluginKit +import TableProTheme @MainActor final class ObjectSourceLoader: ObservableObject { @@ -176,7 +177,7 @@ struct ObjectSourceTabView: View { } .padding(.horizontal) .padding(.vertical, 8) - .background(.bar) + .paneBarBackground() } @ViewBuilder diff --git a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift index 8470242aa3..a4286d60fd 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift @@ -1,10 +1,14 @@ import Charts import SwiftUI +import TableProTheme struct QueryInsightsActivityChart: View { let buckets: [QueryInsightsActivityBucket] let granularity: QueryInsightsGranularity + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + private enum Outcome: String, Plottable { case succeeded case failed @@ -35,8 +39,11 @@ struct QueryInsightsActivityChart: View { } } + // A chart plot takes the grid's lines and background, as the result chart does. Unthemed it keeps + // the default grid line and no plot fill. var body: some View { - QueryInsightsSection( + let gridLine = ThemeShapeStyle.override(.gridLine, engine: themeEngine, contrast: contrast) + return QueryInsightsSection( title: String(localized: "Activity"), systemImage: "chart.bar.xaxis", isEmpty: buckets.isEmpty, @@ -58,7 +65,11 @@ struct QueryInsightsActivityChart: View { .chartLegend(position: .top, alignment: .trailing, spacing: 8) .chartYAxis { AxisMarks(position: .leading) { value in - AxisGridLine() + if let gridLine { + AxisGridLine().foregroundStyle(gridLine) + } else { + AxisGridLine() + } AxisValueLabel { if let count = value.as(Int.self) { Text(count.formatted()) @@ -69,10 +80,17 @@ struct QueryInsightsActivityChart: View { } .chartXAxis { AxisMarks(values: .automatic(desiredCount: 6)) { _ in - AxisGridLine() + if let gridLine { + AxisGridLine().foregroundStyle(gridLine) + } else { + AxisGridLine() + } AxisValueLabel(format: axisFormat, centered: false) } } + .chartPlotStyle { plotArea in + plotArea.themeBackground(.gridBackground, native: Color.clear, in: .rect(cornerRadius: 8)) + } .frame(height: 180) .padding(.top, 4) } diff --git a/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift b/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift index ec3ddfad92..974145e9a7 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift @@ -31,7 +31,7 @@ struct QueryInsightsToolbar: View { Divider() } - .background(.bar) + .paneBarBackground() } private var scopePicker: some View { diff --git a/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift b/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift index ff58040dc8..33f4f3cab2 100644 --- a/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift +++ b/TablePro/Views/QueryPlan/QueryPlanDetailPane.swift @@ -6,6 +6,7 @@ // import SwiftUI +import TableProTheme struct QueryPlanDetailPane: View { let node: QueryPlanNode? diff --git a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift index 9bde2d9607..0bd13ab623 100644 --- a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import TableProTheme struct QueryPlanDiagramView: View { @Binding var selectedNodeId: UUID? diff --git a/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/TablePro/Views/QueryPlan/QueryPlanResultView.swift index fbc06f9b2c..4c47fa4b84 100644 --- a/TablePro/Views/QueryPlan/QueryPlanResultView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -174,7 +174,7 @@ struct QueryPlanResultView: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } // MARK: - Toolbar @@ -231,7 +231,7 @@ struct QueryPlanResultView: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } /// Which earlier run this plan is measured against. It sits in the pane's own bar beside the diff --git a/TablePro/Views/Results/ArrayJsonElementEditor.swift b/TablePro/Views/Results/ArrayJsonElementEditor.swift index e69e5ea9fc..7ed0b12a1b 100644 --- a/TablePro/Views/Results/ArrayJsonElementEditor.swift +++ b/TablePro/Views/Results/ArrayJsonElementEditor.swift @@ -100,6 +100,7 @@ internal struct ArrayJsonElementEditor: View { } description: { Text("Select an element to read or edit its JSON.") } + .valueStandInBackground() } } @@ -124,6 +125,7 @@ internal struct ArrayJsonElementEditor: View { .font(themeEngine.valueFontSwiftUI) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) + .valueStandInBackground() } private func elementLabel(for row: ArrayEditorRow) -> String { diff --git a/TablePro/Views/Results/ArrayValueEditorView.swift b/TablePro/Views/Results/ArrayValueEditorView.swift index 26b1a9e420..7bb0f540a0 100644 --- a/TablePro/Views/Results/ArrayValueEditorView.swift +++ b/TablePro/Views/Results/ArrayValueEditorView.swift @@ -223,11 +223,18 @@ struct ArrayValueEditorView: View { ) } + /// `TextEditor` left quote and dash substitution on, which rewrote the literal. No inset, as + /// `TextEditor` laid it out. private var rawTextEditor: some View { VStack(alignment: .leading, spacing: 4) { - TextEditor(text: $rawText) - .font(themeEngine.valueFontSwiftUI) - .frame(minHeight: 90) + TextValueEditor( + text: $rawText, + isEditable: !(isNull || isReadOnly), + font: themeEngine.valueFont, + textContainerInset: .zero, + style: .code + ) + .frame(minHeight: 90) if PostgresArrayLiteralCodec.parse(rawText, delimiter: delimiter) == nil { Label { Text("This is not a value the list editor can read back.") diff --git a/TablePro/Views/Results/CellOverlayBase.swift b/TablePro/Views/Results/CellOverlayBase.swift index 111cdca760..9bc038ec2e 100644 --- a/TablePro/Views/Results/CellOverlayBase.swift +++ b/TablePro/Views/Results/CellOverlayBase.swift @@ -5,6 +5,7 @@ import AppKit import Combine +import TableProTheme enum CellOverlayDismissReason { case userAction @@ -24,6 +25,9 @@ class CellOverlayBase: NSObject { private var appResignObserver: NSObjectProtocol? private var windowResignKeyObserver: NSObjectProtocol? private var outsideClickMonitor: Any? + /// Held for the life of the open overlay: its subscription is what repaints the text on a theme + /// change. + private var textStyle: ThemeTextViewStyle? var onRemove: (() -> Void)? private(set) var row: Int = -1 @@ -85,6 +89,7 @@ class CellOverlayBase: NSObject { } activeContainer.removeFromSuperview() container = nil + textStyle = nil if let hostTableView { hostTableView.window?.makeFirstResponder(hostTableView) } @@ -103,16 +108,28 @@ class CellOverlayBase: NSObject { return NSRect(x: cellFrame.origin.x, y: cellFrame.origin.y, width: cellFrame.width, height: height) } - static func makeContainer(frame: NSRect) -> CellOverlayContainerView { + /// The grid's own scope, so an overlay over the theme editor's preview wears the draft. + static func themeScope(of tableView: NSTableView) -> ThemeScope { + (tableView as? KeyHandlingTableView)?.themeScope ?? .live + } + + static func makeContainer(frame: NSRect, scope: ThemeScope) -> CellOverlayContainerView { let container = CellOverlayContainerView(frame: frame) container.wantsLayer = true container.layer?.borderWidth = 2 container.layer?.cornerRadius = 2 container.layer?.masksToBounds = true + container.scope = scope container.applyLayerColors() return container } + /// A value is content, so the box takes the editor's colors, caret and selection included, the + /// way the inspector and the pop-out editor show the same value. + func applyTheme(to textView: NSTextView, scope: ThemeScope) { + textStyle = ThemeTextViewStyle(textView: textView, scope: scope) + } + /// Lays a text view out the way an inline cell overlay needs. /// /// A cell holds one value, so the overlay behaves like a field editor and scrolls a long line @@ -135,7 +152,7 @@ class CellOverlayBase: NSObject { textView.textContainer?.containerSize = unbounded } - static func makeScrollView(in container: NSView) -> NSScrollView { + static func makeScrollView(in container: NSView, scope: ThemeScope) -> NSScrollView { let scrollView = NSScrollView(frame: container.bounds) scrollView.autoresizingMask = [.width, .height] scrollView.hasVerticalScroller = true @@ -143,7 +160,7 @@ class CellOverlayBase: NSObject { scrollView.autohidesScrollers = true scrollView.borderType = .noBorder scrollView.drawsBackground = true - scrollView.backgroundColor = .textBackgroundColor + scrollView.backgroundColor = scope.palette.color(.editorBackground, otherwise: .textBackgroundColor) return scrollView } @@ -238,13 +255,25 @@ class CellOverlayBase: NSObject { final class CellOverlayContainerView: NSView { override var isFlipped: Bool { true } + var scope: ThemeScope = .live { + didSet { + guard scope !== oldValue else { return } + themeCancellable = nil + subscribeIfInWindow() + applyLayerColors() + } + } + private var themeCancellable: AnyCancellable? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - guard window != nil, themeCancellable == nil else { return } + subscribeIfInWindow() + } - themeCancellable = AppEvents.shared.themeChanged + private func subscribeIfInWindow() { + guard window != nil, themeCancellable == nil else { return } + themeCancellable = scope.changes .receive(on: RunLoop.main) .sink { [weak self] _ in self?.applyLayerColors() } } @@ -258,9 +287,8 @@ final class CellOverlayContainerView: NSView { /// colours are reapplied whenever the appearance or the theme changes under an open overlay. func applyLayerColors() { let appearance = effectiveAppearance - layer?.borderColor = ThemeEngine.shared.resolved.cgColor(.gridFocusBorder, in: appearance) - appearance.performAsCurrentDrawingAppearance { - layer?.backgroundColor = NSColor.textBackgroundColor.cgColor - } + let resolved = scope.resolved + layer?.borderColor = resolved.cgColor(.gridFocusBorder, in: appearance) + layer?.backgroundColor = resolved.cgColor(.editorBackground, otherwise: .textBackgroundColor, in: appearance) } } diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 2c3fc8b532..25b72b5281 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -27,8 +27,9 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate { guard let window = tableView.window else { return } let frame = Self.overlayFrame(for: cellFrame, value: value) - let containerView = Self.makeContainer(frame: frame) - let scrollView = Self.makeScrollView(in: containerView) + let scope = Self.themeScope(of: tableView) + let containerView = Self.makeContainer(frame: frame, scope: scope) + let scrollView = Self.makeScrollView(in: containerView, scope: scope) let textView = OverlayTextView(frame: scrollView.bounds) textView.overlayEditor = self @@ -38,6 +39,7 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate { textView.font = ThemeEngine.shared.valueFont textView.textColor = .labelColor textView.backgroundColor = .textBackgroundColor + applyTheme(to: textView, scope: scope) textView.focusRingType = .none Self.applyCellTextLayout(to: textView) textView.delegate = self diff --git a/TablePro/Views/Results/CellOverlayViewer.swift b/TablePro/Views/Results/CellOverlayViewer.swift index de7f8d5019..14f80993a8 100644 --- a/TablePro/Views/Results/CellOverlayViewer.swift +++ b/TablePro/Views/Results/CellOverlayViewer.swift @@ -21,8 +21,9 @@ final class CellOverlayViewer: CellOverlayBase, NSTextViewDelegate { guard let window = tableView.window else { return } let frame = Self.overlayFrame(for: cellFrame, value: value) - let containerView = Self.makeContainer(frame: frame) - let scrollView = Self.makeScrollView(in: containerView) + let scope = Self.themeScope(of: tableView) + let containerView = Self.makeContainer(frame: frame, scope: scope) + let scrollView = Self.makeScrollView(in: containerView, scope: scope) let textView = NSTextView(frame: scrollView.bounds) textView.isEditable = false @@ -31,6 +32,7 @@ final class CellOverlayViewer: CellOverlayBase, NSTextViewDelegate { textView.font = ThemeEngine.shared.valueFont textView.textColor = .labelColor textView.backgroundColor = .textBackgroundColor + applyTheme(to: textView, scope: scope) Self.applyCellTextLayout(to: textView) textView.delegate = self textView.string = value diff --git a/TablePro/Views/Results/Cells/DataGridCellAccessoryGlyph.swift b/TablePro/Views/Results/Cells/DataGridCellAccessoryGlyph.swift index ce2929bbb5..100ff0737e 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAccessoryGlyph.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAccessoryGlyph.swift @@ -71,6 +71,8 @@ enum DataGridCellAccessoryGlyph { struct Glyph { let image: CGImage let pointSize: NSSize + /// Held so the color cannot be freed and its address reused while the bitmap is cached. + fileprivate let color: NSColor } private struct Key: Hashable { @@ -83,24 +85,24 @@ enum DataGridCellAccessoryGlyph { private static var glyphsRevision: Int? /// Rasterizing resolves the dynamic symbol color, so a cached bitmap belongs to exactly one - /// appearance and one theme revision. Keying on the appearance is what keeps a dark window from - /// being served the light bitmap, and `NSAppearance.currentDrawing()` only reports the drawing + /// appearance and one color. Keying on the appearance is what keeps a dark window from being + /// served the light bitmap, and `NSAppearance.currentDrawing()` only reports the drawing /// appearance while AppKit is inside `draw(_:)`, `updateLayer` or `layout`. - static func image(for role: Role) -> Glyph? { - let engine = ThemeEngine.shared - if glyphsRevision != engine.revision { + /// The color is compared by identity on a hit, a pointer compare, since the palette hands every + /// cell the same object; a grid on another scope still gets its own bitmap. + static func image(for role: Role, color: NSColor) -> Glyph? { + let revision = ThemeEngine.shared.revision + if glyphsRevision != revision { glyphs.removeAll() - glyphsRevision = engine.revision + glyphsRevision = revision } let key = Key( role: role, appearance: NSAppearance.currentDrawing().name, increasedContrast: NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast ) - if let cached = glyphs[key] { return cached } - guard let glyph = make(role, color: role.color(selectionText: engine.resolved[.gridSelectionText])) else { - return nil - } + if let cached = glyphs[key], cached.color === color { return cached } + guard let glyph = make(role, color: color) else { return nil } glyphs[key] = glyph return glyph } @@ -112,7 +114,7 @@ enum DataGridCellAccessoryGlyph { .withSymbolConfiguration(config) else { return nil } var rect = CGRect(origin: .zero, size: image.size) guard let cgImage = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) else { return nil } - return Glyph(image: cgImage, pointSize: image.size) + return Glyph(image: cgImage, pointSize: image.size, color: color) } /// A symbol stretched to fill the accessory rect stops looking like a system symbol, so the diff --git a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift index 4c7cbbcf25..42ae8e2b18 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift @@ -24,6 +24,7 @@ struct DataGridCellAppearance: Equatable { /// Which symbol the accessory draws, resolved here because it follows the row's state rather /// than anything the renderer can see. let accessoryRole: DataGridCellAccessoryGlyph.Role? + let accessoryColor: NSColor? /// The cell cursor on a selected row, which the selection fill would swallow a focus ring behind. let drawsFocusBorder: Bool /// The cell cursor on an unselected row. @@ -39,7 +40,8 @@ struct DataGridCellAppearance: Equatable { nullDisplayString: String, onEmphasizedSelection: Bool, hasOverlay: Bool, - checkboxMark: DataGridCheckboxMark? = nil + checkboxMark: DataGridCheckboxMark? = nil, + textTone: NSColor? = nil ) -> DataGridCellAppearance { let deletedTextColor = state.visualState.isDeleted ? palette.deletedRowText : nil let font: NSFont @@ -52,7 +54,7 @@ struct DataGridCellAppearance: Equatable { baseColor = deletedTextColor ?? .systemBlue case .none: font = palette.regularFont - baseColor = deletedTextColor ?? valueTextColor(kind: kind, rawValue: content.rawValue, palette: palette) + baseColor = deletedTextColor ?? textTone ?? valueTextColor(kind: kind, rawValue: content.rawValue, palette: palette) case .null, .empty, .absent: font = palette.italicFont baseColor = deletedTextColor ?? palette.placeholderText @@ -91,6 +93,11 @@ struct DataGridCellAppearance: Equatable { rawValue: content.rawValue ) let isCursorVisible = state.isFocused && !hasOverlay + let accessoryRole = DataGridCellAccessoryGlyph.Role( + accessory: accessory, + isEmphasized: onEmphasizedSelection, + isDisabled: state.visualState.isDeleted + ) return DataGridCellAppearance( text: kind == .checkbox ? "" : DataGridCellContent.resolvedDisplayText( content.displayText, @@ -103,11 +110,8 @@ struct DataGridCellAppearance: Equatable { backgroundTint: backgroundTint, textMark: DataGridCellTextMark.resolve(state: state.visualState, columnIndex: state.columnIndex), accessory: accessory, - accessoryRole: DataGridCellAccessoryGlyph.Role( - accessory: accessory, - isEmphasized: onEmphasizedSelection, - isDisabled: state.visualState.isDeleted - ), + accessoryRole: accessoryRole, + accessoryColor: accessoryRole?.color(selectionText: palette.selectionText), drawsFocusBorder: isCursorVisible && onEmphasizedSelection, drawsFocusRing: isCursorVisible && !onEmphasizedSelection, focusColor: onEmphasizedSelection ? palette.selectionText : palette.focusRing, diff --git a/TablePro/Views/Results/Cells/DataGridCellPalette.swift b/TablePro/Views/Results/Cells/DataGridCellPalette.swift index 1acae469dc..4808166b56 100644 --- a/TablePro/Views/Results/Cells/DataGridCellPalette.swift +++ b/TablePro/Views/Results/Cells/DataGridCellPalette.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProTheme @MainActor struct DataGridCellPalette: Equatable { @@ -84,29 +85,34 @@ struct DataGridCellPalette: Equatable { } } -extension ThemeEngine { +extension ThemeScope { /// The row-state tints are dynamic providers, which cost four times a catalog color on every /// fill and `CTLineDraw`, so they are resolved here once in the appearance the grid draws in. - /// The catalog colors stay dynamic and resolve at draw time. - func dataGridCellPalette(in appearance: NSAppearance) -> DataGridCellPalette { - DataGridCellPalette( - regularFont: dataGridFonts.regular, - italicFont: dataGridFonts.italic, - mediumFont: dataGridFonts.medium, + /// The catalog colors stay dynamic and resolve at draw time. Fonts are the user's typography, + /// not the theme's, so every scope draws in the engine's. + func dataGridCellPalette( + in appearance: NSAppearance, + fonts: DataGridFontCache = ThemeEngine.shared.dataGridFonts + ) -> DataGridCellPalette { + let resolved = resolved + return DataGridCellPalette( + regularFont: fonts.regular, + italicFont: fonts.italic, + mediumFont: fonts.medium, text: resolved[.gridText], placeholderText: resolved[.gridNullValue], booleanTrueText: resolved[.gridBoolTrue], booleanFalseText: resolved[.gridBoolFalse], rowNumberText: resolved[.gridRowNumber], - deletedRowText: resolvedTint(.gridDeletedText, in: appearance), - modifiedColumnTint: resolvedTint(.gridModified, in: appearance), + deletedRowText: Self.resolvedTint(.gridDeletedText, of: resolved, in: appearance), + modifiedColumnTint: Self.resolvedTint(.gridModified, of: resolved, in: appearance), findMatchTint: .findHighlightColor, selectionText: resolved[.gridSelectionText], focusRing: resolved[.gridFocusBorder] ) } - private func resolvedTint(_ slot: ThemeSlot, in appearance: NSAppearance) -> NSColor { + private static func resolvedTint(_ slot: ThemeSlot, of resolved: ResolvedTheme, in appearance: NSAppearance) -> NSColor { guard !resolved.isOverridden(slot) else { return resolved[slot] } return NSColor(cgColor: resolved.cgColor(slot, in: appearance)) ?? resolved[slot] } diff --git a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift index 9deca9373f..aea60162ea 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -13,6 +13,7 @@ final class DataGridCellRegistry { private(set) var nullDisplayString: String private(set) var palette: DataGridCellPalette + private(set) var scope: ThemeScope private var paletteKey: PaletteKey private var settingsCancellable: AnyCancellable? @@ -24,11 +25,12 @@ final class DataGridCellRegistry { let appearance: NSAppearance.Name } - init() { + init(scope: ThemeScope = .live) { + self.scope = scope nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay let appearance = NSApp?.effectiveAppearance ?? NSAppearance.currentDrawing() paletteKey = Self.paletteKey(for: appearance) - palette = ThemeEngine.shared.dataGridCellPalette(in: appearance) + palette = scope.dataGridCellPalette(in: appearance) settingsCancellable = AppEvents.shared.dataGridSettingsChanged .receive(on: RunLoop.main) .sink { [weak self] _ in @@ -42,9 +44,17 @@ final class DataGridCellRegistry { let key = Self.paletteKey(for: appearance) guard key != paletteKey else { return } paletteKey = key - palette = ThemeEngine.shared.dataGridCellPalette(in: appearance) + palette = scope.dataGridCellPalette(in: appearance) } + /// A fixed scope never moves the key, so a new one rebuilds the palette here. + func adopt(_ scope: ThemeScope, in appearance: NSAppearance) { + self.scope = scope + paletteKey = Self.paletteKey(for: appearance) + palette = scope.dataGridCellPalette(in: appearance) + } + + /// The engine's counters for every scope: a system color change reaches every scope through it. private static func paletteKey(for appearance: NSAppearance) -> PaletteKey { let change = ThemeEngine.shared.change return PaletteKey(revision: change.revision, generation: change.generation, appearance: appearance.name) diff --git a/TablePro/Views/Results/Cells/DataGridCellRenderer.swift b/TablePro/Views/Results/Cells/DataGridCellRenderer.swift index 80a2a12962..582aec2afa 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRenderer.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRenderer.swift @@ -59,7 +59,7 @@ final class DataGridCellRenderer { NSGraphicsContext.current?.saveGraphicsState() NSBezierPath(rect: rect).addClip() drawText(appearance, in: rect) - drawAccessory(appearance.accessoryRole, in: accessoryRect) + drawAccessory(appearance.accessoryRole, color: appearance.accessoryColor, in: accessoryRect) if let mark = appearance.checkboxMark, let controlView { drawCheckbox(mark, in: rect, controlView: controlView) } @@ -123,9 +123,9 @@ final class DataGridCellRenderer { return created } - private func drawAccessory(_ role: DataGridCellAccessoryGlyph.Role?, in rect: NSRect) { - guard !rect.isEmpty, let role else { return } - guard let glyph = DataGridCellAccessoryGlyph.image(for: role), + private func drawAccessory(_ role: DataGridCellAccessoryGlyph.Role?, color: NSColor?, in rect: NSRect) { + guard !rect.isEmpty, let role, let color else { return } + guard let glyph = DataGridCellAccessoryGlyph.image(for: role, color: color), let context = NSGraphicsContext.current?.cgContext else { return } let drawRect = DataGridCellAccessoryGlyph.centeredRect(pointSize: glyph.pointSize, in: rect) diff --git a/TablePro/Views/Results/DataGridBodyChrome.swift b/TablePro/Views/Results/DataGridBodyChrome.swift index 159abf0a5c..d8545cc094 100644 --- a/TablePro/Views/Results/DataGridBodyChrome.swift +++ b/TablePro/Views/Results/DataGridBodyChrome.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProTheme /// The single owner of the data grid body's column separators and of the table's background, as /// `SortableHeaderChrome` is for the header. @@ -118,8 +119,8 @@ enum DataGridBodyChrome { /// `backgroundColor` for the area an elastic scroll uncovers, and the separators draw in its /// `gridColor`. A theme that declares neither leaves both at the table's own defaults, /// `controlBackgroundColor` and `separatorColor`. - static func applyThemeColors(to tableView: NSTableView) { - let resolved = ThemeEngine.shared.resolved + static func applyThemeColors(to tableView: NSTableView, scope: ThemeScope) { + let resolved = scope.resolved let gridColor = resolved[.gridLine] if tableView.gridColor != gridColor { tableView.gridColor = gridColor @@ -135,9 +136,9 @@ enum DataGridBodyChrome { /// /// The theme's pair when it declares one, otherwise `NSColor.alternatingContentBackgroundColors`, /// which is the pair `NSTableView` itself hands its row views. - static func stripeColor(forRow row: Int, of tableView: NSTableView) -> NSColor? { + static func stripeColor(forRow row: Int, of tableView: NSTableView, scope: ThemeScope) -> NSColor? { guard tableView.usesAlternatingRowBackgroundColors else { return nil } - let resolved = ThemeEngine.shared.resolved + let resolved = scope.resolved return row.isMultiple(of: 2) ? resolved[.gridBackground] : resolved[.gridAlternateRow] } @@ -150,16 +151,37 @@ enum DataGridBodyChrome { /// What a selected row shows, for the surfaces that paint it themselves: the pinned row gutter /// and a selected column's heading. Without a theme override it is the color `NSTableRowView` /// fills with, so they match the rows AppKit draws. - static func selectionColor(emphasized: Bool) -> NSColor { - ThemeEngine.shared.resolved[selectionSlot(emphasized: emphasized)] + static func selectionColor(emphasized: Bool, scope: ThemeScope) -> NSColor { + themedSelectionColor(emphasized: emphasized, scope: scope) + ?? scope.resolved[selectionSlot(emphasized: emphasized)] + } + + static func isSelectionThemed(emphasized: Bool, scope: ThemeScope) -> Bool { + scope.resolved.isOverridden(selectionSlot(emphasized: emphasized)) } /// Nil unless the theme declares its own selection; a row then leaves the selection to /// `NSTableRowView`, which follows Accent and focus. - static func themedSelectionColor(emphasized: Bool) -> NSColor? { - let resolved = ThemeEngine.shared.resolved + /// Opaque: the row, the gutter and a heading sit on three different bases, so a translucent + /// selection is laid over the grid background once and all three paint that. + static func themedSelectionColor(emphasized: Bool, scope: ThemeScope) -> NSColor? { + let resolved = scope.resolved let slot = selectionSlot(emphasized: emphasized) - return resolved.isOverridden(slot) ? resolved[slot] : nil + guard resolved.isOverridden(slot) else { return nil } + guard let selection = resolved.definition.value(for: slot).themedColor, !selection.isOpaque else { + return resolved[slot] + } + if let background = resolved.definition.value(for: .gridBackground).themedColor { + return selection.composited(over: background).nsColor + } + let nativeBackground = resolved[.gridBackground] + return NSColor(name: nil) { appearance in + var base = ThemeColor.white + appearance.performAsCurrentDrawingAppearance { + base = ThemeColor(nativeBackground) + } + return selection.composited(over: base.opaque).nsColor + } } /// What a row view lays down before its tint and selection: its stripe, or the table's own @@ -167,8 +189,8 @@ enum DataGridBodyChrome { /// /// Painted by `DataGridRowView` itself rather than by `NSTableRowView`, which paints the system /// stripes whatever the theme declares. - static func rowBackgroundColor(forRow row: Int, of tableView: NSTableView) -> NSColor { - stripeColor(forRow: row, of: tableView) ?? tableView.backgroundColor + static func rowBackgroundColor(forRow row: Int, of tableView: NSTableView, scope: ThemeScope) -> NSColor { + stripeColor(forRow: row, of: tableView, scope: scope) ?? tableView.backgroundColor } /// Lays `background` down and blends `layers` over it bottom to top, which is the colour a row @@ -191,11 +213,11 @@ enum DataGridBodyChrome { /// rows' 40, so the empty area striped brighter than the rows above it, and nothing that paints /// the stripe once could match it, the pinned row gutter included. Under the rows only the /// table's colour goes down, because a row view blends its own stripe over exactly that. - static func drawTableBackground(in rect: NSRect, of tableView: NSTableView) { + static func drawTableBackground(in rect: NSRect, of tableView: NSTableView, scope: ThemeScope) { tableView.backgroundColor.setFill() rect.fill() for band in rowBands(in: rect, of: tableView, tableView: tableView) where !band.isTableRow { - guard let stripe = stripeColor(forRow: band.row, of: tableView) else { continue } + guard let stripe = stripeColor(forRow: band.row, of: tableView, scope: scope) else { continue } stripe.setFill() band.rect.intersection(rect).fill(using: .sourceOver) } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 20df380fc6..4e87200f75 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -526,6 +526,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var settingsCancellable: AnyCancellable? var themeCancellable: AnyCancellable? + private(set) var themeScope: ThemeScope var systemTimeZoneCancellable: AnyCancellable? private var accessibilityActivationObserver: (any NSObjectProtocol)? private var accessibilityMountedRows = NSRange(location: 0, length: 0) @@ -585,15 +586,17 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData isEditable: Bool, selectedRowIndices: Binding>, delegate: (any DataGridViewDelegate)?, - layoutPersister: any ColumnLayoutPersisting + layoutPersister: any ColumnLayoutPersisting, + themeScope: ThemeScope = .live ) { self.changeManager = changeManager self.isEditable = isEditable self._selectedRowIndices = selectedRowIndices self.delegate = delegate self.layoutPersister = layoutPersister + self.themeScope = themeScope self.lastDataGridSettings = AppSettingsManager.shared.dataGrid - self.cellRegistry = DataGridCellRegistry() + self.cellRegistry = DataGridCellRegistry(scope: themeScope) super.init() cellRegistry.accessoryDelegate = self updateCache() @@ -632,17 +635,28 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func observeThemeChanges() { - themeCancellable = AppEvents.shared.themeChanged + themeCancellable = themeScope.changes .receive(on: RunLoop.main) .sink { [weak self] _ in self?.themeDidChange() } } + /// A fixed scope never changes, so the preview hands over a new one for each draft. + func adoptThemeScope(_ scope: ThemeScope) { + guard scope !== themeScope else { return } + themeScope = scope + cellRegistry.adopt(scope, in: tableView?.effectiveAppearance ?? NSApp?.effectiveAppearance ?? .currentDrawing()) + if themeCancellable != nil { + observeThemeChanges() + } + themeDidChange() + } + /// `themeChanged` also fires for an Accent, Highlight or Increase Contrast change, and none of /// them moves a value, so this repaints what is on screen and never reloads or refetches. func themeDidChange() { guard let tableView else { return } cellRegistry.preparePalette(in: tableView.effectiveAppearance) - DataGridBodyChrome.applyThemeColors(to: tableView) + DataGridBodyChrome.applyThemeColors(to: tableView, scope: themeScope) tableView.headerView?.needsDisplay = true tableView.cornerView?.needsDisplay = true restyleRowNumberCells(in: tableView) diff --git a/TablePro/Views/Results/DataGridRowGutterView.swift b/TablePro/Views/Results/DataGridRowGutterView.swift index ef89460ad0..5271b9b9ae 100644 --- a/TablePro/Views/Results/DataGridRowGutterView.swift +++ b/TablePro/Views/Results/DataGridRowGutterView.swift @@ -137,6 +137,7 @@ final class DataGridRowGutterView: NSView { let pageOffset = coordinator.paginationOffsetProvider() coordinator.cellRegistry.preparePalette(in: NSAppearance.currentDrawing()) let palette = coordinator.cellRegistry.palette + let scope = coordinator.themeScope for band in DataGridBodyChrome.rowBands(in: dirtyRect, of: self, tableView: tableView) { let stripRect = NSRect(x: 0, y: band.rect.minY, width: bounds.width, height: band.rect.height) @@ -146,7 +147,14 @@ final class DataGridRowGutterView: NSView { let state = band.isTableRow ? coordinator.visualState(for: band.row) : .empty DataGridBodyChrome.fill( stripRect, - with: rowLayers(row: band.row, isSelected: isSelected, emphasized: emphasized, state: state, tableView: tableView), + with: rowLayers( + row: band.row, + isSelected: isSelected, + emphasized: emphasized, + state: state, + tableView: tableView, + scope: scope + ), over: tableView.backgroundColor ) guard band.isTableRow else { continue } @@ -170,15 +178,16 @@ final class DataGridRowGutterView: NSView { isSelected: Bool, emphasized: Bool, state: RowVisualState, - tableView: NSTableView + tableView: NSTableView, + scope: ThemeScope ) -> [NSColor] { var layers: [NSColor] = [] - if let stripe = DataGridBodyChrome.stripeColor(forRow: row, of: tableView) { + if let stripe = DataGridBodyChrome.stripeColor(forRow: row, of: tableView, scope: scope) { layers.append(stripe) } if isSelected { - layers.append(DataGridBodyChrome.selectionColor(emphasized: emphasized)) - } else if let tint = state.tint { + layers.append(DataGridBodyChrome.selectionColor(emphasized: emphasized, scope: scope)) + } else if let tint = state.tint(in: scope) { layers.append(tint) } return layers diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index 2723374ae8..6e11b954ce 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -6,6 +6,7 @@ import AppKit import Combine import TableProPluginKit +import TableProTheme @MainActor class DataGridRowView: NSTableRowView { @@ -42,6 +43,11 @@ class DataGridRowView: NSTableRowView { coordinator?.visualState(for: rowIndex) ?? .empty } + /// A row outside a grid, as the copy tests build one, draws with the app's theme. + private var themeScope: ThemeScope { + coordinator?.themeScope ?? .live + } + /// Draws the row's data cells. /// /// A subview rather than the row view's own `draw(_:)`, so the cells land after AppKit has @@ -174,9 +180,8 @@ class DataGridRowView: NSTableRowView { /// The columns of an unselected row that a cell range covers with a theme's opaque selection, /// whose text has to switch to the theme's selection text like a selected row's does. func themedRangeColumns() -> Set { - guard !isSelected, isEmphasized, - DataGridBodyChrome.themedSelectionColor(emphasized: true) != nil, - let coordinator else { return [] } + guard !isSelected, isEmphasized, let coordinator, + DataGridBodyChrome.isSelectionThemed(emphasized: true, scope: coordinator.themeScope) else { return [] } let columns = coordinator.selectionController.selection.columns(in: rowIndex) return Set(columns.compactMap { coordinator.tableColumnIndex(forDisplayPosition: $0) }) } @@ -291,30 +296,31 @@ class DataGridRowView: NSTableRowView { } override func drawBackground(in dirtyRect: NSRect) { - drawRowBackground(in: dirtyRect) - if !isSelected, let tint = visualState.tint { + let scope = themeScope + drawRowBackground(in: dirtyRect, scope: scope) + if !isSelected, let tint = visualState.tint(in: scope) { tint.setFill() bounds.fill() } - drawCellSelectionFill(in: dirtyRect) + drawCellSelectionFill(in: dirtyRect, scope: scope) } /// The row's stripe from `DataGridBodyChrome`, the owner the pinned gutter and the area past the /// last row read too, rather than the `backgroundColor` the table assigns, which is only ever the /// system's. A row outside a table has no stripe, and AppKit's own drawing already leaves it clear: /// its `backgroundColor` is nil there, so it must not be read. - private func drawRowBackground(in dirtyRect: NSRect) { + private func drawRowBackground(in dirtyRect: NSRect, scope: ThemeScope) { guard let tableView = coordinator?.tableView else { super.drawBackground(in: dirtyRect) return } - DataGridBodyChrome.rowBackgroundColor(forRow: rowIndex, of: tableView).setFill() + DataGridBodyChrome.rowBackgroundColor(forRow: rowIndex, of: tableView, scope: scope).setFill() dirtyRect.fill() } override func drawSelection(in dirtyRect: NSRect) { guard isSelected else { return } - guard let themed = DataGridBodyChrome.themedSelectionColor(emphasized: isEmphasized) else { + guard let themed = DataGridBodyChrome.themedSelectionColor(emphasized: isEmphasized, scope: themeScope) else { super.drawSelection(in: dirtyRect) return } @@ -324,7 +330,7 @@ class DataGridRowView: NSTableRowView { /// A cell range on a row the table has selected is already covered by the row's own selection /// fill, which runs after this, so only the remaining rows are painted here. - private func drawCellSelectionFill(in dirtyRect: NSRect) { + private func drawCellSelectionFill(in dirtyRect: NSRect, scope: ThemeScope) { guard !isSelected, let coordinator, let tableView = coordinator.tableView else { return } @@ -333,7 +339,7 @@ class DataGridRowView: NSTableRowView { let columns = selection.columns(in: rowIndex) guard !columns.isEmpty else { return } - cellSelectionFill.setFill() + cellSelectionFill(scope: scope).setFill() for position in columns { guard let tableColumnIndex = coordinator.tableColumnIndex(forDisplayPosition: position) else { continue } @@ -349,11 +355,13 @@ class DataGridRowView: NSTableRowView { /// left the range at 1.09:1 against a white grid, which is no visible selection at all. The /// system's emphasized accent is far too dark to sit behind text these rows do not recolour, so /// that one goes on as a tint; a theme's selection is chosen to sit behind its own text. - private var cellSelectionFill: NSColor { - let resolved = ThemeEngine.shared.resolved - let slot = DataGridBodyChrome.selectionSlot(emphasized: isEmphasized) - guard isEmphasized, !resolved.isOverridden(slot) else { return resolved[slot] } - return resolved[slot].withAlphaComponent(Self.emphasizedCellSelectionAlpha) + private func cellSelectionFill(scope: ThemeScope) -> NSColor { + if let themed = DataGridBodyChrome.themedSelectionColor(emphasized: isEmphasized, scope: scope) { + return themed + } + let native = scope.resolved[DataGridBodyChrome.selectionSlot(emphasized: isEmphasized)] + guard isEmphasized else { return native } + return native.withAlphaComponent(Self.emphasizedCellSelectionAlpha) } private static let emphasizedCellSelectionAlpha: CGFloat = 0.28 diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index b2436eba65..77ebbd99c4 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -9,6 +9,7 @@ import AppKit import SwiftUI import TableProPluginKit +import TableProTheme struct CellPosition: Hashable { let row: Int @@ -78,9 +79,9 @@ struct RowVisualState: Equatable { extension RowVisualState { /// The wash a row in this state carries, read by the row and by the pinned row gutter over it so /// the two cannot disagree. - @MainActor var tint: NSColor? { - if isDeleted { return ThemeEngine.shared.resolved[.gridDeleted] } - if isInserted { return ThemeEngine.shared.resolved[.gridInserted] } + @MainActor func tint(in scope: ThemeScope) -> NSColor? { + if isDeleted { return scope.resolved[.gridDeleted] } + if isInserted { return scope.resolved[.gridInserted] } return highlight.rowColor?.washColor } @@ -141,6 +142,8 @@ struct DataGridView: NSViewRepresentable { var viewportPlacementProvider: (@MainActor () -> GridViewportPlacement?)? var contentRevision: Int = 0 var selectionSummary: SelectionSummaryState? + /// Where every color the grid draws comes from. Only the theme editor's preview passes another. + var themeScope: ThemeScope = .live // MARK: - NSViewRepresentable @@ -171,7 +174,7 @@ struct DataGridView: NSViewRepresentable { tableView.intercellSpacing = NSSize(width: 1, height: 0) tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) tableView.usesAutomaticRowHeights = false - DataGridBodyChrome.applyThemeColors(to: tableView) + DataGridBodyChrome.applyThemeColors(to: tableView, scope: context.coordinator.themeScope) tableView.delegate = context.coordinator tableView.dataSource = context.coordinator @@ -255,6 +258,7 @@ struct DataGridView: NSViewRepresentable { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let tableView = scrollView.documentView as? NSTableView else { return } let coordinator = context.coordinator + coordinator.adoptThemeScope(themeScope) if tableView.editedRow >= 0 { return } if let editor = coordinator.overlayEditor, editor.isActive { return } @@ -708,7 +712,8 @@ struct DataGridView: NSViewRepresentable { isEditable: isEditable, selectedRowIndices: $selectedRowIndices, delegate: delegate, - layoutPersister: layoutPersister ?? FileColumnLayoutPersister.shared + layoutPersister: layoutPersister ?? FileColumnLayoutPersister.shared, + themeScope: themeScope ) /// The cell selection's half of the row selection. Every mutator funnels through /// `GridSelectionController.update(_:)`, so this is the one hook that sees a drag widen. diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index 2912d606af..bf1fcd6453 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -59,6 +59,9 @@ protocol DataGridViewDelegate: AnyObject { func dataGridSetCheckbox(_ isOn: Bool, rows: IndexSet, column: Int) /// What an assistive client reads for a cell in place of its drawn text, or nil to read the text. func dataGridAccessibilityText(row displayRow: Int, column: Int) -> String? + /// A status hue for a value cell's text, or nil for the grid text. A selection or find match still + /// draws its own text color over it. + func dataGridTextTone(row displayRow: Int, column: Int) -> NSColor? /// Nil lets the grid derive the rules from its column types and display formats. func dataGridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? } @@ -71,6 +74,7 @@ extension DataGridViewDelegate { func dataGridCheckboxState(row: Int, column: Int) -> Bool? { nil } func dataGridSetCheckbox(_ isOn: Bool, rows: IndexSet, column: Int) {} func dataGridAccessibilityText(row displayRow: Int, column: Int) -> String? { nil } + func dataGridTextTone(row displayRow: Int, column: Int) -> NSColor? { nil } func dataGridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? { nil } func dataGridDidEditCell(row: Int, column: Int, newValue: String?) {} func dataGridDeleteRows(_ indices: Set) {} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 5e89def5a7..f2c6542183 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -104,7 +104,8 @@ extension TableViewCoordinator { nullDisplayString: cellRegistry.nullDisplayString, onEmphasizedSelection: onEmphasizedSelection, hasOverlay: overlayCell == CellPosition(row: row, column: columnIndex), - checkboxMark: checkboxMark(row: row, columnIndex: columnIndex) + checkboxMark: checkboxMark(row: row, columnIndex: columnIndex), + textTone: delegate?.dataGridTextTone(row: row, column: columnIndex) ) } diff --git a/TablePro/Views/Results/FilterableTreeView.swift b/TablePro/Views/Results/FilterableTreeView.swift index f1bca27c36..928fa77354 100644 --- a/TablePro/Views/Results/FilterableTreeView.swift +++ b/TablePro/Views/Results/FilterableTreeView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme internal struct FilterableTreeView: View { let rootNode: Node @@ -122,8 +123,13 @@ internal struct FilterableTreeView: View { } } - @ViewBuilder private func noMatchesView(isTruncated: Bool) -> some View { + noMatchesState(isTruncated: isTruncated) + .valueStandInBackground() + } + + @ViewBuilder + private func noMatchesState(isTruncated: Bool) -> some View { if isTruncated { UnavailableStateView { Label(String(localized: "No Results"), systemImage: "magnifyingglass") @@ -159,3 +165,11 @@ internal struct FilterableTreeView: View { ) } } + +internal extension View { + /// A state that takes the place of a value's tree or text sits on that view's background, so a + /// failed parse or an empty filter does not swap the theme for the material behind it. + func valueStandInBackground() -> some View { + themeBackground(.editorBackground, native: Color.clear, ignoresSafeAreaEdges: []) + } +} diff --git a/TablePro/Views/Results/JSONViewerView.swift b/TablePro/Views/Results/JSONViewerView.swift index 1c8e120003..68909364ee 100644 --- a/TablePro/Views/Results/JSONViewerView.swift +++ b/TablePro/Views/Results/JSONViewerView.swift @@ -150,12 +150,15 @@ internal struct JSONViewerView: View { switch parsedTree?.document { case .emptyValue?: emptyValueView + .valueStandInBackground() case .tree(let tree)?: JSONTreeView(rootNode: tree, searchText: $treeSearchText) case .unavailable(let error)?: treeErrorView(error) + .valueStandInBackground() case nil: Color.clear + .valueStandInBackground() } } } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 61c399955e..f9b7885840 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -4,6 +4,11 @@ final class KeyHandlingTableView: NSTableView { weak var coordinator: TableViewCoordinator? weak var selectionOverlay: GridSelectionOverlay? + /// A table with no grid draws the app's theme. + var themeScope: ThemeScope { + coordinator?.themeScope ?? .live + } + private var isRaisingOverlay = false override var acceptsFirstResponder: Bool { @@ -25,7 +30,7 @@ final class KeyHandlingTableView: NSTableView { /// the grid's own rather than `super`'s, for the reason on /// `DataGridBodyChrome.drawTableBackground(in:of:)`. override func drawBackground(inClipRect clipRect: NSRect) { - DataGridBodyChrome.drawTableBackground(in: clipRect, of: self) + DataGridBodyChrome.drawTableBackground(in: clipRect, of: self, scope: themeScope) guard let coordinator else { return } let lastRowBottom = numberOfRows > 0 ? rect(ofRow: numberOfRows - 1).maxY : bounds.minY let belowRows = clipRect.intersection( diff --git a/TablePro/Views/Results/PhpViewerView.swift b/TablePro/Views/Results/PhpViewerView.swift index 9b744c6efb..937774f15d 100644 --- a/TablePro/Views/Results/PhpViewerView.swift +++ b/TablePro/Views/Results/PhpViewerView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme internal enum PhpViewMode: String, CaseIterable { case tree @@ -110,6 +111,7 @@ internal struct PhpViewerView: View { ProgressView() .controlSize(.small) .frame(maxWidth: .infinity, maxHeight: .infinity) + .valueStandInBackground() case .parsed(let node): PhpTreeView(rootNode: node, searchText: $searchText) case .tooLarge: @@ -118,12 +120,14 @@ internal struct PhpViewerView: View { detail: String(localized: "This value is too large to parse. Use raw mode to inspect it as text."), systemImage: "doc.text" ) + .valueStandInBackground() case .failed: errorPlaceholder( title: String(localized: "Invalid PHP Serialized Value"), detail: String(localized: "The value could not be parsed. Use raw mode to inspect it as text."), systemImage: "exclamationmark.triangle" ) + .valueStandInBackground() } } diff --git a/TablePro/Views/Results/ResultChartCanvas.swift b/TablePro/Views/Results/ResultChartCanvas.swift index 98c2efc5b5..ea30385ed6 100644 --- a/TablePro/Views/Results/ResultChartCanvas.swift +++ b/TablePro/Views/Results/ResultChartCanvas.swift @@ -5,6 +5,7 @@ import Charts import SwiftUI +import TableProTheme struct ResultChartCanvas: View { /// Solid first, then patterns that stay legible in monochrome and to a colour-blind reader, diff --git a/TablePro/Views/Results/ResultChartToolbar.swift b/TablePro/Views/Results/ResultChartToolbar.swift index f9ad059e97..b4f48e2349 100644 --- a/TablePro/Views/Results/ResultChartToolbar.swift +++ b/TablePro/Views/Results/ResultChartToolbar.swift @@ -69,7 +69,7 @@ struct ResultChartToolbar: View { } .padding(.horizontal, 14) .padding(.vertical, 8) - .background(.bar) + .paneBarBackground(.bar, ignoresSafeAreaEdges: .all) } @ViewBuilder diff --git a/TablePro/Views/Results/ResultMapCoordinator.swift b/TablePro/Views/Results/ResultMapCoordinator.swift index 23b46a9753..cda3f39662 100644 --- a/TablePro/Views/Results/ResultMapCoordinator.swift +++ b/TablePro/Views/Results/ResultMapCoordinator.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine import MapKit import os import TableProGeometry @@ -46,6 +47,7 @@ final class ResultMapCoordinator: NSObject, MKMapViewDelegate { private var polygonRowIDs: [RowID] = [] private var polylineRowIDs: [RowID] = [] private var lastFitToken: Int? + private var systemColorsCancellable: AnyCancellable? init(fitPolicy: FitPolicy = .result, onSelect: @escaping (RowID?) -> Void) { self.fitPolicy = fitPolicy @@ -64,9 +66,16 @@ final class ResultMapCoordinator: NSObject, MKMapViewDelegate { guard let self, let mapView else { return } self.handleClick(at: point, in: mapView) } + systemColorsCancellable = AppEvents.shared.themeChanged + .receive(on: RunLoop.main) + .sink { [weak self, weak mapView] _ in + guard let self, let mapView else { return } + self.restyleOverlays(in: mapView) + } } func detach(from mapView: MKMapView) { + systemColorsCancellable = nil (mapView as? ResultMapSurface)?.onClick = nil mapView.delegate = nil mapView.removeOverlays(mapView.overlays) @@ -251,25 +260,40 @@ final class ResultMapCoordinator: NSObject, MKMapViewDelegate { /// aggregates of the same two types, and identity is also a constant-time question where /// searching the selection was linear in it. func mapView(_ mapView: MKMapView, rendererFor overlay: any MKOverlay) -> MKOverlayRenderer { + let renderer: MKOverlayPathRenderer if let multiPolygon = overlay as? MKMultiPolygon { - let isHighlight = multiPolygon === highlightPolygonOverlay - let renderer = MKMultiPolygonRenderer(multiPolygon: multiPolygon) - let color: NSColor = isHighlight ? .systemOrange : .controlAccentColor - renderer.fillColor = color.withAlphaComponent(isHighlight ? 0.45 : 0.22) - renderer.strokeColor = isHighlight ? color : color.withAlphaComponent(0.9) - renderer.lineWidth = isHighlight ? 2.5 : 1 - return renderer + renderer = MKMultiPolygonRenderer(multiPolygon: multiPolygon) + } else if let multiPolyline = overlay as? MKMultiPolyline { + renderer = MKMultiPolylineRenderer(multiPolyline: multiPolyline) + } else { + return MKOverlayRenderer(overlay: overlay) + } + style(renderer, for: overlay, in: mapView.effectiveAppearance) + return renderer + } + + /// The renderers hold baked colors, so an Accent change or a light/dark flip paints them again + /// rather than leaving the shapes on the old accent until the next projection. + func restyleOverlays(in mapView: MKMapView) { + let appearance = mapView.effectiveAppearance + for overlay in mapView.overlays { + guard let renderer = mapView.renderer(for: overlay) as? MKOverlayPathRenderer else { continue } + style(renderer, for: overlay, in: appearance) + renderer.setNeedsDisplay() } - if let multiPolyline = overlay as? MKMultiPolyline { - let isHighlight = multiPolyline === highlightPolylineOverlay - let renderer = MKMultiPolylineRenderer(multiPolyline: multiPolyline) - renderer.strokeColor = isHighlight - ? .systemOrange - : NSColor.controlAccentColor.withAlphaComponent(0.9) - renderer.lineWidth = isHighlight ? 4 : 2 - return renderer + refreshPointHighlights(in: mapView) + } + + private func style(_ renderer: MKOverlayPathRenderer, for overlay: any MKOverlay, in appearance: NSAppearance) { + let paint: ResultMapOverlayPaint + if overlay is MKMultiPolygon { + paint = .polygon(isHighlight: overlay === highlightPolygonOverlay, in: appearance) + } else { + paint = .polyline(isHighlight: overlay === highlightPolylineOverlay, in: appearance) } - return MKOverlayRenderer(overlay: overlay) + renderer.fillColor = paint.fill + renderer.strokeColor = paint.stroke + renderer.lineWidth = paint.lineWidth } /// MapKit asks this for a cluster it built as well as for a point, so both answers live here. @@ -429,3 +453,40 @@ final class ResultMapAnnotation: NSObject, MKAnnotation { ) } } + +/// Baked in the map view's appearance: MapKit draws overlays with no drawing appearance, and a tint +/// made outside a provider freezes at the Accent current when it was made. +internal struct ResultMapOverlayPaint: Equatable { + internal let fill: NSColor? + internal let stroke: NSColor + internal let lineWidth: CGFloat + + @MainActor + internal static func polygon(isHighlight: Bool, in appearance: NSAppearance) -> ResultMapOverlayPaint { + let base: NSColor = isHighlight ? .systemOrange : .controlAccentColor + return ResultMapOverlayPaint( + fill: baked(base, alpha: isHighlight ? 0.45 : 0.22, in: appearance), + stroke: baked(base, alpha: isHighlight ? 1 : 0.9, in: appearance), + lineWidth: isHighlight ? 2.5 : 1 + ) + } + + @MainActor + internal static func polyline(isHighlight: Bool, in appearance: NSAppearance) -> ResultMapOverlayPaint { + ResultMapOverlayPaint( + fill: nil, + stroke: isHighlight ? baked(.systemOrange, alpha: 1, in: appearance) : baked(.controlAccentColor, alpha: 0.9, in: appearance), + lineWidth: isHighlight ? 4 : 2 + ) + } + + @MainActor + private static func baked(_ base: NSColor, alpha: CGFloat, in appearance: NSAppearance) -> NSColor { + let tint = NSColor(name: nil) { _ in base.withAlphaComponent(alpha) } + var cgColor: CGColor? + appearance.performAsCurrentDrawingAppearance { + cgColor = tint.cgColor + } + return cgColor.flatMap { NSColor(cgColor: $0) } ?? tint + } +} diff --git a/TablePro/Views/Results/ResultsJsonView.swift b/TablePro/Views/Results/ResultsJsonView.swift index 6b1f8ea898..5ef2c9812e 100644 --- a/TablePro/Views/Results/ResultsJsonView.swift +++ b/TablePro/Views/Results/ResultsJsonView.swift @@ -168,10 +168,12 @@ internal struct ResultsJsonView: View { systemImage: "curlybraces", description: Text(String(localized: "Execute a query to view results as JSON")) ) + .valueStandInBackground() } else if !hasRendered { ProgressView() .controlSize(.small) .frame(maxWidth: .infinity, maxHeight: .infinity) + .valueStandInBackground() } else { switch viewMode { case .text: @@ -181,8 +183,10 @@ internal struct ResultsJsonView: View { JSONTreeView(rootNode: tree, searchText: $treeSearchText) } else if let error = parseError { treeErrorView(error) + .valueStandInBackground() } else { treeErrorView(.invalidJSON) + .valueStandInBackground() } } } diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift index 9d85194554..889af11ba7 100644 --- a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -1,4 +1,5 @@ import AppKit +import TableProTheme @MainActor final class GridSelectionOverlay: NSView { @@ -41,8 +42,8 @@ final class GridSelectionOverlay: NSView { guard let tableView, let coordinator else { return } let editingCell = activeOverlayCell(in: coordinator) - let resolved = ThemeEngine.shared.resolved - resolved[.gridSelection].withAlphaComponent(Self.borderAlpha).setStroke() + let resolved = coordinator.themeScope.resolved + Self.rangeBorderColor(resolved).setStroke() for rect in selection.rectangles { guard let frame = frame(for: rect, in: tableView, coordinator: coordinator) else { continue } guard frame.intersects(dirtyRect) else { continue } @@ -59,8 +60,7 @@ final class GridSelectionOverlay: NSView { selection.rectangles.count > 1 || (selection.rectangles.first?.rows.count ?? 0) > 1 || (selection.rectangles.first?.columns.count ?? 0) > 1, let frame = frame(for: GridRect(cell: active), in: tableView, coordinator: coordinator), frame.intersects(dirtyRect) { - let activeColor = resolved.isOverridden(.gridFocusBorder) ? resolved[.gridFocusBorder] : .controlAccentColor - activeColor.setStroke() + Self.activeCellBorderColor(resolved).setStroke() let inset = frame.insetBy(dx: Self.activeCellBorderWidth / 2, dy: Self.activeCellBorderWidth / 2) let path = NSBezierPath(rect: inset) path.lineWidth = Self.activeCellBorderWidth @@ -68,6 +68,18 @@ final class GridSelectionOverlay: NSView { } } + static func activeCellBorderColor(_ resolved: ResolvedTheme) -> NSColor { + resolved.isOverridden(.gridFocusBorder) ? resolved[.gridFocusBorder] : .controlAccentColor + } + + /// A theme's opaque selection fill hides the selection at 70%, so the outline takes the active + /// cell's border color instead, the way the native outline and active cell share the accent. + static func rangeBorderColor(_ resolved: ResolvedTheme) -> NSColor { + let base = resolved.isOverridden(.gridSelection) ? activeCellBorderColor(resolved) : resolved[.gridSelection] + let alpha = borderAlpha + return NSColor(name: nil) { _ in base.withAlphaComponent(alpha) } + } + private func activeOverlayCell(in coordinator: TableViewCoordinator) -> GridCoord? { if let editor = coordinator.overlayEditor, editor.isActive, let position = coordinator.displayPosition(ofDataColumnIndex: editor.columnIndex) { diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 22d9022474..61b1d3ab57 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProTheme @MainActor final class SortableHeaderCell: NSTableHeaderCell { @@ -58,27 +59,33 @@ final class SortableHeaderCell: NSTableHeaderCell { wraps = false } + /// A cell drawn outside the grid's header takes the app's theme. + private static func themeScope(of controlView: NSView?) -> ThemeScope { + (controlView as? SortableHeaderView)?.themeScope ?? .live + } + override func draw(withFrame cellFrame: NSRect, in controlView: NSView) { if drawsDivider { - SortableHeaderChrome.drawColumnDivider(in: cellFrame) + SortableHeaderChrome.drawColumnDivider(in: cellFrame, scope: Self.themeScope(of: controlView)) } guard drawsInterior else { return } drawInterior(withFrame: cellFrame, in: controlView) } override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { + let scope = Self.themeScope(of: controlView) if isColumnSelected { - DataGridBodyChrome.selectionColor(emphasized: isEmphasized).setFill() + DataGridBodyChrome.selectionColor(emphasized: isEmphasized, scope: scope).setFill() cellFrame.fill() } - let foreground = foregroundColor(emphasized: isColumnSelected && isEmphasized) + let foreground = foregroundColor(emphasized: isColumnSelected && isEmphasized, scope: scope) drawTitle( in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: sortDirection != nil && !isDefaultSort), color: foreground, comment: visibleComment(in: controlView), - commentColor: commentColor(emphasized: isColumnSelected && isEmphasized) + commentColor: commentColor(emphasized: isColumnSelected && isEmphasized, scope: scope) ) var trailingCursorX = cellFrame.maxX - Self.indicatorPadding @@ -87,7 +94,7 @@ final class SortableHeaderCell: NSTableHeaderCell { if isValueFiltered || isFunnelVisible { let funnelImage = Self.funnelImage( active: isValueFiltered, - color: funnelColor(active: isValueFiltered, emphasized: isColumnSelected) + color: funnelColor(active: isValueFiltered, emphasized: isColumnSelected, scope: scope) ) let drawSize = funnelImage?.size ?? Self.funnelSize let funnelRect = NSRect( @@ -169,8 +176,8 @@ final class SortableHeaderCell: NSTableHeaderCell { return width + Self.indicatorPadding * 2 } - private func funnelColor(active: Bool, emphasized: Bool) -> NSColor { - if emphasized { return ThemeEngine.shared.resolved[.gridSelectionText] } + private func funnelColor(active: Bool, emphasized: Bool, scope: ThemeScope) -> NSColor { + if emphasized { return scope.resolved[.gridSelectionText] } return active ? .controlAccentColor : .secondaryLabelColor } @@ -193,12 +200,12 @@ final class SortableHeaderCell: NSTableHeaderCell { return headerView.comment(for: self) } - func foregroundColor(emphasized: Bool) -> NSColor { - ThemeEngine.shared.resolved[emphasized ? .gridSelectionText : .gridHeaderText] + func foregroundColor(emphasized: Bool, scope: ThemeScope) -> NSColor { + scope.resolved[emphasized ? .gridSelectionText : .gridHeaderText] } - private func commentColor(emphasized: Bool) -> NSColor { - emphasized ? ThemeEngine.shared.resolved[.gridSelectionText] : .secondaryLabelColor + private func commentColor(emphasized: Bool, scope: ThemeScope) -> NSColor { + emphasized ? scope.resolved[.gridSelectionText] : .secondaryLabelColor } private func drawTitle( diff --git a/TablePro/Views/Results/SortableHeaderChrome.swift b/TablePro/Views/Results/SortableHeaderChrome.swift index ebca9744b8..6eae000b56 100644 --- a/TablePro/Views/Results/SortableHeaderChrome.swift +++ b/TablePro/Views/Results/SortableHeaderChrome.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProTheme @MainActor enum SortableHeaderChrome { @@ -12,21 +13,21 @@ enum SortableHeaderChrome { /// The cells never draw a background of their own, so the header lays one down under /// `NSTableHeaderView.draw`; without a theme override it is the table's own background. - static var backgroundColor: NSColor { - ThemeEngine.shared.resolved[.gridHeaderBackground] + static func backgroundColor(scope: ThemeScope) -> NSColor { + scope.resolved[.gridHeaderBackground] } - static var lineColor: NSColor { - ThemeEngine.shared.resolved[.gridLine] + static func lineColor(scope: ThemeScope) -> NSColor { + scope.resolved[.gridLine] } - static func fillBackground(_ rect: NSRect) { - backgroundColor.setFill() + static func fillBackground(_ rect: NSRect, scope: ThemeScope) { + backgroundColor(scope: scope).setFill() rect.fill() } - static func drawBottomSeparator(in bounds: NSRect) { - lineColor.setFill() + static func drawBottomSeparator(in bounds: NSRect, scope: ThemeScope) { + lineColor(scope: scope).setFill() NSRect( x: bounds.minX, y: bounds.maxY - separatorThickness, @@ -35,9 +36,9 @@ enum SortableHeaderChrome { ).fill() } - static func drawColumnDivider(in cellFrame: NSRect) { + static func drawColumnDivider(in cellFrame: NSRect, scope: ThemeScope) { let dividerHeight = min(columnDividerHeight, cellFrame.height) - lineColor.setFill() + lineColor(scope: scope).setFill() NSRect( x: cellFrame.maxX - separatorThickness, y: cellFrame.midY - dividerHeight / 2, diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index cac3b34d46..e61f634d3b 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -5,6 +5,7 @@ import AppKit import os +import TableProTheme struct HeaderSortTransition: Equatable { let newState: SortState @@ -76,6 +77,10 @@ enum HeaderSortCycle { final class SortableHeaderView: NSTableHeaderView { weak var coordinator: TableViewCoordinator? + var themeScope: ThemeScope { + coordinator?.themeScope ?? .live + } + private static let clickDragThreshold: CGFloat = 4 /// AppKit's own divider band, measured on macOS 27: a press from 3pt before a column's trailing /// edge up to, not including, 2pt after it resizes. A wider band showed the resize cursor where @@ -241,16 +246,39 @@ final class SortableHeaderView: NSTableHeaderView { } override func draw(_ dirtyRect: NSRect) { - SortableHeaderChrome.fillBackground(dirtyRect) + let scope = themeScope + SortableHeaderChrome.fillBackground(dirtyRect, scope: scope) let rowNumberCell = rowNumberColumnIndex.flatMap { tableView?.tableColumns[$0].headerCell as? SortableHeaderCell } rowNumberCell?.drawsInterior = false - super.draw(dirtyRect) + drawHeadings(in: dirtyRect) rowNumberCell?.drawsInterior = true drawOverhang(in: dirtyRect) drawPinnedRowNumberHeading(in: dirtyRect) - SortableHeaderChrome.drawBottomSeparator(in: bounds.union(dirtyRect)) + SortableHeaderChrome.drawBottomSeparator(in: bounds.union(dirtyRect), scope: scope) + } + + /// `NSTableHeaderView.draw` paints AppKit's band over the fill before the cells, measured on + /// macOS 27, so a themed header draws its cells itself and the default theme keeps `super`. + private func drawHeadings(in rect: NSRect) { + let scope = themeScope + guard scope.resolved.isOverridden(.gridHeaderBackground), let tableView else { + super.draw(rect) + return + } + let dragged = draggedColumn + let columns = tableView.columnIndexes(in: NSRect(x: rect.minX, y: 0, width: rect.width, height: 1)) + for index in columns where index != dragged { + let heading = headerRect(ofColumn: index) + guard heading.width > 0, heading.intersects(rect) else { continue } + tableView.tableColumns[index].headerCell.draw(withFrame: heading, in: self) + } + guard dragged >= 0, dragged < tableView.numberOfColumns else { return } + let floating = headerRect(ofColumn: dragged).offsetBy(dx: draggedDistance, dy: 0) + guard floating.intersects(rect) else { return } + SortableHeaderChrome.fillBackground(floating, scope: scope) + tableView.tableColumns[dragged].headerCell.draw(withFrame: floating, in: self) } /// Paints the part of the dirty rect outside the header's bounds, which a sideways bounce exposes, @@ -287,8 +315,8 @@ final class SortableHeaderView: NSTableHeaderView { context.saveGState() band.clip() context.translateBy(x: originX - heading.minX, y: 0) - SortableHeaderChrome.fillBackground(heading) - super.draw(heading) + SortableHeaderChrome.fillBackground(heading, scope: themeScope) + drawHeadings(in: heading) context.restoreGState() originX += heading.width } @@ -339,8 +367,8 @@ final class SortableHeaderView: NSTableHeaderView { context.saveGState() pinned.clip() context.translateBy(x: pinned.minX - heading.minX, y: 0) - SortableHeaderChrome.fillBackground(heading) - super.draw(heading) + SortableHeaderChrome.fillBackground(heading, scope: themeScope) + drawHeadings(in: heading) context.restoreGState() drawnPinnedHeadingRect = pinned } diff --git a/TablePro/Views/Results/TreeOutline/TreeValueTone+Theme.swift b/TablePro/Views/Results/TreeOutline/TreeValueTone+Theme.swift index c82cd73673..4ee0a62705 100644 --- a/TablePro/Views/Results/TreeOutline/TreeValueTone+Theme.swift +++ b/TablePro/Views/Results/TreeOutline/TreeValueTone+Theme.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProTheme internal extension TreeValueTone { /// The slot the JSON text view draws the same token in: its grammar captures a key as a string, diff --git a/TablePro/Views/Results/ValueViewerWindowController.swift b/TablePro/Views/Results/ValueViewerWindowController.swift index 3f72852e28..adc73d2267 100644 --- a/TablePro/Views/Results/ValueViewerWindowController.swift +++ b/TablePro/Views/Results/ValueViewerWindowController.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import TableProTheme /// The detached window a popped-out cell value opens in. Subclasses supply the content and the /// window's identity; the bookkeeping that keeps a detached window alive lives here once. @@ -66,7 +67,7 @@ internal class ValueViewerWindowController { title: String, @ViewBuilder content: (@escaping () -> Void) -> Content ) -> NSWindow { - let window = NSWindow( + let window = ContentWindow( contentRect: NSRect(origin: .zero, size: defaultSize), styleMask: styleMask, backing: .buffered, diff --git a/TablePro/Views/Rewind/RewindReviewSheet.swift b/TablePro/Views/Rewind/RewindReviewSheet.swift index 9f4141d18e..ad7e04d82f 100644 --- a/TablePro/Views/Rewind/RewindReviewSheet.swift +++ b/TablePro/Views/Rewind/RewindReviewSheet.swift @@ -10,6 +10,7 @@ import SwiftUI import TableProPluginKit +import TableProTheme struct RewindReviewSheet: View { @ObservedObject private var themeEngine = ThemeEngine.shared diff --git a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift index 2db3ce9da3..2f02de7fc4 100644 --- a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift @@ -4,10 +4,13 @@ // import SwiftUI +import TableProTheme internal struct BlobHexEditorView: View { @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext + /// False inside a pane that is already a value well, the image field's source pane. + var drawsWell = true @FocusState private var isFocused: Bool @State private var hexEditText = "" @@ -51,11 +54,13 @@ internal struct BlobHexEditorView: View { ScrollView([.horizontal, .vertical]) { Text(BlobFormattingService.shared.format(context.value.wrappedValue, for: .detail) ?? "") .font(themeEngine.valueFontSwiftUI) + .themeForeground(.editorText, native: .primary) .textSelection(.enabled) .fixedSize() .frame(maxWidth: .infinity, alignment: .topLeading) } .frame(maxHeight: 120) + .modifier(ThemedDumpWell(isEnabled: drawsWell)) } /// A value past the cap shows the dump it can show, selectable, rather than a disabled field. @@ -139,3 +144,24 @@ internal struct BlobHexEditorView: View { } } } + +/// The dump sits straight on the inspector natively. Under a theme it takes the well every other +/// value there sits in, so it does not read as system text beside the theme's wells. +private struct ThemedDumpWell: ViewModifier { + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast + + let isEnabled: Bool + + private static let shape = RoundedRectangle(cornerRadius: 5) + + func body(content: Content) -> some View { + let fill = isEnabled + ? ThemeShapeStyle.override(.editorBackground, engine: themeEngine, contrast: contrast) + : nil + content + .padding(fill == nil ? 0 : 6) + .background(fill ?? Color.clear, in: Self.shape) + .overlay(Self.shape.strokeBorder(fill == nil ? Color.clear : Color(nsColor: .separatorColor))) + } +} diff --git a/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift index 58c827cc5a..c59b5b409c 100644 --- a/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift @@ -5,6 +5,7 @@ import Combine import SwiftUI +import TableProTheme /// A field whose value is a geometry: a map of that value, with the editor the field would /// otherwise get one segment away. @@ -188,9 +189,7 @@ internal struct GeometryFieldView: View { ) { mapContent(preview) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .geometryMapWell() } if case .drawable(_, let caption) = preview { Text(caption) @@ -347,3 +346,12 @@ internal final class GeometryFieldPreviewStore: ObservableObject { token &+= 1 } } + +internal extension View { + /// The map pane's well, which shows while the map builds or for a value with nothing to draw. + func geometryMapWell() -> some View { + themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + } +} diff --git a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift index 39527a0a6c..c7c997d0dc 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift @@ -58,7 +58,7 @@ internal struct ImageFieldView: View { style: .code ) case .hex: - BlobHexEditorView(context: context) + BlobHexEditorView(context: context, drawsWell: false) .padding(8) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift index 29d1652a76..4e37ce54e5 100644 --- a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme internal struct SingleLineEditorView: View { let context: FieldEditorContext diff --git a/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift b/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift index b07733c05f..2c3f4bab6e 100644 --- a/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift @@ -7,6 +7,7 @@ import AppKit import SwiftUI +import TableProTheme struct JSONNodeRowView: View { @ObservedObject private var themeEngine = ThemeEngine.shared diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index fef0888d9b..7589ba84af 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import TableProTheme struct JSONRowInspectorView: View { @ObservedObject var viewModel: JSONRowInspectorViewModel diff --git a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift index 926a6b57af..2d2f8e4078 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift @@ -51,7 +51,8 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { ) context.coordinator.slowQueriesController?.rootView = SlowQueryListView( queries: viewModel.slowQueries, - error: viewModel.panelErrors[.slowQueries] + error: viewModel.panelErrors[.slowQueries], + databaseType: viewModel.databaseType ) } @@ -91,7 +92,8 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { let controller = NSHostingController( rootView: SlowQueryListView( queries: viewModel.slowQueries, - error: viewModel.panelErrors[.slowQueries] + error: viewModel.panelErrors[.slowQueries], + databaseType: viewModel.databaseType ) ) controller.sizingOptions = [] diff --git a/TablePro/Views/ServerDashboard/ServerDashboardView.swift b/TablePro/Views/ServerDashboard/ServerDashboardView.swift index 2789fb7019..d49b219dbc 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardView.swift @@ -21,21 +21,29 @@ struct ServerDashboardView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { viewModel.startAutoRefresh() } .onDisappear { viewModel.stopAutoRefresh() } - .alert(String(localized: "Terminate Session"), isPresented: $viewModel.showKillConfirmation) { - Button(String(localized: "Cancel"), role: .cancel) { viewModel.pendingKillProcessId = nil } + .alert( + String(localized: "Terminate Session"), + isPresented: $viewModel.showKillConfirmation, + presenting: viewModel.pendingKillSession + ) { _ in + Button(String(localized: "Cancel"), role: .cancel) { viewModel.pendingKillSession = nil } Button(String(localized: "Terminate"), role: .destructive) { Task { await viewModel.executeKillSession() } } - } message: { - Text(String(localized: "Are you sure you want to terminate this session? Any running queries will be aborted.")) + } message: { session in + Text(ServerDashboardViewModel.killConfirmationMessage(for: session)) } - .alert(String(localized: "Cancel Query"), isPresented: $viewModel.showCancelConfirmation) { - Button(String(localized: "Keep Running"), role: .cancel) { viewModel.pendingCancelProcessId = nil } + .alert( + String(localized: "Cancel Query"), + isPresented: $viewModel.showCancelConfirmation, + presenting: viewModel.pendingCancelSession + ) { _ in + Button(String(localized: "Keep Running"), role: .cancel) { viewModel.pendingCancelSession = nil } Button(String(localized: "Cancel Query"), role: .destructive) { Task { await viewModel.executeCancelQuery() } } - } message: { - Text(String(localized: "Are you sure you want to cancel the running query for this session?")) + } message: { session in + Text(ServerDashboardViewModel.cancelConfirmationMessage(for: session)) } .alert(String(localized: "Action Failed"), isPresented: Binding( get: { viewModel.actionError != nil }, diff --git a/TablePro/Views/ServerDashboard/SessionsTableView.swift b/TablePro/Views/ServerDashboard/SessionsTableView.swift index 86b68ab40c..bdb789cb09 100644 --- a/TablePro/Views/ServerDashboard/SessionsTableView.swift +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -1,96 +1,191 @@ import SwiftUI -struct SessionsTableView: View { - @ObservedObject var viewModel: ServerDashboardViewModel - @State private var selection: Set = [] +/// Sessions are server records, so they draw in the data grid and take its theme. The grid has no +/// per-row buttons, so Cancel and Terminate act on the selected session from the header. +internal struct SessionsTableView: View { + @ObservedObject internal var viewModel: ServerDashboardViewModel + @ObservedObject private var selection: StructureListSelection + @StateObject private var grid = StructureListGridModel( + accessibilityLabel: String(localized: "Active Sessions") + ) + @State private var sortState = SessionGridColumns.defaultSort + @State private var columnLayout = ColumnLayoutState() - var body: some View { + internal init(viewModel: ServerDashboardViewModel) { + _viewModel = ObservedObject(wrappedValue: viewModel) + _selection = ObservedObject(wrappedValue: viewModel.sessionSelection) + } + + internal var body: some View { VStack(alignment: .leading, spacing: 0) { - HStack { - Label(String(localized: "Active Sessions"), systemImage: "person.2") - .font(.headline) - Text("(\(viewModel.sessions.count))") - .foregroundStyle(.secondary) - Spacer() - if let error = viewModel.panelErrors[.activeSessions] { - Label { - RevealedTextView(error) - } icon: { - Image(systemName: "exclamationmark.triangle") - } - .font(.caption) - .foregroundStyle(.red) + header + table + } + } + + private var header: some View { + HStack { + Label(String(localized: "Active Sessions"), systemImage: "person.2") + .font(.headline) + Text("(\(viewModel.sessions.count))") + .foregroundStyle(.secondary) + Spacer() + if let error = viewModel.panelErrors[.activeSessions] { + Label { + RevealedTextView(error) + } icon: { + Image(systemName: "exclamationmark.triangle") } + .font(.caption) + .foregroundStyle(.red) + } + sessionActions + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var sessionActions: some View { + let target = SessionGridColumns.actionTarget(selection.ids, in: viewModel.sessions) + let cancellable = target.map(viewModel.canCancel) ?? false + let killable = target.map(viewModel.canKill) ?? false + return HStack(spacing: 4) { + Button { + guard let target else { return } + viewModel.confirmCancelQuery(target) + } label: { + Image(systemName: "stop.circle") } - .padding(.horizontal, 12) - .padding(.vertical, 8) + .disabled(!cancellable) + .help(String(localized: "Cancel Query")) + .accessibilityLabel(target.map { + String(format: String(localized: "Cancel query for session %@"), $0.id) + } ?? String(localized: "Cancel Query")) - Table(viewModel.sessions, selection: $selection, sortOrder: $viewModel.sessionSortOrder) { - TableColumn(String(localized: "PID"), value: \.id) { session in - Text(session.id).monospacedDigit() + Button { + guard let target else { return } + viewModel.confirmKillSession(target) + } label: { + // The red marks a live destructive action, so a disabled button keeps the standard glyph. + if killable { + Image(systemName: "xmark.circle") + .foregroundStyle(.red) + } else { + Image(systemName: "xmark.circle") } - .width(min: 50, ideal: 70) + } + .disabled(!killable) + .help(String(localized: "Terminate Session")) + .accessibilityLabel(target.map { + String(format: String(localized: "Terminate session %@"), $0.id) + } ?? String(localized: "Terminate Session")) + } + .buttonStyle(.borderless) + } - TableColumn(String(localized: "User"), value: \.user) - .width(min: 60, ideal: 100) + private var table: some View { + let rows = SessionGridColumns.sorted(viewModel.sessions, by: sortState).map(SessionGridColumns.row(for:)) + let selection = selection + var hasher = Hasher() + hasher.combine(rows) + return StructureListGrid( + model: grid, + columns: SessionGridColumns.titles, + defaultWidths: SessionGridColumns.widths, + minimumWidths: SessionGridColumns.minimumWidths, + contentKey: hasher.finalize(), + rows: { rows }, + databaseType: viewModel.databaseType, + selectedIDs: { selection.ids }, + select: { selection.ids = $0 }, + sortState: $sortState, + columnLayout: $columnLayout + ) + } +} - TableColumn(String(localized: "Database"), value: \.database) - .width(min: 60, ideal: 100) +internal enum SessionGridColumns { + internal static let stateColumn = 3 + internal static let durationColumn = 4 - TableColumn(String(localized: "State"), value: \.state) { session in - Text(session.state) - .selectionAwareTint(stateColor(session.state)) - } - .width(min: 60, ideal: 80) + internal static var titles: [String] { + [ + String(localized: "PID"), + String(localized: "User"), + String(localized: "Database"), + String(localized: "State"), + String(localized: "Duration"), + String(localized: "Query"), + ] + } - TableColumn(String(localized: "Duration"), value: \.durationSeconds) { session in - Text(session.duration).monospacedDigit() - } - .width(min: 50, ideal: 80) + internal static let widths: [CGFloat] = [70, 100, 100, 110, 80, 360] + internal static let minimumWidths: [CGFloat] = [50, 60, 60, 60, 50, 120] - TableColumn(String(localized: "Query"), value: \.query) { session in - Text(session.query) - .lineLimit(1) - .truncationMode(.tail) - .help(session.query) - } + // The longest-running session first, as the dashboard has always opened. + internal static var defaultSort: SortState { + SortState( + columns: [SortColumn(columnIndex: durationColumn, direction: .descending, columnName: String(localized: "Duration"))], + source: .user + ) + } - TableColumn("") { session in - HStack(spacing: 4) { - if viewModel.canCancel(session) { - Button { viewModel.confirmCancelQuery(processId: session.id) } label: { - Image(systemName: "stop.circle") - } - .buttonStyle(.borderless) - .help(String(localized: "Cancel Query")) - .accessibilityLabel(String(format: String(localized: "Cancel query for session %@"), session.id)) - } - if viewModel.canKill(session) { - Button { viewModel.confirmKillSession(processId: session.id) } label: { - Image(systemName: "xmark.circle") - .foregroundStyle(.red) - } - .buttonStyle(.borderless) - .help(String(localized: "Terminate Session")) - .accessibilityLabel(String(format: String(localized: "Terminate session %@"), session.id)) - } - } - } - .width(60) - } - .onChange(of: viewModel.sessionSortOrder) { newOrder in - viewModel.sessions.sort(using: newOrder) - } - } + internal static func values(for session: DashboardSession) -> [String] { + [session.id, session.user, session.database, session.state, session.duration, session.query] } - private func stateColor(_ state: String) -> Color { + internal static func row(for session: DashboardSession) -> StructureListRow { + var row = StructureListRow(id: session.id, values: values(for: session)) + row.tones[stateColumn] = stateTone(session.state) + return row + } + + internal static func stateTone(_ state: String) -> StructureListTone? { switch state.lowercased() { - case "active", "running": return .green + case "active", "running": return .positive case "idle": return .secondary - case "idle in transaction": return .orange - case "waiting", "locked": return .red - default: return .primary + case "idle in transaction": return .caution + case "waiting", "locked": return .critical + default: return nil + } + } + + // Duration sorts by its seconds: the drawn text is a formatted span. + internal static func sorted(_ sessions: [DashboardSession], by state: SortState) -> [DashboardSession] { + guard !state.columns.isEmpty else { return sessions } + let keyed = sessions.enumerated().map { offset, session in + (offset: offset, session: session, values: values(for: session)) + } + return keyed.sorted { lhs, rhs in + for column in state.columns where lhs.values.indices.contains(column.columnIndex) { + let comparison = compare(lhs.session, rhs.session, lhs.values, rhs.values, column: column.columnIndex) + guard comparison != .orderedSame else { continue } + return column.direction == .ascending + ? comparison == .orderedAscending + : comparison == .orderedDescending + } + return lhs.offset < rhs.offset + } + .map(\.session) + } + + // Cancel and Terminate name one session, so several selected rows have no target. + internal static func actionTarget(_ selectedIDs: Set, in sessions: [DashboardSession]) -> DashboardSession? { + guard selectedIDs.count == 1, let id = selectedIDs.first else { return nil } + return sessions.first { $0.id == id } + } + + private static func compare( + _ lhs: DashboardSession, + _ rhs: DashboardSession, + _ lhsValues: [String], + _ rhsValues: [String], + column: Int + ) -> ComparisonResult { + guard column == durationColumn else { + return lhsValues[column].localizedStandardCompare(rhsValues[column]) } + if lhs.durationSeconds == rhs.durationSeconds { return .orderedSame } + return lhs.durationSeconds < rhs.durationSeconds ? .orderedAscending : .orderedDescending } } diff --git a/TablePro/Views/ServerDashboard/SlowQueryListView.swift b/TablePro/Views/ServerDashboard/SlowQueryListView.swift index 74e2a2e39f..c5e2b850e5 100644 --- a/TablePro/Views/ServerDashboard/SlowQueryListView.swift +++ b/TablePro/Views/ServerDashboard/SlowQueryListView.swift @@ -1,10 +1,19 @@ import SwiftUI -struct SlowQueryListView: View { - let queries: [DashboardSlowQuery] - let error: String? +/// Slow queries are server records, so they draw in the data grid and take its theme. The server's +/// order, slowest first, is kept: the drawn duration is a formatted span that would not sort. +internal struct SlowQueryListView: View { + internal let queries: [DashboardSlowQuery] + internal let error: String? + internal let databaseType: DatabaseType - var body: some View { + @StateObject private var grid = StructureListGridModel( + accessibilityLabel: String(localized: "Slow Queries") + ) + @StateObject private var selection = StructureListSelection() + @State private var columnLayout = ColumnLayoutState() + + internal var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { Label(String(localized: "Slow Queries"), systemImage: "tortoise") @@ -30,44 +39,68 @@ struct SlowQueryListView: View { .foregroundStyle(.secondary) .font(.caption) .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() } else { - List { - ForEach(queries) { query in - slowQueryRow(query) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 2, leading: 12, bottom: 2, trailing: 12)) - } - } - .listStyle(.plain) + table } } } - private func slowQueryRow(_ query: DashboardSlowQuery) -> some View { - HStack(alignment: .top, spacing: 8) { - Text(query.duration) - .font(.system(.caption, design: .monospaced)) - .monospacedDigit() - .foregroundStyle(.orange) - .frame(width: 50, alignment: .trailing) + private var table: some View { + let rows = SlowQueryGridRows.rows(for: queries) + let selection = selection + var hasher = Hasher() + hasher.combine(rows) + return StructureListGrid( + model: grid, + columns: SlowQueryGridRows.titles, + defaultWidths: SlowQueryGridRows.widths, + minimumWidths: SlowQueryGridRows.minimumWidths, + contentKey: hasher.finalize(), + rows: { rows }, + databaseType: databaseType, + selectedIDs: { selection.ids }, + select: { selection.ids = $0 }, + sortState: nil, + columnLayout: $columnLayout + ) + } +} - VStack(alignment: .leading, spacing: 2) { - Text(query.query) - .font(.system(.caption, design: .monospaced)) - .lineLimit(2) - .truncationMode(.tail) - HStack(spacing: 8) { - if !query.user.isEmpty { - Text(query.user) - } - if !query.database.isEmpty { - Text(query.database) - } - } - .font(.caption2) - .foregroundStyle(.tertiary) - } +internal enum SlowQueryGridRows { + // A running query keeps its row across refreshes while its duration grows, so the duration is + // not part of the identity; the same statement twice is told apart by its occurrence. + internal struct RowID: Hashable { + internal let statement: String + internal let user: String + internal let database: String + internal let occurrence: Int + } + + internal static var titles: [String] { + [ + String(localized: "Duration"), + String(localized: "Query"), + String(localized: "User"), + String(localized: "Database"), + ] + } + + internal static let durationColumn = 0 + internal static let widths: [CGFloat] = [80, 420, 100, 100] + internal static let minimumWidths: [CGFloat] = [50, 120, 60, 60] + + internal static func rows(for queries: [DashboardSlowQuery]) -> [StructureListRow] { + var seen: [RowID: Int] = [:] + return queries.map { query in + let base = RowID(statement: query.query, user: query.user, database: query.database, occurrence: 0) + let occurrence = seen[base, default: 0] + seen[base] = occurrence + 1 + return StructureListRow( + id: RowID(statement: query.query, user: query.user, database: query.database, occurrence: occurrence), + values: [query.duration, query.query, query.user, query.database], + tones: [durationColumn: .caution] + ) } - .padding(.vertical, 2) } } diff --git a/TablePro/Views/Settings/Appearance/CustomThemeListModel.swift b/TablePro/Views/Settings/Appearance/CustomThemeListModel.swift deleted file mode 100644 index eb2bed918a..0000000000 --- a/TablePro/Views/Settings/Appearance/CustomThemeListModel.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation - -internal enum CustomThemeItem: Hashable, Sendable { - case theme(String) - case notLoaded(path: String) -} - -/// The rows of the Custom Themes list: the user's themes in catalog order, then the files the -/// catalog refused, each with its reason. -internal struct CustomThemeListModel: Equatable { - internal let themes: [ThemeDefinition] - internal let notLoaded: [RejectedThemeRecord] - - internal init(themes: [ThemeDefinition], rejected: [RejectedThemeRecord]) { - self.themes = themes.filter(\.isEditable) - notLoaded = rejected - } - - internal var isEmpty: Bool { - themes.isEmpty && notLoaded.isEmpty - } - - internal func theme(for item: CustomThemeItem?) -> ThemeDefinition? { - guard case let .theme(id) = item else { return nil } - return themes.first { $0.id == id } - } - - internal func record(for item: CustomThemeItem?) -> RejectedThemeRecord? { - guard case let .notLoaded(path) = item else { return nil } - return notLoaded.first { $0.path == path } - } - - internal static func appearanceLabel(_ appearance: ThemeAppearance) -> String { - switch appearance { - case .light: return String(localized: "Light") - case .dark: return String(localized: "Dark") - } - } -} - -internal enum ThemeNaming { - /// The name a rename stores, or nil when there is nothing to change. A name another theme - /// already has is numbered the way the catalog numbers copies, because two popup items with - /// one title cannot be told apart. - internal static func renamed(_ proposed: String, current: String, takenNames: Set) -> String? { - let trimmed = proposed.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, trimmed != current else { return nil } - guard takenNames.contains(trimmed) else { return trimmed } - - var index = 2 - while takenNames.contains("\(trimmed) \(index)") { - index += 1 - } - let numbered = "\(trimmed) \(index)" - return numbered == current ? nil : numbered - } -} - -internal extension AppearanceSettings { - /// A slot that held a theme the user just deleted goes back to its built-in, so the popup does - /// not then report that theme as missing. - func releasing(themeId: String) -> AppearanceSettings { - var updated = self - if updated.preferredLightThemeId == themeId { - updated.preferredLightThemeId = BuiltInThemes.defaultLightId - } - if updated.preferredDarkThemeId == themeId { - updated.preferredDarkThemeId = BuiltInThemes.defaultDarkId - } - return updated - } -} diff --git a/TablePro/Views/Settings/Appearance/CustomThemesSection.swift b/TablePro/Views/Settings/Appearance/CustomThemesSection.swift deleted file mode 100644 index 60edf6d0f0..0000000000 --- a/TablePro/Views/Settings/Appearance/CustomThemesSection.swift +++ /dev/null @@ -1,392 +0,0 @@ -import AppKit -import os -import SwiftUI -import UniformTypeIdentifiers - -/// The user's theme files, with the add and remove pair and the action pull-down macOS puts under -/// a list. Every action names the theme it acts on: a context menu acts on the row under the -/// pointer, which is not always the selected one. -internal struct CustomThemesSection: View { - @Binding internal var settings: AppearanceSettings - - @ObservedObject private var catalog = ThemeCatalog.shared - - @State private var selection: CustomThemeItem? - @State private var editing: ThemeDefinition? - @State private var renaming: ThemeDefinition? - @State private var renameText = "" - @State private var deleting: ThemeDefinition? - @State private var failure: Failure? - - private struct Failure { - let title: String - let message: String - } - - private static let logger = Logger(subsystem: "com.TablePro", category: "CustomThemesSection") - private static let rowHeight: CGFloat = 34 - private static let barHeight: CGFloat = 28 - - private var model: CustomThemeListModel { - CustomThemeListModel(themes: catalog.themes, rejected: catalog.rejected) - } - - private var selectedTheme: ThemeDefinition? { - model.theme(for: selection) - } - - internal var body: some View { - Section { - list - .sheet(item: $editing) { theme in - ThemeEditorSheet(theme: theme, takenNames: takenNames(excluding: theme.id), save: saveEdited) - } - .alert(failure?.title ?? "", isPresented: isPresented($failure), presenting: failure) { _ in - Button(String(localized: "OK")) {} - } message: { failure in - Text(failure.message) - } - .onAppear { reload() } - // Picks up a file fixed or dropped into the Themes folder while TablePro was in the background. - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - guard editing == nil else { return } - reload() - } - } header: { - Text("Custom Themes") - } - } - - // MARK: - List - - private var list: some View { - List(selection: $selection) { - ForEach(model.themes) { theme in - themeRow(theme) - .tag(CustomThemeItem.theme(theme.id)) - } - - if !model.notLoaded.isEmpty { - Section(String(localized: "Not Loaded")) { - ForEach(model.notLoaded) { record in - notLoadedRow(record) - .tag(CustomThemeItem.notLoaded(path: record.path)) - } - } - } - } - .listStyle(.bordered(alternatesRowBackgrounds: false)) - .frame(height: listHeight) - .contextMenu(forSelectionType: CustomThemeItem.self) { items in - rowMenu(for: items.first) - } primaryAction: { items in - guard let theme = model.theme(for: items.first) else { return } - editing = theme - } - .onDeleteCommand { requestDelete(selectedTheme) } - .overlay { - if model.isEmpty { - Text("No Custom Themes") - .foregroundStyle(.secondary) - .padding(.bottom, Self.barHeight) - } - } - .safeAreaInset(edge: .bottom, spacing: 0) { - footer - } - .alert(String(localized: "Delete Theme"), isPresented: isPresented($deleting), presenting: deleting) { theme in - Button(String(localized: "Delete"), role: .destructive) { delete(theme) } - Button(String(localized: "Cancel"), role: .cancel) {} - } message: { theme in - Text(String(format: String(localized: "Are you sure you want to delete \"%@\"?"), theme.name)) - } - } - - private var listHeight: CGFloat { - let rows = CGFloat(max(model.themes.count + model.notLoaded.count, 3)) - let sectionHeader: CGFloat = model.notLoaded.isEmpty ? 0 : 28 - return min(rows * Self.rowHeight + sectionHeader + Self.barHeight + 8, 260) - } - - private func themeRow(_ theme: ThemeDefinition) -> some View { - VStack(alignment: .leading, spacing: 1) { - Text(theme.name) - .lineLimit(1) - Text(CustomThemeListModel.appearanceLabel(theme.appearance)) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 2) - } - - private func notLoadedRow(_ record: RejectedThemeRecord) -> some View { - VStack(alignment: .leading, spacing: 1) { - Text(record.fileName) - .lineLimit(1) - Text(record.reason) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - .padding(.vertical, 2) - .help(record.reason) - } - - /// A contextual menu leaves out what does not apply; the pull-down below dims it instead. - @ViewBuilder - private func rowMenu(for item: CustomThemeItem?) -> some View { - if let theme = model.theme(for: item) { - Button(String(localized: "Edit…")) { editing = theme } - Button(String(localized: "Rename…")) { beginRename(theme) } - Button(String(localized: "Duplicate")) { duplicate(theme) } - Button(String(localized: "Export…")) { export(theme) } - Button(String(localized: "Show in Finder")) { reveal(theme) } - Divider() - Button(String(localized: "Delete…")) { requestDelete(theme) } - } else if let record = model.record(for: item) { - Button(String(localized: "Show in Finder")) { reveal(URL(fileURLWithPath: record.path)) } - } - } - - // MARK: - Footer - - private var footer: some View { - VStack(spacing: 0) { - Divider() - HStack(spacing: 0) { - ControlGroup { - addMenu - - Button { - requestDelete(selectedTheme) - } label: { - Label(String(localized: "Delete Theme"), systemImage: "minus") - .labelStyle(.iconOnly) - } - .help(String(localized: "Delete Theme")) - .accessibilityLabel(String(localized: "Delete Theme")) - .accessibilityIdentifier("appearance-theme-remove") - .disabled(selectedTheme == nil) - - actionsMenu - } - .controlGroupStyle(.navigation) - .controlSize(.small) - .fixedSize() - - Spacer() - } - .padding(.horizontal, 4) - .padding(.vertical, 2) - } - .background(.bar) - .alert(String(localized: "Rename Theme"), isPresented: isPresented($renaming), presenting: renaming) { theme in - TextField(String(localized: "Name"), text: $renameText) - .accessibilityIdentifier("appearance-theme-rename-field") - Button(String(localized: "Rename")) { rename(theme) } - Button(String(localized: "Cancel"), role: .cancel) {} - } - } - - /// An icon-only `Menu` is named only through `.contain` plus a label on the menu itself; a - /// label inside its label closure leaves VoiceOver reading "More", or nothing on CI. - private var addMenu: some View { - Menu { - Menu(String(localized: "New Theme Based On")) { - ForEach(catalog.themes.filter(\.isBuiltIn)) { theme in - Button(theme.name) { duplicate(theme) } - } - if !model.themes.isEmpty { - Divider() - ForEach(model.themes) { theme in - Button(theme.name) { duplicate(theme) } - } - } - } - Divider() - Button(String(localized: "Import…"), action: importTheme) - } label: { - Label(String(localized: "Add Theme"), systemImage: "plus") - .labelStyle(.iconOnly) - } - .menuIndicator(.hidden) - .help(String(localized: "Add Theme")) - .accessibilityElement(children: .contain) - .accessibilityLabel(String(localized: "Add Theme")) - .accessibilityIdentifier("appearance-theme-add") - } - - private var actionsMenu: some View { - Menu { - Button(String(localized: "Edit…")) { editing = selectedTheme } - .disabled(selectedTheme == nil) - Button(String(localized: "Rename…")) { - if let theme = selectedTheme { beginRename(theme) } - } - .disabled(selectedTheme == nil) - Button(String(localized: "Duplicate")) { - if let theme = selectedTheme { duplicate(theme) } - } - .disabled(selectedTheme == nil) - Button(String(localized: "Export…")) { - if let theme = selectedTheme { export(theme) } - } - .disabled(selectedTheme == nil) - Divider() - Button(String(localized: "Show in Finder"), action: revealSelection) - } label: { - Label(String(localized: "Theme Actions"), systemImage: "ellipsis.circle") - .labelStyle(.iconOnly) - } - .menuIndicator(.hidden) - .help(String(localized: "Theme Actions")) - .accessibilityElement(children: .contain) - .accessibilityLabel(String(localized: "Theme Actions")) - .accessibilityIdentifier("appearance-theme-actions") - } - - // MARK: - Actions - - private func duplicate(_ theme: ThemeDefinition) { - perform(String(localized: "Could not create the theme")) { - let copy = try catalog.duplicate(theme) - selection = .theme(copy.id) - } - } - - private func beginRename(_ theme: ThemeDefinition) { - renameText = theme.name - renaming = theme - } - - private func rename(_ theme: ThemeDefinition) { - let taken = takenNames(excluding: theme.id) - guard let name = ThemeNaming.renamed(renameText, current: theme.name, takenNames: taken) else { return } - perform(String(localized: "Could not rename the theme")) { - try catalog.rename(id: theme.id, to: name) - reapply() - } - } - - private func requestDelete(_ theme: ThemeDefinition?) { - guard let theme else { return } - deleting = theme - } - - private func delete(_ theme: ThemeDefinition) { - perform(String(localized: "Could not delete the theme")) { - do { - try catalog.delete(id: theme.id) - } catch { - reapply() - throw error - } - if selection == .theme(theme.id) { - selection = nil - } - let released = settings.releasing(themeId: theme.id) - if released != settings { - settings = released - } else { - reapply() - } - } - } - - private func importTheme() { - guard let window = AlertHelper.resolveWindow(nil) else { return } - - let panel = NSOpenPanel() - panel.allowedContentTypes = [.json] - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.beginSheetModal(for: window) { response in - guard response == .OK, let url = panel.url else { return } - perform(String(localized: "Could not import the theme")) { - let imported = try catalog.importTheme(from: url) - selection = .theme(imported.id) - // A saved choice that was missing on this Mac may be the theme just imported. - reapply() - } - } - } - - private func export(_ theme: ThemeDefinition) { - guard let window = AlertHelper.resolveWindow(nil) else { return } - - let panel = NSSavePanel() - panel.allowedContentTypes = [.json] - panel.nameFieldStringValue = theme.name + ".json" - panel.canCreateDirectories = true - panel.beginSheetModal(for: window) { response in - guard response == .OK, let url = panel.url else { return } - perform(String(localized: "Could not export the theme")) { - try catalog.exportTheme(theme, to: url) - } - } - } - - private func revealSelection() { - if let theme = selectedTheme { - reveal(theme) - } else if let record = model.record(for: selection) { - reveal(URL(fileURLWithPath: record.path)) - } else { - NSWorkspace.shared.open(catalog.themesDirectory) - } - } - - private func reveal(_ theme: ThemeDefinition) { - guard let url = catalog.fileURL(for: theme.id) else { - NSWorkspace.shared.open(catalog.themesDirectory) - return - } - reveal(url) - } - - private func reveal(_ url: URL) { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - - // MARK: - Catalog - - /// A conflict with the file on disk reloads the catalog and throws; the engine still takes the reloaded theme. - private func saveEdited(_ theme: ThemeDefinition) throws { - defer { reapply() } - try catalog.save(theme) - } - - private func reload() { - let before = catalog.themes - catalog.reloadSynchronously() - guard catalog.themes != before else { return } - reapply() - } - - private func reapply() { - ThemeEngine.shared.reapply( - lightThemeId: settings.preferredLightThemeId, - darkThemeId: settings.preferredDarkThemeId - ) - } - - private func takenNames(excluding id: String) -> Set { - Set(catalog.themes.filter { $0.id != id }.map(\.name)) - } - - private func perform(_ title: String, _ work: () throws -> Void) { - do { - try work() - } catch { - Self.logger.error("Theme action failed: \(error.publicLogShape, privacy: .public)") - failure = Failure(title: title, message: error.localizedDescription) - } - } - - private func isPresented(_ value: Binding) -> Binding { - Binding( - get: { value.wrappedValue != nil }, - set: { if !$0 { value.wrappedValue = nil } } - ) - } -} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorDraft.swift b/TablePro/Views/Settings/Appearance/ThemeEditorDraft.swift deleted file mode 100644 index a47a9fe3d9..0000000000 --- a/TablePro/Views/Settings/Appearance/ThemeEditorDraft.swift +++ /dev/null @@ -1,47 +0,0 @@ -import AppKit - -/// The theme an Edit sheet is changing. Each mutation reports whether it changed anything, so the -/// sheet schedules a save only for a real edit. -internal struct ThemeEditorDraft: Equatable { - internal var theme: ThemeDefinition - - /// The color a well shows. A slot the theme leaves out shows the system color it draws in, - /// resolved in the theme's appearance rather than the one Settings is in. - internal func displayColor(for slot: ThemeSlot) -> NSColor { - let color = theme.color(for: slot) - guard !theme.isOverridden(slot), - let appearance = NSAppearance(named: theme.appearance == .dark ? .darkAqua : .aqua) - else { return color } - - var resolved = color - appearance.performAsCurrentDrawingAppearance { - resolved = color.usingColorSpace(.sRGB) ?? color - } - return resolved - } - - /// A well can echo the color it was handed. Taking that echo as an edit would freeze a system - /// color into the file as a fixed value, so a value equal to what the well shows is ignored. - @discardableResult - internal mutating func setColor(_ color: ThemeColor, for slot: ThemeSlot) -> Bool { - if let existing = theme.overrides[slot] { - guard existing != color else { return false } - } else { - guard ThemeColor(displayColor(for: slot)) != color else { return false } - } - theme.overrides[slot] = color - return true - } - - @discardableResult - internal mutating func reset(_ slot: ThemeSlot) -> Bool { - theme.overrides.removeValue(forKey: slot) != nil - } - - @discardableResult - internal mutating func rename(to name: String) -> Bool { - guard name != theme.name else { return false } - theme.name = name - return true - } -} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorModel.swift b/TablePro/Views/Settings/Appearance/ThemeEditorModel.swift new file mode 100644 index 0000000000..b79452e486 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorModel.swift @@ -0,0 +1,490 @@ +import AppKit +import Combine +import Foundation +import os +import TableProTheme + +@MainActor +internal protocol ThemePreviewing: AnyObject { + func beginPreview(_ draft: ThemeDefinition) + func endPreview(id: String) +} + +extension ThemeEngine: ThemePreviewing {} + +/// One open theme. The draft is saved a moment after it settles and on close, previewed in every +/// window while it is edited, and undone one settled step at a time. The catalog reloads whenever +/// TablePro becomes active, so a file changed or removed elsewhere is noticed here before a save +/// could write over it. +@MainActor +internal final class ThemeEditorModel: ObservableObject { + internal enum Alert: Identifiable, Equatable { + case changedOnDisk(name: String) + case unreadableOnDisk(name: String) + case deletedOnDisk(name: String) + case saveFailed(name: String, reason: String) + + internal var id: String { + switch self { + case .changedOnDisk: return "changed" + case .unreadableOnDisk: return "unreadable" + case .deletedOnDisk: return "deleted" + case .saveFailed: return "save" + } + } + } + + internal enum CloseDecision: Equatable { + case close + case keepEditing + case confirmDiscard(name: String, reason: String) + } + + @Published internal private(set) var draft: ThemeDefinition + @Published internal private(set) var openingSnapshot: ThemeDefinition + @Published internal private(set) var previewScope: ThemeScope + @Published internal private(set) var fileURL: URL? + @Published internal private(set) var isDetached = false + @Published internal var alert: Alert? + @Published internal var nameText: String + @Published internal var authorText: String + @Published internal private(set) var nameError: String? + @Published internal private(set) var authorError: String? + + internal let undoManager = UndoManager() + internal let isReadOnly: Bool + internal var onRequestClose: (() -> Void)? + internal var onIdentifierChange: ((_ old: String, _ new: String) -> Void)? + + internal var isEditable: Bool { !isReadOnly && !isDetached } + internal var hasChangesSinceOpening: Bool { draft != openingSnapshot } + + private let catalog: ThemeCatalog + private let preview: ThemePreviewing + private let selection: ThemeSelectionStore? + private let clock: any Clock + private let saveDelay: Duration + private var lastSaved: ThemeDefinition + private var saveTask: Task? + private var catalogCancellable: AnyCancellable? + private var isSaving = false + private var isClosed = false + /// The slot whose well is still being dragged. Its ticks share one undo step until the drag + /// settles, which is when the debounced save runs. + private var settlingSlot: ThemeSlot? + + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditor") + + internal static let defaultSaveDelay = Duration.milliseconds(750) + + internal init( + theme: ThemeDefinition, + catalog: ThemeCatalog, + preview: ThemePreviewing, + selection: ThemeSelectionStore?, + clock: any Clock = ContinuousClock(), + saveDelay: Duration = ThemeEditorModel.defaultSaveDelay + ) { + draft = theme + openingSnapshot = theme + lastSaved = theme + previewScope = .fixed(theme) + nameText = theme.name + authorText = theme.author + isReadOnly = !theme.isEditable + fileURL = catalog.fileURL(for: theme.id) + self.catalog = catalog + self.preview = preview + self.selection = selection + self.clock = clock + self.saveDelay = saveDelay + guard !isReadOnly else { return } + catalogCancellable = catalog.$themes.sink { [weak self] _ in + self?.catalogWillPublish() + } + } + + // MARK: - Edits + + /// A well echoes the color it was handed. Taking that echo for an edit would freeze a system + /// color into the file, so a value equal to what an unset slot shows is ignored. + internal func setColor(_ color: ThemeColor, for slot: ThemeSlot) { + guard isEditable else { return } + let value = slot.offersOpacity ? color : color.opaque + if let existing = draft.overrides[slot] { + guard existing != value else { return } + } else { + guard ThemeSlotPresentation.displayColor(for: slot, in: draft) != value else { return } + } + if settlingSlot != slot { + registerUndo(restoring: draft, actionName: String(localized: "Color Change")) + settlingSlot = slot + } + var next = draft + next.overrides[slot] = value + commit(next) + } + + internal func reset(_ slot: ThemeSlot) { + guard isEditable, draft.hasExplicitColor(slot) else { return } + settlingSlot = nil + registerUndo(restoring: draft, actionName: ThemeSlotPresentation.resetTitle(for: slot, in: draft)) + var next = draft + next.overrides[slot] = nil + commit(next) + } + + internal func setAppearance(_ appearance: ThemeAppearance) { + guard isEditable, appearance != draft.appearance else { return } + settlingSlot = nil + registerUndo(restoring: draft, actionName: String(localized: "Appearance Change")) + var next = draft + next.appearance = appearance + commit(next) + } + + @discardableResult + internal func commitName() -> ThemeRenameOutcome { + rename(to: nameText) + } + + /// A taken or malformed name keeps the field as typed with the reason under it, rather than + /// numbering it or dropping it behind the user's back. + @discardableResult + internal func rename(to proposed: String) -> ThemeRenameOutcome { + guard isEditable else { return .unchanged } + let trimmed = proposed.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != draft.name else { + nameText = draft.name + nameError = nil + return .unchanged + } + var next = draft + next.name = trimmed + if let problem = nameProblem(in: next) { + nameText = proposed + nameError = problem + return .rejected(problem) + } + settlingSlot = nil + registerUndo(restoring: draft, actionName: String(localized: "Rename")) + commit(next) + return .renamed(trimmed) + } + + /// False when the text is refused, which then stays in the field with the reason. + @discardableResult + internal func commitAuthor() -> Bool { + guard isEditable else { return true } + let trimmed = authorText.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed != draft.author else { + authorText = draft.author + authorError = nil + return true + } + var next = draft + next.author = trimmed + guard isEncodable(next) else { + authorError = ThemeLoadError.invalidAuthor.localizedDescription + return false + } + settlingSlot = nil + registerUndo(restoring: draft, actionName: String(localized: "Author Change")) + commit(next) + return true + } + + internal func revert() { + guard isEditable, hasChangesSinceOpening else { return } + settlingSlot = nil + registerUndo(restoring: draft, actionName: String(localized: "Revert Changes")) + var next = openingSnapshot + next.id = draft.id + commit(next) + } + + private func commit(_ next: ThemeDefinition) { + showFields(of: next, replacing: draft) + draft = next + previewScope = .fixed(next) + preview.beginPreview(next) + scheduleSave() + } + + /// A field follows the draft only where the draft's value changed. Name and Author commit when + /// focus leaves them, and a well click takes no focus, so a color picked mid-typing would + /// otherwise put the old name back under the caret. + private func showFields(of next: ThemeDefinition, replacing previous: ThemeDefinition) { + if next.name != previous.name { + nameText = next.name + nameError = nil + } + if next.author != previous.author { + authorText = next.author + authorError = nil + } + } + + private func nameProblem(in candidate: ThemeDefinition) -> String? { + let name = candidate.name + let isTaken = catalog.themes.contains { other in + other.id != candidate.id + && other.name.trimmingCharacters(in: .whitespacesAndNewlines) + .compare(name, options: .caseInsensitive) == .orderedSame + } + if isTaken { + return ThemeLoadError.nameTaken(name).localizedDescription + } + return isEncodable(candidate) ? nil : ThemeLoadError.invalidName.localizedDescription + } + + private func isEncodable(_ candidate: ThemeDefinition) -> Bool { + (try? ThemeFileFormat.encode(candidate)) != nil + } + + // MARK: - Undo + + /// A group of its own, so the step is one undo whether or not the manager groups by event. A + /// window's manager does, and nests this in the event's group. + private func registerUndo(restoring snapshot: ThemeDefinition, actionName: String) { + undoManager.beginUndoGrouping() + undoManager.registerUndo(withTarget: self) { model in + MainActor.assumeIsolated { + model.restore(snapshot, actionName: actionName) + } + } + undoManager.setActionName(actionName) + undoManager.endUndoGrouping() + } + + /// Registering the current draft while undoing is what makes the step redoable. + private func restore(_ snapshot: ThemeDefinition, actionName: String) { + guard isEditable else { return } + settlingSlot = nil + registerUndo(restoring: draft, actionName: actionName) + var next = snapshot + next.id = draft.id + commit(next) + } + + // MARK: - Saving + + private func scheduleSave() { + saveTask?.cancel() + let clock = clock + let delay = saveDelay + saveTask = Task { [weak self] in + do { + try await clock.sleep(for: delay) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + self.saveTask = nil + self.save() + } + } + + /// Writes the draft now, with the text still being typed in Name or Author: quitting closes no + /// window, so nothing else commits it. False when the write failed or a newer file took its place. + @discardableResult + internal func flush() -> Bool { + commitName() + commitAuthor() + saveTask?.cancel() + saveTask = nil + return save() + } + + @discardableResult + private func save() -> Bool { + settlingSlot = nil + guard !isReadOnly, !isDetached, draft != lastSaved else { return true } + if let listed = catalog.storedTheme(id: draft.id), listed != lastSaved { + adoptNewerVersion(listed) + return false + } + isSaving = true + defer { isSaving = false } + do { + try catalog.save(draft) + let stored = catalog.storedTheme(id: draft.id) ?? draft + lastSaved = stored + fileURL = catalog.fileURL(for: draft.id) + if stored != draft { + showFields(of: stored, replacing: draft) + draft = stored + previewScope = .fixed(stored) + } + return true + } catch let error as ThemeLoadError { + handleSaveError(error) + return false + } catch { + Self.logger.error("Theme save failed: \(error.publicLogShape, privacy: .public)") + alert = .saveFailed(name: draft.name, reason: error.localizedDescription) + return false + } + } + + private func handleSaveError(_ error: ThemeLoadError) { + Self.logger.error("Theme save refused: \(error.publicLogShape, privacy: .public)") + switch error { + case .changedOnDisk: + if let listed = catalog.storedTheme(id: draft.id) { + adoptNewerVersion(listed) + } else { + detach(.unreadableOnDisk(name: draft.name)) + } + case .deletedOnDisk: + detach(.deletedOnDisk(name: draft.name)) + default: + alert = .saveFailed(name: draft.name, reason: error.localizedDescription) + } + } + + // MARK: - Changes from elsewhere + + /// `@Published` sends before it stores, but the catalog stores the file's own definitions + /// first, so the stored theme read here is already the new one. The shown list can rename a + /// theme to tell two apart, which is not a change to its file. + private func catalogWillPublish() { + guard !isSaving, !isClosed, !isDetached else { return } + guard let listed = catalog.storedTheme(id: draft.id) else { + themeLeftTheCatalog() + return + } + guard listed != lastSaved else { return } + adoptNewerVersion(listed) + } + + private func themeLeftTheCatalog() { + saveTask?.cancel() + saveTask = nil + if let fileURL, FileManager.default.fileExists(atPath: fileURL.path) { + detach(.unreadableOnDisk(name: draft.name)) + } else { + detach(.deletedOnDisk(name: draft.name)) + } + } + + /// The newer file wins, as the catalog's own conflict rule says. The undo steps describe the + /// older version, so they go with it. + private func adoptNewerVersion(_ listed: ThemeDefinition) { + saveTask?.cancel() + saveTask = nil + settlingSlot = nil + undoManager.removeAllActions(withTarget: self) + showFields(of: listed, replacing: draft) + draft = listed + lastSaved = listed + openingSnapshot = listed + previewScope = .fixed(listed) + preview.beginPreview(listed) + alert = .changedOnDisk(name: listed.name) + } + + private func detach(_ reason: Alert) { + saveTask?.cancel() + saveTask = nil + settlingSlot = nil + isDetached = true + undoManager.removeAllActions(withTarget: self) + preview.endPreview(id: draft.id) + alert = reason + } + + /// "Keep Theme" after the file was deleted elsewhere: the draft becomes a new theme with a new + /// id, so a sync of that deletion cannot remove it again, and any slot that chose the old one + /// follows it. + internal func keepAsNewTheme() { + guard isDetached, !isReadOnly else { return } + let oldId = draft.id + var kept = draft + kept.id = ThemeIdentifier.generated() + kept.name = catalog.uniqueName(kept.name) + do { + try catalog.save(kept) + } catch { + Self.logger.error("Keeping a deleted theme failed: \(error.publicLogShape, privacy: .public)") + alert = .saveFailed(name: kept.name, reason: error.localizedDescription) + return + } + let stored = catalog.storedTheme(id: kept.id) ?? kept + isDetached = false + showFields(of: stored, replacing: draft) + draft = stored + lastSaved = stored + openingSnapshot = stored + fileURL = catalog.fileURL(for: stored.id) + previewScope = .fixed(stored) + repointSelection(from: oldId, to: stored.id) + onIdentifierChange?(oldId, stored.id) + } + + private func repointSelection(from oldId: String, to newId: String) { + guard let selection else { return } + var updated = selection.themeSelection + if updated.preferredLightThemeId == oldId { updated.preferredLightThemeId = newId } + if updated.preferredDarkThemeId == oldId { updated.preferredDarkThemeId = newId } + if updated != selection.themeSelection { + selection.themeSelection = updated + } + } + + // MARK: - Closing + + internal func requestClose() { + onRequestClose?() + } + + /// A refused Name or Author keeps the window open with the reason under its field, the same rule + /// as while editing. A write that fails asks first, because closing then loses the changes the + /// alert names. + internal func prepareToClose() -> CloseDecision { + guard isEditable else { return .close } + let nameOutcome = commitName() + let isAuthorAccepted = commitAuthor() + if case .rejected = nameOutcome { + return .keepEditing + } + guard isAuthorAccepted else { return .keepEditing } + saveTask?.cancel() + saveTask = nil + guard draft != lastSaved else { return .close } + if let listed = catalog.storedTheme(id: draft.id), listed != lastSaved { + return .close + } + isSaving = true + defer { isSaving = false } + do { + try catalog.save(draft) + lastSaved = catalog.storedTheme(id: draft.id) ?? draft + return .close + } catch let error as ThemeLoadError where error.isConflict { + return .close + } catch { + Self.logger.error("Theme save on close failed: \(error.publicLogShape, privacy: .public)") + return .confirmDiscard(name: draft.name, reason: error.localizedDescription) + } + } + + internal func close() { + guard !isClosed else { return } + isClosed = true + saveTask?.cancel() + saveTask = nil + catalogCancellable = nil + undoManager.removeAllActions() + preview.endPreview(id: draft.id) + } +} + +private extension ThemeLoadError { + var isConflict: Bool { + switch self { + case .changedOnDisk, .deletedOnDisk: return true + default: return false + } + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift b/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift new file mode 100644 index 0000000000..08320e0bc3 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift @@ -0,0 +1,255 @@ +import AppKit +import SwiftUI +import TableProEditorKit +import TableProGrammars +import TableProPluginKit +import TableProTheme + +/// The real SQL editor and the real data grid drawing the draft through a fixed scope, so the +/// preview cannot disagree with the app: every one of the theme's slots shows here as it shows in a +/// window, keyword weight and selection rules included. +internal struct ThemeEditorPreview: View { + internal let scope: ThemeScope + internal let appearance: ThemeAppearance + + internal static let editorHeight: CGFloat = 150 + internal static let gridHeight: CGFloat = 150 + + internal var body: some View { + ThemePreviewAppearanceHost(appearance: appearance) { + VStack(spacing: 0) { + ThemePreviewEditor(scope: scope) + .frame(height: Self.editorHeight) + Divider() + ThemePreviewGrid(scope: scope) + .frame(height: Self.gridHeight) + } + } + .frame(height: Self.editorHeight + Self.gridHeight + 1) + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Theme Preview")) + .accessibilityIdentifier("theme-editor-preview") + } +} + +/// A view inside the preview, which never takes clicks or keyboard focus: a focused preview editor +/// would answer Undo with its own empty history instead of the window's. +internal protocol ThemePreviewSurface: AnyObject {} + +internal extension NSView { + var isInsideThemePreview: Bool { + var view: NSView? = self + while let current = view { + if current is ThemePreviewSurface { return true } + view = current.superview + } + return false + } +} + +private final class ThemePreviewHostingView: NSHostingView, ThemePreviewSurface { + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + +/// Draws its content in the theme's appearance whatever the window's is, so a dark theme previews as +/// dark from a light Mac. +private struct ThemePreviewAppearanceHost: NSViewRepresentable { + let appearance: ThemeAppearance + @ViewBuilder let content: () -> Content + + func makeNSView(context: Context) -> ThemePreviewHostingView { + let view = ThemePreviewHostingView(rootView: content()) + view.sizingOptions = [] + view.appearance = Self.drawingAppearance(for: appearance) + return view + } + + func updateNSView(_ view: ThemePreviewHostingView, context: Context) { + view.rootView = content() + let drawing = Self.drawingAppearance(for: appearance) + if view.appearance?.name != drawing?.name { + view.appearance = drawing + } + } + + /// Increase Contrast is a system setting AppKit applies to these by itself. + private static func drawingAppearance(for appearance: ThemeAppearance) -> NSAppearance? { + NSAppearance(named: appearance == .dark ? .darkAqua : .aqua) + } +} + +// MARK: - Editor + +internal enum ThemePreviewSample { + internal static let sql = """ + -- Paid orders above a threshold + SELECT c.name, COUNT(*) AS items, CAST(SUM(o.total) AS DECIMAL(10, 2)) + FROM orders AS o + JOIN customers AS c ON c.id = o.customer_id + WHERE o.status = 'paid' + \tAND o.total >= 100 AND o.note IS NULL + GROUP BY c.name; + UPDATE customers SET active = TRUE WHERE id = 42; + """ + + private static var text: NSString { sql as NSString } + + /// The first statement, as the editor bands the statement under the caret. + internal static var statementRange: NSRange { + let start = text.range(of: "SELECT").location + let end = text.range(of: ";").location + return NSRange(location: start, length: end + 1 - start) + } + + /// A caret on one line for the current-line band and a selection on another. + internal static var cursorPositions: [CursorPosition] { + let caretLine = text.range(of: "FROM orders") + let selection = text.range(of: "'paid'") + return [ + CursorPosition(range: NSRange(location: caretLine.location + 4, length: 0)), + CursorPosition(range: selection), + ] + } +} + +private struct ThemePreviewEditor: View { + let scope: ThemeScope + + @State private var state = SourceEditorState(cursorPositions: ThemePreviewSample.cursorPositions) + @State private var statementBand = ThemePreviewStatementBand() + + var body: some View { + SourceEditor( + .constant(ThemePreviewSample.sql), + language: EditorLanguage.sql.treeSitterLanguage, + configuration: configuration, + state: $state, + coordinators: [statementBand] + ) + } + + private var configuration: SourceEditorConfiguration { + var peripherals = EditorPeripherals.inline(lineNumbers: true, folding: false, invisibleCharacters: true) + peripherals.invisibleCharactersConfiguration = InvisibleCharactersConfiguration( + showSpaces: false, + showTabs: true, + showLineEndings: false + ) + return SourceEditorConfiguration( + appearance: .init( + theme: EditorThemeFactory.make( + resolved: scope.resolved, + highlightsCurrentLine: true, + highlightsCurrentStatement: true + ), + font: ThemeEngine.shared.editorFonts.font, + wrapLines: false + ), + behavior: .init(isEditable: false), + layout: .init(contentInsets: NSEdgeInsets(top: 6, left: 8, bottom: 6, right: 8)), + peripherals: peripherals + ) + } +} + +/// The statement band is a decoration the host sets, so a read-only listing gets it from here. +@MainActor +private final class ThemePreviewStatementBand: TextViewCoordinator { + func prepareCoordinator(controller: TextViewController) { + controller.highlightedStatementRange = ThemePreviewSample.statementRange + } + + func controllerDidAppear(controller: TextViewController) { + controller.highlightedStatementRange = ThemePreviewSample.statementRange + } +} + +// MARK: - Grid + +/// Five rows that put every grid slot on screen: header and row numbers, both stripes, NULL, both +/// booleans, an unfocused selection, and a modified, a deleted and an inserted row. +internal enum ThemePreviewGridSample { + internal static let selectedRow = 1 + internal static let modifiedRow = 2 + internal static let modifiedColumn = 4 + internal static let deletedRow = 3 + internal static let insertedRow = 4 + + internal static let tableRows: TableRows = { + let values: [[PluginCellValue]] = [ + [.text("1"), .text("Ada Lovelace"), .text("ada@example.com"), .text("true"), .text("1250.00")], + [.text("2"), .text("Alan Turing"), .null, .text("false"), .text("980.50")], + [.text("3"), .text("Grace Hopper"), .text("grace@example.com"), .text("true"), .text("3100.75")], + [.text("4"), .text("Edsger Dijkstra"), .text("edsger@example.com"), .text("false"), .text("0.00")], + [.text("5"), .text("Barbara Liskov"), .text("barbara@example.com"), .text("true"), .null], + ] + let rows = values.enumerated().map { index, row in + Row( + id: index == insertedRow ? .inserted(UUID()) : .existing(index), + values: ContiguousArray(row) + ) + } + return TableRows( + rows: ContiguousArray(rows), + columns: ["id", "name", "email", "active", "balance"], + columnTypes: [ + .integer(rawType: "INTEGER"), + .text(rawType: "TEXT"), + .text(rawType: "TEXT"), + .boolean(rawType: "BOOLEAN"), + .decimal(rawType: "DECIMAL"), + ] + ) + }() + + internal static func visualState(forRow row: Int) -> RowVisualState? { + switch row { + case modifiedRow: + return RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [modifiedColumn]) + case deletedRow: + return RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []) + case insertedRow: + return RowVisualState(isDeleted: false, isInserted: true, modifiedColumns: []) + default: + return nil + } + } +} + +@MainActor +private final class ThemePreviewGridDelegate: DataGridViewDelegate { + func dataGridVisualState(forRow row: Int) -> RowVisualState? { + ThemePreviewGridSample.visualState(forRow: row) + } +} + +private struct ThemePreviewGrid: View { + let scope: ThemeScope + + @State private var changeManager = AnyChangeManager(DataChangeManager()) + @State private var delegate = ThemePreviewGridDelegate() + @State private var selectedRows: Set = [ThemePreviewGridSample.selectedRow] + @State private var sortState = SortState() + @State private var columnLayout = ColumnLayoutState() + + var body: some View { + DataGridView( + tableRowsProvider: { ThemePreviewGridSample.tableRows }, + changeManager: changeManager, + isEditable: false, + configuration: DataGridConfiguration( + showRowNumbers: true, + supportsColumnCommands: false, + supportsValueFilter: false, + supportsColumnSort: false + ), + delegate: delegate, + selectedRowIndices: $selectedRows, + sortState: $sortState, + columnLayout: $columnLayout, + themeScope: scope + ) + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorSheet.swift b/TablePro/Views/Settings/Appearance/ThemeEditorSheet.swift deleted file mode 100644 index 2dd46c485c..0000000000 --- a/TablePro/Views/Settings/Appearance/ThemeEditorSheet.swift +++ /dev/null @@ -1,174 +0,0 @@ -import os -import SwiftUI - -/// Edits one custom theme in place. Every change is saved shortly after it settles, so Done only -/// closes the sheet; there is no draft to throw away. -internal struct ThemeEditorSheet: View { - @Environment(\.dismiss) private var dismiss - - @State private var draft: ThemeEditorDraft - @State private var lastSaved: ThemeDefinition - @State private var name: String - @State private var saveTask: Task? - @State private var saveError: String? - - private let takenNames: Set - private let save: @MainActor (ThemeDefinition) throws -> Void - - private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorSheet") - // A color well reports every drag tick; one write follows the settle. - private static let saveDelay = Duration.milliseconds(250) - - internal init( - theme: ThemeDefinition, - takenNames: Set, - save: @escaping @MainActor (ThemeDefinition) throws -> Void - ) { - _draft = State(initialValue: ThemeEditorDraft(theme: theme)) - _lastSaved = State(initialValue: theme) - _name = State(initialValue: theme.name) - self.takenNames = takenNames - self.save = save - } - - internal var body: some View { - VStack(spacing: 0) { - ThemePreviewCard(theme: draft.theme) - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 4) - - Form { - Section { - TextField(String(localized: "Name"), text: $name) - .onSubmit(commitName) - .accessibilityIdentifier("theme-editor-name") - } - - ForEach(ThemeSlotGroup.allCases, id: \.self) { group in - Section(group.label) { - ForEach(group.slots, id: \.self) { slot in - colorRow(slot) - } - } - } - } - .formStyle(.grouped) - - Divider() - - HStack { - Spacer() - Button(String(localized: "Done"), action: finish) - .keyboardShortcut(.defaultAction) - .accessibilityIdentifier("theme-editor-done") - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - } - .frame(minWidth: 480, idealWidth: 520, minHeight: 460, idealHeight: 600) - .onExitCommand(perform: finish) - .onDisappear { flush() } - .alert( - String(localized: "Could not save the theme"), - isPresented: Binding(get: { saveError != nil }, set: { if !$0 { saveError = nil } }) - ) { - Button(String(localized: "OK")) {} - } message: { - Text(saveError ?? "") - } - } - - private func colorRow(_ slot: ThemeSlot) -> some View { - LabeledContent(slot.label) { - HStack(spacing: 6) { - if draft.theme.isOverridden(slot) { - Button { - if draft.reset(slot) { scheduleSave() } - } label: { - Label(String(localized: "Use Default"), systemImage: "arrow.uturn.backward") - .labelStyle(.iconOnly) - } - .buttonStyle(.borderless) - .help(String(localized: "Use Default")) - .accessibilityLabel(String(localized: "Use Default")) - .accessibilityIdentifier("theme-color-reset-\(slot.rawValue)") - } - - ColorPicker(slot.label, selection: colorBinding(for: slot), supportsOpacity: true) - .labelsHidden() - .accessibilityIdentifier("theme-color-\(slot.rawValue)") - } - } - } - - private func colorBinding(for slot: ThemeSlot) -> Binding { - Binding( - get: { Color(nsColor: draft.displayColor(for: slot)) }, - set: { newColor in - if draft.setColor(ThemeColor(NSColor(newColor)), for: slot) { - scheduleSave() - } - } - ) - } - - private func commitName() { - guard let renamed = ThemeNaming.renamed(name, current: draft.theme.name, takenNames: takenNames) else { - name = draft.theme.name - return - } - name = renamed - if draft.rename(to: renamed) { - scheduleSave() - } - } - - private func scheduleSave() { - saveTask?.cancel() - let delay = Self.saveDelay - saveTask = Task { @MainActor in - try? await Task.sleep(for: delay) - guard !Task.isCancelled else { return } - write(draft.theme) - } - } - - @discardableResult - private func flush() -> Bool { - saveTask?.cancel() - saveTask = nil - guard draft.theme != lastSaved else { return true } - return write(draft.theme) - } - - @discardableResult - private func write(_ theme: ThemeDefinition) -> Bool { - do { - try save(theme) - lastSaved = theme - return true - } catch { - Self.logger.error("Could not save theme: \(error.publicLogShape, privacy: .public)") - saveError = error.localizedDescription - adoptDiskVersionAfterConflict(error, id: theme.id) - return false - } - } - - /// After a conflict the catalog holds the file's newer contents; editing on from the stale draft - /// would overwrite them with the next save. - private func adoptDiskVersionAfterConflict(_ error: Error, id: String) { - guard case .changedOnDisk = error as? ThemeLoadError, - let current = ThemeCatalog.shared.theme(id: id) else { return } - draft = ThemeEditorDraft(theme: current) - lastSaved = current - name = current.name - } - - private func finish() { - commitName() - guard flush() else { return } - dismiss() - } -} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift new file mode 100644 index 0000000000..92597541e5 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift @@ -0,0 +1,192 @@ +import SwiftUI +import TableProTheme + +/// The Theme Editor window's content: a live preview over the theme's fields. The window is chrome, +/// so only the preview wears the theme. +internal struct ThemeEditorView: View { + @ObservedObject internal var model: ThemeEditorModel + internal let onDuplicate: () -> Void + + @FocusState private var focusedField: Field? + + private enum Field: Hashable { + case name + case author + } + + internal var body: some View { + VStack(spacing: 0) { + ThemeEditorPreview(scope: model.previewScope, appearance: model.draft.appearance) + + Divider() + + Form { + if model.isReadOnly { + builtInSection + } + generalSection + ForEach(ThemeSlotSection.allCases, id: \.self) { section in + slotSection(section) + } + } + .formStyle(.grouped) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("theme-editor-slots") + + if !model.isReadOnly { + Divider() + footer + } + } + .onChange(of: focusedField) { field in + if field != .name { model.commitName() } + if field != .author { model.commitAuthor() } + } + .alert( + model.alert?.title ?? "", + isPresented: Binding( + get: { model.alert != nil }, + set: { if !$0 { model.alert = nil } } + ), + presenting: model.alert + ) { alert in + alertButtons(for: alert) + } message: { alert in + Text(alert.message) + } + } + + // MARK: - Sections + + private var builtInSection: some View { + Section { + HStack { + Text("Built-in themes can't be changed.") + Spacer() + Button(String(localized: "Duplicate"), action: onDuplicate) + .accessibilityIdentifier("theme-editor-duplicate") + } + } + } + + private var generalSection: some View { + Section(String(localized: "General")) { + TextField(String(localized: "Name"), text: $model.nameText) + .focused($focusedField, equals: .name) + .onSubmit { model.commitName() } + .accessibilityIdentifier("theme-editor-name") + if let error = model.nameError { + fieldError(error, identifier: "theme-editor-name-error") + } + + Picker(String(localized: "Appearance"), selection: appearanceBinding) { + Text(ThemeLibraryModel.appearanceLabel(.light)).tag(ThemeAppearance.light) + Text(ThemeLibraryModel.appearanceLabel(.dark)).tag(ThemeAppearance.dark) + } + .accessibilityIdentifier("theme-editor-appearance") + + TextField(String(localized: "Author"), text: $model.authorText) + .focused($focusedField, equals: .author) + .onSubmit { model.commitAuthor() } + .accessibilityIdentifier("theme-editor-author") + if let error = model.authorError { + fieldError(error, identifier: "theme-editor-author-error") + } + } + .disabled(!model.isEditable) + } + + private func slotSection(_ section: ThemeSlotSection) -> some View { + Section { + ForEach(section.slots, id: \.self) { slot in + ThemeSlotRow( + presentation: ThemeSlotPresentation(slot: slot, theme: model.draft), + isEditable: model.isEditable, + onSet: { model.setColor($0, for: slot) }, + onReset: { model.reset(slot) } + ) + } + } header: { + Text(section.label) + } footer: { + sectionFooter(section) + } + } + + @ViewBuilder + private func sectionFooter(_ section: ThemeSlotSection) -> some View { + if section == .window { + Text("With Increase Contrast on, the window uses the system background.") + } else if let note = ThemeContrastNote.message(for: section, in: model.draft) { + Text(note) + .accessibilityIdentifier("theme-editor-contrast-\(section.rawValue)") + } + } + + private func fieldError(_ message: String, identifier: String) -> some View { + Text(message) + .font(.callout) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier(identifier) + } + + private var footer: some View { + HStack { + Button(String(localized: "Revert Changes")) { model.revert() } + .disabled(!model.isEditable || !model.hasChangesSinceOpening) + .accessibilityIdentifier("theme-editor-revert") + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private var appearanceBinding: Binding { + Binding( + get: { model.draft.appearance }, + set: { model.setAppearance($0) } + ) + } + + // MARK: - Alerts + + @ViewBuilder + private func alertButtons(for alert: ThemeEditorModel.Alert) -> some View { + switch alert { + case .changedOnDisk, .saveFailed: + Button(String(localized: "OK")) {} + case .unreadableOnDisk: + Button(String(localized: "Close")) { model.requestClose() } + case .deletedOnDisk: + Button(String(localized: "Keep Theme")) { model.keepAsNewTheme() } + Button(String(localized: "Close"), role: .cancel) { model.requestClose() } + } + } +} + +internal extension ThemeEditorModel.Alert { + var title: String { + switch self { + case let .changedOnDisk(name), let .unreadableOnDisk(name): + return String(format: String(localized: "“%@” Changed on Disk"), name) + case let .deletedOnDisk(name): + return String(format: String(localized: "“%@” Was Deleted"), name) + case let .saveFailed(name, _): + return String(format: String(localized: "Couldn't Save “%@”"), name) + } + } + + var message: String { + switch self { + case .changedOnDisk: + return String(localized: "It was changed outside TablePro. The editor now shows the newer version.") + case .unreadableOnDisk: + return String(localized: "It was changed outside TablePro and no longer loads. Settings > Appearance lists it under Not Loaded with the reason.") + case .deletedOnDisk: + return String(localized: "The file was removed outside TablePro, possibly on another Mac.") + case let .saveFailed(_, reason): + return reason + } + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorWindowController.swift b/TablePro/Views/Settings/Appearance/ThemeEditorWindowController.swift new file mode 100644 index 0000000000..0f020a3b09 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorWindowController.swift @@ -0,0 +1,328 @@ +import AppKit +import Combine +import os +import SwiftUI +import TableProTheme + +/// The open Theme Editor windows, one per theme. Opening a theme that already has a window brings +/// that window forward instead of making a second editor that could save over the first. +@MainActor +internal final class ThemeEditorWindows: ThemeEditorPresenting { + internal static let shared = ThemeEditorWindows( + catalog: .shared, + preview: ThemeEngine.shared, + selection: AppSettingsManager.shared + ) + + private let catalog: ThemeCatalog + private let preview: ThemePreviewing + private let selection: ThemeSelectionStore? + private var controllers: [String: ThemeEditorWindowController] = [:] + private var terminationObserver: NSObjectProtocol? + private let confirmQuitWithoutSaving: @MainActor (_ name: String, _ reason: String) -> Bool + + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorWindows") + + internal init( + catalog: ThemeCatalog, + preview: ThemePreviewing, + selection: ThemeSelectionStore?, + notificationCenter: NotificationCenter = .default, + confirmQuitWithoutSaving: @escaping @MainActor (_ name: String, _ reason: String) -> Bool = ThemeEditorWindows.askToQuitWithoutSaving + ) { + self.catalog = catalog + self.preview = preview + self.selection = selection + self.confirmQuitWithoutSaving = confirmQuitWithoutSaving + // A window closed by quitting never asks to close, so its pending save is written here. + terminationObserver = notificationCenter.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.flushAll() + } + } + } + + internal var openThemeIds: Set { + Set(controllers.keys) + } + + internal func controller(for themeId: String) -> ThemeEditorWindowController? { + controllers[themeId] + } + + internal func openEditor(for themeId: String) { + if let existing = controllers[themeId] { + existing.present() + return + } + guard let theme = catalog.storedTheme(id: themeId) else { + Self.logger.error("No theme \(themeId, privacy: .public) to edit") + return + } + let model = ThemeEditorModel(theme: theme, catalog: catalog, preview: preview, selection: selection) + let controller = ThemeEditorWindowController(model: model, windows: self) + controllers[themeId] = controller + controller.present() + } + + internal func editor(for themeId: String) -> ThemeEditorModel? { + controllers[themeId]?.model + } + + internal func closeEditor(for themeId: String) { + controllers[themeId]?.closeKeepingChanges() + } + + /// A built-in theme is read-only, so its window offers a copy and hands over to the copy's. + internal func duplicate(from controller: ThemeEditorWindowController) { + let source = controller.model.draft + do { + let copy = try catalog.duplicate(source) + openEditor(for: copy.id) + controller.closeKeepingChanges() + } catch { + Self.logger.error("Duplicating a theme failed: \(error.publicLogShape, privacy: .public)") + controller.model.alert = .saveFailed(name: source.name, reason: error.localizedDescription) + } + } + + fileprivate func identifierChanged(from oldId: String, to newId: String, controller: ThemeEditorWindowController) { + guard controllers[oldId] === controller else { return } + controllers[oldId] = nil + controllers[newId] = controller + } + + fileprivate func didClose(_ controller: ThemeEditorWindowController) { + controllers = controllers.filter { $0.value !== controller } + } + + private func flushAll() { + for controller in controllers.values { + controller.model.flush() + } + } + + /// Asked from `applicationShouldTerminate`, before quitting is approved: a save that fails or a + /// refused name has to stop the quit, which the termination notification is too late to do. + internal func prepareToTerminate() -> Bool { + for controller in controllers.values { + switch controller.model.prepareToClose() { + case .close: + continue + case .keepEditing: + controller.present() + NSSound.beep() + return false + case let .confirmDiscard(name, reason): + controller.present() + guard confirmQuitWithoutSaving(name, reason) else { return false } + } + } + return true + } + + internal static func askToQuitWithoutSaving(name: String, reason: String) -> Bool { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(format: String(localized: "Couldn't Save “%@”"), name) + alert.informativeText = reason + alert.addButton(withTitle: String(localized: "Cancel")) + alert.addButton(withTitle: String(localized: "Quit Without Saving")) + return alert.runModal() == .alertSecondButtonReturn + } +} + +/// A non-modal window for one theme: title the theme's name, proxy icon its file, Edit menu undo +/// through the model's undo manager. The window is chrome; only its preview wears the theme. +@MainActor +internal final class ThemeEditorWindowController: NSWindowController, NSWindowDelegate { + internal let model: ThemeEditorModel + + private weak var windows: ThemeEditorWindows? + private var cancellables: Set = [] + private var isCloseConfirmed = false + + private static let contentSize = NSSize(width: 640, height: 700) + private static let minimumSize = NSSize(width: 520, height: 480) + private static var lastTopLeft: NSPoint? + + internal init(model: ThemeEditorModel, windows: ThemeEditorWindows) { + self.model = model + self.windows = windows + super.init(window: nil) + + // The delegate goes in before the content: a window whose undo manager was read while it had + // no delegate keeps that manager and never asks for the model's. + let window = ThemeEditorWindow( + contentRect: NSRect(origin: .zero, size: Self.contentSize), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: true + ) + window.delegate = self + window.identifier = NSUserInterfaceItemIdentifier(Self.windowIdentifier) + window.title = model.draft.name + window.isRestorable = false + window.isReleasedWhenClosed = false + window.keepsKeyViewLoopCurrent() + window.contentMinSize = Self.minimumSize + + let hosting = NSHostingController(rootView: ThemeEditorView(model: model) { [weak self] in + guard let self else { return } + self.windows?.duplicate(from: self) + }) + hosting.sizingOptions = [] + window.contentViewController = hosting + window.setContentSize(Self.contentSize) + window.representedURL = model.fileURL + self.window = window + + placeWindow(window) + observeModel(window: window) + } + + @available(*, unavailable) + internal required init?(coder: NSCoder) { + fatalError("ThemeEditorWindowController does not support NSCoder init") + } + + internal static let windowIdentifier = "theme-editor" + + internal func present() { + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + AppActivationPolicyController.shared.activate() + // SwiftUI can mount the preview after the window first becomes key. + DispatchQueue.main.async { [weak self] in + self?.releasePreviewFocus() + } + } + + /// Closing from the list: the draft is written first, and nothing asks. + internal func closeKeepingChanges() { + model.flush() + isCloseConfirmed = true + window?.close() + } + + private func placeWindow(_ window: NSWindow) { + if let topLeft = Self.lastTopLeft { + Self.lastTopLeft = window.cascadeTopLeft(from: topLeft) + } else { + window.center() + Self.lastTopLeft = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) + } + } + + private func observeModel(window: NSWindow) { + model.$draft + .map(\.name) + .removeDuplicates() + .sink { [weak window] name in window?.title = name } + .store(in: &cancellables) + model.$fileURL + .removeDuplicates() + .sink { [weak window] url in window?.representedURL = url } + .store(in: &cancellables) + model.onRequestClose = { [weak self] in + self?.isCloseConfirmed = true + self?.window?.close() + } + model.onIdentifierChange = { [weak self] oldId, newId in + guard let self else { return } + self.windows?.identifierChanged(from: oldId, to: newId, controller: self) + } + } + + // MARK: - NSWindowDelegate + + internal func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { + model.undoManager + } + + internal func windowDidBecomeKey(_ notification: Notification) { + releasePreviewFocus() + } + + /// AppKit hands a new window's focus to its first key view, which is in the preview, and the + /// preview's editor and grid answer Undo with histories of their own. + private func releasePreviewFocus() { + guard let window, let responder = window.firstResponder as? NSView, responder.isInsideThemePreview else { return } + window.makeFirstResponder(nil) + } + + internal func windowShouldClose(_ sender: NSWindow) -> Bool { + guard !isCloseConfirmed else { return true } + switch model.prepareToClose() { + case .close: + return true + case .keepEditing: + return false + case let .confirmDiscard(name, reason): + confirmDiscard(name: name, reason: reason, in: sender) + return false + } + } + + internal func windowWillClose(_ notification: Notification) { + deactivateColorWells() + model.close() + windows?.didClose(self) + } + + private func confirmDiscard(name: String, reason: String, in window: NSWindow) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(format: String(localized: "Couldn't Save “%@”"), name) + alert.informativeText = reason + alert.addButton(withTitle: String(localized: "Cancel")) + alert.addButton(withTitle: String(localized: "Close Without Saving")) + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertSecondButtonReturn else { return } + self?.isCloseConfirmed = true + self?.window?.close() + } + } + + /// The shared color panel keeps sending to an active well after its window is gone. + private func deactivateColorWells() { + guard let root = window?.contentView else { return } + var pending: [NSView] = [root] + while let view = pending.popLast() { + if let well = view as? NSColorWell, well.isActive { + well.deactivate() + } + pending.append(contentsOf: view.subviews) + } + } +} + +/// Tab and Shift-Tab pass over the preview the way clicks do. Its editor and grid are key views in +/// AppKit's loop, and each answers Undo with an empty history of its own; they cannot be taken out +/// of the loop without losing the caret and selection they preview. +private final class ThemeEditorWindow: NSWindow { + override func selectKeyView(following view: NSView) { + super.selectKeyView(following: view) + passOverPreview { super.selectKeyView(following: $0) } + } + + override func selectKeyView(preceding view: NSView) { + super.selectKeyView(preceding: view) + passOverPreview { super.selectKeyView(preceding: $0) } + } + + private func passOverPreview(_ step: (NSView) -> Void) { + var visited: Set = [] + while let view = firstResponder as? NSView, view.isInsideThemePreview { + guard visited.insert(ObjectIdentifier(view)).inserted else { + makeFirstResponder(nil) + return + } + step(view) + } + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeLibraryModel.swift b/TablePro/Views/Settings/Appearance/ThemeLibraryModel.swift new file mode 100644 index 0000000000..1d12ffda57 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeLibraryModel.swift @@ -0,0 +1,559 @@ +import AppKit +import Combine +import Foundation +import os +import SwiftUI +import TableProTheme +import UniformTypeIdentifiers + +internal enum ThemeLibraryItem: Hashable, Sendable { + case theme(String) + case notLoaded(path: String) +} + +@MainActor +internal protocol ThemeSelectionStore: AnyObject { + var themeSelection: AppearanceSettings { get set } +} + +extension AppSettingsManager: ThemeSelectionStore { + internal var themeSelection: AppearanceSettings { + get { appearance } + set { appearance = newValue } + } +} + +@MainActor +internal protocol ThemeEditorPresenting: AnyObject { + func openEditor(for themeId: String) + func editor(for themeId: String) -> ThemeEditorModel? + func closeEditor(for themeId: String) +} + +internal struct ThemeLibraryAlert: Identifiable, Equatable { + internal let id = UUID() + internal let title: String + internal let message: String + + internal static func == (lhs: ThemeLibraryAlert, rhs: ThemeLibraryAlert) -> Bool { + lhs.title == rhs.title && lhs.message == rhs.message + } +} + +internal struct ThemeTrashConfirmation: Identifiable, Equatable { + internal let item: ThemeLibraryItem + internal let title: String + internal let message: String + + internal var id: ThemeLibraryItem { item } +} + +internal enum ThemeRenameOutcome: Equatable { + case renamed(String) + case unchanged + case rejected(String) +} + +internal struct ThemeImportFailure: Equatable { + internal let fileName: String + internal let reason: String +} + +internal struct ThemeImportReport: Equatable { + internal var imported: [ThemeDefinition] = [] + internal var failures: [ThemeImportFailure] = [] + + /// A clean import needs no alert: the new row is selected. Anything else is one alert for the + /// whole drop, so ten files never mean ten alerts. + internal var alert: ThemeLibraryAlert? { + guard !failures.isEmpty else { return nil } + let lines = failures.map { + String(format: String(localized: "%1$@ could not be read: %2$@"), $0.fileName, $0.reason) + } + if imported.isEmpty, let only = failures.first, failures.count == 1 { + return ThemeLibraryAlert( + title: String(format: String(localized: "Couldn't Import “%@”"), only.fileName), + message: only.reason + ) + } + let title: String + switch imported.count { + case 0: title = String(localized: "No Themes Imported") + case 1: title = String(localized: "Imported 1 theme.") + default: title = String(format: String(localized: "Imported %d themes."), imported.count) + } + return ThemeLibraryAlert(title: title, message: lines.joined(separator: "\n")) + } +} + +internal struct ThemeFileDocument: FileDocument { + internal static var readableContentTypes: [UTType] { [.json] } + + internal let data: Data + + internal init(data: Data) { + self.data = data + } + + internal init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) } + self.data = data + } + + internal func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} + +/// Every action of the Appearance pane's theme list. Views call it and show what it publishes; it +/// never reaches the engine, which follows the catalog by itself. An action on a theme whose editor +/// is open goes through that editor, so the window and the list cannot write over each other. +@MainActor +internal final class ThemeLibraryModel: ObservableObject { + @Published internal var alert: ThemeLibraryAlert? + + internal let catalog: ThemeCatalog + private let selection: ThemeSelectionStore + private let editors: ThemeEditorPresenting + private let revealInFinder: @MainActor ([URL]) -> Void + private let dragDirectory: URL + private var catalogCancellable: AnyCancellable? + + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeLibrary") + + internal init( + catalog: ThemeCatalog, + selection: ThemeSelectionStore, + editors: ThemeEditorPresenting, + dragDirectory: URL = FileManager.default.temporaryDirectory + .appendingPathComponent("TablePro Theme Export", isDirectory: true), + revealInFinder: @escaping @MainActor ([URL]) -> Void = { NSWorkspace.shared.activateFileViewerSelecting($0) } + ) { + self.catalog = catalog + self.selection = selection + self.editors = editors + self.dragDirectory = dragDirectory + self.revealInFinder = revealInFinder + catalogCancellable = catalog.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + } + + // MARK: - Contents + + internal var builtInThemes: [ThemeDefinition] { + catalog.themes.filter(\.isBuiltIn) + } + + internal var customThemes: [ThemeDefinition] { + catalog.themes.filter(\.isEditable) + } + + internal var notLoaded: [RejectedThemeRecord] { + catalog.rejected + } + + internal func theme(for item: ThemeLibraryItem?) -> ThemeDefinition? { + guard case let .theme(id) = item else { return nil } + return catalog.theme(id: id) + } + + internal func record(for item: ThemeLibraryItem?) -> RejectedThemeRecord? { + guard case let .notLoaded(path) = item else { return nil } + return catalog.rejected.first { $0.path == path } + } + + internal func canTrash(_ item: ThemeLibraryItem?) -> Bool { + if let theme = theme(for: item) { return theme.isEditable } + return record(for: item) != nil + } + + /// The slot a theme can still be chosen for, or nil when it is already the choice there. + internal func availableSlot(for theme: ThemeDefinition) -> ThemeAppearance? { + let current = selection.themeSelection + switch theme.appearance { + case .light: return current.preferredLightThemeId == theme.id ? nil : .light + case .dark: return current.preferredDarkThemeId == theme.id ? nil : .dark + } + } + + internal static func appearanceLabel(_ appearance: ThemeAppearance) -> String { + switch appearance { + case .light: return String(localized: "Light") + case .dark: return String(localized: "Dark") + } + } + + // MARK: - Opening and choosing + + internal func edit(_ id: String) { + guard catalog.theme(id: id) != nil else { return } + editors.openEditor(for: id) + } + + internal func use(_ id: String, for appearance: ThemeAppearance) { + var updated = selection.themeSelection + switch appearance { + case .light: updated.preferredLightThemeId = id + case .dark: updated.preferredDarkThemeId = id + } + guard updated != selection.themeSelection else { return } + selection.themeSelection = updated + } + + // MARK: - Creating + + @discardableResult + internal func duplicate(_ id: String, undoManager: UndoManager?) -> ThemeDefinition? { + makeCopy(of: id, actionName: String(localized: "Duplicate"), undoManager: undoManager) + } + + /// A theme made to be edited, so its editor opens with it. + @discardableResult + internal func createTheme(basedOn id: String, undoManager: UndoManager?) -> ThemeDefinition? { + guard let copy = makeCopy(of: id, actionName: String(localized: "New Theme"), undoManager: undoManager) else { + return nil + } + editors.openEditor(for: copy.id) + return copy + } + + private func makeCopy(of id: String, actionName: String, undoManager: UndoManager?) -> ThemeDefinition? { + editors.editor(for: id)?.flush() + guard let source = catalog.storedTheme(id: id) else { return nil } + let copy: ThemeDefinition? = perform(String(localized: "Couldn't Duplicate the Theme")) { + try catalog.duplicate(source) + } + guard let copy else { return nil } + registerCreation(of: [copy.id], actionName: actionName, undoManager: undoManager) + return copy + } + + internal func importThemes(from urls: [URL], undoManager: UndoManager?) -> ThemeImportReport { + var report = ThemeImportReport() + for url in urls where !isDragExport(url) { + let isScoped = url.startAccessingSecurityScopedResource() + defer { if isScoped { url.stopAccessingSecurityScopedResource() } } + do { + report.imported.append(try catalog.importTheme(from: url)) + } catch { + Self.logger.error("Theme import failed: \(error.publicLogShape, privacy: .public)") + report.failures.append(ThemeImportFailure(fileName: url.lastPathComponent, reason: error.localizedDescription)) + } + } + registerCreation(of: report.imported.map(\.id), actionName: String(localized: "Import"), undoManager: undoManager) + if let alert = report.alert { + self.alert = alert + } + return report + } + + // MARK: - Renaming + + /// The name the rename field starts from: the file's own, not the "Ocean 2" the list shows for + /// the second of two "Ocean" themes, so committing it untouched changes nothing. + internal func nameForRenaming(_ id: String) -> String? { + catalog.storedTheme(id: id)?.name + } + + internal func rename(_ id: String, to proposed: String, undoManager: UndoManager?) -> ThemeRenameOutcome { + guard let previous = catalog.storedTheme(id: id)?.name else { return .unchanged } + let outcome = applyRename(id, to: proposed) + if case .renamed = outcome { + registerRename(id, back: previous, undoManager: undoManager) + } + return outcome + } + + private func applyRename(_ id: String, to proposed: String) -> ThemeRenameOutcome { + if let editor = editors.editor(for: id) { + let outcome = editor.rename(to: proposed) + if case .renamed = outcome { + editor.flush() + } + return outcome + } + let trimmed = proposed.trimmingCharacters(in: .whitespacesAndNewlines) + guard let theme = catalog.storedTheme(id: id), theme.isEditable, !trimmed.isEmpty, trimmed != theme.name else { + return .unchanged + } + do { + try catalog.rename(id: id, to: trimmed) + return .renamed(trimmed) + } catch let error as ThemeLoadError { + switch error { + case .nameTaken, .invalidName: + return .rejected(error.localizedDescription) + default: + fail(String(localized: "Couldn't Rename the Theme"), error) + return .unchanged + } + } catch { + fail(String(localized: "Couldn't Rename the Theme"), error) + return .unchanged + } + } + + private func registerRename(_ id: String, back name: String, undoManager: UndoManager?) { + guard let undoManager else { return } + register(on: undoManager, actionName: String(localized: "Rename")) { model in + guard let current = model.catalog.storedTheme(id: id)?.name else { return } + if case let .rejected(reason) = model.applyRename(id, to: name) { + model.alert = ThemeLibraryAlert(title: String(localized: "Couldn't Rename the Theme"), message: reason) + return + } + model.registerRename(id, back: current, undoManager: undoManager) + } + } + + // MARK: - Export + + internal func exportDocument(for id: String) -> ThemeFileDocument? { + editors.editor(for: id)?.flush() + guard let theme = catalog.storedTheme(id: id) else { return nil } + return perform(String(localized: "Couldn't Export the Theme")) { + ThemeFileDocument(data: try ThemeFileFormat.encode(theme)) + } + } + + internal static func exportFileName(for theme: ThemeDefinition) -> String { + let reserved = CharacterSet(charactersIn: "/:") + let cleaned = theme.name.components(separatedBy: reserved).joined(separator: "-") + return cleaned.isEmpty ? theme.id : cleaned + } + + /// A copy in a folder of its own rather than the theme's file, because Finder may move a dragged + /// file instead of copying it, and a built-in theme has no file. + internal func dragExportURL(for id: String) throws -> URL { + editors.editor(for: id)?.flush() + guard let theme = catalog.storedTheme(id: id) else { throw CocoaError(.fileNoSuchFile) } + let folder = dragDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let url = folder.appendingPathComponent(Self.exportFileName(for: theme) + ".json", isDirectory: false) + try ThemeFileFormat.encode(theme).write(to: url, options: .atomic) + return url + } + + internal func dragItemProvider(for id: String) -> NSItemProvider { + do { + let url = try dragExportURL(for: id) + return NSItemProvider(contentsOf: url) ?? NSItemProvider() + } catch { + Self.logger.error("Theme drag export failed: \(error.publicLogShape, privacy: .public)") + return NSItemProvider() + } + } + + private func isDragExport(_ url: URL) -> Bool { + url.standardizedFileURL.path.hasPrefix(dragDirectory.standardizedFileURL.path + "/") + } + + // MARK: - Finder + + internal func revealInFinder(_ item: ThemeLibraryItem?) { + if case let .theme(id) = item, let url = catalog.fileURL(for: id) { + revealInFinder([url]) + } else if let record = record(for: item) { + revealInFinder([URL(fileURLWithPath: record.path)]) + } else { + revealInFinder([catalog.themesDirectory]) + } + } + + internal func retryLoading() { + catalog.reloadSynchronously() + } + + // MARK: - Trash + + /// Asked only when the trash changes what the windows draw. Moving a theme nothing uses is undone + /// from the Edit menu, so it needs no alert. + internal func trashConfirmation(for item: ThemeLibraryItem) -> ThemeTrashConfirmation? { + guard let theme = theme(for: item), theme.isEditable else { return nil } + let current = selection.themeSelection + let holdsLight = current.preferredLightThemeId == theme.id + let holdsDark = current.preferredDarkThemeId == theme.id + let message: String + switch (holdsLight, holdsDark) { + case (false, false): + return nil + case (true, false): + message = String( + format: String(localized: "The file moves to the Trash. Light windows switch to %@."), + BuiltInThemes.light.name + ) + case (false, true): + message = String( + format: String(localized: "The file moves to the Trash. Dark windows switch to %@."), + BuiltInThemes.dark.name + ) + case (true, true): + message = String(localized: "The file moves to the Trash. Light and dark windows switch to their default themes.") + } + return ThemeTrashConfirmation( + item: item, + title: String(format: String(localized: "Delete “%@”?"), theme.name), + message: message + ) + } + + @discardableResult + internal func moveToTrash(_ item: ThemeLibraryItem, undoManager: UndoManager?) -> Bool { + switch item { + case let .theme(id): + guard let theme = catalog.theme(id: id), theme.isEditable else { return false } + editors.closeEditor(for: id) + let before = selection.themeSelection + let receipt: ThemeTrashReceipt? = perform(String(localized: "Couldn't Move the Theme to the Trash")) { + try catalog.trash(id: id) + } + guard let receipt else { return false } + let released = before.releasing(themeId: id) + if released != before { + selection.themeSelection = released + } + registerRestore(receipt, item: item, releasedFrom: before, undoManager: undoManager) + return true + case let .notLoaded(path): + let receipt: ThemeTrashReceipt? = perform(String(localized: "Couldn't Move the File to the Trash")) { + try catalog.trashRejected(path: path) + } + guard let receipt else { return false } + registerRestore(receipt, item: item, releasedFrom: nil, undoManager: undoManager) + return true + } + } + + private func registerRestore( + _ receipt: ThemeTrashReceipt, + item: ThemeLibraryItem, + releasedFrom before: AppearanceSettings?, + undoManager: UndoManager? + ) { + guard let undoManager else { return } + register(on: undoManager, actionName: String(localized: "Move to Trash")) { model in + model.restore(receipt, item: item, releasedFrom: before, undoManager: undoManager) + } + } + + private func restore( + _ receipt: ThemeTrashReceipt, + item: ThemeLibraryItem, + releasedFrom before: AppearanceSettings?, + undoManager: UndoManager + ) { + let restored: Bool? = perform(String(localized: "Couldn't Put the Theme Back")) { + try catalog.restore(receipt) + return true + } + guard restored != nil else { return } + if let before, case let .theme(id) = item { + let reclaimed = selection.themeSelection.reclaiming(themeId: id, from: before) + if reclaimed != selection.themeSelection { + selection.themeSelection = reclaimed + } + } + register(on: undoManager, actionName: String(localized: "Move to Trash")) { model in + _ = model.moveToTrash(item, undoManager: undoManager) + } + } + + // MARK: - Undoing a creation + + /// The way Finder undoes a duplicate: the new file goes to the Trash, and Redo brings it back. + private func registerCreation(of ids: [String], actionName: String, undoManager: UndoManager?) { + guard let undoManager, !ids.isEmpty else { return } + register(on: undoManager, actionName: actionName) { model in + model.undoCreation(of: ids, actionName: actionName, undoManager: undoManager) + } + } + + private func undoCreation(of ids: [String], actionName: String, undoManager: UndoManager) { + var receipts: [ThemeTrashReceipt] = [] + for id in ids where catalog.theme(id: id) != nil { + editors.closeEditor(for: id) + let before = selection.themeSelection + let receipt: ThemeTrashReceipt? = perform(String(localized: "Couldn't Move the Theme to the Trash")) { + try catalog.trash(id: id) + } + guard let receipt else { continue } + receipts.append(receipt) + let released = before.releasing(themeId: id) + if released != before { + selection.themeSelection = released + } + } + register(on: undoManager, actionName: actionName) { model in + model.redoCreation(receipts, ids: ids, actionName: actionName, undoManager: undoManager) + } + } + + private func redoCreation(_ receipts: [ThemeTrashReceipt], ids: [String], actionName: String, undoManager: UndoManager) { + for receipt in receipts { + _ = perform(String(localized: "Couldn't Put the Theme Back")) { + try catalog.restore(receipt) + } + } + registerCreation(of: ids, actionName: actionName, undoManager: undoManager) + } + + /// A group of its own, so each action is one undo whether or not the manager groups by event. + private func register( + on undoManager: UndoManager, + actionName: String, + _ handler: @escaping @MainActor (ThemeLibraryModel) -> Void + ) { + undoManager.beginUndoGrouping() + undoManager.registerUndo(withTarget: self) { model in + MainActor.assumeIsolated { + handler(model) + } + } + undoManager.setActionName(actionName) + undoManager.endUndoGrouping() + } + + // MARK: - Failures + + private func perform(_ title: String, _ work: () throws -> Value) -> Value? { + do { + return try work() + } catch { + fail(title, error) + return nil + } + } + + private func fail(_ title: String, _ error: Error) { + Self.logger.error("Theme action failed: \(error.publicLogShape, privacy: .public)") + alert = ThemeLibraryAlert(title: title, message: error.localizedDescription) + } +} + +internal extension AppearanceSettings { + /// A slot that held a theme the user just trashed goes back to its built-in, so the popup does + /// not then report that theme as missing. + func releasing(themeId: String) -> AppearanceSettings { + var updated = self + if updated.preferredLightThemeId == themeId { + updated.preferredLightThemeId = BuiltInThemes.defaultLightId + } + if updated.preferredDarkThemeId == themeId { + updated.preferredDarkThemeId = BuiltInThemes.defaultDarkId + } + return updated + } + + /// Undo of a trash puts the theme back only into the slots the trash released and nothing has + /// chosen since. + func reclaiming(themeId: String, from before: AppearanceSettings) -> AppearanceSettings { + var updated = self + if before.preferredLightThemeId == themeId, updated.preferredLightThemeId == BuiltInThemes.defaultLightId { + updated.preferredLightThemeId = themeId + } + if before.preferredDarkThemeId == themeId, updated.preferredDarkThemeId == BuiltInThemes.defaultDarkId { + updated.preferredDarkThemeId = themeId + } + return updated + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeLibrarySection.swift b/TablePro/Views/Settings/Appearance/ThemeLibrarySection.swift new file mode 100644 index 0000000000..ac41ca7b59 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeLibrarySection.swift @@ -0,0 +1,398 @@ +import AppKit +import SwiftUI +import TableProTheme +import UniformTypeIdentifiers + +/// Every theme on this Mac in one list: the built-ins, the user's own, and the files that did not +/// load. The add and remove pair and the action pull-down sit under it the way macOS puts them under +/// a list. A context menu acts on the row under the pointer, which is not always the selected one. +internal struct ThemeLibrarySection: View { + @StateObject private var model = ThemeLibraryModel( + catalog: .shared, + selection: AppSettingsManager.shared, + editors: ThemeEditorWindows.shared + ) + + @Environment(\.undoManager) private var undoManager + + @State private var selection: ThemeLibraryItem? + @State private var renamingId: String? + @State private var renameText = "" + @State private var renameError: String? + @State private var pendingTrash: ThemeTrashConfirmation? + @State private var isImporting = false + @State private var exporting: ExportRequest? + @FocusState private var isRenameFieldFocused: Bool + + private struct ExportRequest { + let document: ThemeFileDocument + let fileName: String + } + + // Fits the Settings pane's default 500 pt height under the two sections above it. + private static let listHeight: CGFloat = 176 + + internal var body: some View { + Section { + list + .alert( + model.alert?.title ?? "", + isPresented: isPresented($model.alert), + presenting: model.alert + ) { _ in + Button(String(localized: "OK")) {} + } message: { alert in + Text(alert.message) + } + .alert( + pendingTrash?.title ?? "", + isPresented: isPresented($pendingTrash), + presenting: pendingTrash + ) { confirmation in + Button(String(localized: "Move to Trash"), role: .destructive) { + trash(confirmation.item) + } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { confirmation in + Text(confirmation.message) + } + .fileImporter(isPresented: $isImporting, allowedContentTypes: [.json], allowsMultipleSelection: true) { result in + guard case let .success(urls) = result else { return } + importThemes(urls) + } + .fileExporter( + isPresented: isPresented($exporting), + document: exporting?.document, + contentType: .json, + defaultFilename: exporting?.fileName + ) { _ in } + } header: { + Text("Themes") + } + } + + // MARK: - List + + /// The user's own themes come first: fourteen built-ins above them would push the rows a person + /// creates, renames and trashes out of sight. + private var list: some View { + ScrollViewReader { proxy in + List(selection: $selection) { + Section(String(localized: "Custom")) { + if model.customThemes.isEmpty { + Text("Duplicate a theme or import one to customize colors.") + .foregroundStyle(.secondary) + .accessibilityIdentifier("appearance-themes-custom-empty") + } + ForEach(model.customThemes) { theme in + themeRow(theme) + .tag(ThemeLibraryItem.theme(theme.id)) + .id(ThemeLibraryItem.theme(theme.id)) + } + } + + if !model.notLoaded.isEmpty { + Section(String(localized: "Not Loaded")) { + ForEach(model.notLoaded) { record in + notLoadedRow(record) + .tag(ThemeLibraryItem.notLoaded(path: record.path)) + .id(ThemeLibraryItem.notLoaded(path: record.path)) + } + } + } + + Section(String(localized: "Built-in")) { + ForEach(model.builtInThemes) { theme in + themeRow(theme) + .tag(ThemeLibraryItem.theme(theme.id)) + .id(ThemeLibraryItem.theme(theme.id)) + } + } + } + .listStyle(.bordered(alternatesRowBackgrounds: false)) + .frame(height: Self.listHeight) + .contextMenu(forSelectionType: ThemeLibraryItem.self) { items in + rowMenu(for: items.first) + } primaryAction: { items in + guard case let .theme(id) = items.first else { return } + model.edit(id) + } + .onDeleteCommand { requestTrash(selection) } + .dropDestination(for: URL.self) { urls, _ in + let files = urls.filter(\.isFileURL) + guard !files.isEmpty else { return false } + importThemes(files) + return true + } + .safeAreaInset(edge: .bottom, spacing: 0) { + footer + } + .onChange(of: selection) { item in + guard let item else { return } + proxy.scrollTo(item) + } + } + } + + @ViewBuilder + private func themeRow(_ theme: ThemeDefinition) -> some View { + if renamingId == theme.id { + renameField(for: theme) + } else { + VStack(alignment: .leading, spacing: 1) { + Text(theme.name) + .lineLimit(1) + Text(ThemeLibraryModel.appearanceLabel(theme.appearance)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + .onDrag { model.dragItemProvider(for: theme.id) } + } + } + + private func renameField(for theme: ThemeDefinition) -> some View { + VStack(alignment: .leading, spacing: 2) { + TextField(String(localized: "Name"), text: $renameText) + .labelsHidden() + .focused($isRenameFieldFocused) + .onSubmit { commitRename(theme.id) } + .onExitCommand(perform: cancelRename) + .onAppear { isRenameFieldFocused = true } + .onChange(of: isRenameFieldFocused) { isFocused in + if !isFocused { commitRename(theme.id) } + } + .accessibilityIdentifier("appearance-theme-rename-field") + if let renameError { + Text(renameError) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("appearance-theme-rename-error") + } + } + .padding(.vertical, 2) + } + + private func notLoadedRow(_ record: RejectedThemeRecord) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(record.fileName) + .lineLimit(1) + Text(record.reason) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 2) + .help(record.reason) + } + + /// A contextual menu leaves out what does not apply; the pull-down below dims it instead. + @ViewBuilder + private func rowMenu(for item: ThemeLibraryItem?) -> some View { + if let theme = model.theme(for: item) { + if theme.isEditable { + Button(String(localized: "Edit…")) { model.edit(theme.id) } + } + Button(String(localized: "Duplicate")) { duplicate(theme.id) } + if theme.isEditable { + RenameButton() + .renameAction { beginRename(theme) } + } + Button(String(localized: "Export…")) { export(theme.id) } + if let slot = model.availableSlot(for: theme) { + Button(useTitle(for: slot)) { model.use(theme.id, for: slot) } + } + if theme.isEditable { + Button(String(localized: "Show in Finder")) { model.revealInFinder(item) } + Divider() + Button(String(localized: "Move to Trash")) { requestTrash(item) } + } + } else if let item, model.record(for: item) != nil { + Button(String(localized: "Show in Finder")) { model.revealInFinder(item) } + Button(String(localized: "Try Again")) { model.retryLoading() } + Divider() + Button(String(localized: "Move to Trash")) { requestTrash(item) } + } + } + + private func useTitle(for slot: ThemeAppearance) -> String { + switch slot { + case .light: return String(localized: "Use for Light Appearance") + case .dark: return String(localized: "Use for Dark Appearance") + } + } + + // MARK: - Footer + + private var footer: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: 0) { + ControlGroup { + addMenu + + Button { + requestTrash(selection) + } label: { + Label(String(localized: "Move to Trash"), systemImage: "minus") + .labelStyle(.iconOnly) + } + .help(String(localized: "Move to Trash")) + .accessibilityLabel(String(localized: "Move to Trash")) + .accessibilityIdentifier("appearance-theme-remove") + .disabled(!model.canTrash(selection)) + + actionsMenu + } + .controlGroupStyle(.navigation) + .controlSize(.small) + .fixedSize() + + Spacer() + } + .padding(.horizontal, 4) + .padding(.vertical, 2) + } + .background(.bar) + } + + /// An icon-only `Menu` is named only through `.contain` plus a label on the menu itself; a + /// label inside its label closure leaves VoiceOver reading "More", or nothing on CI. + private var addMenu: some View { + Menu { + Menu(String(localized: "New Theme Based On")) { + ForEach(model.builtInThemes) { theme in + Button(theme.name) { createTheme(basedOn: theme.id) } + } + if !model.customThemes.isEmpty { + Divider() + ForEach(model.customThemes) { theme in + Button(theme.name) { createTheme(basedOn: theme.id) } + } + } + } + Divider() + Button(String(localized: "Import…")) { isImporting = true } + } label: { + Label(String(localized: "Add Theme"), systemImage: "plus") + .labelStyle(.iconOnly) + } + .menuIndicator(.hidden) + .help(String(localized: "Add Theme")) + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Add Theme")) + .accessibilityIdentifier("appearance-theme-add") + } + + private var actionsMenu: some View { + let theme = model.theme(for: selection) + return Menu { + Button(String(localized: "Edit…")) { + if let theme { model.edit(theme.id) } + } + .disabled(theme == nil) + Button(String(localized: "Duplicate")) { + if let theme { duplicate(theme.id) } + } + .disabled(theme == nil) + Button(String(localized: "Rename")) { + if let theme { beginRename(theme) } + } + .disabled(theme?.isEditable != true) + Button(String(localized: "Export…")) { + if let theme { export(theme.id) } + } + .disabled(theme == nil) + Divider() + Button(String(localized: "Show in Finder")) { model.revealInFinder(selection) } + } label: { + Label(String(localized: "Theme Actions"), systemImage: "ellipsis.circle") + .labelStyle(.iconOnly) + } + .menuIndicator(.hidden) + .help(String(localized: "Theme Actions")) + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Theme Actions")) + .accessibilityIdentifier("appearance-theme-actions") + } + + // MARK: - Actions + + private func duplicate(_ id: String) { + guard let copy = model.duplicate(id, undoManager: undoManager) else { return } + selection = .theme(copy.id) + } + + private func createTheme(basedOn id: String) { + guard let copy = model.createTheme(basedOn: id, undoManager: undoManager) else { return } + selection = .theme(copy.id) + } + + private func importThemes(_ urls: [URL]) { + let report = model.importThemes(from: urls, undoManager: undoManager) + if let last = report.imported.last { + selection = .theme(last.id) + } + } + + private func export(_ id: String) { + guard let theme = model.catalog.storedTheme(id: id), let document = model.exportDocument(for: id) else { return } + exporting = ExportRequest(document: document, fileName: ThemeLibraryModel.exportFileName(for: theme)) + } + + private func beginRename(_ theme: ThemeDefinition) { + guard theme.isEditable else { return } + selection = .theme(theme.id) + renameText = model.nameForRenaming(theme.id) ?? theme.name + renameError = nil + renamingId = theme.id + } + + /// A taken name keeps the field open with the reason, the way Finder refuses one. + private func commitRename(_ id: String) { + guard renamingId == id else { return } + switch model.rename(id, to: renameText, undoManager: undoManager) { + case .renamed, .unchanged: + endRename() + case let .rejected(message): + renameError = message + NSSound.beep() + isRenameFieldFocused = true + } + } + + private func cancelRename() { + endRename() + } + + private func endRename() { + renamingId = nil + renameError = nil + isRenameFieldFocused = false + } + + private func requestTrash(_ item: ThemeLibraryItem?) { + guard let item, model.canTrash(item) else { return } + if let confirmation = model.trashConfirmation(for: item) { + pendingTrash = confirmation + } else { + trash(item) + } + } + + private func trash(_ item: ThemeLibraryItem) { + guard model.moveToTrash(item, undoManager: undoManager) else { return } + if selection == item { + selection = nil + } + } + + private func isPresented(_ value: Binding) -> Binding { + Binding( + get: { value.wrappedValue != nil }, + set: { if !$0 { value.wrappedValue = nil } } + ) + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeSlotChoice.swift b/TablePro/Views/Settings/Appearance/ThemeSlotChoice.swift index 9e7437cbe0..fb53778f5a 100644 --- a/TablePro/Views/Settings/Appearance/ThemeSlotChoice.swift +++ b/TablePro/Views/Settings/Appearance/ThemeSlotChoice.swift @@ -1,4 +1,5 @@ import Foundation +import TableProTheme /// What the Light or Dark theme popup offers and shows. The popup shows the theme the engine draws /// with, so a saved choice this Mac cannot honor (a theme synced from another Mac, a file that no @@ -24,11 +25,10 @@ internal struct ThemeSlotChoice: Equatable { // The engine's own resolver, so the popup can never name a theme other than the one drawn. let inUse = ThemeResolver.resolve( - mode: slot == .dark ? .dark : .light, lightThemeId: savedId, darkThemeId: savedId, themes: themes, - systemIsDark: slot == .dark + effectiveAppearance: slot ).active selectedId = inUse.id diff --git a/TablePro/Views/Settings/Appearance/ThemeSlotPresentation.swift b/TablePro/Views/Settings/Appearance/ThemeSlotPresentation.swift new file mode 100644 index 0000000000..5cd35554e5 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeSlotPresentation.swift @@ -0,0 +1,121 @@ +import AppKit +import TableProTheme + +/// What one slot row says about the theme: the color the well shows, a line naming where that color +/// comes from, and what a reset would return to. Text, not color alone, tells a set slot from one +/// that follows the system. +internal struct ThemeSlotPresentation: Equatable { + internal let slot: ThemeSlot + internal let hasExplicitColor: Bool + internal let displayColor: ThemeColor + internal let stateLine: String + internal let resetTitle: String + internal let resetAccessibilityLabel: String + + internal init(slot: ThemeSlot, theme: ThemeDefinition) { + self.slot = slot + hasExplicitColor = theme.hasExplicitColor(slot) + displayColor = Self.displayColor(for: slot, in: theme) + stateLine = Self.stateLine(for: slot, in: theme) + resetTitle = Self.resetTitle(for: slot, in: theme) + resetAccessibilityLabel = Self.resetsToSystem(slot, in: theme) + ? String(format: String(localized: "Use System Color for %@"), slot.accessibilityLabel) + : String(format: String(localized: "Use Default Color for %@"), slot.accessibilityLabel) + } + + /// An unset slot shows the system color resolved in the theme's own appearance, not the one the + /// editor window happens to be in. + internal static func displayColor(for slot: ThemeSlot, in theme: ThemeDefinition) -> ThemeColor { + switch theme.value(for: slot) { + case let .themed(color): + return color + case let .defaultSyntax(source): + return DefaultSyntaxPalette.color(for: source, appearance: theme.appearance, highContrast: false) + ?? resolved(source.nativeColor, in: theme.appearance) + case let .system(source): + return resolved(source.nativeColor, in: theme.appearance) + } + } + + internal static func stateLine(for slot: ThemeSlot, in theme: ThemeDefinition) -> String { + if let explicit = theme.overrides[slot] { + return explicit.hex + } + switch theme.value(for: slot) { + case .system: + return String(localized: "System color") + case .defaultSyntax: + switch theme.appearance { + case .light: return String(localized: "Default Light color") + case .dark: return String(localized: "Default Dark color") + } + case .themed: + guard let source = slot.fallbackSource else { return String(localized: "System color") } + return String(format: String(localized: "Based on %@"), source.accessibilityLabel) + } + } + + internal static func resetTitle(for slot: ThemeSlot, in theme: ThemeDefinition) -> String { + resetsToSystem(slot, in: theme) + ? String(localized: "Use System Color") + : String(localized: "Use Default Color") + } + + private static func resetsToSystem(_ slot: ThemeSlot, in theme: ThemeDefinition) -> Bool { + var cleared = theme + cleared.overrides[slot] = nil + if case .system = cleared.value(for: slot) { + return true + } + return false + } + + private static func resolved(_ color: NSColor, in appearance: ThemeAppearance) -> ThemeColor { + guard let drawing = NSAppearance(named: appearance == .dark ? .darkAqua : .aqua) else { + return ThemeColor(color) + } + var result = ThemeColor(color) + drawing.performAsCurrentDrawingAppearance { + result = ThemeColor(color.usingColorSpace(.sRGB) ?? color) + } + return result + } +} + +/// Text on its background, for the note under a section whose pair reads below the HIG's 4.5:1. +internal enum ThemeContrastNote { + internal static let minimumRatio = 4.5 + + internal static func pair(for section: ThemeSlotSection) -> (text: ThemeSlot, background: ThemeSlot)? { + switch section { + case .editor: return (.editorText, .editorBackground) + case .dataGrid: return (.gridText, .gridBackground) + case .window, .syntax, .selection, .changes: return nil + } + } + + internal static func ratio(for section: ThemeSlotSection, in theme: ThemeDefinition) -> Double? { + guard let pair = pair(for: section) else { return nil } + let background = ThemeSlotPresentation.displayColor(for: pair.background, in: theme).opaque + let text = ThemeSlotPresentation.displayColor(for: pair.text, in: theme).composited(over: background) + return text.contrastRatio(with: background) + } + + internal static func message(for section: ThemeSlotSection, in theme: ThemeDefinition) -> String? { + guard let ratio = ratio(for: section, in: theme), ratio < minimumRatio else { return nil } + let formatted = ratio.formatted(.number.precision(.fractionLength(1))) + return String(format: String(localized: "Text contrast is %@:1. Aim for at least 4.5:1."), formatted) + } +} + +private extension ThemeSlot { + /// The slot an unset one takes its color from, when that is a theme color. + var fallbackSource: ThemeSlot? { + switch fallback { + case .system, .defaultSyntax: return nil + case let .slot(source): return source + case let .derived(base, _): return base + case let .paired(_, fallback): return fallback + } + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeSlotRow.swift b/TablePro/Views/Settings/Appearance/ThemeSlotRow.swift new file mode 100644 index 0000000000..6cbe96f927 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeSlotRow.swift @@ -0,0 +1,133 @@ +import AppKit +import SwiftUI +import TableProTheme + +/// One color of the theme: its name, where the color comes from, the system color well, and a reset +/// that appears only while the file sets the slot. +internal struct ThemeSlotRow: View { + internal let presentation: ThemeSlotPresentation + internal let isEditable: Bool + internal let onSet: (ThemeColor) -> Void + internal let onReset: () -> Void + + private var slot: ThemeSlot { presentation.slot } + + internal var body: some View { + LabeledContent { + HStack(spacing: 8) { + if presentation.hasExplicitColor, isEditable { + Button(action: onReset) { + Label(presentation.resetTitle, systemImage: "arrow.uturn.backward") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .help(presentation.resetTitle) + .accessibilityLabel(presentation.resetAccessibilityLabel) + .accessibilityIdentifier("theme-color-reset-\(slot.rawValue)") + } + + ThemeColorWell( + color: presentation.displayColor, + canPickOpacity: slot.offersOpacity, + isEnabled: isEditable, + label: slot.accessibilityLabel, + identifier: "theme-color-\(slot.rawValue)", + onPick: onSet + ) + .fixedSize() + } + } label: { + Text(slot.label) + Text(presentation.stateLine) + .accessibilityIdentifier("theme-color-state-\(slot.rawValue)") + } + .contextMenu { + if presentation.hasExplicitColor, isEditable { + Button(presentation.resetTitle, action: onReset) + } + } + } +} + +/// AppKit on purpose: `ColorPicker` reports every color the shared panel sends, and activating a +/// well without opacity turns the panel's alpha off, which sends the previous well's color and then +/// this one's, both made opaque. Taken as picks, they froze a system color into the file. +private struct ThemeColorWell: NSViewRepresentable { + let color: ThemeColor + let canPickOpacity: Bool + let isEnabled: Bool + let label: String + let identifier: String + let onPick: (ThemeColor) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> ThemeSlotColorWell { + let well = ThemeSlotColorWell(frame: .zero) + well.target = context.coordinator + well.action = #selector(Coordinator.colorPicked(_:)) + return well + } + + func updateNSView(_ well: ThemeSlotColorWell, context: Context) { + context.coordinator.onPick = onPick + well.canPickOpacity = canPickOpacity + well.isEnabled = isEnabled && context.environment.isEnabled + well.setAccessibilityLabel(label) + well.setAccessibilityIdentifier(identifier) + // A well without opacity holds an opaque copy, so comparing with its own color would rewrite it + // on every update. + guard context.coordinator.shownColor != color else { return } + context.coordinator.shownColor = color + well.show(color.nsColor) + } + + @MainActor + final class Coordinator: NSObject { + var onPick: (ThemeColor) -> Void = { _ in } + var shownColor: ThemeColor? + + @objc func colorPicked(_ sender: NSColorWell) { + let picked = ThemeColor(sender.color) + shownColor = picked + onPick(picked) + } + } +} + +/// A well that sends its action only for a color the user picks: the panel's sends while the well +/// activates, or while code sets its color, are the panel catching up. +internal final class ThemeSlotColorWell: NSColorWell { + private var isSettingItself = false + + internal var canPickOpacity = true { + didSet { + guard canPickOpacity != oldValue else { return } + if #available(macOS 14, *) { + supportsAlpha = canPickOpacity + } + } + } + + internal func show(_ shown: NSColor) { + isSettingItself = true + defer { isSettingItself = false } + color = shown + } + + override func activate(_ exclusive: Bool) { + isSettingItself = true + defer { isSettingItself = false } + if #unavailable(macOS 14) { + NSColorPanel.shared.showsAlpha = canPickOpacity + } + super.activate(exclusive) + } + + override func sendAction(_ action: Selector?, to target: Any?) -> Bool { + guard !isSettingItself else { return false } + return super.sendAction(action, to: target) + } +} diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 0a11932aae..37d750ee7c 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -1,21 +1,25 @@ import SwiftUI +import TableProTheme internal struct AppearanceSettingsView: View { @Binding internal var settings: AppearanceSettings @ObservedObject private var catalog = ThemeCatalog.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared private static let modes: [AppAppearanceMode] = [.auto, .light, .dark] internal var body: some View { Form { Section { - Picker(String(localized: "Appearance"), selection: $settings.appearanceMode) { + Picker(String(localized: "Appearance"), selection: $settingsManager.appearanceMode) { ForEach(Self.modes, id: \.self) { mode in Text(Self.title(for: mode)).tag(mode) } } .accessibilityIdentifier("appearance-mode-picker") + } footer: { + Text("Applies to this Mac.") } Section { @@ -25,7 +29,7 @@ internal struct AppearanceSettingsView: View { 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) + ThemeLibrarySection() } .formStyle(.grouped) } diff --git a/TablePro/Views/Settings/ThemePreviewCard.swift b/TablePro/Views/Settings/ThemePreviewCard.swift deleted file mode 100644 index f7f1ce530b..0000000000 --- a/TablePro/Views/Settings/ThemePreviewCard.swift +++ /dev/null @@ -1,201 +0,0 @@ -import SwiftUI - -/// 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 - - private struct Token { - let text: String - let slot: ThemeSlot - } - - private struct GridCell { - let text: String - let slot: ThemeSlot - } - - private static let codeLines: [[Token]] = [ - [Token(text: "-- Active customers", slot: .syntaxComment)], - [ - Token(text: "SELECT", slot: .syntaxKeyword), - Token(text: " name, ", slot: .editorText), - Token(text: "COUNT", slot: .syntaxFunction), - Token(text: "(*) ", slot: .editorText), - Token(text: "AS", slot: .syntaxKeyword), - Token(text: " orders", slot: .editorText), - ], - [ - Token(text: "FROM", slot: .syntaxKeyword), - Token(text: " customers", slot: .editorText), - ], - [ - Token(text: "WHERE", slot: .syntaxKeyword), - Token(text: " country ", slot: .editorText), - Token(text: "=", slot: .syntaxOperator), - Token(text: " 'VN' ", slot: .syntaxString), - Token(text: "AND", slot: .syntaxKeyword), - Token(text: " score ", slot: .editorText), - Token(text: ">", slot: .syntaxOperator), - Token(text: " 42", slot: .syntaxNumber), - ], - [ - Token(text: " AND", slot: .syntaxKeyword), - Token(text: " deleted_at ", slot: .editorText), - Token(text: "IS", slot: .syntaxKeyword), - Token(text: " NULL", slot: .syntaxNull), - Token(text: "::", slot: .syntaxOperator), - Token(text: "TIMESTAMP", slot: .syntaxType), - ], - ] - - private static let currentLine = 3 - private static let selectedLine = 2 - private static let selectedToken = 1 - - private static let gridHeader = ["id", "name", "active"] - private static let gridRows: [[GridCell]] = [ - [ - GridCell(text: "1", slot: .gridText), - GridCell(text: "Ada", slot: .gridText), - GridCell(text: "true", slot: .gridBoolTrue), - ], - [ - GridCell(text: "2", slot: .gridText), - GridCell(text: "NULL", slot: .gridNullValue), - GridCell(text: "false", slot: .gridBoolFalse), - ], - [ - GridCell(text: "3", slot: .gridText), - GridCell(text: "Grace", slot: .gridText), - GridCell(text: "true", slot: .gridBoolTrue), - ], - ] - - private static let selectedRow = 2 - private static let columnWidths: [CGFloat] = [28, 72, 56] - - internal var body: some View { - 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)) - } - .padding(8) - .background(color(.windowBackdrop)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color(nsColor: .separatorColor)) - ) - .environment(\.colorScheme, theme.appearance == .dark ? .dark : .light) - .accessibilityElement(children: .ignore) - .accessibilityLabel(String(format: String(localized: "Preview of %@"), theme.name)) - } - - private func color(_ slot: ThemeSlot) -> Color { - Color(nsColor: theme.color(for: slot)) - } - - // MARK: - Editor - - private var editor: some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(Self.codeLines.indices, id: \.self) { index in - HStack(spacing: 8) { - Text(verbatim: "\(index + 1)") - .foregroundStyle(color(.editorLineNumber)) - .frame(width: 14, alignment: .trailing) - codeLine(index) - Spacer(minLength: 0) - } - .padding(.horizontal, 8) - .padding(.vertical, 1) - .background(index == Self.currentLine ? color(.editorCurrentLine) : Color.clear) - } - } - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background(color(.editorBackground)) - } - - private func codeLine(_ line: Int) -> some View { - HStack(spacing: 0) { - ForEach(Self.codeLines[line].indices, id: \.self) { index in - let token = Self.codeLines[line][index] - let isSelected = line == Self.selectedLine && index == Self.selectedToken - Text(verbatim: token.text) - .foregroundStyle(color(token.slot)) - .background(isSelected ? color(.editorSelection) : Color.clear) - } - if line == Self.currentLine { - Rectangle() - .fill(color(.editorCursor)) - .frame(width: 1.5, height: 12) - } - } - .lineLimit(1) - .fixedSize() - } - - // MARK: - Grid - - private var grid: some View { - VStack(spacing: 0) { - gridRow( - number: "", - cells: Self.gridHeader.map { GridCell(text: $0, slot: .gridHeaderText) }, - background: color(.gridHeaderBackground) - ) - gridLine - ForEach(Self.gridRows.indices, id: \.self) { index in - let isSelected = index == Self.selectedRow - gridRow( - number: "\(index + 1)", - cells: Self.gridRows[index], - background: rowBackground(index), - textSlot: isSelected ? .gridSelectionText : nil - ) - gridLine - } - } - .background(color(.gridBackground)) - } - - private func rowBackground(_ index: Int) -> Color { - if index == Self.selectedRow { return color(.gridSelection) } - return index.isMultiple(of: 2) ? color(.gridBackground) : color(.gridAlternateRow) - } - - private func gridRow(number: String, cells: [GridCell], background: Color, textSlot: ThemeSlot? = nil) -> some View { - HStack(spacing: 0) { - Text(verbatim: number) - .foregroundStyle(color(textSlot ?? .gridRowNumber)) - .frame(width: 22, alignment: .trailing) - .padding(.trailing, 8) - ForEach(cells.indices, id: \.self) { index in - Text(verbatim: cells[index].text) - .foregroundStyle(color(textSlot ?? cells[index].slot)) - .lineLimit(1) - .frame(width: Self.columnWidths[index], alignment: .leading) - } - Spacer(minLength: 0) - } - .padding(.vertical, 3) - .background(background) - } - - private var gridLine: some View { - Rectangle() - .fill(color(.gridLine)) - .frame(height: 1) - } -} diff --git a/TablePro/Views/Shared/FieldEditors/ValueWell.swift b/TablePro/Views/Shared/FieldEditors/ValueWell.swift index da933f17c8..a723a315d9 100644 --- a/TablePro/Views/Shared/FieldEditors/ValueWell.swift +++ b/TablePro/Views/Shared/FieldEditors/ValueWell.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme internal extension View { /// The rounded, bordered well a stored value sits in, on the editor's background while the active diff --git a/TablePro/Views/Shared/ObjectSourceView.swift b/TablePro/Views/Shared/ObjectSourceView.swift index 42d1ceb2a6..5aeb84e0bc 100644 --- a/TablePro/Views/Shared/ObjectSourceView.swift +++ b/TablePro/Views/Shared/ObjectSourceView.swift @@ -71,7 +71,7 @@ struct ObjectSourceView: View { .disabled(!hasSource) } .padding() - .background(.bar) + .paneBarBackground() } private var fontStepper: some View { @@ -136,6 +136,6 @@ struct ObjectAttributeStrip: View { } .padding(.horizontal) .padding(.vertical, 8) - .background(.bar) + .paneBarBackground() } } diff --git a/TablePro/Views/Shared/TextDiffView.swift b/TablePro/Views/Shared/TextDiffView.swift index 894928b2ba..9fb65e98eb 100644 --- a/TablePro/Views/Shared/TextDiffView.swift +++ b/TablePro/Views/Shared/TextDiffView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme internal enum TextDiffLayout: Hashable { case split diff --git a/TablePro/Views/Sidebar/FileConflictDiffSheet.swift b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift index 3950d3a45f..d7ed41f2a2 100644 --- a/TablePro/Views/Sidebar/FileConflictDiffSheet.swift +++ b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme internal struct FileConflictDiffSheet: View { let fileName: String diff --git a/TablePro/Views/Structure/CreateTableFormPreview.swift b/TablePro/Views/Structure/CreateTableFormPreview.swift index bd360e1957..e902a1da1a 100644 --- a/TablePro/Views/Structure/CreateTableFormPreview.swift +++ b/TablePro/Views/Structure/CreateTableFormPreview.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProTheme internal struct CreateTableFormPreview: View { internal let preview: CreateTableFormState.Preview @@ -26,6 +27,8 @@ internal struct CreateTableFormPreview: View { } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) + // Stands in for the statement text view, so under a theme it keeps the editor's background. + .themeBackground(.editorBackground, native: Color.clear, ignoresSafeAreaEdges: []) } } .accessibilityElement(children: .contain) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 0b1c735057..206b4dbec3 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -11,6 +11,7 @@ import Combine import os import SwiftUI import TableProPluginKit +import TableProTheme private enum CreateTableTab: CaseIterable { case columns @@ -226,7 +227,7 @@ struct CreateTableView: View { Spacer() } .padding() - .background(.bar) + .paneBarBackground() .onChange(of: draft.tableOptions.charset) { newCharset in if let first = CreateTableOptions.collations[newCharset]?.first { draft.tableOptions.collation = first @@ -421,39 +422,21 @@ struct CreateTableView: View { private var sqlPreviewView: some View { if let composed = draft.composed { if composed.statements.isEmpty { - sqlPreviewPlaceholder(composed.issues.first?.qualifiedMessage) + CreateTableSQLPreviewStandIn(content: .message(composed.issues.first?.qualifiedMessage)) } else { DDLTextView(ddl: composed.preview, fontSize: .constant(13), databaseType: connection.type) } } else if let failure = draft.compositionFailure { - sqlPreviewPlaceholder(failure) + CreateTableSQLPreviewStandIn(content: .message(failure)) } else { uncomposedPreview(plan: draft.plan(for: connection.type)) } } - @ViewBuilder private func uncomposedPreview(plan: CreateTablePlan) -> some View { - if plan.definition == nil { - sqlPreviewPlaceholder(plan.issues.first?.qualifiedMessage) - } else { - ProgressView() - .controlSize(.small) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - private func sqlPreviewPlaceholder(_ message: String?) -> some View { - VStack(spacing: 8) { - Image(systemName: "doc.plaintext") - .font(.largeTitle) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - Text(message ?? String(localized: "Add columns to see the CREATE TABLE statement")) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + CreateTableSQLPreviewStandIn( + content: plan.definition == nil ? .message(plan.issues.first?.qualifiedMessage) : .composing + ) } // Cell editing, row operations, undo/redo handled by CreateTableGridDelegate @@ -624,3 +607,39 @@ struct CreateTableView: View { AppCommands.shared.objectChanged.send(created) } } + +// Stands in for the statement text view, so under a theme it keeps the editor's background. +internal struct CreateTableSQLPreviewStandIn: View { + internal enum Content: Equatable { + case message(String?) + case composing + } + + internal let content: Content + + internal var body: some View { + stateView + .themeBackground(.editorBackground, native: Color.clear, ignoresSafeAreaEdges: []) + } + + @ViewBuilder + private var stateView: some View { + switch content { + case .message(let message): + VStack(spacing: 8) { + Image(systemName: "doc.plaintext") + .font(.largeTitle) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(message ?? String(localized: "Add columns to see the CREATE TABLE statement")) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .composing: + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/TablePro/Views/Structure/StructureListGrid.swift b/TablePro/Views/Structure/StructureListGrid.swift index 5e88c21ca1..e20c82921d 100644 --- a/TablePro/Views/Structure/StructureListGrid.swift +++ b/TablePro/Views/Structure/StructureListGrid.swift @@ -13,6 +13,25 @@ internal struct StructureListRow: Hashable { internal let values: [String] /// Keyed by column, for a cell whose drawn text reads wrong aloud, such as YES for Enabled. internal var spokenValues: [Int: String] = [:] + internal var tones: [Int: StructureListTone] = [:] +} + +/// A status hue on one cell's text. Status hues stay system under every theme, and the text still +/// turns to the selection text on an emphasized selection, like the rest of the row. +internal enum StructureListTone: Hashable { + case positive + case caution + case critical + case secondary + + internal var color: NSColor { + switch self { + case .positive: return .systemGreen + case .caution: return .systemOrange + case .critical: return .systemRed + case .secondary: return .secondaryLabelColor + } + } } /// Selected row ids held by reference: a read made during a SwiftUI update gets a `State` or `Binding` snapshot. @@ -32,6 +51,7 @@ internal final class StructureListGridModel: ObservableObject, Dat internal private(set) var tableRows = TableRows() internal private(set) var rowIDs: [ID] = [] private var spokenValues: [[Int: String]] = [] + private var tones: [[Int: StructureListTone]] = [] private var loadedKey: Int? private weak var coordinator: TableViewCoordinator? @@ -50,6 +70,7 @@ internal final class StructureListGridModel: ObservableObject, Dat let listed = rows() rowIDs = listed.map(\.id) spokenValues = listed.map(\.spokenValues) + tones = listed.map(\.tones) tableRows = TableRows.from( queryRows: listed.map { $0.values.map(PluginCellValue.text) }, columns: columns, @@ -115,6 +136,10 @@ internal final class StructureListGridModel: ObservableObject, Dat rowIndex(forDisplayRow: row).flatMap { spokenValues[$0][column] } } + internal func dataGridTextTone(row: Int, column: Int) -> NSColor? { + rowIndex(forDisplayRow: row).flatMap { tones[$0][column]?.color } + } + internal func dataGridSortStateChanged(_ state: SortState) { onSortStateChanged?(state) } diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index 4775081f14..98be94a3e2 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -10,6 +10,7 @@ import Combine import os import SwiftUI import TableProPluginKit +import TableProTheme import UniformTypeIdentifiers // MARK: - Schema Operations @@ -117,12 +118,12 @@ extension TableStructureView { .buttonStyle(.bordered) } .padding() - .background(.bar) + .paneBarBackground() Divider() if ddlStatement.isEmpty { - emptyState(String(localized: "No DDL available")) + StructureDDLUnavailableView() } else { DDLTextView(ddl: ddlStatement, fontSize: $ddlFontSize, databaseType: connection.type) } @@ -187,3 +188,19 @@ extension TableStructureView { } } } + +// Stands in for the DDL text view, so under a theme it keeps the editor's background. +internal struct StructureDDLUnavailableView: View { + internal var body: some View { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.largeTitle) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(String(localized: "No DDL available")) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themeBackground(.editorBackground, native: Color.clear, ignoresSafeAreaEdges: []) + } +} diff --git a/TablePro/Views/Structure/TableStructureView+TableComment.swift b/TablePro/Views/Structure/TableStructureView+TableComment.swift index 0e44a2866f..2b1d994295 100644 --- a/TablePro/Views/Structure/TableStructureView+TableComment.swift +++ b/TablePro/Views/Structure/TableStructureView+TableComment.swift @@ -53,7 +53,7 @@ internal extension TableStructureView { .accessibilityIdentifier("structure-table-comment") } .padding() - .background(.bar) + .paneBarBackground() Divider() } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 322949a5ea..ba5031534c 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -578,18 +578,6 @@ struct TableStructureView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - - func emptyState(_ message: String) -> some View { - VStack(spacing: 8) { - Image(systemName: "tray") - .font(.largeTitle) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - Text(message) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } } #Preview { diff --git a/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift b/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift index 5731e0a61d..a40201a80d 100644 --- a/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift +++ b/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift @@ -50,7 +50,7 @@ struct PrivilegeEditorPane: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) .onChange(of: viewModel.scopeMode) { _ in viewModel.applyScopeMode() } diff --git a/TablePro/Views/VersionHistory/VersionHistoryDetailHeader.swift b/TablePro/Views/VersionHistory/VersionHistoryDetailHeader.swift index 616e25d36c..15f06a0f38 100644 --- a/TablePro/Views/VersionHistory/VersionHistoryDetailHeader.swift +++ b/TablePro/Views/VersionHistory/VersionHistoryDetailHeader.swift @@ -52,7 +52,7 @@ internal struct VersionHistoryDetailHeader: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } private func restoreSelection() { diff --git a/TablePro/Views/VersionHistory/VersionHistoryListPane.swift b/TablePro/Views/VersionHistory/VersionHistoryListPane.swift index 0074899d40..8aa9072bac 100644 --- a/TablePro/Views/VersionHistory/VersionHistoryListPane.swift +++ b/TablePro/Views/VersionHistory/VersionHistoryListPane.swift @@ -29,7 +29,7 @@ internal struct VersionHistoryListPane: View { .padding(.horizontal, 10) .padding(.vertical, 6) } - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } } } diff --git a/TablePro/Views/VersionHistory/VersionHistoryTabView.swift b/TablePro/Views/VersionHistory/VersionHistoryTabView.swift index 06957b146f..b89ebc9414 100644 --- a/TablePro/Views/VersionHistory/VersionHistoryTabView.swift +++ b/TablePro/Views/VersionHistory/VersionHistoryTabView.swift @@ -5,6 +5,7 @@ import Combine import SwiftUI +import TableProTheme internal struct VersionHistoryTabView: View { let tabId: UUID diff --git a/TableProTests/Core/Database/DatabaseEndpointTests.swift b/TableProTests/Core/Database/DatabaseEndpointTests.swift index 1f118fc144..f2bb6cb001 100644 --- a/TableProTests/Core/Database/DatabaseEndpointTests.swift +++ b/TableProTests/Core/Database/DatabaseEndpointTests.swift @@ -56,6 +56,17 @@ struct DatabaseEndpointTests { #expect(moved.schema == nil) } + @Test + @MainActor + func theConnectionIconSurvivesAScopeChange() { + let connection = DatabaseConnection(name: "prod", type: .postgresql, color: .red, iconName: "star") + let endpoint = DatabaseEndpoint.from(connection: connection, database: "app") + + #expect(endpoint.iconName == "star") + #expect(endpoint.withDatabase("other").iconName == "star") + #expect(endpoint.withSchema("public").iconName == "star") + } + @Test func readOnlyEndpointIsRefusedAsATarget() { let readOnly = DatabaseEndpoint( diff --git a/TableProTests/Core/Operations/AppSettingsCategoryParityTests.swift b/TableProTests/Core/Operations/AppSettingsCategoryParityTests.swift index 40b2ebd815..29f8559502 100644 --- a/TableProTests/Core/Operations/AppSettingsCategoryParityTests.swift +++ b/TableProTests/Core/Operations/AppSettingsCategoryParityTests.swift @@ -12,6 +12,13 @@ struct AppSettingsCategoryParityTests { #expect(AppSettingsCategory.deviceLocal.isDisjoint(with: Set(AppSettingsCategory.synced))) } + @Test("The appearance mode is device-local, and the theme ids it sat next to still sync") + func appearanceModeIsDeviceLocal() { + #expect(AppSettingsCategory.deviceLocal.contains(AppSettingsCategory.appearanceMode)) + #expect(!AppSettingsCategory.synced.contains(AppSettingsCategory.appearanceMode)) + #expect(AppSettingsCategory.synced.contains(AppSettingsCategory.appearance)) + } + @Test("Notification settings are among the synced categories") func notificationsSync() { #expect(AppSettingsCategory.synced.contains(AppSettingsCategory.notifications)) diff --git a/TableProTests/Core/Services/Infrastructure/DetailColumnBackdropTests.swift b/TableProTests/Core/Services/Infrastructure/DetailColumnBackdropTests.swift new file mode 100644 index 0000000000..a7c4efa359 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/DetailColumnBackdropTests.swift @@ -0,0 +1,127 @@ +// +// DetailColumnBackdropTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +/// Connecting, Unavailable and the agent conversation draw no backdrop of their own, so the theme +/// reaches them only through the detail column. Windows are never ordered front and never closed. +@MainActor +struct DetailColumnBackdropTests { + private static let size = NSSize(width: 400, height: 300) + + private func snapshot(_ view: NSView) throws -> NSBitmapImageRep { + view.layoutSubtreeIfNeeded() + view.displayIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + return bitmap + } + + private func pixel(_ bitmap: NSBitmapImageRep, at point: CGPoint) throws -> NSColor { + let x = min(bitmap.pixelsWide - 1, Int(CGFloat(bitmap.pixelsWide) * point.x)) + let y = min(bitmap.pixelsHigh - 1, Int(CGFloat(bitmap.pixelsHigh) * point.y)) + return try #require(ThemeTestFixture.pixel(bitmap, x: x, y: y)) + } + + private func window(containing view: NSView) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: Self.size), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .darkAqua) + window.contentView = view + return window + } + + /// Drawn through the same path as the column, so the capture's color management cancels out. + private func reference(_ style: some ShapeStyle) throws -> NSColor { + let host = NSHostingView(rootView: Rectangle().fill(style)) + host.frame = NSRect(origin: .zero, size: Self.size) + let window = window(containing: host) + defer { window.contentView = nil } + return try pixel(snapshot(host), at: CGPoint(x: 0.5, y: 0.5)) + } + + /// The detail column the main window builds, showing a pane that draws nothing, the way the + /// Connecting screen leaves the area around its message. + private func column(withBackdrop: Bool) -> (host: WorkspacePaneHost, window: NSWindow) { + let host = WorkspacePaneHost() + if withBackdrop { + host.installBackdrop(DetailColumnBackdrop.makeView()) + } + let pane = NSHostingController(rootView: AnyView(Color.clear)) + pane.sizingOptions = [] + host.show(pane) + host.view.frame = NSRect(origin: .zero, size: Self.size) + return (host, window(containing: host.view)) + } + + @Test("The detail column draws the window theme under a pane with no backdrop of its own") + func themedUnderABarePane() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + let theme = try reference(Color(nsColor: HexColor.color(ThemeTestFixture.windowHex))) + let (host, window) = column(withBackdrop: true) + defer { window.contentView = nil } + let drawn = try pixel(snapshot(host.view), at: CGPoint(x: 0.1, y: 0.9)) + #expect(ThemeTestFixture.matches(drawn, theme), "drew \(drawn), theme \(theme)") + } + } + + @Test("Under the default pair the detail column draws the system background") + func defaultDrawsTheSystemBackground() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let native = try reference(.background) + let (host, window) = column(withBackdrop: true) + defer { window.contentView = nil } + let drawn = try pixel(snapshot(host.view), at: CGPoint(x: 0.1, y: 0.9)) + #expect(ThemeTestFixture.matches(drawn, native), "drew \(drawn), native \(native)") + } + } + + @Test("Without the backdrop the same pane shows nothing, which is what the window drew through") + func withoutTheBackdropNothingIsDrawn() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + let (host, window) = column(withBackdrop: false) + defer { window.contentView = nil } + let drawn = try pixel(snapshot(host.view), at: CGPoint(x: 0.1, y: 0.9)) + #expect(drawn.alphaComponent < 0.05, "drew \(drawn)") + } + } + + @Test("A pane shown later still sits above the backdrop, and the backdrop never takes a click") + func backdropStaysBelowAndPassesClicks() throws { + let (host, window) = column(withBackdrop: true) + defer { window.contentView = nil } + let backdrop = try #require(host.view.subviews.first) + #expect(backdrop.hitTest(NSPoint(x: 10, y: 10)) == nil) + + let next = NSViewController() + next.view = NSView() + host.show(next) + #expect(host.view.subviews.first === backdrop) + #expect(host.view.subviews.last === next.view) + } + + @Test("The main window's Connecting screen sits on the window theme") + func mainWindowConnectingIsThemed() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + let theme = try reference(Color(nsColor: HexColor.color(ThemeTestFixture.windowHex))) + let fixture = try OffscreenConnectionWindow(size: CGSize(width: 1_200, height: 800)) + defer { fixture.tearDown() } + fixture.window.appearance = NSAppearance(named: .darkAqua) + #expect(fixture.workspace.resolvedPane == .connecting) + + let drawn = try pixel(snapshot(fixture.split.detailPaneHost.view), at: CGPoint(x: 0.05, y: 0.95)) + #expect(ThemeTestFixture.matches(drawn, theme), "drew \(drawn), theme \(theme)") + } + } +} diff --git a/TableProTests/Core/Storage/AppearanceModeStorageTests.swift b/TableProTests/Core/Storage/AppearanceModeStorageTests.swift new file mode 100644 index 0000000000..88944ae2a0 --- /dev/null +++ b/TableProTests/Core/Storage/AppearanceModeStorageTests.swift @@ -0,0 +1,156 @@ +// +// AppearanceModeStorageTests.swift +// TableProTests +// +// The Auto, Light or Dark mode used to sit inside the synced appearance blob, so a change on one +// Mac flipped every other Mac. It now has its own device-local key, carried out of the blob once. +// + +import AppKit +import Foundation +@testable import TablePro +import TableProSyncTransport +import TableProTheme +import Testing + +@MainActor +struct AppearanceModeStorageTests { + private static let appearanceKey = "com.TablePro.settings.appearance" + private static let modeKey = "com.TablePro.settings.appearanceMode" + private static let legacyBlob = #"{"appearanceMode":"dark","preferredLightThemeId":"user.day","preferredDarkThemeId":"user.night"}"# + + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private var defaults: UserDefaults { suite.defaults } + + // MARK: - Storage + + @Test("The mode an older build kept in the appearance blob moves to its own key on the first load") + func legacyModeIsCarriedOnce() { + defaults.set(Data(Self.legacyBlob.utf8), forKey: Self.appearanceKey) + let storage = AppSettingsStorage(userDefaults: defaults) + + #expect(storage.loadAppearanceMode() == .dark) + #expect(defaults.string(forKey: Self.modeKey) == "dark") + #expect(storage.loadAppearance() == AppearanceSettings(preferredLightThemeId: "user.day", preferredDarkThemeId: "user.night")) + } + + @Test("Saving the theme ids after the carry-over keeps the mode the blob held") + func carriedModeSurvivesTheNextSave() { + defaults.set(Data(Self.legacyBlob.utf8), forKey: Self.appearanceKey) + let storage = AppSettingsStorage(userDefaults: defaults) + _ = storage.loadAppearanceMode() + + storage.saveAppearance(AppearanceSettings(preferredLightThemeId: "user.day", preferredDarkThemeId: "user.other")) + + #expect(storage.loadAppearanceMode() == .dark) + } + + @Test("The device-local key wins over a mode still in the blob") + func ownKeyWins() { + defaults.set(Data(Self.legacyBlob.utf8), forKey: Self.appearanceKey) + defaults.set("light", forKey: Self.modeKey) + + #expect(AppSettingsStorage(userDefaults: defaults).loadAppearanceMode() == .light) + } + + @Test("A fresh install starts on Auto and records it") + func freshInstallIsAuto() { + let storage = AppSettingsStorage(userDefaults: defaults) + + #expect(storage.loadAppearanceMode() == .auto) + #expect(defaults.string(forKey: Self.modeKey) == "auto") + } + + @Test("A stored value this build does not know reads as Auto") + func unknownValueIsAuto() { + defaults.set("sepia", forKey: Self.modeKey) + + #expect(AppSettingsStorage(userDefaults: defaults).loadAppearanceMode() == .auto) + } + + @Test("The appearance blob no longer carries a mode") + func blobHasNoMode() throws { + let storage = AppSettingsStorage(userDefaults: defaults) + storage.saveAppearanceMode(.dark) + storage.saveAppearance(AppearanceSettings(preferredLightThemeId: "user.day", preferredDarkThemeId: "user.night")) + + let data = try #require(defaults.data(forKey: Self.appearanceKey)) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(Set(object.keys) == ["preferredLightThemeId", "preferredDarkThemeId"]) + } + + @Test("Reset returns the mode to Auto") + func resetReturnsToAuto() { + let storage = AppSettingsStorage(userDefaults: defaults) + storage.saveAppearanceMode(.dark) + + storage.resetToDefaults() + + #expect(storage.loadAppearanceMode() == .auto) + } + + // MARK: - Manager + + private func makeManager(tracker: SyncChangeTracker, root: URL) -> (AppSettingsManager, ThemeEngine) { + let catalog = ThemeCatalog( + userDirectory: root.appendingPathComponent("Themes", isDirectory: true), + bundleDirectory: nil, + notificationCenter: NotificationCenter(), + fileTrash: .folder(root.appendingPathComponent("Trash", isDirectory: true)), + syncTracker: tracker + ) + let engine = ThemeEngine(catalog: catalog) + let manager = AppSettingsManager( + storage: AppSettingsStorage(userDefaults: defaults), + themeEngine: engine, + syncTracker: tracker, + appEvents: AppEvents() + ) + return (manager, engine) + } + + private static func makeRoot() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("AppearanceModeStorageTests-\(UUID().uuidString)", isDirectory: true) + } + + @Test("Changing the mode saves it on this Mac, applies it, and marks nothing for sync") + func modeChangeStaysLocal() throws { + let syncSuite = try EphemeralDefaults() + let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncSuite.defaults)) + let root = Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let (manager, engine) = makeManager(tracker: tracker, root: root) + let original = NSApp.appearance + defer { NSApp.appearance = original } + + manager.appearanceMode = .dark + + #expect(AppSettingsStorage(userDefaults: defaults).loadAppearanceMode() == .dark) + #expect(engine.mode == .dark) + #expect(tracker.dirtyRecords(for: .settings).isEmpty) + + manager.appearanceMode = .auto + #expect(engine.mode == .auto) + } + + @Test("Changing a theme id marks the appearance record and leaves the mode") + func themeChangeSyncs() throws { + let syncSuite = try EphemeralDefaults() + let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncSuite.defaults)) + let root = Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let (manager, engine) = makeManager(tracker: tracker, root: root) + + manager.appearance = AppearanceSettings(preferredDarkThemeId: "user.night") + + #expect(tracker.dirtyRecords(for: .settings) == [AppSettingsCategory.appearance]) + #expect(manager.appearanceMode == .auto) + #expect(engine.darkThemeId == "user.night") + } +} diff --git a/TableProTests/Core/Sync/SyncCoordinatorThemeTests.swift b/TableProTests/Core/Sync/SyncCoordinatorThemeTests.swift new file mode 100644 index 0000000000..1715d1e02c --- /dev/null +++ b/TableProTests/Core/Sync/SyncCoordinatorThemeTests.swift @@ -0,0 +1,453 @@ +// +// SyncCoordinatorThemeTests.swift +// TableProTests +// +// Themes ride the AppSettings record type as `theme.`, next to the `appearance` record that +// selects them. The Auto, Light or Dark mode no longer rides along: each Mac keeps its own. +// + +import CloudKit +import Foundation +@testable import TablePro +import TableProSyncTransport +import TableProTestSupport +import TableProTheme +import Testing + +@MainActor +struct SyncCoordinatorThemeTests { + @MainActor + private final class Harness { + let environment: SyncTestEnvironment + let transport: ScriptedSyncTransport + let themes: URL + let catalog: ThemeCatalog + let engine: ThemeEngine + let storage: AppSettingsStorage + let settings: AppSettingsManager + let coordinator: SyncCoordinator + + var tracker: SyncChangeTracker { environment.tracker } + var metadata: SyncMetadataStorage { environment.metadata } + + init( + label: String, + themesDirectory: URL? = nil, + pulled: @escaping @Sendable ([CKRecord]) -> PullResult = { _ in + PullResult(changedRecords: [], deletedRecordIDs: [], newToken: nil) + } + ) throws { + let environment = try SyncTestEnvironment(label: label) + let transport = ScriptedSyncTransport(zoneID: SyncTestEnvironment.zoneID, pulled: pulled) + self.environment = environment + self.transport = transport + themes = themesDirectory ?? environment.directory.appendingPathComponent("Themes", isDirectory: true) + let trash = environment.directory.appendingPathComponent("Trash", isDirectory: true) + try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true) + catalog = ThemeCatalog( + userDirectory: themes, + bundleDirectory: nil, + notificationCenter: NotificationCenter(), + fileTrash: .folder(trash), + syncTracker: environment.tracker + ) + engine = ThemeEngine(catalog: catalog) + storage = AppSettingsStorage(userDefaults: environment.defaults) + settings = AppSettingsManager( + storage: storage, + themeEngine: engine, + syncTracker: environment.tracker, + appEvents: AppEvents() + ) + let base = environment.makeCoordinator(transport: transport) + let columnLayouts = environment.columnLayouts + coordinator = SyncCoordinator( + services: base.services.replacing(appSettings: settings, storage: storage, themeEngine: engine), + recordCache: environment.recordCache, + transport: transport, + makeNetworkMonitor: { nil }, + columnLayouts: columnLayouts, + themeCatalog: catalog, + licenseGate: { nil } + ) + } + + func setSync(_ enabled: Bool) { + var sync = SyncSettings.default + sync.enabled = enabled + storage.saveSync(sync) + } + + func writeOnDisk(_ theme: ThemeDefinition) throws { + try FileManager.default.createDirectory(at: themes, withIntermediateDirectories: true) + try ThemeFileFormat.encode(theme).write(to: themes.appendingPathComponent("\(theme.id).json")) + catalog.reloadSynchronously() + } + + func fileExists(_ id: String) -> Bool { + FileManager.default.fileExists(atPath: themes.appendingPathComponent("\(id).json").path) + } + } + + nonisolated private static func theme(_ id: String, background: String = "#224466") throws -> ThemeDefinition { + ThemeDefinition( + id: id, + name: id, + author: "", + appearance: .dark, + overrides: [.editorBackground: try ThemeColor(validating: background)] + ) + } + + private static func themeRecord(_ theme: ThemeDefinition) throws -> CKRecord { + SyncRecordMapper.toCKRecord( + category: ThemeCatalog.syncCategory(for: theme.id), + settingsData: try ThemeFileFormat.encode(theme), + in: SyncTestEnvironment.zoneID + ) + } + + private static func settingsRecord(_ category: String, json: String) -> CKRecord { + SyncRecordMapper.toCKRecord(category: category, settingsData: Data(json.utf8), in: SyncTestEnvironment.zoneID) + } + + private static func deletion(ofTheme id: String) -> CKRecord.ID { + CKRecord.ID( + recordName: SyncRecordType.settings.recordName(for: ThemeCatalog.syncCategory(for: id)), + zoneID: SyncTestEnvironment.zoneID + ) + } + + private static func pull(_ records: [CKRecord], deleting deletions: [CKRecord.ID] = []) -> PullResult { + PullResult(changedRecords: records, deletedRecordIDs: deletions, newToken: nil) + } + + // MARK: - Arrival + + @Test("A pulled theme lands on disk and reaches the windows, whichever record comes first", arguments: [true, false]) + func pulledThemeReachesTheEngine(themeFirst: Bool) async throws { + let harness = try Harness(label: "theme-arrival") + let synced = try Self.theme("user.synced") + let selection = Self.settingsRecord( + AppSettingsCategory.appearance, + json: #"{"preferredLightThemeId":"tablepro.default-light","preferredDarkThemeId":"user.synced"}"# + ) + let records = themeFirst ? [try Self.themeRecord(synced), selection] : [selection, try Self.themeRecord(synced)] + + let acknowledged = await harness.coordinator.applyPullResult(Self.pull(records)) + + #expect(acknowledged) + try await waitUntil { harness.engine.pair.dark == synced } + #expect(harness.settings.appearance.preferredDarkThemeId == synced.id) + #expect(harness.fileExists(synced.id)) + #expect(harness.tracker.dirtyRecords(for: .settings).isEmpty) + } + + @Test("A theme record that fails validation is skipped, and the pull is still acknowledged") + func invalidThemeIsSkipped() async throws { + let harness = try Harness(label: "theme-invalid") + let mismatched = SyncRecordMapper.toCKRecord( + category: ThemeCatalog.syncCategory(for: "user.claimed"), + settingsData: try ThemeFileFormat.encode(try Self.theme("user.other")), + in: SyncTestEnvironment.zoneID + ) + + let acknowledged = await harness.coordinator.applyPullResult(Self.pull([mismatched])) + + #expect(acknowledged) + #expect(!harness.fileExists("user.claimed")) + #expect(!harness.fileExists("user.other")) + } + + @Test("A theme the disk refused withholds the change token, so the record arrives again") + func diskFailureWithholdsTheToken() async throws { + let blocker = FileManager.default.temporaryDirectory + .appendingPathComponent("SyncCoordinatorThemeTests-blocker-\(UUID().uuidString)") + #expect(FileManager.default.createFile(atPath: blocker.path, contents: Data())) + defer { try? FileManager.default.removeItem(at: blocker) } + let harness = try Harness(label: "theme-disk", themesDirectory: blocker.appendingPathComponent("Themes")) + let token = try Self.makeChangeToken() + + let acknowledged = await harness.coordinator.applyPullResult( + PullResult(changedRecords: [try Self.themeRecord(try Self.theme("user.synced"))], deletedRecordIDs: [], newToken: token) + ) + + #expect(!acknowledged) + #expect(harness.metadata.loadToken() == nil) + } + + // MARK: - Deletion + + @Test("A theme deleted on another Mac leaves this one, with no tombstone sent back") + func pulledDeletionRemovesTheTheme() async throws { + let harness = try Harness(label: "theme-deletion") + let night = try Self.theme("user.night") + try harness.writeOnDisk(night) + harness.engine.select(lightThemeId: BuiltInThemes.defaultLightId, darkThemeId: night.id) + #expect(harness.engine.pair.dark == night) + + let acknowledged = await harness.coordinator.applyPullResult(Self.pull([], deleting: [Self.deletion(ofTheme: night.id)])) + + #expect(acknowledged) + #expect(harness.catalog.theme(id: night.id) == nil) + #expect(!harness.fileExists(night.id)) + #expect(harness.tracker.tombstonedIds(for: .settings).isEmpty) + try await waitUntil { harness.engine.pair.dark == BuiltInThemes.dark } + } + + @Test("A theme this Mac moved to the Trash stays gone when an edit of it arrives") + func localTombstoneBeatsARemoteEdit() async throws { + let harness = try Harness(label: "theme-tombstone") + let night = try Self.theme("user.night") + try harness.writeOnDisk(night) + try harness.catalog.trash(id: night.id) + #expect(harness.tracker.tombstonedIds(for: .settings) == [ThemeCatalog.syncCategory(for: night.id)]) + + let edited = try Self.theme("user.night", background: "#336699") + let acknowledged = await harness.coordinator.applyPullResult(Self.pull([try Self.themeRecord(edited)])) + + #expect(acknowledged) + #expect(harness.catalog.theme(id: night.id) == nil) + #expect(!harness.fileExists(night.id)) + } + + // MARK: - Push + + @Test("A theme's outgoing record is its canonical file, and a trashed one is pushed as a deletion") + func pushCarriesThemesAndTheirDeletions() async throws { + let harness = try Harness(label: "theme-push") + let night = try Self.theme("user.night") + try harness.catalog.save(night) + let category = ThemeCatalog.syncCategory(for: night.id) + + let canonical = try ThemeFileFormat.encode(night) + #expect(harness.coordinator.settingsData(for: category) == canonical) + #expect(await harness.coordinator.runSyncCycle() == nil) + let pushed = await harness.transport.pushedRecords + #expect(pushed.contains { SyncRecordMapper.settingsCategory(from: $0) == category }) + #expect(harness.tracker.dirtyRecords(for: .settings).isEmpty) + + try harness.catalog.trash(id: night.id) + #expect(await harness.coordinator.runSyncCycle() == nil) + let deletions = await harness.transport.pushedDeletions + #expect(deletions.contains(Self.deletion(ofTheme: night.id))) + #expect(harness.tracker.tombstonedIds(for: .settings).isEmpty) + } + + @Test("Turning sync on marks every theme on this Mac") + func enablingSyncMarksThemes() throws { + let harness = try Harness(label: "theme-enable") + try harness.writeOnDisk(try Self.theme("user.night")) + try harness.writeOnDisk(try Self.theme("user.day")) + #expect(harness.tracker.dirtyRecords(for: .settings).isEmpty) + harness.setSync(true) + + harness.coordinator.enableSync() + + let dirty = harness.tracker.dirtyRecords(for: .settings) + #expect(dirty.isSuperset(of: [ThemeCatalog.syncCategory(for: "user.night"), ThemeCatalog.syncCategory(for: "user.day")])) + } + + // MARK: - Late upgraders + + @Test("A build that reads themes, or a newer theme format, fetches the zone once") + func themeContentKindGrowsTheReadableSet() throws { + let suite = try EphemeralDefaults() + let metadata = SyncMetadataStorage(userDefaults: suite.defaults) + let types = Set(SyncRecordType.verifiedInProduction.map(\.rawValue)) + _ = metadata.adoptReadableRecordTypes(types) + + #expect(SyncCoordinator.readableContent.contains(SyncCoordinator.themeContentKind)) + #expect(SyncCoordinator.themeContentKind == "AppSettings/theme.v\(ThemeSchema.current)") + #expect(metadata.adoptReadableRecordTypes(SyncCoordinator.readableContent)) + #expect(!metadata.adoptReadableRecordTypes(SyncCoordinator.readableContent)) + #expect(metadata.adoptReadableRecordTypes(types.union(["AppSettings/theme.v\(ThemeSchema.current + 1)"]))) + } + + @Test("The first launch of a theme-syncing build clears the change token, and the next one keeps it") + func firstThemeBuildClearsTheToken() async throws { + let harness = try Harness(label: "theme-token") + #expect(harness.environment.connections.saveConnections([DatabaseConnection(name: "Local")])) + _ = harness.metadata.adoptReadableRecordTypes(Set(SyncRecordType.verifiedInProduction.map(\.rawValue))) + harness.metadata.saveToken(try Self.makeChangeToken()) + harness.setSync(true) + + harness.coordinator.start() + #expect(harness.metadata.loadToken() == nil) + try await waitUntil { await harness.transport.pullCount == 1 && harness.coordinator.syncStatus != .syncing } + + harness.metadata.saveToken(try Self.makeChangeToken()) + let columnLayouts = harness.environment.columnLayouts + let relaunched = SyncCoordinator( + services: harness.coordinator.services, + recordCache: harness.environment.recordCache, + transport: ScriptedSyncTransport(zoneID: SyncTestEnvironment.zoneID), + makeNetworkMonitor: { nil }, + columnLayouts: columnLayouts, + themeCatalog: harness.catalog, + licenseGate: { nil } + ) + relaunched.start() + #expect(harness.metadata.loadToken() != nil) + } + + @Test("After the first full pull, only the themes the server lacks are uploaded, and only once") + func seedMarksOnlyWhatTheServerLacks() async throws { + let held = try Self.theme("user.held") + let heldRecord = try Self.themeRecord(held) + let harness = try Harness(label: "theme-seed") { _ in + PullResult(changedRecords: [heldRecord], deletedRecordIDs: [], newToken: nil) + } + #expect(harness.environment.connections.saveConnections([DatabaseConnection(name: "Local")])) + try harness.writeOnDisk(held) + try harness.writeOnDisk(try Self.theme("user.local")) + let localCategory = ThemeCatalog.syncCategory(for: "user.local") + #expect(harness.tracker.dirtyRecords(for: .settings).isEmpty) + harness.setSync(true) + + harness.coordinator.start() + #expect(harness.metadata.themeSeedState == .pending) + try await waitUntil { harness.metadata.themeSeedState == .done && harness.coordinator.syncStatus != .syncing } + #expect(harness.tracker.dirtyRecords(for: .settings) == [localCategory]) + + await harness.coordinator.syncNow() + + let pushed = await harness.transport.pushedRecords + #expect(pushed.contains { SyncRecordMapper.settingsCategory(from: $0) == localCategory }) + #expect(!pushed.contains { SyncRecordMapper.settingsCategory(from: $0) == ThemeCatalog.syncCategory(for: held.id) }) + #expect(harness.tracker.dirtyRecords(for: .settings).isEmpty) + } + + @Test("The first full pull keeps a never-synced local theme that differs from the record, and the seed uploads it") + func firstFullPullKeepsADifferingLocalTheme() async throws { + let remote = try Self.theme("user.x", background: "#336699") + let remoteRecord = try Self.themeRecord(remote) + let harness = try Harness(label: "theme-seed-conflict") { _ in + PullResult(changedRecords: [remoteRecord], deletedRecordIDs: [], newToken: nil) + } + #expect(harness.environment.connections.saveConnections([DatabaseConnection(name: "Local")])) + let local = try Self.theme("user.x", background: "#224466") + try harness.writeOnDisk(local) + harness.setSync(true) + + harness.coordinator.start() + try await waitUntil { harness.metadata.themeSeedState == .done && harness.coordinator.syncStatus != .syncing } + + #expect(harness.catalog.storedTheme(id: remote.id) == remote) + let kept = try #require(harness.catalog.themes.first { !$0.isBuiltIn && $0.id != remote.id }) + #expect(kept.overrides == local.overrides) + #expect(harness.tracker.dirtyRecords(for: .settings) == [ThemeCatalog.syncCategory(for: kept.id)]) + } + + @Test("Once the seed ran, a pulled edit replaces the local file and keeps no copy") + func seededPullReplacesTheLocalTheme() async throws { + let harness = try Harness(label: "theme-seeded-edit") + try harness.writeOnDisk(try Self.theme("user.x", background: "#224466")) + harness.metadata.themeSeedState = .done + let remote = try Self.theme("user.x", background: "#336699") + + let acknowledged = await harness.coordinator.applyPullResult(Self.pull([try Self.themeRecord(remote)])) + + #expect(acknowledged) + #expect(harness.catalog.themes.filter { !$0.isBuiltIn } == [remote]) + } + + // MARK: - Appearance mode + + @Test("An appearance record from an older Mac changes the theme ids and leaves this Mac's mode alone") + func olderPeerPayloadKeepsTheLocalMode() async throws { + let harness = try Harness(label: "theme-mode-in") + #expect(harness.settings.appearanceMode == .auto) + let legacy = Self.settingsRecord( + AppSettingsCategory.appearance, + json: #"{"appearanceMode":"dark","preferredLightThemeId":"user.day","preferredDarkThemeId":"user.night"}"# + ) + + let acknowledged = await harness.coordinator.applyPullResult(Self.pull([legacy])) + + #expect(acknowledged) + #expect(harness.settings.appearance == AppearanceSettings(preferredLightThemeId: "user.day", preferredDarkThemeId: "user.night")) + #expect(harness.settings.appearanceMode == .auto) + #expect(harness.storage.loadAppearanceMode() == .auto) + #expect(harness.engine.mode == .auto) + } + + @Test("The outgoing appearance record carries the theme ids and no mode") + func outgoingPayloadCarriesNoMode() throws { + let harness = try Harness(label: "theme-mode-out") + harness.storage.saveAppearance(AppearanceSettings(preferredLightThemeId: "user.day", preferredDarkThemeId: "user.night")) + harness.storage.saveAppearanceMode(.dark) + + let data = try #require(harness.coordinator.settingsData(for: AppSettingsCategory.appearance)) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(Set(object.keys) == ["preferredLightThemeId", "preferredDarkThemeId"]) + #expect(object["preferredDarkThemeId"] as? String == "user.night") + } + + // MARK: - Fixtures + + private static func makeChangeToken() throws -> CKServerChangeToken { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + archiver.setClassName("CKServerChangeToken", for: ThemeSyncChangeTokenArchive.self) + archiver.encode(ThemeSyncChangeTokenArchive(), forKey: NSKeyedArchiveRootObjectKey) + archiver.finishEncoding() + let token = try NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: archiver.encodedData) + return try #require(token) + } +} + +@objc(SyncCoordinatorThemeTestsChangeTokenArchive) +private final class ThemeSyncChangeTokenArchive: NSObject, NSSecureCoding { + static var supportsSecureCoding: Bool { true } + + override init() { + super.init() + } + + required init?(coder: NSCoder) { + super.init() + } + + func encode(with coder: NSCoder) {} +} + +@MainActor +private extension AppServices { + func replacing(appSettings: AppSettingsManager, storage: AppSettingsStorage, themeEngine: ThemeEngine) -> AppServices { + AppServices( + appEvents: appEvents, + appSettings: appSettings, + appSettingsStorage: storage, + connectionStorage: connectionStorage, + databaseManager: databaseManager, + pluginManager: pluginManager, + schemaService: schemaService, + schemaRefreshService: schemaRefreshService, + schemaProviderRegistry: schemaProviderRegistry, + catalogChangeService: catalogChangeService, + sqlFavoriteManager: sqlFavoriteManager, + favoriteTablesStorage: favoriteTablesStorage, + favoriteDatabasesStorage: favoriteDatabasesStorage, + tableFolderStorage: tableFolderStorage, + aiChatStorage: aiChatStorage, + aiKeyStorage: aiKeyStorage, + aiAccessApprovals: aiAccessApprovals, + groupStorage: groupStorage, + tagStorage: tagStorage, + sshProfileStorage: sshProfileStorage, + credentialProfileStorage: credentialProfileStorage, + licenseManager: licenseManager, + syncMetadataStorage: syncMetadataStorage, + favoritesExpansionState: favoritesExpansionState, + linkedFolderWatcher: linkedFolderWatcher, + queryHistoryManager: queryHistoryManager, + dateFormattingService: dateFormattingService, + copilotService: copilotService, + mcpServerManager: mcpServerManager, + syncTracker: syncTracker, + themeEngine: themeEngine, + welcomeRouter: welcomeRouter + ) + } +} diff --git a/TableProTests/Helpers/ThemeTestFixture.swift b/TableProTests/Helpers/ThemeTestFixture.swift index b4dfea8012..fb94d1a9a5 100644 --- a/TableProTests/Helpers/ThemeTestFixture.swift +++ b/TableProTests/Helpers/ThemeTestFixture.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing /// Synchronous and main-actor, so nothing else on the main actor runs between an adopt and its restore. @@ -64,11 +65,8 @@ internal extension ThemeTestFixture { 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) } diff --git a/TableProTests/Models/Settings/LegacyThemeFontsTests.swift b/TableProTests/Models/Settings/LegacyThemeFontsTests.swift index f15612b21d..3008f362ed 100644 --- a/TableProTests/Models/Settings/LegacyThemeFontsTests.swift +++ b/TableProTests/Models/Settings/LegacyThemeFontsTests.swift @@ -35,9 +35,7 @@ struct LegacyThemeFontsTests { return url } - private func appearance(_ mode: AppAppearanceMode) -> AppearanceSettings { - AppearanceSettings(appearanceMode: mode, preferredLightThemeId: "user.light", preferredDarkThemeId: "user.dark") - } + private let slots = AppearanceSettings(preferredLightThemeId: "user.light", preferredDarkThemeId: "user.dark") @Test("Dark mode carries the fonts of the theme in the dark slot") func darkModeReadsTheDarkSlot() throws { @@ -45,7 +43,7 @@ struct LegacyThemeFontsTests { try writeTheme(id: "user.light", editorSize: 12, in: directory) try writeTheme(id: "user.dark", editorSize: 16, in: directory) - let carried = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: false, themesDirectory: directory) + let carried = LegacyThemeFonts.read(appearance: slots, mode: .dark, systemIsDark: false, themesDirectory: directory) #expect(carried.editorFontSize == 16) } @@ -55,8 +53,8 @@ struct LegacyThemeFontsTests { try writeTheme(id: "user.light", editorSize: 12, in: directory) try writeTheme(id: "user.dark", editorSize: 16, in: directory) - let night = LegacyThemeFonts.read(appearance: appearance(.auto), systemIsDark: true, themesDirectory: directory) - let day = LegacyThemeFonts.read(appearance: appearance(.auto), systemIsDark: false, themesDirectory: directory) + let night = LegacyThemeFonts.read(appearance: slots, mode: .auto, systemIsDark: true, themesDirectory: directory) + let day = LegacyThemeFonts.read(appearance: slots, mode: .auto, systemIsDark: false, themesDirectory: directory) #expect(night.editorFontSize == 16) #expect(day.editorFontSize == 12) } @@ -67,7 +65,7 @@ struct LegacyThemeFontsTests { try writeTheme(id: "user.light", editorSize: 15, in: directory) try writeTheme(id: "user.dark", in: directory) - let carried = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: true, themesDirectory: directory) + let carried = LegacyThemeFonts.read(appearance: slots, mode: .dark, systemIsDark: true, themesDirectory: directory) #expect(carried.editorFontSize == 15) } @@ -78,11 +76,11 @@ struct LegacyThemeFontsTests { try writeTheme(id: "user.dark", editorSize: 24, dataGridSize: 9, in: directory) try writeTheme(id: "user.light", editorSize: 9, dataGridSize: 24, in: directory) - let dark = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: true, themesDirectory: directory) + let dark = LegacyThemeFonts.read(appearance: slots, mode: .dark, systemIsDark: true, themesDirectory: directory) #expect(dark.editorFontSize == 18) #expect(dark.dataGridFontSize == 10) - let light = LegacyThemeFonts.read(appearance: appearance(.light), systemIsDark: false, themesDirectory: directory) + let light = LegacyThemeFonts.read(appearance: slots, mode: .light, systemIsDark: false, themesDirectory: directory) #expect(light.editorFontSize == 11) #expect(light.dataGridFontSize == 18) } @@ -92,7 +90,7 @@ struct LegacyThemeFontsTests { let directory = try makeDirectory() try writeTheme(id: "user.dark", editorSize: 17, editorFamily: "Menlo", fileName: "My Theme.json", in: directory) - let carried = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: true, themesDirectory: directory) + let carried = LegacyThemeFonts.read(appearance: slots, mode: .dark, systemIsDark: true, themesDirectory: directory) #expect(carried.editorFontSize == 17) #expect(carried.editorFontFamily == "Menlo") } @@ -100,7 +98,7 @@ struct LegacyThemeFontsTests { @Test("No old theme files leaves the default fonts") func nothingToCarry() throws { let directory = try makeDirectory() - let carried = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: true, themesDirectory: directory) + let carried = LegacyThemeFonts.read(appearance: slots, mode: .dark, systemIsDark: true, themesDirectory: directory) #expect(carried == .default) } @@ -113,7 +111,8 @@ struct LegacyThemeFontsTests { let suite = try EphemeralDefaults() let defaults = suite.defaults let storage = AppSettingsStorage(userDefaults: defaults) - storage.saveAppearance(appearance(.dark)) + storage.saveAppearance(slots) + storage.saveAppearanceMode(.dark) let first = storage.loadTypography(systemIsDark: true, legacyThemesDirectory: directory) #expect(first.editorFontSize == 16) @@ -124,4 +123,19 @@ struct LegacyThemeFontsTests { let second = storage.loadTypography(systemIsDark: true, legacyThemesDirectory: directory) #expect(second.editorFontSize == 16) } + + @Test("An upgrade reads the mode out of the old appearance blob to pick the slot the fonts came from") + func upgradeUsesTheModeFromTheOldBlob() throws { + let directory = try makeDirectory() + try writeTheme(id: "user.light", editorSize: 12, in: directory) + try writeTheme(id: "user.dark", editorSize: 16, in: directory) + + let suite = try EphemeralDefaults() + let blob = #"{"appearanceMode":"dark","preferredLightThemeId":"user.light","preferredDarkThemeId":"user.dark"}"# + suite.defaults.set(Data(blob.utf8), forKey: "com.TablePro.settings.appearance") + let storage = AppSettingsStorage(userDefaults: suite.defaults) + + let carried = storage.loadTypography(systemIsDark: false, legacyThemesDirectory: directory) + #expect(carried.editorFontSize == 16) + } } diff --git a/TableProTests/Models/UI/JSONTreeParserTests.swift b/TableProTests/Models/UI/JSONTreeParserTests.swift index 70ca41b05b..b3bd062dd5 100644 --- a/TableProTests/Models/UI/JSONTreeParserTests.swift +++ b/TableProTests/Models/UI/JSONTreeParserTests.swift @@ -5,6 +5,7 @@ import AppKit import Foundation +import TableProTheme import Testing @testable import TablePro @@ -322,15 +323,15 @@ struct JSONTreeParserTests { ]) func tonesUseTheDefaultSyntaxColors(appearance: NSAppearance.Name) throws { try ThemeTestFixture.adopting([:], appearance: .light) { - let theme = appearance == .aqua ? BuiltInThemes.light : BuiltInThemes.dark + let syntax = DefaultSyntaxPalette.colors(for: appearance == .aqua ? .light : .dark, highContrast: false) @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(.key) == syntax[.syntaxString]?.hex) + #expect(drawn(.string) == syntax[.syntaxString]?.hex) + #expect(drawn(.number) == syntax[.syntaxNumber]?.hex) + #expect(drawn(.literal) == syntax[.syntaxNull]?.hex) + #expect(drawn(.serialized) == syntax[.syntaxType]?.hex) + #expect(drawn(.reference) == syntax[.syntaxComment]?.hex) #expect(drawn(.container) == ThemeTestFixture.hex(.textColor, in: appearance)) } } diff --git a/TableProTests/Services/ContentWindowBackdropTests.swift b/TableProTests/Services/ContentWindowBackdropTests.swift index eaf93ab695..047ad4b37b 100644 --- a/TableProTests/Services/ContentWindowBackdropTests.swift +++ b/TableProTests/Services/ContentWindowBackdropTests.swift @@ -6,6 +6,7 @@ import AppKit import Foundation @testable import TablePro +import TableProTheme import Testing /// The real controllers, built offscreen: never ordered front and never closed. diff --git a/TableProTests/Services/ContentWindowTitlebarTests.swift b/TableProTests/Services/ContentWindowTitlebarTests.swift new file mode 100644 index 0000000000..cb199f1998 --- /dev/null +++ b/TableProTests/Services/ContentWindowTitlebarTests.swift @@ -0,0 +1,140 @@ +// +// ContentWindowTitlebarTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +/// The four content windows as the app builds them. Never ordered front and never closed: `close()` +/// on a test window over-releases it. +@MainActor +struct ContentWindowTitlebarTests { + private struct Built { + let name: String + let window: NSWindow + let separator: NSTitlebarSeparatorStyle + let owner: AnyObject? + } + + private var transparentRelease: Bool { + if #available(macOS 26.0, *) { return true } + return false + } + + private func builtWindows() throws -> [Built] { + let dataFile = DataFileWindowController(document: DataFileDocument()) + let dataFileWindow = try #require(dataFile.window) + return [ + Built(name: "main", window: TabWindowController.makeEditorWindow(), separator: .none, owner: nil), + Built(name: "data file", window: dataFileWindow, separator: .automatic, owner: dataFile), + Built(name: "compare", window: CompareSyncWindowController.makeWindow(), separator: .automatic, owner: nil), + Built( + name: "value viewer", + window: ValueViewerWindowController.makeWindow(identifier: "titlebar-test", title: "Value") { _ in + Color.clear + }, + separator: .automatic, + owner: nil + ), + ] + } + + private func tearDown(_ built: [Built]) { + for entry in built { + entry.window.delegate = nil + entry.window.contentViewController = nil + entry.window.isReleasedWhenClosed = false + } + } + + @Test("Every content window shows its backdrop through the titlebar on macOS 26 and later only") + func transparentFromTheBackdropRelease() throws { + let built = try builtWindows() + defer { tearDown(built) } + for entry in built { + #expect(entry.window is ContentWindow, "\(entry.name) is not a content window") + #expect( + entry.window.titlebarAppearsTransparent == transparentRelease, + "\(entry.name) titlebar transparent: \(entry.window.titlebarAppearsTransparent)" + ) + } + } + + @Test("Each content window keeps the toolbar separator it had") + func separatorIsUnchanged() throws { + let built = try builtWindows() + defer { tearDown(built) } + for entry in built { + #expect(entry.window.titlebarSeparatorStyle == entry.separator, "\(entry.name) separator changed") + } + } + + @Test( + "The titlebar stays opaque only in full screen with the toolbar hidden", + arguments: [ + (isFullScreen: false, toolbarVisible: false, transparent: true), + (isFullScreen: false, toolbarVisible: true, transparent: true), + (isFullScreen: true, toolbarVisible: true, transparent: true), + (isFullScreen: true, toolbarVisible: false, transparent: false), + ] + ) + func transparencyFollowsTheSafeArea(state: (isFullScreen: Bool, toolbarVisible: Bool, transparent: Bool)) { + #expect( + ContentWindow.titlebarIsTransparent( + isFullScreen: state.isFullScreen, + toolbarVisible: state.toolbarVisible + ) == state.transparent + ) + } + + @Test( + "Entering full screen without a toolbar restores the titlebar background, and leaving clears it", + .enabled( + if: ProcessInfo.processInfo.isOperatingSystemAtLeast( + OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0) + ), + "the titlebar turns transparent from macOS 26" + ) + ) + func fullScreenWithoutAToolbar() throws { + let built = try builtWindows() + defer { tearDown(built) } + let viewer = try #require(built.first { $0.name == "value viewer" }?.window) + #expect(viewer.toolbar == nil) + + NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: viewer) + #expect(!viewer.titlebarAppearsTransparent) + + NotificationCenter.default.post(name: NSWindow.didExitFullScreenNotification, object: viewer) + #expect(viewer.titlebarAppearsTransparent) + } + + @Test( + "A full-screen window keeps a transparent titlebar while its toolbar is showing", + .enabled( + if: ProcessInfo.processInfo.isOperatingSystemAtLeast( + OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0) + ), + "the titlebar turns transparent from macOS 26" + ) + ) + func fullScreenWithAToolbar() throws { + let window = CompareSyncWindowController.makeWindow() + defer { window.toolbar = nil } + let toolbar = NSToolbar(identifier: "com.TablePro.tests.titlebar.\(UUID().uuidString)") + toolbar.autosavesConfiguration = false + window.toolbar = toolbar + toolbar.isVisible = true + try #require(toolbar.isVisible) + + NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: window) + #expect(window.titlebarAppearsTransparent) + + toolbar.isVisible = false + NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: window) + #expect(!window.titlebarAppearsTransparent) + } +} diff --git a/TableProTests/Services/EditorWindowChromeTests.swift b/TableProTests/Services/EditorWindowChromeTests.swift index 2a5385ce2d..04a62e1480 100644 --- a/TableProTests/Services/EditorWindowChromeTests.swift +++ b/TableProTests/Services/EditorWindowChromeTests.swift @@ -49,39 +49,10 @@ struct EditorWindowChromeTests { } } - /// A full-screen window with no toolbar has a top safe area of zero, so its content reaches the - /// screen edge and the titlebar that slides down over it has to bring its own background. - @Test( - "The titlebar stays opaque only in full screen with the toolbar hidden", - arguments: [ - (isFullScreen: false, toolbarVisible: false, transparent: true), - (isFullScreen: false, toolbarVisible: true, transparent: true), - (isFullScreen: true, toolbarVisible: true, transparent: true), - (isFullScreen: true, toolbarVisible: false, transparent: false), - ] - ) - func transparencyFollowsTheSafeArea(state: (isFullScreen: Bool, toolbarVisible: Bool, transparent: Bool)) { - #expect( - TabWindowController.titlebarIsTransparent( - isFullScreen: state.isFullScreen, - toolbarVisible: state.toolbarVisible - ) == state.transparent - ) - } - - @Test("Entering full screen with the toolbar hidden restores the titlebar background") - func enteringFullScreenWithoutAToolbarRestoresTheBackground() { + @Test("The editor window is a content window, so it follows the shared titlebar policy") + func editorWindowIsAContentWindow() { withEditorWindow { window in - TabWindowController.applyTitlebarChrome(to: window, isFullScreen: true) - - if #available(macOS 26.0, *) { - #expect(!window.titlebarAppearsTransparent) - } - - TabWindowController.applyTitlebarChrome(to: window, isFullScreen: false) - if #available(macOS 26.0, *) { - #expect(window.titlebarAppearsTransparent) - } + #expect(window is ContentWindow) } } } diff --git a/TableProTests/Theme/AppAppearanceModeThemeTests.swift b/TableProTests/Theme/AppAppearanceModeThemeTests.swift new file mode 100644 index 0000000000..9ca615f0ef --- /dev/null +++ b/TableProTests/Theme/AppAppearanceModeThemeTests.swift @@ -0,0 +1,18 @@ +// +// AppAppearanceModeThemeTests.swift +// TableProTests +// + +@testable import TablePro +import TableProTheme +import Testing + +struct AppAppearanceModeThemeTests { + @Test("Auto follows the system appearance, forced modes ignore it") + func autoFollowsSystem() { + #expect(AppAppearanceMode.auto.effectiveAppearance(systemIsDark: true) == .dark) + #expect(AppAppearanceMode.auto.effectiveAppearance(systemIsDark: false) == .light) + #expect(AppAppearanceMode.light.effectiveAppearance(systemIsDark: true) == .light) + #expect(AppAppearanceMode.dark.effectiveAppearance(systemIsDark: false) == .dark) + } +} diff --git a/TableProTests/Theme/DerivedSlotDrawingTests.swift b/TableProTests/Theme/DerivedSlotDrawingTests.swift new file mode 100644 index 0000000000..12ea1fd2e0 --- /dev/null +++ b/TableProTests/Theme/DerivedSlotDrawingTests.swift @@ -0,0 +1,112 @@ +// +// DerivedSlotDrawingTests.swift +// TableProTests +// +// A theme that sets only the grid background used to stripe its odd rows in the system's opaque +// light stripe and keep a white header over its own color. The stripe and header now come from the +// theme's background, and the selection text from the theme's own selection and text. +// + +import AppKit +@testable import TablePro +import TableProTheme +import Testing + +@MainActor +struct DerivedSlotDrawingTests { + private func stripedTable() -> NSTableView { + let tableView = NSTableView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + tableView.usesAlternatingRowBackgroundColors = true + return tableView + } + + // MARK: - Grid stripes and header + + @Test("A background-only light theme stripes and heads the grid from its own background") + func backgroundOnlyLightGrid() throws { + try ThemeTestFixture.adopting([.gridBackground: "#FDF6E3"], appearance: .light) { + let tableView = stripedTable() + + #expect(ThemeTestFixture.hex(DataGridBodyChrome.stripeColor(forRow: 0, of: tableView, scope: .live)) == "#FDF6E3") + #expect(ThemeTestFixture.hex(DataGridBodyChrome.stripeColor(forRow: 1, of: tableView, scope: .live)) == "#F3ECDA") + #expect(ThemeTestFixture.hex(SortableHeaderChrome.backgroundColor(scope: .live)) == "#FDF6E3") + } + } + + @Test("A background-only dark theme stripes toward white") + func backgroundOnlyDarkGrid() throws { + try ThemeTestFixture.adopting([.gridBackground: "#282A36"], appearance: .dark) { + let tableView = stripedTable() + + #expect(ThemeTestFixture.hex(DataGridBodyChrome.stripeColor(forRow: 1, of: tableView, scope: .live), in: .darkAqua) == "#31333E") + #expect(ThemeTestFixture.hex(SortableHeaderChrome.backgroundColor(scope: .live), in: .darkAqua) == "#282A36") + } + } + + @Test("Under the default pair the stripe and the header stay the system's own objects") + func defaultStripeAndHeaderAreNative() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let tableView = stripedTable() + + #expect(DataGridBodyChrome.stripeColor(forRow: 1, of: tableView, scope: .live) === NativeThemeColors.alternateRow) + #expect(SortableHeaderChrome.backgroundColor(scope: .live) === NSColor.controlBackgroundColor) + } + } + + @Test("A records table stripes a background-only theme from its own background too") + func recordsTableStripes() throws { + try ThemeTestFixture.adopting([.gridBackground: "#FDF6E3"], appearance: .light) { + let tableView = stripedTable() + let scrollView = NSScrollView(frame: tableView.frame) + scrollView.documentView = tableView + let chrome = ThemeTableChrome(tableView: tableView, family: .records) + + #expect(ThemeTestFixture.hex(chrome.rowBackgroundColor(forRow: 1)) == "#F3ECDA") + #expect(ThemeTestFixture.hex(chrome.rowBackgroundColor(forRow: 2)) == "#FDF6E3") + } + } + + // MARK: - Cell palette + + private func cellPalette() throws -> DataGridCellPalette { + let aqua = try #require(NSAppearance(named: .aqua)) + return ThemeScope.live.dataGridCellPalette(in: aqua) + } + + @Test("Over a themed selection the cell text takes the theme's grid text when it has no selection text") + func selectionTextFollowsThemedText() throws { + try ThemeTestFixture.adopting([.gridSelection: "#44475A", .gridText: "#F8F8F2"], appearance: .light) { + let palette = try cellPalette() + #expect(ThemeTestFixture.hex(palette.selectionText) == "#F8F8F2") + } + } + + @Test("Over a themed selection with native text the cell text is the native label color") + func selectionTextFollowsNativeText() throws { + try ThemeTestFixture.adopting([.gridSelection: "#44475A"], appearance: .light) { + let palette = try cellPalette() + #expect(palette.selectionText === NSColor.labelColor) + } + } + + @Test("A selection text with no themed selection leaves AppKit's white selection text") + func selectionTextWithoutASelectionIsNative() throws { + try ThemeTestFixture.adopting([.gridSelectionText: "#00FF00", .gridText: "#112233"], appearance: .light) { + let palette = try cellPalette() + #expect(palette.selectionText === NSColor.alternateSelectedControlTextColor) + } + } + + @Test("Bools follow the theme's grid text") + func boolsFollowThemedText() throws { + try ThemeTestFixture.adopting([.gridText: "#112233"], appearance: .light) { + let palette = try cellPalette() + #expect(ThemeTestFixture.hex(palette.booleanTrueText) == "#112233") + #expect(ThemeTestFixture.hex(palette.booleanFalseText) == "#112233") + } + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let palette = try cellPalette() + #expect(palette.booleanTrueText === NSColor.labelColor) + } + } +} diff --git a/TableProTests/Theme/NativeDefaultsTests.swift b/TableProTests/Theme/NativeDefaultsTests.swift index 40b43836fc..0b7e578f8c 100644 --- a/TableProTests/Theme/NativeDefaultsTests.swift +++ b/TableProTests/Theme/NativeDefaultsTests.swift @@ -12,6 +12,7 @@ import AppKit import Foundation import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor @@ -35,7 +36,7 @@ struct NativeDefaultsTests { private static func bundledTheme(_ id: String) throws -> ThemeDefinition { let url = try #require(Bundle.main.url(forResource: id, withExtension: "json")) - return try ThemeDocument(data: try Data(contentsOf: url)).definition() + return try ThemeFileFormat.decode(try Data(contentsOf: url)) } // MARK: - Definitions diff --git a/TableProTests/Theme/ThemeCatalogSyncTests.swift b/TableProTests/Theme/ThemeCatalogSyncTests.swift new file mode 100644 index 0000000000..21bf318034 --- /dev/null +++ b/TableProTests/Theme/ThemeCatalogSyncTests.swift @@ -0,0 +1,615 @@ +// +// ThemeCatalogSyncTests.swift +// TableProTests +// +// Each user theme syncs as the settings record `theme.`. The catalog marks it on every edit, +// tombstones it after a move to the Trash, and writes what another Mac sent without marking +// anything, after treating it as untrusted input. +// + +import AppKit +import Foundation +import os +@testable import TablePro +import TableProSyncTransport +import TableProTheme +import Testing + +@MainActor +struct ThemeCatalogSyncTests { + @MainActor + private final class Harness { + let root: URL + let center = NotificationCenter() + let suite: EphemeralDefaults + let tracker: SyncChangeTracker + let fileTrash: ThemeFileTrash + private(set) var catalog: ThemeCatalog + + var themes: URL { root.appendingPathComponent("Themes", isDirectory: true) } + var trash: URL { root.appendingPathComponent("Trash", isDirectory: true) } + var dirty: Set { tracker.dirtyRecords(for: .settings) } + var tombstoned: Set { tracker.tombstonedIds(for: .settings) } + + init(userDirectory: URL? = nil, fileTrash: ThemeFileTrash? = nil) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("ThemeCatalogSyncTests-\(UUID().uuidString)", isDirectory: true) + suite = try EphemeralDefaults() + tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: suite.defaults)) + let folders = [ + root.appendingPathComponent("Themes", isDirectory: true), + root.appendingPathComponent("Trash", isDirectory: true), + ] + for folder in folders { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + self.fileTrash = fileTrash ?? .folder(folders[1]) + catalog = ThemeCatalog( + userDirectory: userDirectory ?? folders[0], + bundleDirectory: nil, + notificationCenter: center, + fileTrash: self.fileTrash, + syncTracker: tracker + ) + } + + @discardableResult + func write(_ theme: ThemeDefinition, as fileName: String? = nil) throws -> URL { + let url = themes.appendingPathComponent(fileName ?? "\(theme.id).json") + try ThemeFileFormat.encode(theme).write(to: url) + return url + } + + func reopen() { + catalog = ThemeCatalog( + userDirectory: themes, + bundleDirectory: nil, + notificationCenter: center, + fileTrash: fileTrash, + syncTracker: tracker + ) + } + + func fileNames() throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: themes.path).sorted() + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + } + + nonisolated private static func theme( + _ id: String, + name: String? = nil, + appearance: ThemeAppearance = .dark, + background: String = "#101010" + ) throws -> ThemeDefinition { + ThemeDefinition( + id: id, + name: name ?? id, + author: "", + appearance: appearance, + overrides: [.editorBackground: try ThemeColor(validating: background)] + ) + } + + nonisolated private static func category(_ id: String) -> String { + ThemeCatalog.syncCategory(for: id) + } + + // MARK: - Local changes + + @Test("Save, rename, duplicate and import each mark the theme's record dirty") + func editsMarkDirty() throws { + let harness = try Harness() + defer { harness.remove() } + + let saved = try Self.theme("user.saved") + try harness.catalog.save(saved) + #expect(harness.dirty == [Self.category(saved.id)]) + + let renamedTarget = try Self.theme("user.renamed") + try harness.write(renamedTarget) + harness.reopen() + try harness.catalog.rename(id: renamedTarget.id, to: "Night Shift") + #expect(harness.dirty.contains(Self.category(renamedTarget.id))) + + let copy = try harness.catalog.duplicate(saved) + #expect(harness.dirty.contains(Self.category(copy.id))) + + let incoming = harness.root.appendingPathComponent("incoming.json") + try ThemeFileFormat.encode(try Self.theme("user.incoming")).write(to: incoming) + let imported = try harness.catalog.importTheme(from: incoming) + #expect(harness.dirty.contains(Self.category(imported.id))) + #expect(harness.tombstoned.isEmpty) + } + + @Test("Loading the folder and reloading on activation mark nothing") + func loadsMarkNothing() throws { + let harness = try Harness() + defer { harness.remove() } + try harness.write(try Self.theme("user.existing")) + + harness.reopen() + harness.center.post(name: NSApplication.didBecomeActiveNotification, object: nil) + harness.catalog.reloadSynchronously() + + #expect(harness.catalog.theme(id: "user.existing") != nil) + #expect(harness.dirty.isEmpty) + #expect(harness.tombstoned.isEmpty) + } + + @Test("Moving a theme to the Trash tombstones its record only once the file is gone") + func trashTombstonesAfterTheMove() throws { + let tombstonedDuringMove = OSAllocatedUnfairLock(initialState: nil) + let probe = TrackerProbe() + let trashFolder = FileManager.default.temporaryDirectory + .appendingPathComponent("ThemeCatalogSyncTests-trash-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: trashFolder) } + let folder = ThemeFileTrash.folder(trashFolder) + let harness = try Harness(fileTrash: ThemeFileTrash { url in + tombstonedDuringMove.withLock { $0 = probe.isTombstoned() } + return try folder.moveToTrash(url) + }) + defer { harness.remove() } + let night = try Self.theme("user.night") + try harness.write(night) + harness.reopen() + probe.watch(harness.tracker, category: Self.category(night.id)) + + try harness.catalog.trash(id: night.id) + + #expect(tombstonedDuringMove.withLock { $0 } == false) + #expect(harness.tombstoned == [Self.category(night.id)]) + #expect(!harness.dirty.contains(Self.category(night.id))) + #expect(try harness.fileNames().isEmpty) + } + + @Test("Putting a trashed theme back marks its record dirty again") + func restoreMarksDirty() throws { + let harness = try Harness() + defer { harness.remove() } + let night = try Self.theme("user.night") + try harness.write(night) + harness.reopen() + + let receipt = try harness.catalog.trash(id: night.id) + #expect(receipt.themeId == night.id) + try harness.catalog.restore(receipt) + + #expect(harness.catalog.theme(id: night.id) != nil) + #expect(harness.dirty == [Self.category(night.id)]) + } + + @Test("A file that failed to load moves to the Trash and back with no sync mark") + func rejectedFileTrashAndRestore() throws { + let harness = try Harness() + defer { harness.remove() } + let broken = harness.themes.appendingPathComponent("broken.json") + try Data("{ not json".utf8).write(to: broken) + harness.reopen() + let path = try #require(harness.catalog.rejected.first?.path) + + let receipt = try harness.catalog.trashRejected(path: path) + #expect(receipt.themeId == nil) + #expect(receipt.originalURL.lastPathComponent == broken.lastPathComponent) + #expect(!FileManager.default.fileExists(atPath: broken.path)) + #expect(harness.catalog.rejected.isEmpty) + #expect(try harness.fileNames().isEmpty) + + try harness.catalog.restore(receipt) + #expect(harness.catalog.rejected.map(\.fileName) == ["broken.json"]) + #expect(harness.dirty.isEmpty) + #expect(harness.tombstoned.isEmpty) + } + + @Test("Trashing a path that is not a failed file is refused") + func trashRejectedRefusesUnknownPath() throws { + let harness = try Harness() + defer { harness.remove() } + let loaded = try harness.write(try Self.theme("user.loaded")) + harness.reopen() + + #expect(throws: CocoaError.self) { try harness.catalog.trashRejected(path: loaded.path) } + #expect(try harness.fileNames() == ["user.loaded.json"]) + } + + // MARK: - Remote apply + + @Test("A synced theme is written as its canonical file, joins the list and marks nothing") + func remoteThemeIsWrittenUnmarked() throws { + let harness = try Harness() + defer { harness.remove() } + let synced = try Self.theme("user.synced", name: "From the other Mac") + let payload = try ThemeFileFormat.encode(synced) + + let outcome = harness.catalog.applyRemote(category: Self.category(synced.id), data: payload) + + #expect(outcome == .applied) + #expect(harness.catalog.theme(id: synced.id) == synced) + #expect(try Data(contentsOf: harness.themes.appendingPathComponent("user.synced.json")) == payload) + #expect(harness.dirty.isEmpty) + #expect(harness.catalog.syncPayload(forCategory: Self.category(synced.id)) == payload) + } + + @Test("A synced copy of the file already on disk writes nothing") + func identicalRemoteThemeIsSkipped() throws { + let harness = try Harness() + defer { harness.remove() } + let night = try Self.theme("user.night") + let url = try harness.write(night) + harness.reopen() + let before = try FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date + + let outcome = harness.catalog.applyRemote(category: Self.category(night.id), data: try ThemeFileFormat.encode(night)) + + #expect(outcome == .skipped) + #expect(try FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date == before) + } + + @Test("A synced edit replaces the theme in the file it was loaded from") + func remoteEditUsesTheExistingFile() throws { + let harness = try Harness() + defer { harness.remove() } + let night = try Self.theme("user.night") + try harness.write(night, as: "My Night.json") + harness.reopen() + + let edited = try Self.theme("user.night", name: "Edited elsewhere", background: "#202020") + let outcome = harness.catalog.applyRemote(category: Self.category(night.id), data: try ThemeFileFormat.encode(edited)) + + #expect(outcome == .applied) + #expect(try harness.fileNames() == ["My Night.json"]) + #expect(harness.catalog.theme(id: night.id) == edited) + } + + @Test("While the local file may be unsynced, a differing record keeps the local version as its own theme") + func keepBothSavesTheLocalVersionAsANewTheme() throws { + let harness = try Harness() + defer { harness.remove() } + let local = try Self.theme("user.x", name: "Ocean", background: "#101010") + try harness.write(local, as: "Ocean.json") + harness.reopen() + + let incoming = try Self.theme("user.x", name: "Ocean", background: "#202020") + let payload = try ThemeFileFormat.encode(incoming) + let outcome = harness.catalog.applyRemote(category: Self.category(incoming.id), data: payload, merge: .keepBoth) + + #expect(outcome == .applied) + #expect(harness.catalog.storedTheme(id: incoming.id) == incoming) + #expect(try Data(contentsOf: harness.themes.appendingPathComponent("Ocean.json")) == payload) + let kept = try #require(harness.catalog.themes.first { !$0.isBuiltIn && $0.id != incoming.id }) + #expect(kept.id.hasPrefix(ThemeDefinition.userPrefix)) + #expect(kept.name == "Ocean 2") + #expect(kept.overrides == local.overrides) + #expect(harness.catalog.storedTheme(id: kept.id) == kept) + #expect(try harness.fileNames().count == 2) + #expect(harness.dirty == [Self.category(kept.id)]) + + #expect(harness.catalog.applyRemote(category: Self.category(incoming.id), data: payload, merge: .keepBoth) == .skipped) + #expect(try harness.fileNames().count == 2) + } + + @Test("A kept copy keeps its name when the record arrives under another name") + func keptCopyKeepsAFreeName() throws { + let harness = try Harness() + defer { harness.remove() } + try harness.write(try Self.theme("user.x", name: "Ocean", background: "#101010")) + harness.reopen() + + let incoming = try Self.theme("user.x", name: "Sea", background: "#202020") + let payload = try ThemeFileFormat.encode(incoming) + #expect(harness.catalog.applyRemote(category: Self.category(incoming.id), data: payload, merge: .keepBoth) == .applied) + + let names = Set(harness.catalog.themes.filter { !$0.isBuiltIn }.map(\.name)) + #expect(names == ["Ocean", "Sea"]) + } + + @Test("Keep both makes no copy of a local file that holds the same theme in other bytes") + func keepBothSkipsAnEqualTheme() throws { + let harness = try Harness() + defer { harness.remove() } + let night = try Self.theme("user.night") + let reformatted = try JSONSerialization.data( + withJSONObject: JSONSerialization.jsonObject(with: try ThemeFileFormat.encode(night)) + ) + try reformatted.write(to: harness.themes.appendingPathComponent("user.night.json")) + harness.reopen() + + let payload = try ThemeFileFormat.encode(night) + #expect(payload != reformatted) + #expect(harness.catalog.applyRemote(category: Self.category(night.id), data: payload, merge: .keepBoth) == .applied) + + #expect(try harness.fileNames() == ["user.night.json"]) + #expect(harness.dirty.isEmpty) + } + + @Test("A record that cannot land takes its kept copy with it, so a replay makes no second copy") + func keepBothRollsBackOnAFailedWrite() throws { + let harness = try Harness() + defer { harness.remove() } + let local = try Self.theme("user.x", background: "#101010") + let url = try harness.write(local) + harness.reopen() + try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: url.path) + defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: url.path) } + + let incoming = try Self.theme("user.x", background: "#202020") + let payload = try ThemeFileFormat.encode(incoming) + #expect(harness.catalog.applyRemote(category: Self.category(incoming.id), data: payload, merge: .keepBoth) == .failed) + + #expect(try harness.fileNames() == ["user.x.json"]) + #expect(harness.catalog.themes.filter { !$0.isBuiltIn } == [local]) + #expect(harness.dirty.isEmpty) + } + + @Test("A synced theme never lands on a file that failed to load") + func remoteThemeSparesARejectedFile() throws { + let harness = try Harness() + defer { harness.remove() } + let blocking = harness.themes.appendingPathComponent("user.synced.json") + let brokenBytes = Data("{ \"schema\": 3, broken".utf8) + try brokenBytes.write(to: blocking) + harness.reopen() + + let synced = try Self.theme("user.synced") + let outcome = harness.catalog.applyRemote(category: Self.category(synced.id), data: try ThemeFileFormat.encode(synced)) + + #expect(outcome == .applied) + #expect(try Data(contentsOf: blocking) == brokenBytes) + #expect(try harness.fileNames() == ["user.synced-2.json", "user.synced.json"]) + #expect(harness.catalog.theme(id: synced.id) == synced) + } + + @Test("A disk failure is reported as failed, so the pull is not acknowledged") + func diskFailureIsFailed() throws { + let blocker = FileManager.default.temporaryDirectory + .appendingPathComponent("ThemeCatalogSyncTests-blocker-\(UUID().uuidString)") + #expect(FileManager.default.createFile(atPath: blocker.path, contents: Data())) + defer { try? FileManager.default.removeItem(at: blocker) } + let harness = try Harness(userDirectory: blocker.appendingPathComponent("Themes", isDirectory: true)) + defer { harness.remove() } + let synced = try Self.theme("user.synced") + + let outcome = harness.catalog.applyRemote(category: Self.category(synced.id), data: try ThemeFileFormat.encode(synced)) + + #expect(outcome == .failed) + #expect(harness.catalog.theme(id: synced.id) == nil) + } + + struct Rejection: CustomTestStringConvertible, Sendable { + let label: String + let category: String + let payload: @Sendable () throws -> Data + var testDescription: String { label } + } + + nonisolated static let rejections: [Rejection] = [ + Rejection(label: "not a theme category", category: "columnLayout.user.a") { + try ThemeFileFormat.encode(try theme("user.a")) + }, + Rejection(label: "id with a path separator", category: "theme.user/../a") { + Data(#"{"schema":3,"id":"user/../a","name":"a","appearance":"dark"}"#.utf8) + }, + Rejection(label: "built-in id", category: "theme.tablepro.dracula") { + Data(#"{"schema":3,"id":"tablepro.dracula","name":"Fake","appearance":"dark"}"#.utf8) + }, + Rejection(label: "id past the length cap", category: "theme.user." + String(repeating: "a", count: 124)) { + let id = "user." + String(repeating: "a", count: 124) + return Data(#"{"schema":3,"id":"\#(id)","name":"Long","appearance":"dark"}"#.utf8) + }, + Rejection(label: "document for another id", category: "theme.user.a") { + try ThemeFileFormat.encode(try theme("user.b")) + }, + Rejection(label: "payload past the size cap", category: "theme.user.a") { + let body = #"{"schema":3,"id":"user.a","name":"a","appearance":"dark"}"# + return Data((body + String(repeating: " ", count: ThemeCatalog.maximumSyncPayloadSize)).utf8) + }, + Rejection(label: "payload nested past the depth cap", category: "theme.user.a") { + Data(##"{"schema":3,"id":"user.a","name":"a","appearance":"dark","editor":{"a":{"b":{"c":{"d":"#000000"}}}}}"##.utf8) + }, + Rejection(label: "newer format", category: "theme.user.a") { + Data(#"{"schema":\#(ThemeSchema.current + 1),"id":"user.a","name":"a","appearance":"dark"}"#.utf8) + }, + Rejection(label: "unknown key", category: "theme.user.a") { + Data(#"{"schema":3,"id":"user.a","name":"a","appearance":"dark","script":"run"}"#.utf8) + }, + Rejection(label: "name with a newline", category: "theme.user.a") { + Data(#"{"schema":3,"id":"user.a","name":"line\nbreak","appearance":"dark"}"#.utf8) + }, + Rejection(label: "name past the length cap", category: "theme.user.a") { + let name = String(repeating: "n", count: ThemeFileFormat.maximumTextLength + 1) + return Data(#"{"schema":3,"id":"user.a","name":"\#(name)","appearance":"dark"}"#.utf8) + }, + Rejection(label: "not JSON", category: "theme.user.a") { + Data("not json".utf8) + }, + ] + + @Test("A synced record that fails validation is skipped and leaves the disk alone", arguments: rejections) + func invalidRemoteThemeIsSkipped(_ rejection: Rejection) throws { + let harness = try Harness() + defer { harness.remove() } + + let outcome = harness.catalog.applyRemote(category: rejection.category, data: try rejection.payload()) + + #expect(outcome == .skipped) + #expect(try harness.fileNames().isEmpty) + #expect(!harness.catalog.themes.contains { !$0.isBuiltIn }) + #expect(harness.dirty.isEmpty) + } + + @Test("A synced edit of a theme removed in Finder brings it back, since only a deletion record removes it") + func remoteEditAfterAFinderDelete() throws { + let harness = try Harness() + defer { harness.remove() } + let night = try Self.theme("user.night") + let url = try harness.write(night) + harness.reopen() + try FileManager.default.removeItem(at: url) + harness.catalog.reloadSynchronously() + + let edited = try Self.theme("user.night", background: "#303030") + let payload = try ThemeFileFormat.encode(edited) + #expect(harness.catalog.applyRemote(category: Self.category(night.id), data: payload) == .applied) + + #expect(harness.catalog.theme(id: night.id) == edited) + var local = edited + local.name = "Edited here" + try harness.catalog.save(local) + } + + // MARK: - Remote deletion + + @Test("A theme deleted on another Mac goes to this Mac's Trash with its pending mark and no tombstone") + func remoteDeletionTrashesWithoutSync() throws { + let harness = try Harness() + defer { harness.remove() } + let night = try Self.theme("user.night") + try harness.catalog.save(night) + #expect(harness.dirty == [Self.category(night.id)]) + + #expect(harness.catalog.removeWithoutSync(ids: [night.id, "user.unknown"])) + + #expect(harness.catalog.theme(id: night.id) == nil) + #expect(try harness.fileNames().isEmpty) + #expect(try FileManager.default.contentsOfDirectory(atPath: harness.trash.path).count == 1) + #expect(harness.dirty.isEmpty) + #expect(harness.tombstoned.isEmpty) + } + + @Test("A remote deletion leaves alone a file that was replaced by another theme since it loaded") + func remoteDeletionSparesAReplacedFile() throws { + let harness = try Harness() + defer { harness.remove() } + let night = try Self.theme("user.night") + try harness.catalog.save(night) + let replacement = try Self.theme("user.dawn", name: "Dawn", background: "#202020") + try harness.write(replacement, as: "user.night.json") + + #expect(harness.catalog.removeWithoutSync(ids: [night.id])) + + #expect(try harness.fileNames() == ["user.night.json"]) + #expect(try FileManager.default.contentsOfDirectory(atPath: harness.trash.path).isEmpty) + #expect(harness.catalog.theme(id: replacement.id) == replacement) + } + + @Test("An editor saving a theme another Mac deleted does not bring it back") + func saveAfterRemoteDeletionIsRefused() throws { + let harness = try Harness() + defer { harness.remove() } + let night = try Self.theme("user.night") + try harness.catalog.save(night) + harness.catalog.removeWithoutSync(ids: [night.id]) + + #expect(throws: ThemeLoadError.self) { try harness.catalog.save(night) } + #expect(try harness.fileNames().isEmpty) + } + + // MARK: - Payload + + @Test("Only a user theme on disk has a sync payload") + func payloadOnlyForUserThemes() throws { + let harness = try Harness() + defer { harness.remove() } + let night = try Self.theme("user.night") + try harness.catalog.save(night) + + let canonical = try ThemeFileFormat.encode(night) + #expect(harness.catalog.syncPayload(forCategory: Self.category(night.id)) == canonical) + #expect(harness.catalog.syncPayload(forCategory: Self.category(BuiltInThemes.defaultDarkId)) == nil) + #expect(harness.catalog.syncPayload(forCategory: Self.category("user.missing")) == nil) + #expect(harness.catalog.syncPayload(forCategory: "appearance") == nil) + #expect(harness.catalog.syncCategories == [Self.category(night.id)]) + } + + // MARK: - Names + + @Test("Two themes with one name show apart in the list, while their files and records keep the name") + func collidingNamesAreToldApart() throws { + let harness = try Harness() + defer { harness.remove() } + let first = try Self.theme("user.a", name: "Ocean") + let second = try Self.theme("user.b", name: "Ocean") + let builtInName = try Self.theme("user.c", name: BuiltInThemes.dark.name.uppercased()) + try harness.write(first) + try harness.write(second) + try harness.write(builtInName) + harness.reopen() + + #expect(harness.catalog.theme(id: first.id)?.name == "Ocean") + #expect(harness.catalog.theme(id: second.id)?.name == "Ocean 2") + #expect(harness.catalog.theme(id: builtInName.id)?.name == "\(BuiltInThemes.dark.name.uppercased()) 2") + #expect(harness.catalog.theme(id: BuiltInThemes.defaultDarkId)?.name == BuiltInThemes.dark.name) + #expect(harness.catalog.storedTheme(id: second.id) == second) + + let payload = try #require(harness.catalog.syncPayload(forCategory: Self.category(second.id))) + #expect(try ThemeFileFormat.decode(payload).name == "Ocean") + let onDisk = try Data(contentsOf: harness.themes.appendingPathComponent("user.b.json")) + #expect(try ThemeFileFormat.decode(onDisk).name == "Ocean") + #expect(harness.dirty.isEmpty) + } + + @Test("A synced theme that takes a local theme's name shows apart without a write") + func syncedNameCollision() throws { + let harness = try Harness() + defer { harness.remove() } + try harness.write(try Self.theme("user.local", name: "Ocean")) + harness.reopen() + + let synced = try Self.theme("user.synced", name: "Ocean") + let payload = try ThemeFileFormat.encode(synced) + #expect(harness.catalog.applyRemote(category: Self.category(synced.id), data: payload) == .applied) + + let names = harness.catalog.themes.filter { !$0.isBuiltIn }.map(\.name) + #expect(names == ["Ocean", "Ocean 2"]) + #expect(harness.dirty.isEmpty) + } + + // MARK: - Trash location + + @Test("A sandboxed run trashes themes inside its sandbox, never into the user's Trash") + func defaultTrashStaysInTheSandbox() throws { + let environment = AppStorageEnvironment.shared + let sandboxTrash = try #require(environment.trashDirectory) + #expect(sandboxTrash.path.hasPrefix(environment.supportDirectory.path)) + + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ThemeCatalogSyncTests-default-\(UUID().uuidString)", isDirectory: true) + let folder = root.appendingPathComponent("Themes", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let suite = try EphemeralDefaults() + let night = try Self.theme("user.night") + try ThemeFileFormat.encode(night).write(to: folder.appendingPathComponent("user.night.json")) + let catalog = ThemeCatalog( + userDirectory: folder, + bundleDirectory: nil, + notificationCenter: NotificationCenter(), + syncTracker: SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: suite.defaults)) + ) + + let receipt = try catalog.trash(id: night.id) + defer { try? FileManager.default.removeItem(at: receipt.trashedURL.deletingLastPathComponent()) } + + #expect(receipt.trashedURL.standardizedFileURL.path.hasPrefix(sandboxTrash.standardizedFileURL.path)) + #expect(FileManager.default.fileExists(atPath: receipt.trashedURL.path)) + try catalog.restore(receipt) + #expect(catalog.theme(id: night.id) == night) + } +} + +// Reads the tracker from inside the trash closure, which runs off the main actor's bookkeeping. +private final class TrackerProbe: @unchecked Sendable { + private let state = OSAllocatedUnfairLock<(SyncChangeTracker, String)?>(initialState: nil) + + func watch(_ tracker: SyncChangeTracker, category: String) { + state.withLock { $0 = (tracker, category) } + } + + func isTombstoned() -> Bool? { + guard let (tracker, category) = state.withLock({ $0 }) else { return nil } + return tracker.tombstonedIds(for: .settings).contains(category) + } +} diff --git a/TableProTests/Theme/ThemeCatalogTests.swift b/TableProTests/Theme/ThemeCatalogTests.swift index 5ea0681c0f..7ee89d9389 100644 --- a/TableProTests/Theme/ThemeCatalogTests.swift +++ b/TableProTests/Theme/ThemeCatalogTests.swift @@ -5,35 +5,51 @@ // A user theme is identified by its JSON id but lives in whatever file it came from. When the // catalog wrote and deleted `.json` instead, deleting a theme a user had named themselves // removed nothing, and editing it wrote a second file that could win on the next launch. +// A file removed outside TablePro is a conflict too: a save must never bring it back. // +import AppKit import Foundation @testable import TablePro +import TableProTheme import Testing @MainActor struct ThemeCatalogTests { private struct Sandbox { let root: URL + let center = NotificationCenter() var themes: URL { root.appendingPathComponent("Themes", isDirectory: true) } var incoming: URL { root.appendingPathComponent("Incoming", isDirectory: true) } + var trash: URL { root.appendingPathComponent("Trash", isDirectory: true) } init() throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("ThemeCatalogTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: themes, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: incoming, withIntermediateDirectories: true) + for folder in [themes, incoming, trash] { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + } + + /// A trash inside the sandbox, so a test never puts a file in the user's own Trash. + var fileTrash: ThemeFileTrash { + let trash = trash + return ThemeFileTrash { url in + let destination = trash.appendingPathComponent("\(UUID().uuidString)-\(url.lastPathComponent)") + try FileManager.default.moveItem(at: url, to: destination) + return destination + } } @MainActor func catalog() -> ThemeCatalog { - ThemeCatalog(userDirectory: themes, bundleDirectory: nil) + ThemeCatalog(userDirectory: themes, bundleDirectory: nil, notificationCenter: center, fileTrash: fileTrash) } @discardableResult func write(_ theme: ThemeDefinition, as fileName: String, in directory: URL? = nil) throws -> URL { let url = (directory ?? themes).appendingPathComponent(fileName) - try ThemeEncoder.data(for: theme).write(to: url) + try ThemeFileFormat.encode(theme).write(to: url) return url } @@ -41,8 +57,16 @@ struct ThemeCatalogTests { try FileManager.default.contentsOfDirectory(atPath: themes.path).sorted() } + func trashedNames() throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: trash.path).sorted() + } + func read(_ fileName: String) throws -> ThemeDefinition { - try ThemeDocument(data: try Data(contentsOf: themes.appendingPathComponent(fileName))).definition() + try ThemeFileFormat.decode(try Data(contentsOf: themes.appendingPathComponent(fileName))) + } + + func delete(_ fileName: String) throws { + try FileManager.default.removeItem(at: themes.appendingPathComponent(fileName)) } func remove() { @@ -92,8 +116,8 @@ struct ThemeCatalogTests { #expect(try sandbox.read("mine.json").overrides[.editorText]?.hex == "#FFFFFF") } - @Test("A file replaced outside TablePro is not deleted") - func deleteKeepsAFileChangedOnDisk() throws { + @Test("A file replaced outside TablePro is not moved to the Trash") + func trashKeepsAFileChangedOnDisk() throws { let sandbox = try Sandbox() defer { sandbox.remove() } try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "mine.json") @@ -101,11 +125,144 @@ struct ThemeCatalogTests { try sandbox.write(try Self.userTheme(id: "user.mine", name: "Replaced"), as: "mine.json") - #expect(throws: ThemeLoadError.changedOnDisk("mine.json")) { try catalog.delete(id: "user.mine") } + #expect(throws: ThemeLoadError.changedOnDisk("mine.json")) { try catalog.trash(id: "user.mine") } #expect(try sandbox.fileNames() == ["mine.json"]) + #expect(try sandbox.trashedNames().isEmpty) #expect(catalog.theme(id: "user.mine")?.name == "Replaced") } + // MARK: - Deleted on disk + + /// The file is gone, so writing it again would bring back a theme deleted in Finder or on + /// another Mac. + @Test("A save of a theme whose file was deleted outside TablePro is refused and recreates nothing") + func saveRefusesAFileDeletedOnDisk() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "Mine.json") + let catalog = sandbox.catalog() + var stale = try #require(catalog.theme(id: "user.mine")) + + try sandbox.delete("Mine.json") + stale.overrides[.editorText] = try ThemeColor(validating: "#FFFFFF") + + #expect(throws: ThemeLoadError.deletedOnDisk("Mine.json")) { try catalog.save(stale) } + #expect(try sandbox.fileNames().isEmpty) + #expect(catalog.theme(id: "user.mine") == nil) + + #expect(throws: ThemeLoadError.deletedOnDisk("Mine.json")) { try catalog.save(stale) } + #expect(try sandbox.fileNames().isEmpty) + } + + @Test("After an activation reload drops a deleted theme, a stale save is still refused") + func saveAfterReloadRefusesADeletedTheme() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "Mine.json") + let catalog = sandbox.catalog() + let stale = try #require(catalog.theme(id: "user.mine")) + + try sandbox.delete("Mine.json") + sandbox.center.post(name: NSApplication.didBecomeActiveNotification, object: nil) + #expect(catalog.theme(id: "user.mine") == nil) + + #expect(throws: ThemeLoadError.deletedOnDisk("Mine.json")) { try catalog.save(stale) } + #expect(try sandbox.fileNames().isEmpty) + } + + /// A file edited into one that no longer loads keeps its name, so a stale save would otherwise + /// write `-2.json` and leave two files with one id once the first is fixed. + @Test("A save of a theme whose file no longer loads is refused, and writes no second file") + func saveRefusesAFileThatNoLongerLoads() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "user.mine.json") + let catalog = sandbox.catalog() + let stale = try #require(catalog.theme(id: "user.mine")) + + try Data("{ broken".utf8).write(to: sandbox.themes.appendingPathComponent("user.mine.json")) + #expect(throws: ThemeLoadError.changedOnDisk("user.mine.json")) { try catalog.save(stale) } + #expect(catalog.rejected.map(\.fileName) == ["user.mine.json"]) + + #expect(throws: ThemeLoadError.changedOnDisk("user.mine.json")) { try catalog.save(stale) } + #expect(try sandbox.fileNames() == ["user.mine.json"]) + } + + @Test("Moving a theme deleted outside TablePro to the Trash reports it and drops it") + func trashOfADeletedFile() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "Mine.json") + let catalog = sandbox.catalog() + + try sandbox.delete("Mine.json") + + #expect(throws: ThemeLoadError.deletedOnDisk("Mine.json")) { try catalog.trash(id: "user.mine") } + #expect(catalog.theme(id: "user.mine") == nil) + } + + // MARK: - Activation + + @Test("The catalog reloads when TablePro becomes active") + func reloadsOnActivation() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let catalog = sandbox.catalog() + #expect(catalog.theme(id: "user.dropped") == nil) + + try sandbox.write(try Self.userTheme(id: "user.dropped", name: "Dropped"), as: "Dropped.json") + #expect(catalog.theme(id: "user.dropped") == nil) + + sandbox.center.post(name: NSApplication.didBecomeActiveNotification, object: nil) + #expect(catalog.theme(id: "user.dropped")?.name == "Dropped") + } + + // MARK: - Trash + + @Test("Move to Trash moves the file it was loaded from, and restore brings it back") + func trashAndRestore() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let original = try Self.userTheme(id: "user.mine", name: "Mine", overrides: [.gridBackground: "#123456"]) + try sandbox.write(original, as: "My Theme.json") + let catalog = sandbox.catalog() + + let receipt = try catalog.trash(id: "user.mine") + + #expect(receipt.themeId == "user.mine") + #expect(receipt.originalURL.lastPathComponent == "My Theme.json") + #expect(try sandbox.fileNames().isEmpty) + #expect(try sandbox.trashedNames().count == 1) + #expect(catalog.theme(id: "user.mine") == nil) + #expect(catalog.fileURL(for: "user.mine") == nil) + + try catalog.restore(receipt) + + #expect(try sandbox.fileNames() == ["My Theme.json"]) + #expect(try sandbox.trashedNames().isEmpty) + #expect(catalog.theme(id: "user.mine") == original) + #expect(catalog.fileURL(for: "user.mine")?.lastPathComponent == "My Theme.json") + + var edited = try #require(catalog.theme(id: "user.mine")) + edited.name = "Edited" + try catalog.save(edited) + #expect(try sandbox.read("My Theme.json").name == "Edited") + } + + @Test("A theme moved to the Trash is not recreated by a stale save") + func trashedThemeIsNotResurrected() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "Mine.json") + let catalog = sandbox.catalog() + let stale = try #require(catalog.theme(id: "user.mine")) + + try catalog.trash(id: "user.mine") + + #expect(throws: ThemeLoadError.deletedOnDisk("Mine.json")) { try catalog.save(stale) } + #expect(try sandbox.fileNames().isEmpty) + } + // MARK: - File name differs from id @Test("A theme is saved back to the file it was loaded from") @@ -126,18 +283,17 @@ struct ThemeCatalogTests { #expect(sandbox.catalog().theme(id: "user.mine")?.overrides[.gridBackground]?.hex == "#123456") } - @Test("A theme is deleted from the file it was loaded from") - func deleteRemovesTheLoadedFile() throws { + @Test("A theme moved to the Trash leaves no file behind for the next launch") + func trashRemovesTheLoadedFile() throws { let sandbox = try Sandbox() defer { sandbox.remove() } try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "My Theme.json") let catalog = sandbox.catalog() - try catalog.delete(id: "user.mine") + try catalog.trash(id: "user.mine") #expect(try sandbox.fileNames().isEmpty) #expect(catalog.theme(id: "user.mine") == nil) - #expect(catalog.fileURL(for: "user.mine") == nil) #expect(sandbox.catalog().theme(id: "user.mine") == nil) } @@ -155,6 +311,57 @@ struct ThemeCatalogTests { #expect(catalog.theme(id: "user.renamed")?.name == "After") } + @Test("Rename to another theme's name is refused, whatever the case or surrounding space") + func renameRefusesATakenName() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.ocean", name: "Ocean"), as: "ocean.json") + try sandbox.write(try Self.userTheme(id: "user.other", name: "Other"), as: "other.json") + let catalog = sandbox.catalog() + + #expect(throws: ThemeLoadError.nameTaken("ocean")) { try catalog.rename(id: "user.other", to: " ocean ") } + #expect(throws: ThemeLoadError.nameTaken(BuiltInThemes.dark.name)) { + try catalog.rename(id: "user.other", to: BuiltInThemes.dark.name) + } + #expect(catalog.theme(id: "user.other")?.name == "Other") + #expect(try sandbox.read("other.json").name == "Other") + + try catalog.rename(id: "user.ocean", to: "OCEAN") + #expect(catalog.theme(id: "user.ocean")?.name == "OCEAN") + } + + @Test("Rename compares with the name the file holds, not the one shown to tell two themes apart") + func renameUsesTheStoredName() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.a", name: "Ocean"), as: "a.json") + let second = try sandbox.write(try Self.userTheme(id: "user.b", name: "Ocean"), as: "b.json") + let catalog = sandbox.catalog() + let before = try Data(contentsOf: second) + #expect(catalog.theme(id: "user.b")?.name == "Ocean 2") + + try catalog.rename(id: "user.b", to: "Ocean") + #expect(try Data(contentsOf: second) == before) + + try catalog.rename(id: "user.b", to: "Ocean 2") + #expect(try sandbox.read("b.json").name == "Ocean 2") + #expect(catalog.storedTheme(id: "user.b")?.name == "Ocean 2") + #expect(catalog.theme(id: "user.b")?.name == "Ocean 2") + #expect(try sandbox.read("a.json").name == "Ocean") + } + + @Test("Rename to a name the format refuses writes nothing") + func renameRefusesAnInvalidName() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "mine.json") + let catalog = sandbox.catalog() + + #expect(throws: ThemeLoadError.invalidName) { try catalog.rename(id: "user.mine", to: " ") } + #expect(throws: ThemeLoadError.invalidName) { try catalog.rename(id: "user.mine", to: "Two\nLines") } + #expect(try sandbox.read("mine.json").name == "Mine") + } + // MARK: - Loading @Test("Two files with one id: the first by file name loads, the second is listed as not loaded") @@ -191,6 +398,44 @@ struct ThemeCatalogTests { }) } + @Test("A theme 0.81 saved with a long or multi-line name or author loads, and its next save writes the repaired text") + func legacyLongNameLoadsAndIsRepairedOnSave() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let cap = ThemeFileFormat.maximumTextLength + let legacy: [String: Any] = [ + "schema": 3, + "id": "user.legacy", + "name": String(repeating: "N", count: 150), + "author": "Someone\non two lines", + "appearance": "dark", + "editor": ["background": "#102030"], + ] + let bytes = try JSONSerialization.data(withJSONObject: legacy) + let url = sandbox.themes.appendingPathComponent("legacy.json") + try bytes.write(to: url) + + let catalog = sandbox.catalog() + var theme = try #require(catalog.storedTheme(id: "user.legacy")) + + #expect(catalog.rejected.isEmpty) + #expect(theme.name == String(repeating: "N", count: cap)) + #expect(theme.author == "Someone on two lines") + #expect(theme.overrides[.editorBackground]?.hex == "#102030") + #expect(try Data(contentsOf: url) == bytes) + + theme.overrides[.editorText] = try ThemeColor(validating: "#FFFFFF") + try catalog.save(theme) + + let saved = try sandbox.read("legacy.json") + #expect(saved.name == String(repeating: "N", count: cap)) + #expect(saved.author == "Someone on two lines") + + let picked = sandbox.incoming.appendingPathComponent("legacy.json") + try bytes.write(to: picked) + #expect(throws: ThemeLoadError.invalidName) { try catalog.importTheme(from: picked) } + } + @Test("A file in an older format is listed with its reason") func olderFormatIsListedWithItsReason() throws { let sandbox = try Sandbox() @@ -204,17 +449,20 @@ struct ThemeCatalogTests { #expect(catalog.rejected.first?.reason == ThemeLoadError.schemaTooOld(found: 2, supported: 3).localizedDescription) } - @Test("The bundled themes load after the two defaults") + @Test("The bundled themes load after the two defaults, in the shared order") func bundledThemesLoad() throws { let sandbox = try Sandbox() defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.aardvark", name: "Aardvark"), as: "aardvark.json") let bundle = try #require(Bundle.main.resourceURL) - let catalog = ThemeCatalog(userDirectory: sandbox.themes, bundleDirectory: bundle) + let catalog = ThemeCatalog(userDirectory: sandbox.themes, bundleDirectory: bundle, notificationCenter: sandbox.center) let ids = catalog.themes.map(\.id) #expect(Array(ids.prefix(2)) == [BuiltInThemes.defaultLightId, BuiltInThemes.defaultDarkId]) #expect(Set(ids).isSuperset(of: ["tablepro.dracula", "tablepro.nord"])) + #expect(ids.last == "user.aardvark") + #expect(catalog.themes == ThemeOrdering.sorted(catalog.themes)) #expect(catalog.rejected.isEmpty) } @@ -243,6 +491,30 @@ struct ThemeCatalogTests { #expect(catalog.fileURL(for: first.id)?.deletingLastPathComponent().lastPathComponent == "Themes") } + @Test("A copied or numbered name is shortened to stay within the format's cap, so its save succeeds") + func longNamesStayWithinTheCap() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let cap = ThemeFileFormat.maximumTextLength + let longName = String(repeating: "N", count: cap) + try sandbox.write(try Self.userTheme(id: "user.long", name: longName), as: "long.json") + let incoming = try sandbox.write(try Self.userTheme(id: "user.long", name: longName), as: "long.json", in: sandbox.incoming) + let catalog = sandbox.catalog() + let source = try #require(catalog.theme(id: "user.long")) + + let copy = try catalog.duplicate(source) + let secondCopy = try catalog.duplicate(source) + let imported = try catalog.importTheme(from: incoming) + + let overhead = Self.copyName(of: "").count + #expect(copy.name == Self.copyName(of: String(repeating: "N", count: cap - overhead))) + #expect(secondCopy.name == Self.copyName(of: String(repeating: "N", count: cap - overhead - 2)) + " 2") + #expect(imported.name == String(repeating: "N", count: cap - 2) + " 2") + #expect([copy, secondCopy, imported].allSatisfy { $0.name.count <= cap }) + #expect(try sandbox.fileNames().count == 4) + #expect(Set(catalog.themes.map(\.name)).count == catalog.themes.count) + } + @Test("Duplicating a built-in theme gives an editable user theme") func duplicateOfBuiltInIsEditable() throws { let sandbox = try Sandbox() @@ -253,7 +525,7 @@ struct ThemeCatalogTests { #expect(copy.isEditable) #expect(copy.name == Self.copyName(of: BuiltInThemes.dark.name)) - #expect(copy.overrides == BuiltInThemes.dark.overrides) + #expect(copy.overrides.isEmpty) #expect(catalog.theme(id: BuiltInThemes.defaultDarkId) == BuiltInThemes.dark) } @@ -296,16 +568,38 @@ struct ThemeCatalogTests { #expect(catalog.theme(id: BuiltInThemes.defaultDarkId) == BuiltInThemes.dark) } - @Test("Import keeps an id and a name nothing else uses") - func importKeepsAFreshIdentity() throws { + /// The id in a file says nothing about which theme on this Mac it is: the same file imported + /// twice, or on two Macs, would otherwise collide in sync. + @Test("Import mints a new id even when the file's id is free, and keeps a free name") + func importMintsAFreshId() throws { let sandbox = try Sandbox() defer { sandbox.remove() } let url = try sandbox.write(try Self.userTheme(id: "user.fresh", name: "Fresh"), as: "fresh.json", in: sandbox.incoming) + let catalog = sandbox.catalog() - let imported = try sandbox.catalog().importTheme(from: url) + let first = try catalog.importTheme(from: url) + let second = try catalog.importTheme(from: url) - #expect(imported.id == "user.fresh") - #expect(imported.name == "Fresh") + #expect(first.id != "user.fresh") + #expect(first.id.hasPrefix(ThemeDefinition.userPrefix)) + #expect(second.id != first.id) + #expect(first.name == "Fresh") + #expect(second.name == "Fresh 2") + #expect(catalog.theme(id: first.id) == first) + #expect(try sandbox.fileNames() == ["\(first.id).json", "\(second.id).json"].sorted()) + } + + @Test("Import refuses a file over the size cap and writes nothing") + func importRefusesAnOversizedFile() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let url = sandbox.incoming.appendingPathComponent("huge.json") + try Data(count: ThemeFileImporter.maximumFileSize + 1).write(to: url) + + #expect(throws: ThemeLoadError.fileTooLarge(limit: ThemeFileImporter.maximumFileSize)) { + try sandbox.catalog().importTheme(from: url) + } + #expect(try sandbox.fileNames().isEmpty) } // MARK: - Built-ins @@ -320,10 +614,10 @@ struct ThemeCatalogTests { try catalog.save(BuiltInThemes.dark) } #expect(throws: ThemeLoadError.reservedIdentifier(BuiltInThemes.defaultLightId)) { - try catalog.delete(id: BuiltInThemes.defaultLightId) + try catalog.trash(id: BuiltInThemes.defaultLightId) } #expect(throws: ThemeLoadError.reservedIdentifier("tablepro.dracula")) { - try catalog.delete(id: "tablepro.dracula") + try catalog.trash(id: "tablepro.dracula") } #expect(try sandbox.fileNames().isEmpty) diff --git a/TableProTests/Theme/ThemeContrastTests.swift b/TableProTests/Theme/ThemeContrastTests.swift index d075dd98d8..b016c80629 100644 --- a/TableProTests/Theme/ThemeContrastTests.swift +++ b/TableProTests/Theme/ThemeContrastTests.swift @@ -3,13 +3,16 @@ // 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. +// background, so a bundled theme that sets one has to keep that label legible. The system's own +// secondary label sits under 4.5:1 on every light window (3.95:1 on white), so a light theme is held +// to the WCAG floor for secondary text, 3:1. // import AppKit import Foundation import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor @@ -17,9 +20,11 @@ struct ThemeContrastTests { private static func bundledThemes() throws -> [ThemeDefinition] { try (Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []) .filter { $0.lastPathComponent.hasPrefix(ThemeDefinition.builtInPrefix) } - .map { try ThemeDocument(data: try Data(contentsOf: $0)).definition() } + .map { try ThemeFileFormat.decode(try Data(contentsOf: $0)) } } + private static let lightSecondaryContrast: CGFloat = 3 + /// `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 @@ -50,11 +55,12 @@ struct ThemeContrastTests { label = NSColor.secondaryLabelColor.usingColorSpace(.sRGB) background = theme.color(for: .windowBackdrop, in: appearance).usingColorSpace(.sRGB) } + let secondary = try #require(label) let fill = try #require(background) - let text = Self.composite(try #require(label), over: fill) - let ratio = Self.contrast(text, fill) + let ratio = Self.contrast(Self.composite(secondary, over: fill), fill) + let bar = theme.appearance == .dark ? Color.minimumLabelContrast : Self.lightSecondaryContrast - #expect(ratio >= Color.minimumLabelContrast, "\(theme.id): \(ratio)") + #expect(ratio >= bar, "\(theme.id): \(ratio), bar \(bar)") } } } diff --git a/TableProTests/Theme/ThemeDocumentTests.swift b/TableProTests/Theme/ThemeDocumentTests.swift deleted file mode 100644 index 36c2a7638e..0000000000 --- a/TableProTests/Theme/ThemeDocumentTests.swift +++ /dev/null @@ -1,284 +0,0 @@ -// -// ThemeDocumentTests.swift -// TableProTests -// -// Format 3: every color is optional, a slot left out draws native, and the file is refused for -// anything that would otherwise load as something its author did not write. -// - -import AppKit -import Foundation -@testable import TablePro -import Testing - -struct ThemeDocumentTests { - private static func data(_ object: [String: Any]) throws -> Data { - try JSONSerialization.data(withJSONObject: object) - } - - private static func metadata( - id: String = "user.test", - appearance: ThemeAppearance = .dark, - schema: Any = 3 - ) -> [String: Any] { - ["schema": schema, "id": id, "name": "Test", "appearance": appearance.rawValue] - } - - private static func document(_ object: [String: Any]) throws -> ThemeDocument { - try ThemeDocument(data: try data(object)) - } - - private static func withColor(_ value: String, at path: [String]) -> [String: Any] { - var root = metadata() - insert(value, at: path, into: &root) - return root - } - - private static func insert(_ value: Any, at path: [String], into container: inout [String: Any]) { - guard let head = path.first else { return } - guard path.count > 1 else { - container[head] = value - return - } - var child = container[head] as? [String: Any] ?? [:] - insert(value, at: Array(path.dropFirst()), into: &child) - container[head] = child - } - - private static func loads(_ object: [String: Any]) -> Bool { - (try? document(object)) != nil - } - - private static func refusal(_ object: [String: Any]) throws -> ThemeLoadError? { - do { - _ = try document(object) - return nil - } catch let error as ThemeLoadError { - return error - } - } - - // MARK: - What loads - - @Test("The current format is 3") - func currentFormatIsThree() { - #expect(ThemeSchema.current == 3) - } - - @Test("A file with only metadata loads and overrides nothing") - func metadataOnlyLoads() throws { - let document = try Self.document(Self.metadata()) - - #expect(document.id == "user.test") - #expect(document.name == "Test") - #expect(document.author.isEmpty) - #expect(document.appearance == .dark) - #expect(document.colors.isEmpty) - - let definition = document.definition() - for slot in ThemeSlot.allCases { - #expect(!definition.isOverridden(slot), "\(slot.rawValue)") - } - } - - @Test("A file sets only the slots it names") - func slotsAreOptional() throws { - var root = Self.metadata() - Self.insert("#FF0000", at: ["editor", "syntax", "keyword"], into: &root) - Self.insert("#00000080", at: ["dataGrid", "background"], into: &root) - - let colors = try Self.document(root).colors.mapValues { $0.hex } - - #expect(colors == [.syntaxKeyword: "#FF0000", .gridBackground: "#00000080"]) - } - - @Test("Every slot path is accepted") - func everySlotPathLoads() throws { - var root = Self.metadata() - for slot in ThemeSlot.allCases { - Self.insert("#123456", at: slot.rawValue.components(separatedBy: "."), into: &root) - } - - #expect(try Self.document(root).colors.count == ThemeSlot.allCases.count) - } - - // MARK: - What is refused - - @Test("A key TablePro does not use is refused, and the message names it") - func unknownKeyIsRefused() throws { - var root = Self.metadata() - Self.insert("#000000", at: ["editor", "sidebar"], into: &root) - Self.insert("#000000", at: ["panel", "text"], into: &root) - - let error = try Self.refusal(root) - - #expect(error == .unknownKeys(["editor.sidebar", "panel.text"])) - #expect(error?.localizedDescription.contains("editor.sidebar") == true) - } - - /// The pane and status groups of format 2 are gone, so a file that still carries them is - /// refused rather than loaded with those colors dropped. - @Test("Format 2 slot groups are refused") - func formerGroupsAreRefused() throws { - for path in [["panel", "background"], ["status", "warning"], ["content", "editor", "background"]] { - let error = try Self.refusal(Self.withColor("#000000", at: path)) - #expect(error == .unknownKeys([path.joined(separator: ".")]), "\(path)") - } - } - - /// A value that is not a string would otherwise be dropped silently, which loads the slot as - /// native under a key the author did write. - @Test("A key whose value is not a color string is refused") - func nonStringValueIsRefused() { - var unknownNumber = Self.metadata() - unknownNumber["version"] = 1 - #expect(!Self.loads(unknownNumber), "an unknown numeric key loaded") - - var numericColor = Self.metadata() - Self.insert(16_777_215, at: ["editor", "background"], into: &numericColor) - #expect(!Self.loads(numericColor), "a numeric color loaded") - } - - @Test("A slot written twice, nested and dotted, is refused") - func duplicateSlotIsRefused() throws { - var root = Self.withColor("#000000", at: ["editor", "background"]) - root["editor.background"] = "#FFFFFF" - - #expect(try Self.refusal(root) == .duplicateKey("editor.background")) - } - - @Test("A color that is not ASCII #RRGGBB or #RRGGBBAA is refused") - func malformedColorsAreRefused() throws { - let rejected = [ - "#FF79CG", - "FF0000", - "#FFF", - "#FF00000", - "", - "system:label", - "#\u{FF26}\u{FF26}0000", - ] - - for value in rejected { - let error = try Self.refusal(Self.withColor(value, at: ["editor", "background"])) - #expect(error == .invalidColor(value), "\(value)") - } - } - - @Test("An 8-digit color keeps its alpha") - func alphaColorIsAccepted() throws { - let colors = try Self.document(Self.withColor("#ff79c680", at: ["editor", "selection"])).colors - let color = try #require(colors[.editorSelection]) - - #expect(color.hex == "#FF79C680") - #expect(abs(color.nsColor.alphaComponent - CGFloat(0x80) / 255) < 0.001) - } - - @Test("Format 2 and a file with no format are refused") - func otherFormatsAreRefused() throws { - #expect(try Self.refusal(Self.metadata(schema: 2)) == .schemaTooOld(found: 2, supported: 3)) - #expect(try Self.refusal(Self.metadata(schema: 4)) == .schemaTooNew(found: 4, supported: 3)) - #expect(try Self.refusal(Self.metadata(schema: "3")) == .missingSchema) - - var unversioned = Self.metadata() - unversioned.removeValue(forKey: "schema") - #expect(try Self.refusal(unversioned) == .missingSchema) - #expect(try Self.refusal([:]) == .missingSchema) - } - - @Test("Missing metadata is refused by name") - func missingMetadataIsRefused() throws { - for key in ["id", "name", "appearance"] { - var root = Self.metadata() - root.removeValue(forKey: key) - #expect(try Self.refusal(root) == .missingField(key), "\(key)") - } - - var unknownAppearance = Self.metadata() - unknownAppearance["appearance"] = "sepia" - #expect(try Self.refusal(unknownAppearance) == .missingField("appearance")) - } - - @Test("An id that is not a plain file-safe name is refused") - func invalidIdIsRefused() throws { - for id in ["user/evil", "../escape", "user..x", "user x", "user.\u{00E9}"] { - #expect(try Self.refusal(Self.metadata(id: id)) == .invalidIdentifier(id), "\(id)") - } - } - - // MARK: - Encoder - - @Test("The encoder writes metadata and the overrides, nothing else") - func encoderWritesOnlyOverrides() throws { - let theme = ThemeDefinition( - id: "user.sparse", - name: "Sparse", - author: "Tests", - appearance: .light, - overrides: [ - .syntaxKeyword: try ThemeColor(validating: "#AA0000"), - .gridBackground: try ThemeColor(validating: "#FAFAFA"), - ] - ) - - let object = try JSONSerialization.jsonObject(with: try ThemeEncoder.data(for: theme)) - let root = try #require(object as? [String: Any]) - let editor = try #require(root["editor"] as? [String: Any]) - let syntax = try #require(editor["syntax"] as? [String: String]) - let grid = try #require(root["dataGrid"] as? [String: String]) - - #expect(Set(root.keys) == ["schema", "id", "name", "author", "appearance", "editor", "dataGrid"]) - #expect(Set(editor.keys) == ["syntax"]) - #expect(syntax == ["keyword": "#AA0000"]) - #expect(grid == ["background": "#FAFAFA"]) - #expect(root["schema"] as? Int == 3) - } - - @Test("A default theme encodes as syntax only") - func defaultThemeEncodesSyntaxOnly() throws { - let object = try JSONSerialization.jsonObject(with: try ThemeEncoder.data(for: BuiltInThemes.light)) - let root = try #require(object as? [String: Any]) - 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] = [:] - for (index, slot) in ThemeSlot.allCases.enumerated() { - overrides[slot] = try ThemeColor(validating: String(format: "#%02X%02X%02X", index, 255 - index, index * 3)) - } - let full = ThemeDefinition(id: "user.full", name: "Full", author: "Tests", appearance: .dark, overrides: overrides) - let sparse = ThemeDefinition( - id: "user.sparse", - name: "Sparse", - author: "", - appearance: .light, - overrides: [.editorCursor: try ThemeColor(validating: "#11223344")] - ) - - for theme in [full, sparse, BuiltInThemes.light, BuiltInThemes.dark] { - let reread = try ThemeDocument(data: try ThemeEncoder.data(for: theme)).definition() - #expect(reread == theme, "\(theme.id)") - } - } -} diff --git a/TableProTests/Theme/ThemeEngineTests.swift b/TableProTests/Theme/ThemeEngineTests.swift new file mode 100644 index 0000000000..623bbb49bb --- /dev/null +++ b/TableProTests/Theme/ThemeEngineTests.swift @@ -0,0 +1,237 @@ +// +// ThemeEngineTests.swift +// TableProTests +// +// The engine follows the catalog itself. Before, only the Appearance pane told it a theme had +// changed, so a save, a move to the Trash or a file fixed in Finder left every window on the old +// colors until Settings was opened. Each case runs its own engine on its own source, catalog and +// change subject, so nothing the app draws with moves. +// + +import AppKit +import Combine +import Foundation +@testable import TablePro +import TableProTestSupport +import TableProTheme +import Testing + +@MainActor +struct ThemeEngineTests { + @MainActor + private final class Harness { + let root: URL + let center = NotificationCenter() + let changes = PassthroughSubject() + let catalog: ThemeCatalog + let engine: ThemeEngine + private(set) var sent = 0 + private var cancellable: AnyCancellable? + + var themes: URL { root.appendingPathComponent("Themes", isDirectory: true) } + + init(themes seeded: [ThemeDefinition]) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("ThemeEngineTests-\(UUID().uuidString)", isDirectory: true) + let folder = root.appendingPathComponent("Themes", isDirectory: true) + let trash = root.appendingPathComponent("Trash", isDirectory: true) + for directory in [folder, trash] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + for theme in seeded { + try ThemeFileFormat.encode(theme).write(to: folder.appendingPathComponent("\(theme.id).json")) + } + catalog = ThemeCatalog( + userDirectory: folder, + bundleDirectory: nil, + notificationCenter: center, + fileTrash: ThemeFileTrash { url in + let destination = trash.appendingPathComponent(url.lastPathComponent) + try FileManager.default.moveItem(at: url, to: destination) + return destination + } + ) + engine = ThemeEngine(catalog: catalog, changes: changes) + cancellable = changes.sink { [weak self] _ in self?.sent += 1 } + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + } + + private static func theme(_ id: String, _ appearance: ThemeAppearance, background hex: String) throws -> ThemeDefinition { + ThemeDefinition( + id: id, + name: id, + author: "", + appearance: appearance, + overrides: [.editorBackground: try ThemeColor(validating: hex)] + ) + } + + private static func backgroundHex(of engine: ThemeEngine) -> String? { + engine.activeTheme.overrides[.editorBackground]?.hex + } + + // MARK: - Catalog changes + + @Test("Saving the theme a slot shows repaints with no view involved") + func saveRepaints() async throws { + let day = try Self.theme("user.day", .light, background: "#FFFFF0") + let night = try Self.theme("user.night", .dark, background: "#101010") + let harness = try Harness(themes: [day, night]) + defer { harness.remove() } + harness.engine.select(lightThemeId: day.id, darkThemeId: night.id) + #expect(harness.engine.pair == ThemePair(light: day, dark: night)) + let sentBefore = harness.sent + + var editedNight = night + editedNight.overrides[.editorBackground] = try ThemeColor(validating: "#202020") + try harness.catalog.save(editedNight) + + try await waitUntil { harness.engine.pair.dark == editedNight } + #expect(harness.engine.pair.light == day) + #expect(harness.sent > sentBefore) + #expect(harness.engine.lightThemeId == day.id) + #expect(harness.engine.darkThemeId == night.id) + } + + @Test("Moving the active theme to the Trash falls back to the slot's default, and a restore brings it back") + func trashFallsBackAndRestoreReturns() async throws { + let night = try Self.theme("user.night", .dark, background: "#101010") + let harness = try Harness(themes: [night]) + defer { harness.remove() } + harness.engine.select(lightThemeId: BuiltInThemes.defaultLightId, darkThemeId: night.id) + #expect(harness.engine.pair.dark == night) + + let receipt = try harness.catalog.trash(id: night.id) + try await waitUntil { harness.engine.pair.dark == BuiltInThemes.dark } + #expect(harness.engine.darkThemeId == night.id) + + try harness.catalog.restore(receipt) + try await waitUntil { harness.engine.pair.dark == night } + } + + @Test("A file changed on disk repaints once TablePro becomes active") + func activationReloadRepaints() async throws { + let night = try Self.theme("user.night", .dark, background: "#101010") + let harness = try Harness(themes: [night]) + defer { harness.remove() } + harness.engine.select(lightThemeId: BuiltInThemes.defaultLightId, darkThemeId: night.id) + + var fixed = night + fixed.overrides[.editorBackground] = try ThemeColor(validating: "#303030") + try ThemeFileFormat.encode(fixed).write(to: harness.themes.appendingPathComponent("\(night.id).json")) + harness.center.post(name: NSApplication.didBecomeActiveNotification, object: nil) + + try await waitUntil { harness.engine.pair.dark == fixed } + } + + @Test("A theme imported under a saved id that was missing is picked up") + func savedIdArrivingLater() async throws { + let harness = try Harness(themes: []) + defer { harness.remove() } + harness.engine.select(lightThemeId: BuiltInThemes.defaultLightId, darkThemeId: "user.later") + #expect(harness.engine.pair.dark == BuiltInThemes.dark) + + let later = try Self.theme("user.later", .dark, background: "#123456") + try harness.catalog.save(later) + + try await waitUntil { harness.engine.pair.dark == later } + } + + // MARK: - Draft preview + + @Test("A draft stands in for its theme until the preview ends") + func draftOverlay() async throws { + let harness = try Harness(themes: []) + defer { harness.remove() } + harness.engine.select(lightThemeId: BuiltInThemes.defaultLightId, darkThemeId: BuiltInThemes.defaultDarkId) + let appearance = harness.engine.effectiveAppearance + let saved = try Self.theme("user.edited", appearance, background: "#101010") + try harness.catalog.save(saved) + harness.engine.select( + lightThemeId: appearance == .light ? saved.id : BuiltInThemes.defaultLightId, + darkThemeId: appearance == .dark ? saved.id : BuiltInThemes.defaultDarkId + ) + try await waitUntil { harness.engine.activeTheme == saved } + + var draft = saved + draft.overrides[.editorBackground] = try ThemeColor(validating: "#FF0000") + harness.engine.beginPreview(draft) + + try await waitUntil { harness.engine.activeTheme == draft } + #expect(harness.engine.resolved.definition == draft) + #expect(ThemeTestFixture.hex(harness.engine.resolved[.editorBackground]) == "#FF0000") + #expect(harness.catalog.theme(id: saved.id) == saved) + + draft.overrides[.editorBackground] = try ThemeColor(validating: "#00FF00") + harness.engine.beginPreview(draft) + try await waitUntil { Self.backgroundHex(of: harness.engine) == "#00FF00" } + + harness.engine.endPreview(id: draft.id) + try await waitUntil { harness.engine.activeTheme == saved } + #expect(harness.engine.resolved.definition == saved) + } + + @Test("A burst of draft changes resolves once") + func draftBurstCoalesces() async throws { + let harness = try Harness(themes: []) + defer { harness.remove() } + harness.engine.select(lightThemeId: BuiltInThemes.defaultLightId, darkThemeId: BuiltInThemes.defaultDarkId) + let appearance = harness.engine.effectiveAppearance + let saved = try Self.theme("user.edited", appearance, background: "#101010") + try harness.catalog.save(saved) + harness.engine.select( + lightThemeId: appearance == .light ? saved.id : BuiltInThemes.defaultLightId, + darkThemeId: appearance == .dark ? saved.id : BuiltInThemes.defaultDarkId + ) + try await waitUntil { harness.engine.activeTheme == saved } + let sentBefore = harness.sent + + var draft = saved + for step in 0 ..< 40 { + draft.overrides[.editorBackground] = ThemeColor(red: UInt8(step), green: 0, blue: 0) + harness.engine.beginPreview(draft) + } + + try await waitUntil { harness.engine.activeTheme == draft } + #expect(harness.sent == sentBefore + 1) + } + + @Test("A draft no slot selects changes nothing, and is used once a slot selects it") + func unselectedDraftIsRegistered() async throws { + let harness = try Harness(themes: []) + defer { harness.remove() } + let before = harness.engine.pair + let draft = try Self.theme("user.unsaved", .dark, background: "#ABCDEF") + + harness.engine.beginPreview(draft) + harness.engine.select(lightThemeId: before.light.id, darkThemeId: before.dark.id) + #expect(harness.engine.pair == before) + + harness.engine.select(lightThemeId: before.light.id, darkThemeId: draft.id) + #expect(harness.engine.pair.dark == draft) + + harness.engine.endPreview(id: draft.id) + try await waitUntil { harness.engine.pair.dark == BuiltInThemes.dark } + } + + @Test("A test engine leaves the shared engine and its source alone") + func separateEngineIsolation() throws { + let shared = ThemeEngine.shared + let pairBefore = shared.pair + let revisionBefore = shared.revision + let harness = try Harness(themes: []) + defer { harness.remove() } + + harness.engine.adopt(ThemeSelection( + pair: ThemePair(light: BuiltInThemes.light, dark: try Self.theme("user.x", .dark, background: "#010203")), + effectiveAppearance: .dark + )) + + #expect(shared.pair == pairBefore) + #expect(shared.revision == revisionBefore) + } +} diff --git a/TableProTests/Theme/ThemeScopeTests.swift b/TableProTests/Theme/ThemeScopeTests.swift new file mode 100644 index 0000000000..c4bda84df1 --- /dev/null +++ b/TableProTests/Theme/ThemeScopeTests.swift @@ -0,0 +1,145 @@ +// +// ThemeScopeTests.swift +// TableProTests +// +// A fixed scope is how the theme editor renders a draft through the real carriers. It has to draw +// the draft whatever the engine holds, and leave the engine exactly as it was. +// + +import AppKit +import Combine +@testable import TablePro +import TableProTheme +import Testing + +@MainActor +struct ThemeScopeTests { + private struct Table { + let window: NSWindow + let scrollView: NSScrollView + let tableView: NSTableView + } + + /// Never ordered front and never closed: `close()` on a test window over-releases it. + private func makeTable() -> Table { + let tableView = NSTableView(frame: NSRect(x: 0, y: 0, width: 240, height: 120)) + tableView.headerView = nil + tableView.usesAlternatingRowBackgroundColors = true + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("value")) + column.width = 200 + tableView.addTableColumn(column) + 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) + } + + private static func draft() throws -> ThemeDefinition { + ThemeDefinition( + id: "user.draft", + name: "Draft", + author: "", + appearance: .dark, + overrides: [ + .gridBackground: try ThemeColor(validating: "#282A36"), + .gridSelection: try ThemeColor(validating: "#44475A"), + .editorBackground: try ThemeColor(validating: "#1B3A2C"), + .editorText: try ThemeColor(validating: "#F8F8F2"), + ] + ) + } + + @Test("A fixed scope drives a table's chrome from its definition, in either appearance, without the engine") + func fixedScopeDrivesTheTableChrome() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let engine = ThemeEngine.shared + let pairBefore = engine.pair + let revisionBefore = engine.revision + let table = makeTable() + let scope = ThemeScope.fixed(try Self.draft()) + + let chrome = ThemeTableChrome(tableView: table.tableView, family: .records, scope: scope) + + #expect(chrome.isBackgroundThemed) + #expect(chrome.themesRows) + #expect(ThemeTestFixture.hex(table.tableView.backgroundColor, in: .aqua) == "#282A36") + #expect(ThemeTestFixture.hex(table.tableView.backgroundColor, in: .darkAqua) == "#282A36") + #expect(ThemeTestFixture.hex(chrome.rowBackgroundColor(forRow: 1), in: .aqua) == "#31333E") + #expect(ThemeTestFixture.hex(chrome.selectionColor(emphasized: true)) == "#44475A") + #expect(!table.scrollView.drawsBackground) + #expect(engine.pair == pairBefore) + #expect(engine.revision == revisionBefore) + withExtendedLifetime(chrome) {} + } + } + + @Test("A live theme change leaves a fixed-scope table alone") + func liveChangesDoNotReachAFixedScope() throws { + try ThemeTestFixture.preservingEngine { + ThemeTestFixture.adopt(.builtIn, appearance: .light) + let table = makeTable() + let chrome = ThemeTableChrome(tableView: table.tableView, family: .records, scope: .fixed(try Self.draft())) + + try ThemeTestFixture.adopt([.gridBackground: "#FAFAFA"], appearance: .light) + chrome.apply() + + #expect(ThemeTestFixture.hex(table.tableView.backgroundColor, in: .aqua) == "#282A36") + withExtendedLifetime(chrome) {} + } + } + + @Test("A fixed scope drives a text view's colors") + func fixedScopeDrivesATextView() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 40)) + let style = ThemeTextViewStyle(textView: textView, scope: .fixed(try Self.draft())) + + #expect(ThemeTestFixture.hex(textView.backgroundColor, in: .aqua) == "#1B3A2C") + #expect(ThemeTestFixture.hex(textView.textColor, in: .aqua) == "#F8F8F2") + + style.detach() + #expect(ThemeTestFixture.hex(textView.backgroundColor, in: .aqua) == ThemeTestFixture.hex(.textBackgroundColor, in: .aqua)) + } + } + + @Test("A fixed scope reports the definition and never a change") + func fixedScopeIsConstant() throws { + let draft = try Self.draft() + let scope = ThemeScope.fixed(draft) + var received = 0 + let cancellable = scope.changes.sink { _ in received += 1 } + + try ThemeTestFixture.adopting([.gridBackground: "#FAFAFA"], appearance: .light) { + #expect(scope.activeTheme == draft) + #expect(scope.resolved.definition == draft) + #expect(ThemeTestFixture.hex(scope.palette[.gridBackground], in: .aqua) == "#282A36") + } + #expect(received == 0) + withExtendedLifetime(cancellable) {} + } + + @Test("The live scope is the engine") + func liveScopeForwardsToTheEngine() throws { + try ThemeTestFixture.adopting([.gridBackground: "#FAFAFA"], appearance: .light) { + let engine = ThemeEngine.shared + #expect(ThemeScope.live.activeTheme == engine.activeTheme) + #expect(ThemeScope.live.resolved == engine.resolved) + #expect(ThemeScope.live.palette.revision == engine.palette.revision) + } + } + + @Test("A fixed palette never shares a color name with the live palette") + func fixedPaletteIsNamedApart() throws { + let fixed = ThemeScope.fixed(try Self.draft()).palette[.gridBackground] + let live = ThemeEngine.shared.palette[.gridBackground] + #expect(fixed != live) + } +} diff --git a/TableProTests/Theme/ThemeSlotCoverageTests.swift b/TableProTests/Theme/ThemeSlotCoverageTests.swift index cc780c3d37..3b217c8366 100644 --- a/TableProTests/Theme/ThemeSlotCoverageTests.swift +++ b/TableProTests/Theme/ThemeSlotCoverageTests.swift @@ -2,53 +2,26 @@ // ThemeSlotCoverageTests.swift // TableProTests // +// What needs the app: the bundled theme files and the AppKit colors the defaults draw. The slot +// registry itself is tested in TableProThemeTests. +// import AppKit import Foundation @testable import TablePro +import TableProTheme import Testing +@MainActor struct ThemeSlotCoverageTests { - @Test("Slot paths are unique and sit under their group's key") - func slotPathsAreWellFormed() { - let paths = ThemeSlot.allCases.map(\.rawValue) - #expect(Set(paths).count == paths.count) - - 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)") - case .syntax: - #expect(slot.rawValue.hasPrefix("editor.syntax."), "\(slot.rawValue)") - case .dataGrid: - #expect(slot.rawValue.hasPrefix("dataGrid."), "\(slot.rawValue)") - } - } - } - - @Test("Every slot belongs to exactly one group") - func groupsPartitionTheSlots() { - let grouped = ThemeSlotGroup.allCases.flatMap(\.slots) - #expect(Set(grouped) == Set(ThemeSlot.allCases)) - #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") + @Test("The window slot draws the window's own background color natively") + func windowSlotIsTheWindowColor() { #expect(ThemeSlot.windowBackdrop.nativeColor === NSColor.windowBackgroundColor) - #expect(ThemeSlot.allCases.filter(\.yieldsToIncreasedContrast) == [.windowBackdrop]) } // MARK: - Bundled themes - /// The bundle is the list, so a third bundled theme is decoded here the day it ships. + /// The bundle is the list, so a new bundled theme is decoded here the day it ships. private static func bundledThemeFiles() -> [URL] { (Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []) .filter { $0.lastPathComponent.hasPrefix(ThemeDefinition.builtInPrefix) } @@ -61,11 +34,11 @@ struct ThemeSlotCoverageTests { var ids: Set = [] for file in files { - let document = try ThemeDocument(data: try Data(contentsOf: file)) - #expect(document.id == file.deletingPathExtension().lastPathComponent) - #expect(document.id.hasPrefix(ThemeDefinition.builtInPrefix)) - #expect(!document.colors.isEmpty, "\(document.id) overrides nothing") - ids.insert(document.id) + let theme = try ThemeFileFormat.decode(try Data(contentsOf: file)) + #expect(theme.id == file.deletingPathExtension().lastPathComponent) + #expect(theme.id.hasPrefix(ThemeDefinition.builtInPrefix)) + #expect(!theme.overrides.isEmpty, "\(theme.id) sets nothing") + ids.insert(theme.id) } #expect(ids.isSuperset(of: ["tablepro.dracula", "tablepro.nord"])) @@ -75,9 +48,17 @@ struct ThemeSlotCoverageTests { private static let syntaxWithoutOperator = Set(ThemeSlotGroup.syntax.slots).subtracting([.syntaxOperator]) + private static func drawnSyntax(_ theme: ThemeDefinition, in name: NSAppearance.Name) -> [ThemeSlot: String] { + var drawn: [ThemeSlot: String] = [:] + for slot in syntaxWithoutOperator { + drawn[slot] = ThemeTestFixture.hex(theme.color(for: slot), in: name) + } + return drawn + } + /// Xcode 27.1 `Default (Light).xccolortheme`: keyword, string, number, comment, system function /// and system type. SQL `NULL` takes the keyword colour the way Swift's `nil` does. - @Test("Default Light overrides only syntax, with Xcode's Default (Light) values") + @Test("Default Light sets nothing and draws Xcode's Default (Light) syntax") func defaultLightIsXcodeDefaultLight() { let expected: [ThemeSlot: String] = [ .syntaxKeyword: "#9B2393", @@ -88,15 +69,14 @@ struct ThemeSlotCoverageTests { .syntaxType: "#3900A0", .syntaxNull: "#9B2393", ] - let actual = BuiltInThemes.light.overrides.mapValues { $0.hex } - #expect(Set(actual.keys) == Self.syntaxWithoutOperator) - #expect(actual == expected) + #expect(BuiltInThemes.light.overrides.isEmpty) + #expect(Self.drawnSyntax(BuiltInThemes.light, in: .aqua) == expected) #expect(BuiltInThemes.light.appearance == .light) #expect(BuiltInThemes.light.id == BuiltInThemes.defaultLightId) } - @Test("Default Dark overrides only syntax, with Xcode's Default (Dark) values") + @Test("Default Dark sets nothing and draws Xcode's Default (Dark) syntax") func defaultDarkIsXcodeDefaultDark() { let expected: [ThemeSlot: String] = [ .syntaxKeyword: "#FC5FA3", @@ -107,11 +87,22 @@ struct ThemeSlotCoverageTests { .syntaxType: "#D0A8FF", .syntaxNull: "#FC5FA3", ] - let actual = BuiltInThemes.dark.overrides.mapValues { $0.hex } - #expect(Set(actual.keys) == Self.syntaxWithoutOperator) - #expect(actual == expected) + #expect(BuiltInThemes.dark.overrides.isEmpty) + #expect(Self.drawnSyntax(BuiltInThemes.dark, in: .darkAqua) == expected) #expect(BuiltInThemes.dark.appearance == .dark) #expect(BuiltInThemes.default(for: .dark).id == BuiltInThemes.defaultDarkId) } + + /// The default ids used to keep their syntax as overrides, which a duplicate copied and then + /// froze under Increase Contrast. A copy now draws the same adaptive objects as the default. + @Test("A duplicate of a default draws the default's adaptive syntax objects") + func duplicateOfADefaultStaysAdaptive() { + var copy = BuiltInThemes.dark + copy.id = "user.copy" + for slot in Self.syntaxWithoutOperator { + #expect(copy.color(for: slot) === BuiltInThemes.dark.color(for: slot), "\(slot.rawValue)") + #expect(copy.color(for: slot) === BuiltInThemes.adaptiveSyntaxColor(slot, appearance: .dark), "\(slot.rawValue)") + } + } } diff --git a/TableProTests/Theme/ThemeTableChromeTests.swift b/TableProTests/Theme/ThemeTableChromeTests.swift index 7e56a16837..3e0fb340ec 100644 --- a/TableProTests/Theme/ThemeTableChromeTests.swift +++ b/TableProTests/Theme/ThemeTableChromeTests.swift @@ -11,6 +11,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor @@ -437,7 +438,7 @@ struct ThemeTableChromeTests { #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) + try ThemeTestFixture.adopt([.gridText: "#FF0000", .gridSelection: "#C8E1FF", .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) diff --git a/TableProTests/Theme/ThemeTableHeaderTests.swift b/TableProTests/Theme/ThemeTableHeaderTests.swift new file mode 100644 index 0000000000..7519e7717b --- /dev/null +++ b/TableProTests/Theme/ThemeTableHeaderTests.swift @@ -0,0 +1,243 @@ +// +// ThemeTableHeaderTests.swift +// TableProTests +// +// A records table (the query plan outline, the privilege tree) kept AppKit's header band over a +// themed body, while the data grid's header took the theme. Its chrome now puts a themed header in +// while the theme colors the grid's header, and hands the owner's own header back when it stops. +// + +import AppKit +@testable import TablePro +import TableProTheme +import Testing + +@MainActor +private final class HeaderTableSource: NSObject, NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { 3 } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { nil } +} + +@MainActor +private final class FillHeaderSwatch: NSView { + private let color: NSColor + + init(color: NSColor) { + self.color = color + super.init(frame: NSRect(x: 0, y: 0, width: 4, height: 4)) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { nil } + + override func draw(_ dirtyRect: NSRect) { + color.setFill() + dirtyRect.fill() + } +} + +private final class SortIndicatorRecordingCell: NSTableHeaderCell { + var drawnIndicators: [Bool] = [] + + override func drawSortIndicator(withFrame cellFrame: NSRect, in controlView: NSView, ascending: Bool, priority: Int) { + drawnIndicators.append(ascending) + super.drawSortIndicator(withFrame: cellFrame, in: controlView, ascending: ascending, priority: priority) + } +} + +@MainActor +struct ThemeTableHeaderTests { + private struct Table { + let window: NSWindow + let tableView: NSTableView + let header: NSTableHeaderView + let corner: NSView? + let source: HeaderTableSource + } + + private func makeTable() -> Table { + let tableView = NSTableView(frame: NSRect(x: 0, y: 0, width: 320, height: 160)) + let header = NSTableHeaderView() + tableView.headerView = header + for title in ["Operation", "Cost"] { + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(title)) + column.title = title + column.width = 140 + tableView.addTableColumn(column) + } + let source = HeaderTableSource() + tableView.dataSource = source + tableView.delegate = source + + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 320, height: 160)) + scrollView.documentView = tableView + 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 + tableView.reloadData() + return Table(window: window, tableView: tableView, header: header, corner: tableView.cornerView, source: source) + } + + private func scope(_ overrides: [ThemeSlot: String]) throws -> ThemeScope { + var theme = try ThemeTestFixture.theme(.light, overrides) + theme.id = "test.records-header.\(UUID().uuidString)" + return .fixed(theme) + } + + /// The view's `draw(_:)` called straight into an sRGB bitmap, so the theme's hex reads back as + /// written. Row 0 of the bitmap is the top of the view. + private func raster(_ view: NSView) throws -> NSBitmapImageRep { + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let cgContext = try #require(CGContext( + data: nil, + width: Int(view.bounds.width), + height: Int(view.bounds.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + if view.isFlipped { + cgContext.translateBy(x: 0, y: view.bounds.height) + cgContext.scaleBy(x: 1, y: -1) + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: cgContext, flipped: view.isFlipped) + view.effectiveAppearance.performAsCurrentDrawingAppearance { + view.draw(view.bounds) + } + NSGraphicsContext.restoreGraphicsState() + return NSBitmapImageRep(cgImage: try #require(cgContext.makeImage())) + } + + private func hex(_ rep: NSBitmapImageRep, x: Int, y: Int) throws -> String { + ThemeColor(try #require(rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB))).hex + } + + /// Held to the expected color as a plain fill drawn the same way, so the conversion on the way + /// into the bitmap cancels out. + private func isNear(_ drawn: String, _ expected: String) -> Bool { + guard let lhs = ThemeColor(hex: drawn), let color = ThemeColor(hex: expected)?.nsColor else { return false } + let fill = FillHeaderSwatch(color: color) + guard let rep = try? raster(fill), let reference = try? hex(rep, x: 1, y: 1), + let rhs = ThemeColor(hex: reference) else { return false } + return abs(Int(lhs.red) - Int(rhs.red)) <= 3 + && abs(Int(lhs.green) - Int(rhs.green)) <= 3 + && abs(Int(lhs.blue) - Int(rhs.blue)) <= 3 + && abs(Int(lhs.alpha) - Int(rhs.alpha)) <= 3 + } + + @Test("Under the default theme a records table keeps the header and corner its owner installed") + func defaultThemeKeepsTheOwnersHeader() throws { + let table = makeTable() + let chrome = ThemeTableChrome(tableView: table.tableView, family: .records, scope: .fixed(BuiltInThemes.light)) + + #expect(table.tableView.headerView === table.header) + #expect(table.tableView.cornerView === table.corner) + #expect(!chrome.isHeaderThemed) + withExtendedLifetime(table.window) {} + } + + @Test("A theme that colors the grid's header themes a records table's header, band and titles") + func themedHeaderDrawsTheTheme() throws { + let table = makeTable() + let chrome = ThemeTableChrome( + tableView: table.tableView, + family: .records, + scope: try scope([.gridHeaderBackground: "#21222C", .gridHeaderText: "#F8F8F2", .gridLine: "#44475A"]) + ) + let header = try #require(table.tableView.headerView as? ThemeTableHeaderView) + #expect(chrome.isHeaderThemed) + #expect(header.chrome === chrome) + #expect(header !== table.header) + #expect(table.tableView.cornerView is ThemeTableCornerView) + + let rep = try raster(header) + let firstHeading = header.headerRect(ofColumn: 0) + let band = try hex(rep, x: Int(firstHeading.maxX) - 12, y: 3) + let past = try hex(rep, x: Int(header.bounds.maxX) - 4, y: 3) + #expect(isNear(band, "#21222C"), "the band drew \(band)") + #expect(isNear(past, "#21222C"), "past the last heading drew \(past)") + + var titleInk = 0 + for y in 0 ..< rep.pixelsHigh - 1 { + for x in Int(firstHeading.minX) ..< Int(firstHeading.midX) { + guard let pixel = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { continue } + if ThemeColor(pixel).relativeLuminance > 0.5 { titleInk += 1 } + } + } + #expect(titleInk > 0, "the title is not in the theme's light header text") + withExtendedLifetime(table.window) {} + } + + @Test("A themed header still draws the sort indicator of the primary sort column, and only that one") + func themedHeaderDrawsTheSortIndicator() throws { + let table = makeTable() + let cells = table.tableView.tableColumns.map { column -> SortIndicatorRecordingCell in + let cell = SortIndicatorRecordingCell(textCell: column.title) + column.headerCell = cell + column.sortDescriptorPrototype = NSSortDescriptor(key: column.identifier.rawValue, ascending: true) + return cell + } + table.tableView.sortDescriptors = [ + NSSortDescriptor(key: "Cost", ascending: false), + NSSortDescriptor(key: "Operation", ascending: true), + ] + let chrome = ThemeTableChrome(tableView: table.tableView, family: .records, scope: try scope([.gridHeaderBackground: "#21222C"])) + let header = try #require(table.tableView.headerView as? ThemeTableHeaderView) + + _ = try raster(header) + + #expect(chrome.isHeaderThemed) + #expect(cells[0].drawnIndicators.isEmpty) + #expect(cells[1].drawnIndicators == [false]) + withExtendedLifetime(table.window) {} + } + + @Test("A background-only theme heads a records table in that background, as the grid does") + func backgroundOnlyThemeHeadsTheTable() throws { + let table = makeTable() + let chrome = ThemeTableChrome(tableView: table.tableView, family: .records, scope: try scope([.gridBackground: "#FDF6E3"])) + let header = try #require(table.tableView.headerView as? ThemeTableHeaderView) + + #expect(chrome.isHeaderThemed) + let past = try hex(raster(header), x: Int(header.bounds.maxX) - 4, y: 3) + #expect(isNear(past, "#FDF6E3"), "drew \(past)") + withExtendedLifetime(table.window) {} + } + + @Test("A code table's header stays AppKit's, since the editor family has no header slots") + func codeTablesKeepTheirHeader() throws { + let table = makeTable() + let chrome = ThemeTableChrome(tableView: table.tableView, family: .code, scope: try scope([.gridHeaderBackground: "#21222C"])) + + #expect(!chrome.isHeaderThemed) + #expect(table.tableView.headerView === table.header) + withExtendedLifetime(table.window) {} + } + + @Test("When the theme stops coloring the header the owner's header and corner come back") + func themeStopRestoresTheOwnersHeader() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([.gridHeaderBackground: "#21222C"], appearance: .light) + let table = makeTable() + let chrome = ThemeTableChrome(tableView: table.tableView, family: .records) + #expect(table.tableView.headerView is ThemeTableHeaderView) + + ThemeTestFixture.adopt(.builtIn, appearance: .light) + chrome.apply() + + #expect(!chrome.isHeaderThemed) + #expect(table.tableView.headerView === table.header) + #expect(table.tableView.cornerView === table.corner) + withExtendedLifetime(table.window) {} + } + } +} diff --git a/TableProTests/Theme/ThemeTextViewStyleTests.swift b/TableProTests/Theme/ThemeTextViewStyleTests.swift index 17ee323067..d6b5ef559c 100644 --- a/TableProTests/Theme/ThemeTextViewStyleTests.swift +++ b/TableProTests/Theme/ThemeTextViewStyleTests.swift @@ -9,6 +9,7 @@ import AppKit @testable import TablePro +import TableProTheme import Testing /// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are diff --git a/TableProTests/Theme/ThemeWindowBackdropTests.swift b/TableProTests/Theme/ThemeWindowBackdropTests.swift index 44bfde5738..aa93618f07 100644 --- a/TableProTests/Theme/ThemeWindowBackdropTests.swift +++ b/TableProTests/Theme/ThemeWindowBackdropTests.swift @@ -9,6 +9,7 @@ import AppKit @testable import TablePro +import TableProTheme import Testing /// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are diff --git a/TableProTests/ViewModels/ServerDashboardViewModelTests.swift b/TableProTests/ViewModels/ServerDashboardViewModelTests.swift index 8fd5d79607..91e45cb404 100644 --- a/TableProTests/ViewModels/ServerDashboardViewModelTests.swift +++ b/TableProTests/ViewModels/ServerDashboardViewModelTests.swift @@ -152,22 +152,73 @@ struct ServerDashboardViewModelTests { #expect(payload["can_cancel"]?.boolValue == vm.canCancel(session)) } - @Test("confirmKillSession stores process id and shows confirmation") + private func listed(_ id: String, user: String = "app") -> DashboardSession { + DashboardSession( + id: id, user: user, database: "shop", state: "active", durationSeconds: 1, duration: "1s", query: "SELECT 1" + ) + } + + @Test("confirmKillSession stores the session and shows confirmation") func confirmKillSessionUpdatesState() { let vm = makeViewModel(databaseType: .mysql) - vm.confirmKillSession(processId: "42") - #expect(vm.pendingKillProcessId == "42") + vm.confirmKillSession(listed("42")) + #expect(vm.pendingKillSession?.id == "42") #expect(vm.showKillConfirmation) } - @Test("confirmCancelQuery stores process id and shows confirmation") + @Test("confirmCancelQuery stores the session and shows confirmation") func confirmCancelQueryUpdatesState() { let vm = makeViewModel(databaseType: .mysql) - vm.confirmCancelQuery(processId: "99") - #expect(vm.pendingCancelProcessId == "99") + vm.confirmCancelQuery(listed("99")) + #expect(vm.pendingCancelSession?.id == "99") #expect(vm.showCancelConfirmation) } + @Test("The Terminate and Cancel confirmations name the session's PID and user") + func confirmationsNameTheSession() { + let session = listed("57", user: "sa") + #expect(ServerDashboardViewModel.killConfirmationMessage(for: session) + == "Terminate session 57 (user sa)? Any running queries will be aborted.") + #expect(ServerDashboardViewModel.cancelConfirmationMessage(for: session) + == "Cancel the running query for session 57 (user sa)?") + } + + @Test("A session the server lists with no user is named by its PID alone") + func confirmationsWithoutAUser() { + let session = listed("1733", user: "") + #expect(ServerDashboardViewModel.killConfirmationMessage(for: session) + == "Terminate session 1733? Any running queries will be aborted.") + #expect(ServerDashboardViewModel.cancelConfirmationMessage(for: session) + == "Cancel the running query for session 1733?") + } + + @Test("A selected session that ends leaves the selection, and a new session reusing its id starts unselected") + func endedSessionLeavesTheSelection() { + let vm = makeViewModel(databaseType: .mssql) + vm.sessions = [listed("57"), listed("60")] + vm.sessionSelection.ids = ["57"] + + vm.sessions = [listed("60")] + #expect(vm.sessionSelection.ids.isEmpty) + + vm.sessions = [listed("60"), listed("57", user: "other")] + #expect(vm.sessionSelection.ids.isEmpty) + #expect(SessionGridColumns.actionTarget(vm.sessionSelection.ids, in: vm.sessions) == nil) + } + + @Test("A refresh that still lists the selected session keeps it selected") + func listedSessionKeepsItsSelection() { + let vm = makeViewModel(databaseType: .postgresql) + vm.sessions = [listed("10"), listed("7")] + vm.sessionSelection.ids = ["10", "7"] + + vm.sessions = [listed("7"), listed("10"), listed("12")] + #expect(vm.sessionSelection.ids == ["10", "7"]) + + vm.sessions = [listed("10")] + #expect(vm.sessionSelection.ids == ["10"]) + } + @Test("stopAutoRefresh clears the refreshing flag") func stopAutoRefreshClearsRefreshingFlag() { let vm = makeViewModel(databaseType: .mysql) diff --git a/TableProTests/Views/AIChat/AIChatCodeThemeTests.swift b/TableProTests/Views/AIChat/AIChatCodeThemeTests.swift index 704c1f4ccf..0296aec759 100644 --- a/TableProTests/Views/AIChat/AIChatCodeThemeTests.swift +++ b/TableProTests/Views/AIChat/AIChatCodeThemeTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Agent/AgentResultPaneThemeTests.swift b/TableProTests/Views/Agent/AgentResultPaneThemeTests.swift new file mode 100644 index 0000000000..f704610fb6 --- /dev/null +++ b/TableProTests/Views/Agent/AgentResultPaneThemeTests.swift @@ -0,0 +1,117 @@ +// +// AgentResultPaneThemeTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +@MainActor +struct AgentResultPaneThemeTests { + private static let size = CGSize(width: 320, height: 240) + private static let statePoint = CGPoint(x: 0.05, y: 0.92) + + private static let statement = ProposedStatement( + id: "1", + sql: "update users set active = 0 where id = 7", + toolName: "execute_sql", + state: .waiting + ) + + /// What the session got back for each state the pane draws in place of the grid. + nonisolated private static let stateReplies: [String] = [ + #"{"columns":["id"],"rows":[]}"#, + #"{"columns":[],"rows":[],"rows_affected":2}"#, + "The table has two rows.", + ] + + private func rowsView(runs: [AgentQueryRun]) -> some View { + AgentResultRowsView(runs: runs, artifacts: AgentArtifactCache(), connection: nil) + .frame(width: Self.size.width, height: Self.size.height) + } + + private func assertStandsInForTheGrid(_ view: some View) throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let drawn = try ThemeTestFixture.color(of: view, at: Self.statePoint, colorScheme: .dark) + #expect(drawn.alphaComponent < 0.01, "default pair drew \(drawn)") + + try ThemeTestFixture.adopt([.gridBackground: ThemeTestFixture.gridHex], appearance: .dark) + let themed = try ThemeTestFixture.color(of: view, at: Self.statePoint, colorScheme: .dark) + let grid = try ThemeTestFixture.filled(hex: ThemeTestFixture.gridHex, colorScheme: .dark) + #expect(ThemeTestFixture.matches(themed, grid), "grid theme drew \(themed), theme \(grid)") + + try ThemeTestFixture.adopt([ + .windowBackdrop: ThemeTestFixture.windowHex, + .editorBackground: ThemeTestFixture.editorHex, + ], appearance: .dark) + let unrelated = try ThemeTestFixture.color(of: view, at: Self.statePoint, colorScheme: .dark) + #expect(unrelated.alphaComponent < 0.01, "another slot drew \(unrelated)") + } + } + + // MARK: - Result states + + @Test("Before the session reads anything the pane stands in for the grid") + func noResultsYetStandsInForTheGrid() throws { + try assertStandsInForTheGrid(rowsView(runs: [])) + } + + @Test("A run with nothing to draw stands in for the grid", arguments: AgentResultPaneThemeTests.stateReplies) + func emptyRunStandsInForTheGrid(reply: String) throws { + let run = AgentQueryRun(id: "run", sql: "select id from users where false", resultJSON: reply) + try assertStandsInForTheGrid(rowsView(runs: [run])) + } + + // MARK: - Proposed statements + + private func row(_ statement: ProposedStatement = AgentResultPaneThemeTests.statement) -> some View { + AgentProposedStatementRow(statement: statement).frame(width: Self.size.width) + } + + /// One short line, so the point sampled beside it is the well and never a glyph. + private static let shortStatement = ProposedStatement(id: "2", sql: "select 1", toolName: "execute_sql", state: .ran) + + @Test("A proposed statement sits on the editor background a theme sets, in the chat well's shape") + func proposedStatementSitsOnTheEditorBackground() throws { + try ThemeTestFixture.adopting([.editorBackground: ThemeTestFixture.editorHex], appearance: .dark) { + let drawn = try ThemeTestFixture.color(of: row(Self.shortStatement), at: CGPoint(x: 0.9, y: 0.2), colorScheme: .dark) + let editor = try ThemeTestFixture.filled(hex: ThemeTestFixture.editorHex, colorScheme: .dark) + #expect(ThemeTestFixture.matches(drawn, editor), "drew \(drawn), theme \(editor)") + } + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let drawn = try ThemeTestFixture.color(of: row(Self.shortStatement), at: CGPoint(x: 0.9, y: 0.2), colorScheme: .dark) + #expect(drawn.alphaComponent < 0.01, "default pair drew \(drawn)") + } + } + + /// `ProposedStatement` is equatable, so a row that does not observe the engine is skipped when + /// only the font moves. + @Test("A proposed statement on screen follows a Data Grid font change") + func proposedStatementFollowsTheValueFont() throws { + let previous = AppSettingsManager.shared.typography + defer { ThemeEngine.shared.apply(typography: previous) } + ThemeEngine.shared.apply(typography: TypographySettings(dataGridFontSize: 11)) + + let host = NSHostingView(rootView: row()) + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: Self.size), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = host + host.layoutSubtreeIfNeeded() + let small = host.fittingSize.height + + ThemeEngine.shared.apply(typography: TypographySettings(dataGridFontSize: 22)) + host.layoutSubtreeIfNeeded() + let large = host.fittingSize.height + + #expect(large > small + 10, "row was \(small)pt at 11pt and \(large)pt at 22pt") + withExtendedLifetime(window) {} + } +} diff --git a/TableProTests/Views/Compare/CompareStatusStyleTests.swift b/TableProTests/Views/Compare/CompareStatusStyleTests.swift index 06a138346d..a7a0e7cb06 100644 --- a/TableProTests/Views/Compare/CompareStatusStyleTests.swift +++ b/TableProTests/Views/Compare/CompareStatusStyleTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Compare/CompareWindowThemeTests.swift b/TableProTests/Views/Compare/CompareWindowThemeTests.swift new file mode 100644 index 0000000000..cdc7c552b6 --- /dev/null +++ b/TableProTests/Views/Compare/CompareWindowThemeTests.swift @@ -0,0 +1,100 @@ +// +// CompareWindowThemeTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +/// The Compare & Sync window: a backdrop behind its panes on every macOS version, bars that drop +/// their material over it, and row states that stand in for the grid. +@MainActor +struct CompareWindowThemeTests { + private static let windowSize = CGSize(width: 720, height: 460) + private static let paneSize = CGSize(width: 360, height: 240) + + private func makeSession() throws -> (CompareSyncSession, EphemeralDefaults) { + let suite = try EphemeralDefaults() + let session = CompareSyncSession( + profileStorage: CompareSyncProfileStorage(defaults: suite.defaults), + connectionsProvider: { [] } + ) + return (session, suite) + } + + private func color(of view: some View, size: CGSize, at point: CGPoint) throws -> NSColor { + try ThemeTestFixture.color( + of: view.frame(width: size.width, height: size.height), + at: point, + colorScheme: .dark + ) + } + + @Test( + "A row state stands in for the grid in the grid's background", + arguments: [CompareRowsStandIn.Reason.noRowsMatch, .notCompared("Include this table and compare again.")] + ) + func rowStateTakesTheGridBackground(reason: CompareRowsStandIn.Reason) throws { + let view = CompareRowsStandIn(reason: reason) + let corner = CGPoint(x: 0.03, y: 0.05) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let unthemed = try color(of: view, size: Self.paneSize, at: corner) + #expect(unthemed.alphaComponent < 0.05, "the default pair drew \(unthemed)") + + try ThemeTestFixture.adopt([.gridBackground: ThemeTestFixture.gridHex], appearance: .dark) + let grid = try ThemeTestFixture.filled(hex: ThemeTestFixture.gridHex, colorScheme: .dark) + let themed = try color(of: view, size: Self.paneSize, at: corner) + #expect(ThemeTestFixture.matches(themed, grid), "drew \(themed), grid \(grid)") + + try ThemeTestFixture.adopt([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + let unrelated = try color(of: view, size: Self.paneSize, at: corner) + #expect(unrelated.alphaComponent < 0.05, "the window slot drew \(unrelated)") + } + } + + /// The trailing end of the status bar, where neither the resting line nor a count draws. + @Test("The status bar drops its material and shows the window theme behind the panes") + func statusBarShowsTheBackdrop() throws { + let (session, suite) = try makeSession() + defer { withExtendedLifetime(suite) {} } + let view = CompareWindowContentView(session: session, onCompare: {}, onGenerateScript: {}, onApply: {}) + let statusBar = CGPoint(x: 0.97, y: 1 - 14 / Self.windowSize.height) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let window = try ThemeTestFixture.filled(hex: ThemeTestFixture.windowHex, colorScheme: .dark) + let unthemed = try color(of: view, size: Self.windowSize, at: statusBar) + #expect(!ThemeTestFixture.matches(unthemed, window)) + + try ThemeTestFixture.adopt([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + let themed = try color(of: view, size: Self.windowSize, at: statusBar) + #expect(ThemeTestFixture.matches(themed, window), "status bar drew \(themed), window \(window)") + + try ThemeTestFixture.adopt([.editorBackground: ThemeTestFixture.editorHex], appearance: .dark) + let unrelated = try color(of: view, size: Self.windowSize, at: statusBar) + #expect(ThemeTestFixture.matches(unrelated, unthemed), "another slot moved the status bar") + } + } + + @Test("The results filter bar keeps its material until the theme colors the window") + func resultsBarDropsItsMaterial() throws { + let (session, suite) = try makeSession() + defer { withExtendedLifetime(suite) {} } + let view = CompareResultsView(session: session, onCompare: {}) + let bar = CGPoint(x: 0.005, y: 0.01) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let native = try ThemeTestFixture.filled(.bar, colorScheme: .dark) + let unthemed = try color(of: view, size: Self.paneSize, at: bar) + #expect(ThemeTestFixture.matches(unthemed, native), "the default pair drew \(unthemed), bar \(native)") + + try ThemeTestFixture.adopt([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + let themed = try color(of: view, size: Self.paneSize, at: bar) + #expect(themed.alphaComponent < 0.05, "the bar drew \(themed) over a themed window") + + try ThemeTestFixture.adopt([.gridBackground: ThemeTestFixture.gridHex], appearance: .dark) + let unrelated = try color(of: view, size: Self.paneSize, at: bar) + #expect(ThemeTestFixture.matches(unrelated, native), "another slot drew \(unrelated)") + } + } +} diff --git a/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift b/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift index a8d028ce54..d0753c7edf 100644 --- a/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift +++ b/TableProTests/Views/Components/HighlightedSQLTextViewLayoutTests.swift @@ -7,6 +7,7 @@ import AppKit import SwiftUI @testable import TablePro import TableProPluginKit +import TableProTheme import Testing @MainActor @@ -137,18 +138,22 @@ struct HighlightedSQLTextViewLayoutTests { } } + /// A double-quoted name is a string in the MongoDB grammar and an identifier in SQL. @Test("The maintenance preview highlights in the connection's query language") func maintenancePreviewUsesConnectionLanguage() throws { try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let command = #"db.runCommand({"compact": "orders"})"# let window = ThemeTestFixture.host( - maintenanceSheet(["db.orders.reIndex()"], databaseType: .mongodb), + maintenanceSheet([command], 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 location = (textView.string as NSString).range(of: #""orders""#).location + try #require(location != NSNotFound) + let color = textView.textStorage?.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor let palette = ThemeEngine.shared.palette - #expect(ThemeTestFixture.hex(color) == ThemeTestFixture.hex(palette[.syntaxKeyword])) + #expect(ThemeTestFixture.hex(color) == ThemeTestFixture.hex(palette[.syntaxString])) #expect(ThemeTestFixture.hex(color) != ThemeTestFixture.hex(palette[.editorText])) withExtendedLifetime(window) {} } diff --git a/TableProTests/Views/Components/SQLPreviewThemeTests.swift b/TableProTests/Views/Components/SQLPreviewThemeTests.swift new file mode 100644 index 0000000000..1d468e6319 --- /dev/null +++ b/TableProTests/Views/Components/SQLPreviewThemeTests.swift @@ -0,0 +1,188 @@ +// +// SQLPreviewThemeTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProEditorKit +import TableProTheme +import Testing + +@MainActor +struct SQLPreviewThemeTests { + private static let statement = "-- note SELECT 1\nSELECT COUNT(id) FROM orders o WHERE x IS NULL AND y = 'a' AND z > 2" + + /// Every slot the statement draws in, each a color no other slot shares. + private static let syntaxTheme: [ThemeSlot: String] = [ + .editorText: "#101010", + .syntaxKeyword: "#C00000", + .syntaxComment: "#00A000", + .syntaxFunction: "#0000C0", + .syntaxNull: "#C000C0", + .syntaxString: "#00C0C0", + .syntaxNumber: "#C0C000", + .syntaxOperator: "#800040", + .syntaxType: "#408000", + ] + + private static let size = NSSize(width: 900, height: 160) + + private func host(_ sql: String = statement) -> NSWindow { + ThemeTestFixture.host(HighlightedSQLTextView(sql: sql, databaseType: .mysql), size: Self.size) + } + + private func textView(in window: NSWindow) throws -> NSTextView { + try #require(ThemeTestFixture.textViews(in: window).first) + } + + /// The `occurrence`th match of `fragment`, counted from 1. + private func range(of fragment: String, occurrence: Int = 1, in textView: NSTextView) throws -> NSRange { + let text = textView.string as NSString + var searchFrom = 0 + var found = NSRange(location: NSNotFound, length: 0) + for _ in 0 ..< occurrence { + found = text.range(of: fragment, range: NSRange(location: searchFrom, length: text.length - searchFrom)) + try #require(found.location != NSNotFound, "no \(fragment)") + searchFrom = NSMaxRange(found) + } + return found + } + + private func hex(of fragment: String, occurrence: Int = 1, in textView: NSTextView) throws -> String? { + let range = try range(of: fragment, occurrence: occurrence, in: textView) + let color = textView.textStorage?.attribute(.foregroundColor, at: range.location, effectiveRange: nil) as? NSColor + return ThemeTestFixture.hex(color) + } + + /// The table alias, a name the user wrote. + private func aliasHex(in textView: NSTextView) throws -> String? { + let location = try range(of: " o ", in: textView).location + 1 + let color = textView.textStorage?.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor + return ThemeTestFixture.hex(color) + } + + private func selectionHex(of textView: NSTextView) -> String? { + ThemeTestFixture.hex(textView.selectedTextAttributes[.backgroundColor] as? NSColor) + } + + // MARK: - Text view colors + + @Test("Under the default pair the preview keeps the text view's own system colors") + func defaultPairKeepsSystemColors() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = host() + let textView = try textView(in: window) + let scrollView = try #require(textView.enclosingScrollView) + + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(scrollView.backgroundColor === NSColor.textBackgroundColor) + #expect(selectionHex(of: textView) == ThemeTestFixture.hex(.selectedTextBackgroundColor)) + withExtendedLifetime(window) {} + } + } + + @Test("A theme's editor selection reaches the preview, and the system's comes back after") + func selectionFollowsTheTheme() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = host() + let textView = try textView(in: window) + + try ThemeTestFixture.adopt([.editorSelection: "#FF00FF"], appearance: .light) + #expect(selectionHex(of: textView) == "#FF00FF") + + ThemeTestFixture.adopt(.builtIn, appearance: .light) + #expect(selectionHex(of: textView) == ThemeTestFixture.hex(.selectedTextBackgroundColor)) + withExtendedLifetime(window) {} + } + } + + @Test("The scroll view behind a short query takes the editor background with the text view") + func scrollViewFollowsTheEditorBackground() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = host("SELECT 1") + let textView = try textView(in: window) + let scrollView = try #require(textView.enclosingScrollView) + + try ThemeTestFixture.adopt([.editorBackground: ThemeTestFixture.editorHex], appearance: .light) + window.contentView?.layoutSubtreeIfNeeded() + + #expect(ThemeTestFixture.hex(textView.backgroundColor) == ThemeTestFixture.editorHex) + #expect(ThemeTestFixture.hex(scrollView.backgroundColor) == ThemeTestFixture.editorHex) + withExtendedLifetime(window) {} + } + } + + // MARK: - Syntax + + @Test("Each token draws in the slot the SQL editor gives it") + func tokensTakeTheEditorsSlots() throws { + try ThemeTestFixture.adopting(Self.syntaxTheme, appearance: .light) { + let window = host() + let textView = try textView(in: window) + let theme = Self.syntaxTheme + + #expect(try hex(of: "-- note", in: textView) == theme[.syntaxComment]) + #expect(try hex(of: "SELECT", occurrence: 1, in: textView) == theme[.syntaxComment]) + #expect(try hex(of: "SELECT", occurrence: 2, in: textView) == theme[.syntaxKeyword]) + #expect(try hex(of: "COUNT", in: textView) == theme[.syntaxFunction]) + #expect(try hex(of: "NULL", in: textView) == theme[.syntaxNull]) + #expect(try hex(of: "'a'", in: textView) == theme[.syntaxString]) + #expect(try hex(of: "2", occurrence: 1, in: textView) == theme[.syntaxNumber]) + #expect(try hex(of: "=", in: textView) == theme[.syntaxOperator]) + #expect(try aliasHex(in: textView) == theme[.editorText]) + withExtendedLifetime(window) {} + } + } + + @Test("Keywords are bold, as the editor draws them") + func keywordsAreBold() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = host() + let textView = try textView(in: window) + let keyword = try range(of: "SELECT", occurrence: 2, in: textView) + let alias = try range(of: " o ", in: textView) + + let keywordFont = try #require(textView.textStorage?.attribute(.font, at: keyword.location, effectiveRange: nil) as? NSFont) + let aliasFont = try #require(textView.textStorage?.attribute(.font, at: alias.location + 1, effectiveRange: nil) as? NSFont) + #expect(keywordFont.fontDescriptor.symbolicTraits.contains(.bold)) + #expect(!aliasFont.fontDescriptor.symbolicTraits.contains(.bold)) + withExtendedLifetime(window) {} + } + } + + /// The text view's text color covers every character, so a theme that sets it would leave the + /// statement one color unless the syntax is applied again after it. + @Test("A theme change keeps the syntax colors, in both directions") + func themeChangeKeepsTheSyntax() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = host() + let textView = try textView(in: window) + let defaultKeyword = try hex(of: "SELECT", occurrence: 2, in: textView) + + try ThemeTestFixture.adopt(Self.syntaxTheme, appearance: .light) + window.contentView?.layoutSubtreeIfNeeded() + #expect(try hex(of: "SELECT", occurrence: 2, in: textView) == Self.syntaxTheme[.syntaxKeyword]) + #expect(try aliasHex(in: textView) == Self.syntaxTheme[.editorText]) + + ThemeTestFixture.adopt(.builtIn, appearance: .light) + window.contentView?.layoutSubtreeIfNeeded() + #expect(try hex(of: "SELECT", occurrence: 2, in: textView) == defaultKeyword) + withExtendedLifetime(window) {} + } + } + + // MARK: - Statement preview font + + @Test("The rich statement preview uses the editor font setting") + func richPreviewUsesTheEditorFont() { + let previous = AppSettingsManager.shared.typography + ThemeEngine.shared.apply(typography: TypographySettings(editorFontFamily: "Menlo", editorFontSize: 17)) + defer { ThemeEngine.shared.apply(typography: previous) } + + let font = SQLStatementPreview.makeConfiguration().appearance.font + #expect(font == ThemeEngine.shared.editorFonts.font) + #expect(font != NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)) + } +} diff --git a/TableProTests/Views/Connection/CommandPreviewThemeTests.swift b/TableProTests/Views/Connection/CommandPreviewThemeTests.swift new file mode 100644 index 0000000000..4750592f0b --- /dev/null +++ b/TableProTests/Views/Connection/CommandPreviewThemeTests.swift @@ -0,0 +1,74 @@ +// +// CommandPreviewThemeTests.swift +// TableProTests +// +// The deep-link sheet's Startup SQL and the tunnel command's Will Run preview are code fields, so +// each sits in a well that wears the editor theme while the form around it stays system. +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProImport +import TableProTheme +import Testing + +@MainActor +struct CommandPreviewThemeTests { + private static let sheetSize = NSSize(width: 420, height: 560) + private static let formSize = NSSize(width: 480, height: 720) + + private func bundle(startupCommands: String?) throws -> ConnectionBundle { + var settings = ExportableConnection( + name: "Shared", + host: "db.example.com", + port: 5_432, + database: "app", + username: "reader", + type: DatabaseType.postgresql.rawValue + ) + settings.startupCommands = startupCommands + return try ConnectionBundle(appVersion: "1.0", connections: [BundleConnection(ref: "c1", settings: settings)]) + } + + private func magentaCount(_ view: some View, size: NSSize) throws -> Int { + let window = ThemeTestFixture.host(view, size: size) + defer { withExtendedLifetime(window) {} } + let snapshot = try #require(ThemeTestFixture.snapshot(window)) + return ThemeTestFixture.count(in: snapshot, where: ThemeTestFixture.isMagenta) + } + + @Test("A shared connection's Startup SQL sits in an editor-themed well") + func startupSQLWearsTheEditorTheme() throws { + let withSQL = DeeplinkImportSheet(bundle: try bundle(startupCommands: "SET search_path TO app;"), onFinished: { _ in }) + let withoutSQL = DeeplinkImportSheet(bundle: try bundle(startupCommands: nil), onFinished: { _ in }) + try ThemeTestFixture.adopting(ThemeTestFixture.pair([.editorBackground: "#FF00FF"]), appearance: .light) { + let themed = try magentaCount(withSQL, size: Self.sheetSize) + let withoutWell = try magentaCount(withoutSQL, size: Self.sheetSize) + #expect(themed > 200) + #expect(withoutWell == 0, "something else in the sheet took the theme") + } + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let unthemed = try magentaCount(withSQL, size: Self.sheetSize) + #expect(unthemed == 0) + } + } + + @Test("The tunnel command's Will Run preview sits in an editor-themed well") + func tunnelPreviewWearsTheEditorTheme() throws { + let coordinator = ConnectionFormCoordinator(connectionId: nil) + coordinator.tunnelCommand.state.config.method = .custom + coordinator.tunnelCommand.state.config.command = "ssh -N -L {port}:{host}:{remotePort} bastion" + try #require(coordinator.tunnelCommandPreview != nil) + let form = Form { TunnelCommandTransportSections(coordinator: coordinator) }.formStyle(.grouped) + + try ThemeTestFixture.adopting(ThemeTestFixture.pair([.editorBackground: "#FF00FF"]), appearance: .light) { + let themed = try magentaCount(form, size: Self.formSize) + #expect(themed > 200) + } + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let unthemed = try magentaCount(form, size: Self.formSize) + #expect(unthemed == 0) + } + } +} diff --git a/TableProTests/Views/DataFiles/DataFileStateThemeTests.swift b/TableProTests/Views/DataFiles/DataFileStateThemeTests.swift new file mode 100644 index 0000000000..c2e7fed6c6 --- /dev/null +++ b/TableProTests/Views/DataFiles/DataFileStateThemeTests.swift @@ -0,0 +1,83 @@ +// +// DataFileStateThemeTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +/// The Data File window's content sits on the window backdrop, and its loading and failure states +/// stand in for the grid, so they take the grid's background while a theme sets it. +@MainActor +struct DataFileStateThemeTests { + private static let size = CGSize(width: 320, height: 240) + private static let body = CGPoint(x: 0.03, y: 0.05) + + private func content(_ controller: DataFileController) -> some View { + DataFileContentView(controller: controller, gridDelegate: DataFileGridDelegate(controller: controller)) + .frame(width: Self.size.width, height: Self.size.height) + } + + private func failedController() async throws -> DataFileController { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("DataFileStateThemeTests-\(UUID().uuidString)") + .appendingPathComponent("missing.csv") + let controller = DataFileController() + controller.load(url: missing, kind: try #require(DataFileKind.classify(missing))) + await controller.waitForPendingWork() + guard case .failed = controller.loadState else { + Issue.record("The missing file loaded as \(controller.loadState)") + return controller + } + return controller + } + + private func drawn(_ view: some View) throws -> NSColor { + try ThemeTestFixture.color(of: view, at: Self.body, colorScheme: .dark) + } + + @Test("The content fills the window backdrop, and only the window slot themes it") + func rootFollowsTheWindowSlot() throws { + let view = content(DataFileController()) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let native = try ThemeTestFixture.filled(NSColor.windowBackgroundColor, colorScheme: .dark) + #expect(ThemeTestFixture.matches(try drawn(view), native)) + + try ThemeTestFixture.adopt([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + let window = try ThemeTestFixture.filled(hex: ThemeTestFixture.windowHex, colorScheme: .dark) + #expect(ThemeTestFixture.matches(try drawn(view), window)) + + try ThemeTestFixture.adopt([.editorBackground: ThemeTestFixture.editorHex], appearance: .dark) + #expect(ThemeTestFixture.matches(try drawn(view), native)) + } + } + + @Test("Opening a file stands in for the grid in the grid's background") + func loadingTakesTheGridBackground() throws { + let view = content(DataFileController()) + try ThemeTestFixture.adopting([.gridBackground: ThemeTestFixture.gridHex], appearance: .dark) { + let grid = try ThemeTestFixture.filled(hex: ThemeTestFixture.gridHex, colorScheme: .dark) + let loading = try drawn(view) + #expect(ThemeTestFixture.matches(loading, grid), "loading drew \(loading), grid \(grid)") + } + } + + @Test("A file that cannot be opened stands in for the grid in the grid's background") + func failureTakesTheGridBackground() async throws { + let controller = try await failedController() + let view = content(controller) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let native = try ThemeTestFixture.filled(NSColor.windowBackgroundColor, colorScheme: .dark) + #expect(ThemeTestFixture.matches(try drawn(view), native), "the default pair moved the failure state") + + try ThemeTestFixture.adopt([.gridBackground: ThemeTestFixture.gridHex], appearance: .dark) + let grid = try ThemeTestFixture.filled(hex: ThemeTestFixture.gridHex, colorScheme: .dark) + let failure = try drawn(view) + #expect(ThemeTestFixture.matches(failure, grid), "failure drew \(failure), grid \(grid)") + } + } +} diff --git a/TableProTests/Views/DetailSurfaceThemeTests.swift b/TableProTests/Views/DetailSurfaceThemeTests.swift index 0fcd31dce2..bcfaca56a6 100644 --- a/TableProTests/Views/DetailSurfaceThemeTests.swift +++ b/TableProTests/Views/DetailSurfaceThemeTests.swift @@ -10,6 +10,7 @@ import AppKit import Foundation import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor @@ -68,14 +69,6 @@ struct DetailSurfaceThemeTests { 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 @@ -95,22 +88,4 @@ struct DetailSurfaceThemeTests { .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 index 5c1a395f06..49e32a55a4 100644 --- a/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift +++ b/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift @@ -9,6 +9,7 @@ import AppKit @testable import TablePro +import TableProTheme import Testing /// A bitmap cache converts through its own color space, so a theme color is held to the same color @@ -113,6 +114,40 @@ struct ERDiagramThemeTests { } } + @Test("A theme that colors only the grid background keeps the header's accent wash over the body") + func backgroundOnlyThemeKeepsHeaderWash() throws { + try Fixture.preservingEngine { + try Fixture.adopt([.gridBackground: "#2A6F4E"], 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 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.hex(palette.nodeFill) == "#2A6F4E") + #expect(palette.headerFill == nil) + #expect(palette.headerWash(cluster: nil) == mainAccentWash(in: appearance)) + #expect(Fixture.matches(body, try reference(palette.nodeFill))) + #expect(!Fixture.matches(header, try reference(palette.nodeFill))) + } + } + + @Test("A header the theme sets apart from the body fills with its own color") + func distinctHeaderFillsItsOwnColor() throws { + try Fixture.preservingEngine { + try Fixture.adopt([.gridBackground: "#2A6F4E", .gridHeaderBackground: "#2A6F4E"], appearance: .light) + #expect(ERDiagramPalette(appearance: try appearance(.aqua)).headerFill == nil) + + try Fixture.adopt([.gridHeaderBackground: "#7A3B8F"], appearance: .light) + #expect(Fixture.hex(ERDiagramPalette(appearance: try appearance(.aqua)).headerFill) == "#7A3B8F") + } + } + @Test("A canvas bakes the theme of its own appearance, not the active one") func paletteFollowsTheCanvasAppearance() throws { try Fixture.preservingEngine { diff --git a/TableProTests/Views/Editor/EditorNativeBackgroundTests.swift b/TableProTests/Views/Editor/EditorNativeBackgroundTests.swift index a9bc761494..ff3b7302cc 100644 --- a/TableProTests/Views/Editor/EditorNativeBackgroundTests.swift +++ b/TableProTests/Views/Editor/EditorNativeBackgroundTests.swift @@ -8,6 +8,7 @@ import AppKit import TableProEditorKit import TableProGrammars import TableProTextEngine +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Editor/EditorThemeBridgeTests.swift b/TableProTests/Views/Editor/EditorThemeBridgeTests.swift index 1a02e7f341..2f3e509a7e 100644 --- a/TableProTests/Views/Editor/EditorThemeBridgeTests.swift +++ b/TableProTests/Views/Editor/EditorThemeBridgeTests.swift @@ -9,6 +9,7 @@ import AppKit @testable import TablePro import TableProEditorKit +import TableProTheme import Testing @MainActor @@ -28,7 +29,8 @@ struct EditorThemeBridgeTests { let theme = makeTheme(.builtIn, appearance: .light) #expect(hex(theme.keywords) == "#9B2393") - #expect(hex(theme.variables) == "#9B2393") + #expect(hex(theme.values) == "#9B2393") + #expect(hex(theme.attributes) == "#9B2393") #expect(hex(theme.strings) == "#C41A16") #expect(hex(theme.numbers) == "#1C00CF") #expect(hex(theme.comments) == "#5D6C79") @@ -42,7 +44,8 @@ struct EditorThemeBridgeTests { let theme = makeTheme(.builtIn, appearance: .dark) #expect(hex(theme.keywords) == "#FC5FA3") - #expect(hex(theme.variables) == "#FC5FA3") + #expect(hex(theme.values) == "#FC5FA3") + #expect(hex(theme.attributes) == "#FC5FA3") #expect(hex(theme.strings) == "#FC6A5D") #expect(hex(theme.numbers) == "#D0BF69") #expect(hex(theme.comments) == "#6C7986") @@ -86,6 +89,47 @@ struct EditorThemeBridgeTests { } } + /// The grammar tags aliases, parameters and properties as variables. They are names the user + /// wrote, so they draw in the editor text rather than in the NULL color. + @Test("Variables take the editor text, constants keep the NULL slot", arguments: [ThemeAppearance.light, .dark]) + func variablesTakeTheEditorText(appearance: ThemeAppearance) throws { + let defaults = makeTheme(.builtIn, appearance: appearance) + #expect(defaults.variables == defaults.text) + #expect(defaults.variables.color === NSColor.textColor) + #expect(!defaults.variables.bold) + + var custom = BuiltInThemes.default(for: appearance) + custom.overrides[.editorText] = try ThemeColor(validating: "#112233") + custom.overrides[.syntaxNull] = try ThemeColor(validating: "#AA5500") + let pair = appearance == .light + ? ThemePair(light: custom, dark: BuiltInThemes.dark) + : ThemePair(light: BuiltInThemes.light, dark: custom) + let themed = makeTheme(pair, appearance: appearance) + + #expect(hex(themed.variables) == "#112233") + #expect(hex(themed.values) == "#AA5500") + #expect(hex(themed.attributes) == "#AA5500") + } + + @Test("The factory builds the same theme the engine hands the editor") + func factoryMatchesTheEngine() { + let settings = AppSettingsManager.shared.editor + let made = EditorThemeFactory.make( + resolved: ThemeEngine.shared.resolved, + highlightsCurrentLine: settings.highlightCurrentLine, + highlightsCurrentStatement: settings.highlightCurrentStatement + ) + #expect(made == ThemeEngine.shared.makeEditorTheme()) + + let plain = EditorThemeFactory.make( + resolved: ResolvedTheme(definition: BuiltInThemes.light), + highlightsCurrentLine: false, + highlightsCurrentStatement: false + ) + #expect(plain.lineHighlight == .clear) + #expect(plain.statementHighlight == .clear) + } + @Test("The theme's operator and function colours reach the editor") func themeCarriesOperatorAndFunctionColors() { let theme = ThemeEngine.shared.makeEditorTheme() diff --git a/TableProTests/Views/Editor/PaneBarThemeTests.swift b/TableProTests/Views/Editor/PaneBarThemeTests.swift new file mode 100644 index 0000000000..26d5b53bec --- /dev/null +++ b/TableProTests/Views/Editor/PaneBarThemeTests.swift @@ -0,0 +1,106 @@ +// +// PaneBarThemeTests.swift +// TableProTests +// +// A bar over the detail pane keeps the material or fill it always drew until the theme colors the +// window, then draws nothing, so the pane's theme shows through instead of a system band. +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +@MainActor +struct PaneBarThemeTests { + private func assertBar(_ view: some View, at point: CGPoint, native: some ShapeStyle) throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let expected = try ThemeTestFixture.filled(native, colorScheme: .dark) + try #require(expected.alphaComponent > 0.01, "the native style rendered as nothing") + 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([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + let themed = try ThemeTestFixture.color(of: view, at: point, colorScheme: .dark) + #expect(themed.alphaComponent < 0.01, "window theme drew \(themed)") + + try ThemeTestFixture.adopt([ + .editorBackground: ThemeTestFixture.editorHex, + .gridBackground: ThemeTestFixture.gridHex, + ], appearance: .dark) + let unrelated = try ThemeTestFixture.color(of: view, at: point, colorScheme: .dark) + #expect(ThemeTestFixture.matches(unrelated, expected), "content slots drew \(unrelated), native \(expected)") + } + } + + @Test("The query parameter panel keeps its flat fills until the theme colors the window") + func parameterPanel() throws { + let parameters = [QueryParameter(name: "id")] + let panel = QueryParameterPanelView(parameters: .constant(parameters), onDismiss: {}) + .frame(width: 480) + try assertBar(panel, at: CGPoint(x: 0.5, y: 0.05), native: Color(nsColor: .controlBackgroundColor)) + try assertBar(panel, at: CGPoint(x: 0.004, y: 0.95), native: Color(nsColor: .windowBackgroundColor)) + } + + @Test("The query editor bar keeps its material until the theme colors the window") + func queryEditorBar() throws { + let bar = QueryEditorBar( + scope: QueryScopeBarModel( + containers: [], + selectedName: "shop", + entityName: String(localized: "Database"), + isReadOnly: false, + schemaName: nil + ), + commands: QueryCommandAvailability( + isConnected: true, + hasQueryText: true, + isExecuting: false, + isStoppable: true, + hasResults: false, + explainVariants: [], + shortcutHint: { label, _ in label } + ), + isExecuting: false, + vimMode: nil, + showsHistoryTip: false, + onAIAction: { _ in }, + onRun: {}, + onRunAllStatements: {}, + onRunWithoutLimit: {}, + onStop: {}, + onExplain: { _ in }, + onFormat: {}, + onSaveAsFavorite: {}, + onClearQuery: {}, + onClearResults: {}, + onContainerChanged: { _ in } + ) + .frame(width: 900) + try assertBar(bar, at: CGPoint(x: 0.5, y: 0.5), native: .bar) + } + + @Test("The object source bar keeps its material until the theme colors the window") + func objectSourceBar() throws { + let view = ObjectSourceTabView( + connectionId: UUID(), + databaseType: .mysql, + objectRef: DatabaseObjectRef(kind: .view, name: "active_orders", database: "shop"), + onOpenInEditor: { _ in } + ) + .frame(width: 480, height: 240) + try assertBar(view, at: CGPoint(x: 0.5, y: 0.02), native: .bar) + } + + @Test("The loaded source's toolbar keeps its material until the theme colors the window") + func objectSourceToolbar() throws { + let view = ObjectSourceView( + source: "CREATE VIEW active_orders AS SELECT 1", + databaseType: .mysql, + exportFileName: "active_orders.sql" + ) + .frame(width: 480, height: 240) + try assertBar(view, at: CGPoint(x: 0.5, y: 0.02), native: .bar) + } +} diff --git a/TableProTests/Views/Filter/FilterPanelBackgroundTests.swift b/TableProTests/Views/Filter/FilterPanelBackgroundTests.swift new file mode 100644 index 0000000000..147804e8a5 --- /dev/null +++ b/TableProTests/Views/Filter/FilterPanelBackgroundTests.swift @@ -0,0 +1,64 @@ +// +// FilterPanelBackgroundTests.swift +// TableProTests +// +// The filter bar is chrome: it keeps its system fills until the window backdrop is themed, then +// draws nothing and shows the themed pane behind it. It used to paint `window.background` itself, +// which read the theme from a chrome file. +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +@MainActor +struct FilterPanelBackgroundTests { + private var panel: some View { + FilterPanelView( + state: .constant(TabFilterState()), + configuration: FilterPanelConfiguration( + columns: [], + primaryKeyColumn: nil, + offersRawFilter: false, + caseMatching: .inMemory, + sqlPreview: nil, + presetStore: nil + ), + actions: DataFileController() + ) + .frame(width: 480) + } + + private static let headerPoint = CGPoint(x: 0.004, y: 0.1) + + @Test("Under the default pair the header keeps its control background") + func defaultIsNative() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let drawn = try ThemeTestFixture.color(of: panel, at: Self.headerPoint, colorScheme: .dark) + let native = try ThemeTestFixture.filled(.controlBackgroundColor, colorScheme: .dark) + #expect(ThemeTestFixture.matches(drawn, native), "drew \(drawn), native \(native)") + } + } + + @Test("Under a themed window backdrop the bar draws nothing, so the pane's theme shows through") + func themedBackdropDrawsNothing() throws { + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + let drawn = try ThemeTestFixture.color(of: panel, at: Self.headerPoint, colorScheme: .dark) + #expect(drawn.alphaComponent < 0.02, "drew \(drawn)") + } + } + + @Test("A theme that leaves the window alone leaves the bar's system fill") + func otherSlotsKeepTheNativeFill() throws { + try ThemeTestFixture.adopting( + [.editorBackground: ThemeTestFixture.editorHex, .gridBackground: ThemeTestFixture.gridHex], + appearance: .dark + ) { + let drawn = try ThemeTestFixture.color(of: panel, at: Self.headerPoint, colorScheme: .dark) + let native = try ThemeTestFixture.filled(.controlBackgroundColor, colorScheme: .dark) + #expect(ThemeTestFixture.matches(drawn, native), "drew \(drawn), native \(native)") + } + } +} diff --git a/TableProTests/Views/Main/DetailPaneBackdropTests.swift b/TableProTests/Views/Main/DetailPaneBackdropTests.swift index ac871a7d4f..6b8514eff3 100644 --- a/TableProTests/Views/Main/DetailPaneBackdropTests.swift +++ b/TableProTests/Views/Main/DetailPaneBackdropTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Main/EditorTabStripSurfacesTests.swift b/TableProTests/Views/Main/EditorTabStripSurfacesTests.swift index 10a4efadcf..fb448cbd59 100644 --- a/TableProTests/Views/Main/EditorTabStripSurfacesTests.swift +++ b/TableProTests/Views/Main/EditorTabStripSurfacesTests.swift @@ -2,10 +2,10 @@ // EditorTabStripSurfacesTests.swift // TableProTests // -// The strip's track and selected tab are opaque fills, so what these pin is the one -// property the selection rests on: that it can never resolve at or below the track, in any -// appearance. It used to be a pair of Liquid Glass tints, whose step was a function of the -// wallpaper behind the window and inverted outright on macOS 27 (#2439). +// What these pin is the one property the selection rests on: that it can never resolve at or +// below the track, over the system window or a themed one. It used to be a pair of Liquid Glass +// tints, whose step was a function of the wallpaper behind the window and inverted outright on +// macOS 27 (#2439). // import AppKit @@ -56,6 +56,17 @@ struct EditorTabStripSurfacesTests { return (max(first, second) + 0.05) / (min(first, second) + 0.05) } + /// The track over a window that keeps its system color. + private static let systemTrack = EditorTabStripPalette.trackFill(overThemedBackdrop: false) + + /// Window colors a theme draws behind the strip: Solarized Light, GitHub Light, plain white and Dracula. + nonisolated private static let themedBackdrops: [(NSAppearance.Name, String)] = [ + (.aqua, "#FDF6E3"), + (.aqua, "#F6F8FA"), + (.aqua, "#FFFFFF"), + (.darkAqua, "#282A36"), + ] + private func resolve(_ appearance: NSAppearance.Name, _ body: () -> T) -> T? { var result: T? NSAppearance(named: appearance)?.performAsCurrentDrawingAppearance { result = body() } @@ -74,7 +85,7 @@ struct EditorTabStripSurfacesTests { func selectedFillIsNotTheTrackFill(appearance: NSAppearance.Name) { let pair = resolve(appearance) { ( - NSColor(EditorTabStripPalette.trackFill).usingColorSpace(.sRGB), + NSColor(Self.systemTrack).usingColorSpace(.sRGB), NSColor(EditorTabStripPalette.selectedFill).usingColorSpace(.sRGB) ) } @@ -96,7 +107,7 @@ struct EditorTabStripSurfacesTests { ) func opaqueSelectionStandsClearOfTheTrack(appearance: NSAppearance.Name) { let measured = resolve(appearance) { () -> CGFloat in - let track = self.composite(EditorTabStripPalette.trackFill, over: .windowBackgroundColor) + let track = self.composite(Self.systemTrack, over: .windowBackgroundColor) let selected = self.composite(EditorTabStripPalette.selectedFill, over: track) return self.contrast(selected, track) } @@ -123,7 +134,7 @@ struct EditorTabStripSurfacesTests { ) func selectionNeverInverts(appearance: NSAppearance.Name) { let measured = resolve(appearance) { () -> (CGFloat, CGFloat) in - let track = self.composite(EditorTabStripPalette.trackFill, over: .windowBackgroundColor) + let track = self.composite(Self.systemTrack, over: .windowBackgroundColor) let selected = self.composite(EditorTabStripPalette.selectedFill, over: track) return (track.relativeLuminance, selected.relativeLuminance) } @@ -143,7 +154,7 @@ struct EditorTabStripSurfacesTests { ) func hoverNeverOutreadsTheSelection(appearance: NSAppearance.Name) { let measured = resolve(appearance) { () -> (CGFloat, CGFloat) in - let track = self.composite(EditorTabStripPalette.trackFill, over: .windowBackgroundColor) + let track = self.composite(Self.systemTrack, over: .windowBackgroundColor) let hovered = self.composite(EditorTabStripPalette.hoverFill, over: track) let selected = self.composite(EditorTabStripPalette.selectedFill, over: track) return ( @@ -171,6 +182,31 @@ struct EditorTabStripSurfacesTests { #expect(EditorTabStripLayout.trackHeight < EditorTabStripLayout.bandHeight) } + // MARK: - Over a themed window + + /// The window color itself was the track here before, which left the selected tab a step of 1.00 to + /// 1.08 under a light theme. The system's own tab bar measures 1.161 in light. + @Test( + "Over a themed window the selected tab keeps a step above the track", + arguments: EditorTabStripSurfacesTests.themedBackdrops + ) + func selectionStandsClearOverAThemedWindow(appearance: NSAppearance.Name, windowHex: String) { + let measured = resolve(appearance) { () -> (CGFloat, CGFloat, CGFloat) in + let track = self.composite( + EditorTabStripPalette.trackFill(overThemedBackdrop: true), + over: HexColor.color(windowHex) + ) + let selected = self.composite(EditorTabStripPalette.selectedFill, over: track) + return (self.contrast(selected, track), track.relativeLuminance, selected.relativeLuminance) + } + guard let measured else { + Issue.record("The strip's fills did not resolve") + return + } + #expect(measured.0 > 1.16, "step \(measured.0) over \(windowHex)") + #expect(measured.2 > measured.1) + } + // MARK: - Leaving glass behind @Test("Either accessibility setting alone takes the strip off glass") diff --git a/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift b/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift index e3d13ed26a..2e23c211a7 100644 --- a/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift +++ b/TableProTests/Views/Main/EditorTabStripTrackThemeTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor @@ -23,41 +24,37 @@ struct EditorTabStripTrackThemeTests { try resolved(Color(nsColor: .unemphasizedSelectedContentBackgroundColor), in: name) } + private var isOverThemedBackdrop: Bool { + EditorTabStripPalette.isOverThemedBackdrop(highContrast: false) + } + @Test("The default pair keeps the system track in both appearances") func defaultPairKeepsTheSystemTrack() throws { try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + #expect(!isOverThemedBackdrop) 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)") + let track = try resolved(EditorTabStripPalette.trackFill(overThemedBackdrop: isOverThemedBackdrop), in: name) + #expect(ThemeTestFixture.matches(track, try native(in: name)), "\(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 { + @Test("Where the window takes the theme, a window theme puts the track over it, unless Increase Contrast is on") + func windowThemeMovesTheTrack() 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)") + #expect(isOverThemedBackdrop == EditorTabStripPalette.followsWindowBackdrop) + #expect(!EditorTabStripPalette.isOverThemedBackdrop(highContrast: true)) } } - @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("Over a themed window the track is a translucent system fill, never the theme's own color") + func themedTrackIsATranslucentSystemFill() throws { + guard #available(macOS 14.0, *) else { return } + for name in [NSAppearance.Name.aqua, .darkAqua] { + let track = try resolved(EditorTabStripPalette.trackFill(overThemedBackdrop: true), in: name) + let fill = try resolved(Color(nsColor: .secondarySystemFill), in: name) + #expect(ThemeTestFixture.matches(track, fill), "\(name.rawValue) drew \(track)") + #expect(track.alphaComponent < 0.5, "\(name.rawValue) drew \(track)") } } @@ -69,7 +66,7 @@ struct EditorTabStripTrackThemeTests { } /// 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") + @Test("The track moves exactly when a content window's backdrop takes the theme") func trackMatchesTheWindowBackdrop() throws { let windowTheme = try ThemeTestFixture.pair([.windowBackdrop: ThemeTestFixture.windowHex], on: .dark) let editorTheme = try ThemeTestFixture.pair([.editorBackground: ThemeTestFixture.editorHex], on: .dark) @@ -88,9 +85,7 @@ struct EditorTabStripTrackThemeTests { 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)") + #expect(windowThemed == isOverThemedBackdrop, "\(theme.dark.id): window themed \(windowThemed)") withExtendedLifetime(backdrop) {} } } @@ -102,26 +97,24 @@ struct EditorTabStripTrackThemeTests { .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)") + #expect(!isOverThemedBackdrop) } } @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)") + let system = try Self.center(of: Self.host(Color(nsColor: .unemphasizedSelectedContentBackgroundColor))) + #expect(ThemeTestFixture.matches(before, system), "default drew \(before), system \(system)") + 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)") - } + let expected = try Self.center(of: Self.host( + Color(nsColor: EditorTabStripPalette.trackColor(overThemedBackdrop: EditorTabStripPalette.followsWindowBackdrop)) + )) + #expect(ThemeTestFixture.matches(after, expected), "themed drew \(after), expected \(expected)") } } diff --git a/TableProTests/Views/Main/GridStandInBackgroundTests.swift b/TableProTests/Views/Main/GridStandInBackgroundTests.swift index 8a76fb7b79..82be5179ec 100644 --- a/TableProTests/Views/Main/GridStandInBackgroundTests.swift +++ b/TableProTests/Views/Main/GridStandInBackgroundTests.swift @@ -7,6 +7,7 @@ import AppKit import SwiftUI @testable import TablePro import TableProPluginKit +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/QueryInsights/QueryInsightsChartThemeTests.swift b/TableProTests/Views/QueryInsights/QueryInsightsChartThemeTests.swift new file mode 100644 index 0000000000..9d9ba4cc06 --- /dev/null +++ b/TableProTests/Views/QueryInsights/QueryInsightsChartThemeTests.swift @@ -0,0 +1,57 @@ +// +// QueryInsightsChartThemeTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +/// A chart plot takes the grid's lines and background, as the result chart does, and draws the +/// default grid line and no plot fill while the theme leaves those slots out. +@MainActor +struct QueryInsightsChartThemeTests { + private static let size = CGSize(width: 480, height: 260) + + private var chart: some View { + let start = Date(timeIntervalSince1970: 1_760_000_000) + let buckets = (0 ..< 6).map { day in + QueryInsightsActivityBucket( + date: start.addingTimeInterval(Double(day) * 86_400), + totalCount: 4 + day, + failedCount: day % 2 + ) + } + return QueryInsightsActivityChart(buckets: buckets, granularity: .daily) + .frame(width: Self.size.width, height: Self.size.height) + } + + private func count(_ predicate: (NSColor) -> Bool) throws -> Int { + let rep = try #require(ThemeTestFixture.render(chart, colorScheme: .dark, scale: 2)) + return ThemeTestFixture.count(in: rep, where: predicate) + } + + @Test("The plot takes the grid background only while the theme sets it") + func plotTakesTheGridBackground() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let grid = try ThemeTestFixture.filled(hex: ThemeTestFixture.gridHex, colorScheme: .dark) + let isGrid: (NSColor) -> Bool = { ThemeTestFixture.matches($0, grid, tolerance: 0.01) } + #expect(try count(isGrid) == 0) + + try ThemeTestFixture.adopt([.gridBackground: ThemeTestFixture.gridHex], appearance: .dark) + #expect(try count(isGrid) > 10_000) + } + } + + @Test("The grid lines take the grid line color only while the theme sets it") + func gridLinesTakeTheGridLineColor() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + #expect(try count(ThemeTestFixture.isMagenta) == 0) + + try ThemeTestFixture.adopt([.gridLine: "#FF00FF"], appearance: .dark) + #expect(try count(ThemeTestFixture.isMagenta) > 100) + } + } +} diff --git a/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift b/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift index 595f713920..bdb03909d7 100644 --- a/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift +++ b/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Results/CellOverlayAppearanceTests.swift b/TableProTests/Views/Results/CellOverlayAppearanceTests.swift index d3aabe4244..08055ac98b 100644 --- a/TableProTests/Views/Results/CellOverlayAppearanceTests.swift +++ b/TableProTests/Views/Results/CellOverlayAppearanceTests.swift @@ -4,9 +4,19 @@ // import AppKit +import SwiftUI @testable import TablePro +import TableProPluginKit +import TableProTheme import Testing +@MainActor +private final class OverlayLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + /// `appAppearanceChangeRepaintsAnOpenOverlay` assigns `NSApp.appearance`, which is global. What keeps /// that from reaching a suite running in parallel is that the body is synchronous and `@MainActor`, /// so nothing else on the main actor can interleave before the `defer` restores it. `.serialized` @@ -14,7 +24,7 @@ import Testing @MainActor struct CellOverlayAppearanceTests { private func makeContainer() -> CellOverlayContainerView { - CellOverlayBase.makeContainer(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + CellOverlayBase.makeContainer(frame: NSRect(x: 0, y: 0, width: 120, height: 24), scope: .live) } private func components(of color: CGColor?) throws -> (brightness: CGFloat, alpha: CGFloat) { @@ -117,4 +127,121 @@ struct CellOverlayAppearanceTests { #expect(themed.blueComponent < 0.1) #expect(container.layer?.borderColor != nativeBorder) } + + // MARK: - The value inside the overlay + + private struct OverlayGrid { + let window: NSWindow + let tableView: KeyHandlingTableView + let coordinator: TableViewCoordinator + } + + /// A grid with one row, on `scope`, in a window an overlay can take first responder in. + private func makeGrid(scope: ThemeScope) -> OverlayGrid { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: OverlayLayoutPersister(), + themeScope: scope + ) + let tableRows = TableRows.from( + queryRows: [[PluginCellValue.text("value")]], + columns: ["name"], + columnTypes: [ColumnType.text(rawType: "TEXT")] + ) + coordinator.tableRowsProvider = { tableRows } + coordinator.rebuildColumnMetadataCache(from: tableRows) + coordinator.updateCache() + + let tableView = KeyHandlingTableView(frame: NSRect(x: 0, y: 0, width: 300, height: 120)) + tableView.rowHeight = 21 + tableView.coordinator = coordinator + tableView.dataSource = coordinator + tableView.delegate = coordinator + coordinator.tableView = tableView + coordinator.columnPool.reconcile( + tableView: tableView, + schema: coordinator.identitySchema, + columnTypes: tableRows.columnTypes, + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + firstClickSortDirection: .ascending, + widthCalculator: { _, _ in 200 } + ) + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 300, height: 120)) + scrollView.documentView = tableView + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 120), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentView = scrollView + tableView.reloadData() + tableView.layoutSubtreeIfNeeded() + return OverlayGrid(window: window, tableView: tableView, coordinator: coordinator) + } + + private func openEditor(on grid: OverlayGrid) throws -> (CellOverlayEditor, NSTextView) { + let column = try #require(grid.coordinator.tableColumnIndex(forDisplayPosition: 0)) + let editor = CellOverlayEditor() + editor.show(in: grid.tableView, row: 0, column: column, columnIndex: 0, value: "value") + let container = try #require(editor.containerView) + let scrollView = try #require(container.subviews.first as? NSScrollView) + let textView = try #require(scrollView.documentView as? NSTextView) + return (editor, textView) + } + + @Test("Under the default theme the inline editor keeps the system text colors") + func overlayEditorDefaultIsNative() throws { + let grid = makeGrid(scope: .fixed(BuiltInThemes.light)) + let (editor, textView) = try openEditor(on: grid) + defer { editor.dismiss(commit: false) } + + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(textView.textColor === NSColor.labelColor) + let aqua = try #require(NSAppearance(named: .aqua)) + #expect(ThemeTestFixture.hex(editor.containerView?.layer?.backgroundColor) + == ThemeTestFixture.hex(ThemeTestFixture.baked(.textBackgroundColor, in: aqua))) + withExtendedLifetime(grid.window) {} + } + + /// The box used to be the system's text background with white text inside a theme's border, on + /// a themed grid. + @Test("Under a theme the inline editor and viewer take the editor's colors, through the grid's scope") + func overlayTakesTheEditorColors() throws { + var theme = try ThemeTestFixture.theme(.light, [ + .editorBackground: "#282A36", + .editorText: "#F8F8F2", + .editorCursor: "#FF79C6", + .editorSelection: "#44475A", + ]) + theme.id = "test.overlay-editor" + let grid = makeGrid(scope: .fixed(theme)) + let (editor, textView) = try openEditor(on: grid) + defer { editor.dismiss(commit: false) } + + #expect(ThemeTestFixture.hex(textView.backgroundColor) == "#282A36") + #expect(ThemeTestFixture.hex(textView.textColor) == "#F8F8F2") + #expect(ThemeTestFixture.hex(textView.insertionPointColor) == "#FF79C6") + #expect(ThemeTestFixture.hex(textView.selectedTextAttributes[.backgroundColor] as? NSColor) == "#44475A") + #expect(ThemeTestFixture.hex(editor.containerView?.layer?.backgroundColor) == "#282A36") + #expect(ThemeTestFixture.hex((textView.enclosingScrollView?.backgroundColor)) == "#282A36") + + let viewer = CellOverlayViewer() + let column = try #require(grid.coordinator.tableColumnIndex(forDisplayPosition: 0)) + viewer.show(in: grid.tableView, row: 0, column: column, columnIndex: 0, value: "value") + defer { viewer.dismiss() } + let viewerText = try #require( + (viewer.containerView?.subviews.first as? NSScrollView)?.documentView as? NSTextView + ) + #expect(ThemeTestFixture.hex(viewerText.backgroundColor) == "#282A36") + #expect(ThemeTestFixture.hex(viewerText.textColor) == "#F8F8F2") + withExtendedLifetime(grid.window) {} + } } diff --git a/TableProTests/Views/Results/DataGridBodyChromeTests.swift b/TableProTests/Views/Results/DataGridBodyChromeTests.swift index 8b6ada0705..78f2c18cff 100644 --- a/TableProTests/Views/Results/DataGridBodyChromeTests.swift +++ b/TableProTests/Views/Results/DataGridBodyChromeTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI import TableProPluginKit +import TableProTheme import Testing @testable import TablePro @@ -161,7 +162,7 @@ struct DataGridBodyChromeTests { let stock = NSTableView() tableView.gridColor = .systemRed tableView.backgroundColor = .systemRed - DataGridBodyChrome.applyThemeColors(to: tableView) + DataGridBodyChrome.applyThemeColors(to: tableView, scope: .live) #expect(tableView.gridColor == .separatorColor) #expect(tableView.gridColor == stock.gridColor) @@ -181,7 +182,7 @@ struct DataGridBodyChromeTests { engine.adopt(ThemeSelection(pair: ThemePair(light: theme, dark: BuiltInThemes.dark), effectiveAppearance: .light)) let tableView = NSTableView() - DataGridBodyChrome.applyThemeColors(to: tableView) + DataGridBodyChrome.applyThemeColors(to: tableView, scope: .live) #expect(tableView.gridColor == engine.resolved[.gridLine]) #expect(tableView.backgroundColor == engine.resolved[.gridBackground]) @@ -282,7 +283,7 @@ struct DataGridBodyChromeTests { context.cgContext.translateBy(x: 0, y: bounds.height) context.cgContext.scaleBy(x: 1, y: -1) dark.performAsCurrentDrawingAppearance { - DataGridBodyChrome.drawTableBackground(in: bounds, of: grid.tableView) + DataGridBodyChrome.drawTableBackground(in: bounds, of: grid.tableView, scope: .live) } NSGraphicsContext.restoreGraphicsState() diff --git a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift index 1565483a5c..1523315e4f 100644 --- a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift +++ b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift @@ -10,6 +10,7 @@ import AppKit import SwiftUI import TableProPluginKit +import TableProTheme import Testing @testable import TablePro diff --git a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift index ac98961d50..c90f6ae61f 100644 --- a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift +++ b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor @@ -132,8 +133,8 @@ struct DataGridRowTintThemeTests { let tint = try renderedTint(of: harness.rowView) #expect(tint.redComponent > tint.blueComponent) - #expect(Self.deleted.highlighted(highlight).tint == engine.resolved[.gridDeleted]) - #expect(RowVisualState.empty.highlighted(highlight).tint == HighlightColor.blue.washColor) + #expect(Self.deleted.highlighted(highlight).tint(in: .live) == engine.resolved[.gridDeleted]) + #expect(RowVisualState.empty.highlighted(highlight).tint(in: .live) == HighlightColor.blue.washColor) } @Test("A row with no deleted or inserted state stays untinted across a theme change") diff --git a/TableProTests/Views/Results/DataGridThemeDrawingTests.swift b/TableProTests/Views/Results/DataGridThemeDrawingTests.swift index 2009d30c01..bae113116d 100644 --- a/TableProTests/Views/Results/DataGridThemeDrawingTests.swift +++ b/TableProTests/Views/Results/DataGridThemeDrawingTests.swift @@ -10,6 +10,7 @@ import AppKit import SwiftUI import TableProPluginKit +import TableProTheme import Testing @testable import TablePro @@ -175,7 +176,7 @@ struct DataGridThemeDrawingTests { @Test("Under the default theme a selected row draws exactly what NSTableRowView draws", arguments: [true, false]) func defaultThemeSelectionIsNative(emphasized: Bool) throws { try adopting([:]) { - #expect(DataGridBodyChrome.themedSelectionColor(emphasized: emphasized) == nil) + #expect(DataGridBodyChrome.themedSelectionColor(emphasized: emphasized, scope: .live) == nil) let grid = try centerColor(of: selected(DataGridRowView(frame: Self.rowFrame), emphasized: emphasized)) let stock = try centerColor(of: selected(DrawPathRowView(frame: Self.rowFrame), emphasized: emphasized)) @@ -186,11 +187,11 @@ struct DataGridThemeDrawingTests { @Test("Under the default theme the gutter and a selected heading fill with the system selection") func defaultThemeSelectionColors() throws { try adopting([:]) { - #expect(DataGridBodyChrome.selectionColor(emphasized: true) == .selectedContentBackgroundColor) - #expect(DataGridBodyChrome.selectionColor(emphasized: false) == .unemphasizedSelectedContentBackgroundColor) + #expect(DataGridBodyChrome.selectionColor(emphasized: true, scope: .live) == .selectedContentBackgroundColor) + #expect(DataGridBodyChrome.selectionColor(emphasized: false, scope: .live) == .unemphasizedSelectedContentBackgroundColor) let aqua = try #require(NSAppearance(named: .aqua)) - let palette = ThemeEngine.shared.dataGridCellPalette(in: aqua) + let palette = ThemeScope.live.dataGridCellPalette(in: aqua) #expect(palette.selectionText == .alternateSelectedControlTextColor) #expect(palette.rowNumberColor(for: .empty, onEmphasizedSelection: true) == .alternateSelectedControlTextColor) #expect(palette.rowNumberColor(for: .empty, onEmphasizedSelection: false) == palette.rowNumberText) @@ -216,7 +217,7 @@ struct DataGridThemeDrawingTests { let engine = ThemeEngine.shared let selectionText = engine.resolved[.gridSelectionText] let aqua = try #require(NSAppearance(named: .aqua)) - let palette = engine.dataGridCellPalette(in: aqua) + let palette = ThemeScope.live.dataGridCellPalette(in: aqua) let selectedCell = cellAppearance(palette: palette, onEmphasizedSelection: true) let plainCell = cellAppearance(palette: palette, onEmphasizedSelection: false) @@ -225,9 +226,9 @@ struct DataGridThemeDrawingTests { #expect(selectedCell.focusColor == selectionText) #expect(plainCell.textColor == palette.text) #expect(palette.rowNumberColor(for: .empty, onEmphasizedSelection: true) == selectionText) - #expect(DataGridBodyChrome.selectionColor(emphasized: true) == engine.resolved[.gridSelection]) - #expect(DataGridBodyChrome.themedSelectionColor(emphasized: true) == engine.resolved[.gridSelection]) - #expect(DataGridBodyChrome.themedSelectionColor(emphasized: false) == nil) + #expect(DataGridBodyChrome.selectionColor(emphasized: true, scope: .live) == engine.resolved[.gridSelection]) + #expect(DataGridBodyChrome.themedSelectionColor(emphasized: true, scope: .live) == engine.resolved[.gridSelection]) + #expect(DataGridBodyChrome.themedSelectionColor(emphasized: false, scope: .live) == nil) } } @@ -255,10 +256,10 @@ struct DataGridThemeDrawingTests { try adopting([:]) { let cell = SortableHeaderCell(textCell: "name") - #expect(cell.foregroundColor(emphasized: false) == .headerTextColor) - #expect(cell.foregroundColor(emphasized: true) == .alternateSelectedControlTextColor) - #expect(SortableHeaderChrome.lineColor == .separatorColor) - #expect(SortableHeaderChrome.backgroundColor == .controlBackgroundColor) + #expect(cell.foregroundColor(emphasized: false, scope: .live) == .headerTextColor) + #expect(cell.foregroundColor(emphasized: true, scope: .live) == .alternateSelectedControlTextColor) + #expect(SortableHeaderChrome.lineColor(scope: .live) == .separatorColor) + #expect(SortableHeaderChrome.backgroundColor(scope: .live) == .controlBackgroundColor) } } @@ -268,10 +269,10 @@ struct DataGridThemeDrawingTests { let engine = ThemeEngine.shared let cell = SortableHeaderCell(textCell: "name") - #expect(cell.foregroundColor(emphasized: false) == engine.resolved[.gridHeaderText]) - #expect(cell.foregroundColor(emphasized: true) == engine.resolved[.gridSelectionText]) - #expect(SortableHeaderChrome.lineColor == engine.resolved[.gridLine]) - #expect(SortableHeaderChrome.lineColor != .separatorColor) + #expect(cell.foregroundColor(emphasized: false, scope: .live) == engine.resolved[.gridHeaderText]) + #expect(cell.foregroundColor(emphasized: true, scope: .live) == engine.resolved[.gridSelectionText]) + #expect(SortableHeaderChrome.lineColor(scope: .live) == engine.resolved[.gridLine]) + #expect(SortableHeaderChrome.lineColor(scope: .live) != .separatorColor) } } diff --git a/TableProTests/Views/Results/DataGridThemeScopeTests.swift b/TableProTests/Views/Results/DataGridThemeScopeTests.swift new file mode 100644 index 0000000000..b6b1fede22 --- /dev/null +++ b/TableProTests/Views/Results/DataGridThemeScopeTests.swift @@ -0,0 +1,518 @@ +// +// DataGridThemeScopeTests.swift +// TableProTests +// +// A grid draws every color through the scope it is handed, so the theme editor can show a draft in +// a real grid while every other grid keeps the app's theme. Mounted through `DataGridView` itself, +// the way the editor's preview mounts it, and rasterized into sRGB so a theme's hex reads back as +// written. Every case runs on fixed scopes and leaves the shared engine alone. +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import TableProTheme +import Testing + +@MainActor +private final class ScopeLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class ScopedGrid { + private let window: NSWindow + private let host: NSHostingView + private let rows: TableRows + + init(scope: ThemeScope, appearance: NSAppearance.Name = .aqua, rowCount: Int = 4) async { + let columns = ["id", "name"] + rows = TableRows.from( + queryRows: (0 ..< rowCount).map { row in columns.map { PluginCellValue.text("\($0)-\(row)") } }, + columns: columns, + columnTypes: Array(repeating: ColumnType.text(rawType: nil), count: columns.count) + ) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: appearance) + host = NSHostingView(rootView: Self.makeGrid(rows: rows, scope: scope)) + window.contentView = host + await settle() + tableView?.usesAlternatingRowBackgroundColors = true + } + + /// A new root value, which is how the editor hands the preview a new draft. + func show(scope: ThemeScope) async { + host.rootView = Self.makeGrid(rows: rows, scope: scope) + await settle() + } + + func close() { + window.orderOut(nil) + } + + var tableView: KeyHandlingTableView? { + Self.first(KeyHandlingTableView.self, in: host) + } + + var coordinator: TableViewCoordinator? { tableView?.coordinator } + + var header: SortableHeaderView? { tableView?.headerView as? SortableHeaderView } + + func rowView(_ row: Int) throws -> DataGridRowView { + let tableView = try #require(tableView) + return try #require(tableView.rowView(atRow: row, makeIfNecessary: true) as? DataGridRowView) + } + + private static func makeGrid(rows: TableRows, scope: ThemeScope) -> DataGridView { + DataGridView( + tableRowsProvider: { rows }, + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + configuration: DataGridConfiguration(tabType: .query), + layoutPersister: ScopeLayoutPersister(), + selectedRowIndices: .constant([]), + sortState: .constant(SortState()), + columnLayout: .constant(ColumnLayoutState()), + themeScope: scope + ) + } + + private func settle() async { + for _ in 0 ..< 10 { + host.layoutSubtreeIfNeeded() + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + } + + private static func first(_ type: T.Type, in view: NSView) -> T? { + if let match = view as? T { return match } + for subview in view.subviews { + if let found = first(type, in: subview) { return found } + } + return nil + } +} + +/// A view's drawing methods called straight into an sRGB bitmap, the way AppKit calls them, so a +/// theme's hex reads back as written. `cacheDisplay` of a table and its rows goes through the layer +/// tree and the window's color space instead. +@MainActor +private struct Raster { + let rep: NSBitmapImageRep + let bounds: NSRect + let isFlipped: Bool + + init(_ view: NSView, draw: (NSRect) -> Void) throws { + let area = view.bounds + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let cgContext = try #require(CGContext( + data: nil, + width: Int(area.width), + height: Int(area.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + if view.isFlipped { + cgContext.translateBy(x: 0, y: area.height) + cgContext.scaleBy(x: 1, y: -1) + } + cgContext.translateBy(x: -area.minX, y: -area.minY) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: cgContext, flipped: view.isFlipped) + view.effectiveAppearance.performAsCurrentDrawingAppearance { + draw(area) + } + NSGraphicsContext.restoreGraphicsState() + rep = NSBitmapImageRep(cgImage: try #require(cgContext.makeImage())) + bounds = area + isFlipped = view.isFlipped + } + + /// What a row view lays down, in AppKit's order: background, selection, then the cells. + static func row(_ rowView: DataGridRowView) throws -> Raster { + try Raster(rowView) { rect in + rowView.drawBackground(in: rect) + if rowView.isSelected { + rowView.drawSelection(in: rect) + } + rowView.drawCells(in: rect, of: rowView) + } + } + + static func view(_ view: NSView) throws -> Raster { + try Raster(view) { view.draw($0) } + } + + func color(at point: NSPoint) throws -> NSColor { + let column = Int(point.x - bounds.minX) + let fromTop = isFlipped ? point.y - bounds.minY : bounds.maxY - point.y + return try #require(rep.colorAt(x: column, y: Int(fromTop))?.usingColorSpace(.sRGB)) + } + + func hex(at point: NSPoint) throws -> String { + ThemeColor(try color(at: point)).hex + } + + /// The lightest and darkest pixels in `rect`, by relative luminance. + func luminanceRange(in rect: NSRect) throws -> ClosedRange { + var lowest = Double.infinity + var highest = -Double.infinity + for y in Int(rect.minY) ..< Int(rect.maxY) { + for x in Int(rect.minX) ..< Int(rect.maxX) { + let luminance = ThemeColor(try color(at: NSPoint(x: x, y: y))).relativeLuminance + lowest = min(lowest, luminance) + highest = max(highest, luminance) + } + } + return lowest ... highest + } +} + +/// Held to the expected color drawn the same way, a plain fill through the same context, so the +/// conversion AppKit applies on the way into the bitmap cancels out. Within three levels a channel. +@MainActor +private func isNear(_ drawn: String, _ expected: String) -> Bool { + guard let lhs = ThemeColor(hex: drawn), + let color = ThemeColor(hex: expected)?.nsColor, + let reference = try? Raster(NSView(frame: NSRect(x: 0, y: 0, width: 2, height: 2)), draw: { rect in + color.setFill() + rect.fill() + }).hex(at: NSPoint(x: 1, y: 1)), + let rhs = ThemeColor(hex: reference) else { return false } + return abs(Int(lhs.red) - Int(rhs.red)) <= 3 + && abs(Int(lhs.green) - Int(rhs.green)) <= 3 + && abs(Int(lhs.blue) - Int(rhs.blue)) <= 3 + && abs(Int(lhs.alpha) - Int(rhs.alpha)) <= 3 +} + +@MainActor +struct DataGridThemeScopeTests { + private func scope(_ appearance: ThemeAppearance, _ overrides: [ThemeSlot: String]) throws -> ThemeScope { + var theme = try ThemeTestFixture.theme(appearance, overrides) + theme.id = "test.grid-scope.\(UUID().uuidString)" + return .fixed(theme) + } + + private func rgb(_ color: CGColor) -> String? { + ThemeTestFixture.hex(color).map { String($0.prefix(7)) } + } + + /// Clear of every cell's text: the width past the last column, which the row still paints. + private func backgroundPoint(of rowView: NSView) -> NSPoint { + NSPoint(x: rowView.bounds.maxX - 6, y: rowView.bounds.midY) + } + + // MARK: - The scope reaches every surface + + @Test("A grid on a fixed scope draws that theme on its table, rows, cells, header and overlays") + func fixedScopeReachesEverySurface() async throws { + try #require(!ThemeScope.live.resolved.isOverridden(.gridBackground), "the app's theme colors the grid") + let scope = try scope(.light, [ + .gridBackground: "#FDF6E3", + .gridText: "#112233", + .gridLine: "#AA0000", + .gridHeaderText: "#00AA00", + ]) + let grid = await ScopedGrid(scope: scope) + defer { grid.close() } + let tableView = try #require(grid.tableView) + let coordinator = try #require(grid.coordinator) + let header = try #require(grid.header) + + #expect(coordinator.themeScope === scope) + #expect(tableView.themeScope === scope) + #expect(header.themeScope === scope) + #expect(CellOverlayBase.themeScope(of: tableView) === scope) + #expect(ThemeTestFixture.hex(tableView.backgroundColor) == "#FDF6E3") + #expect(ThemeTestFixture.hex(tableView.gridColor) == "#AA0000") + #expect(ThemeTestFixture.hex(coordinator.cellRegistry.palette.text) == "#112233") + #expect(ThemeTestFixture.hex(SortableHeaderChrome.backgroundColor(scope: header.themeScope)) == "#FDF6E3") + #expect(!ThemeScope.live.resolved.isOverridden(.gridBackground), "the scope leaked into the app's theme") + + let even = try grid.rowView(0) + let odd = try grid.rowView(1) + let evenFill = try Raster.row(even).hex(at: backgroundPoint(of: even)) + let oddFill = try Raster.row(odd).hex(at: backgroundPoint(of: odd)) + #expect(isNear(evenFill, "#FDF6E3"), "drew \(evenFill)") + #expect(isNear(oddFill, "#F3ECDA"), "a background-only theme stripes from its background, drew \(oddFill)") + } + + @Test("A grid on the app's theme keeps the system's own colors") + func liveScopeIsNative() async throws { + try #require(!ThemeScope.live.resolved.isOverridden(.gridBackground), "the app's theme colors the grid") + let grid = await ScopedGrid(scope: .live) + defer { grid.close() } + let tableView = try #require(grid.tableView) + let coordinator = try #require(grid.coordinator) + + #expect(coordinator.themeScope === ThemeScope.live) + #expect(tableView.backgroundColor == NSColor.controlBackgroundColor) + #expect(coordinator.cellRegistry.palette.text == NSColor.labelColor) + } + + @Test("A new scope repaints the grid in place, keeping its coordinator, row views and formatted values") + func newScopeRepaintsInPlace() async throws { + let first = try scope(.light, [.gridBackground: "#FDF6E3", .gridText: "#112233"]) + let second = try scope(.light, [.gridBackground: "#E6E7ED", .gridText: "#343B59"]) + let grid = await ScopedGrid(scope: first) + defer { grid.close() } + let tableView = try #require(grid.tableView) + let coordinator = try #require(grid.coordinator) + let rowView = try grid.rowView(0) + let displayCache = coordinator.displayCache + + await grid.show(scope: second) + + #expect(grid.coordinator === coordinator) + #expect(coordinator.themeScope === second) + #expect(ThemeTestFixture.hex(tableView.backgroundColor) == "#E6E7ED") + #expect(ThemeTestFixture.hex(coordinator.cellRegistry.palette.text) == "#343B59") + #expect(tableView.rowView(atRow: 0, makeIfNecessary: false) === rowView, "the rows were rebuilt") + #expect(coordinator.displayCache === displayCache) + let fill = try Raster.row(rowView).hex(at: backgroundPoint(of: rowView)) + #expect(isNear(fill, "#E6E7ED"), "drew \(fill)") + + await grid.show(scope: .live) + + #expect(coordinator.themeScope === ThemeScope.live) + #expect(tableView.backgroundColor == NSColor.controlBackgroundColor) + #expect(coordinator.cellRegistry.palette.text == NSColor.labelColor) + } + + // MARK: - Derived slots, end to end + + @Test("A background-only light theme stripes the rows, the gutter and the header from its own background") + func backgroundOnlyLightStripes() async throws { + try await assertBackgroundOnlyStripes(appearance: .light, name: .aqua, background: "#FDF6E3", stripe: "#F3ECDA") + } + + @Test("A background-only dark theme stripes the rows, the gutter and the header toward white") + func backgroundOnlyDarkStripes() async throws { + try await assertBackgroundOnlyStripes(appearance: .dark, name: .darkAqua, background: "#282A36", stripe: "#31333E") + } + + private func assertBackgroundOnlyStripes( + appearance: ThemeAppearance, + name: NSAppearance.Name, + background: String, + stripe: String + ) async throws { + let grid = await ScopedGrid(scope: try scope(appearance, [.gridBackground: background]), appearance: name) + defer { grid.close() } + let tableView = try #require(grid.tableView) + let gutter = try #require(grid.coordinator?.rowGutter) + let header = try #require(grid.header) + let even = try grid.rowView(2) + let odd = try grid.rowView(3) + + let evenFill = try Raster.row(even).hex(at: backgroundPoint(of: even)) + let oddFill = try Raster.row(odd).hex(at: backgroundPoint(of: odd)) + #expect(isNear(evenFill, background), "drew \(evenFill)") + #expect(isNear(oddFill, stripe), "drew \(oddFill)") + + let oddBand = gutter.convert(tableView.rect(ofRow: 3), from: tableView) + let gutterFill = try Raster.view(gutter).hex(at: NSPoint(x: 2, y: oddBand.midY)) + #expect(isNear(gutterFill, stripe), "the gutter drew \(gutterFill)") + + #expect(ThemeTestFixture.hex(SortableHeaderChrome.backgroundColor(scope: header.themeScope), in: name) == background) + } + + // MARK: - Header + + /// `NSTableHeaderView.draw` lays AppKit's own band over whatever is beneath it, so a header that + /// filled the theme's color and then let AppKit draw showed AppKit's band. + @Test("A theme's header background is what the grid's header shows, behind and between the headings") + func themedHeaderBandShows() async throws { + let grid = await ScopedGrid( + scope: try scope(.dark, [.gridHeaderBackground: "#21222C", .gridHeaderText: "#F8F8F2"]), + appearance: .darkAqua + ) + defer { grid.close() } + let coordinator = try #require(grid.coordinator) + let header = try #require(grid.header) + let column = try #require(coordinator.tableColumnIndex(forDisplayPosition: 1)) + let heading = header.headerRect(ofColumn: column) + let raster = try Raster.view(header) + let behind = try raster.hex(at: NSPoint(x: heading.minX + 3, y: heading.minY + 3)) + let past = try raster.hex(at: NSPoint(x: header.bounds.maxX - 4, y: header.bounds.minY + 3)) + + #expect(isNear(behind, "#21222C"), "behind a heading drew \(behind)") + #expect(isNear(past, "#21222C"), "past the headings drew \(past)") + } + + // MARK: - Selection text, end to end + + /// The rows' text is the dark ink of the theme's text, never AppKit's white selection text. + @Test("A theme that sets the selection but not its text draws the theme's text on the selection") + func selectionWithoutTextTakesTheGridText() async throws { + let grid = await ScopedGrid(scope: try scope(.light, [.gridSelection: "#EEE8D5", .gridText: "#112233"])) + defer { grid.close() } + let tableView = try #require(grid.tableView) + let coordinator = try #require(grid.coordinator) + let header = try #require(grid.header) + tableView.selectRowIndexes(IndexSet(integer: 1), byExtendingSelection: false) + let rowView = try grid.rowView(1) + rowView.isEmphasized = true + + let cell = try #require(coordinator.cellAppearance(row: 1, columnIndex: 0, onEmphasizedSelection: true)) + #expect(ThemeTestFixture.hex(cell.textColor) == "#112233") + #expect(ThemeTestFixture.hex(coordinator.cellRegistry.palette.rowNumberColor(for: .empty, onEmphasizedSelection: true)) == "#112233") + let heading = SortableHeaderCell(textCell: "name") + #expect(ThemeTestFixture.hex(heading.foregroundColor(emphasized: true, scope: header.themeScope)) == "#112233") + + let raster = try Raster.row(rowView) + let fill = try raster.hex(at: backgroundPoint(of: rowView)) + #expect(isNear(fill, "#EEE8D5"), "drew \(fill)") + let selectionLuminance = try #require(ThemeColor(hex: fill)).relativeLuminance + let firstCell = rowView.convert(tableView.rect(ofColumn: try #require(coordinator.tableColumnIndex(forDisplayPosition: 0))), from: tableView) + let range = try raster.luminanceRange(in: firstCell.intersection(rowView.bounds).insetBy(dx: 2, dy: 2)) + #expect(range.upperBound <= selectionLuminance + 0.03, "a pixel lighter than the selection is white text: \(range)") + #expect(range.lowerBound < 0.2, "the cell drew no dark text: \(range)") + } + + @Test("A selection text with no themed selection leaves AppKit's own selection and its white text") + func selectionTextAloneStaysNative() async throws { + let grid = await ScopedGrid(scope: try scope(.light, [.gridSelectionText: "#00FF00", .gridText: "#112233"])) + defer { grid.close() } + let coordinator = try #require(grid.coordinator) + + let cell = try #require(coordinator.cellAppearance(row: 0, columnIndex: 0, onEmphasizedSelection: true)) + #expect(cell.textColor === NSColor.alternateSelectedControlTextColor) + #expect(DataGridBodyChrome.themedSelectionColor(emphasized: true, scope: coordinator.themeScope) == nil) + } + + // MARK: - Translucent selection + + /// The row used to replace its stripe with the translucent color, the heading replaced the + /// header's band, and the gutter blended over the stripe: three colors for one selection. + @Test("A translucent selection draws one color on the row, the gutter and a selected heading") + func translucentSelectionAgrees() async throws { + let scope = try scope(.dark, [ + .gridBackground: "#282A36", + .gridAlternateRow: "#21222C", + .gridHeaderBackground: "#21222C", + .gridInactiveSelection: "#44475A80", + ]) + let selection = try #require(ThemeColor(hex: "#44475A80")) + let expected = selection.composited(over: try #require(ThemeColor(hex: "#282A36"))).hex + let grid = await ScopedGrid(scope: scope, appearance: .darkAqua) + defer { grid.close() } + let tableView = try #require(grid.tableView) + let coordinator = try #require(grid.coordinator) + let gutter = try #require(coordinator.rowGutter) + let header = try #require(grid.header) + tableView.selectRowIndexes(IndexSet(integer: 1), byExtendingSelection: false) + let rowView = try grid.rowView(1) + rowView.isEmphasized = false + + let fill = try #require(DataGridBodyChrome.themedSelectionColor(emphasized: false, scope: scope)) + #expect(ThemeTestFixture.hex(fill, in: .darkAqua) == expected) + #expect(fill.alphaComponent == 1) + + let row = try Raster.row(rowView).hex(at: backgroundPoint(of: rowView)) + #expect(isNear(row, expected), "the row drew \(row), expected \(expected)") + + let band = gutter.convert(tableView.rect(ofRow: 1), from: tableView) + let strip = try Raster.view(gutter).hex(at: NSPoint(x: 2, y: band.midY)) + #expect(isNear(strip, expected), "the gutter drew \(strip), expected \(expected)") + + let column = try #require(coordinator.tableColumnIndex(forDisplayPosition: 0)) + let cell = try #require(tableView.tableColumns[column].headerCell as? SortableHeaderCell) + cell.isColumnSelected = true + cell.isEmphasized = false + defer { cell.isColumnSelected = false } + let heading = header.headerRect(ofColumn: column) + let headingFill = try Raster.view(header).hex(at: NSPoint(x: heading.minX + 3, y: heading.minY + 3)) + #expect(isNear(headingFill, expected), "the heading drew \(headingFill), expected \(expected)") + } + + // MARK: - Range outline + + @Test("Under the default theme a cell range keeps the selection at 70% as its outline") + func defaultRangeBorderIsNative() throws { + let aqua = try #require(NSAppearance(named: .aqua)) + let resolved = ThemeScope.fixed(BuiltInThemes.light).resolved + let drawn = ThemeTestFixture.baked(GridSelectionOverlay.rangeBorderColor(resolved), in: aqua) + let native = ThemeTestFixture.resolved(in: aqua) { + NSColor.selectedContentBackgroundColor.withAlphaComponent(0.7).cgColor + } + + #expect(ThemeTestFixture.hex(drawn) == ThemeTestFixture.hex(native)) + #expect(abs(drawn.alpha - 0.7) < 0.001) + } + + /// The theme's selection fills the range opaque, so the selection itself at 70% drew no edge. + @Test("Under a theme's selection the range outline is the active cell's border and stands apart from the fill") + func themedRangeBorderStandsApart() throws { + let aqua = try #require(NSAppearance(named: .aqua)) + let withFocus = try scope(.dark, [.gridSelection: "#44475A", .gridFocusBorder: "#BD93F9"]).resolved + let border = ThemeTestFixture.baked(GridSelectionOverlay.rangeBorderColor(withFocus), in: aqua) + #expect(rgb(border) == "#BD93F9") + #expect(abs(border.alpha - 0.7) < 0.001) + + let fill = try #require(ThemeColor(hex: "#44475A")) + let stroke = try #require(ThemeColor(hex: "#BD93F9B3")).composited(over: fill) + #expect(stroke.contrastRatio(with: fill) > 2) + + let withoutFocus = try scope(.dark, [.gridSelection: "#44475A"]).resolved + let accent = ThemeTestFixture.baked(GridSelectionOverlay.rangeBorderColor(withoutFocus), in: aqua) + #expect(rgb(accent) == rgb(ThemeTestFixture.baked(.controlAccentColor, in: aqua))) + } + + // MARK: - Accessory glyphs + + @Test("An accessory glyph is drawn again for another color, and reused for the same one") + func accessoryGlyphFollowsItsColor() throws { + let red = NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + let blue = NSColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + + let first = try #require(DataGridCellAccessoryGlyph.image(for: .foreignKeyEmphasized, color: red)) + let again = try #require(DataGridCellAccessoryGlyph.image(for: .foreignKeyEmphasized, color: red)) + let other = try #require(DataGridCellAccessoryGlyph.image(for: .foreignKeyEmphasized, color: blue)) + + #expect(first.image === again.image) + #expect(other.image !== first.image) + } + + @Test("An emphasized accessory takes the paired selection text, a plain one the secondary label") + func accessoryColorFollowsTheSelectionPair() throws { + let aqua = try #require(NSAppearance(named: .aqua)) + let scope = try scope(.light, [.gridSelection: "#EEE8D5", .gridText: "#112233"]) + let palette = scope.dataGridCellPalette(in: aqua) + func appearance(emphasized: Bool) -> DataGridCellAppearance { + DataGridCellAppearance.resolve( + kind: .foreignKey, + content: DataGridCellContent(displayText: "7", rawValue: "7", placeholder: nil), + state: DataGridCellState( + visualState: .empty, + isFocused: false, + isEditable: true, + isLargeDataset: false, + isCurrentFindMatch: false, + row: 0, + columnIndex: 0 + ), + palette: palette, + nullDisplayString: "NULL", + onEmphasizedSelection: emphasized, + hasOverlay: false + ) + } + + #expect(ThemeTestFixture.hex(appearance(emphasized: true).accessoryColor) == "#112233") + #expect(appearance(emphasized: false).accessoryColor === NSColor.secondaryLabelColor) + } +} diff --git a/TableProTests/Views/Results/ResultChartCanvasTests.swift b/TableProTests/Views/Results/ResultChartCanvasTests.swift index 2390e49312..471fbd8f90 100644 --- a/TableProTests/Views/Results/ResultChartCanvasTests.swift +++ b/TableProTests/Views/Results/ResultChartCanvasTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Results/ResultChartToolbarTests.swift b/TableProTests/Views/Results/ResultChartToolbarTests.swift index 4eefe30da7..82e0325ea9 100644 --- a/TableProTests/Views/Results/ResultChartToolbarTests.swift +++ b/TableProTests/Views/Results/ResultChartToolbarTests.swift @@ -3,8 +3,10 @@ // TableProTests // +import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor @@ -48,6 +50,20 @@ struct ResultChartToolbarTests { #expect(cap.lowerBound < total.lowerBound) } + /// The bar's material reads as a system band over a themed pane; its siblings draw no fill. + @Test("The toolbar keeps its bar material until the window backdrop is themed, then draws nothing") + func barMaterialFollowsTheBackdrop() throws { + let corner = CGPoint(x: 0.002, y: 0.05) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let native = try ThemeTestFixture.color(of: toolbar(width: 600), at: corner, colorScheme: .dark) + #expect(native.alphaComponent > 0.5, "the bar drew no material: \(native)") + } + try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { + let themed = try ThemeTestFixture.color(of: toolbar(width: 600), at: corner, colorScheme: .dark) + #expect(themed.alphaComponent < 0.02, "the bar still drew a band: \(themed)") + } + } + private func columns() -> [ResultChartColumn] { [ ResultChartColumn( @@ -74,6 +90,33 @@ struct ResultChartToolbarTests { ] } + private func toolbar(width: CGFloat) -> some View { + let all = columns() + let configuration = ResultChartConfiguration( + chartType: .bar, + xColumn: all[0].id, + yColumn: all[1].id, + seriesColumn: all[2].id + ) + return ResultChartToolbar( + configuration: .constant(configuration), + columns: all, + resolved: configuration.resolved(in: all), + projection: ResultChartProjection( + points: [], + xAxisKind: .category, + limits: [], + loadedRowCount: 40, + skippedRowCount: 0, + xAxisLabel: "category", + yAxisLabel: "amount", + seriesLabel: nil + ) + ) + .frame(width: width) + .fixedSize(horizontal: false, vertical: true) + } + private func renderedHeight(width: CGFloat) throws -> Int { let all = columns() let configuration = ResultChartConfiguration( diff --git a/TableProTests/Views/Results/ResultMapOverlayPaintTests.swift b/TableProTests/Views/Results/ResultMapOverlayPaintTests.swift new file mode 100644 index 0000000000..1e2ca7c698 --- /dev/null +++ b/TableProTests/Views/Results/ResultMapOverlayPaintTests.swift @@ -0,0 +1,75 @@ +// +// ResultMapOverlayPaintTests.swift +// TableProTests +// +// The map's shape renderers held `controlAccentColor.withAlphaComponent(...)`, a color frozen at +// the Accent and appearance current when it was made, so an Accent change left the shapes on the +// old one until the next projection. Each color is now baked in the map view's own appearance and +// baked again whenever the system colors move. +// + +import AppKit +import MapKit +@testable import TablePro +import Testing + +@MainActor +struct ResultMapOverlayPaintTests { + private func baked(_ color: NSColor, alpha: CGFloat, in name: NSAppearance.Name) throws -> String { + let appearance = try #require(NSAppearance(named: name)) + return try #require(ThemeTestFixture.hex(ThemeTestFixture.resolved(in: appearance) { + color.withAlphaComponent(alpha).cgColor + })) + } + + private func hex(_ color: NSColor?) throws -> String { + try #require(color.flatMap { HexColor.string(from: $0) }) + } + + @Test("A shape's colors are fixed values baked in the map's appearance, at the alphas it always drew") + func paintIsBakedInTheAppearance() throws { + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + let polygon = ResultMapOverlayPaint.polygon(isHighlight: false, in: appearance) + let highlight = ResultMapOverlayPaint.polygon(isHighlight: true, in: appearance) + let line = ResultMapOverlayPaint.polyline(isHighlight: false, in: appearance) + + #expect(try hex(polygon.fill) == baked(.controlAccentColor, alpha: 0.22, in: name)) + #expect(try hex(polygon.stroke) == baked(.controlAccentColor, alpha: 0.9, in: name)) + #expect(try hex(highlight.fill) == baked(.systemOrange, alpha: 0.45, in: name)) + #expect(try hex(highlight.stroke) == baked(.systemOrange, alpha: 1, in: name)) + #expect(try hex(line.stroke) == baked(.controlAccentColor, alpha: 0.9, in: name)) + #expect(line.fill == nil) + #expect(polygon.fill?.type == .componentBased, "a dynamic color would resolve wherever MapKit draws") + } + } + + @Test("The highlight is orange in each appearance's own shade") + func highlightFollowsTheAppearance() throws { + let light = ResultMapOverlayPaint.polygon(isHighlight: true, in: try #require(NSAppearance(named: .aqua))) + let dark = ResultMapOverlayPaint.polygon(isHighlight: true, in: try #require(NSAppearance(named: .darkAqua))) + #expect(try hex(light.stroke) != hex(dark.stroke)) + } + + @Test("A renderer the map asks for is painted in the map view's appearance") + func rendererTakesTheMapsAppearance() throws { + let coordinator = ResultMapCoordinator(onSelect: { _ in }) + let coordinates = [ + CLLocationCoordinate2D(latitude: 0, longitude: 0), + CLLocationCoordinate2D(latitude: 0, longitude: 1), + CLLocationCoordinate2D(latitude: 1, longitude: 1), + ] + let overlay = MKMultiPolygon([MKPolygon(coordinates: coordinates, count: coordinates.count)]) + + for name in [NSAppearance.Name.aqua, .darkAqua] { + let mapView = MKMapView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + mapView.appearance = try #require(NSAppearance(named: name)) + let renderer = try #require(coordinator.mapView(mapView, rendererFor: overlay) as? MKOverlayPathRenderer) + let expected = ResultMapOverlayPaint.polygon(isHighlight: false, in: mapView.effectiveAppearance) + + #expect(try hex(renderer.fillColor) == hex(expected.fill)) + #expect(try hex(renderer.strokeColor) == hex(expected.stroke)) + #expect(renderer.lineWidth == expected.lineWidth) + } + } +} diff --git a/TableProTests/Views/Results/StatusBarChromeTests.swift b/TableProTests/Views/Results/StatusBarChromeTests.swift index 65392423af..8c17e391a7 100644 --- a/TableProTests/Views/Results/StatusBarChromeTests.swift +++ b/TableProTests/Views/Results/StatusBarChromeTests.swift @@ -7,6 +7,7 @@ import AppKit import Foundation import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift index 9c48cb3c36..0561a6cb71 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift @@ -6,6 +6,7 @@ import AppKit import Foundation @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift index df837d40c6..ff2ce3314c 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift @@ -4,6 +4,7 @@ import AppKit import Foundation @testable import TablePro +import TableProTheme import Testing extension GlobalStateSuites { diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineFixture.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineFixture.swift index 4c65bf2676..4ef7813fd6 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineFixture.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineFixture.swift @@ -1,6 +1,7 @@ import AppKit import Foundation @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift b/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift index e4a2ffe8ba..4a2644df92 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift @@ -12,6 +12,7 @@ import Foundation @testable import TablePro import TableProEditorKit import TableProGrammars +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Results/ValueStandInThemeTests.swift b/TableProTests/Views/Results/ValueStandInThemeTests.swift new file mode 100644 index 0000000000..b7a4bb1c31 --- /dev/null +++ b/TableProTests/Views/Results/ValueStandInThemeTests.swift @@ -0,0 +1,110 @@ +// +// ValueStandInThemeTests.swift +// TableProTests +// +// A value's tree or text sits on the editor background under a theme. The states that take its +// place (a parse failure, an empty value, the wait while it parses, no rows) fell through to the +// popover or pane material behind them, and the array editor's raw text was a system text view. +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import TableProTheme +import Testing + +@MainActor +struct ValueStandInThemeTests { + private static let size = CGSize(width: 360, height: 280) + private static let editorBackground = "#282A36" + + /// Under the default pair the state draws nothing of its own, under a theme the editor background. + private func assertStandsIn(_ view: some View, at point: CGPoint = CGPoint(x: 0.03, y: 0.8)) throws { + let framed = view.frame(width: Self.size.width, height: Self.size.height) + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let native = try ThemeTestFixture.color(of: framed, at: point, colorScheme: .light) + #expect(native.alphaComponent < 0.02, "the default pair drew \(native)") + } + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground], appearance: .light) { + let themed = try ThemeTestFixture.color(of: framed, at: point, colorScheme: .light) + let theme = try ThemeTestFixture.filled(hex: Self.editorBackground, colorScheme: .light) + #expect(ThemeTestFixture.matches(themed, theme), "drew \(themed), theme \(theme)") + } + } + + @Test("Invalid JSON in the tree view sits on the editor background") + func invalidJSONTree() throws { + try assertStandsIn(JSONViewerView(text: .constant("{\"a\": "), isEditable: false, initialViewMode: .tree)) + } + + @Test("An empty JSON value in the tree view sits on the editor background") + func emptyJSONTree() throws { + try assertStandsIn(JSONViewerView(text: .constant(""), isEditable: false, initialViewMode: .tree)) + } + + @Test("A PHP value that fails to parse, and one still parsing, sit on the editor background") + func phpStates() throws { + try assertStandsIn(PhpViewerView(rawValue: "a:1:{", initialParseResult: .failed)) + try assertStandsIn(PhpViewerView(rawValue: "a:1:{", initialParseResult: .tooLarge)) + try assertStandsIn(PhpViewerView(rawValue: "a:1:{", initialParseResult: .parsing)) + } + + @Test("The result pane's JSON mode with no rows sits on the editor background") + func resultJSONWithoutRows() throws { + try assertStandsIn(ResultsJsonView( + tableRows: TableRows(), + selectedRowIndices: [], + displayIDs: nil, + deletedRowIDs: [], + valueFilter: GridValueFilterState(), + dataRevision: 0, + displayRevision: 0, + columnLayout: ColumnLayoutState() + )) + } + + // MARK: - Array raw text + + private func rawTextView(in window: NSWindow) throws -> NSTextView { + try #require(ThemeTestFixture.textViews(in: window).first) + } + + /// A literal the list editor cannot read opens straight into raw text. + private var rawArrayEditor: some View { + ArrayValueEditorView( + literal: "{{1,2},{3}}", + allowedValues: [], + isNullable: true, + onCommit: { _ in }, + onDismiss: {} + ) + } + + @Test("The array editor's raw text keeps the system text colors and types what it is given") + func arrayRawTextDefault() throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .light) { + let window = ThemeTestFixture.host(rawArrayEditor, size: NSSize(width: 320, height: 260)) + let textView = try rawTextView(in: window) + + #expect(textView.string == "{{1,2},{3}}") + #expect(textView.backgroundColor === NSColor.textBackgroundColor) + #expect(!textView.isAutomaticQuoteSubstitutionEnabled) + #expect(!textView.isAutomaticDashSubstitutionEnabled) + #expect(textView.isEditable) + withExtendedLifetime(window) {} + } + } + + @Test("The array editor's raw text takes the editor's colors under a theme") + func arrayRawTextThemed() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground, .editorText: "#F8F8F2"], appearance: .light) { + let window = ThemeTestFixture.host(rawArrayEditor, size: NSSize(width: 320, height: 260)) + let textView = try rawTextView(in: window) + + #expect(ThemeTestFixture.hex(textView.backgroundColor) == Self.editorBackground) + #expect(ThemeTestFixture.hex(textView.textColor) == "#F8F8F2") + withExtendedLifetime(window) {} + } + } +} diff --git a/TableProTests/Views/Results/ValueTextThemeTests.swift b/TableProTests/Views/Results/ValueTextThemeTests.swift index 28e4d3609b..cc2ae3935c 100644 --- a/TableProTests/Views/Results/ValueTextThemeTests.swift +++ b/TableProTests/Views/Results/ValueTextThemeTests.swift @@ -9,6 +9,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift b/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift index 85342fcd7c..c9ed846e0b 100644 --- a/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift +++ b/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift @@ -9,6 +9,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor @@ -131,4 +132,69 @@ struct InspectorValueThemeTests { #expect(ThemeTestFixture.matches(corner, themed), "\(corner) against \(themed)") } } + + // MARK: - Binary dump + + private static let dumpSize = CGSize(width: 240, height: 120) + + /// Inside the well's margin, left of the dump's own text. + private func dumpMarginPixel(_ view: some View) throws -> NSColor { + try pixel(of: view, size: Self.dumpSize, x: 3) + } + + private func dump(drawsWell: Bool = true) -> BlobHexEditorView { + BlobHexEditorView(context: context("Hello, world"), drawsWell: drawsWell) + } + + @Test("Under the default pair a read-only binary dump sits straight on the inspector, with no well") + func dumpDefaultHasNoWell() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let margin = try dumpMarginPixel(dump()) + #expect(margin.alphaComponent < 0.02, "drew \(margin)") + } + } + + @Test("Under a theme a read-only binary dump sits in an editor well like the values beside it") + func dumpThemedTakesAWell() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground], appearance: .light) { + let themed = try ThemeTestFixture.filled(hex: Self.editorBackground, colorScheme: .light) + let margin = try dumpMarginPixel(dump()) + #expect(ThemeTestFixture.matches(margin, themed), "\(margin) against \(themed)") + } + } + + @Test("A dump inside the image field's own well draws no second one") + func dumpInsideAWellDrawsNone() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground], appearance: .light) { + let margin = try dumpMarginPixel(dump(drawsWell: false)) + #expect(margin.alphaComponent < 0.02, "drew \(margin)") + } + } + + // MARK: - Geometry map + + /// The map's well, with no map drawn over it: the state a field is in while the map builds. + private func mapWellPixel() throws -> NSColor { + let well = Color.clear.frame(width: 200, height: 100).geometryMapWell() + let rep = try ThemeTestFixture.bitmap(of: well, colorScheme: .light) + return try #require(ThemeTestFixture.pixel(rep, x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)) + } + + @Test("Under the default pair the geometry map's well keeps the text background it always drew") + func geometryWellDefaultIsNative() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let native = try ThemeTestFixture.filled(.textBackgroundColor, colorScheme: .light) + let drawn = try mapWellPixel() + #expect(ThemeTestFixture.matches(drawn, native), "\(drawn) against \(native)") + } + } + + @Test("Under a theme the geometry map's well takes the editor background while it has no map") + func geometryWellFollowsTheTheme() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground], appearance: .light) { + let themed = try ThemeTestFixture.filled(hex: Self.editorBackground, colorScheme: .light) + let drawn = try mapWellPixel() + #expect(ThemeTestFixture.matches(drawn, themed), "\(drawn) against \(themed)") + } + } } diff --git a/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift b/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift index ee21d83041..df9e697b13 100644 --- a/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift +++ b/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift @@ -8,6 +8,7 @@ import AppKit import Foundation import SwiftUI +import TableProTheme import Testing @testable import TablePro diff --git a/TableProTests/Views/SQLCodeFieldThemeTests.swift b/TableProTests/Views/SQLCodeFieldThemeTests.swift index 5c8c71560d..0665b88c19 100644 --- a/TableProTests/Views/SQLCodeFieldThemeTests.swift +++ b/TableProTests/Views/SQLCodeFieldThemeTests.swift @@ -10,6 +10,7 @@ import AppKit import SwiftUI @testable import TablePro import TableProPluginKit +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/ServerDashboard/ServerDashboardGridTests.swift b/TableProTests/Views/ServerDashboard/ServerDashboardGridTests.swift new file mode 100644 index 0000000000..361091c58a --- /dev/null +++ b/TableProTests/Views/ServerDashboard/ServerDashboardGridTests.swift @@ -0,0 +1,210 @@ +// +// ServerDashboardGridTests.swift +// TableProTests +// +// Sessions and slow queries are server records, so they draw in the data grid and take its theme, +// where they were a SwiftUI Table and a plain List that kept the system background under any theme. +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct ServerDashboardGridTests { + private let sessions = [ + DashboardSession(id: "10", user: "app", database: "shop", state: "active", durationSeconds: 5, duration: "5s", query: "SELECT 1"), + DashboardSession(id: "2", user: "admin", database: "shop", state: "idle", durationSeconds: 90, duration: "1m 30s", query: ""), + DashboardSession(id: "7", user: "app", database: "logs", state: "active", durationSeconds: 5, duration: "5s", query: "SELECT 2"), + ] + + private func ids(_ list: [DashboardSession]) -> [String] { + list.map(\.id) + } + + private func sort(_ column: Int, _ direction: SortDirection) -> SortState { + SortState(columns: [SortColumn(columnIndex: column, direction: direction)], source: .user) + } + + private func gridTable(in view: NSView?) -> KeyHandlingTableView? { + guard let view else { return nil } + if let table = view as? KeyHandlingTableView { return table } + for subview in view.subviews { + if let found = gridTable(in: subview) { return found } + } + return nil + } + + private func host(_ view: some View) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: view.frame(width: 800, height: 400)) + window.contentView?.layoutSubtreeIfNeeded() + return window + } + + @Test("Sessions open longest first, with ties kept in the order the server listed them") + func sessionsOpenLongestFirst() { + let sorted = SessionGridColumns.sorted(sessions, by: SessionGridColumns.defaultSort) + #expect(ids(sorted) == ["2", "10", "7"]) + } + + @Test("A header sort orders by that column, PID in numeric order") + func sessionsSortByColumn() { + #expect(ids(SessionGridColumns.sorted(sessions, by: sort(0, .ascending))) == ["2", "7", "10"]) + #expect(ids(SessionGridColumns.sorted(sessions, by: sort(1, .descending))) == ["10", "7", "2"]) + #expect(ids(SessionGridColumns.sorted(sessions, by: sort(SessionGridColumns.durationColumn, .ascending))) == ["10", "7", "2"]) + } + + @Test("Don't Sort shows the sessions in the order the server listed them") + func clearedSortKeepsArrivalOrder() { + #expect(ids(SessionGridColumns.sorted(sessions, by: SortState(columns: [], source: .user))) == ["10", "2", "7"]) + } + + @Test("A session row draws every column the table had, in order") + func sessionRowValues() { + let row = SessionGridColumns.row(for: sessions[1]) + #expect(row.id == "2") + #expect(row.values == ["2", "admin", "shop", "idle", "1m 30s", ""]) + #expect(row.values.count == SessionGridColumns.titles.count) + } + + @Test("The State column keeps the system status hue each session state has always had") + func stateTones() { + #expect(SessionGridColumns.stateTone("active") == .positive) + #expect(SessionGridColumns.stateTone("Running") == .positive) + #expect(SessionGridColumns.stateTone("idle") == .secondary) + #expect(SessionGridColumns.stateTone("idle in transaction") == .caution) + #expect(SessionGridColumns.stateTone("waiting") == .critical) + #expect(SessionGridColumns.stateTone("LOCKED") == .critical) + #expect(SessionGridColumns.stateTone("executing") == nil) + #expect(SessionGridColumns.stateTone("") == nil) + } + + @Test("A session row tones only its State cell") + func sessionRowTonesItsState() { + #expect(SessionGridColumns.row(for: sessions[0]).tones == [SessionGridColumns.stateColumn: .positive]) + #expect(SessionGridColumns.row(for: sessions[1]).tones == [SessionGridColumns.stateColumn: .secondary]) + let unknown = DashboardSession( + id: "3", user: "app", database: "shop", state: "executing", durationSeconds: 1, duration: "1s", query: "" + ) + #expect(SessionGridColumns.row(for: unknown).tones.isEmpty) + } + + @Test("A slow query's duration keeps its orange") + func slowQueryDurationTone() { + let rows = SlowQueryGridRows.rows(for: [ + DashboardSlowQuery(duration: "4s", query: "SELECT SLEEP(4)", user: "app", database: "shop"), + ]) + #expect(rows.first?.tones == [SlowQueryGridRows.durationColumn: .caution]) + } + + @Test("The sessions grid draws the State hue, white on an emphasized selection, and every other cell in the grid text") + func sessionsGridDrawsTheStateHue() throws { + let viewModel = ServerDashboardViewModel(connectionId: UUID(), databaseType: .postgresql, services: .live) + viewModel.sessions = sessions + let window = host(SessionsTableView(viewModel: viewModel)) + defer { + viewModel.stopAutoRefresh() + window.contentView = nil + } + let coordinator = try #require(gridTable(in: window.contentView)?.coordinator) + let palette = coordinator.cellRegistry.palette + let state = SessionGridColumns.stateColumn + func textColor(row: Int, column: Int, selected: Bool = false) -> NSColor? { + coordinator.cellAppearance(row: row, columnIndex: column, onEmphasizedSelection: selected)?.textColor + } + + // Longest first: the idle session, then the two active ones. + #expect(textColor(row: 0, column: state) == .secondaryLabelColor) + #expect(textColor(row: 1, column: state) == .systemGreen) + #expect(textColor(row: 1, column: state, selected: true) == palette.selectionText) + #expect(textColor(row: 1, column: 0) == palette.text) + #expect(textColor(row: 1, column: SessionGridColumns.durationColumn) == palette.text) + } + + private func headerRedPixels(selecting ids: Set) throws -> Int { + let viewModel = ServerDashboardViewModel(connectionId: UUID(), databaseType: .postgresql, services: .live) + viewModel.sessions = sessions + viewModel.sessionSelection.ids = ids + let size = NSSize(width: 800, height: 400) + let host = NSHostingView(rootView: SessionsTableView(viewModel: viewModel).frame(width: size.width, height: size.height)) + host.frame = NSRect(origin: .zero, size: size) + let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentView = host + defer { + viewModel.stopAutoRefresh() + window.contentView = nil + } + host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let headerRows = Int(36 * CGFloat(bitmap.pixelsHigh) / size.height) + return ThemeTestFixture.count(in: bitmap, rows: 0 ..< headerRows) { color in + color.alphaComponent > 0.5 && color.redComponent > 0.75 && color.greenComponent < 0.45 && color.blueComponent < 0.45 + } + } + + @Test("Terminate draws its red glyph while it can end the selected session, and the standard glyph while disabled") + func terminateGlyphIsRedOnlyWhileEnabled() throws { + #expect(try headerRedPixels(selecting: ["10"]) > 0) + #expect(try headerRedPixels(selecting: []) == 0) + } + + @Test("Cancel and Terminate act on exactly one selected session that is still listed") + func actionTargetIsOneListedSession() { + #expect(SessionGridColumns.actionTarget(["7"], in: sessions)?.id == "7") + #expect(SessionGridColumns.actionTarget(["7", "2"], in: sessions) == nil) + #expect(SessionGridColumns.actionTarget([], in: sessions) == nil) + #expect(SessionGridColumns.actionTarget(["99"], in: sessions) == nil) + } + + @Test("A running slow query keeps its row while its duration grows, and a repeat gets its own") + func slowQueryRowIdentity() { + let first = [ + DashboardSlowQuery(duration: "4s", query: "SELECT SLEEP(9)", user: "app", database: "shop"), + DashboardSlowQuery(duration: "3s", query: "SELECT SLEEP(9)", user: "app", database: "shop"), + ] + let later = [ + DashboardSlowQuery(duration: "9s", query: "SELECT SLEEP(9)", user: "app", database: "shop"), + DashboardSlowQuery(duration: "8s", query: "SELECT SLEEP(9)", user: "app", database: "shop"), + ] + let before = SlowQueryGridRows.rows(for: first) + let after = SlowQueryGridRows.rows(for: later) + #expect(before.map(\.id) == after.map(\.id)) + #expect(Set(before.map(\.id)).count == 2) + #expect(after[0].values == ["9s", "SELECT SLEEP(9)", "app", "shop"]) + #expect(after[0].values.count == SlowQueryGridRows.titles.count) + } + + @Test("The sessions pane draws its records in the data grid") + func sessionsMountTheDataGrid() throws { + let viewModel = ServerDashboardViewModel(connectionId: UUID(), databaseType: .mysql, services: .live) + viewModel.sessions = sessions + let window = host(SessionsTableView(viewModel: viewModel)) + defer { + viewModel.stopAutoRefresh() + window.contentView = nil + } + let table = try #require(gridTable(in: window.contentView)) + #expect(table.numberOfRows == sessions.count) + } + + @Test("The slow query pane draws its records in the data grid") + func slowQueriesMountTheDataGrid() throws { + let queries = [DashboardSlowQuery(duration: "4s", query: "SELECT SLEEP(4)", user: "app", database: "shop")] + let window = host(SlowQueryListView(queries: queries, error: nil, databaseType: .mysql)) + defer { window.contentView = nil } + let table = try #require(gridTable(in: window.contentView)) + #expect(table.numberOfRows == 1) + } +} diff --git a/TableProTests/Views/Settings/CustomThemeListModelTests.swift b/TableProTests/Views/Settings/CustomThemeListModelTests.swift deleted file mode 100644 index a7bfe2c624..0000000000 --- a/TableProTests/Views/Settings/CustomThemeListModelTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -@testable import TablePro -import Testing - -struct CustomThemeListModelTests { - private func theme(_ id: String, _ name: String, _ appearance: ThemeAppearance) -> ThemeDefinition { - ThemeDefinition(id: id, name: name, author: "", appearance: appearance, overrides: [:]) - } - - private var themes: [ThemeDefinition] { - BuiltInThemes.all + [ - theme("tablepro.dracula", "Dracula", .dark), - theme("user.aaaa0001", "Paper", .light), - theme("user.aaaa0002", "Midnight", .dark), - ] - } - - private let refused = RejectedThemeRecord( - path: "/Themes/Broken.json", - fileName: "Broken.json", - reason: "The file does not declare a theme format version." - ) - - @Test("Only user themes are listed, in catalog order, with refused files kept apart") - func listsUserThemesAndRefusedFiles() { - let model = CustomThemeListModel(themes: themes, rejected: [refused]) - #expect(model.themes.map(\.id) == ["user.aaaa0001", "user.aaaa0002"]) - #expect(model.notLoaded == [refused]) - #expect(!model.isEmpty) - } - - @Test("Built-in themes alone leave the list empty") - func builtInsAloneAreEmpty() { - #expect(CustomThemeListModel(themes: themes.filter(\.isBuiltIn), rejected: []).isEmpty) - } - - @Test("A row resolves to its own theme or file and to nothing else") - func itemsResolve() { - let model = CustomThemeListModel(themes: themes, rejected: [refused]) - #expect(model.theme(for: .theme("user.aaaa0001"))?.name == "Paper") - #expect(model.theme(for: .theme("tablepro.dracula")) == nil) - #expect(model.theme(for: .notLoaded(path: refused.path)) == nil) - #expect(model.theme(for: nil) == nil) - #expect(model.record(for: .notLoaded(path: refused.path)) == refused) - #expect(model.record(for: .theme("user.aaaa0001")) == nil) - } - - @Test("A rename trims, and ignores an empty or unchanged name") - func renameTrimsAndIgnoresNoChange() { - #expect(ThemeNaming.renamed(" Midnight ", current: "Dracula Copy", takenNames: []) == "Midnight") - #expect(ThemeNaming.renamed(" ", current: "Dracula Copy", takenNames: []) == nil) - #expect(ThemeNaming.renamed("Dracula Copy", current: "Dracula Copy", takenNames: []) == nil) - } - - @Test("A rename to a name another theme has is numbered, never duplicated") - func renameNumbersATakenName() { - #expect(ThemeNaming.renamed("Dracula", current: "Mine", takenNames: ["Dracula"]) == "Dracula 2") - #expect(ThemeNaming.renamed("Dracula", current: "Mine", takenNames: ["Dracula", "Dracula 2"]) == "Dracula 3") - #expect(ThemeNaming.renamed("Dracula", current: "Dracula 2", takenNames: ["Dracula"]) == nil) - } - - @Test("Deleting a theme sends only the slots that held it back to their built-ins") - func deletingReleasesOnlyItsSlots() { - let settings = AppearanceSettings( - appearanceMode: .auto, - preferredLightThemeId: "user.aaaa0001", - preferredDarkThemeId: "user.aaaa0002" - ) - - let released = settings.releasing(themeId: "user.aaaa0002") - #expect(released.preferredLightThemeId == "user.aaaa0001") - #expect(released.preferredDarkThemeId == BuiltInThemes.defaultDarkId) - #expect(released.appearanceMode == .auto) - - #expect(settings.releasing(themeId: "user.other") == settings) - } -} diff --git a/TableProTests/Views/Settings/ThemeEditorDraftTests.swift b/TableProTests/Views/Settings/ThemeEditorDraftTests.swift deleted file mode 100644 index 3693533fef..0000000000 --- a/TableProTests/Views/Settings/ThemeEditorDraftTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -import AppKit -@testable import TablePro -import Testing - -@MainActor -struct ThemeEditorDraftTests { - private func midnight() throws -> ThemeDefinition { - var theme = BuiltInThemes.dark - theme.id = "user.aaaa0002" - theme.name = "Midnight" - theme.overrides[.editorBackground] = try ThemeColor(validating: "#282A36") - return theme - } - - @Test("Setting a color overrides the slot, and Use Default removes the override") - func setThenReset() throws { - var draft = try ThemeEditorDraft(theme: midnight()) - let pink = try ThemeColor(validating: "#FF79C6") - - let set = draft.setColor(pink, for: .editorText) - #expect(set) - #expect(draft.theme.overrides[.editorText] == pink) - - let reset = draft.reset(.editorText) - #expect(reset) - #expect(!draft.theme.isOverridden(.editorText)) - - let resetAgain = draft.reset(.editorText) - #expect(!resetAgain) - } - - @Test("Setting the value a slot already holds is not an edit") - func sameOverrideIsNotAnEdit() throws { - var draft = try ThemeEditorDraft(theme: midnight()) - let same = try ThemeColor(validating: "#282A36") - - let changed = draft.setColor(same, for: .editorBackground) - #expect(!changed) - } - - /// A well hands back the color it shows. Storing that echo would freeze a system color into - /// the file, and the slot would stop following Accent and Increase Contrast. - @Test("The system color a well shows for an unset slot is not stored when echoed back") - func echoOfTheSystemColorIsIgnored() throws { - var draft = try ThemeEditorDraft(theme: midnight()) - let shown = ThemeColor(draft.displayColor(for: .gridBackground)) - - let changed = draft.setColor(shown, for: .gridBackground) - #expect(!changed) - #expect(!draft.theme.isOverridden(.gridBackground)) - } - - @Test("An unset slot shows its system color in the theme's appearance, not the app's") - func unsetSlotResolvesInTheThemeAppearance() throws { - let draft = try ThemeEditorDraft(theme: midnight()) - let dark = try #require(NSAppearance(named: .darkAqua)) - var expected = NSColor.controlBackgroundColor - dark.performAsCurrentDrawingAppearance { - expected = NSColor.controlBackgroundColor.usingColorSpace(.sRGB) ?? expected - } - - let shown = ThemeColor(draft.displayColor(for: .gridBackground)) - #expect(shown == ThemeColor(expected)) - } - - @Test("An overridden slot shows its override") - func overriddenSlotShowsItsOverride() throws { - let draft = try ThemeEditorDraft(theme: midnight()) - let shown = ThemeColor(draft.displayColor(for: .editorBackground)) - #expect(shown.hex == "#282A36") - } - - @Test("A rename to the current name is not an edit") - func renameReportsChange() throws { - var draft = try ThemeEditorDraft(theme: midnight()) - - let unchanged = draft.rename(to: "Midnight") - #expect(!unchanged) - - let renamed = draft.rename(to: "Night Owl") - #expect(renamed) - #expect(draft.theme.name == "Night Owl") - } -} diff --git a/TableProTests/Views/Settings/ThemeEditorModelTests.swift b/TableProTests/Views/Settings/ThemeEditorModelTests.swift new file mode 100644 index 0000000000..b5b8eae445 --- /dev/null +++ b/TableProTests/Views/Settings/ThemeEditorModelTests.swift @@ -0,0 +1,445 @@ +import AppKit +import Foundation +@testable import TablePro +import TableProTestSupport +import TableProTheme +import Testing + +@MainActor +struct ThemeEditorModelTests { + private typealias Fixture = ThemeLibraryTestFixture + + private static let midnightId = "user.aaaa0002" + + private func midnight(in fixture: Fixture) throws { + try fixture.install( + Fixture.theme(Self.midnightId, "Midnight", overrides: [.editorBackground: Fixture.midnightBackground]), + as: "Midnight.json" + ) + } + + private func onDisk(_ fixture: Fixture) throws -> ThemeDefinition { + try fixture.onDisk("Midnight.json") + } + + // MARK: - Undo + + @Test("Every tick of one well drag is one undo step") + func wellDragIsOneUndoStep() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId) + + for red in [0x10, 0x20, 0x30, 0x40] as [UInt8] { + Fixture.step(editor) { editor.setColor(ThemeColor(red: red, green: 0x10, blue: 0x10), for: .editorText) } + } + #expect(editor.draft.overrides[.editorText] == ThemeColor(red: 0x40, green: 0x10, blue: 0x10)) + #expect(editor.undoManager.undoActionName == "Color Change") + + editor.undoManager.undo() + #expect(editor.draft.overrides[.editorText] == nil) + #expect(!editor.undoManager.canUndo) + + editor.undoManager.redo() + #expect(editor.draft.overrides[.editorText] == ThemeColor(red: 0x40, green: 0x10, blue: 0x10)) + editor.close() + } + + @Test("A drag that settled and was saved is its own step, and another slot starts a new one") + func settledDragStartsANewStep() async throws { + let fixture = try Fixture() + try midnight(in: fixture) + let clock = ManualClock() + let editor = try fixture.makeEditor(for: Self.midnightId, clock: clock) + + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + await clock.waitUntilSleeping() + clock.advance(by: ThemeEditorModel.defaultSaveDelay) + try await waitUntil { (try? self.onDisk(fixture).overrides[.editorText]) == Fixture.pink } + + Fixture.step(editor) { editor.setColor(Fixture.green, for: .editorText) } + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .syntaxKeyword) } + + editor.undoManager.undo() + #expect(editor.draft.overrides[.syntaxKeyword] == nil) + #expect(editor.draft.overrides[.editorText] == Fixture.green) + editor.undoManager.undo() + #expect(editor.draft.overrides[.editorText] == Fixture.pink) + editor.undoManager.undo() + #expect(editor.draft.overrides[.editorText] == nil) + editor.close() + } + + /// A well hands back the color it shows. Storing that echo would freeze a system color into the + /// file, and the slot would stop following Accent and Increase Contrast. + @Test("The system color a well shows for an unset slot is not stored when echoed back") + func echoOfTheShownColorIsIgnored() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId) + let shown = ThemeSlotPresentation.displayColor(for: .gridBackground, in: editor.draft) + + Fixture.step(editor) { editor.setColor(shown, for: .gridBackground) } + + #expect(!editor.draft.hasExplicitColor(.gridBackground)) + #expect(!editor.undoManager.canUndo) + #expect(fixture.preview.drafts.isEmpty) + editor.close() + } + + @Test("A slot without an opacity control stores an opaque color") + func opaqueSlotDropsAlpha() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId) + let translucent = ThemeColor(red: 0xFF, green: 0x79, blue: 0xC6, alpha: 0x80) + + Fixture.step(editor) { editor.setColor(translucent, for: .editorText) } + Fixture.step(editor) { editor.setColor(translucent, for: .editorSelection) } + + #expect(editor.draft.overrides[.editorText] == translucent.opaque) + #expect(editor.draft.overrides[.editorSelection] == translucent) + editor.close() + } + + @Test("Use System Color removes the slot and is undone by name") + func resetIsUndoable() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId) + + Fixture.step(editor) { editor.reset(.editorBackground) } + #expect(!editor.draft.hasExplicitColor(.editorBackground)) + #expect(editor.undoManager.undoActionName == "Use System Color") + + editor.undoManager.undo() + #expect(editor.draft.overrides[.editorBackground] == Fixture.midnightBackground) + editor.close() + } + + @Test("Revert Changes goes back to the theme as it opened, and is undoable") + func revertAndUndoRevert() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId) + let opened = editor.draft + + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + Fixture.step(editor) { editor.setAppearance(.light) } + #expect(editor.hasChangesSinceOpening) + + Fixture.step(editor) { editor.revert() } + #expect(editor.draft == opened) + #expect(!editor.hasChangesSinceOpening) + + editor.undoManager.undo() + #expect(editor.draft.appearance == .light) + #expect(editor.draft.overrides[.editorText] == Fixture.pink) + editor.close() + } + + // MARK: - Names + + @Test("A taken name stays in the field with the reason, and the theme keeps its name") + func takenNameIsShownInline() throws { + let fixture = try Fixture() + try midnight(in: fixture) + try fixture.install(Fixture.theme("user.aaaa0001", "Paper", appearance: .light)) + let editor = try fixture.makeEditor(for: Self.midnightId) + + editor.nameText = "paper" + let outcome = Fixture.step(editor) { editor.commitName() } + + #expect(outcome == .rejected("A theme named “paper” already exists.")) + #expect(editor.nameError == "A theme named “paper” already exists.") + #expect(editor.nameText == "paper") + #expect(editor.draft.name == "Midnight") + #expect(!editor.undoManager.canUndo) + + editor.nameText = "Night Owl" + #expect(Fixture.step(editor) { editor.commitName() } == .renamed("Night Owl")) + #expect(editor.nameError == nil) + #expect(editor.undoManager.undoActionName == "Rename") + editor.close() + } + + /// Name and Author commit when focus leaves them, and a well click takes no focus, so every + /// commit used to write the old name and author back over the text being typed. + @Test("A color, a reset, an appearance or Revert while Name and Author are being typed keeps the text") + func editsKeepTheTextBeingTyped() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + + editor.nameText = "Night Owl" + editor.authorText = "Ada" + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + #expect(editor.nameText == "Night Owl") + #expect(editor.authorText == "Ada") + Fixture.step(editor) { editor.reset(.editorBackground) } + Fixture.step(editor) { editor.setAppearance(.light) } + Fixture.step(editor) { editor.revert() } + #expect(editor.nameText == "Night Owl") + #expect(editor.authorText == "Ada") + #expect(editor.draft.name == "Midnight") + + #expect(Fixture.step(editor) { editor.commitName() } == .renamed("Night Owl")) + #expect(Fixture.step(editor) { editor.commitAuthor() }) + #expect(editor.draft.name == "Night Owl") + #expect(editor.draft.author == "Ada") + editor.close() + } + + @Test("A refused name keeps its text and reason through a color change") + func refusedNameSurvivesAnEdit() throws { + let fixture = try Fixture() + try midnight(in: fixture) + try fixture.install(Fixture.theme("user.aaaa0001", "Paper", appearance: .light)) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + + editor.nameText = "Paper" + Fixture.step(editor) { editor.commitName() } + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + + #expect(editor.nameText == "Paper") + #expect(editor.nameError == "A theme named “Paper” already exists.") + editor.close() + } + + @Test("Closing with a refused name or author keeps the window, and closes once it is fixed") + func refusedFieldKeepsTheWindowOpen() throws { + let fixture = try Fixture() + try midnight(in: fixture) + try fixture.install(Fixture.theme("user.aaaa0001", "Paper", appearance: .light)) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + + editor.nameText = "Paper" + #expect(editor.prepareToClose() == .keepEditing) + #expect(editor.nameText == "Paper") + #expect(editor.nameError == "A theme named “Paper” already exists.") + + editor.nameText = "Night Owl" + editor.authorText = "Ada\nLovelace" + #expect(editor.prepareToClose() == .keepEditing) + #expect(editor.authorError == ThemeLoadError.invalidAuthor.localizedDescription) + #expect(editor.draft.name == "Night Owl") + + editor.authorText = "Ada" + #expect(editor.prepareToClose() == .close) + editor.close() + let saved = try onDisk(fixture) + #expect(saved.name == "Night Owl") + #expect(saved.author == "Ada") + #expect(saved.overrides[.editorText] == Fixture.pink) + } + + // MARK: - Saving + + @Test("Edits reach the file after the pause, not on every tick, and the preview follows each one") + func saveIsDebouncedAndPreviewIsLive() async throws { + let fixture = try Fixture() + try midnight(in: fixture) + let clock = ManualClock() + let editor = try fixture.makeEditor(for: Self.midnightId, clock: clock) + + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + #expect(fixture.preview.drafts[Self.midnightId]?.overrides[.editorText] == Fixture.pink) + #expect(try onDisk(fixture).overrides[.editorText] == nil) + + await clock.waitUntilSleeping() + clock.advance(by: ThemeEditorModel.defaultSaveDelay) + try await waitUntil { (try? self.onDisk(fixture).overrides[.editorText]) == Fixture.pink } + editor.close() + #expect(fixture.preview.ended == [Self.midnightId]) + #expect(fixture.preview.drafts.isEmpty) + } + + @Test("Closing writes what the pause had not written yet") + func closeFlushesThePendingSave() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + #expect(editor.prepareToClose() == .close) + editor.close() + + #expect(try onDisk(fixture).overrides[.editorText] == Fixture.pink) + } + + /// Quitting closes no window, so the termination flush is the only write the typed text gets. + @Test("A flush writes the name and author still being typed") + func flushCommitsTheFields() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + + editor.nameText = "Night Owl" + editor.authorText = "Ada" + #expect(editor.flush()) + + let saved = try onDisk(fixture) + #expect(saved.name == "Night Owl") + #expect(saved.author == "Ada") + editor.close() + } + + @Test("A save that fails reports why, and closing then asks rather than trapping the window") + func saveFailureLeavesTheWindowClosable() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + let manager = FileManager.default + try manager.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.themes.path) + defer { try? manager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.themes.path) } + + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + #expect(!editor.flush()) + guard case let .saveFailed(name, _) = editor.alert else { + Issue.record("Expected a save failure, got \(String(describing: editor.alert))") + return + } + #expect(name == "Midnight") + #expect(editor.isEditable) + + guard case .confirmDiscard = editor.prepareToClose() else { + Issue.record("Closing over a failed save should ask first") + return + } + var closeRequests = 0 + editor.onRequestClose = { closeRequests += 1 } + editor.requestClose() + #expect(closeRequests == 1) + editor.close() + } + + // MARK: - Changes from elsewhere + + @Test("A file changed outside TablePro replaces the draft, with the alert, and no save writes over it") + func externalChangeIsAdopted() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + + var newer = try onDisk(fixture) + newer.overrides[.syntaxKeyword] = Fixture.green + try ThemeFileFormat.encode(newer).write(to: fixture.themes.appendingPathComponent("Midnight.json")) + fixture.catalog.reloadSynchronously() + + #expect(editor.alert == .changedOnDisk(name: "Midnight")) + #expect(editor.draft.overrides[.syntaxKeyword] == Fixture.green) + #expect(editor.draft.overrides[.editorText] == nil) + #expect(!editor.undoManager.canUndo) + #expect(editor.flush()) + #expect(try onDisk(fixture) == newer) + editor.close() + } + + /// The list shows the later of two "Midnight" themes as "Midnight 2" so a popup can tell them + /// apart. That is not a change to this theme's file, and the suffix must never be written to it. + @Test("A same-named theme arriving elsewhere neither raises a conflict nor renames the file") + func sameNamedArrivalIsNotAConflict() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + + try fixture.install(Fixture.theme("user.0000aaaa", "Midnight"), as: "Arrived.json") + #expect(fixture.catalog.theme(id: Self.midnightId)?.name == "Midnight 2") + + #expect(editor.alert == nil) + #expect(editor.draft.name == "Midnight") + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + #expect(editor.flush()) + #expect(editor.alert == nil) + #expect(try onDisk(fixture).name == "Midnight") + #expect(try onDisk(fixture).overrides[.editorText] == Fixture.pink) + editor.close() + } + + @Test("A save over a file changed since it was read adopts the newer file instead") + func saveConflictAdoptsTheNewerFile() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + + var newer = try onDisk(fixture) + newer.name = "Midnight Blue" + try ThemeFileFormat.encode(newer).write(to: fixture.themes.appendingPathComponent("Midnight.json")) + + #expect(!editor.flush()) + #expect(editor.alert == .changedOnDisk(name: "Midnight Blue")) + #expect(editor.draft.name == "Midnight Blue") + #expect(try onDisk(fixture).name == "Midnight Blue") + #expect(try onDisk(fixture).overrides[.editorText] == nil) + editor.close() + } + + @Test("A theme deleted outside TablePro is not written back, and Keep Theme saves it under a new id") + func externalDeleteDoesNotResurrect() throws { + let fixture = try Fixture() + try midnight(in: fixture) + fixture.selection.themeSelection.preferredDarkThemeId = Self.midnightId + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + var renamed: (String, String)? + editor.onIdentifierChange = { renamed = ($0, $1) } + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + + try FileManager.default.removeItem(at: fixture.themes.appendingPathComponent("Midnight.json")) + fixture.catalog.reloadSynchronously() + + #expect(editor.alert == .deletedOnDisk(name: "Midnight")) + #expect(editor.isDetached) + #expect(!editor.isEditable) + #expect(fixture.preview.ended == [Self.midnightId]) + #expect(editor.flush()) + #expect(editor.prepareToClose() == .close) + #expect(try fixture.fileNames().isEmpty) + + editor.keepAsNewTheme() + #expect(!editor.isDetached) + #expect(editor.draft.id != Self.midnightId) + #expect(editor.draft.overrides[.editorText] == Fixture.pink) + #expect(try fixture.fileNames() == ["\(editor.draft.id).json"]) + #expect(fixture.selection.themeSelection.preferredDarkThemeId == editor.draft.id) + #expect(renamed?.0 == Self.midnightId) + #expect(renamed?.1 == editor.draft.id) + editor.close() + } + + @Test("A file that changed into one that no longer loads stops the editor without a second file") + func unreadableFileStopsTheEditor() throws { + let fixture = try Fixture() + try midnight(in: fixture) + let editor = try fixture.makeEditor(for: Self.midnightId, clock: ManualClock()) + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + + try Data("{".utf8).write(to: fixture.themes.appendingPathComponent("Midnight.json")) + fixture.catalog.reloadSynchronously() + + #expect(editor.alert == .unreadableOnDisk(name: "Midnight")) + #expect(editor.isDetached) + #expect(editor.prepareToClose() == .close) + #expect(try fixture.fileNames() == ["Midnight.json"]) + editor.close() + } + + // MARK: - Built-in themes + + @Test("A built-in theme opens read-only and ignores edits") + func builtInIsReadOnly() throws { + let fixture = try Fixture() + let editor = try fixture.makeEditor(for: BuiltInThemes.defaultDarkId) + + #expect(editor.isReadOnly) + Fixture.step(editor) { editor.setColor(Fixture.pink, for: .editorText) } + Fixture.step(editor) { editor.setAppearance(.light) } + + #expect(editor.draft == BuiltInThemes.dark) + #expect(editor.prepareToClose() == .close) + #expect(fixture.preview.drafts.isEmpty) + editor.close() + } +} diff --git a/TableProTests/Views/Settings/ThemeEditorWindowTests.swift b/TableProTests/Views/Settings/ThemeEditorWindowTests.swift new file mode 100644 index 0000000000..578fff0877 --- /dev/null +++ b/TableProTests/Views/Settings/ThemeEditorWindowTests.swift @@ -0,0 +1,290 @@ +import AppKit +import Foundation +@testable import TablePro +import TableProTestSupport +import TableProTheme +import Testing + +@MainActor +struct ThemeEditorWindowTests { + private typealias Fixture = ThemeLibraryTestFixture + + private func makeWindows(_ fixture: Fixture) -> ThemeEditorWindows { + ThemeEditorWindows( + catalog: fixture.catalog, + preview: fixture.preview, + selection: fixture.selection, + notificationCenter: fixture.center + ) + } + + @Test("Opening a theme twice brings its one window forward, titled and proxied by the theme") + func oneWindowPerTheme() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + let windows = makeWindows(fixture) + + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + windows.openEditor(for: "user.aaaa0002") + + #expect(windows.controller(for: "user.aaaa0002") === controller) + #expect(windows.openThemeIds == ["user.aaaa0002"]) + let window = try #require(controller.window) + #expect(window.title == "Midnight") + #expect(window.representedURL?.lastPathComponent == "Midnight.json") + #expect(window.identifier?.rawValue == ThemeEditorWindowController.windowIdentifier) + #expect(window.undoManager === controller.model.undoManager) + + Fixture.step(controller.model) { controller.model.rename(to: "Night Owl") } + #expect(window.title == "Night Owl") + + windows.closeEditor(for: "user.aaaa0002") + #expect(windows.openThemeIds.isEmpty) + #expect(!window.isVisible) + #expect(fixture.preview.ended.contains("user.aaaa0002")) + #expect(try fixture.onDisk("Midnight.json").name == "Night Owl") + } + + /// The preview's editor and grid keep undo histories of their own, so while either held focus + /// Edit > Undo did nothing to the theme. + @Test("The preview takes no clicks and gives up focus, so Undo reaches the theme") + func previewNeverHoldsFocus() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + let windows = makeWindows(fixture) + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + let window = try #require(controller.window) + window.contentView?.layoutSubtreeIfNeeded() + + let views = Self.subviews(of: try #require(window.contentView)) + let surface = try #require(views.first { $0 is ThemePreviewSurface }) + #expect(surface.hitTest(NSPoint(x: surface.bounds.midX, y: surface.bounds.midY)) == nil) + + let grid = try #require(views.first { $0 is NSTableView && $0.isInsideThemePreview }) + #expect(window.makeFirstResponder(grid)) + controller.windowDidBecomeKey(Notification(name: NSWindow.didBecomeKeyNotification, object: window)) + + #expect(window.firstResponder === window) + #expect(window.firstResponder?.undoManager === controller.model.undoManager) + windows.closeEditor(for: "user.aaaa0002") + } + + /// AppKit's key view loop reached the preview's editor and grid, and Undo then went to their empty + /// histories instead of the theme's. + @Test("Tab and Shift-Tab pass over the preview") + func tabPassesOverThePreview() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + let windows = makeWindows(fixture) + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + let window = try #require(controller.window) + window.contentView?.layoutSubtreeIfNeeded() + defer { windows.closeEditor(for: "user.aaaa0002") } + + let fields = Self.subviews(of: try #require(window.contentView)) + .compactMap { $0 as? NSTextField } + .filter { $0.isEditable && !$0.isInsideThemePreview } + .sorted { $0.convert($0.bounds, to: nil).minY > $1.convert($1.bounds, to: nil).minY } + #expect(fields.count == 2) + let name = try #require(fields.first) + let author = try #require(fields.last) + + #expect(window.makeFirstResponder(author)) + Self.fieldEditor(of: window)?.doCommand(by: #selector(NSResponder.insertTab(_:))) + #expect(!Self.isPreviewFocused(in: window)) + #expect(window.firstResponder !== window) + + #expect(window.makeFirstResponder(name)) + Self.fieldEditor(of: window)?.doCommand(by: #selector(NSResponder.insertBacktab(_:))) + #expect(!Self.isPreviewFocused(in: window)) + #expect(window.firstResponder !== window) + + window.makeFirstResponder(nil) + window.selectNextKeyView(nil) + #expect(!Self.isPreviewFocused(in: window)) + } + + private static func fieldEditor(of window: NSWindow) -> NSTextView? { + guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { return nil } + return editor + } + + private static func isPreviewFocused(in window: NSWindow) -> Bool { + guard let view = window.firstResponder as? NSView else { return false } + return view.isInsideThemePreview + } + + /// Quitting closes no window, so the field's text reached no commit. + @Test("Quitting writes the name still being typed") + func quitWritesTheTypedName() async throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + let windows = makeWindows(fixture) + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + defer { windows.closeEditor(for: "user.aaaa0002") } + + controller.model.nameText = "Night Owl" + fixture.center.post(name: NSApplication.willTerminateNotification, object: nil) + + try await waitUntil { (try? fixture.onDisk("Midnight.json").name) == "Night Owl" } + } + + @Test("Quitting over a refused name is cancelled, with the window kept and the reason shown") + func quitOverARefusedNameIsCancelled() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0001", "Paper", appearance: .light)) + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + let windows = makeWindows(fixture) + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + defer { windows.closeEditor(for: "user.aaaa0002") } + + controller.model.nameText = "Paper" + + #expect(!windows.prepareToTerminate()) + #expect(windows.openThemeIds == ["user.aaaa0002"]) + #expect(controller.model.nameError == "A theme named “Paper” already exists.") + } + + @Test("Quitting over a failed save asks first, and only a confirmed quit goes ahead") + func quitOverAFailedSaveAsks() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + var answer = false + var asked: [String] = [] + let windows = ThemeEditorWindows( + catalog: fixture.catalog, + preview: fixture.preview, + selection: fixture.selection, + notificationCenter: fixture.center, + confirmQuitWithoutSaving: { name, _ in + asked.append(name) + return answer + } + ) + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + let manager = FileManager.default + try manager.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.themes.path) + defer { + try? manager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.themes.path) + windows.closeEditor(for: "user.aaaa0002") + } + + controller.model.nameText = "Night Owl" + + #expect(!windows.prepareToTerminate()) + answer = true + #expect(windows.prepareToTerminate()) + #expect(asked == ["Night Owl", "Night Owl"]) + } + + @Test("Quitting with a clean editor saves what was typed and lets the quit go ahead") + func quitWithACleanEditorSaves() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + let windows = makeWindows(fixture) + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + defer { windows.closeEditor(for: "user.aaaa0002") } + + controller.model.nameText = "Night Owl" + + #expect(windows.prepareToTerminate()) + #expect(try fixture.onDisk("Midnight.json").name == "Night Owl") + } + + /// The close button takes no focus, so the rename was still pending, refused, and dropped as the + /// window closed in the same event. + @Test("Closing with a taken name keeps the window open with the reason") + func closeWithATakenNameKeepsTheWindow() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0001", "Paper", appearance: .light)) + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + let windows = makeWindows(fixture) + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + let window = try #require(controller.window) + + controller.model.nameText = "Paper" + window.performClose(nil) + #expect(window.isVisible) + #expect(windows.openThemeIds == ["user.aaaa0002"]) + #expect(controller.model.nameError == "A theme named “Paper” already exists.") + + controller.model.nameText = "Night Owl" + window.performClose(nil) + #expect(windows.openThemeIds.isEmpty) + #expect(try fixture.onDisk("Midnight.json").name == "Night Owl") + } + + private static func subviews(of root: NSView) -> [NSView] { + var found: [NSView] = [] + var pending = [root] + while let view = pending.popLast() { + found.append(view) + pending.append(contentsOf: view.subviews) + } + return found + } + + @Test("Closing with the close button saves and forgets the window") + func closeButtonSavesAndForgets() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + let windows = makeWindows(fixture) + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + let window = try #require(controller.window) + + Fixture.step(controller.model) { controller.model.setColor(Fixture.pink, for: .editorText) } + window.performClose(nil) + + #expect(windows.openThemeIds.isEmpty) + #expect(try fixture.onDisk("Midnight.json").overrides[.editorText] == Fixture.pink) + } + + @Test("A built-in theme opens read-only, and Duplicate hands over to an editor for the copy") + func duplicateFromABuiltIn() throws { + let fixture = try Fixture() + let windows = makeWindows(fixture) + + windows.openEditor(for: BuiltInThemes.defaultDarkId) + let readOnly = try #require(windows.controller(for: BuiltInThemes.defaultDarkId)) + #expect(readOnly.model.isReadOnly) + #expect(readOnly.window?.representedURL == nil) + + windows.duplicate(from: readOnly) + + #expect(windows.controller(for: BuiltInThemes.defaultDarkId) == nil) + let copyId = try #require(windows.openThemeIds.first) + #expect(copyId != BuiltInThemes.defaultDarkId) + let copy = try #require(windows.controller(for: copyId)) + #expect(!copy.model.isReadOnly) + #expect(copy.window?.title == "Default Dark Copy") + windows.closeEditor(for: copyId) + } + + @Test("Keep Theme after a deletion keeps the window under the theme's new id") + func keptThemeIsRekeyed() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight"), as: "Midnight.json") + let windows = makeWindows(fixture) + windows.openEditor(for: "user.aaaa0002") + let controller = try #require(windows.controller(for: "user.aaaa0002")) + + try FileManager.default.removeItem(at: fixture.themes.appendingPathComponent("Midnight.json")) + fixture.catalog.reloadSynchronously() + controller.model.keepAsNewTheme() + + let newId = controller.model.draft.id + #expect(windows.controller(for: "user.aaaa0002") == nil) + #expect(windows.controller(for: newId) === controller) + #expect(controller.window?.representedURL?.lastPathComponent == "\(newId).json") + windows.closeEditor(for: newId) + } +} diff --git a/TableProTests/Views/Settings/ThemeLibraryModelTests.swift b/TableProTests/Views/Settings/ThemeLibraryModelTests.swift new file mode 100644 index 0000000000..7691aa16ed --- /dev/null +++ b/TableProTests/Views/Settings/ThemeLibraryModelTests.swift @@ -0,0 +1,381 @@ +import AppKit +import Foundation +@testable import TablePro +import TableProTheme +import Testing + +@MainActor +struct ThemeLibraryModelTests { + private typealias Fixture = ThemeLibraryTestFixture + + @Test("The list holds the built-ins, the user's themes and the refused files apart") + func listSections() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0001", "Paper", appearance: .light)) + try Data("{".utf8).write(to: fixture.themes.appendingPathComponent("Broken.json")) + fixture.catalog.reloadSynchronously() + let library = fixture.makeLibrary() + + #expect(library.builtInThemes.map(\.id) == [BuiltInThemes.defaultLightId, BuiltInThemes.defaultDarkId]) + #expect(library.customThemes.map(\.id) == ["user.aaaa0001"]) + #expect(library.notLoaded.map(\.fileName) == ["Broken.json"]) + #expect(library.canTrash(.theme("user.aaaa0001"))) + #expect(!library.canTrash(.theme(BuiltInThemes.defaultDarkId))) + let broken = try #require(library.notLoaded.first) + #expect(library.canTrash(.notLoaded(path: broken.path))) + } + + @Test("Duplicate writes a copy with a new id and its own name, and New Theme Based On opens it") + func duplicateAndCreate() throws { + let fixture = try Fixture() + let library = fixture.makeLibrary() + + let copy = try #require(library.duplicate(BuiltInThemes.defaultDarkId, undoManager: nil)) + #expect(copy.id != BuiltInThemes.defaultDarkId) + #expect(copy.isEditable) + #expect(copy.name == "Default Dark Copy") + #expect(fixture.editors.opened.isEmpty) + #expect(try fixture.fileNames() == ["\(copy.id).json"]) + + let created = try #require(library.createTheme(basedOn: BuiltInThemes.defaultDarkId, undoManager: nil)) + #expect(created.name == "Default Dark Copy 2") + #expect(fixture.editors.opened == [created.id]) + } + + /// A copy of a default has no syntax colors of its own, so it keeps Xcode's Increase Contrast + /// palette instead of freezing the standard one into the file. + @Test("A duplicate of a default keeps the adaptive syntax palette") + func duplicateOfDefaultKeepsAdaptiveSyntax() throws { + let fixture = try Fixture() + let library = fixture.makeLibrary() + + let copy = try #require(library.duplicate(BuiltInThemes.defaultLightId, undoManager: nil)) + let stored = try fixture.onDisk("\(copy.id).json") + #expect(stored.overrides.isEmpty) + #expect(stored.value(for: .syntaxKeyword, highContrast: true) == .defaultSyntax(.syntaxKeyword)) + + // A high-contrast appearance cannot be made in the test host, so the check is that the copy + // draws the adaptive color object itself, which switches palettes at draw time. + let adaptive = try #require(BuiltInThemes.adaptiveSyntaxColor(.syntaxKeyword, appearance: .light)) + #expect(stored.color(for: .syntaxKeyword) === adaptive) + } + + @Test("Move to Trash releases the slot that held the theme, and Undo puts both back") + func trashAndUndo() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight")) + fixture.selection.themeSelection.preferredDarkThemeId = "user.aaaa0002" + let library = fixture.makeLibrary() + let undoManager = UndoManager() + undoManager.groupsByEvent = false + + let confirmation = try #require(library.trashConfirmation(for: .theme("user.aaaa0002"))) + #expect(confirmation.title == "Delete “Midnight”?") + #expect(confirmation.message == "The file moves to the Trash. Dark windows switch to Default Dark.") + + undoManager.beginUndoGrouping() + let trashed = library.moveToTrash(.theme("user.aaaa0002"), undoManager: undoManager) + undoManager.endUndoGrouping() + #expect(trashed) + #expect(try fixture.fileNames().isEmpty) + #expect(fixture.catalog.theme(id: "user.aaaa0002") == nil) + #expect(fixture.selection.themeSelection.preferredDarkThemeId == BuiltInThemes.defaultDarkId) + #expect(fixture.editors.closed == ["user.aaaa0002"]) + #expect(undoManager.undoActionName == "Move to Trash") + + undoManager.undo() + #expect(try fixture.fileNames() == ["user.aaaa0002.json"]) + #expect(fixture.catalog.theme(id: "user.aaaa0002")?.name == "Midnight") + #expect(fixture.selection.themeSelection.preferredDarkThemeId == "user.aaaa0002") + + undoManager.redo() + #expect(try fixture.fileNames().isEmpty) + #expect(fixture.selection.themeSelection.preferredDarkThemeId == BuiltInThemes.defaultDarkId) + } + + @Test("A theme nothing uses moves to the Trash without asking") + func unusedThemeNeedsNoConfirmation() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0003", "Spare")) + let library = fixture.makeLibrary() + + #expect(library.trashConfirmation(for: .theme("user.aaaa0003")) == nil) + #expect(library.trashConfirmation(for: .theme(BuiltInThemes.defaultDarkId)) == nil) + } + + @Test("Undo of a trash leaves a slot the user has chosen since alone") + func undoDoesNotOverrideANewerChoice() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight")) + try fixture.install(Fixture.theme("user.aaaa0004", "Dusk")) + fixture.selection.themeSelection.preferredDarkThemeId = "user.aaaa0002" + let library = fixture.makeLibrary() + let undoManager = UndoManager() + undoManager.groupsByEvent = false + + undoManager.beginUndoGrouping() + library.moveToTrash(.theme("user.aaaa0002"), undoManager: undoManager) + undoManager.endUndoGrouping() + library.use("user.aaaa0004", for: .dark) + undoManager.undo() + + #expect(fixture.catalog.theme(id: "user.aaaa0002") != nil) + #expect(fixture.selection.themeSelection.preferredDarkThemeId == "user.aaaa0004") + } + + @Test("A file that did not load moves to the Trash and comes back on Undo") + func trashNotLoadedFile() throws { + let fixture = try Fixture() + try Data("{ \"schema\": 3".utf8).write(to: fixture.themes.appendingPathComponent("Broken.json")) + fixture.catalog.reloadSynchronously() + let library = fixture.makeLibrary() + let undoManager = UndoManager() + undoManager.groupsByEvent = false + let record = try #require(library.notLoaded.first) + let broken = URL(fileURLWithPath: record.path) + let item = ThemeLibraryItem.notLoaded(path: record.path) + + #expect(library.trashConfirmation(for: item) == nil) + undoManager.beginUndoGrouping() + #expect(library.moveToTrash(item, undoManager: undoManager)) + undoManager.endUndoGrouping() + #expect(library.notLoaded.isEmpty) + #expect(!FileManager.default.fileExists(atPath: broken.path)) + + undoManager.undo() + #expect(library.notLoaded.map(\.fileName) == ["Broken.json"]) + } + + @Test("Importing several files imports each good one and reports the rest in one alert") + func importSeveralFilesWithMixedResults() throws { + let fixture = try Fixture() + let library = fixture.makeLibrary() + let first = try fixture.writeIncoming( + "Ocean.json", + contents: ThemeFileFormat.encode(Fixture.theme("user.11111111", "Ocean")) + ) + let second = try fixture.writeIncoming( + "Paper.json", + contents: ThemeFileFormat.encode(Fixture.theme("user.22222222", "Paper", appearance: .light)) + ) + let broken = try fixture.writeIncoming("Monokai.json", contents: Data("not json".utf8)) + + let report = library.importThemes(from: [first, broken, second], undoManager: nil) + + #expect(report.imported.map(\.name) == ["Ocean", "Paper"]) + #expect(report.imported.allSatisfy { $0.id != "user.11111111" && $0.id != "user.22222222" }) + #expect(report.failures.map(\.fileName) == ["Monokai.json"]) + let alert = try #require(library.alert) + #expect(alert.title == "Imported 2 themes.") + #expect(alert.message.hasPrefix("Monokai.json could not be read: ")) + #expect(library.customThemes.map(\.name) == ["Ocean", "Paper"]) + } + + @Test("A single file that fails names itself in the alert title") + func singleFailedImport() throws { + let fixture = try Fixture() + let library = fixture.makeLibrary() + let broken = try fixture.writeIncoming("Monokai.json", contents: Data("[]".utf8)) + + let report = library.importThemes(from: [broken], undoManager: nil) + + #expect(report.imported.isEmpty) + #expect(library.alert?.title == "Couldn't Import “Monokai.json”") + #expect(library.alert?.message == ThemeLoadError.notATheme.localizedDescription) + } + + @Test("A clean import raises no alert") + func cleanImportHasNoAlert() throws { + let fixture = try Fixture() + let library = fixture.makeLibrary() + let file = try fixture.writeIncoming( + "Ocean.json", + contents: ThemeFileFormat.encode(Fixture.theme("user.11111111", "Ocean")) + ) + + let report = library.importThemes(from: [file], undoManager: nil) + + #expect(report.imported.count == 1) + #expect(library.alert == nil) + } + + @Test("Export hands out the theme's file contents, and a drag writes a named copy outside the Themes folder") + func exportAndDrag() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Mid/Night", overrides: [.editorBackground: Fixture.midnightBackground])) + let library = fixture.makeLibrary() + + let document = try #require(library.exportDocument(for: "user.aaaa0002")) + #expect(try ThemeFileFormat.decode(document.data) == fixture.catalog.theme(id: "user.aaaa0002")) + #expect(ThemeLibraryModel.exportFileName(for: try #require(fixture.catalog.theme(id: "user.aaaa0002"))) == "Mid-Night") + + let dragged = try library.dragExportURL(for: "user.aaaa0002") + #expect(dragged.lastPathComponent == "Mid-Night.json") + #expect(dragged.path.hasPrefix(fixture.drags.path)) + #expect(try ThemeFileFormat.decode(Data(contentsOf: dragged)).name == "Mid/Night") + + let builtIn = try library.dragExportURL(for: BuiltInThemes.defaultDarkId) + #expect(try ThemeFileFormat.decode(Data(contentsOf: builtIn)).id == BuiltInThemes.defaultDarkId) + } + + @Test("A row dragged back onto the list is not imported again") + func dropOfOwnDragIsIgnored() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight")) + let library = fixture.makeLibrary() + let dragged = try library.dragExportURL(for: "user.aaaa0002") + + let report = library.importThemes(from: [dragged], undoManager: nil) + + #expect(report.imported.isEmpty) + #expect(report.failures.isEmpty) + #expect(library.customThemes.count == 1) + } + + @Test("A rename to another theme's name is refused with the reason and changes nothing") + func renameCollision() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0001", "Paper", appearance: .light)) + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight")) + let library = fixture.makeLibrary() + + #expect(library.rename("user.aaaa0002", to: " paper ", undoManager: nil) == .rejected("A theme named “paper” already exists.")) + #expect(try fixture.onDisk("user.aaaa0002.json").name == "Midnight") + #expect(library.rename("user.aaaa0002", to: " ", undoManager: nil) == .unchanged) + #expect(library.rename("user.aaaa0002", to: "Midnight", undoManager: nil) == .unchanged) + #expect(library.rename("user.aaaa0002", to: String(repeating: "x", count: 101), undoManager: nil) == .rejected(ThemeLoadError.invalidName.localizedDescription)) + + #expect(library.rename("user.aaaa0002", to: "Night Owl", undoManager: nil) == .renamed("Night Owl")) + #expect(try fixture.onDisk("user.aaaa0002.json").name == "Night Owl") + } + + @Test("A rename, a duplicate and an import are undone the way Finder undoes them") + func listActionsAreUndoable() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight")) + let library = fixture.makeLibrary() + let undoManager = UndoManager() + undoManager.groupsByEvent = false + func step(_ action: () -> Result) -> Result { + undoManager.beginUndoGrouping() + defer { undoManager.endUndoGrouping() } + return action() + } + + #expect(step { library.rename("user.aaaa0002", to: "Night Owl", undoManager: undoManager) } == .renamed("Night Owl")) + #expect(undoManager.undoActionName == "Rename") + undoManager.undo() + #expect(try fixture.onDisk("user.aaaa0002.json").name == "Midnight") + undoManager.redo() + #expect(try fixture.onDisk("user.aaaa0002.json").name == "Night Owl") + + let copy = try #require(step { library.duplicate("user.aaaa0002", undoManager: undoManager) }) + #expect(undoManager.undoActionName == "Duplicate") + undoManager.undo() + #expect(fixture.catalog.theme(id: copy.id) == nil) + #expect(try fixture.fileNames() == ["user.aaaa0002.json"]) + undoManager.redo() + #expect(fixture.catalog.theme(id: copy.id)?.name == "Night Owl Copy") + + let file = try fixture.writeIncoming( + "Ocean.json", + contents: ThemeFileFormat.encode(Fixture.theme("user.11111111", "Ocean")) + ) + let report = step { library.importThemes(from: [file], undoManager: undoManager) } + let imported = try #require(report.imported.first) + #expect(undoManager.undoActionName == "Import") + undoManager.undo() + #expect(fixture.catalog.theme(id: imported.id) == nil) + #expect(FileManager.default.fileExists(atPath: file.path)) + } + + @Test("A rename of a theme open in an editor goes through that editor and is saved at once") + func renameThroughOpenEditor() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight")) + let editor = try fixture.makeEditor(for: "user.aaaa0002") + fixture.editors.models["user.aaaa0002"] = editor + let library = fixture.makeLibrary() + + let outcome = Fixture.step(editor) { library.rename("user.aaaa0002", to: "Night Owl", undoManager: nil) } + #expect(outcome == .renamed("Night Owl")) + #expect(editor.draft.name == "Night Owl") + #expect(editor.undoManager.undoActionName == "Rename") + #expect(try fixture.onDisk("user.aaaa0002.json").name == "Night Owl") + #expect(editor.alert == nil) + } + + /// The list shows the second of two "Ocean" themes as "Ocean 2". The rename field started from that + /// shown name, and committing it untouched reported a rename and left an Undo step that did nothing. + @Test("Renaming the second of two same-named themes starts from its stored name, with or without its editor") + func renameOfADisambiguatedTheme() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0001", "Ocean")) + try fixture.install(Fixture.theme("user.aaaa0002", "Ocean")) + let shown = try #require(fixture.catalog.themes.first { $0.name == "Ocean 2" }) + let fileName = "\(shown.id).json" + let library = fixture.makeLibrary() + let undoManager = UndoManager() + undoManager.groupsByEvent = false + + let seed = try #require(library.nameForRenaming(shown.id)) + #expect(seed == "Ocean") + #expect(library.rename(shown.id, to: seed, undoManager: undoManager) == .unchanged) + #expect(!undoManager.canUndo) + #expect(try fixture.onDisk(fileName).name == "Ocean") + + let editor = try fixture.makeEditor(for: shown.id) + fixture.editors.models[shown.id] = editor + #expect(editor.nameText == "Ocean") + #expect(library.rename(shown.id, to: seed, undoManager: undoManager) == .unchanged) + #expect(!undoManager.canUndo) + #expect(!editor.undoManager.canUndo) + #expect(try fixture.onDisk(fileName).name == "Ocean") + editor.close() + } + + /// Typed on purpose, the shown name becomes the file's name. It used to be a silent no-op without + /// an editor open and a write with one. + @Test("Typing the shown name renames the file the same way with or without its editor", arguments: [false, true]) + func typedShownNameRenamesTheFile(isEditorOpen: Bool) throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0001", "Ocean")) + try fixture.install(Fixture.theme("user.aaaa0002", "Ocean")) + let shown = try #require(fixture.catalog.themes.first { $0.name == "Ocean 2" }) + let library = fixture.makeLibrary() + let editor = try isEditorOpen ? fixture.makeEditor(for: shown.id) : nil + fixture.editors.models[shown.id] = editor + defer { editor?.close() } + let undoManager = UndoManager() + undoManager.groupsByEvent = false + + #expect(library.rename(shown.id, to: "Ocean 2", undoManager: undoManager) == .renamed("Ocean 2")) + #expect(try fixture.onDisk("\(shown.id).json").name == "Ocean 2") + #expect(undoManager.undoActionName == "Rename") + } + + @Test("Use for an appearance offers only the slot the theme fits and does not already hold") + func useForAppearance() throws { + let fixture = try Fixture() + try fixture.install(Fixture.theme("user.aaaa0002", "Midnight")) + let library = fixture.makeLibrary() + let midnight = try #require(fixture.catalog.theme(id: "user.aaaa0002")) + + #expect(library.availableSlot(for: midnight) == .dark) + library.use(midnight.id, for: .dark) + #expect(fixture.selection.themeSelection.preferredDarkThemeId == midnight.id) + #expect(library.availableSlot(for: midnight) == nil) + #expect(library.availableSlot(for: BuiltInThemes.light) == nil) + } + + @Test("Releasing and reclaiming a theme touch only the slots that held it") + func releaseAndReclaim() { + let settings = AppearanceSettings(preferredLightThemeId: "user.aaaa0001", preferredDarkThemeId: "user.aaaa0002") + + let released = settings.releasing(themeId: "user.aaaa0002") + #expect(released.preferredLightThemeId == "user.aaaa0001") + #expect(released.preferredDarkThemeId == BuiltInThemes.defaultDarkId) + #expect(settings.releasing(themeId: "user.other") == settings) + #expect(released.reclaiming(themeId: "user.aaaa0002", from: settings) == settings) + } +} diff --git a/TableProTests/Views/Settings/ThemeLibraryTestFixture.swift b/TableProTests/Views/Settings/ThemeLibraryTestFixture.swift new file mode 100644 index 0000000000..e109e82e9a --- /dev/null +++ b/TableProTests/Views/Settings/ThemeLibraryTestFixture.swift @@ -0,0 +1,165 @@ +import AppKit +import Foundation +@testable import TablePro +import TableProSyncTransport +import TableProTheme + +/// A catalog in a temporary folder with its own trash and sync tracker, plus stand-ins for the +/// settings, the engine and the editor windows, so a theme list or editor test touches nothing real. +@MainActor +final class ThemeLibraryTestFixture { + let root: URL + let center = NotificationCenter() + let suite: EphemeralDefaults + let tracker: SyncChangeTracker + let selection = InMemoryThemeSelection() + let preview = RecordingThemePreview() + let editors = StubThemeEditors() + private(set) var catalog: ThemeCatalog + + var themes: URL { root.appendingPathComponent("Themes", isDirectory: true) } + var trash: URL { root.appendingPathComponent("Trash", isDirectory: true) } + var incoming: URL { root.appendingPathComponent("Incoming", isDirectory: true) } + var drags: URL { root.appendingPathComponent("Drags", isDirectory: true) } + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("ThemeLibraryTests-\(UUID().uuidString)", isDirectory: true) + suite = try EphemeralDefaults() + tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: suite.defaults)) + let folders = [ + root.appendingPathComponent("Themes", isDirectory: true), + root.appendingPathComponent("Trash", isDirectory: true), + root.appendingPathComponent("Incoming", isDirectory: true), + ] + for folder in folders { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + catalog = ThemeCatalog( + userDirectory: folders[0], + bundleDirectory: nil, + notificationCenter: center, + fileTrash: .folder(folders[1]), + syncTracker: tracker + ) + } + + deinit { + try? FileManager.default.removeItem(at: root) + } + + func makeLibrary() -> ThemeLibraryModel { + ThemeLibraryModel( + catalog: catalog, + selection: selection, + editors: editors, + dragDirectory: drags, + revealInFinder: { _ in } + ) + } + + /// The window's manager groups by event; a test has no events, so each step groups itself. + func makeEditor( + for id: String, + clock: any Clock = ContinuousClock(), + saveDelay: Duration = ThemeEditorModel.defaultSaveDelay + ) throws -> ThemeEditorModel { + guard let theme = catalog.storedTheme(id: id) else { throw CocoaError(.fileNoSuchFile) } + let editor = ThemeEditorModel( + theme: theme, + catalog: catalog, + preview: preview, + selection: selection, + clock: clock, + saveDelay: saveDelay + ) + editor.undoManager.groupsByEvent = false + return editor + } + + /// One user action, as one event makes it in a window. + @discardableResult + static func step(_ editor: ThemeEditorModel, _ action: () -> Result) -> Result { + action() + } + + static func theme( + _ id: String, + _ name: String, + appearance: ThemeAppearance = .dark, + overrides: [ThemeSlot: ThemeColor] = [:] + ) -> ThemeDefinition { + ThemeDefinition(id: id, name: name, author: "", appearance: appearance, overrides: overrides) + } + + /// Writes the file and reloads, as a theme copied in with Finder arrives. + @discardableResult + func install(_ theme: ThemeDefinition, as fileName: String? = nil) throws -> URL { + let url = themes.appendingPathComponent(fileName ?? "\(theme.id).json") + try ThemeFileFormat.encode(theme).write(to: url) + catalog.reloadSynchronously() + return url + } + + @discardableResult + func writeIncoming(_ name: String, contents: Data) throws -> URL { + let url = incoming.appendingPathComponent(name) + try contents.write(to: url) + return url + } + + func onDisk(_ fileName: String) throws -> ThemeDefinition { + try ThemeFileFormat.decode(try Data(contentsOf: themes.appendingPathComponent(fileName))) + } + + func fileNames() throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: themes.path).sorted() + } + + static let midnightBackground = ThemeColor(red: 0x28, green: 0x2A, blue: 0x36) + static let pink = ThemeColor(red: 0xFF, green: 0x79, blue: 0xC6) + static let green = ThemeColor(red: 0x50, green: 0xFA, blue: 0x7B) +} + +@MainActor +final class InMemoryThemeSelection: ThemeSelectionStore { + var themeSelection = AppearanceSettings.default +} + +@MainActor +final class RecordingThemePreview: ThemePreviewing { + private(set) var drafts: [String: ThemeDefinition] = [:] + private(set) var ended: [String] = [] + + func beginPreview(_ draft: ThemeDefinition) { + drafts[draft.id] = draft + } + + func endPreview(id: String) { + drafts[id] = nil + ended.append(id) + } +} + +@MainActor +final class StubThemeEditors: ThemeEditorPresenting { + private(set) var opened: [String] = [] + private(set) var closed: [String] = [] + var models: [String: ThemeEditorModel] = [:] + + func openEditor(for themeId: String) { + opened.append(themeId) + } + + func editor(for themeId: String) -> ThemeEditorModel? { + models[themeId] + } + + func closeEditor(for themeId: String) { + closed.append(themeId) + if let model = models.removeValue(forKey: themeId) { + model.flush() + model.close() + } + } +} diff --git a/TableProTests/Views/Settings/ThemeSlotChoiceTests.swift b/TableProTests/Views/Settings/ThemeSlotChoiceTests.swift index 2186a3616d..56e87e43ea 100644 --- a/TableProTests/Views/Settings/ThemeSlotChoiceTests.swift +++ b/TableProTests/Views/Settings/ThemeSlotChoiceTests.swift @@ -1,4 +1,5 @@ @testable import TablePro +import TableProTheme import Testing struct ThemeSlotChoiceTests { @@ -56,11 +57,10 @@ struct ThemeSlotChoiceTests { for savedId in ["user.aaaa0002", "user.missing", "user.aaaa0001", "tablepro.dracula"] { let choice = ThemeSlotChoice(slot: .dark, savedId: savedId, themes: catalog) let resolved = ThemeResolver.resolve( - mode: .dark, lightThemeId: BuiltInThemes.defaultLightId, darkThemeId: savedId, themes: catalog, - systemIsDark: true + effectiveAppearance: .dark ) #expect(choice.selectedId == resolved.active.id, "\(savedId)") } diff --git a/TableProTests/Views/Settings/ThemeSlotRowTests.swift b/TableProTests/Views/Settings/ThemeSlotRowTests.swift new file mode 100644 index 0000000000..69104035dd --- /dev/null +++ b/TableProTests/Views/Settings/ThemeSlotRowTests.swift @@ -0,0 +1,219 @@ +import AppKit +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +@MainActor +struct ThemeSlotRowTests { + private typealias Fixture = ThemeLibraryTestFixture + + private static let gridBase = ThemeColor(red: 0x28, green: 0x2A, blue: 0x36) + + private static let dark = ThemeDefinition( + id: "user.aaaa0002", + name: "Midnight", + author: "", + appearance: .dark, + overrides: [ + .editorBackground: Fixture.midnightBackground, + .editorText: Fixture.pink, + .syntaxKeyword: Fixture.green, + .gridBackground: gridBase, + ] + ) + + @Test("Every well and every reset is named apart for VoiceOver") + func accessibilityLabelsAreUnique() { + let slots = ThemeSlot.allCases + let wells = Set(slots.map(\.accessibilityLabel)) + let resets = Set(slots.map { ThemeSlotPresentation(slot: $0, theme: Self.dark).resetAccessibilityLabel }) + + #expect(wells.count == slots.count) + #expect(resets.count == slots.count) + #expect( + ThemeSlotPresentation(slot: .editorBackground, theme: Self.dark).resetAccessibilityLabel + == "Use System Color for Editor Background" + ) + } + + @Test("The state line says where the color comes from") + func stateLines() { + func line(_ slot: ThemeSlot) -> String { + ThemeSlotPresentation(slot: slot, theme: Self.dark).stateLine + } + #expect(line(.editorBackground) == Fixture.midnightBackground.hex) + #expect(line(.windowBackdrop) == "System color") + #expect(line(.syntaxString) == "Default Dark color") + #expect(line(.syntaxOperator) == "Based on Editor Text") + #expect(line(.gridAlternateRow) == "Based on Data Grid Background") + #expect(line(.gridHeaderText) == "System color") + + var light = Self.dark + light.appearance = .light + #expect(ThemeSlotPresentation(slot: .syntaxString, theme: light).stateLine == "Default Light color") + } + + @Test("Reset names what it returns to, and shows only for a slot the file sets") + func resetTitles() { + func presentation(_ slot: ThemeSlot, _ theme: ThemeDefinition = ThemeSlotRowTests.dark) -> ThemeSlotPresentation { + ThemeSlotPresentation(slot: slot, theme: theme) + } + #expect(presentation(.editorBackground).hasExplicitColor) + #expect(presentation(.editorBackground).resetTitle == "Use System Color") + #expect(presentation(.syntaxKeyword).resetTitle == "Use Default Color") + #expect(!presentation(.windowBackdrop).hasExplicitColor) + + var striped = Self.dark + striped.overrides[.gridAlternateRow] = Fixture.pink + #expect(presentation(.gridAlternateRow, striped).resetTitle == "Use Default Color") + striped.overrides[.gridBackground] = nil + #expect(presentation(.gridAlternateRow, striped).resetTitle == "Use System Color") + } + + @Test("An unset slot's well shows the system color in the theme's appearance") + func unsetSlotShowsTheSystemColorOfTheThemeAppearance() throws { + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + var expected = ThemeColor(red: 0, green: 0, blue: 0) + darkAqua.performAsCurrentDrawingAppearance { + expected = ThemeColor(NSColor.windowBackgroundColor.usingColorSpace(.sRGB) ?? .windowBackgroundColor) + } + + #expect(ThemeSlotPresentation(slot: .windowBackdrop, theme: Self.dark).displayColor == expected) + #expect(ThemeSlotPresentation(slot: .editorBackground, theme: Self.dark).displayColor == Fixture.midnightBackground) + let keyword = DefaultSyntaxPalette.color(for: .syntaxString, appearance: .dark, highContrast: false) + #expect(ThemeSlotPresentation(slot: .syntaxString, theme: Self.dark).displayColor == keyword) + } + + @Test("The contrast note appears only below 4.5:1") + func contrastNote() { + var low = Self.dark + low.overrides[.editorText] = ThemeColor(red: 0x55, green: 0x57, blue: 0x66) + let message = ThemeContrastNote.message(for: .editor, in: low) + #expect(message?.hasPrefix("Text contrast is ") == true) + #expect(message?.hasSuffix(":1. Aim for at least 4.5:1.") == true) + + #expect(ThemeContrastNote.message(for: .editor, in: Self.dark) == nil) + #expect(ThemeContrastNote.message(for: .syntax, in: low) == nil) + } + + /// The well's alpha slider is offered only where the slot is a wash over content. + @Test("A slot's well offers opacity only when the slot does") + func wellOpacityFollowsTheSlot() throws { + guard #available(macOS 14, *) else { return } + for slot in [ThemeSlot.editorBackground, .editorText, .editorSelection, .gridDeleted] { + let well = try hostedWell(for: slot) + #expect(well.supportsAlpha == slot.offersOpacity, "\(slot.rawValue)") + } + } + + /// Activating a well without opacity after one with it turns the shared panel's alpha off, and + /// the panel then sends the previous well's color and this one's, both made opaque. Taken as + /// picks, they froze a system color into the file. + @Test("Activating a well is not a pick, and a color chosen in the panel still is") + func activationIsNotAPick() throws { + guard #available(macOS 14, *) else { return } + let picks = PickRecorder() + var theme = Fixture.theme("user.aaaa0003", "Paper", appearance: .light) + theme.overrides[.editorCurrentLine] = ThemeColor(red: 0x00, green: 0x7A, blue: 0xFF, alpha: 0x14) + let rows = Form { + ForEach([ThemeSlot.editorCurrentLine, .gridLine], id: \.self) { slot in + ThemeSlotRow( + presentation: ThemeSlotPresentation(slot: slot, theme: theme), + isEditable: true, + onSet: { picks.record(slot, $0) }, + onReset: {} + ) + } + } + .formStyle(.grouped) + let (window, host) = Self.hostedWindow(rows, height: 200) + window.orderFront(nil) + let wells = Self.colorWells(in: host) + let translucent = try #require(wells.first { $0.supportsAlpha }) + let opaque = try #require(wells.first { !$0.supportsAlpha }) + let panel = NSColorPanel.shared + defer { + for well in wells { well.deactivate() } + panel.orderOut(nil) + window.orderOut(nil) + } + + translucent.activate(true) + panel.orderFront(nil) + opaque.activate(true) + #expect(!panel.showsAlpha) + #expect(picks.slots.isEmpty) + + panel.color = NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + #expect(picks.slots.last == .gridLine) + #expect(picks.colors.last == ThemeColor(red: 0xFF, green: 0x00, blue: 0x00)) + } + + @Test("A well is named for VoiceOver and dims when the theme cannot be edited") + func wellAccessibilityAndState() throws { + let (_, editable) = Self.hostedWindow(Self.row(for: .gridLine, isEditable: true), height: 120) + let well = try #require(Self.colorWells(in: editable).first) + #expect(well.accessibilityLabel() == ThemeSlot.gridLine.accessibilityLabel) + #expect(well.accessibilityIdentifier() == "theme-color-dataGrid.gridLine") + #expect(well.isEnabled) + + let (_, readOnly) = Self.hostedWindow(Self.row(for: .gridLine, isEditable: false), height: 120) + #expect(Self.colorWells(in: readOnly).first?.isEnabled == false) + } + + private func hostedWell(for slot: ThemeSlot) throws -> NSColorWell { + let (window, host) = Self.hostedWindow(Self.row(for: slot, isEditable: true), height: 120) + defer { window.orderOut(nil) } + guard let well = Self.colorWells(in: host).first else { throw CocoaError(.featureUnsupported) } + return well + } + + private static func row(for slot: ThemeSlot, isEditable: Bool) -> some View { + Form { + ThemeSlotRow( + presentation: ThemeSlotPresentation(slot: slot, theme: Self.dark), + isEditable: isEditable, + onSet: { _ in }, + onReset: {} + ) + } + .formStyle(.grouped) + } + + private static func hostedWindow(_ content: Content, height: CGFloat) -> (NSWindow, NSView) { + let host = NSHostingView(rootView: content) + host.frame = NSRect(x: 0, y: 0, width: 480, height: height) + let window = NSWindow( + contentRect: host.frame, + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = host + host.layoutSubtreeIfNeeded() + return (window, host) + } + + private static func colorWells(in root: NSView) -> [NSColorWell] { + var found: [NSColorWell] = [] + var pending: [NSView] = [root] + while let view = pending.popLast() { + if let well = view as? NSColorWell { found.append(well) } + pending.append(contentsOf: view.subviews) + } + return found + } +} + +@MainActor +private final class PickRecorder { + private(set) var slots: [ThemeSlot] = [] + private(set) var colors: [ThemeColor] = [] + + func record(_ slot: ThemeSlot, _ color: ThemeColor) { + slots.append(slot) + colors.append(color) + } +} diff --git a/TableProTests/Views/Shared/DiffSurfaceThemeTests.swift b/TableProTests/Views/Shared/DiffSurfaceThemeTests.swift index 707557cb9b..f221f539e3 100644 --- a/TableProTests/Views/Shared/DiffSurfaceThemeTests.swift +++ b/TableProTests/Views/Shared/DiffSurfaceThemeTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift b/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift index a51c70dc2e..084658e211 100644 --- a/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift +++ b/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Sidebar/MaintenancePreviewSyntaxTests.swift b/TableProTests/Views/Sidebar/MaintenancePreviewSyntaxTests.swift index 31558ba617..646d741b1d 100644 --- a/TableProTests/Views/Sidebar/MaintenancePreviewSyntaxTests.swift +++ b/TableProTests/Views/Sidebar/MaintenancePreviewSyntaxTests.swift @@ -7,6 +7,7 @@ import AppKit import SwiftUI @testable import TablePro import TableProPluginKit +import TableProTheme import Testing @MainActor diff --git a/TableProTests/Views/Structure/StructureCodeStandInThemeTests.swift b/TableProTests/Views/Structure/StructureCodeStandInThemeTests.swift new file mode 100644 index 0000000000..82cb841267 --- /dev/null +++ b/TableProTests/Views/Structure/StructureCodeStandInThemeTests.swift @@ -0,0 +1,59 @@ +// +// StructureCodeStandInThemeTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +/// A state that replaces a DDL or statement text view keeps the editor's background under a theme, +/// and draws nothing of its own otherwise, so the pane behind it shows as it did. +@MainActor +struct StructureCodeStandInThemeTests { + private static let size = CGSize(width: 320, height: 240) + private static let corner = CGPoint(x: 0.03, y: 0.05) + + private func expectEditorStandIn(_ view: some View) throws { + let framed = view.frame(width: Self.size.width, height: Self.size.height) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let unthemed = try ThemeTestFixture.color(of: framed, at: Self.corner, colorScheme: .dark) + #expect(unthemed.alphaComponent < 0.05, "the default pair drew \(unthemed)") + + try ThemeTestFixture.adopt([.editorBackground: ThemeTestFixture.editorHex], appearance: .dark) + let editor = try ThemeTestFixture.filled(hex: ThemeTestFixture.editorHex, colorScheme: .dark) + let themed = try ThemeTestFixture.color(of: framed, at: Self.corner, colorScheme: .dark) + #expect(ThemeTestFixture.matches(themed, editor), "drew \(themed), editor \(editor)") + + try ThemeTestFixture.adopt([ + .windowBackdrop: ThemeTestFixture.windowHex, + .gridBackground: ThemeTestFixture.gridHex, + ], appearance: .dark) + let unrelated = try ThemeTestFixture.color(of: framed, at: Self.corner, colorScheme: .dark) + #expect(unrelated.alphaComponent < 0.05, "another slot drew \(unrelated)") + } + } + + @Test("No DDL available stands in for the DDL view on the editor background") + func missingDDL() throws { + try expectEditorStandIn(StructureDDLUnavailableView()) + } + + @Test("The Create Table SQL Preview placeholder sits on the editor background") + func createTablePlaceholder() throws { + try expectEditorStandIn(CreateTableSQLPreviewStandIn(content: .message(nil))) + try expectEditorStandIn(CreateTableSQLPreviewStandIn(content: .message("Add a column first"))) + } + + @Test("The Create Table SQL Preview progress sits on the editor background") + func createTableComposing() throws { + try expectEditorStandIn(CreateTableSQLPreviewStandIn(content: .composing)) + } + + @Test("The form's preview message sits on the editor background") + func formPreviewMessage() throws { + try expectEditorStandIn(CreateTableFormPreview(preview: .message("No columns yet"), databaseType: .mysql)) + } +} diff --git a/TableProTests/Views/Structure/StructureListGridModelTests.swift b/TableProTests/Views/Structure/StructureListGridModelTests.swift index ab83f6898d..a8b4a8047e 100644 --- a/TableProTests/Views/Structure/StructureListGridModelTests.swift +++ b/TableProTests/Views/Structure/StructureListGridModelTests.swift @@ -199,3 +199,88 @@ extension GlobalStateSuites { } } } + +@MainActor +struct StructureListGridToneTests { + private let palette = DataGridCellPalette( + regularFont: .systemFont(ofSize: 13), + italicFont: .systemFont(ofSize: 13), + mediumFont: .systemFont(ofSize: 13, weight: .medium), + text: .systemPurple, + placeholderText: .systemBrown, + booleanTrueText: .labelColor, + booleanFalseText: .labelColor, + rowNumberText: .secondaryLabelColor, + deletedRowText: .systemPink, + modifiedColumnTint: .systemYellow, + findMatchTint: .systemOrange, + selectionText: .systemIndigo + ) + + private func appearance( + tone: NSColor?, + placeholder: DataGridCellPlaceholder? = nil, + visualState: RowVisualState = .empty, + isCurrentFindMatch: Bool = false, + onEmphasizedSelection: Bool = false + ) -> DataGridCellAppearance { + DataGridCellAppearance.resolve( + kind: .text, + content: DataGridCellContent(displayText: "active", rawValue: "active", placeholder: placeholder), + state: DataGridCellState( + visualState: visualState, + isFocused: false, + isEditable: false, + isLargeDataset: false, + isCurrentFindMatch: isCurrentFindMatch, + row: 0, + columnIndex: 0 + ), + palette: palette, + nullDisplayString: "NULL", + onEmphasizedSelection: onEmphasizedSelection, + hasOverlay: false, + textTone: tone + ) + } + + @Test("Each tone is a system status color") + func tonesAreSystemColors() { + #expect(StructureListTone.positive.color == .systemGreen) + #expect(StructureListTone.caution.color == .systemOrange) + #expect(StructureListTone.critical.color == .systemRed) + #expect(StructureListTone.secondary.color == .secondaryLabelColor) + } + + @Test("A tone is answered for its own cell, and a row without tones answers none") + func modelAnswersTonePerCell() { + let model = StructureListGridModel() + _ = model.ensureLoaded(key: 1, columns: ["Name", "State"]) { + [ + StructureListRow(id: "a", values: ["alpha", "active"], tones: [1: .positive]), + StructureListRow(id: "b", values: ["beta", "idle"]), + ] + } + + #expect(model.dataGridTextTone(row: 0, column: 1) == .systemGreen) + #expect(model.dataGridTextTone(row: 0, column: 0) == nil) + #expect(model.dataGridTextTone(row: 1, column: 1) == nil) + #expect(model.dataGridTextTone(row: 5, column: 1) == nil) + } + + @Test("A toned value draws in its tone and turns to the selection text on an emphasized selection") + func toneGivesWayToTheSelection() { + #expect(appearance(tone: .systemGreen).textColor == .systemGreen) + #expect(appearance(tone: .systemGreen, onEmphasizedSelection: true).textColor == .systemIndigo) + #expect(appearance(tone: nil).textColor == .systemPurple) + } + + @Test("A placeholder, a deleted row and a find match keep their own text color over a tone") + func toneLeavesOtherStatesAlone() { + let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []) + + #expect(appearance(tone: .systemGreen, placeholder: .empty).textColor == .systemBrown) + #expect(appearance(tone: .systemGreen, visualState: deleted).textColor == .systemPink) + #expect(appearance(tone: .systemGreen, isCurrentFindMatch: true).textColor == palette.findMatchText) + } +} diff --git a/TableProTests/Views/ToolPaneBarThemeTests.swift b/TableProTests/Views/ToolPaneBarThemeTests.swift new file mode 100644 index 0000000000..89506a8c0e --- /dev/null +++ b/TableProTests/Views/ToolPaneBarThemeTests.swift @@ -0,0 +1,191 @@ +// +// ToolPaneBarThemeTests.swift +// TableProTests +// +// A bar or pane fill over the themed detail pane keeps the system fill it drew, and draws nothing +// while the theme colors the window, so the pane's own color shows through it. +// + +import AppKit +import Combine +import SwiftUI +@testable import TablePro +import TableProTheme +import Testing + +private struct ToolPaneVersionHistoryProvider: VersionHistoryProvider { + let notice: VersionHistoryNotice? + + func loadHistory() async throws -> VersionHistoryPage { + VersionHistoryPage(entries: [VersionHistoryEntry(reference: .current)], notice: notice) + } + + func content(of reference: VersionHistoryReference) async throws -> String { + "" + } + + func prepareRestore(_ reference: VersionHistoryReference) async throws -> VersionRestorePlan { + throw VersionHistoryError.versionNotFound + } +} + +@MainActor +struct ToolPaneBarThemeTests { + private static let size = CGSize(width: 480, height: 240) + + private func color(of view: some View, at point: CGPoint) throws -> NSColor { + try ThemeTestFixture.color( + of: view.frame(width: Self.size.width, height: Self.size.height), + at: point, + colorScheme: .dark + ) + } + + private func expectDropsFill(_ view: some View, at point: CGPoint, native: some ShapeStyle) throws { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let expected = try ThemeTestFixture.filled(native, colorScheme: .dark) + let unthemed = try color(of: view, at: point) + #expect(ThemeTestFixture.matches(unthemed, expected), "the default pair drew \(unthemed), native \(expected)") + + try ThemeTestFixture.adopt([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + let themed = try color(of: view, at: point) + #expect(themed.alphaComponent < 0.05, "the bar drew \(themed) over a themed window") + + try ThemeTestFixture.adopt([ + .editorBackground: ThemeTestFixture.editorHex, + .gridBackground: ThemeTestFixture.gridHex, + ], appearance: .dark) + let unrelated = try color(of: view, at: point) + #expect(ThemeTestFixture.matches(unrelated, expected), "another slot drew \(unrelated)") + } + } + + private func versionHistory(notice: VersionHistoryNotice? = nil) async -> VersionHistoryViewModel { + let viewModel = VersionHistoryViewModel( + subject: .savedQuery(id: UUID()), + provider: ToolPaneVersionHistoryProvider(notice: notice), + refreshSignal: Empty().eraseToAnyPublisher() + ) + await viewModel.loadList() + return viewModel + } + + @Test("The query plan bar keeps its control background until the theme colors the window") + func queryPlanBar() throws { + let view = QueryPlanResultView( + rawText: "not a plan", + executionTime: nil, + plan: nil, + tabState: QueryPlanTabState(), + planState: QueryPlanViewState() + ) + try expectDropsFill(view, at: CGPoint(x: 0.004, y: 0.008), native: Color(nsColor: .controlBackgroundColor)) + } + + @Test("The unparsed plan banner keeps its control background until the theme colors the window") + func queryPlanBanner() throws { + let view = QueryPlanResultView( + rawText: "not a plan", + executionTime: nil, + plan: nil, + tabState: QueryPlanTabState(), + planState: QueryPlanViewState() + ) + let banner = try bannerPoint(in: view) + try expectDropsFill(view, at: banner, native: Color(nsColor: .controlBackgroundColor)) + } + + /// The banner sits under the bar and a divider; the first row below them that the default pair + /// fills with the control background is inside it. + private func bannerPoint(in view: QueryPlanResultView) throws -> CGPoint { + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let control = try ThemeTestFixture.filled(Color(nsColor: .controlBackgroundColor), colorScheme: .dark) + let rep = try ThemeTestFixture.bitmap( + of: view.frame(width: Self.size.width, height: Self.size.height), + colorScheme: .dark + ) + // Runs down the leading edge: the bar's fill, the divider, then the banner's fill. + var runs = 0 + var wasControl = false + for y in 0 ..< rep.pixelsHigh { + guard let pixel = ThemeTestFixture.pixel(rep, x: 2, y: y) else { continue } + let isControl = ThemeTestFixture.matches(pixel, control) + if isControl, !wasControl { + runs += 1 + if runs == 2 { + return CGPoint(x: 2.5 / CGFloat(rep.pixelsWide), y: (CGFloat(y) + 4.5) / CGFloat(rep.pixelsHigh)) + } + } + wasControl = isControl + } + Issue.record("no banner under the plan bar") + return CGPoint(x: 0.004, y: 0.2) + } + } + + @Test("The version history header keeps its control background until the theme colors the window") + func versionHistoryHeader() async throws { + let view = VersionHistoryDetailHeader(viewModel: await versionHistory()) + // The header keeps its own height, centred in the frame. + try expectDropsFill(view, at: CGPoint(x: 0.004, y: 0.5), native: Color(nsColor: .controlBackgroundColor)) + } + + @Test("The version history notice keeps its control background until the theme colors the window") + func versionHistoryNotice() async throws { + let viewModel = await versionHistory(notice: .keepsLatestVersions(50)) + #expect(viewModel.page.notice != nil) + let view = VersionHistoryListPane(viewModel: viewModel) + try expectDropsFill(view, at: CGPoint(x: 0.004, y: 0.99), native: Color(nsColor: .controlBackgroundColor)) + } + + @Test("The Query Insights toolbar keeps its material until the theme colors the window") + func queryInsightsToolbar() throws { + let viewModel = QueryInsightsViewModel(connectionId: UUID(), history: QueryHistoryManager.shared) + try expectDropsFill(QueryInsightsToolbar(viewModel: viewModel), at: CGPoint(x: 0.004, y: 0.5), native: .bar) + } +} + +/// The scope pane sits in an AppKit split view, which only a real hosting view draws. +@MainActor +struct PrivilegeScopePaneThemeTests { + private static let size = NSSize(width: 720, height: 360) + + private func snapshot(_ view: some View) throws -> NSBitmapImageRep { + let host = NSHostingView(rootView: view.frame(width: Self.size.width, height: Self.size.height)) + host.frame = NSRect(origin: .zero, size: Self.size) + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: Self.size), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .darkAqua) + window.contentView = host + defer { window.contentView = nil } + host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + return bitmap + } + + private func corner(of view: some View) throws -> NSColor { + let rep = try snapshot(view) + return try #require(ThemeTestFixture.pixel(rep, x: 3, y: 3)) + } + + @Test("The privilege scope pane keeps its control background until the theme colors the window") + func scopePane() throws { + let viewModel = UsersRolesViewModel(connectionId: UUID(), databaseType: .postgresql) + let pane = PrivilegeEditorPane(viewModel: viewModel) + try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let native = try corner(of: Color(nsColor: .controlBackgroundColor)) + let unthemed = try corner(of: pane) + #expect(ThemeTestFixture.matches(unthemed, native), "the default pair drew \(unthemed), native \(native)") + + try ThemeTestFixture.adopt([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) + let themed = try corner(of: pane) + #expect(themed.alphaComponent < 0.05, "the pane drew \(themed) over a themed window") + } + } +} diff --git a/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift b/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift index ea1997c59a..60b7353d3e 100644 --- a/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift +++ b/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift @@ -10,6 +10,7 @@ import AppKit import Combine @testable import TablePro import TableProPluginKit +import TableProTheme import Testing /// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are diff --git a/TableProUITests/AppearanceSettingsUITests.swift b/TableProUITests/AppearanceSettingsUITests.swift index d2a0195874..0834c4fcab 100644 --- a/TableProUITests/AppearanceSettingsUITests.swift +++ b/TableProUITests/AppearanceSettingsUITests.swift @@ -1,22 +1,31 @@ import XCTest final class AppearanceSettingsUITests: UITestCase { - func testCustomThemeFromCreationToDeletion() throws { - let window = try openAppearancePane() + func testCustomThemeFromCreationToTrashAndBack() throws { + let app = try launchOpeningURL("tablepro://settings/appearance") + let settings = try appearancePane(in: app) - createTheme(basedOn: "Dracula", in: window) - let copy = row("Dracula Copy", in: window) - XCTAssertTrue(copy.waitToExist(timeout: 10), "The new theme should be listed") + createTheme(basedOn: "Dracula", in: settings) + let editor = window("theme-editor", in: app) + XCTAssertTrue(editor.waitToExist(timeout: 10), "New Theme Based On should open the Theme Editor") + XCTAssertTrue( + waitForPredicate(timeout: 5) { editor.title == "Dracula Copy" }, + "The editor window is titled with the theme's name, got \(editor.title)" + ) XCTAssertTrue( waitForPredicate(timeout: 5) { self.themeDocument(named: "Dracula Copy") != nil }, "The new theme should be written to the Themes folder" ) - rename(selectedThemeTo: "Midnight", in: window) - XCTAssertTrue(row("Midnight", in: window).waitToExist(timeout: 5), "The list should show the new name") - XCTAssertFalse(row("Dracula Copy", in: window).exists) + resetAndUndoEditorBackground(in: editor, app: app, themeName: "Dracula Copy") + + editor.buttons[XCUIIdentifierCloseWindow].click() + XCTAssertTrue(waitForPredicate(timeout: 5) { !editor.exists }, "The close button should close the editor") + XCTAssertNotNil(editorColor("background", ofThemeNamed: "Dracula Copy"), "Closing keeps the undone color") - let dark = window.popUpButtons["appearance-dark-theme"] + renameInRow("Dracula Copy", to: "Midnight", in: settings) + + let dark = settings.popUpButtons["appearance-dark-theme"] XCTAssertTrue(dark.waitToExist(timeout: 10)) choose("Midnight", in: dark) XCTAssertTrue( @@ -24,34 +33,32 @@ final class AppearanceSettingsUITests: UITestCase { "A custom dark theme should be offered in the Dark popup" ) - clearBackgroundOverride(ofThemeNamed: "Midnight", in: window) - - let midnight = row("Midnight", in: window) - XCTAssertTrue(waitUntilHittable(midnight, timeout: 10)) - midnight.click() - let remove = window.buttons["appearance-theme-remove"] - XCTAssertTrue(remove.waitToExist(timeout: 5)) - XCTAssertEqual(remove.label, "Delete Theme") - XCTAssertTrue(waitForPredicate(timeout: 5) { remove.isEnabled }, "A selected custom theme can be deleted") - remove.click() - - let confirmation = window.sheets.firstMatch - XCTAssertTrue(confirmation.waitToExist(timeout: 5), "Delete should ask first") - confirmation.buttons["Delete"].click() - - XCTAssertTrue(waitForPredicate(timeout: 5) { !midnight.exists }, "The deleted theme should leave the list") + moveToTrash("Midnight", in: settings) XCTAssertTrue( waitForPredicate(timeout: 5) { (dark.value as? String) == "Default Dark" }, - "The slot that held the deleted theme should go back to its built-in" + "The slot that held the trashed theme should go back to its built-in" + ) + XCTAssertNil(themeDocument(named: "Midnight"), "The theme file should leave the Themes folder") + + app.typeShortcut(.undo) + XCTAssertTrue(row("Midnight", in: settings).waitToExist(timeout: 5), "Undo should bring the theme back") + XCTAssertTrue( + waitForPredicate(timeout: 5) { self.themeDocument(named: "Midnight") != nil }, + "Undo should put the file back in the Themes folder" + ) + XCTAssertTrue( + waitForPredicate(timeout: 5) { (dark.value as? String) == "Midnight" }, + "Undo should give the Dark slot its theme back" ) - XCTAssertNil(themeDocument(named: "Midnight"), "The theme file should be gone") } // MARK: - Steps private func createTheme(basedOn source: String, in window: XCUIElement) { let add = element("appearance-theme-add", in: window) - XCTAssertTrue(waitUntilHittable(add, timeout: 10), "The Custom Themes list should offer its add menu") + XCTAssertTrue(add.waitToExist(timeout: 10), "The Themes list should offer its add menu") + revealInForm(add, in: window) + XCTAssertTrue(waitUntilHittable(add, timeout: 10), "The Themes list should offer its add menu") XCTAssertTrue( add.label == "Add Theme" || add.title == "Add Theme", "The add menu must be named for VoiceOver: got label '\(add.label)', title '\(add.title)'" @@ -66,68 +73,81 @@ final class AppearanceSettingsUITests: UITestCase { item.click() } - private func rename(selectedThemeTo name: String, in window: XCUIElement) { + /// The shared color panel cannot be driven reliably; ThemeEditorModelTests covers what a well hands back. + private func resetAndUndoEditorBackground(in editor: XCUIElement, app: XCUIApplication, themeName: String) { + let reset = element("theme-color-reset-editor.background", in: editor) + XCTAssertTrue(reset.waitToExist(timeout: 10), "A Dracula copy sets the editor background") + XCTAssertEqual(reset.label, "Use System Color for Editor Background") + let slots = element("theme-editor-slots", in: editor) + XCTAssertTrue(slots.waitToExist(timeout: 5)) + reveal(reset, in: slots, scrollingAt: CGVector(dx: 0.5, dy: 0.5)) + reset.click() + XCTAssertTrue(waitForPredicate(timeout: 5) { !reset.exists }, "Use System Color should clear the slot") + let state = element("theme-color-state-editor.background", in: editor) + XCTAssertTrue( + waitForPredicate(timeout: 5) { (state.value as? String) == "System color" }, + "The row should say the slot follows the system" + ) + XCTAssertTrue( + waitForPredicate(timeout: 5) { self.editorColor("background", ofThemeNamed: themeName) == nil }, + "The cleared slot should be saved" + ) + + app.typeShortcut(.undo) + XCTAssertTrue(reset.waitToExist(timeout: 5), "Undo should put the color back") + XCTAssertTrue( + waitForPredicate(timeout: 5) { self.editorColor("background", ofThemeNamed: themeName) != nil }, + "The undone change should be saved" + ) + } + + private func renameInRow(_ name: String, to newName: String, in window: XCUIElement) { + let themeRow = row(name, in: window) + XCTAssertTrue(waitUntilHittable(themeRow, timeout: 10)) + themeRow.click() + let actions = element("appearance-theme-actions", in: window) + XCTAssertTrue(actions.waitToExist(timeout: 10)) + revealInForm(actions, in: window) XCTAssertTrue(waitUntilHittable(actions, timeout: 10)) XCTAssertTrue( actions.label == "Theme Actions" || actions.title == "Theme Actions", "The action menu must be named for VoiceOver: got label '\(actions.label)', title '\(actions.title)'" ) actions.click() - let renameItem = window.menus.firstMatch.menuItems["Rename…"] + let renameItem = window.menus.firstMatch.menuItems["Rename"] XCTAssertTrue(renameItem.waitToExist(timeout: 5)) renameItem.click() - let alert = window.sheets.firstMatch - XCTAssertTrue(alert.waitToExist(timeout: 5), "Rename should ask for the new name") - let field = alert.textFields.firstMatch - XCTAssertTrue(waitUntilHittable(field, timeout: 5)) - field.click() + let field = element("appearance-theme-rename-field", in: window) + XCTAssertTrue(waitUntilHittable(field, timeout: 5), "Rename should edit the name in its row") field.typeKey("a", modifierFlags: .command) - field.typeText(name) - alert.buttons["Rename"].click() - XCTAssertTrue(waitForPredicate(timeout: 5) { !alert.exists }) + field.typeText(newName + "\r") + XCTAssertTrue(waitForPredicate(timeout: 5) { !field.exists }, "Return should end the rename") + XCTAssertTrue(row(newName, in: window).waitToExist(timeout: 5), "The list should show the new name") + XCTAssertFalse(row(name, in: window).exists) } - private func clearBackgroundOverride(ofThemeNamed name: String, in window: XCUIElement) { + private func moveToTrash(_ name: String, in window: XCUIElement) { let themeRow = row(name, in: window) XCTAssertTrue(waitUntilHittable(themeRow, timeout: 10)) - themeRow.doubleClick() - - let sheet = window.sheets.firstMatch - XCTAssertTrue(sheet.waitToExist(timeout: 10), "Double-clicking a theme should open its editor") - - let reset = sheet.buttons["theme-color-reset-editor.background"] - XCTAssertTrue(reset.waitToExist(timeout: 10), "Every color of a Dracula copy starts overridden") - XCTAssertEqual(reset.label, "Use Default") - XCTAssertTrue( - scrollUntilHittable(reset, in: sheet.scrollViews.firstMatch, timeout: 10), - "The Editor Background row must scroll into the editor's list" - ) - reset.click() - XCTAssertTrue(waitForPredicate(timeout: 5) { !reset.exists }, "Use Default should clear the override") - XCTAssertTrue( - waitForPredicate(timeout: 5) { - self.themeDocument(named: name) != nil && self.editorColor("background", ofThemeNamed: name) == nil - }, - "The cleared override should be saved" - ) - - // The shared color panel cannot be driven reliably; ThemeEditorDraftTests covers what a well hands back. - let background = element("theme-color-editor.background", in: sheet) - XCTAssertTrue(waitUntilHittable(background, timeout: 5), "The cleared slot keeps its well") + themeRow.click() + let remove = window.buttons["appearance-theme-remove"] + XCTAssertTrue(remove.waitToExist(timeout: 5)) + revealInForm(remove, in: window) + XCTAssertEqual(remove.label, "Move to Trash") + XCTAssertTrue(waitForPredicate(timeout: 5) { remove.isEnabled }, "A selected custom theme can be trashed") + remove.click() - let done = sheet.buttons["theme-editor-done"] - XCTAssertTrue(done.waitToExist(timeout: 5)) - done.click() - XCTAssertTrue(waitForPredicate(timeout: 5) { !sheet.exists }, "Done should close the editor") - XCTAssertNil(editorColor("background", ofThemeNamed: name), "Closing the editor keeps the last change") + let confirmation = window.sheets.firstMatch + XCTAssertTrue(confirmation.waitToExist(timeout: 5), "Trashing the theme a slot uses should ask first") + confirmation.buttons["Move to Trash"].click() + XCTAssertTrue(waitForPredicate(timeout: 5) { !themeRow.exists }, "The trashed theme should leave the list") } // MARK: - Helpers - private func openAppearancePane() throws -> XCUIElement { - let app = try launchOpeningURL("tablepro://settings/appearance") + private func appearancePane(in app: XCUIApplication) throws -> XCUIElement { let settings = window("settings", in: app) XCTAssertTrue(settings.waitToExist(timeout: 10)) XCTAssertTrue( @@ -137,6 +157,30 @@ final class AppearanceSettingsUITests: UITestCase { return settings } + /// The Settings pane scrolls over its top sections, because a scroll over the theme list + /// scrolls the list instead. + private func revealInForm(_ control: XCUIElement, in window: XCUIElement) { + let form = window.scrollViews.firstMatch + XCTAssertTrue(form.waitToExist(timeout: 5)) + reveal(control, in: form, scrollingAt: CGVector(dx: 0.5, dy: 0.05)) + } + + /// A control clipped by its scroll view still reads as hittable, and a click on it lands on + /// whatever is drawn there, so the frames decide when it is in view. + private func reveal(_ control: XCUIElement, in scrollView: XCUIElement, scrollingAt point: CGVector) { + let deadline = Date(timeIntervalSinceNow: 10) + while control.frame.maxY > scrollView.frame.maxY || control.frame.minY < scrollView.frame.minY, Date() < deadline { + let overshoot = control.frame.maxY > scrollView.frame.maxY + ? scrollView.frame.maxY - control.frame.maxY - 20 + : scrollView.frame.minY - control.frame.minY + 20 + scrollView.coordinate(withNormalizedOffset: point).scroll(byDeltaX: 0, deltaY: overshoot) + } + XCTAssertTrue( + control.frame.minY >= scrollView.frame.minY && control.frame.maxY <= scrollView.frame.maxY, + "\(control.identifier) should scroll into view" + ) + } + private func choose(_ title: String, in popup: XCUIElement) { XCTAssertTrue(waitUntilHittable(popup, timeout: 10)) popup.click() diff --git a/project.yml b/project.yml index b773fd6e30..3565b0736a 100644 --- a/project.yml +++ b/project.yml @@ -403,7 +403,7 @@ targets: - package: Sparkle - package: Yams - package: TableProCore - products: [TableProAnalytics, TableProConnectionLibrary, TableProGeometry, TableProGoogleCloud, TableProImport, TableProLogRedaction, TableProNumberFormatting, TableProRedisCore, TableProSQLGrammar, TableProSSHTransport, TableProSyncTransport, TableProTabular, TableProTabularIO] + products: [TableProAnalytics, TableProConnectionLibrary, TableProGeometry, TableProGoogleCloud, TableProImport, TableProLogRedaction, TableProNumberFormatting, TableProRedisCore, TableProSQLGrammar, TableProSSHTransport, TableProSyncTransport, TableProTabular, TableProTabularIO, TableProTheme] - package: TableProMacCore products: [TableProAutocomplete, TableProMCP, TableProSSHConfig, TableProUtilities] settings: