diff --git a/.claude/rules/data-grid.md b/.claude/rules/data-grid.md index 0686c9cf17..272876fd8b 100644 --- a/.claude/rules/data-grid.md +++ b/.claude/rules/data-grid.md @@ -41,14 +41,14 @@ Three layers, knowledge pointing one way: an owner's adapter, then `DataGridMode - **Every column stays attached.** Never hide an off-screen column with `NSTableColumn.isHidden`: each write costs O(attached columns). Columns come from each pane's pool, keyed by `DataGridColumnID`, so a re-run of the same result does not flicker (#951). - **Resolve a table column through `DataGridPane.slot(atTableIndex:columns:)` and `tableIndex(forDisplayPosition:columns:)`**, never a fixed index into `tableColumns`: the row-number column sits first and the frozen pane holds a subset. - **`gridStyleMask` stays empty.** AppKit's vertical grid lines keep a subview per column (518 ms a layout at 500 columns). `DataGridBodyChrome` draws the separators in each row, and the stripes and separators past the last row. -- **Selection is the system's.** `drawSelection(in:)` fills the whole row in a row selection and the selected columns' span in a cell range: `selectedContentBackgroundColor` when emphasized, else `unemphasizedSelectedContentBackgroundColor`. The active cell is an inner stroke in `keyboardFocusIndicatorColor`, 2 pt with focus and 1 pt without, in the selected text color over an emphasized fill. No outline goes round a range. +- **Selection takes the theme.** `drawSelection(in:)` fills the whole row in a row selection and the selected columns' span in a cell range: `dataGrid.selection` when emphasized, else `dataGrid.inactiveSelection`. The active cell is an inner stroke in `dataGrid.focusBorder`, 2 pt with focus and 1 pt without, in the selected text color over an emphasized fill. No outline goes round a range. - **Never draw the active cell with the focus ring API.** From a row view the ring loses its top and bottom; on an `NSTableView` it draws nothing. -- **Colors come from `model.themeScope`**, read once per draw. A theme, Accent or Increase Contrast change repaints the visible rows and never reloads data. `ThemeEngine.shared` supplies only the fonts. No theme slot colors the header, the selection, selected text or the active-cell border (`.claude/rules/theme.md`). +- **Colors come from `model.themeScope`**, read once per draw. A theme, Accent or Increase Contrast change repaints the visible rows and never reloads data. `ThemeEngine.shared` supplies only the fonts. - **The row view draws the column a header drag is moving**, at the header's `draggedDistance`: drawn cells have no views for AppKit to float. ## Header -- **Stock `NSTableHeaderView` and `NSTableHeaderCell`.** `DataGridHeaderView` overrides only `menu(for:)` and `viewWillDraw`, which repaints the body on each step of a column drag. AppKit draws the primary sort arrow; the controller sets each header cell's `accessibilitySortDirection`, which AppKit leaves unknown. +- **AppKit's header behavior in the theme's colors.** `DataGridHeaderView` is a `ThemeTableHeaderView` and every column a `ThemeTableHeaderCell` (`.claude/rules/theme.md`); it adds `menu(for:)` and a `viewWillDraw` that repaints the body on each step of a column drag. AppKit draws the title and the primary sort arrow; the controller sets each header cell's `accessibilitySortDirection`, which AppKit leaves unknown. - **The model's sort is the source.** `tableView.sortDescriptors` is written from `store.sortState` under `isApplyingSortDescriptors`, because an assignment inside the callback fires it again. A plain click keeps only the clicked key and flips it on a repeat; an Option-click keeps the key AppKit appended (`HeaderSortCycle`). Shift-click reaches no sort callback, and Command-click selects the column. - **Header text is the column name.** The type, the comment and, while several keys sort, the sort order go in `headerToolTip`; an active value filter adds a funnel symbol attachment to the title, in the header text color. diff --git a/.claude/rules/theme.md b/.claude/rules/theme.md index d552dbcc44..a95c0b71af 100644 --- a/.claude/rules/theme.md +++ b/.claude/rules/theme.md @@ -21,7 +21,6 @@ paths: - **`ThemeSlot` is the one registry.** A slot exists only while a view reads it: removing its last reader removes the slot. - `isOverridden(_:)` means the slot draws a theme color, set or derived: ask it before swapping a native drawing path (a code table's selection, the caret). Only the theme editor asks `hasExplicitColor(_:)`. - **Adding a slot bumps `ThemeSchema.current`.** `ThemeFileFormat` reads only the current schema: an older or newer one, or an unknown key, is refused rather than loaded without colors its author wrote, so a bump stops every older file from loading. The bump renames the sync content kind (Sync). -- **Removing a slot never bumps the schema.** Its key joins `ThemeFileFormat.retiredKeys`, which the decoder drops, so a file that still carries it loads without it. - **An imported file is untrusted.** `ThemeFileImporter` checks the size before it reads, mints a fresh id every time, and never follows a VS Code `include` out of the file's folder. ## Reading colors @@ -29,7 +28,7 @@ paths: - **A scoped carrier reads through its `ThemeScope`, never `ThemeEngine.shared`.** Only the theme editor preview uses `.fixed(_:)`, and a new definition is a new scope. - `palette.color(_:otherwise:)` returns a new object on every call: never compare it to skip a write. Bake `resolved` into a `CGColor` only inside the drawing view's appearance (`resolved.cgColor(_:otherwise:in:)`). - **Hold every AppKit carrier** (`ThemeTextViewStyle`, `ThemeTableChrome`, never on a source list) **as a stored property.** Its subscription and row views hold it weakly, so one held in a local stops at the first theme change. -- **Header and selection are always the system's.** No slot colors a table header, a records row's selection or its selected text, or the data grid's active-cell border, so they follow Accent, focus and Increase Contrast. `ThemeTableChrome` never replaces a header; only a code table recolors its selection, with `editor.selection`. +- **A records table's header and selection have one drawing path.** The header band, its titles, the selection, the inactive selection, selected text and the data grid's active-cell border always draw `dataGrid.headerBackground`, `headerText`, `selection`, `inactiveSelection`, `selectionText` and `focusBorder`; an unset slot resolves to the system color. AppKit's header cell paints an opaque band and colors a title only from its attributes, so a records header is a `ThemeTableHeaderView` with `ThemeTableHeaderCell` columns: the cell paints the band and AppKit's interior draws the title and sort arrow, which the view styles before each draw. A code table recolors its selection with `editor.selection` only while the theme sets it. - **A theme change has to reach the view.** SwiftUI caches `Color(nsColor:)` by object identity, so a view reading the engine holds `@ObservedObject private var themeEngine = ThemeEngine.shared`; an AppKit view that bakes a color re-bakes on `AppEvents.shared.themeChanged`. `withAlphaComponent` on a system color outside `NSColor(name:dynamicProvider:)` freezes it. - **No theme color on selectable-row text**: it does not turn white on the emphasized selection. Never put `.themeForeground` on a container of selectable rows; `ThemeTableChrome.recordsTextSlot(...)` picks a records cell's slot. diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift b/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift index 0f0365a41c..6269a02475 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift @@ -82,8 +82,20 @@ internal extension ThemeSlot { 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. diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift index a8ad66013d..4bdaab4028 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift @@ -1,6 +1,5 @@ import Foundation -// Removing a slot never bumps it: the slot's key joins `ThemeFileFormat.retiredKeys`. public enum ThemeSchema { public static let current = 3 } @@ -26,16 +25,6 @@ public enum ThemeIdentifier { public enum ThemeFileFormat { public static let maximumTextLength = 100 - // Removed slots: a file that still carries one loads without it. - static let retiredKeys: Set = [ - "dataGrid.headerBackground", - "dataGrid.headerText", - "dataGrid.selection", - "dataGrid.selectionText", - "dataGrid.inactiveSelection", - "dataGrid.focusBorder", - ] - public enum TextPolicy: Sendable { // A file someone picked, and every record another Mac sent. case strict @@ -137,7 +126,6 @@ public enum ThemeFileFormat { let leaves = root .filter { MetadataKey(rawValue: $0.key) == nil } .flatMap { key, node in node.leaves(prefix: key) } - .filter { !retiredKeys.contains($0.path) } .sorted { $0.path < $1.path } var parsed: [ThemeSlot: ThemeColor] = [:] diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift index 29a24bf997..4b4489b75d 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift @@ -24,7 +24,13 @@ public enum ThemeSlot: String, CaseIterable, Sendable { 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" @@ -44,7 +50,8 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType: return .syntax - case .gridBackground, .gridText, .gridAlternateRow, .gridLine, + case .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, + .gridLine, .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return .dataGrid @@ -61,9 +68,11 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType: return .syntax - case .gridBackground, .gridText, .gridAlternateRow, .gridLine, - .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber: + 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 } @@ -77,13 +86,14 @@ public enum ThemeSlot: String, CaseIterable, Sendable { // A translucent fill under everything else would show the desktop through the window. public var requiresOpaque: Bool { switch self { - case .windowBackdrop, .editorBackground, .gridBackground: + 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, .gridLine, .gridNullValue, .gridBoolTrue, .gridBoolFalse, + .gridText, .gridAlternateRow, .gridHeaderText, .gridLine, .gridSelection, .gridSelectionText, + .gridInactiveSelection, .gridFocusBorder, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return false } @@ -92,12 +102,14 @@ public enum ThemeSlot: String, CaseIterable, Sendable { public var offersOpacity: Bool { switch self { case .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorInvisibles, - .gridAlternateRow, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: + .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, .gridLine, .gridNullValue, .gridBoolTrue, .gridBoolFalse, + .gridBackground, .gridText, .gridHeaderBackground, .gridHeaderText, .gridLine, + .gridSelectionText, .gridFocusBorder, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber: return false } @@ -108,7 +120,7 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case .windowBackdrop, .editorBackground, .gridBackground: return String(localized: "Background") case .editorText, .gridText: return String(localized: "Text") case .editorCursor: return String(localized: "Cursor") - case .editorSelection: return String(localized: "Selection") + 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") @@ -122,7 +134,12 @@ public enum ThemeSlot: String, CaseIterable, Sendable { 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") @@ -157,7 +174,13 @@ public enum ThemeSlot: String, CaseIterable, Sendable { 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") diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift index 9a0cce3b3a..83cbe6112d 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift @@ -25,7 +25,8 @@ public extension ThemeSlot { case .windowBackdrop, .editorBackground, .editorText, .editorCursor, .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles, - .gridBackground, .gridText, .gridLine, + .gridBackground, .gridText, .gridHeaderBackground, .gridHeaderText, .gridLine, + .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder, .gridNullValue, .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return .system case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift index 67e87176f3..f64c4e6109 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift @@ -1,11 +1,12 @@ import Foundation -// The theme editor's grouping: the row change colors, chosen together, get a section of their own. +// The theme editor's grouping: selection colors and row change colors, each chosen as a set, get sections of their own. public enum ThemeSlotSection: String, CaseIterable, Sendable { case window case editor case syntax case dataGrid + case selection case changes public var label: String { @@ -14,6 +15,7 @@ public enum ThemeSlotSection: String, CaseIterable, Sendable { 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") } } diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift index 1c6739c747..c0d16fd667 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift @@ -135,13 +135,14 @@ struct VSCodeThemeImportTests { #expect(dark.hex(.editorText) == nil) } - @Test("The three fills nothing themed sits under are flattened; washes keep their alpha") + @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" }} @@ -153,6 +154,7 @@ struct VSCodeThemeImportTests { #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)") } @@ -176,22 +178,23 @@ struct VSCodeThemeImportTests { #expect(theme.value(for: .gridBoolTrue) == .themed(ThemeColor(red: 0xE0, green: 0xE0, blue: 0xE0))) } - @Test("A VS Code theme's list selection, focus and table header colors import nothing: those stay the system's") - func selectionAndHeaderColorsImportNothing() throws { + @Test("A VS Code theme's table header, list selection and focus colors import as set, even where they match the grid") + func selectionAndHeaderColorsImport() throws { let theme = try Self.theme( """ {"colors": {"editor.background": "#202020", "editor.foreground": "#E0E0E0", "list.activeSelectionBackground": "#0050A0", "list.activeSelectionForeground": "#FFFFFF", "list.inactiveSelectionBackground": "#00305080", "list.focusOutline": "#0080FF", - "focusBorder": "#3366FF", "keybindingTable.headerBackground": "#2A2A2A", "foreground": "#C0C0C0"}} + "focusBorder": "#3366FF", "keybindingTable.headerBackground": "#202020", "foreground": "#E0E0E0"}} """ ) - let imported = Set(theme.overrides.values.map(\.hex)) - for hex in ["#0050A0", "#FFFFFF", "#00305080", "#0080FF", "#3366FF", "#2A2A2A", "#C0C0C0"] { - #expect(!imported.contains(hex), "\(hex)") - } - #expect(theme.hex(.gridBackground) == "#202020") + #expect(theme.hex(.gridHeaderBackground) == "#202020") + #expect(theme.hex(.gridHeaderText) == "#E0E0E0") + #expect(theme.hex(.gridSelection) == "#0050A0") + #expect(theme.hex(.gridSelectionText) == "#FFFFFF") + #expect(theme.hex(.gridInactiveSelection) == "#00305080") + #expect(theme.hex(.gridFocusBorder) == "#0080FF") } @Test("An imported theme survives the TablePro format unchanged") diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift index 62040133ce..99a31ad1a2 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift @@ -19,8 +19,15 @@ struct VSCodeThemeMappingTests { "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", @@ -66,7 +73,13 @@ struct VSCodeThemeMappingTests { 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" @@ -87,6 +100,8 @@ struct VSCodeThemeMappingTests { "editor.lineHighlightBorder": "#E0E0E0", "tree.tableOddRowsBackground": "#00000008", "foreground": "#404040", + "focusBorder": "#3366FF", + "list.activeSelectionForeground": "#FFFFFF", "diffEditor.insertedTextBackground": "#00AA0033", "diffEditor.removedTextBackground": "#AA000033" }, "tokenColors": [ @@ -117,7 +132,14 @@ struct VSCodeThemeMappingTests { 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 + case .gridSelectionText: return "#FFFFFF" + case .gridInactiveSelection: return nil + case .gridFocusBorder: return "#3366FF" case .gridNullValue: return "#40404080" case .gridBoolTrue: return nil case .gridBoolFalse: return nil diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift index 136641224c..25e0faeb27 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift @@ -105,7 +105,7 @@ struct ThemeFileFormatTests { } @Test("A fill that must be opaque loads without its alpha, and is written without it", arguments: [ - ThemeSlot.windowBackdrop, .editorBackground, .gridBackground, + ThemeSlot.windowBackdrop, .editorBackground, .gridBackground, .gridHeaderBackground, ]) func opaqueSlotsDropAlphaOnLoad(slot: ThemeSlot) throws { let theme = try Self.decode(Self.withColor("#282A3680", at: slot.rawValue.components(separatedBy: "."))) @@ -117,8 +117,8 @@ struct ThemeFileFormatTests { #expect(reread.overrides[slot]?.hex == "#282A36") } - @Test("A file that still carries the retired header and selection keys loads without them, and saves without them") - func retiredKeysAreDropped() throws { + @Test("A file with header and selection keys loads them as colors, and saves them back") + func headerAndSelectionKeysLoad() throws { let text = """ { "appearance": "dark", @@ -143,22 +143,33 @@ struct ThemeFileFormatTests { let theme = try ThemeFileFormat.decode(Data(text.utf8)) let colors = theme.overrides.mapValues(\.hex) - #expect(colors == [.gridBackground: "#282A36", .gridText: "#F8F8F2", .editorSelection: "#44475A"]) + #expect(colors == [ + .gridBackground: "#282A36", .gridText: "#F8F8F2", .editorSelection: "#44475A", + .gridFocusBorder: "#BD93F9", .gridHeaderBackground: "#21222C", .gridHeaderText: "#F8F8F2", + .gridInactiveSelection: "#44475A80", .gridSelection: "#44475A", .gridSelectionText: "#F8F8F2", + ]) let object = try JSONSerialization.jsonObject(with: try ThemeFileFormat.encode(theme)) let grid = try #require((object as? [String: Any])?["dataGrid"] as? [String: String]) - #expect(grid == ["background": "#282A36", "text": "#F8F8F2"]) + #expect(grid == [ + "background": "#282A36", "focusBorder": "#BD93F9", "headerBackground": "#21222C", "headerText": "#F8F8F2", + "inactiveSelection": "#44475A80", "selection": "#44475A", "selectionText": "#F8F8F2", "text": "#F8F8F2", + ]) + #expect(try ThemeFileFormat.decode(try ThemeFileFormat.encode(theme)) == theme) } - @Test("A retired key is dropped whatever its value, nested or dotted, and only the retired keys are") - func onlyRetiredKeysAreDropped() throws { - var root = Self.metadata() - Self.insert("not a color", at: ["dataGrid", "selection"], into: &root) - Self.insert(16_777_215, at: ["dataGrid", "focusBorder"], into: &root) - root["dataGrid.headerText"] = "#FFFFFF" - #expect(try Self.decode(root).overrides.isEmpty) + @Test("A header or selection key is checked like any other slot") + func headerAndSelectionKeysAreChecked() throws { + #expect(try Self.refusal(Self.withColor("not a color", at: ["dataGrid", "selection"])) == .invalidColor("not a color")) + + var numeric = Self.metadata() + Self.insert(16_777_215, at: ["dataGrid", "focusBorder"], into: &numeric) + #expect(try Self.refusal(numeric) == .invalidColor("dataGrid.focusBorder")) + + var twice = Self.withColor("#000000", at: ["dataGrid", "headerText"]) + twice["dataGrid.headerText"] = "#FFFFFF" + #expect(try Self.refusal(twice) == .duplicateKey("dataGrid.headerText")) - Self.insert("#000000", at: ["dataGrid", "selectionBorder"], into: &root) - #expect(try Self.refusal(root) == .unknownKeys(["dataGrid.selectionBorder"])) + #expect(try Self.refusal(Self.withColor("#000000", at: ["dataGrid", "selectionBorder"])) == .unknownKeys(["dataGrid.selectionBorder"])) } @Test("Name and author are trimmed") diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift index 8379816385..6822fb722c 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift @@ -44,7 +44,13 @@ struct ThemeSlotFallbackTests { .gridBackground: .system, .gridText: .system, .gridAlternateRow: .derived(base: .gridBackground, rule: .stripe(toward: .gridText)), + .gridHeaderBackground: .system, + .gridHeaderText: .system, .gridLine: .system, + .gridSelection: .system, + .gridSelectionText: .system, + .gridInactiveSelection: .system, + .gridFocusBorder: .system, .gridNullValue: .system, .gridBoolTrue: .slot(.gridText), .gridBoolFalse: .slot(.gridText), @@ -150,6 +156,37 @@ struct ThemeSlotFallbackTests { #expect(theme.value(for: .gridAlternateRow) == .themed(Self.color("#EEE8D5"))) } + // MARK: - Header and selection + + private static let headerAndSelection: [ThemeSlot] = [ + .gridHeaderBackground, .gridHeaderText, .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder, + ] + + @Test("Header and selection stay native under a themed grid until the theme sets them") + func headerAndSelectionStayNative() throws { + let theme = try Self.theme(.dark, [.gridBackground: "#282A36", .gridText: "#F8F8F2"]) + for slot in Self.headerAndSelection { + #expect(theme.value(for: slot) == .system(slot), "\(slot.rawValue)") + #expect(!theme.isOverridden(slot), "\(slot.rawValue)") + } + } + + @Test("Each header and selection color draws as set, whatever the others are") + func headerAndSelectionDrawAsSet() throws { + let theme = try Self.theme(.dark, [ + .gridHeaderBackground: "#21222C80", .gridHeaderText: "#F8F8F2", .gridSelectionText: "#50FA7B", + .gridInactiveSelection: "#44475A80", .gridFocusBorder: "#BD93F9", + ]) + + #expect(theme.value(for: .gridHeaderBackground) == .themed(Self.color("#21222C"))) + #expect(theme.value(for: .gridHeaderText) == .themed(Self.color("#F8F8F2"))) + #expect(theme.value(for: .gridSelectionText) == .themed(Self.color("#50FA7B"))) + #expect(theme.value(for: .gridInactiveSelection) == .themed(Self.color("#44475A80"))) + #expect(theme.value(for: .gridFocusBorder) == .themed(Self.color("#BD93F9"))) + #expect(theme.value(for: .gridSelection) == .system(.gridSelection)) + #expect(theme.isOverridden(.gridSelectionText, highContrast: true)) + } + // MARK: - Slot fallbacks @Test("Bools follow themed grid text, and stay native without it") diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift index d8edcf5f54..a097ffd52c 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift @@ -23,9 +23,9 @@ struct ThemeSlotTests { } } - @Test("The registry holds 29 slots") + @Test("The registry holds 35 slots") func slotCount() { - #expect(ThemeSlot.allCases.count == 29) + #expect(ThemeSlot.allCases.count == 35) } @Test("Every slot belongs to exactly one group, listed in registry order") @@ -44,26 +44,33 @@ struct ThemeSlotTests { #expect(sectioned.count == ThemeSlot.allCases.count) } - @Test("The editor sections split the data grid into rows and changes") + @Test("The editor sections split the data grid into rows, selection and changes") func sectionsSplitTheGrid() { - #expect(ThemeSlotSection.allCases == [.window, .editor, .syntax, .dataGrid, .changes]) + #expect(ThemeSlotSection.allCases == [.window, .editor, .syntax, .dataGrid, .selection, .changes]) #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, + .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]) } - @Test("Header and selection colors are the system's, so no slot names them") - func noHeaderOrSelectionSlot() { - for path in [ - "dataGrid.headerBackground", "dataGrid.headerText", "dataGrid.selection", - "dataGrid.selectionText", "dataGrid.inactiveSelection", "dataGrid.focusBorder", - ] { - #expect(ThemeSlot(rawValue: path) == nil, "\(path)") + @Test("The header and selection slots sit under the data grid key") + func headerAndSelectionSlots() { + let expected: [String: ThemeSlot] = [ + "dataGrid.headerBackground": .gridHeaderBackground, + "dataGrid.headerText": .gridHeaderText, + "dataGrid.selection": .gridSelection, + "dataGrid.selectionText": .gridSelectionText, + "dataGrid.inactiveSelection": .gridInactiveSelection, + "dataGrid.focusBorder": .gridFocusBorder, + ] + for (path, slot) in expected { + #expect(ThemeSlot(rawValue: path) == slot, "\(path)") + #expect(slot.group == .dataGrid, "\(path)") } } @@ -86,13 +93,15 @@ struct ThemeSlotTests { } #expect(ThemeSlot.editorBackground.accessibilityLabel == "Editor Background") #expect(ThemeSlot.gridAlternateRow.accessibilityLabel == "Data Grid Alternate Row") + #expect(ThemeSlot.gridSelection.accessibilityLabel == "Data Grid Selection") + #expect(ThemeSlot.gridSelectionText.accessibilityLabel == "Data Grid Selected Text") #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]) + #expect(opaque == [.windowBackdrop, .editorBackground, .gridBackground, .gridHeaderBackground]) } @Test("Opacity is offered on the washes and never on a slot that must be opaque") @@ -100,7 +109,8 @@ struct ThemeSlotTests { let translucent = Set(ThemeSlot.allCases.filter { $0.offersOpacity }) #expect(translucent == [ .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorInvisibles, - .gridAlternateRow, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText, + .gridAlternateRow, .gridSelection, .gridInactiveSelection, + .gridModified, .gridInserted, .gridDeleted, .gridDeletedText, ]) for slot in ThemeSlot.allCases { #expect(!(slot.offersOpacity && slot.requiresOpaque), "\(slot.rawValue)") diff --git a/apple/TablePro/Resources/Themes/tablepro.catppuccin-latte.json b/apple/TablePro/Resources/Themes/tablepro.catppuccin-latte.json index 385ce65b3c..8cf7be7217 100644 --- a/apple/TablePro/Resources/Themes/tablepro.catppuccin-latte.json +++ b/apple/TablePro/Resources/Themes/tablepro.catppuccin-latte.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json b/apple/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json index 3a72c559ef..2ce5a2d0e8 100644 --- a/apple/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json +++ b/apple/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.dracula.json b/apple/TablePro/Resources/Themes/tablepro.dracula.json index c1bf304da2..94a1575329 100644 --- a/apple/TablePro/Resources/Themes/tablepro.dracula.json +++ b/apple/TablePro/Resources/Themes/tablepro.dracula.json @@ -8,11 +8,17 @@ "boolTrue": "#50FA7B", "deleted": "#FF555526", "deletedText": "#FF555580", + "focusBorder": "#BD93F9", "gridLine": "#44475A", + "headerBackground": "#21222C", + "headerText": "#F8F8F2", + "inactiveSelection": "#44475A80", "inserted": "#50FA7B26", "modified": "#FFB86C4D", "nullValue": "#6272A4", "rowNumber": "#6272A4", + "selection": "#44475A", + "selectionText": "#F8F8F2", "text": "#F8F8F2" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.github-dark.json b/apple/TablePro/Resources/Themes/tablepro.github-dark.json index c941acf3d1..cb921d592e 100644 --- a/apple/TablePro/Resources/Themes/tablepro.github-dark.json +++ b/apple/TablePro/Resources/Themes/tablepro.github-dark.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.github-light.json b/apple/TablePro/Resources/Themes/tablepro.github-light.json index 24f5eeb8ea..b18d295e08 100644 --- a/apple/TablePro/Resources/Themes/tablepro.github-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.github-light.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.gruvbox-dark.json b/apple/TablePro/Resources/Themes/tablepro.gruvbox-dark.json index 0a9fa77e46..7cb0d25f12 100644 --- a/apple/TablePro/Resources/Themes/tablepro.gruvbox-dark.json +++ b/apple/TablePro/Resources/Themes/tablepro.gruvbox-dark.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.gruvbox-light.json b/apple/TablePro/Resources/Themes/tablepro.gruvbox-light.json index cab686b6db..2ef6cd9cf9 100644 --- a/apple/TablePro/Resources/Themes/tablepro.gruvbox-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.gruvbox-light.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.nord.json b/apple/TablePro/Resources/Themes/tablepro.nord.json index 5ec322af04..a2b30e1b3b 100644 --- a/apple/TablePro/Resources/Themes/tablepro.nord.json +++ b/apple/TablePro/Resources/Themes/tablepro.nord.json @@ -8,11 +8,17 @@ "boolTrue": "#A3BE8C", "deleted": "#BF616A26", "deletedText": "#BF616A80", + "focusBorder": "#88C0D0", "gridLine": "#434C5E", + "headerBackground": "#3B4252", + "headerText": "#D8DEE9", + "inactiveSelection": "#434C5E80", "inserted": "#A3BE8C26", "modified": "#EBCB8B4D", "nullValue": "#4C566A", "rowNumber": "#4C566A", + "selection": "#434C5E", + "selectionText": "#ECEFF4", "text": "#D8DEE9" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.one-dark.json b/apple/TablePro/Resources/Themes/tablepro.one-dark.json index c350fa8510..f9098d550a 100644 --- a/apple/TablePro/Resources/Themes/tablepro.one-dark.json +++ b/apple/TablePro/Resources/Themes/tablepro.one-dark.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.one-light.json b/apple/TablePro/Resources/Themes/tablepro.one-light.json index 63451b4990..0af9b52720 100644 --- a/apple/TablePro/Resources/Themes/tablepro.one-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.one-light.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.solarized-dark.json b/apple/TablePro/Resources/Themes/tablepro.solarized-dark.json index 07b0431b68..7f7374fcab 100644 --- a/apple/TablePro/Resources/Themes/tablepro.solarized-dark.json +++ b/apple/TablePro/Resources/Themes/tablepro.solarized-dark.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.solarized-light.json b/apple/TablePro/Resources/Themes/tablepro.solarized-light.json index 5b8e6e2967..9ff51235cf 100644 --- a/apple/TablePro/Resources/Themes/tablepro.solarized-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.solarized-light.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.tokyo-night-light.json b/apple/TablePro/Resources/Themes/tablepro.tokyo-night-light.json index d99ba60db0..02342393c3 100644 --- a/apple/TablePro/Resources/Themes/tablepro.tokyo-night-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.tokyo-night-light.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Resources/Themes/tablepro.tokyo-night.json b/apple/TablePro/Resources/Themes/tablepro.tokyo-night.json index 5dcca7b6ae..3fa07811f3 100644 --- a/apple/TablePro/Resources/Themes/tablepro.tokyo-night.json +++ b/apple/TablePro/Resources/Themes/tablepro.tokyo-night.json @@ -8,11 +8,17 @@ "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": { diff --git a/apple/TablePro/Theme/ThemeColor.swift b/apple/TablePro/Theme/ThemeColor.swift index 49ed7e7f06..32dc09a58f 100644 --- a/apple/TablePro/Theme/ThemeColor.swift +++ b/apple/TablePro/Theme/ThemeColor.swift @@ -62,7 +62,13 @@ internal extension ThemeSlot { case .gridBackground: return .controlBackgroundColor case .gridText, .gridBoolTrue, .gridBoolFalse: return .labelColor case .gridAlternateRow: return NativeThemeColors.alternateRow + case .gridHeaderBackground: return .controlBackgroundColor + case .gridHeaderText: return .headerTextColor case .gridLine: return .separatorColor + case .gridSelection: return .selectedContentBackgroundColor + case .gridSelectionText: return .alternateSelectedControlTextColor + case .gridInactiveSelection: return .unemphasizedSelectedContentBackgroundColor + case .gridFocusBorder: return .keyboardFocusIndicatorColor case .gridNullValue: return .secondaryLabelColor case .gridRowNumber: return .secondaryLabelColor case .gridModified: return NativeThemeColors.modifiedRow diff --git a/apple/TablePro/Theme/ThemeTableChrome.swift b/apple/TablePro/Theme/ThemeTableChrome.swift index b65bf2a13c..ed7ee842b6 100644 --- a/apple/TablePro/Theme/ThemeTableChrome.swift +++ b/apple/TablePro/Theme/ThemeTableChrome.swift @@ -31,10 +31,9 @@ internal final class ThemeTableChrome { } } - // A records table's selection is always the system's, as the data grid's is. - internal var selection: ThemeSlot? { + internal func selection(emphasized: Bool) -> ThemeSlot { switch self { - case .records: return nil + case .records: return emphasized ? .gridSelection : .gridInactiveSelection case .code: return .editorSelection } } @@ -48,7 +47,8 @@ internal final class ThemeTableChrome { internal let family: Family internal let scope: ThemeScope - // A row class that overrides a drawing method loses AppKit's layer-backed selection, so it is used only while needed. + // A records table always draws its theme's selection. A code table takes the overriding row class only while the + // theme needs it, since that class loses AppKit's layer-backed selection. internal private(set) var themesRows = false internal private(set) var isRebuildingRows = false @@ -99,14 +99,19 @@ internal final class ThemeTableChrome { return palette[alternate] } - internal func selectionColor(emphasized _: Bool) -> NSColor? { - guard let slot = family.selection else { return nil } + // A records table draws the theme's selection as the data grid does; a code table recolors AppKit's only while + // the theme sets `editor.selection`, since its text keeps the editor's colors on it. + internal func selectionColor(emphasized: Bool) -> NSColor? { let resolved = scope.resolved - return resolved.isOverridden(slot) ? resolved[slot] : nil + let slot = family.selection(emphasized: emphasized) + switch family { + case .records: return resolved[slot] + case .code: return resolved.isOverridden(slot) ? resolved[slot] : nil + } } - private func isOnThemedSelection(_ rowView: NSTableRowView) -> Bool { - rowView.isSelected && selectionColor(emphasized: rowView.isEmphasized) != nil + private func isOnRecoloredCodeSelection(_ rowView: NSTableRowView) -> Bool { + family == .code && rowView.isSelected && selectionColor(emphasized: rowView.isEmphasized) != nil } private func restyle() { @@ -115,8 +120,9 @@ internal final class ThemeTableChrome { 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) - themesRows = isBackgroundThemed || family.selection.map { resolved.isOverridden($0) } ?? false + themesRows = isBackgroundThemed || selectionColor(emphasized: true) != nil tableView.needsDisplay = true + tableView.headerView?.needsDisplay = true tableView.enumerateAvailableRowViews { rowView, _ in rowView.needsDisplay = true } @@ -256,9 +262,9 @@ internal extension ThemeTableChrome { context.restoreGState() } - // A theme picks its selection to sit behind its own text, so the text does not flip to white. + // An editor selection sits behind the editor's own text, so the text does not flip to white. func interiorBackgroundStyle(of rowView: NSTableRowView, native: NSView.BackgroundStyle) -> NSView.BackgroundStyle { - isOnThemedSelection(rowView) ? .normal : native + isOnRecoloredCodeSelection(rowView) ? .normal : native } } @@ -291,13 +297,13 @@ internal final class ThemeTableRowView: NSTableRowView, ThemeTableRowDrawing { // MARK: - Records cell text internal extension ThemeTableChrome { - // Nil keeps the cell's own hierarchical text, which turns white on AppKit's emphasized selection. + // Nil keeps the cell's own hierarchical text. static func recordsTextSlot( onEmphasizedSelection: Bool, highContrast: Bool, scope: ThemeScope = .live ) -> ThemeSlot? { - guard !onEmphasizedSelection else { return nil } + guard !onEmphasizedSelection else { return .gridSelectionText } return scope.activeTheme.isOverridden(.gridText, highContrast: highContrast) ? .gridText : nil } diff --git a/apple/TablePro/Theme/ThemeTableHeader.swift b/apple/TablePro/Theme/ThemeTableHeader.swift new file mode 100644 index 0000000000..de96571768 --- /dev/null +++ b/apple/TablePro/Theme/ThemeTableHeader.swift @@ -0,0 +1,87 @@ +import AppKit +import TableProTheme + +// AppKit's header cell paints an opaque band and colors a title only from the title's own attributes, so the +// header view styles each title before a draw and `ThemeTableHeaderCell` paints the band. +@MainActor +class ThemeTableHeaderView: NSTableHeaderView { + var themeScope: ThemeScope { .live } + + // On macOS 26 the header sits in the scroll pocket's vibrant banner, which would blend the band lighter (measured). + override var allowsVibrancy: Bool { false } + + // AppKit bolds the highlighted column's title only in the highlight pass the themed band replaces. + override func viewWillDraw() { + super.viewWillDraw() + guard let tableView else { return } + let color = themeScope.resolved[.gridHeaderText] + let highlighted = tableView.highlightedTableColumn + for column in tableView.tableColumns { + (column.headerCell as? ThemeTableHeaderCell)?.styleTitle(color: color, bold: column === highlighted) + } + } +} + +// AppKit copies the last column's cell to draw past the last column, so the cell holds no object references. +@MainActor +final class ThemeTableHeaderCell: NSTableHeaderCell { + // AppKit's interior band, measured on macOS 27: 16 pt, centered in any header height and for any font. + static let interiorHeight: CGFloat = 16 + static let lineWidth: CGFloat = 1 + + override func draw(withFrame cellFrame: NSRect, in controlView: NSView) { + let header = controlView as? NSTableHeaderView + let resolved = ((header as? ThemeTableHeaderView)?.themeScope ?? .live).resolved + resolved[.gridHeaderBackground].setFill() + cellFrame.fill() + + let interior = Self.interiorRect(in: cellFrame) + resolved[.gridLine].setFill() + Self.bottomLineRect(in: cellFrame).fill(using: .sourceOver) + if let tableView = header?.tableView, !Self.isFiller(cellFrame, of: tableView) { + Self.dividerRect(in: cellFrame, interior: interior).fill(using: .sourceOver) + } + drawInterior(withFrame: interior, in: controlView) + } + + // AppKit's highlight pass draws its own band; the highlighted title is bold instead (`ThemeTableHeaderView`). + override func highlight(_ flag: Bool, withFrame cellFrame: NSRect, in controlView: NSView) { + draw(withFrame: cellFrame, in: controlView) + } + + func styleTitle(color: NSColor, bold: Bool) { + let title = attributedStringValue + guard title.length > 0 else { return } + let base = font ?? .systemFont(ofSize: NSFont.smallSystemFontSize) + let titleFont = bold ? NSFontManager.shared.convert(base, toHaveTrait: .boldFontMask) : base + if title.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == color, + title.attribute(.font, at: 0, effectiveRange: nil) as? NSFont == titleFont { + return + } + let styled = NSMutableAttributedString(attributedString: title) + styled.addAttributes( + [.foregroundColor: color, .font: titleFont], + range: NSRange(location: 0, length: styled.length) + ) + attributedStringValue = styled + } + + static func interiorRect(in cellFrame: NSRect) -> NSRect { + let height = min(interiorHeight, cellFrame.height) + return NSRect(x: cellFrame.minX, y: cellFrame.midY - height / 2, width: cellFrame.width, height: height) + } + + static func bottomLineRect(in cellFrame: NSRect) -> NSRect { + NSRect(x: cellFrame.minX, y: cellFrame.maxY - lineWidth, width: cellFrame.width, height: lineWidth) + } + + static func dividerRect(in cellFrame: NSRect, interior: NSRect) -> NSRect { + NSRect(x: cellFrame.maxX - lineWidth, y: interior.minY, width: lineWidth, height: interior.height) + } + + // The band past the last column has no divider at its end. + static func isFiller(_ cellFrame: NSRect, of tableView: NSTableView) -> Bool { + guard tableView.numberOfColumns > 0 else { return true } + return cellFrame.minX >= tableView.rect(ofColumn: tableView.numberOfColumns - 1).maxX + } +} diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridCellPalette.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPalette.swift index 21d3df7aac..4f015cde7f 100644 --- a/apple/TablePro/Views/DataGrid/Cells/DataGridCellPalette.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPalette.swift @@ -22,6 +22,7 @@ struct DataGridCellPalette: Equatable { let findMatchTint: NSColor let findMatchText: NSColor let selectionText: NSColor + let focusBorder: NSColor init( regularFont: NSFont, @@ -37,7 +38,8 @@ struct DataGridCellPalette: Equatable { modifiedColumnTint: NSColor, findMatchTint: NSColor, findMatchText: NSColor = DataGridCellPalette.findMatchTextColor, - selectionText: NSColor = .alternateSelectedControlTextColor + selectionText: NSColor, + focusBorder: NSColor ) { self.regularFont = regularFont self.italicFont = italicFont @@ -53,22 +55,9 @@ struct DataGridCellPalette: Equatable { self.findMatchTint = findMatchTint self.findMatchText = findMatchText self.selectionText = selectionText + self.focusBorder = focusBorder } - static let placeholder = DataGridCellPalette( - regularFont: .systemFont(ofSize: NSFont.systemFontSize), - italicFont: .systemFont(ofSize: NSFont.systemFontSize), - mediumFont: .systemFont(ofSize: NSFont.systemFontSize, weight: .medium), - text: .labelColor, - placeholderText: .secondaryLabelColor, - booleanTrueText: .labelColor, - booleanFalseText: .labelColor, - rowNumberText: .secondaryLabelColor, - deletedRowText: .secondaryLabelColor, - modifiedColumnTint: .systemYellow, - findMatchTint: .findHighlightColor - ) - // NSTextFinder draws its find indicator as a light bubble in every appearance, so the text on it is light-appearance text. static let findMatchTextColor: NSColor = { var color = NSColor.textColor @@ -103,7 +92,9 @@ extension ThemeScope { rowNumberText: resolved[.gridRowNumber], deletedRowText: Self.resolvedTint(.gridDeletedText, of: resolved, in: appearance), modifiedColumnTint: Self.resolvedTint(.gridModified, of: resolved, in: appearance), - findMatchTint: .findHighlightColor + findMatchTint: .findHighlightColor, + selectionText: resolved[.gridSelectionText], + focusBorder: resolved[.gridFocusBorder] ) } diff --git a/apple/TablePro/Views/DataGrid/DataGridHeaderView.swift b/apple/TablePro/Views/DataGrid/DataGridHeaderView.swift index c2812f2e9e..1e0e5c8d5c 100644 --- a/apple/TablePro/Views/DataGrid/DataGridHeaderView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridHeaderView.swift @@ -5,9 +5,9 @@ import AppKit -// The stock header. It overrides only the menu, resolved from the event, and the per-step repaint drawn cells need. +// The menu is resolved from the event, and drawn cells need a repaint on each step of a column drag. @MainActor -final class DataGridHeaderView: NSTableHeaderView { +final class DataGridHeaderView: ThemeTableHeaderView { var paneKind: DataGridPane.Kind = .main private var bodyFollowsColumnDrag = false @@ -15,6 +15,10 @@ final class DataGridHeaderView: NSTableHeaderView { (tableView as? DataGridTableView)?.controller } + override var themeScope: ThemeScope { + controller?.model.themeScope ?? .live + } + override func menu(for event: NSEvent) -> NSMenu? { guard let tableView else { return nil } let index = column(at: convert(event.locationInWindow, from: nil)) diff --git a/apple/TablePro/Views/DataGrid/DataGridPane.swift b/apple/TablePro/Views/DataGrid/DataGridPane.swift index a604d916f9..29cc00195f 100644 --- a/apple/TablePro/Views/DataGrid/DataGridPane.swift +++ b/apple/TablePro/Views/DataGrid/DataGridPane.swift @@ -106,6 +106,7 @@ final class DataGridPane { if let existing = columnsByID[id] { return existing } let identifier = NSUserInterfaceItemIdentifier("DataGrid.column.\(id.stringValue)") let column = NSTableColumn(identifier: identifier) + column.headerCell = ThemeTableHeaderCell() column.minWidth = DataGridMetrics.dataColumnMinWidth column.maxWidth = DataGridMetrics.dataColumnMaxWidth column.resizingMask = .userResizingMask @@ -117,6 +118,7 @@ final class DataGridPane { func pooledRowNumberColumn() -> NSTableColumn { if let rowNumberColumn { return rowNumberColumn } let column = NSTableColumn(identifier: Self.rowNumberIdentifier) + column.headerCell = ThemeTableHeaderCell() column.title = "#" column.isEditable = false column.resizingMask = [] diff --git a/apple/TablePro/Views/DataGrid/DataGridRowView.swift b/apple/TablePro/Views/DataGrid/DataGridRowView.swift index 11175a1b10..98250d144f 100644 --- a/apple/TablePro/Views/DataGrid/DataGridRowView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridRowView.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProTheme // Draws its pane's cells: NSTableView mounts a view for every column of every prepared row, which at 200 columns // costs 45 ms a frame against 1.5 ms drawn (probe P1). @@ -102,8 +103,7 @@ final class DataGridRowView: NSTableRowView { override func drawSelection(in dirtyRect: NSRect) { guard isSelected, let controller, let gridTable else { return } let row = rowIndex - let color: NSColor = isEmphasized ? .selectedContentBackgroundColor : .unemphasizedSelectedContentBackgroundColor - color.setFill() + controller.model.themeScope.resolved[isEmphasized ? .gridSelection : .gridInactiveSelection].setFill() for rect in controller.selectionRects(forRow: row, in: gridTable, within: convert(dirtyRect, to: gridTable)) { let local = convert(rect, from: gridTable) let band = NSRect(x: local.minX, y: bounds.minY, width: local.width, height: bounds.height) diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Drawing.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Drawing.swift index c9cfeb609e..46d9a5b9c3 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController+Drawing.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Drawing.swift @@ -134,7 +134,7 @@ extension DataGridViewController { } // An inner stroke, 2 pt while the grid has focus and 1 pt otherwise: a focus ring from a row view loses its top - // and bottom (critic B M5). On an emphasized fill it takes the selected text color, which stays visible there. + // and bottom. On an emphasized fill it takes the selected text color, which stays visible there. private func drawActiveCellBorder(of view: NSView, table: DataGridTableView, pane: DataGridPane, line: DrawnRow) { guard let active = line.pass.selection.activeCell, active.row == line.row, editedCell != active, @@ -148,7 +148,7 @@ extension DataGridViewController { height: view.bounds.height ) let onFill = line.isEmphasized && line.selected.contains(active.displayColumn) - let color = onFill ? line.pass.palette.selectionText : NSColor.keyboardFocusIndicatorColor + let color = onFill ? line.pass.palette.selectionText : line.pass.palette.focusBorder let path = NSBezierPath(rect: cellRect.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)) path.lineWidth = lineWidth color.setStroke() diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift index 74d65806b3..ea37295a25 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProTheme extension DataGridViewController { // The sort entries the current result carries a column for, resolved by name: a stale index would put the @@ -176,11 +177,10 @@ extension DataGridViewController { paragraph.alignment = cell.alignment let attributes: [NSAttributedString.Key: Any] = [ .font: font, - .foregroundColor: NSColor.headerTextColor, .paragraphStyle: paragraph, ] let title = NSMutableAttributedString(string: column.name + " ", attributes: attributes) - if let funnel = Self.filterFunnel(for: font) { + if let funnel = Self.filterFunnel(for: font, color: model.themeScope.resolved[.gridHeaderText]) { let attachment = NSAttributedString(attachment: funnel) let marked = NSMutableAttributedString(attributedString: attachment) marked.addAttributes(attributes, range: NSRange(location: 0, length: marked.length)) @@ -189,10 +189,10 @@ extension DataGridViewController { cell.attributedStringValue = title } - // Drawn in the header's own text color, so the funnel never turns white on an inactive selection (F1). - private static func filterFunnel(for font: NSFont) -> NSTextAttachment? { + // A symbol image keeps the color it was built with, so it takes the header text color here. + private static func filterFunnel(for font: NSFont, color: NSColor) -> NSTextAttachment? { let configuration = NSImage.SymbolConfiguration(pointSize: font.pointSize, weight: .regular) - .applying(.init(hierarchicalColor: .headerTextColor)) + .applying(.init(hierarchicalColor: color)) guard let image = NSImage( systemSymbolName: "line.3.horizontal.decrease.circle.fill", accessibilityDescription: String(localized: "Filtered") diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridEditingCellView.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridEditingCellView.swift index 21762e8b70..62f30a5670 100644 --- a/apple/TablePro/Views/DataGrid/Editing/DataGridEditingCellView.swift +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridEditingCellView.swift @@ -86,7 +86,7 @@ final class DataGridEditingCellView: NSTableCellView { bounds.fill() let path = NSBezierPath(rect: bounds.insetBy(dx: Self.borderWidth / 2, dy: Self.borderWidth / 2)) path.lineWidth = Self.borderWidth - NSColor.keyboardFocusIndicatorColor.setStroke() + scope.palette[.gridFocusBorder].setStroke() path.stroke() } } diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift b/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift index 8fdd29deeb..50acc31232 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift @@ -54,7 +54,12 @@ enum ERDiagramNodeRenderer { ) canvas.clip(to: body) { - canvas.fill(headerRect, color: palette.headerWash(cluster: clusterColor)) + if let headerFill = palette.headerFill { + canvas.fill(headerRect, color: headerFill) + } + if palette.headerFill == nil || clusterColor != nil { + canvas.fill(headerRect, color: palette.headerWash(cluster: clusterColor)) + } } canvas.stroke( @@ -70,7 +75,7 @@ enum ERDiagramNodeRenderer { canvas.drawText( displayName, font: .monospacedSystemFont(ofSize: headerPointSize * scale, weight: .semibold), - color: palette.text, + color: palette.headerText, at: CGPoint(x: rect.minX + headerTextXOffset, y: rect.minY + headerHeight / 2), anchor: .leading ) diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramPalette.swift b/apple/TablePro/Views/ERDiagram/ERDiagramPalette.swift index 5e1639dab8..5fa5bd935c 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramPalette.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramPalette.swift @@ -7,10 +7,13 @@ internal struct ERDiagramPalette { internal let appearance: NSAppearance internal let backdrop: CGColor internal let nodeFill: CGColor + // Nil keeps the accent wash a table header has always had. + internal let headerFill: CGColor? internal let rule: CGColor internal let selectionRule: CGColor internal let edge: CGColor internal let text: NSColor + internal let headerText: NSColor internal let secondaryText: NSColor internal let insertedRow: CGColor internal let modifiedRow: CGColor @@ -35,10 +38,15 @@ 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 rule = Self.bake(color(.gridLine, otherwise: .tertiaryLabelColor), in: appearance) - selectionRule = Self.bake(.controlAccentColor, in: appearance) + // The grid outlines its active cell with the focus border, so a selected table does too. + selectionRule = Self.bake(color(.gridFocusBorder, otherwise: .controlAccentColor), in: appearance) edge = Self.wash(.secondaryLabelColor, alpha: Self.edgeAlpha, in: appearance) text = color(.gridText, otherwise: .labelColor) + headerText = color(.gridHeaderText, otherwise: text) secondaryText = .secondaryLabelColor insertedRow = Self.bake(color(.gridInserted, otherwise: NativeThemeColors.insertedRow), in: appearance) modifiedRow = Self.bake(color(.gridModified, otherwise: NativeThemeColors.modifiedRow), in: appearance) diff --git a/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift b/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift index bf48e11048..ce4826d5bd 100644 --- a/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift +++ b/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift @@ -171,6 +171,8 @@ struct QueryPlanArrowsView: View { struct QueryPlanDiagramNodeView: View { @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + @Environment(\.colorSchemeContrast) private var contrast + @ObservedObject private var themeEngine = ThemeEngine.shared let node: QueryPlanNode let isSelected: Bool @@ -245,7 +247,9 @@ struct QueryPlanDiagramNodeView: View { node.severity?.tint(differentiateWithoutColor: differentiateWithoutColor) ?? .secondary } - private var stroke: Color { - isSelected ? .accentColor : tint + // The grid outlines its active cell with the focus border, so a selected step does too. + private var stroke: AnyShapeStyle { + guard isSelected else { return AnyShapeStyle(tint) } + return ThemeShapeStyle.style(.gridFocusBorder, native: Color.accentColor, engine: themeEngine, contrast: contrast) } } diff --git a/apple/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift b/apple/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift index 533f6a36b2..fb7d79b364 100644 --- a/apple/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift +++ b/apple/TablePro/Views/QueryPlan/QueryPlanOutlineCoordinator.swift @@ -116,6 +116,7 @@ final class QueryPlanOutlineCoordinator: NSObject, NSOutlineViewDataSource, NSOu for column in QueryPlanOutlineColumn.allCases { let tableColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(column.rawValue)) + tableColumn.headerCell = ThemeTableHeaderCell() tableColumn.title = column.title(metric: metric) tableColumn.width = column.width tableColumn.minWidth = column.minimumWidth diff --git a/apple/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift b/apple/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift index 6b82a938d2..0e6d2426c8 100644 --- a/apple/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift +++ b/apple/TablePro/Views/QueryPlan/QueryPlanOutlineView.swift @@ -31,7 +31,7 @@ struct QueryPlanOutlineView: NSViewRepresentable { outlineView.allowsMultipleSelection = false outlineView.allowsEmptySelection = true outlineView.usesAlternatingRowBackgroundColors = true - outlineView.headerView = NSTableHeaderView() + outlineView.headerView = ThemeTableHeaderView() outlineView.allowsColumnResizing = true // Bumped when the magnitude column landed. Measured: with a saved layout from the // four-column era, AppKit restores the known columns in their saved order and appends diff --git a/apple/TablePro/Views/ServerDashboard/DashboardTileView.swift b/apple/TablePro/Views/ServerDashboard/DashboardTileView.swift index 540a3af189..6c60afd7c3 100644 --- a/apple/TablePro/Views/ServerDashboard/DashboardTileView.swift +++ b/apple/TablePro/Views/ServerDashboard/DashboardTileView.swift @@ -196,7 +196,7 @@ private struct DashboardTileContentView: View { ForEach(Array(table.columns.enumerated()), id: \.offset) { _, column in Text(verbatim: column) .fontWeight(.semibold) - .themeForeground(.gridText, native: HierarchicalShapeStyle.secondary) + .themeForeground(.gridHeaderText, native: HierarchicalShapeStyle.secondary) } } ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in diff --git a/apple/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift b/apple/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift index 6ec1478d9c..6e8bdfaa02 100644 --- a/apple/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift +++ b/apple/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift @@ -162,8 +162,8 @@ private final class ThemePreviewStatementBand: TextViewCoordinator { // MARK: - Grid -// Five rows that put every grid slot on screen: row numbers, both stripes, NULL, both booleans, an -// unfocused selection, and a modified, a deleted and an inserted row. +// 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 diff --git a/apple/TablePro/Views/Settings/Appearance/ThemeSlotPresentation.swift b/apple/TablePro/Views/Settings/Appearance/ThemeSlotPresentation.swift index 42a46d3dc2..68a2b3e993 100644 --- a/apple/TablePro/Views/Settings/Appearance/ThemeSlotPresentation.swift +++ b/apple/TablePro/Views/Settings/Appearance/ThemeSlotPresentation.swift @@ -86,7 +86,7 @@ internal enum ThemeContrastNote { switch section { case .editor: return (.editorText, .editorBackground) case .dataGrid: return (.gridText, .gridBackground) - case .window, .syntax, .changes: return nil + case .window, .syntax, .selection, .changes: return nil } } diff --git a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift index 04e1953609..b543a45e93 100644 --- a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift +++ b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift @@ -45,12 +45,14 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, // The pane's minimum thickness is 240pt, so the two columns must fit inside that or the // summary is clipped away entirely. let scope = NSTableColumn(identifier: Self.scopeColumn) + scope.headerCell = ThemeTableHeaderCell() scope.title = String(localized: "Object") scope.width = 150 scope.minWidth = 110 outlineView.addTableColumn(scope) let summary = NSTableColumn(identifier: Self.summaryColumn) + summary.headerCell = ThemeTableHeaderCell() summary.title = String(localized: "Privileges") summary.width = 130 summary.minWidth = 90 diff --git a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift index 2a4285b6b0..40f7eebfbf 100644 --- a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift +++ b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineView.swift @@ -28,7 +28,7 @@ struct PrivilegeScopeOutlineView: NSViewRepresentable { outlineView.allowsMultipleSelection = true outlineView.allowsEmptySelection = true outlineView.usesAlternatingRowBackgroundColors = true - outlineView.headerView = NSTableHeaderView() + outlineView.headerView = ThemeTableHeaderView() outlineView.allowsColumnResizing = true outlineView.autosaveName = SplitViewAutosaveName.current("com.TablePro.usersRoles.scopeOutline") // Without a name AppKit still saves, under "NSTableView Columns v3 (null)" in the standard domain. diff --git a/apple/TableProTests/Theme/DerivedSlotDrawingTests.swift b/apple/TableProTests/Theme/DerivedSlotDrawingTests.swift index a1e0943574..95e630a3da 100644 --- a/apple/TableProTests/Theme/DerivedSlotDrawingTests.swift +++ b/apple/TableProTests/Theme/DerivedSlotDrawingTests.swift @@ -38,6 +38,29 @@ struct DerivedSlotDrawingTests { } } + // MARK: - Header and selection + + @Test("The header and selection take nothing from the grid's background, text or each other") + func headerAndSelectionDeriveNothing() throws { + let theme = try ThemeTestFixture.theme(.dark, [ + .gridBackground: "#282A36", + .gridText: "#F8F8F2", + .gridSelection: "#44475A", + ]) + let resolved = ResolvedTheme(definition: theme) + + #expect(!resolved.isOverridden(.gridHeaderBackground)) + #expect(resolved[.gridHeaderBackground] === NSColor.controlBackgroundColor) + #expect(!resolved.isOverridden(.gridHeaderText)) + #expect(resolved[.gridHeaderText] === NSColor.headerTextColor) + #expect(!resolved.isOverridden(.gridSelectionText)) + #expect(resolved[.gridSelectionText] === NSColor.alternateSelectedControlTextColor) + #expect(!resolved.isOverridden(.gridInactiveSelection)) + #expect(resolved[.gridInactiveSelection] === NSColor.unemphasizedSelectedContentBackgroundColor) + #expect(!resolved.isOverridden(.gridFocusBorder)) + #expect(resolved[.gridFocusBorder] === NSColor.keyboardFocusIndicatorColor) + } + // MARK: - Cell palette private func cellPalette() throws -> DataGridCellPalette { @@ -45,6 +68,15 @@ struct DerivedSlotDrawingTests { return ThemeScope.live.dataGridCellPalette(in: aqua) } + @Test("A theme's selection without a selected text keeps the system's selected text in the grid") + func selectionTextIsNotPairedWithTheSelection() throws { + try ThemeTestFixture.adopting([.gridSelection: "#44475A", .gridText: "#F8F8F2"], appearance: .light) { + let palette = try cellPalette() + #expect(palette.selectionText === NSColor.alternateSelectedControlTextColor) + #expect(palette.focusBorder === NSColor.keyboardFocusIndicatorColor) + } + } + @Test("Bools follow the theme's grid text") func boolsFollowThemedText() throws { try ThemeTestFixture.adopting([.gridText: "#112233"], appearance: .light) { diff --git a/apple/TableProTests/Theme/NativeDefaultsTests.swift b/apple/TableProTests/Theme/NativeDefaultsTests.swift index 36e88dd397..4c44a4e10e 100644 --- a/apple/TableProTests/Theme/NativeDefaultsTests.swift +++ b/apple/TableProTests/Theme/NativeDefaultsTests.swift @@ -54,6 +54,12 @@ struct NativeDefaultsTests { #expect(theme.color(for: .gridBackground) === NSColor.controlBackgroundColor) #expect(theme.color(for: .gridLine) === NSColor.separatorColor) #expect(theme.color(for: .gridText) === NSColor.labelColor) + #expect(theme.color(for: .gridHeaderBackground) === NSColor.controlBackgroundColor) + #expect(theme.color(for: .gridHeaderText) === NSColor.headerTextColor) + #expect(theme.color(for: .gridSelection) === NSColor.selectedContentBackgroundColor) + #expect(theme.color(for: .gridSelectionText) === NSColor.alternateSelectedControlTextColor) + #expect(theme.color(for: .gridInactiveSelection) === NSColor.unemphasizedSelectedContentBackgroundColor) + #expect(theme.color(for: .gridFocusBorder) === NSColor.keyboardFocusIndicatorColor) } } @@ -86,6 +92,7 @@ struct NativeDefaultsTests { #expect(theme.color(for: .editorSelection) === NSColor.selectedTextBackgroundColor) #expect(theme.color(for: .editorCursor) === ThemeSlot.editorCursor.nativeColor) #expect(theme.color(for: .gridLine) === NSColor.separatorColor) + #expect(theme.color(for: .gridSelection) === NSColor.selectedContentBackgroundColor) #expect(theme.color(for: .gridBackground) === NSColor.controlBackgroundColor) } @@ -116,6 +123,8 @@ struct NativeDefaultsTests { #expect(Self.hex(partial[.gridBackground]) == "#282A36") #expect(!partial.isOverridden(.gridLine)) #expect(partial[.gridLine] === NSColor.separatorColor) + #expect(!partial.isOverridden(.gridSelection)) + #expect(partial[.gridSelection] === NSColor.selectedContentBackgroundColor) } @Test("cgColor resolves a system slot inside the appearance it is given") @@ -188,6 +197,35 @@ struct NativeDefaultsTests { aqua.performAsCurrentDrawingAppearance { #expect(Self.hex(palette[.editorBackground]) == Self.hex(NSColor.textBackgroundColor)) #expect(Self.hex(palette[.gridBackground]) == Self.hex(NSColor.controlBackgroundColor)) + #expect(Self.hex(palette[.gridSelection]) == Self.hex(NSColor.selectedContentBackgroundColor)) + } + } + + @Test("Dracula colors the grid's header, selection and focus border in Dark Aqua and leaves Aqua to the system") + func draculaPaletteResolvesItsHeaderAndSelection() throws { + let source = ThemeSource() + source.update(ThemePair(light: BuiltInThemes.light, dark: try Self.bundledTheme("tablepro.dracula"))) + let palette = ThemePalette(revision: 910_006, source: source) + let aqua = try #require(NSAppearance(named: .aqua)) + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + let expected: [ThemeSlot: String] = [ + .gridHeaderBackground: "#21222C", + .gridHeaderText: "#F8F8F2", + .gridSelection: "#44475A", + .gridSelectionText: "#F8F8F2", + .gridInactiveSelection: "#44475A80", + .gridFocusBorder: "#BD93F9", + ] + + darkAqua.performAsCurrentDrawingAppearance { + for (slot, hex) in expected { + #expect(Self.hex(palette[slot]) == hex, "\(slot.rawValue)") + } + } + aqua.performAsCurrentDrawingAppearance { + for slot in expected.keys { + #expect(Self.hex(palette[slot]) == Self.hex(slot.nativeColor), "\(slot.rawValue)") + } } } diff --git a/apple/TableProTests/Theme/ThemeScopeTests.swift b/apple/TableProTests/Theme/ThemeScopeTests.swift index ee7ebc6582..6be1da5dbf 100644 --- a/apple/TableProTests/Theme/ThemeScopeTests.swift +++ b/apple/TableProTests/Theme/ThemeScopeTests.swift @@ -44,6 +44,7 @@ struct ThemeScopeTests { appearance: .dark, overrides: [ .gridBackground: try ThemeColor(validating: "#282A36"), + .gridSelection: try ThemeColor(validating: "#6272A4"), .editorSelection: try ThemeColor(validating: "#44475A"), .editorBackground: try ThemeColor(validating: "#1B3A2C"), .editorText: try ThemeColor(validating: "#F8F8F2"), @@ -67,7 +68,8 @@ struct ThemeScopeTests { #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(chrome.selectionColor(emphasized: true) == nil) + #expect(ThemeTestFixture.hex(chrome.selectionColor(emphasized: true)) == "#6272A4") + #expect(chrome.selectionColor(emphasized: false) === NSColor.unemphasizedSelectedContentBackgroundColor) #expect(!table.scrollView.drawsBackground) #expect(engine.pair == pairBefore) #expect(engine.revision == revisionBefore) diff --git a/apple/TableProTests/Theme/ThemeTableChromeTests.swift b/apple/TableProTests/Theme/ThemeTableChromeTests.swift index 8ad61d9af2..b4ec602e0e 100644 --- a/apple/TableProTests/Theme/ThemeTableChromeTests.swift +++ b/apple/TableProTests/Theme/ThemeTableChromeTests.swift @@ -159,9 +159,14 @@ struct ThemeTableChromeTests { return row } + private func center(of row: NSTableRowView) throws -> NSColor { + let rep = try pixels(of: row) + return try color(in: rep, x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2) + } + // MARK: - Table properties - @Test("Under the default theme the table, its stripes and its scroll view stay AppKit's own") + @Test("Under the default theme the table, its stripes and its scroll view stay AppKit's own, and a records table's selection is the system's") func defaultThemeTouchesNothing() throws { try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt([:], appearance: .light) @@ -178,13 +183,19 @@ struct ThemeTableChromeTests { #expect(table.scrollView.drawsBackground == drawsBackground) #expect(!chrome.isBackgroundThemed) #expect(chrome.rowBackgroundColor(forRow: 1) == nil) - #expect(chrome.selectionColor(emphasized: true) == nil) - #expect(chrome.selectionColor(emphasized: false) == nil) + switch family { + case .records: + #expect(chrome.selectionColor(emphasized: true) === NSColor.selectedContentBackgroundColor) + #expect(chrome.selectionColor(emphasized: false) === NSColor.unemphasizedSelectedContentBackgroundColor) + case .code: + #expect(chrome.selectionColor(emphasized: true) == nil) + #expect(chrome.selectionColor(emphasized: false) == nil) + } } } } - @Test("A records table takes the grid's background, stripes and grid line, keeps AppKit's selection, and gets its own back") + @Test("A records table takes the grid's background, stripes, grid line and selection, and gets its own back") func recordsFamilyThemesAndRestores() throws { try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt([:], appearance: .light) @@ -198,7 +209,9 @@ struct ThemeTableChromeTests { .gridBackground: "#FAFAFA", .gridAlternateRow: "#F0F0F0", .gridLine: "#DDDDDD", - .editorSelection: "#C8E1FF", + .gridSelection: "#C8E1FF", + .gridInactiveSelection: "#E0E0E0", + .editorSelection: "#FF00FF", ], appearance: .light) #expect(try hex(table.tableView.backgroundColor) == "#FAFAFA") #expect(try hex(table.tableView.gridColor) == "#DDDDDD") @@ -206,8 +219,8 @@ struct ThemeTableChromeTests { #expect(!table.scrollView.drawsBackground) #expect(try hex(chrome.rowBackgroundColor(forRow: 0)) == "#FAFAFA") #expect(try hex(chrome.rowBackgroundColor(forRow: 1)) == "#F0F0F0") - #expect(chrome.selectionColor(emphasized: true) == nil) - #expect(chrome.selectionColor(emphasized: false) == nil) + #expect(try hex(chrome.selectionColor(emphasized: true)) == "#C8E1FF") + #expect(try hex(chrome.selectionColor(emphasized: false)) == "#E0E0E0") try ThemeTestFixture.adopt([:], appearance: .light) #expect(table.tableView.backgroundColor == background) @@ -215,6 +228,8 @@ struct ThemeTableChromeTests { #expect(table.tableView.usesAlternatingRowBackgroundColors) #expect(table.scrollView.drawsBackground == drawsBackground) #expect(chrome.rowBackgroundColor(forRow: 1) == nil) + #expect(chrome.selectionColor(emphasized: true) === NSColor.selectedContentBackgroundColor) + #expect(chrome.selectionColor(emphasized: false) === NSColor.unemphasizedSelectedContentBackgroundColor) } } @@ -251,12 +266,12 @@ struct ThemeTableChromeTests { // MARK: - Row drawing - @Test("Under the default theme the rows are AppKit's own row view and draw what a stock table draws") - func defaultRowsAreNative() throws { + @Test("Under the default theme a code table's rows are AppKit's own row view and draw what a stock table draws") + func defaultCodeRowsAreNative() throws { try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt([:], appearance: .light) let table = makeTable(alternates: false) - let chrome = attach(.records, to: table) + let chrome = attach(.code, to: table) let stock = makeTable(alternates: false, source: StockTableSource()) stock.tableView.reloadData() stock.tableView.layoutSubtreeIfNeeded() @@ -277,14 +292,35 @@ struct ThemeTableChromeTests { } } - @Test("A theme that colors the rows swaps in the themed row class, and a default theme swaps AppKit's back") - func rowClassFollowsTheTheme() throws { + @Test("Under the default theme a records table's rows draw the system selection, and its text still flips") + func defaultRecordsRowsDrawTheSystemSelection() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let table = makeTable(style: .fullWidth, alternates: false) + let chrome = attach(.records, to: table) + defer { withExtendedLifetime(chrome) {} } + + #expect(chrome.themesRows) + let row = try #require(table.tableView.rowView(atRow: 0, makeIfNecessary: true) as? ThemeTableRowView) + #expect(row.chrome === chrome) + + #expect(chrome.selectionColor(emphasized: true) === NSColor.selectedContentBackgroundColor) + // An offscreen cache draws AppKit's selection shape with partial coverage, so the hue is what it can pin. + let drawn = try selectedRowPixels(in: table) + let fill = try color(in: drawn, x: drawn.pixelsWide / 2, y: drawn.pixelsHigh / 2) + #expect(fill.blueComponent > fill.redComponent + 0.4, "drew \(fill)") + #expect(row.interiorBackgroundStyle == .emphasized) + } + } + + @Test("A theme that colors a code table's rows swaps in the themed row class, and a default theme swaps AppKit's back") + func codeRowClassFollowsTheTheme() throws { try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt([:], appearance: .light) let source = ChromeTableSource() source.rowCount = 40 let table = makeTable(source: source) - let chrome = attach(.records, to: table) + let chrome = attach(.code, to: table) table.tableView.selectRowIndexes(IndexSet(integer: 12), byExtendingSelection: false) table.tableView.scroll(NSPoint(x: 0, y: 240)) table.tableView.layoutSubtreeIfNeeded() @@ -292,7 +328,7 @@ struct ThemeTableChromeTests { #expect(origin.y > 0) #expect(type(of: try #require(table.tableView.rowView(atRow: 12, makeIfNecessary: true))) == NSTableRowView.self) - for overrides in [[ThemeSlot.gridBackground: "#FAFAFA"], [.gridAlternateRow: "#F0F0F0"]] { + for overrides in [[ThemeSlot.editorBackground: "#FAFAFA"], [.editorSelection: "#C8E1FF"]] { try ThemeTestFixture.adopt(overrides, appearance: .light) #expect(chrome.themesRows) let themedRow = try #require(table.tableView.rowView(atRow: 12, makeIfNecessary: true) as? ThemeTableRowView) @@ -309,15 +345,45 @@ struct ThemeTableChromeTests { } } - @Test("A theme that only colors the grid text, lines or the editor's selection keeps AppKit's row view in a records table") - func textOnlyThemeKeepsNativeRows() throws { + @Test("A records table keeps its themed rows through every theme change, so it never rebuilds them") + func recordsRowsSurviveThemeChanges() throws { try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt([:], appearance: .light) - let table = makeTable() + let source = ChromeTableSource() + source.rowCount = 40 + let table = makeTable(source: source) let chrome = attach(.records, to: table) + table.tableView.selectRowIndexes(IndexSet(integer: 12), byExtendingSelection: false) + table.tableView.scroll(NSPoint(x: 0, y: 240)) + table.tableView.layoutSubtreeIfNeeded() + let origin = table.scrollView.contentView.bounds.origin + let row = try #require(table.tableView.rowView(atRow: 12, makeIfNecessary: true) as? ThemeTableRowView) + + for overrides in [[ThemeSlot.gridBackground: "#FAFAFA"], [.gridSelection: "#C8E1FF"], [:]] { + try ThemeTestFixture.adopt(overrides, appearance: .light) + #expect(chrome.themesRows) + #expect(table.tableView.rowView(atRow: 12, makeIfNecessary: true) === row) + #expect(table.tableView.selectedRowIndexes == IndexSet(integer: 12)) + #expect(table.scrollView.contentView.bounds.origin == origin) + } + } + } + + @Test("A theme that colors only grid slots keeps AppKit's row view in a code table") + func gridOnlyThemeKeepsNativeCodeRows() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([:], appearance: .light) + let table = makeTable() + let chrome = attach(.code, to: table) - try ThemeTestFixture.adopt([.gridText: "#112233", .gridLine: "#DDDDDD", .editorSelection: "#FF00FF"], appearance: .light) + try ThemeTestFixture.adopt([ + .gridText: "#112233", + .gridLine: "#DDDDDD", + .gridSelection: "#FF00FF", + .gridInactiveSelection: "#00FF00", + ], appearance: .light) #expect(!chrome.themesRows) + #expect(chrome.selectionColor(emphasized: true) == nil) #expect(type(of: try #require(table.tableView.rowView(atRow: 0, makeIfNecessary: true))) == NSTableRowView.self) } } @@ -331,15 +397,36 @@ struct ThemeTableChromeTests { let row = ThemeTableRowView(frame: Self.rowFrame) row.chrome = chrome - let rep = try pixels(of: selected(row)) - let center = try color(in: rep, x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2) + let drawn = try center(of: selected(row)) let magenta = try cached(HexColor.color("#FF00FF")) - #expect(matches(center, magenta), "drew \(center), theme \(magenta)") + #expect(matches(drawn, magenta), "drew \(drawn), theme \(magenta)") #expect(row.interiorBackgroundStyle == .normal) withExtendedLifetime(chrome) {} } } + @Test("A records row fills in the grid selection, the inactive selection once it loses emphasis, and keeps AppKit's text style") + func recordsSelectionDrawsTheGridSlots() throws { + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt([.gridSelection: "#FF00FF", .gridInactiveSelection: "#00FF00"], appearance: .light) + let table = makeTable() + let chrome = attach(.records, to: table) + defer { withExtendedLifetime(chrome) {} } + let row = ThemeTableRowView(frame: Self.rowFrame) + row.chrome = chrome + + let emphasized = try center(of: selected(row)) + let magenta = try cached(HexColor.color("#FF00FF")) + #expect(matches(emphasized, magenta), "drew \(emphasized), theme \(magenta)") + #expect(row.interiorBackgroundStyle == .emphasized) + + row.isEmphasized = false + let inactive = try center(of: row) + let green = try cached(HexColor.color("#00FF00")) + #expect(matches(inactive, green), "drew \(inactive), theme \(green)") + } + } + @Test("A themed selection covers the same pixels as AppKit's in an inset table") func themedSelectionKeepsTheInsetShape() throws { try ThemeTestFixture.preservingEngine { @@ -350,51 +437,50 @@ struct ThemeTableChromeTests { let native = try selectedRowPixels(in: nativeTable) let accent = try color(in: native, x: native.pixelsWide / 2, y: native.pixelsHigh / 2) - try ThemeTestFixture.adopt([.editorSelection: "#FF00FF"], appearance: .light) - let themedTable = makeTable(style: .inset, alternates: false) - let themedChrome = attach(.code, to: themedTable) - let themed = try selectedRowPixels(in: themedTable) - withExtendedLifetime(themedChrome) {} - let magenta = try color(in: themed, x: themed.pixelsWide / 2, y: themed.pixelsHigh / 2) - #expect(!matches(magenta, accent)) - #expect(magenta.redComponent > 0.8 && magenta.greenComponent < 0.5 && magenta.blueComponent > 0.8, "\(magenta)") - - let middle = native.pixelsHigh / 2 - var nativeCovered = 0 - var themedCovered = 0 - for x in 0.. 0.8 && magenta.greenComponent < 0.5 && magenta.blueComponent > 0.8, "\(magenta)") + + let middle = native.pixelsHigh / 2 + var nativeCovered = 0 + var themedCovered = 0 + for x in 0.. 0) + #expect(abs(nativeCovered - themedCovered) <= 2, "\(family): native \(nativeCovered), themed \(themedCovered)") + #expect( + matches(try color(in: native, x: 0, y: middle), accent) == matches(try color(in: themed, x: 0, y: middle), magenta) + ) } - #expect(nativeCovered > 0) - #expect(abs(nativeCovered - themedCovered) <= 2, "native \(nativeCovered), themed \(themedCovered)") - #expect( - matches(try color(in: native, x: 0, y: middle), accent) == matches(try color(in: themed, x: 0, y: middle), magenta) - ) } } - @Test("Under a theme a records table's selection is still AppKit's, and its text still flips") - func recordsSelectionStaysNative() throws { + @Test("Under a theme a records table's selection takes the grid selection, not the editor's, and its text still flips") + func recordsSelectionTakesTheGridSlot() throws { try ThemeTestFixture.preservingEngine { - try ThemeTestFixture.adopt([:], appearance: .light) - let nativeTable = makeTable(style: .inset, alternates: false, source: DrawPathTableSource()) - nativeTable.tableView.reloadData() - nativeTable.tableView.layoutSubtreeIfNeeded() - let native = try selectedRowPixels(in: nativeTable) - - try ThemeTestFixture.adopt([.gridBackground: "#FAFAFA", .gridText: "#112233", .editorSelection: "#FF00FF"], appearance: .light) - let themedTable = makeTable(style: .inset, alternates: false) + try ThemeTestFixture.adopt([ + .gridBackground: "#FAFAFA", + .gridText: "#112233", + .gridSelection: "#FF00FF", + .editorSelection: "#00FF00", + ], appearance: .light) + let themedTable = makeTable(style: .fullWidth, alternates: false) let chrome = attach(.records, to: themedTable) defer { withExtendedLifetime(chrome) {} } let themed = try selectedRowPixels(in: themedTable) let row = try #require(themedTable.tableView.rowView(atRow: 0, makeIfNecessary: true) as? ThemeTableRowView) - #expect(chrome.selectionColor(emphasized: true) == nil) - #expect(chrome.selectionColor(emphasized: false) == nil) - let x = native.pixelsWide / 2 - let y = native.pixelsHigh / 2 - #expect(matches(try color(in: themed, x: x, y: y), try color(in: native, x: x, y: y))) + #expect(try hex(chrome.selectionColor(emphasized: true)) == "#FF00FF") + let fill = try color(in: themed, x: themed.pixelsWide / 2, y: themed.pixelsHigh / 2) + #expect(fill.redComponent > 0.8 && fill.greenComponent < 0.5 && fill.blueComponent > 0.8, "drew \(fill)") #expect(row.interiorBackgroundStyle == .emphasized) } } @@ -428,15 +514,16 @@ struct ThemeTableChromeTests { Rectangle().frame(width: 8, height: 8) } - @Test("Text slots resolve: grid text by default, none on AppKit's emphasized selection") + @Test("Text slots resolve: grid text when the theme sets it, the selected text on the emphasized selection") func recordsTextSlotFollowsTheRow() throws { try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt([:], appearance: .light) #expect(ThemeTableChrome.recordsTextSlot(onEmphasizedSelection: false, highContrast: false) == nil) + #expect(ThemeTableChrome.recordsTextSlot(onEmphasizedSelection: true, highContrast: false) == .gridSelectionText) try ThemeTestFixture.adopt([.gridText: "#FF0000"], appearance: .light) #expect(ThemeTableChrome.recordsTextSlot(onEmphasizedSelection: false, highContrast: false) == .gridText) - #expect(ThemeTableChrome.recordsTextSlot(onEmphasizedSelection: true, highContrast: false) == nil) + #expect(ThemeTableChrome.recordsTextSlot(onEmphasizedSelection: true, highContrast: false) == .gridSelectionText) } } @@ -476,9 +563,8 @@ struct ThemeTableChromeTests { } } - // AppKit's emphasized selection is the accent color, which a theme's text is not chosen to sit on. - @Test("On AppKit's emphasized selection a records cell keeps its own text, which flips with the row") - func nativeEmphasizedSelectionKeepsTheFlip() throws { + @Test("On the emphasized selection a records cell with no selected text set draws the system's, as a stock cell does") + func emphasizedSelectionWithoutSelectedTextIsTheSystems() throws { guard #available(macOS 14, *) else { return } try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt(ThemeTestFixture.pair([.gridText: "#FF0000"]), appearance: .light) @@ -490,4 +576,17 @@ struct ThemeTableChromeTests { #expect(!matches(flipped, try reference("#FF0000"))) } } + + @Test("On the emphasized selection a records cell takes the theme's selected text") + func emphasizedSelectionTakesTheSelectedText() throws { + guard #available(macOS 14, *) else { return } + try ThemeTestFixture.preservingEngine { + try ThemeTestFixture.adopt(ThemeTestFixture.pair([.gridText: "#FF0000", .gridSelectionText: "#00AA00"]), appearance: .light) + let cell = swatchCell(in: makeTable()) + + let onSelection = try center(cell.rootView.environment(\.backgroundProminence, .increased)) + #expect(matches(onSelection, try reference("#00AA00")), "drew \(onSelection)") + #expect(matches(try center(cell.rootView), try reference("#FF0000"))) + } + } } diff --git a/apple/TableProTests/Theme/ThemeTableHeaderTests.swift b/apple/TableProTests/Theme/ThemeTableHeaderTests.swift index 3a28462b8b..b2b4f77b10 100644 --- a/apple/TableProTests/Theme/ThemeTableHeaderTests.swift +++ b/apple/TableProTests/Theme/ThemeTableHeaderTests.swift @@ -10,6 +10,13 @@ private final class HeaderTableSource: NSObject, NSTableViewDataSource, NSTableV func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { nil } } +@MainActor +private final class ScopedHeaderView: ThemeTableHeaderView { + var scope: ThemeScope = .live + + override var themeScope: ThemeScope { scope } +} + @MainActor struct ThemeTableHeaderTests { private struct Table { @@ -21,12 +28,18 @@ struct ThemeTableHeaderTests { } // Never ordered front and never closed: `close()` on a test window over-releases it. - private func makeTable() -> Table { + private func makeTable( + header: NSTableHeaderView = NSTableHeaderView(), + titles: [String] = ["Operation", "Cost"], + themedCells: Bool = false + ) -> 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"] { + for title in titles { let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(title)) + if themedCells { + column.headerCell = ThemeTableHeaderCell() + } column.title = title column.width = 140 tableView.addTableColumn(column) @@ -60,6 +73,38 @@ struct ThemeTableHeaderTests { .editorSelection: "#44475A", ] + private static let headerTheme: [ThemeSlot: String] = [ + .gridHeaderBackground: "#21222C", + .gridHeaderText: "#F8F8F2", + .gridLine: "#44475A", + ] + + private func scope(_ overrides: [ThemeSlot: String]) throws -> ThemeScope { + .fixed(try ThemeTestFixture.theme(.light, overrides)) + } + + private func titleColor(_ cell: NSCell) -> NSColor? { + let title = cell.attributedStringValue + guard title.length > 0 else { return nil } + return title.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor + } + + private func titleFont(_ cell: NSCell) -> NSFont? { + let title = cell.attributedStringValue + guard title.length > 0 else { return nil } + return title.attribute(.font, at: 0, effectiveRange: nil) as? NSFont + } + + private func isBold(_ font: NSFont?) -> Bool { + font?.fontDescriptor.symbolicTraits.contains(.bold) == true + } + + private func headerCells(of table: Table) -> [ThemeTableHeaderCell] { + table.tableView.tableColumns.compactMap { $0.headerCell as? ThemeTableHeaderCell } + } + + // MARK: - Chrome + @Test("Under the default theme a table keeps the header and corner its owner installed") func defaultThemeKeepsTheOwnersHeader() throws { for family in [ThemeTableChrome.Family.records, .code] { @@ -93,4 +138,254 @@ struct ThemeTableHeaderTests { } } } + + // MARK: - Cell geometry + + @Test("The interior is a 16 pt band centered in the header", arguments: [CGFloat(22), 28, 34]) + func interiorIsCentered(height: CGFloat) { + let frame = NSRect(x: 10, y: 0, width: 120, height: height) + + #expect(ThemeTableHeaderCell.interiorRect(in: frame) == NSRect(x: 10, y: (height - 16) / 2, width: 120, height: 16)) + } + + @Test("A header shorter than the band gets an interior its own height") + func interiorNeverOverflows() { + let frame = NSRect(x: 10, y: 0, width: 120, height: 12) + + #expect(ThemeTableHeaderCell.interiorRect(in: frame) == frame) + } + + @Test("The bottom line is 1 pt along the cell's bottom edge, full width") + func bottomLineSitsOnTheBottomEdge() { + let frame = NSRect(x: 10, y: 0, width: 120, height: 28) + + #expect(ThemeTableHeaderCell.bottomLineRect(in: frame) == NSRect(x: 10, y: 27, width: 120, height: 1)) + } + + @Test("The divider is 1 pt at the cell's trailing edge and spans only the band") + func dividerSpansTheBand() { + let frame = NSRect(x: 10, y: 0, width: 120, height: 28) + let interior = ThemeTableHeaderCell.interiorRect(in: frame) + + #expect(ThemeTableHeaderCell.dividerRect(in: frame, interior: interior) == NSRect(x: 129, y: 6, width: 1, height: 16)) + } + + @Test("Only the band past the last column is a filler") + func fillerStartsPastTheLastColumn() throws { + let table = makeTable(themedCells: true) + let last = table.tableView.rect(ofColumn: table.tableView.numberOfColumns - 1) + let first = table.tableView.rect(ofColumn: 0) + + #expect(!ThemeTableHeaderCell.isFiller(NSRect(x: first.minX, y: 0, width: first.width, height: 28), of: table.tableView)) + #expect(!ThemeTableHeaderCell.isFiller(NSRect(x: last.minX, y: 0, width: last.width, height: 28), of: table.tableView)) + #expect(ThemeTableHeaderCell.isFiller(NSRect(x: last.maxX, y: 0, width: 40, height: 28), of: table.tableView)) + + let empty = makeTable(titles: []) + #expect(ThemeTableHeaderCell.isFiller(NSRect(x: 0, y: 0, width: 40, height: 28), of: empty.tableView)) + withExtendedLifetime(table.window) {} + withExtendedLifetime(empty.window) {} + } + + // MARK: - Title + + @Test("Styling a title colors it, bolds it only when asked, and drops the bold again") + func styleTitleSetsColorAndWeight() { + let red = NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + let blue = NSColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + let cell = ThemeTableHeaderCell() + cell.stringValue = "Cost" + + cell.styleTitle(color: red, bold: false) + #expect(cell.attributedStringValue.string == "Cost") + #expect(titleColor(cell) == red) + #expect(!isBold(titleFont(cell))) + + cell.styleTitle(color: red, bold: true) + #expect(titleColor(cell) == red) + #expect(isBold(titleFont(cell))) + + cell.styleTitle(color: blue, bold: false) + #expect(titleColor(cell) == blue) + #expect(!isBold(titleFont(cell))) + } + + // The header restyles before every draw, so a title already styled must not be written again. + @Test("Styling a title already in its color and weight leaves it as it is") + func styleTitleSkipsAStyledTitle() throws { + let red = NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + let green = NSColor(srgbRed: 0, green: 1, blue: 0, alpha: 1) + let cell = ThemeTableHeaderCell() + cell.stringValue = "Cost" + cell.styleTitle(color: red, bold: false) + let font = try #require(titleFont(cell)) + + let mixed = NSMutableAttributedString(string: "Cost", attributes: [.foregroundColor: red, .font: font]) + mixed.addAttribute(.foregroundColor, value: green, range: NSRange(location: 2, length: 2)) + cell.attributedStringValue = mixed + + cell.styleTitle(color: red, bold: false) + #expect(cell.attributedStringValue.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor == green) + + cell.styleTitle(color: red, bold: true) + #expect(cell.attributedStringValue.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor == red) + } + + @Test("Styling an empty title leaves it empty") + func styleTitleIgnoresAnEmptyTitle() { + let cell = ThemeTableHeaderCell() + cell.stringValue = "" + + cell.styleTitle(color: .systemRed, bold: true) + + #expect(cell.attributedStringValue.length == 0) + } + + @Test("Before a draw the header styles its theme cells in the scope's header text, the highlighted column bold") + func viewWillDrawStylesTheTitles() throws { + let header = ScopedHeaderView() + header.scope = try scope(Self.headerTheme) + let table = makeTable(header: header, themedCells: true) + let plain = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Plain")) + plain.title = "Plain" + table.tableView.addTableColumn(plain) + let cells = headerCells(of: table) + #expect(cells.count == 2) + + header.viewWillDraw() + for cell in cells { + #expect(ThemeTestFixture.hex(titleColor(cell)) == "#F8F8F2") + #expect(!isBold(titleFont(cell))) + } + #expect(ThemeTestFixture.hex(titleColor(plain.headerCell)) != "#F8F8F2") + + table.tableView.highlightedTableColumn = table.tableView.tableColumns[1] + header.viewWillDraw() + #expect(!isBold(titleFont(cells[0]))) + #expect(isBold(titleFont(cells[1]))) + #expect(ThemeTestFixture.hex(titleColor(cells[1])) == "#F8F8F2") + + table.tableView.highlightedTableColumn = nil + header.viewWillDraw() + #expect(!isBold(titleFont(cells[1]))) + withExtendedLifetime(table.window) {} + } + + @Test("With the header text unset the titles take the system header text color") + func unsetHeaderTextIsTheSystemColor() throws { + let header = ScopedHeaderView() + header.scope = .fixed(BuiltInThemes.light) + let table = makeTable(header: header, themedCells: true) + + header.viewWillDraw() + + #expect(ThemeTableHeaderView().themeScope === ThemeScope.live) + for cell in headerCells(of: table) { + #expect(titleColor(cell) == NSColor.headerTextColor) + } + withExtendedLifetime(table.window) {} + } + + // MARK: - Drawing + + // Drawn straight into an sRGB bitmap, flipped like the header, so row 0 of the bitmap is the top. + private func raster(size: NSSize, _ draw: () -> Void) throws -> NSBitmapImageRep { + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let context = try #require(CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.translateBy(x: 0, y: size.height) + context.scaleBy(x: 1, y: -1) + let aqua = try #require(NSAppearance(named: .aqua)) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true) + aqua.performAsCurrentDrawingAppearance(draw) + NSGraphicsContext.restoreGraphicsState() + return NSBitmapImageRep(cgImage: try #require(context.makeImage())) + } + + // A plain fill through the same bitmap, so the conversion on the way in cancels out. + private func reference(_ color: NSColor) throws -> NSColor { + let rep = try raster(size: NSSize(width: 4, height: 4)) { + color.setFill() + NSRect(x: 0, y: 0, width: 4, height: 4).fill() + } + return try #require(ThemeTestFixture.pixel(rep, x: 1, y: 1)) + } + + private func pixel(_ rep: NSBitmapImageRep, x: CGFloat, y: CGFloat) throws -> NSColor { + try #require(ThemeTestFixture.pixel(rep, x: Int(x), y: Int(y))) + } + + private struct DrawnHeader { + let rep: NSBitmapImageRep + let cellFrame: NSRect + let fillerFrame: NSRect + let interior: NSRect + } + + private func drawHeader(scope: ThemeScope) throws -> DrawnHeader { + let header = ScopedHeaderView() + header.scope = scope + let table = makeTable(header: header, themedCells: true) + defer { withExtendedLifetime(table.window) {} } + header.viewWillDraw() + let height: CGFloat = 28 + let first = table.tableView.rect(ofColumn: 0) + let last = table.tableView.rect(ofColumn: table.tableView.numberOfColumns - 1) + let cellFrame = NSRect(x: first.minX, y: 0, width: first.width, height: height) + let fillerFrame = NSRect(x: last.maxX, y: 0, width: 40, height: height) + let cell = try #require(headerCells(of: table).first) + let filler = ThemeTableHeaderCell() + let rep = try raster(size: NSSize(width: fillerFrame.maxX, height: height)) { + cell.draw(withFrame: cellFrame, in: header) + filler.draw(withFrame: fillerFrame, in: header) + } + return DrawnHeader( + rep: rep, + cellFrame: cellFrame, + fillerFrame: fillerFrame, + interior: ThemeTableHeaderCell.interiorRect(in: cellFrame) + ) + } + + @Test("A theme cell paints the band, the bottom line and the divider in the theme, with no divider past the last column") + func cellPaintsTheTheme() throws { + let drawn = try drawHeader(scope: try scope(Self.headerTheme)) + let band = try reference(HexColor.color("#21222C")) + let line = try reference(HexColor.color("#44475A")) + let frame = drawn.cellFrame + let filler = drawn.fillerFrame + + #expect(ThemeTestFixture.matches(try pixel(drawn.rep, x: frame.minX + 2, y: 1), band)) + #expect(ThemeTestFixture.matches(try pixel(drawn.rep, x: frame.minX + 2, y: frame.maxY - 1), line)) + #expect(ThemeTestFixture.matches(try pixel(drawn.rep, x: frame.maxX - 1, y: drawn.interior.midY), line)) + #expect(ThemeTestFixture.matches(try pixel(drawn.rep, x: frame.maxX - 1, y: 1), band)) + #expect(ThemeTestFixture.matches(try pixel(drawn.rep, x: filler.maxX - 1, y: 1), band)) + #expect(ThemeTestFixture.matches(try pixel(drawn.rep, x: filler.maxX - 1, y: drawn.interior.midY), band)) + #expect(ThemeTestFixture.matches(try pixel(drawn.rep, x: filler.maxX - 1, y: filler.maxY - 1), line)) + + var titleInk = 0 + for y in Int(drawn.interior.minY) ..< Int(drawn.interior.maxY) { + for x in Int(frame.minX) ..< Int(frame.midX) { + guard let color = ThemeTestFixture.pixel(drawn.rep, x: x, y: y) else { continue } + if ThemeColor(color).relativeLuminance > 0.5 { titleInk += 1 } + } + } + #expect(titleInk > 0, "the title is not in the theme's light header text") + } + + @Test("With the header background unset the band is the system's control background") + func unsetBandIsTheSystemColor() throws { + let drawn = try drawHeader(scope: .fixed(BuiltInThemes.light)) + let band = try reference(.controlBackgroundColor) + + #expect(ThemeTestFixture.matches(try pixel(drawn.rep, x: drawn.cellFrame.minX + 2, y: 1), band)) + #expect(ThemeTestFixture.matches(try pixel(drawn.rep, x: drawn.fillerFrame.maxX - 1, y: 1), band)) + } } diff --git a/apple/TableProTests/Views/Compare/CompareRowGridModelTests.swift b/apple/TableProTests/Views/Compare/CompareRowGridModelTests.swift index 7cf21cbf62..4ac04fa01c 100644 --- a/apple/TableProTests/Views/Compare/CompareRowGridModelTests.swift +++ b/apple/TableProTests/Views/Compare/CompareRowGridModelTests.swift @@ -728,7 +728,9 @@ struct CompareRowGridModelTests { rowNumberText: .secondaryLabelColor, deletedRowText: .systemRed, modifiedColumnTint: .systemYellow, - findMatchTint: .systemOrange + findMatchTint: .systemOrange, + selectionText: .alternateSelectedControlTextColor, + focusBorder: .keyboardFocusIndicatorColor ), nullDisplayString: "NULL", onEmphasizedSelection: false, diff --git a/apple/TableProTests/Views/DataGrid/DataGridActiveCellBorderTests.swift b/apple/TableProTests/Views/DataGrid/DataGridActiveCellBorderTests.swift index 46680b4e19..0d2e4501a3 100644 --- a/apple/TableProTests/Views/DataGrid/DataGridActiveCellBorderTests.swift +++ b/apple/TableProTests/Views/DataGrid/DataGridActiveCellBorderTests.swift @@ -5,17 +5,19 @@ import AppKit @testable import TablePro +import TableProTheme import Testing @MainActor struct DataGridActiveCellBorderTests { // Whole-point widths, so every column edge lands on a pixel of a 1x bitmap. - private func makeGrid() -> HostedDataGrid { + private func makeGrid(scope: ThemeScope = .live) -> HostedDataGrid { var layout = ColumnLayoutState() layout.columnWidths = ["id": 80, "name": 120, "age": 80] let grid = HostedDataGrid( rows: GridFixtureRows.people, - store: InMemoryDataGridStateStore(columnLayout: layout) + store: InMemoryDataGridStateStore(columnLayout: layout), + prepare: { _, model in model.themeScope = scope } ) grid.model.setSelection(GridSelectionReducer.single(GridCoord(row: 0, displayColumn: 1))) grid.layout() @@ -63,6 +65,14 @@ struct DataGridActiveCellBorderTests { try #require(bitmap.colorAt(x: x, y: bitmap.pixelsHigh / 2)?.usingColorSpace(.sRGB)) } + private func isGreen(_ color: NSColor) -> Bool { + color.alphaComponent > 0.5 && color.greenComponent > 0.75 && color.redComponent < 0.45 && color.blueComponent < 0.45 + } + + private func scope(_ overrides: [ThemeSlot: String]) throws -> ThemeScope { + .fixed(try ThemeTestFixture.theme(.light, overrides)) + } + private func differs(_ lhs: NSColor, _ rhs: NSColor) -> Bool { max( abs(lhs.redComponent - rhs.redComponent), @@ -130,4 +140,44 @@ struct DataGridActiveCellBorderTests { let fill = try pixel(bitmap, x: edge + 2) #expect(!differs(border, fill)) } + + // MARK: - Theme + + @Test("With focus the selection fills in the theme's selection and the active cell takes the theme's selected text") + func focusedSelectionTakesTheTheme() throws { + let grid = makeGrid(scope: try scope([ + .gridSelection: "#00FF00", + .gridSelectionText: "#FF00FF", + .gridFocusBorder: "#0000FF", + ])) + defer { grid.close() } + #expect(grid.focus()) + + let (rowView, bitmap) = try render(row: 0, in: grid) + let edge = try leadingEdge(ofDisplayPosition: 1, in: grid, rowView: rowView) + + let border = try pixel(bitmap, x: edge) + let fill = try pixel(bitmap, x: edge + 2) + #expect(ThemeTestFixture.isMagenta(border), "border drew \(border)") + #expect(isGreen(fill), "fill drew \(fill)") + } + + @Test("Without focus the selection fills in the theme's inactive selection and the active cell takes the theme's focus border") + func unfocusedSelectionTakesTheTheme() throws { + let grid = makeGrid(scope: try scope([ + .gridInactiveSelection: "#00FF00", + .gridFocusBorder: "#FF00FF", + .gridSelectionText: "#0000FF", + ])) + defer { grid.close() } + #expect(!grid.controller.gridHasFocus) + + let (rowView, bitmap) = try render(row: 0, in: grid) + let edge = try leadingEdge(ofDisplayPosition: 1, in: grid, rowView: rowView) + + let border = try pixel(bitmap, x: edge) + let fill = try pixel(bitmap, x: edge + 2) + #expect(ThemeTestFixture.isMagenta(border), "border drew \(border)") + #expect(isGreen(fill), "fill drew \(fill)") + } } diff --git a/apple/TableProTests/Views/DataGrid/DataGridCellAppearanceTests.swift b/apple/TableProTests/Views/DataGrid/DataGridCellAppearanceTests.swift index 5303847a7f..c8ea6aa420 100644 --- a/apple/TableProTests/Views/DataGrid/DataGridCellAppearanceTests.swift +++ b/apple/TableProTests/Views/DataGrid/DataGridCellAppearanceTests.swift @@ -6,6 +6,7 @@ import AppKit @testable import TablePro import TableProPluginKit +import TableProTheme import Testing @MainActor @@ -21,7 +22,9 @@ struct DataGridCellAppearanceTests { rowNumberText: .secondaryLabelColor, deletedRowText: .systemRed, modifiedColumnTint: .systemYellow, - findMatchTint: .systemOrange + findMatchTint: .systemOrange, + selectionText: .alternateSelectedControlTextColor, + focusBorder: .keyboardFocusIndicatorColor ) private let themedPalette = DataGridCellPalette( @@ -35,7 +38,9 @@ struct DataGridCellAppearanceTests { rowNumberText: .systemTeal, deletedRowText: .systemRed, modifiedColumnTint: .systemYellow, - findMatchTint: .systemOrange + findMatchTint: .systemOrange, + selectionText: .systemIndigo, + focusBorder: .systemMint ) private func resolve( @@ -375,7 +380,6 @@ struct DataGridCellAppearanceTests { #expect(appearance.textColor == .labelColor) } - // Changed on purpose: the selected text is always the system's, so it no longer takes a theme color. @Test("Selection, deletion and a find match outrank the boolean color") func booleanColorYieldsToState() { let selected = resolve(kind: .boolean, text: "1", rawValue: "1", onEmphasizedSelection: true, palette: themedPalette) @@ -388,33 +392,49 @@ struct DataGridCellAppearanceTests { ) let found = resolve(kind: .boolean, text: "1", rawValue: "1", isCurrentFindMatch: true, palette: themedPalette) - #expect(selected.textColor == .alternateSelectedControlTextColor) + #expect(selected.textColor == .systemIndigo) #expect(deleted.textColor == .systemRed) #expect(found.textColor == themedPalette.findMatchText) } // MARK: - Selection - @Test("A selected cell's text takes the system's selected text color") - func nativeSelectionColors() { + @Test("A selected cell's text takes the palette's selected text color") + func selectionTakesThePaletteSelectedText() { let selected = resolve(onEmphasizedSelection: true) + let themed = resolve(onEmphasizedSelection: true, palette: themedPalette) let unselected = resolve() - #expect(selected.textColor == .alternateSelectedControlTextColor) + #expect(selected.textColor == palette.selectionText) + #expect(themed.textColor == .systemIndigo) #expect(unselected.textColor == .labelColor) } - // Changed on purpose: the selection text slot is gone, so a theme cannot reach a selected cell. - @Test("A theme that overrides every slot leaves the selected text to the system") - func themeNeverReachesSelectedText() throws { + @Test("A theme's selected text and focus border reach the grid's palette") + func themeReachesSelectedTextAndFocusBorder() throws { let aqua = try #require(NSAppearance(named: .aqua)) - let palette = try ThemeTestFixture.withEverySlotOverridden { - ThemeScope.live.dataGridCellPalette(in: aqua) - } + let scope = ThemeScope.fixed(try ThemeTestFixture.theme(.light, [ + .gridSelectionText: "#00AA00", + .gridFocusBorder: "#FF79C6", + ])) + let palette = scope.dataGridCellPalette(in: aqua) let selected = resolve(onEmphasizedSelection: true, palette: palette) - #expect(palette.selectionText == .alternateSelectedControlTextColor) - #expect(selected.textColor == .alternateSelectedControlTextColor) - #expect(resolve(palette: palette).textColor != .alternateSelectedControlTextColor) + #expect(ThemeTestFixture.hex(palette.selectionText) == "#00AA00") + #expect(ThemeTestFixture.hex(palette.focusBorder) == "#FF79C6") + #expect(selected.textColor === palette.selectionText) + #expect(resolve(palette: palette).textColor !== palette.selectionText) + #expect(palette.rowNumberColor(for: .empty, onEmphasizedSelection: true) === palette.selectionText) + #expect(palette.rowNumberColor(for: .empty, onEmphasizedSelection: false) === palette.rowNumberText) + } + + @Test("With the slots unset the grid's palette carries the system's selected text and focus ring colors") + func unsetSlotsGiveTheSystemColors() throws { + let aqua = try #require(NSAppearance(named: .aqua)) + let palette = ThemeScope.fixed(BuiltInThemes.light).dataGridCellPalette(in: aqua) + + #expect(palette.selectionText === NSColor.alternateSelectedControlTextColor) + #expect(palette.focusBorder === NSColor.keyboardFocusIndicatorColor) + #expect(resolve(onEmphasizedSelection: true, palette: palette).textColor === NSColor.alternateSelectedControlTextColor) } } diff --git a/apple/TableProTests/Views/DataGrid/DataGridInvisibleCharacterTests.swift b/apple/TableProTests/Views/DataGrid/DataGridInvisibleCharacterTests.swift index a8ff9a99b8..d7407746f6 100644 --- a/apple/TableProTests/Views/DataGrid/DataGridInvisibleCharacterTests.swift +++ b/apple/TableProTests/Views/DataGrid/DataGridInvisibleCharacterTests.swift @@ -22,7 +22,8 @@ struct DataGridInvisibleCharacterTests { deletedRowText: .systemRed, modifiedColumnTint: .systemYellow, findMatchTint: .systemYellow, - selectionText: .white + selectionText: .white, + focusBorder: .keyboardFocusIndicatorColor ) private func resolve( diff --git a/apple/TableProTests/Views/DataGrid/DataGridPaneTests.swift b/apple/TableProTests/Views/DataGrid/DataGridPaneTests.swift index 24478b6047..05f6297621 100644 --- a/apple/TableProTests/Views/DataGrid/DataGridPaneTests.swift +++ b/apple/TableProTests/Views/DataGrid/DataGridPaneTests.swift @@ -6,6 +6,7 @@ import AppKit @testable import TablePro import TableProPluginKit +import TableProTheme import Testing @MainActor @@ -25,6 +26,25 @@ struct DataGridPaneTests { return found } + private func titleColor(_ column: NSTableColumn) -> NSColor? { + let title = column.headerCell.attributedStringValue + guard title.length > 0 else { return nil } + return title.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor + } + + private func isBold(_ column: NSTableColumn) -> Bool { + let title = column.headerCell.attributedStringValue + guard title.length > 0 else { return false } + let font = title.attribute(.font, at: 0, effectiveRange: nil) as? NSFont + return font?.fontDescriptor.symbolicTraits.contains(.bold) == true + } + + private func themedGrid(_ overrides: [ThemeSlot: String]) throws -> (HostedDataGrid, ThemeScope) { + let scope = ThemeScope.fixed(try ThemeTestFixture.theme(.light, overrides)) + let grid = HostedDataGrid(rows: GridFixtureRows.people, prepare: { _, model in model.themeScope = scope }) + return (grid, scope) + } + private func frozenGrid(count: Int) -> HostedDataGrid { var layout = ColumnLayoutState() layout.frozenColumnCount = count @@ -190,6 +210,56 @@ struct DataGridPaneTests { #expect(grid.model.store.sortState.columns.isEmpty) } + @Test("Each pane's header reads the model's scope, and every heading, the row numbers' too, is a theme header cell") + func headersAreThemeHeaders() throws { + let (grid, scope) = try themedGrid([.gridHeaderText: "#F8F8F2"]) + defer { grid.close() } + + for pane in grid.controller.panes { + #expect(pane.tableView.headerView === pane.headerView) + #expect(pane.headerView.themeScope === scope) + #expect(!pane.tableView.tableColumns.isEmpty) + #expect(pane.tableView.tableColumns.allSatisfy { $0.headerCell is ThemeTableHeaderCell }) + } + } + + @Test("The headings take the theme's header text, the highlighted one bold, and a filtered one keeps its symbol") + func headingsTakeTheThemeHeaderText() throws { + let (grid, _) = try themedGrid([.gridHeaderText: "#F8F8F2"]) + defer { grid.close() } + let header = grid.controller.main.headerView + let name = try #require(grid.tableColumn(named: "name")) + let age = try #require(grid.tableColumn(named: "age")) + + header.viewWillDraw() + #expect(ThemeTestFixture.hex(titleColor(name)) == "#F8F8F2") + #expect(ThemeTestFixture.hex(titleColor(age)) == "#F8F8F2") + #expect(!isBold(name)) + + grid.setCurrentEventModifiers(.command) + grid.controller.tableView(grid.main, didClick: name) + header.viewWillDraw() + #expect(grid.main.highlightedTableColumn === name) + #expect(isBold(name)) + #expect(!isBold(age)) + + grid.model.setValueFilter(GridFixtureRows.nameFilter(["bob"])) + header.viewWillDraw() + #expect(hasAttachment(name.headerCell.attributedStringValue)) + #expect(ThemeTestFixture.hex(titleColor(name)) == "#F8F8F2") + } + + @Test("With the header text unset the headings take the system header text color") + func unsetHeaderTextIsTheSystemColor() throws { + let (grid, _) = try themedGrid([:]) + defer { grid.close() } + let name = try #require(grid.tableColumn(named: "name")) + + grid.controller.main.headerView.viewWillDraw() + + #expect(titleColor(name) == NSColor.headerTextColor) + } + // MARK: - Frozen pane @Test("Frozen columns that would cover more than two thirds of the grid scroll with the rest") diff --git a/apple/TableProTests/Views/DataGrid/DataGridPendingChangeMarkTests.swift b/apple/TableProTests/Views/DataGrid/DataGridPendingChangeMarkTests.swift index bf43ff369e..5f2c5c43ae 100644 --- a/apple/TableProTests/Views/DataGrid/DataGridPendingChangeMarkTests.swift +++ b/apple/TableProTests/Views/DataGrid/DataGridPendingChangeMarkTests.swift @@ -21,7 +21,9 @@ struct DataGridPendingChangeMarkTests { rowNumberText: .secondaryLabelColor, deletedRowText: .systemRed, modifiedColumnTint: .systemYellow, - findMatchTint: .systemOrange + findMatchTint: .systemOrange, + selectionText: .alternateSelectedControlTextColor, + focusBorder: .keyboardFocusIndicatorColor ) private static let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []) diff --git a/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift b/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift index da4945ac10..35da06b250 100644 --- a/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift +++ b/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift @@ -29,8 +29,11 @@ struct ERDiagramThemeTests { private static let themed: [ThemeSlot: String] = [ .windowBackdrop: "#1E2030", .gridBackground: "#2A6F4E", + .gridHeaderBackground: "#7A3B8F", + .gridHeaderText: "#F8F8F2", .gridText: "#E0E0E0", .gridLine: "#6272A4", + .gridFocusBorder: "#FF79C6", ] private func appearance(_ name: NSAppearance.Name) throws -> NSAppearance { @@ -69,17 +72,19 @@ struct ERDiagramThemeTests { #expect(palette.backdrop == Fixture.baked(.controlBackgroundColor, in: appearance)) #expect(palette.nodeFill == Fixture.baked(.controlBackgroundColor, in: appearance)) + #expect(palette.headerFill == nil) #expect(palette.headerWash(cluster: nil) == mainAccentWash(in: appearance)) #expect(palette.headerWash(cluster: .systemGreen) == mainClusterWash(.systemGreen, in: appearance)) #expect(palette.rule == Fixture.baked(.tertiaryLabelColor, in: appearance)) #expect(palette.selectionRule == Fixture.baked(.controlAccentColor, in: appearance)) #expect(palette.edge == mainEdge(in: appearance)) #expect(palette.text == .labelColor) + #expect(palette.headerText == .labelColor) #expect(palette.secondaryText == .secondaryLabelColor) } } - @Test("A theme colors the tables from the grid's slots and keeps the header wash, selection, meaning hues and connectors system") + @Test("A theme colors the tables from the grid's slots, header and focus border included, and keeps meaning hues and connectors system") func themeColorsTablesFromGridSlots() throws { try Fixture.preservingEngine { try Fixture.adopt(Self.themed, appearance: .light) @@ -88,10 +93,11 @@ struct ERDiagramThemeTests { #expect(Fixture.hex(palette.backdrop) == "#1E2030") #expect(Fixture.hex(palette.nodeFill) == "#2A6F4E") - #expect(palette.headerWash(cluster: nil) == mainAccentWash(in: appearance)) + #expect(Fixture.hex(palette.headerFill) == "#7A3B8F") #expect(Fixture.hex(palette.rule) == "#6272A4") - #expect(palette.selectionRule == Fixture.baked(.controlAccentColor, in: appearance)) + #expect(Fixture.hex(palette.selectionRule) == "#FF79C6") #expect(HexColor.string(from: palette.text) == "#E0E0E0") + #expect(HexColor.string(from: palette.headerText) == "#F8F8F2") #expect(palette.secondaryText == .secondaryLabelColor) #expect(palette.edge == mainEdge(in: appearance)) #expect(palette.headerWash(cluster: .systemGreen) == mainClusterWash(.systemGreen, in: appearance)) @@ -114,12 +120,52 @@ struct ERDiagramThemeTests { )) #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 fills its own color, even the body's, and takes only a cluster's wash over it") + func themedHeaderFillsItsOwnColor() throws { + try Fixture.preservingEngine { + try Fixture.adopt([.gridBackground: "#2A6F4E", .gridHeaderBackground: "#2A6F4E"], appearance: .light) + #expect(Fixture.hex(ERDiagramPalette(appearance: try appearance(.aqua)).headerFill) == "#2A6F4E") + + try Fixture.adopt([.gridHeaderBackground: "#7A3B8F"], appearance: .light) + let aqua = try appearance(.aqua) + let palette = ERDiagramPalette(appearance: aqua) + let headerFill = try #require(palette.headerFill) + #expect(Fixture.hex(headerFill) == "#7A3B8F") + + let padding = Int(ERDiagramSceneRenderer.exportPadding) + @MainActor func headerPixel(cluster: NSColor?) throws -> NSColor { + let (scene, rect) = scene(clusterColor: cluster) + let image = try #require(ERDiagramSceneRenderer.image(scene, appearance: aqua, scale: 1)) + let rep = try #require(image.representations.first as? NSBitmapImageRep) + return try #require(Fixture.pixel( + rep, x: padding + Int(rect.maxX) - 20, y: padding + Int(ERDiagramLayout.headerHeight / 2) + )) + } + #expect(Fixture.matches(try headerPixel(cluster: nil), try reference(headerFill))) + #expect(!Fixture.matches(try headerPixel(cluster: .systemGreen), try reference(headerFill))) + } + } + + @Test("A header text the theme leaves out takes the node text, which sits on the same fill") + func headerTextFollowsTheNodeText() throws { + try Fixture.preservingEngine { + try Fixture.adopt([.gridText: "#E0E0E0"], appearance: .light) + let aqua = try appearance(.aqua) + let palette = ERDiagramPalette(appearance: aqua) + + #expect(HexColor.string(from: palette.text) == "#E0E0E0") + #expect(HexColor.string(from: palette.headerText) == "#E0E0E0") + #expect(palette.selectionRule == Fixture.baked(.controlAccentColor, in: aqua)) + } + } + @Test("A canvas bakes the theme of its own appearance, not the active one") func paletteFollowsTheCanvasAppearance() throws { try Fixture.preservingEngine { @@ -242,6 +288,7 @@ struct ERDiagramThemeTests { #expect(!Fixture.matches(header, try reference(palette.nodeFill))) if themed { #expect(Fixture.hex(palette.backdrop) == "#1E2030") + #expect(Fixture.matches(header, try reference(try #require(palette.headerFill)))) } else { #expect(palette.backdrop == Fixture.baked(.controlBackgroundColor, in: appearance)) } diff --git a/apple/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift b/apple/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift index 0c28384502..d118cbd59a 100644 --- a/apple/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift +++ b/apple/TableProTests/Views/QueryPlan/QueryPlanThemeTests.swift @@ -77,55 +77,67 @@ struct QueryPlanThemeTests { #expect(mounted.outline.autosaveTableColumns == (mounted.outline.autosaveName != nil)) } - @Test("Under the default pair the outline's rows are AppKit's own row view, and its cells are records cells") - func outlineRowsAreNativeByDefault() throws { + @Test("Under the default pair the outline's rows are themed rows drawing the system selection, and its cells are records cells") + func outlineRowsDrawTheSystemSelectionByDefault() throws { try Fixture.preservingEngine { try Fixture.adopt([:], appearance: .light) let mounted = try mountOutline() let chrome = try #require(mounted.coordinator.themeChrome) #expect(chrome.family == .records) + #expect(chrome.selectionColor(emphasized: true) === NSColor.selectedContentBackgroundColor) + #expect(chrome.selectionColor(emphasized: false) === NSColor.unemphasizedSelectedContentBackgroundColor) let root = try #require(mounted.coordinator.root) - #expect(mounted.coordinator.outlineView(mounted.outline, rowViewForItem: root) == nil) + #expect(mounted.coordinator.outlineView(mounted.outline, rowViewForItem: root) is ThemeTableRowView) #expect(mounted.outline.numberOfRows == 3) for row in 0 ..< mounted.outline.numberOfRows { - let rowView = try #require(mounted.outline.rowView(atRow: row, makeIfNecessary: true)) - #expect(type(of: rowView) == NSTableRowView.self) + let rowView = try #require(mounted.outline.rowView(atRow: row, makeIfNecessary: true) as? ThemeTableRowView) + #expect(rowView.chrome === chrome) #expect(mounted.outline.view(atColumn: 0, row: row, makeIfNecessary: true) is ThemeTableChrome.HostingCell) } mounted.window.contentView = nil } } - @Test("A theme's background swaps in themed rows that keep AppKit's selection, and a default theme swaps back") - func outlineRowClassFollowsTheTheme() throws { + @Test("A theme change restyles the outline's rows in place, keeping the selection and the expansion") + func outlineRowsSurviveThemeChanges() throws { try Fixture.preservingEngine { try Fixture.adopt([:], appearance: .light) let mounted = try mountOutline() let chrome = try #require(mounted.coordinator.themeChrome) #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 0)) - _ = try #require(mounted.outline.rowView(atRow: 0, makeIfNecessary: true)) + let row = try #require(mounted.outline.rowView(atRow: 0, makeIfNecessary: true) as? ThemeTableRowView) var selections: [UUID?] = [] mounted.coordinator.onSelect = { selections.append($0) } - try Fixture.adopt([.gridBackground: "#2A6F4E"], appearance: .light) - let themed = try #require(mounted.outline.rowView(atRow: 0, makeIfNecessary: true) as? ThemeTableRowView) - #expect(themed.chrome === chrome) - #expect(chrome.selectionColor(emphasized: true) == nil) + try Fixture.adopt([.gridBackground: "#2A6F4E", .gridSelection: "#6272A4"], appearance: .light) + #expect(mounted.outline.rowView(atRow: 0, makeIfNecessary: true) === row) + #expect(row.chrome === chrome) + #expect(Fixture.hex(chrome.selectionColor(emphasized: true)) == "#6272A4") #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 0)) #expect(mounted.outline.isItemExpanded(try #require(mounted.coordinator.root))) try Fixture.adopt([:], appearance: .light) - let native = try #require(mounted.outline.rowView(atRow: 0, makeIfNecessary: true)) - #expect(type(of: native) == NSTableRowView.self) + #expect(mounted.outline.rowView(atRow: 0, makeIfNecessary: true) === row) + #expect(chrome.selectionColor(emphasized: true) === NSColor.selectedContentBackgroundColor) #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 0)) #expect(mounted.outline.numberOfRows == 3) - #expect(selections.isEmpty, "the rebuild reported \(selections.count) selection changes") + #expect(selections.isEmpty, "the theme change reported \(selections.count) selection changes") mounted.window.contentView = nil } } + @Test("The outline's header is a theme header, and every column a theme header cell") + func outlineHeaderIsAThemeHeader() throws { + let mounted = try mountOutline() + defer { mounted.window.contentView = nil } + + #expect(mounted.outline.headerView is ThemeTableHeaderView) + #expect(!mounted.outline.tableColumns.isEmpty) + #expect(mounted.outline.tableColumns.allSatisfy { $0.headerCell is ThemeTableHeaderCell }) + } + @Test("Under the default pair the outline stays AppKit's; under a theme it takes the grid's colors") func outlineFollowsTheGridSlots() throws { try Fixture.preservingEngine { @@ -190,6 +202,25 @@ struct QueryPlanThemeTests { } } + @Test("A selected step's outline is the accent color by default and the theme's focus border under a theme") + func selectedStepTakesTheFocusBorder() throws { + try Fixture.preservingEngine { + let step = QueryPlanDiagramNodeView(node: node("Seq Scan"), isSelected: true) + + try Fixture.adopt([:], appearance: .light) + let native = try #require(Fixture.render(step, colorScheme: .light, scale: 2)) + #expect(Fixture.count(in: native, where: Fixture.isMagenta) == 0) + + try Fixture.adopt([.gridFocusBorder: "#FF00FF"], appearance: .light) + let themed = try #require(Fixture.render(step, colorScheme: .light, scale: 2)) + #expect(Fixture.count(in: themed, where: Fixture.isMagenta) > 0) + + let unselected = QueryPlanDiagramNodeView(node: node("Seq Scan"), isSelected: false) + let plain = try #require(Fixture.render(unselected, colorScheme: .light, scale: 2)) + #expect(Fixture.count(in: plain, where: Fixture.isMagenta) == 0) + } + } + @Test("An export renders in the canvas's scheme, on the system color by default", arguments: [ ColorScheme.light, .dark, ]) diff --git a/apple/TableProTests/Views/Settings/ThemeSlotRowTests.swift b/apple/TableProTests/Views/Settings/ThemeSlotRowTests.swift index f579b4ccb0..828f9c0525 100644 --- a/apple/TableProTests/Views/Settings/ThemeSlotRowTests.swift +++ b/apple/TableProTests/Views/Settings/ThemeSlotRowTests.swift @@ -48,6 +48,10 @@ struct ThemeSlotRowTests { #expect(line(.syntaxOperator) == "Based on Editor Text") #expect(line(.gridAlternateRow) == "Based on Data Grid Background") #expect(line(.gridBoolTrue) == "System color") + for slot in [ThemeSlot.gridHeaderBackground, .gridHeaderText, .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder] { + #expect(line(slot) == "System color", "\(slot.rawValue)") + #expect(ThemeSlotPresentation(slot: slot, theme: Self.dark).resetTitle == "Use System Color", "\(slot.rawValue)") + } var light = Self.dark light.appearance = .light diff --git a/apple/TableProTests/Views/Structure/StructureListGridModelTests.swift b/apple/TableProTests/Views/Structure/StructureListGridModelTests.swift index bf5a3f9f6a..bceac298d7 100644 --- a/apple/TableProTests/Views/Structure/StructureListGridModelTests.swift +++ b/apple/TableProTests/Views/Structure/StructureListGridModelTests.swift @@ -226,7 +226,8 @@ struct StructureListGridToneTests { deletedRowText: .systemPink, modifiedColumnTint: .systemYellow, findMatchTint: .systemOrange, - selectionText: .systemIndigo + selectionText: .systemIndigo, + focusBorder: .keyboardFocusIndicatorColor ) private func appearance( diff --git a/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift b/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift index 97159c9167..b53f3899dd 100644 --- a/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift +++ b/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineThemeTests.swift @@ -46,7 +46,7 @@ struct PrivilegeScopeOutlineThemeTests { return hex } - @Test("Under the default theme the outline keeps AppKit's background, stripes and selection") + @Test("Under the default theme the outline keeps AppKit's background and stripes, and its rows draw the system selection") func defaultThemeTouchesNothing() throws { try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt([:], appearance: .light) @@ -59,17 +59,18 @@ struct PrivilegeScopeOutlineThemeTests { #expect(mounted.outline.usesAlternatingRowBackgroundColors) #expect(!mounted.scrollView.drawsBackground) #expect(!chrome.isBackgroundThemed) - #expect(chrome.selectionColor(emphasized: true) == nil) + #expect(chrome.selectionColor(emphasized: true) === NSColor.selectedContentBackgroundColor) + #expect(chrome.selectionColor(emphasized: false) === NSColor.unemphasizedSelectedContentBackgroundColor) #expect(mounted.outline.numberOfRows == 2) for row in 0 ..< mounted.outline.numberOfRows { - let rowView = try #require(mounted.outline.rowView(atRow: row, makeIfNecessary: true)) - #expect(type(of: rowView) == NSTableRowView.self) + let rowView = try #require(mounted.outline.rowView(atRow: row, makeIfNecessary: true) as? ThemeTableRowView) + #expect(rowView.chrome === chrome) } } } - @Test("A theme's background swaps in themed rows and a default theme swaps AppKit's back, keeping the selection") - func rowClassFollowsTheTheme() throws { + @Test("A theme change restyles the rows in place, keeping the selection") + func rowsSurviveThemeChanges() throws { try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt([:], appearance: .light) let mounted = try mount() @@ -77,23 +78,22 @@ struct PrivilegeScopeOutlineThemeTests { mounted.outline.selectRowIndexes(IndexSet(integer: 1), byExtendingSelection: false) let selectedScopes = mounted.coordinator.viewModel.selectedScopes #expect(selectedScopes.count == 1) - _ = try #require(mounted.outline.rowView(atRow: 1, makeIfNecessary: true)) + let row = try #require(mounted.outline.rowView(atRow: 1, makeIfNecessary: true) as? ThemeTableRowView) var published = 0 let subscription = mounted.coordinator.viewModel.$selectedScopes.dropFirst().sink { _ in published += 1 } defer { subscription.cancel() } - try ThemeTestFixture.adopt([.gridBackground: "#FAFAFA"], appearance: .light) - let themed = try #require(mounted.outline.rowView(atRow: 1, makeIfNecessary: true) as? ThemeTableRowView) - #expect(themed.chrome === chrome) + try ThemeTestFixture.adopt([.gridBackground: "#FAFAFA", .gridSelection: "#C8E1FF"], appearance: .light) + #expect(mounted.outline.rowView(atRow: 1, makeIfNecessary: true) === row) + #expect(row.chrome === chrome) #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 1)) try ThemeTestFixture.adopt([:], appearance: .light) - let native = try #require(mounted.outline.rowView(atRow: 1, makeIfNecessary: true)) - #expect(type(of: native) == NSTableRowView.self) + #expect(mounted.outline.rowView(atRow: 1, makeIfNecessary: true) === row) #expect(mounted.outline.selectedRowIndexes == IndexSet(integer: 1)) #expect(mounted.coordinator.viewModel.selectedScopes == selectedScopes) - #expect(published == 0, "the rebuild republished the selection \(published) times") + #expect(published == 0, "the theme change republished the selection \(published) times") } } @@ -111,7 +111,7 @@ struct PrivilegeScopeOutlineThemeTests { } } - @Test("Under a theme the outline takes the grid's background and stripes, keeps AppKit's selection, and gets its own back") + @Test("Under a theme the outline takes the grid's background, stripes and selection, and gets its own back") func themeAppliesAndRestores() throws { try ThemeTestFixture.preservingEngine { try ThemeTestFixture.adopt([:], appearance: .light) @@ -122,18 +122,31 @@ struct PrivilegeScopeOutlineThemeTests { try ThemeTestFixture.adopt([ .gridBackground: "#FAFAFA", .gridAlternateRow: "#F0F0F0", + .gridSelection: "#C8E1FF", + .gridInactiveSelection: "#E0E0E0", ], appearance: .light) #expect(try hex(mounted.outline.backgroundColor) == "#FAFAFA") #expect(!mounted.outline.usesAlternatingRowBackgroundColors) #expect(try hex(chrome.rowBackgroundColor(forRow: 0)) == "#FAFAFA") #expect(try hex(chrome.rowBackgroundColor(forRow: 1)) == "#F0F0F0") - #expect(chrome.selectionColor(emphasized: true) == nil) + #expect(try hex(chrome.selectionColor(emphasized: true)) == "#C8E1FF") + #expect(try hex(chrome.selectionColor(emphasized: false)) == "#E0E0E0") try ThemeTestFixture.adopt([:], appearance: .light) #expect(mounted.outline.backgroundColor == background) #expect(mounted.outline.usesAlternatingRowBackgroundColors) #expect(!mounted.scrollView.drawsBackground) #expect(chrome.rowBackgroundColor(forRow: 1) == nil) + #expect(chrome.selectionColor(emphasized: true) === NSColor.selectedContentBackgroundColor) } } + + @Test("The outline's header is a theme header, and both columns theme header cells") + func headerIsAThemeHeader() throws { + let mounted = try mount() + + #expect(mounted.outline.headerView is ThemeTableHeaderView) + #expect(mounted.outline.tableColumns.count == 2) + #expect(mounted.outline.tableColumns.allSatisfy { $0.headerCell is ThemeTableHeaderCell }) + } } diff --git a/apple/changelog.d/3833.changed.1.md b/apple/changelog.d/3833.changed.1.md deleted file mode 100644 index c9861bc279..0000000000 --- a/apple/changelog.d/3833.changed.1.md +++ /dev/null @@ -1 +0,0 @@ -The data grid header, selection and selected cell use the system colors and follow Accent and focus. diff --git a/apple/changelog.d/3833.removed.md b/apple/changelog.d/3833.removed.md deleted file mode 100644 index 6ca41449bd..0000000000 --- a/apple/changelog.d/3833.removed.md +++ /dev/null @@ -1 +0,0 @@ -Themes no longer color the data grid header, selection, selected text or focus border. diff --git a/apple/changelog.d/3837.changed.md b/apple/changelog.d/3837.changed.md new file mode 100644 index 0000000000..66a0cbb415 --- /dev/null +++ b/apple/changelog.d/3837.changed.md @@ -0,0 +1 @@ +A theme that leaves out the data grid header or selected text colors uses the macOS colors instead of its grid colors.