Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/rules/data-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 1 addition & 2 deletions .claude/rules/theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,14 @@ 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

- **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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -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<String> = [
"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
Expand Down Expand Up @@ -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] = [:]
Expand Down
39 changes: 31 additions & 8 deletions apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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")
}
}
Expand Down
Loading
Loading