diff --git a/.claude/rules/data-grid.md b/.claude/rules/data-grid.md index de52786bb5..174e7a4317 100644 --- a/.claude/rules/data-grid.md +++ b/.claude/rules/data-grid.md @@ -11,6 +11,7 @@ paths: - **Cells are drawn, not mounted.** `tableView(_:viewFor:row:)` returns nil for data columns and `DataGridRowView` draws the visible cells, and every column stays attached. Never hide off-screen columns with `NSTableColumn.isHidden`: each write costs O(attached columns). - **Find data columns through `DataGridColumnPool.presentsColumn` and `firstPresentedColumnIndex`**, never a fixed index into `tableColumns` (the row-number column and pool slots sit there too). - **The header draws all of its own chrome** through `SortableHeaderChrome`: the cell never calls `super.draw`, and `highlightedTableColumn` stays unset, because AppKit paints a fixed-height band that does not fit the taller header. Sort state goes through `SortableHeaderView.applySortState(_:schema:)`, which also sets each header cell's accessibility sort direction. +- **Row selection is `NSTableRowView`'s own** unless the theme overrides `gridSelection` or `gridInactiveSelection`, so the default theme follows Accent and focus. The gutter and a selected column heading read `DataGridBodyChrome.selectionColor(emphasized:)` so they match the row. A theme change repaints visible rows; it never reloads data. - **`gridStyleMask` stays empty; `DataGridBodyChrome` draws column separators and the stripes.** AppKit's vertical grid lines add one subview per column and make every layout pass quadratic. Take separator positions from `presentsColumn` and `rect(ofColumn:)`. - **Scroll the grid with `NSView.scroll(_:)`**, never `clipView.scroll(to:)` plus `reflectScrolledClipView`, which leaves the header behind. - **Accessibility goes through `DataGridCellAccessibilityView`**, mounted for on-screen rows once `DataGridAccessibility.isActive`; `NSTableView` builds its cell tree from views only. Keep that path working whenever row drawing changes. diff --git a/.claude/rules/editor.md b/.claude/rules/editor.md index 76b4375cca..ecf3411406 100644 --- a/.claude/rules/editor.md +++ b/.claude/rules/editor.md @@ -9,10 +9,10 @@ paths: # Editor and theme - **`ThemeEngine` is the single source of theme colors**; `ThemeEngine.makeEditorTheme()` builds the `TableProEditorTheme` adapter. -- **A slot exists only with a reader.** `ThemeSlot` is the one registry the gate, the theme editor and the decoder enumerate, and `ThemeSlotCoverageTests` fails a slot nothing reads. A theme file must declare every slot of its schema, as `#hex` or `system:`. -- **Two color tiers.** `palette[slot]` is a dynamic color that answers for the theme in effect; use it wherever a view holds a color. `resolved[slot]` is a snapshot for code that bakes one (`CALayer`, the editor theme, the grid's cell drawing), paired with an `AppEvents.shared.themeChanged` subscription. -- **A theme change has to reach the view.** A SwiftUI view that reads the engine observes it (`@ObservedObject private var themeEngine = ThemeEngine.shared`), because SwiftUI caches `Color(nsColor:)` by object identity; an AppKit view subscribes to `themeChanged` and redisplays. -- **A theme colors content, never chrome.** `NSApp.appearance` is the only lever on the sidebar, the trailing pane, the editor tab strip and the window background: a theme declares `light` or `dark` and the frame follows. Painting them means covering views AppKit owns (the sidebar's `NSGlassEffectView`, the titlebar band, the source-list selection), so every slot is namespaced `content.` and none is added for chrome. +- **A theme colors two surfaces: code views and the data grid.** Code views are the SQL editor and every other `SourceEditor` or SQL text view (JSON editor, DDL, trigger, previews, AI code blocks, history). Every other pane, Settings and all chrome use semantic system colors directly and never read the theme. `NSApp.appearance` is the only lever on chrome. +- **A slot exists only with a reader.** `ThemeSlot` is the one registry the decoder, encoder and theme editor enumerate, and `ThemeSlotCoverageTests` fails a slot nothing reads. Every slot is optional: a theme that leaves one out draws `ThemeSlot.nativeColor`, the color AppKit itself uses there. Default Light and Default Dark override syntax only, with Xcode's Default values. +- **Two color tiers.** `palette[slot]` is a dynamic color over the live theme pair; use it wherever a view holds a color. `resolved[slot]` is the active theme's override or the system color object itself, never a flattened copy; turn it into a `CGColor` only inside the drawing view's appearance (`resolved.cgColor(_:in:)`), and check `resolved.isOverridden(_:)` before replacing a native drawing path (row selection, caret, header). Never call `withAlphaComponent` on a system color outside an `NSColor(name:dynamicProvider:)`: it freezes the color. +- **A theme change has to reach the view.** `AppEvents.shared.themeChanged` fires on a theme change, a light/dark flip, an Accent or Highlight change and an Increase Contrast toggle. A SwiftUI view that reads the engine observes it (`@ObservedObject private var themeEngine = ThemeEngine.shared`), because SwiftUI caches `Color(nsColor:)` by object identity; an AppKit view that bakes a color subscribes to `themeChanged` and re-bakes. - **Fonts are not theme.** `TypographySettings` is device-local and stays out of `AppSettingsCategory.synced`: sync replaces a category wholesale, so an older Mac would reset it and each zoom press would push a record. - **Two font domains.** A view that also wears the editor's colors (the SQL editor, `JSONCodeEditor`, previews) uses `editorFonts`. Every control that shows or edits a stored value uses `ThemeEngine.valueFont` or `valueFontSwiftUI` (the Data Grid Font), so a value reads the same in the grid, the inspector and a popover; a system text style there looks right only while the two settings are equal. `InspectorFieldRow` sets it for the whole inspector, and its `FieldEditorKind` switch is exhaustive on purpose. - **Multi-cursor lives in `cursorPositions: [CursorPosition]`.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 075b676ca9..7817a15a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Header, grid line, selection and focus colors in the theme editor. -- Panel and status colors in the theme editor, covering the results, inspector, structure, compare and query plan panes. -- Themes that name a system color for a slot, so the built-in themes keep the system's own contrast settings. +- Light theme and Dark theme pickers, and New Theme Based On, Rename and Show in Finder for custom themes in Settings > Appearance. - Reason shown in Settings > Appearance for a theme file that could not be loaded. - Setting in Settings > General to hide the version and Check for Updates on the Welcome window. - Install Parquet… in the Export dialog's format menu when the Parquet plugin is not installed. @@ -19,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Theme file format 2. Themes written for earlier versions are not read and need to be recreated. +- Default Light and Default Dark draw the editor and data grid in the system colors and use Xcode's default syntax colors. +- Themes color only the SQL editor, other code views and the data grid. Every other pane keeps the system colors. +- Theme file format 3, where every color is optional. Themes written for earlier versions are not read and need to be recreated. - Settings > Editor grouped into Display, Gutter and Editing. - Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac. - Editor font size range of 10 to 24 points everywhere, including zoom. @@ -63,9 +64,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Theme editor changing the active theme instead of the theme selected for the slot being edited. - Malformed theme files loading as Default Light under their own name. - Color with a typo in it rendering as a different color instead of being reported. -- Content panes outside the editor and the data grid ignoring the theme. +- Editor and data grid colors not following a change to the Accent color, Highlight color or Increase Contrast. +- Selected text in the editor ignoring the Highlight color, and the cursor ignoring the Accent color. +- Zoom In and Zoom Out staying enabled at the largest and smallest font size. - JSON and PHP tree values colored differently from the same values in the row inspector. -- Autocomplete icon colors ignoring the theme. - Theme color wells and the theme preview unreadable to VoiceOver. - Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background. - GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift index 4127dc02bb..f699ec590e 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Model/SuggestionViewModel.swift @@ -14,8 +14,6 @@ final class SuggestionViewModel: ObservableObject { /// The items to be displayed in the window @Published var items: [CodeSuggestionEntry] = [] @Published var selectedIndex: Int = 0 - @Published var themeBackground: NSColor = .windowBackgroundColor - @Published var themeTextColor: NSColor = .labelColor var itemsRequestTask: Task? weak var activeTextView: TextViewController? @@ -76,28 +74,6 @@ final class SuggestionViewModel: ObservableObject { } } - func updateTheme(from textView: TextViewController) { - themeTextColor = textView.theme.text.color - switch textView.systemAppearance { - case .aqua: - let color = textView.theme.background - if color != .clear { - themeBackground = NSColor( - red: color.redComponent * 0.95, - green: color.greenComponent * 0.95, - blue: color.blueComponent * 0.95, - alpha: 1.0 - ) - } else { - themeBackground = .windowBackgroundColor - } - case .darkAqua: - themeBackground = textView.theme.background - default: - break - } - } - func showCompletions( textView: TextViewController, delegate: CodeSuggestionDelegate, diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/CodeSuggestionLabelView.swift b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/CodeSuggestionLabelView.swift index 7cde5c1a4d..3c5bb18c75 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/CodeSuggestionLabelView.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/CodeSuggestionLabelView.swift @@ -12,23 +12,21 @@ struct CodeSuggestionLabelView: View { static let horizontalPadding: CGFloat = 13 let suggestion: CodeSuggestionEntry - let labelColor: NSColor - let secondaryLabelColor: NSColor let font: NSFont var isSelected: Bool = false private var effectiveLabelColor: Color { if isSelected { - return .white + return Color(nsColor: .alternateSelectedControlTextColor) } - return suggestion.deprecated ? Color(secondaryLabelColor) : Color(labelColor) + return Color(nsColor: suggestion.deprecated ? NSColor.secondaryLabelColor : NSColor.labelColor) } private var effectiveSecondaryColor: Color { if isSelected { - return Color.white.opacity(0.7) + return Color(nsColor: .alternateSelectedControlTextColor).opacity(0.7) } - return Color(secondaryLabelColor) + return Color(nsColor: .secondaryLabelColor) } // swiftlint:disable shorthand_operator diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/SuggestionContentView.swift b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/SuggestionContentView.swift index ccad81e058..9a2f74b50a 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/SuggestionContentView.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/View/SuggestionContentView.swift @@ -32,7 +32,7 @@ struct SuggestionContentView: View { } } .frame(width: contentWidth) - .background(Color(nsColor: model.themeBackground)) + .background(SuggestionPanelBackground()) .clipShape(RoundedRectangle(cornerRadius: 8.5)) .contentShape(Rectangle()) .onTapGesture { @@ -67,8 +67,6 @@ struct SuggestionContentView: View { private func suggestionRow(index: Int, item: CodeSuggestionEntry) -> some View { CodeSuggestionLabelView( suggestion: item, - labelColor: model.themeTextColor, - secondaryLabelColor: model.themeTextColor.withAlphaComponent(0.5), font: model.activeTextView?.font ?? .systemFont(ofSize: 12), isSelected: index == model.selectedIndex ) @@ -125,3 +123,26 @@ struct SuggestionContentView: View { .padding(.vertical, 10) } } + +/// The panel's window is clear, so a SwiftUI material would have nothing behind it to blur. A visual effect view +/// blending behind the window samples the desktop and the editor the way a menu does. +struct SuggestionPanelBackground: NSViewRepresentable { + static func makeView() -> NSVisualEffectView { + let view = DecorativeVisualEffectView() + view.material = .menu + view.blendingMode = .behindWindow + view.state = .active + return view + } + + func makeNSView(context: Context) -> NSVisualEffectView { + Self.makeView() + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +/// A click on the panel's background belongs to the SwiftUI tap that dismisses the panel, not to this view. +private final class DecorativeVisualEffectView: NSVisualEffectView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift index f16b6448a3..ba50ea5e73 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/CodeSuggestion/Window/SuggestionController.swift @@ -117,8 +117,6 @@ public final class SuggestionController: NSWindowController { cursorPosition: cursorPosition, isManualTrigger: isManualTrigger ) { parentWindow, cursorRect in - self.model.updateTheme(from: textView) - if asPopover { self.popover?.close() self.popover = nil diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift index 7967e3a3bb..ec96fb7be7 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift @@ -6,6 +6,7 @@ // import AppKit +import Combine import TableProTextEngine extension TextViewController { @@ -158,12 +159,39 @@ extension TextViewController { .store(in: &cancellables) } + /// A Highlight color or Increase Contrast change reaches no view's `viewDidChangeEffectiveAppearance`, and the + /// editor's system colors are only resolved when drawn, so it redraws what holds them. + func setUpSystemColorsObserver() { + NotificationCenter.default.publisher(for: NSColor.systemColorsDidChangeNotification) + .merge( + with: NSWorkspace.shared.notificationCenter.publisher( + for: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification + ) + ) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.redisplayColors() + } + .store(in: &cancellables) + } + + func redisplayColors() { + guard isViewLoaded, let textView else { return } + textView.emphasisManager?.refreshColors() + textView.needsDisplay = true + textView.subviews.forEach { $0.needsDisplay = true } + gutterView?.needsDisplay = true + gutterView?.foldingRibbon.needsDisplay = true + gutterView?.statementRunRibbon.needsDisplay = true + } + func setUpOberservers() { setUpOnScrollChangeObserver() setUpOnScrollViewFrameChangeObserver() setUpTextViewFrameChangeObserver() setUpSelectionChangedObserver() setUpAppearanceChangedObserver() + setUpSystemColorsObserver() } /// Asked before any link of any editor's chain, whichever of its views holds focus, for a key diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Extensions/NSColor+LightDark.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Extensions/NSColor+LightDark.swift index aebb3cd924..72aea7d16a 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Extensions/NSColor+LightDark.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Extensions/NSColor+LightDark.swift @@ -18,4 +18,16 @@ extension NSColor { } } } + + /// `withAlphaComponent` on a system or provider color resolves it once, for the appearance current at the call, + /// and keeps that value. Inside a provider it resolves again for every appearance the color is drawn in. + func withDynamicAlpha(_ alpha: CGFloat) -> NSColor { + NSColor(name: nil) { appearance in + var tinted = self + appearance.performAsCurrentDrawingAppearance { + tinted = self.withAlphaComponent(alpha) + } + return tinted + } + } } diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Highlighting/Highlighter.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Highlighting/Highlighter.swift index 2239c4af80..89ef4808f7 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Highlighting/Highlighter.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Highlighting/Highlighter.swift @@ -128,11 +128,18 @@ class Highlighter: NSObject { // MARK: - Public - /// Invalidates all text in the editor. Useful for updating themes. + /// Invalidates all text in the editor, so every provider is asked for its captures again. func invalidate() { highlightProviders.forEach { $0.invalidate() } } + /// A theme change moves no capture, so the captures already found are styled again in place. Asking the providers + /// again would leave the text unstyled until they answer. + func restyle() { + guard let textView else { return } + styleContainerDidUpdate(in: NSRange(location: 0, length: textView.textStorage.length)) + } + func invalidate(_ set: IndexSet) { highlightProviders.forEach { $0.invalidate(set) } } diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/InvisibleCharacters/InvisibleCharactersCoordinator.swift b/Packages/TableProEditor/Sources/TableProEditorKit/InvisibleCharacters/InvisibleCharactersCoordinator.swift index 0d08870179..1ea3e1c338 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/InvisibleCharacters/InvisibleCharactersCoordinator.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/InvisibleCharacters/InvisibleCharactersCoordinator.swift @@ -178,8 +178,10 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { ) } + private static let warningColor = NSColor.systemRed.withDynamicAlpha(0.3) + private func warningCharacterStyle(for character: UInt16) -> InvisibleCharacterStyle? { guard warningCharacters.contains(character) else { return nil } - return .emphasize(color: .systemRed.withAlphaComponent(0.3)) + return .emphasize(color: Self.warningColor) } } diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/LineFolding/Model/LineFoldModel.swift b/Packages/TableProEditor/Sources/TableProEditorKit/LineFolding/Model/LineFoldModel.swift index 5d8acb9917..6212fa9817 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/LineFolding/Model/LineFoldModel.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/LineFolding/Model/LineFoldModel.swift @@ -332,11 +332,11 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { extension LineFoldModel: LineFoldPlaceholderDelegate { func placeholderBackgroundColor() -> NSColor { - controller?.configuration.appearance.theme.invisibles.color ?? .lightGray + controller?.configuration.appearance.theme.invisibles.color ?? .tertiaryLabelColor } func placeholderTextColor() -> NSColor { - controller?.configuration.appearance.theme.text.color.withAlphaComponent(0.35) ?? .tertiaryLabelColor + controller?.configuration.appearance.theme.text.color.withDynamicAlpha(0.35) ?? .tertiaryLabelColor } func placeholderSelectedColor() -> NSColor { diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift b/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift index 3b64171d31..628c4e3eeb 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -82,6 +82,7 @@ public extension SourceEditorConfiguration { @MainActor func didSetOnController(controller: TextViewController, oldConfig: Appearance?) { var needsHighlighterInvalidation = false + var needsRestyle = false if oldConfig?.font != font { controller.textView.font = font @@ -92,7 +93,7 @@ public extension SourceEditorConfiguration { if oldConfig?.theme != theme || oldConfig?.useThemeBackground != useThemeBackground { updateControllerNewTheme(controller: controller) - needsHighlighterInvalidation = true + needsRestyle = true } if oldConfig?.tabWidth != tabWidth { @@ -140,6 +141,8 @@ public extension SourceEditorConfiguration { if needsHighlighterInvalidation { controller.highlighter?.invalidate() + } else if needsRestyle { + controller.highlighter?.restyle() } } @@ -150,9 +153,7 @@ public extension SourceEditorConfiguration { range: NSRange(location: 0, length: controller.textView.textStorage.length) ) controller.textView.selectionManager.selectionBackgroundColor = theme.selection - controller.textView.selectionManager.selectedLineBackgroundColor = getThemeBackground( - systemAppearance: controller.systemAppearance - ) + controller.textView.selectionManager.selectedLineBackgroundColor = currentLineColor controller.textView.selectionManager.insertionPointColor = theme.insertionPoint controller.textView.enclosingScrollView?.backgroundColor = if useThemeBackground { theme.background @@ -161,23 +162,17 @@ public extension SourceEditorConfiguration { } (controller.textView as? SourceEditorTextView)?.statementHighlightColor = theme.statementHighlight + (controller.textView as? SourceEditorTextView)?.backgroundFillColor = useThemeBackground ? theme.background : nil - let gutterColor = theme.lineNumber ?? theme.text.color.withAlphaComponent(0.35) + let gutterColor = theme.lineNumber ?? .secondaryLabelColor controller.gutterView.textColor = gutterColor controller.gutterView.statementRunRibbon.glyphColor = gutterColor - controller.gutterView.statementRunRibbon.hoveredGlyphColor = theme.text.color controller.gutterView.foldingRibbon.chevronColor = gutterColor - controller.gutterView.foldingRibbon.collapsedChevronColor = theme.text.color.withAlphaComponent(0.65) + controller.gutterView.foldingRibbon.collapsedChevronColor = theme.text.color.withDynamicAlpha(0.65) controller.gutterView.foldingRibbon.hoveredChevronColor = theme.text.color - controller.gutterView.foldingRibbon.foldExtentColor = theme.text.color.withAlphaComponent(0.2) + controller.gutterView.foldingRibbon.foldExtentColor = theme.text.color.withDynamicAlpha(0.2) controller.gutterView.selectedLineTextColor = theme.text.color - controller.gutterView.selectedLineColor = if useThemeBackground { - theme.lineHighlight - } else if controller.systemAppearance == .darkAqua { - NSColor.quaternaryLabelColor - } else { - NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - } + controller.gutterView.selectedLineColor = currentLineColor controller.gutterView.backgroundColor = if useThemeBackground { theme.background } else { @@ -187,18 +182,13 @@ public extension SourceEditorConfiguration { controller.textView.typingAttributes = controller.attributesFor(nil) } - /// Finds the preferred use theme background. - /// - Returns: The background color to use. - private func getThemeBackground(systemAppearance: NSAppearance.Name?) -> NSColor { - if useThemeBackground { - return theme.lineHighlight - } - - if systemAppearance == .darkAqua { - return NSColor.quaternaryLabelColor - } - - return NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + private var currentLineColor: NSColor { + useThemeBackground ? theme.lineHighlight : Self.transparentCurrentLineColor } + + private static let transparentCurrentLineColor = NSColor( + light: NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled), + dark: .quaternaryLabelColor + ) } } diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift b/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift index b32b95346e..9b2f1fa8b1 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift @@ -37,7 +37,21 @@ final class SourceEditorTextView: TextView { /// view, the trigger editor, the SQL preview and the AI chat code blocks all use this class. var statementBoundaryProvider: ((_ offset: Int, _ forward: Bool) -> Int?)? + /// Painted by the text view itself, the way `NSTextView.drawsBackground` does. Left to the enclosing scroll view, + /// a semantic color such as `textBackgroundColor` is drawn as the window's background inside a window with a + /// toolbar, so the text area no longer matched the gutter in Dark. + var backgroundFillColor: NSColor? { + didSet { + guard backgroundFillColor != oldValue else { return } + needsDisplay = true + } + } + override func draw(_ dirtyRect: NSRect) { + if let backgroundFillColor { + backgroundFillColor.setFill() + dirtyRect.fill() + } drawStatementHighlight(in: dirtyRect) super.draw(dirtyRect) } diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift index 4964d8077e..6c95adc5f2 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift @@ -26,7 +26,10 @@ public struct EditorTheme: Equatable { } public var text: Attribute - public var insertionPoint: NSColor + + /// `nil` leaves the caret to the system, which draws it in the Highlight color on macOS 14 and later. + public var insertionPoint: NSColor? + public var invisibles: Attribute public var background: NSColor public var lineHighlight: NSColor @@ -36,7 +39,7 @@ public struct EditorTheme: Equatable { public var selection: NSColor - /// The gutter's line numbers. `nil` keeps the derived look: the text colour at 35% alpha. + /// The gutter's line numbers, run controls and fold chevrons. `nil` draws them in `secondaryLabelColor`. public var lineNumber: NSColor? public var keywords: Attribute @@ -54,7 +57,7 @@ public struct EditorTheme: Equatable { public init( text: Attribute, - insertionPoint: NSColor, + insertionPoint: NSColor? = nil, invisibles: Attribute, background: NSColor, lineHighlight: NSColor, diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/Cursors/CursorView.swift b/Packages/TableProEditor/Sources/TableProTextEngine/Cursors/CursorView.swift index c6b514aa6f..b563e0b5e9 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/Cursors/CursorView.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/Cursors/CursorView.swift @@ -9,10 +9,9 @@ import AppKit /// Animates a cursor. Will sync animation with any other cursor views. open class CursorView: NSView { - /// The color of the cursor. public var color: NSColor { didSet { - layer?.backgroundColor = color.safeCGColor + needsDisplay = true } } @@ -25,13 +24,14 @@ open class CursorView: NSView { true } + /// The layer holds a resolved color, so it is set where AppKit has made this view's appearance current, and set + /// again whenever that appearance changes. + override open var wantsUpdateLayer: Bool { + true + } + override open func hitTest(_ point: NSPoint) -> NSView? { nil } - /// Create a cursor view. - /// - Parameters: - /// - blinkDuration: The duration to blink, leave as nil to never blink. - /// - color: The color of the cursor. - /// - width: How wide the cursor should be. init( color: NSColor = NSColor.labelColor, width: CGFloat = 1.0 @@ -43,6 +43,11 @@ open class CursorView: NSView { frame.size.width = width wantsLayer = true + layerContentsRedrawPolicy = .onSetNeedsDisplay + needsDisplay = true + } + + override open func updateLayer() { layer?.backgroundColor = color.safeCGColor } diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/Emphasis.swift b/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/Emphasis.swift index 8c960742ea..36be0ffd43 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/Emphasis.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/Emphasis.swift @@ -22,9 +22,8 @@ public struct Emphasis: Equatable { /// Set to `true` to style the emphasis as 'inactive'. /// - /// When ``style`` is ``EmphasisStyle/standard``, this reduces shadows and background color. - /// For all styles, if drawing text on top of them, this uses ``EmphasisManager/getInactiveTextColor`` instead of - /// the text view's text color to render the emphasized text. + /// When ``style`` is ``EmphasisStyle/standard``, this draws the unemphasized selection colors instead of the + /// find highlight, with no shadow. public let inactive: Bool /// Set to `true` if the emphasis manager should update the text view's selected range to match diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift b/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift index af77a724bf..cd0a43a960 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/EmphasisManager/EmphasisManager.swift @@ -62,8 +62,10 @@ public final class EmphasisManager { } private var emphasisGroups: [String: [EmphasisLayer]] = [:] - private let activeColor: NSColor = .findHighlightColor - private let inactiveColor = NSColor.lightGray.withAlphaComponent(0.4) + private static let activeColor: NSColor = .findHighlightColor + private static let activeTextColor: NSColor = .black + private static let inactiveColor: NSColor = .unemphasizedSelectedTextBackgroundColor + private static let inactiveTextColor: NSColor = .unemphasizedSelectedTextColor private var originalSelectionColor: NSColor? private var hasPendingGeometryUpdates = false let toolTips = EmphasisToolTips() @@ -179,6 +181,17 @@ public final class EmphasisManager { emphasisGroups[id, default: []].map(\.emphasis) } + /// Every emphasis holds its colors resolved for one appearance, so they are resolved again when the text view's + /// appearance, accent, highlight or contrast changes. + public func refreshColors() { + withoutImplicitAnimations { + forEachEmphasisLayer { emphasisLayer in + applyColors(of: emphasisLayer.emphasis, to: emphasisLayer.layer) + refreshTextLayer(of: emphasisLayer) + } + } + } + /// The tool tip an emphasis presents at a point in the text view, or `nil` where none is registered. /// /// Emphases own their tool tips, and AppKit only ever asks the owner. This is how anything outside the @@ -411,32 +424,52 @@ public final class EmphasisManager { switch emphasis.style { case .standard: layer.cornerRadius = 4.0 - layer.fillColor = (emphasis.inactive ? inactiveColor : activeColor).safeCGColor - layer.shadowColor = .black layer.shadowOpacity = emphasis.inactive ? 0.0 : 0.5 layer.shadowOffset = CGSize(width: 0, height: 1.5) layer.shadowRadius = 1.5 layer.opacity = 1.0 layer.zPosition = emphasis.inactive ? 0 : 1 - case .underline(let color): + case .underline: layer.lineWidth = 1.0 layer.lineCap = .round - layer.strokeColor = color.safeCGColor layer.fillColor = nil layer.opacity = emphasis.flash ? 0.0 : 1.0 layer.zPosition = 1 - case let .outline(color, shouldFill): + case .outline: layer.cornerRadius = 2.5 - layer.borderColor = color.safeCGColor layer.borderWidth = 0.5 - layer.fillColor = shouldFill ? color.safeCGColor : nil layer.opacity = emphasis.flash ? 0.0 : 1.0 layer.zPosition = 1 } + applyColors(of: emphasis, to: layer) return layer } + private func applyColors(of emphasis: Emphasis, to layer: CAShapeLayer) { + switch emphasis.style { + case .standard: + layer.fillColor = cgColor(emphasis.inactive ? Self.inactiveColor : Self.activeColor) + layer.shadowColor = .black + case .underline(let color): + layer.strokeColor = cgColor(color) + case let .outline(color, shouldFill): + layer.borderColor = cgColor(color) + layer.fillColor = shouldFill ? cgColor(color) : nil + } + } + + private func cgColor(_ color: NSColor) -> CGColor { + guard let textView else { return color.safeCGColor } + return color.safeCGColor(in: textView.effectiveAppearance) + } + + private func resolvedTextColor(for emphasis: Emphasis) -> NSColor { + let color = emphasis.inactive ? Self.inactiveTextColor : Self.activeTextColor + guard let textView else { return color } + return color.resolved(in: textView.effectiveAppearance) + } + private func createTextLayer(for emphasis: Emphasis) -> CATextLayer? { guard let textView = textView, let layoutManager = textView.layoutManager, @@ -475,7 +508,7 @@ public final class EmphasisManager { with originalString: NSAttributedString, emphasis: Emphasis ) { - let textColor = emphasis.inactive ? getInactiveTextColor() : NSColor.black + let textColor = resolvedTextColor(for: emphasis) let text = NSMutableAttributedString(attributedString: originalString) text.addAttribute(.foregroundColor, value: textColor, range: NSRange(location: 0, length: text.length)) let display = SpecialCharacterDisplay.make(from: text, style: textView?.layoutManager.specialCharacterStyle) @@ -500,11 +533,18 @@ public final class EmphasisManager { layer.setNeedsDisplay() } - private func getInactiveTextColor() -> NSColor { - if textView?.effectiveAppearance.name == .darkAqua { - return .white + private func refreshTextLayer(of emphasisLayer: EmphasisLayer) { + guard let textLayer = emphasisLayer.textLayer, + let textStorage = textView?.textStorage, + rangeIsInDocument(emphasisLayer.emphasis.range) else { + return } - return .black + textLayer.sublayers?.forEach { $0.removeFromSuperlayer() } + updateTextLayer( + textLayer, + with: textStorage.attributedSubstring(from: emphasisLayer.emphasis.range), + emphasis: emphasisLayer.emphasis + ) } // MARK: - Animations diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/Extensions/NSColor+SafeCGColor.swift b/Packages/TableProEditor/Sources/TableProTextEngine/Extensions/NSColor+SafeCGColor.swift index adf166302c..41caa3118b 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/Extensions/NSColor+SafeCGColor.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/Extensions/NSColor+SafeCGColor.swift @@ -12,4 +12,24 @@ public extension NSColor { } return CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 0) } + + /// A layer keeps the value it is handed, so a system color going into one is resolved for the appearance its view + /// draws in, never for whatever appearance happens to be current. + func safeCGColor(in appearance: NSAppearance) -> CGColor { + var result: CGColor? + appearance.performAsCurrentDrawingAppearance { + result = self.safeCGColor + } + return result ?? safeCGColor + } + + /// The fixed value of a system color in an appearance, for drawing that runs outside AppKit's own drawing pass, + /// such as a `CATextLayer`. + func resolved(in appearance: NSAppearance) -> NSColor { + var result: NSColor? + appearance.performAsCurrentDrawingAppearance { + result = self.usingColorSpace(.sRGB) + } + return result ?? self + } } diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/TextSelectionManager/TextSelectionManager.swift b/Packages/TableProEditor/Sources/TableProTextEngine/TextSelectionManager/TextSelectionManager.swift index 21fe683ff3..9e7367b694 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/TextSelectionManager/TextSelectionManager.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/TextSelectionManager/TextSelectionManager.swift @@ -23,11 +23,32 @@ public class TextSelectionManager: NSObject { public static let selectionChangedNotification = Notification.Name("com.CodeEdit.TextSelectionManager.TextSelectionChangedNotification") - public var insertionPointColor = NSColor.labelColor { + /// `nil` leaves the caret to the system: an `NSTextInsertionIndicator` keeps its own color, and the drawn caret + /// takes ``nativeInsertionPointColor``. + public var insertionPointColor: NSColor? { didSet { - textSelections.compactMap({ $0.view as? CursorView }).forEach { $0.color = insertionPointColor } + for view in textSelections.compactMap(\.view) { + if let cursorView = view as? CursorView { + cursorView.color = drawnInsertionPointColor + } else if #available(macOS 14.0, *), let indicator = view as? NSTextInsertionIndicator { + indicator.color = insertionPointColor + } + } + } + } + + /// The color the system gives a caret: the Highlight color from macOS 14, the text color before it. + public static var nativeInsertionPointColor: NSColor { + if #available(macOS 14.0, *) { + return .textInsertionPointColor } + return .textColor + } + + var drawnInsertionPointColor: NSColor { + insertionPointColor ?? Self.nativeInsertionPointColor } + public var highlightSelectedLine: Bool = true public var selectedLineBackgroundColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) public var selectionBackgroundColor = NSColor.selectedTextBackgroundColor @@ -213,8 +234,11 @@ public class TextSelectionManager: NSObject { let systemCursorView = NSTextInsertionIndicator(frame: .zero) cursorView = systemCursorView systemCursorView.displayMode = .automatic + if let insertionPointColor { + systemCursorView.color = insertionPointColor + } } else { - let internalCursorView = CursorView(color: insertionPointColor) + let internalCursorView = CursorView(color: drawnInsertionPointColor) cursorView = internalCursorView cursorTimer.register(internalCursorView) } diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+Drag.swift b/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+Drag.swift index a8d6fe9aa1..144f72a3a2 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+Drag.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+Drag.swift @@ -188,9 +188,12 @@ extension TextView: NSDraggingSource { let systemCursor = NSTextInsertionIndicator() cursor = systemCursor systemCursor.displayMode = .visible + if let color = selectionManager.insertionPointColor { + systemCursor.color = color + } addSubview(cursor) } else { - cursor = CursorView(color: selectionManager.insertionPointColor) + cursor = CursorView(color: selectionManager.drawnInsertionPointColor) addSubview(cursor) } diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+Lifecycle.swift b/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+Lifecycle.swift index 3eaf340fe2..7429aaec57 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+Lifecycle.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+Lifecycle.swift @@ -27,4 +27,10 @@ public extension TextView { super.viewDidEndLiveResize() updateFrameIfNeeded() } + + /// AppKit redraws the text, but the emphasis layers hold colors resolved for the old appearance. + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + emphasisManager?.refreshColors() + } } diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelNativeColorTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelNativeColorTests.swift new file mode 100644 index 0000000000..53a8b434f6 --- /dev/null +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/CodeSuggestion/SuggestionPanelNativeColorTests.swift @@ -0,0 +1,99 @@ +// +// SuggestionPanelNativeColorTests.swift +// TableProEditorKit +// + +import AppKit +@testable import TableProEditorKit +import Testing + +/// The panel used to build its background by scaling the editor background's red, green and blue components, which +/// raises for a system color in Light. It now draws a menu material and reads no theme color at all. +@MainActor +struct SuggestionPanelNativeColorTests { + @Test("Completions open in Light over an editor whose background is a system color") + func presentsOverASystemColorBackground() async throws { + let (window, textViewController) = Mock.keyWindowedTextViewController() + defer { window.close() } + window.appearance = NSAppearance(named: .aqua) + textViewController.systemAppearance = .aqua + #expect(textViewController.theme.background.type == .catalog) + + let controller = SuggestionController() + defer { controller.close() } + controller.showCompletions( + textView: textViewController, + delegate: ImmediateSuggestionDelegate(items: [StubSuggestionEntry(label: "SELECT")]), + cursorPosition: CursorPosition(range: NSRange(location: 0, length: 0)) + ) + await controller.model.itemsRequestTask?.value + + #expect(controller.model.isPresented) + } + + @Test("The panel draws a menu material behind its rows") + func panelDrawsAMenuMaterial() throws { + let controller = SuggestionController() + let window = try #require(controller.window) + controller.model.items = [StubSuggestionEntry(label: "SELECT")] + window.setContentSize(NSSize(width: 300, height: 200)) + window.orderFrontRegardless() + defer { window.close() } + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2)) + + let background = try #require(visualEffectView(in: window.contentView)) + #expect(background.material == .menu) + #expect(background.blendingMode == .behindWindow) + } + + @Test("The panel background never takes a click from the tap that dismisses it") + func backgroundIsNotHitTestable() { + let view = SuggestionPanelBackground.makeView() + view.frame = NSRect(x: 0, y: 0, width: 40, height: 40) + + #expect(view.hitTest(NSPoint(x: 10, y: 10)) == nil) + } + + private func visualEffectView(in view: NSView?) -> NSVisualEffectView? { + guard let view else { return nil } + if let effectView = view as? NSVisualEffectView { + return effectView + } + for subview in view.subviews { + if let found = visualEffectView(in: subview) { + return found + } + } + return nil + } +} + +@MainActor +private final class ImmediateSuggestionDelegate: CodeSuggestionDelegate { + let items: [CodeSuggestionEntry] + + init(items: [CodeSuggestionEntry]) { + self.items = items + } + + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition, + isManualTrigger: Bool + ) async -> CodeSuggestionResponse? { + .answeringAnEmptyPrefix(items, at: cursorPosition) + } + + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + nil + } + + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition? + ) {} +} diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorThemeNativeColorTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorThemeNativeColorTests.swift new file mode 100644 index 0000000000..c08927f8f6 --- /dev/null +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorThemeNativeColorTests.swift @@ -0,0 +1,119 @@ +// +// EditorThemeNativeColorTests.swift +// TableProEditorKit +// + +import AppKit +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +/// A theme that overrides nothing hands the editor system colors. The editor has to draw them the way AppKit does, +/// resolved for the appearance it draws in, never frozen at the appearance current when the theme was applied. +@MainActor +struct EditorThemeNativeColorTests { + @Test("Line numbers, run controls and fold chevrons default to the secondary label color") + func gutterDefaultsToSecondaryLabel() { + let controller = Mock.loadedTextViewController() + + #expect(controller.gutterView.textColor == .secondaryLabelColor) + #expect(controller.gutterView.statementRunRibbon.glyphColor == .secondaryLabelColor) + #expect(controller.gutterView.foldingRibbon.chevronColor == .secondaryLabelColor) + } + + @Test("The run control under the pointer keeps the accent color") + func hoveredRunControlFollowsAccent() { + let controller = Mock.loadedTextViewController() + + #expect(controller.gutterView.statementRunRibbon.hoveredGlyphColor == .controlAccentColor) + } + + @Test("A theme's line number color reaches every gutter control") + func lineNumberOverrideReachesTheGutter() { + let controller = Mock.loadedTextViewController() + var theme = controller.theme + theme.lineNumber = .systemPurple + + controller.configuration.appearance.theme = theme + + #expect(controller.gutterView.textColor == .systemPurple) + #expect(controller.gutterView.statementRunRibbon.glyphColor == .systemPurple) + #expect(controller.gutterView.foldingRibbon.chevronColor == .systemPurple) + } + + @Test("Tints derived from the text color resolve again in each appearance") + func derivedTintsStayDynamic() throws { + let controller = Mock.loadedTextViewController() + let ribbon = controller.gutterView.foldingRibbon + + let light = try components(of: ribbon.collapsedChevronColor, in: .aqua) + let dark = try components(of: ribbon.collapsedChevronColor, in: .darkAqua) + + #expect(light.red < 0.1 && abs(light.alpha - 0.65) < 0.01, "Aqua: \(light)") + #expect(dark.red > 0.9 && abs(dark.alpha - 0.65) < 0.01, "DarkAqua: \(dark)") + } + + @Test("The text view paints the theme background itself, the way NSTextView draws its background") + func textViewPaintsTheThemeBackground() throws { + let controller = Mock.loadedTextViewController() + let textView = try #require(controller.textView as? SourceEditorTextView) + + #expect(textView.backgroundFillColor == controller.theme.background) + + var theme = controller.theme + theme.background = .systemTeal + controller.configuration.appearance.theme = theme + #expect(textView.backgroundFillColor == .systemTeal) + + controller.configuration.appearance.useThemeBackground = false + #expect(textView.backgroundFillColor == nil) + } + + @Test("A theme without a cursor color leaves the caret to the system") + func caretIsNativeUnlessOverridden() { + let controller = Mock.loadedTextViewController() + #expect(controller.textView.selectionManager.insertionPointColor == nil) + + var theme = controller.theme + theme.insertionPoint = .systemPink + controller.configuration.appearance.theme = theme + #expect(controller.textView.selectionManager.insertionPointColor == .systemPink) + + theme.insertionPoint = nil + controller.configuration.appearance.theme = theme + #expect(controller.textView.selectionManager.insertionPointColor == nil) + } + + /// An Accent or Highlight change rebuilds the theme from the same system color objects. Equal themes are what + /// keep that from restyling the document and dropping every measured line width. + @Test("A theme rebuilt from the same system colors leaves the laid out document alone") + func equalSystemColorThemeIsANoOp() { + let (window, controller) = Mock.windowedTextViewController(theme: Mock.theme()) + defer { window.close() } + controller.configuration.appearance.wrapLines = false + controller.reloadUI() + window.layoutIfNeeded() + controller.textView.string = String(repeating: "SELECT 1 ", count: 80) + controller.textView.updateFrameIfNeeded() + controller.textView.layoutManager.layoutLines() + let width = controller.textView.layoutManager.lineStorage.maxWidth + #expect(width > 0) + + controller.configuration.appearance.theme = Mock.theme() + + #expect(controller.textView.layoutManager.lineStorage.maxWidth == width) + } + + private func components( + of color: NSColor, + in name: NSAppearance.Name + ) throws -> (red: CGFloat, alpha: CGFloat) { + let appearance = try #require(NSAppearance(named: name)) + var resolved: NSColor? + appearance.performAsCurrentDrawingAppearance { + resolved = color.usingColorSpace(.sRGB) + } + let srgb = try #require(resolved) + return (srgb.redComponent, srgb.alphaComponent) + } +} diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/HighlighterRestyleTests.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/HighlighterRestyleTests.swift new file mode 100644 index 0000000000..3ac789b1ee --- /dev/null +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Highlighting/HighlighterRestyleTests.swift @@ -0,0 +1,51 @@ +// +// HighlighterRestyleTests.swift +// TableProEditorKit +// + +import AppKit +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +/// A theme change moves no capture. Styling the captures already found keeps the text styled, where asking the +/// providers again left it plain until they answered. +@MainActor +struct HighlighterRestyleTests { + private final class SwitchableAttributes: ThemeAttributesProviding { + var keywordColor: NSColor = .systemRed + + func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { + capture == .keyword ? [.foregroundColor: keywordColor] : [:] + } + } + + @Test("Restyling applies the new colors to the captures already found without asking the providers again") + func restyleReusesCaptures() { + let provider = HighlighterTests.MockHighlightProvider { + .success([HighlightRange(range: NSRange(location: 0, length: 6), capture: .keyword)]) + } + let attributes = SwitchableAttributes() + let textView = Mock.textView() + textView.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) + textView.setText("SELECT 1") + let highlighter = Mock.highlighter( + textView: textView, + highlightProviders: [provider], + attributeProvider: attributes + ) + highlighter.invalidate() + #expect(color(at: 0, in: textView) == .systemRed) + let queries = provider.queryCount + + attributes.keywordColor = .systemBlue + highlighter.restyle() + + #expect(color(at: 0, in: textView) == .systemBlue) + #expect(provider.queryCount == queries) + } + + private func color(at location: Int, in textView: TextView) -> NSColor? { + textView.textStorage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor + } +} diff --git a/Packages/TableProEditor/Tests/TableProEditorKitTests/Mock.swift b/Packages/TableProEditor/Tests/TableProEditorKitTests/Mock.swift index 6eea0031a9..0a044a6374 100644 --- a/Packages/TableProEditor/Tests/TableProEditorKitTests/Mock.swift +++ b/Packages/TableProEditor/Tests/TableProEditorKitTests/Mock.swift @@ -166,14 +166,14 @@ enum Mock { return (window, controller) } + /// System colors, unflattened, the way the app hands the editor a theme that overrides nothing. static func theme() -> EditorTheme { EditorTheme( text: EditorTheme.Attribute(color: .textColor), - insertionPoint: .textColor.usingColorSpace(.deviceRGB) ?? .black, - invisibles: EditorTheme.Attribute(color: .gray), - background: .textBackgroundColor.usingColorSpace(.deviceRGB) ?? .black, - lineHighlight: .highlightColor.usingColorSpace(.deviceRGB) ?? .black, - selection: .selectedTextColor.usingColorSpace(.deviceRGB) ?? .black, + invisibles: EditorTheme.Attribute(color: .tertiaryLabelColor), + background: .textBackgroundColor, + lineHighlight: .quinaryLabelColor, + selection: .selectedTextBackgroundColor, keywords: EditorTheme.Attribute(color: .systemPink), commands: EditorTheme.Attribute(color: .systemBlue), types: EditorTheme.Attribute(color: .systemMint), diff --git a/Packages/TableProEditor/Tests/TableProTextEngineTests/SystemColorResolutionTests.swift b/Packages/TableProEditor/Tests/TableProTextEngineTests/SystemColorResolutionTests.swift new file mode 100644 index 0000000000..5f5472045e --- /dev/null +++ b/Packages/TableProEditor/Tests/TableProTextEngineTests/SystemColorResolutionTests.swift @@ -0,0 +1,83 @@ +// +// SystemColorResolutionTests.swift +// TableProTextEngine +// + +import AppKit +@testable import TableProTextEngine +import Testing + +/// A layer keeps the color it is handed. Every system color the text view puts in one is resolved for the text +/// view's own appearance and resolved again when that appearance changes. +@MainActor +struct SystemColorResolutionTests { + @Test("An underline is resolved for the text view's appearance and again after it changes") + func underlineFollowsTheTextViewAppearance() throws { + let textView = makeLaidOutTextView() + textView.appearance = NSAppearance(named: .darkAqua) + let manager = try #require(textView.emphasisManager) + manager.addEmphasis(Emphasis(range: NSRange(location: 0, length: 5), style: .underline(color: .textColor)), for: "e") + let layer = try #require(shapeLayer(in: textView)) + #expect(try brightness(of: layer.strokeColor) > 0.9) + + textView.appearance = NSAppearance(named: .aqua) + textView.viewDidChangeEffectiveAppearance() + + #expect(try brightness(of: layer.strokeColor) < 0.1) + } + + @Test("Other find matches use the unemphasized selection colors") + func inactiveMatchUsesTheUnemphasizedSelection() throws { + let textView = makeLaidOutTextView() + let appearance = try #require(NSAppearance(named: .darkAqua)) + textView.appearance = appearance + let manager = try #require(textView.emphasisManager) + manager.addEmphasis(Emphasis(range: NSRange(location: 0, length: 5), inactive: true), for: "e") + let layer = try #require(shapeLayer(in: textView)) + + let expected = NSColor.unemphasizedSelectedTextBackgroundColor.safeCGColor(in: appearance) + #expect(layer.fillColor == expected) + } + + @Test("A drawn caret resolves its color where AppKit draws it") + func cursorViewResolvesInItsAppearance() throws { + let cursor = CursorView(color: .textColor) + let dark = try #require(NSAppearance(named: .darkAqua)) + let light = try #require(NSAppearance(named: .aqua)) + + dark.performAsCurrentDrawingAppearance { cursor.updateLayer() } + #expect(try brightness(of: cursor.layer?.backgroundColor) > 0.9) + + light.performAsCurrentDrawingAppearance { cursor.updateLayer() } + #expect(try brightness(of: cursor.layer?.backgroundColor) < 0.1) + } + + @Test("With no caret color set, the caret is the system's") + func caretDefaultsToTheSystemColor() { + let textView = makeLaidOutTextView() + let manager = textView.selectionManager + + #expect(manager?.insertionPointColor == nil) + #expect(manager?.drawnInsertionPointColor == TextSelectionManager.nativeInsertionPointColor) + if #available(macOS 14.0, *) { + #expect(TextSelectionManager.nativeInsertionPointColor == .textInsertionPointColor) + } + } + + private func makeLaidOutTextView() -> TextView { + let textView = TextView(string: "Lorem Ipsum") + textView.frame = NSRect(x: 0, y: 0, width: 1_000, height: 100) + textView.layoutManager.layoutLines(in: CGRect(origin: .zero, size: CGSize(width: 1_000, height: 100))) + return textView + } + + private func shapeLayer(in textView: TextView) -> CAShapeLayer? { + textView.layer?.sublayers?.compactMap { $0 as? CAShapeLayer }.first + } + + private func brightness(of color: CGColor?) throws -> CGFloat { + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let components = try #require(color?.converted(to: space, intent: .defaultIntent, options: nil)?.components) + return components.prefix(3).reduce(0, +) / 3 + } +} diff --git a/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift b/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift index 1370dd3509..3290878fce 100644 --- a/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift +++ b/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift @@ -4,6 +4,7 @@ // @preconcurrency import AppKit +import Combine import os import TableProEditorKit import TableProTextEngine @@ -16,6 +17,7 @@ final class GhostTextRenderer { private var ghostLayer: CATextLayer? private var currentText: String? private var currentOffset: Int = 0 + private var themeCancellable: AnyCancellable? private let _scrollObserver = OSAllocatedUnfairLock(uncheckedState: nil) deinit { @@ -24,6 +26,9 @@ final class GhostTextRenderer { func install(controller: TextViewController) { self.controller = controller + themeCancellable = AppEvents.shared.themeChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.redrawForThemeChange() } } func show(_ text: String, at offset: Int) { @@ -43,7 +48,7 @@ final class GhostTextRenderer { let font = ThemeEngine.shared.editorFonts.font let attrs: [NSAttributedString.Key: Any] = [ .font: font, - .foregroundColor: NSColor.tertiaryLabelColor + .foregroundColor: NSColor.tertiaryLabelColor.resolved(in: textView.effectiveAppearance) ] layer.string = NSAttributedString(string: text, attributes: attrs) @@ -78,9 +83,17 @@ final class GhostTextRenderer { func uninstall() { hide() removeScrollObserver() + themeCancellable = nil controller = nil } + /// A `CATextLayer` draws outside AppKit's drawing pass and keeps the color it was built with, so a visible + /// suggestion is rebuilt for the new appearance and font. + private func redrawForThemeChange() { + guard let currentText else { return } + show(currentText, at: currentOffset) + } + // MARK: - Scroll Observer private func installScrollObserver() { diff --git a/TablePro/Core/Autocomplete/SQLCompletionItem.swift b/TablePro/Core/Autocomplete/SQLCompletionItem.swift index 6e06f929a8..8f14e68b5d 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionItem.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionItem.swift @@ -35,18 +35,18 @@ enum SQLCompletionKind: String, CaseIterable { } } - /// Color for the icon - @MainActor + /// The badge sits in the completion panel, which is system chrome, so each kind keeps a system color of its own + /// whatever the editor theme. var iconColor: NSColor { switch self { - case .keyword: return ThemeEngine.shared.palette[.syntaxKeyword] - case .table: return ThemeEngine.shared.palette[.syntaxType] - case .view: return ThemeEngine.shared.palette[.syntaxType] - case .column: return ThemeEngine.shared.palette[.syntaxNull] - case .function: return ThemeEngine.shared.palette[.syntaxFunction] - case .schema: return ThemeEngine.shared.palette[.syntaxType] - case .alias: return ThemeEngine.shared.palette[.syntaxNull] - case .operator: return ThemeEngine.shared.palette[.syntaxOperator] + case .keyword: return .systemBlue + case .table: return .systemTeal + case .view: return .systemPurple + case .column: return .systemOrange + case .function: return .systemPink + case .schema: return .systemGreen + case .alias: return .systemGray + case .operator: return .systemIndigo case .favorite: return .systemYellow } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 08c5d2b7a6..6742a437d5 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -153,6 +153,8 @@ struct MenuValidationContext: Equatable { var hasDatabaseFilter = false var canFilterSchemas = false var hasSchemaFilter = false + var canZoomIn = false + var canZoomOut = false } extension MainSplitViewController: NSMenuItemValidation { @@ -416,10 +418,10 @@ extension MainSplitViewController: NSMenuItemValidation { /// connection and something that can hold a selection, not merely a window. case #selector(clearSelection(_:)): return context.isConnected - /// Unconditional on purpose, and stated rather than left to the fall-through. The window is - /// the last responder to answer these, and what it does with them is change the editor font - /// size, which is an app setting and needs no session. A focused diagram claims them first. - case #selector(zoomIn(_:)), #selector(zoomOut(_:)): return true + /// The window's zoom changes the editor font, an app setting that needs no session. A focused + /// diagram claims these first. + case #selector(zoomIn(_:)): return context.canZoomIn + case #selector(zoomOut(_:)): return context.canZoomOut default: return isAgentSessionCommandEnabled(selector, context: context) } @@ -602,6 +604,7 @@ extension MainSplitViewController: NSMenuItemValidation { /// command actions, so read from them, the one command that reaches its composer was dimmed. var menuValidationContext: MenuValidationContext { let conversations = assistantConversationModel + let typography = AppSettingsManager.shared.typography guard let actions = commandActions else { return MenuValidationContext( hasSelectedWorkspace: workspaces.selectedConnectionId != nil, @@ -612,7 +615,9 @@ extension MainSplitViewController: NSMenuItemValidation { canFocusAssistant: canFocusAssistant, canMoveTabToConnection: canMoveSelectedTabToConnection, hasOtherWindowTabs: hasOtherWindowTabs, - canToggleWorkspaceRail: canToggleWorkspaceRail + canToggleWorkspaceRail: canToggleWorkspaceRail, + canZoomIn: typography.editorFontSize(zoomedBy: 1) != nil, + canZoomOut: typography.editorFontSize(zoomedBy: -1) != nil ) } return MenuValidationContext( @@ -700,7 +705,9 @@ extension MainSplitViewController: NSMenuItemValidation { canFavoriteActiveDatabase: actions.canFavoriteActiveDatabase, hasDatabaseFilter: actions.hasDatabaseFilter, canFilterSchemas: actions.canFilterSchemas, - hasSchemaFilter: actions.hasSchemaFilter + hasSchemaFilter: actions.hasSchemaFilter, + canZoomIn: typography.editorFontSize(zoomedBy: 1) != nil, + canZoomOut: typography.editorFontSize(zoomedBy: -1) != nil ) } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift index 0441981841..fdcda67941 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift @@ -153,8 +153,7 @@ extension MainSplitViewController: ZoomCommandResponding { private func adjustEditorTextSize(by delta: Int) { var typography = AppSettingsManager.shared.typography - let size = TypographySettings.clamp(typography.editorFontSize + delta) - guard size != typography.editorFontSize else { return } + guard let size = typography.editorFontSize(zoomedBy: delta) else { return } typography.editorFontSize = size AppSettingsManager.shared.typography = typography } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 60fe381bae..c17d0b3b0d 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -830,8 +830,8 @@ internal final class MainSplitViewController: NSSplitViewController { /// intermediate value instead of letting one run-loop turn settle on the final one. private func refreshPanes(of workspace: ConnectionWorkspace) { workspace.panes.sidebar.rootView = AnyView(buildSidebarView(for: workspace)) - workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace).themedContent()) - workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace).themedContent()) + workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace)) + workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace)) workspace.panes.assistant.rootView = AnyView(buildAssistantView(for: workspace)) refreshAgentPanes(of: workspace) refreshTabStripPane(of: workspace) @@ -1055,7 +1055,7 @@ internal final class MainSplitViewController: NSSplitViewController { /// publishes those actions. func rebuildTrailingPanes() { guard let selected = workspaces.selected else { return } - selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected).themedContent()) + selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected)) selected.panes.assistant.rootView = AnyView(buildAssistantView(for: selected)) } diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 9ac1f238de..88bfda0f9d 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -206,7 +206,7 @@ final class AppSettingsManager: ObservableObject { self.sync = storage.loadSync() self.mcp = storage.loadMCP() self.notifications = storage.loadNotifications() - self.typography = storage.loadTypography() + self.typography = storage.loadTypography(systemIsDark: NSApp?.effectiveAppearance.isDarkTheme ?? false) general.language.apply() diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index 45bb685f25..3ba55fa3ef 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -86,11 +86,12 @@ final class AppSettingsStorage: Sendable { load(key: Keys.appearance, default: .default) } - /// Fonts moved out of the theme file, and the theme file format they lived in is no longer - /// read. Without this every user who had picked a font or zoomed the editor would silently be - /// returned to System Mono 13 on upgrade, so their sizes are lifted once from whichever theme - /// their slot pointed at, and the theme file itself is left untouched. - func loadTypography() -> TypographySettings { + /// The first launch with no typography setting lifts the fonts out of the old theme files once, + /// so an upgrade does not return everyone to System Mono 13. + func loadTypography( + systemIsDark: Bool, + legacyThemesDirectory: URL = ThemeCatalog.defaultUserDirectory + ) -> TypographySettings { if let stored = defaults.data(forKey: Keys.typography) { do { return try decoder.decode(TypographySettings.self, from: stored) @@ -100,7 +101,11 @@ final class AppSettingsStorage: Sendable { } } - let carried = LegacyThemeFonts.read(preferring: loadAppearance()) + let carried = LegacyThemeFonts.read( + appearance: loadAppearance(), + systemIsDark: systemIsDark, + themesDirectory: legacyThemesDirectory + ) saveTypography(carried) return carried } diff --git a/TablePro/Core/Vim/VimCursorManager.swift b/TablePro/Core/Vim/VimCursorManager.swift index 998196f567..80ffa36e1f 100644 --- a/TablePro/Core/Vim/VimCursorManager.swift +++ b/TablePro/Core/Vim/VimCursorManager.swift @@ -7,12 +7,13 @@ // and hides it to show the default I-beam cursor in Insert mode. // // On macOS 14+, TableProTextEngine uses NSTextInsertionIndicator (system cursor) -// instead of its internal CursorView. Setting insertionPointColor only affects -// CursorView, so we must directly set displayMode on NSTextInsertionIndicator -// subviews to hide/show the I-beam. +// instead of its internal CursorView. No caret color hides it, so we must +// directly set displayMode on NSTextInsertionIndicator subviews to hide/show +// the I-beam. // import AppKit +import Combine import os import TableProTextEngine @@ -28,6 +29,7 @@ final class VimCursorManager { private var isBlockCursorActive = false private var isPaused = false private var appObservers: [NSObjectProtocol] = [] + private var themeCancellable: AnyCancellable? /// Pending work item for deferred cursor hiding — cancels previous to avoid pileup private var deferredHideWorkItem: DispatchWorkItem? @@ -52,6 +54,9 @@ final class VimCursorManager { MainActor.assumeIsolated { self?.resumeBlink() } } appObservers = [resignObserver, activateObserver] + themeCancellable = AppEvents.shared.themeChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.applyBlockCursorColor() } updateMode(.normal) } @@ -60,6 +65,7 @@ final class VimCursorManager { func uninstall() { appObservers.forEach { NotificationCenter.default.removeObserver($0) } appObservers.removeAll() + themeCancellable = nil deferredHideWorkItem?.cancel() deferredHideWorkItem = nil @@ -154,7 +160,6 @@ final class VimCursorManager { } else { let layer = CALayer() layer.contentsScale = textView.window?.backingScaleFactor ?? 2.0 - layer.backgroundColor = ThemeEngine.shared.resolved[.editorCursor].withAlphaComponent(0.4).cgColor layer.frame = frame if !isPaused { @@ -163,9 +168,22 @@ final class VimCursorManager { textView.layer?.addSublayer(layer) blockCursorLayer = layer + applyBlockCursorColor() } } + /// A theme without a cursor color leaves the slot on the system caret color, so the block follows the + /// Highlight color the way the I-beam does. The layer keeps a resolved color, so it is set again on every + /// theme change. + private func applyBlockCursorColor() { + guard let textView, let layer = blockCursorLayer else { return } + let color = ThemeEngine.shared.resolved.cgColor(.editorCursor, in: textView.effectiveAppearance) + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.backgroundColor = color.copy(alpha: color.alpha * 0.4) + CATransaction.commit() + } + // MARK: - Private Helpers private func makeBlinkAnimation() -> CABasicAnimation { diff --git a/TablePro/Models/Settings/LegacyThemeFonts.swift b/TablePro/Models/Settings/LegacyThemeFonts.swift index 2bb3fdfa75..5e54a41ec6 100644 --- a/TablePro/Models/Settings/LegacyThemeFonts.swift +++ b/TablePro/Models/Settings/LegacyThemeFonts.swift @@ -1,12 +1,16 @@ import Foundation import os -/// Reads the `fonts` object out of a theme file written before fonts moved into settings. It is -/// the only code that still opens the old format, it runs once, and it writes nothing back: the -/// theme file keeps its own shape and is rejected by the current loader like any other old file. +/// Reads the `fonts` object out of the theme files TablePro 0.80 wrote, once, on the first launch +/// with no typography setting. It never writes a theme file. internal enum LegacyThemeFonts { private static let logger = Logger(subsystem: "com.TablePro", category: "LegacyThemeFonts") + /// 0.80 drew whatever size the file stored inside these ranges, so the carried size is the + /// one the user saw rather than the one zoom had stored. + private static let renderedEditorSizes = 11...18 + private static let renderedDataGridSizes = 10...18 + private struct Document: Decodable { struct Fonts: Decodable { var editorFontFamily: String? @@ -15,52 +19,72 @@ internal enum LegacyThemeFonts { var dataGridFontSize: Int? } + var id: String? var fonts: Fonts? } - internal static func read(preferring appearance: AppearanceSettings) -> TypographySettings { - let candidates = [appearance.preferredLightThemeId, appearance.preferredDarkThemeId] - - for id in candidates { - guard let fonts = fonts(forThemeId: id) else { continue } + internal static func read( + appearance: AppearanceSettings, + systemIsDark: Bool, + themesDirectory: URL + ) -> TypographySettings { + let fontsById = loadFonts(in: themesDirectory) + for id in candidateIds(appearance: appearance, systemIsDark: systemIsDark) { + guard let fonts = fontsById[id] else { continue } return settings(from: fonts) } - return .default } - private static func fonts(forThemeId id: String) -> Document.Fonts? { - let directories = [userThemesDirectory] + (Bundle.main.resourceURL.map { [$0] } ?? []) + /// 0.80 applied the fonts of the theme on screen, so the slot of the appearance in effect + /// comes first and the other slot only fills in when that theme carried none. + internal static func candidateIds(appearance: AppearanceSettings, systemIsDark: Bool) -> [String] { + let effective = ThemeResolver.effectiveAppearance(mode: appearance.appearanceMode, systemIsDark: systemIsDark) + switch effective { + case .light: return [appearance.preferredLightThemeId, appearance.preferredDarkThemeId] + case .dark: return [appearance.preferredDarkThemeId, appearance.preferredLightThemeId] + } + } - for directory in directories { - let url = directory.appendingPathComponent("\(id).json", isDirectory: false) - guard FileManager.default.fileExists(atPath: url.path) else { continue } + /// Keyed by the `id` inside each file, not the file name: an exported theme the user copied in + /// keeps the name they gave it. + private static func loadFonts(in directory: URL) -> [String: Document.Fonts] { + let files: [URL] + do { + files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension.lowercased() == "json" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } catch { + return [:] + } + var fontsById: [String: Document.Fonts] = [:] + for file in files { do { - let document = try JSONDecoder().decode(Document.self, from: try Data(contentsOf: url)) + let document = try JSONDecoder().decode(Document.self, from: try Data(contentsOf: file)) guard let fonts = document.fonts else { continue } - return fonts + let id = document.id ?? file.deletingPathExtension().lastPathComponent + if fontsById[id] == nil { + fontsById[id] = fonts + } } catch { - logger.error("Could not read legacy fonts from \(url.lastPathComponent): \(error)") + logger.error("Could not read legacy fonts from \(file.lastPathComponent): \(error.publicLogShape, privacy: .public)") } } - - return nil + return fontsById } private static func settings(from fonts: Document.Fonts) -> TypographySettings { let fallback = TypographySettings.default - return TypographySettings( editorFontFamily: fonts.editorFontFamily ?? fallback.editorFontFamily, - editorFontSize: TypographySettings.clamp(fonts.editorFontSize ?? fallback.editorFontSize), + editorFontSize: rendered(fonts.editorFontSize ?? fallback.editorFontSize, in: renderedEditorSizes), dataGridFontFamily: fonts.dataGridFontFamily ?? fallback.dataGridFontFamily, - dataGridFontSize: TypographySettings.clamp(fonts.dataGridFontSize ?? fallback.dataGridFontSize) + dataGridFontSize: rendered(fonts.dataGridFontSize ?? fallback.dataGridFontSize, in: renderedDataGridSizes) ) } - private static var userThemesDirectory: URL { - AppStorageEnvironment.shared.applicationSupportRoot - .appendingPathComponent("TablePro/Themes", isDirectory: true) + private static func rendered(_ size: Int, in range: ClosedRange) -> Int { + TypographySettings.clamp(min(max(size, range.lowerBound), range.upperBound)) } } diff --git a/TablePro/Models/Settings/TypographySettings.swift b/TablePro/Models/Settings/TypographySettings.swift index 998fbcb7ec..be99e8cd9a 100644 --- a/TablePro/Models/Settings/TypographySettings.swift +++ b/TablePro/Models/Settings/TypographySettings.swift @@ -1,11 +1,7 @@ import Foundation -/// Device-local on purpose. Fonts used to live inside the theme file, so a zoom shortcut rewrote -/// and saved a whole theme. Moving them into a synced settings category would be worse: sync -/// replaces the whole struct on apply, so a Mac still on an older build would push a payload -/// without these keys and reset the fonts on every other Mac, and each zoom press would push a -/// record. The one range here is shared by the pickers, the zoom commands and the font caches; -/// three disagreeing ranges are what let zoom store a size the renderer clamped away. +/// Device-local on purpose: sync replaces a whole category, so an older Mac would reset these and +/// every zoom press would push a record. One range serves the pickers, zoom and the font caches. internal struct TypographySettings: Codable, Equatable, Sendable { internal var editorFontFamily: String internal var editorFontSize: Int @@ -54,4 +50,11 @@ internal struct TypographySettings: Codable, Equatable, Sendable { internal static func clamp(_ size: Int) -> Int { min(max(size, sizeRange.lowerBound), sizeRange.upperBound) } + + /// Nil at either end of the range, which is what dims Zoom In and Zoom Out there. + internal func editorFontSize(zoomedBy delta: Int) -> Int? { + let current = clampedEditorFontSize + let zoomed = Self.clamp(current + delta) + return zoomed == current ? nil : zoomed + } } diff --git a/TablePro/Models/UI/FilterableTreeNode.swift b/TablePro/Models/UI/FilterableTreeNode.swift index 02d2a2c227..aaca3d4b34 100644 --- a/TablePro/Models/UI/FilterableTreeNode.swift +++ b/TablePro/Models/UI/FilterableTreeNode.swift @@ -169,7 +169,11 @@ internal struct TreeSiblingCounter { // MARK: - Row Content +/// Shared by the JSON and PHP tree viewers, the results tree and the JSON row inspector. These are +/// system colors on purpose: a theme colors only the SQL editor and the data grid, so a value reads +/// the same here under every theme. internal enum TreeValueTone: Equatable, Sendable { + case key case container case string case number @@ -178,16 +182,15 @@ internal enum TreeValueTone: Equatable, Sendable { case reference case muted - @MainActor var color: NSColor { switch self { - case .container: return ThemeEngine.shared.palette[.syntaxKeyword] - case .string: return ThemeEngine.shared.palette[.syntaxString] - case .number: return ThemeEngine.shared.palette[.syntaxNumber] - case .literal: return ThemeEngine.shared.palette[.syntaxNull] - case .serialized: return ThemeEngine.shared.palette[.syntaxType] + case .key, .container: return .systemBlue + case .string: return .systemRed + case .number: return .systemPurple + case .literal: return .systemOrange + case .serialized: return .systemTeal case .reference: return .systemGray - case .muted: return ThemeEngine.shared.palette[.panelSecondaryText] + case .muted: return .secondaryLabelColor } } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index d196056e2c..f65b8498fd 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -900,9 +900,6 @@ "\"%@\" is not a Realtime Database URL. It looks like https://.firebaseio.com or https://..firebasedatabase.app." : { "extractionState" : "manual", "generatesSymbol" : false - }, - "\"%@\" is not a system color TablePro knows." : { - }, "\"%@\" is not a URL. Enter an endpoint such as http://localhost:8000." : { "extractionState" : "manual", @@ -930,7 +927,7 @@ } } }, - "\"%@\" is not a valid color." : { + "\"%@\" is not a valid color. Use #RRGGBB or #RRGGBBAA." : { }, "\"%@\" is not a valid entry point name." : { @@ -2986,6 +2983,9 @@ } } } + }, + "%@ changed on disk since it was opened. The newer file was kept and reloaded." : { + }, "%@ changed while its documents were being checked, so nothing was changed. Save again." : { "extractionState" : "manual", @@ -3090,6 +3090,9 @@ } } } + }, + "%@ Copy" : { + }, "%@ could not be loaded" : { "localizations" : { @@ -6883,6 +6886,12 @@ } } } + }, + "%1$@ is a dark theme, so %2$@ is used." : { + + }, + "%1$@ is a light theme, so %2$@ is used." : { + }, "%1$@ is the local port, %2$@ and %3$@ are this connection's host and port. The command runs directly rather than through a shell, so start it with /usr/bin/env if you need environment variables." : { "localizations" : { @@ -51230,6 +51239,9 @@ } } } + }, + "Could not create the theme" : { + }, "Could not decode image" : { "localizations" : { @@ -51302,6 +51314,9 @@ } } } + }, + "Could not delete the theme" : { + }, "could not derive the SCRAM key" : { "extractionState" : "manual", @@ -51734,6 +51749,9 @@ } } } + }, + "Could not import the theme" : { + }, "Could not initialize SQL projection" : { "extractionState" : "manual", @@ -52504,6 +52522,9 @@ } } } + }, + "Could not rename the theme" : { + }, "Could Not Render This Image" : { "localizations" : { @@ -52646,6 +52667,9 @@ } } } + }, + "Could not save the theme" : { + }, "Could not search the referenced table" : { "localizations" : { @@ -55611,7 +55635,7 @@ } } }, - "Custom theme" : { + "Custom Themes" : { }, "Custom…" : { @@ -55948,6 +55972,9 @@ } } } + }, + "Dark theme" : { + }, "Dashboard" : { "localizations" : { @@ -56204,6 +56231,7 @@ } }, "Data Grid Font" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -66611,9 +66639,6 @@ } } } - }, - "Duplicate it to change its colors." : { - }, "Duplicate it to customize colors." : { "extractionState" : "stale", @@ -66735,11 +66760,9 @@ } } } - }, - "Duplicate Selected" : { - }, "Duplicate Theme" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -68347,6 +68370,7 @@ } }, "Editor Font" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -78932,9 +78956,6 @@ } } } - }, - "Field Background" : { - }, "Field name" : { "localizations" : { @@ -99464,6 +99485,9 @@ "Light Bulb" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Light theme" : { + }, "Limit" : { "localizations" : { @@ -111689,6 +111713,9 @@ } } } + }, + "New Theme Based On" : { + }, "New Trigger" : { "localizations" : { @@ -113310,6 +113337,9 @@ } } } + }, + "No Custom Themes" : { + }, "No Data" : { "localizations" : { @@ -127569,15 +127599,6 @@ } } } - }, - "Pane Background" : { - - }, - "Pane Text" : { - - }, - "Panels" : { - }, "Panels to read. Omit for all." : { "localizations" : { @@ -133022,6 +133043,9 @@ } } } + }, + "Preview of %@" : { + }, "Preview Query" : { "localizations" : { @@ -144058,6 +144082,9 @@ } } } + }, + "Rename Theme" : { + }, "Rename…" : { "localizations" : { @@ -156057,6 +156084,7 @@ } }, "Secondary Text" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -173334,8 +173362,12 @@ } } } + }, + "Syntax" : { + }, "Syntax Colors" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -175788,6 +175820,9 @@ }, "TablePro does not run a pre-connect script on its own." : { + }, + "TablePro does not use these theme keys: %@." : { + }, "TablePro doesn't recognize the database type “%@”." : { "localizations" : { @@ -177993,6 +178028,7 @@ } }, "Tertiary Text" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -180122,6 +180158,9 @@ } } } + }, + "The chosen theme is not on this Mac, so %@ is used." : { + }, "The client certificate at %@ could not be read as a PEM or DER certificate." : { "extractionState" : "manual", @@ -188259,13 +188298,13 @@ } } }, - "The theme declares %1$d colors TablePro does not use, starting with \"%2$@\"." : { + "The theme identifier \"%1$@\" is already used by %2$@." : { }, - "The theme is missing %1$d required colors, starting with \"%2$@\"." : { + "The theme is missing the required field \"%@\"." : { }, - "The theme is missing the required field \"%@\"." : { + "The theme sets \"%@\" more than once." : { }, "the TLS handshake failed" : { @@ -189047,6 +189086,9 @@ } } } + }, + "Themes color the SQL editor and the data grid. Everything else follows the system appearance." : { + }, "There are no connections to publish." : { "localizations" : { @@ -192280,6 +192322,7 @@ } }, "This is a built-in theme." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -192332,6 +192375,7 @@ } }, "This is a registry theme." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -203524,9 +203568,6 @@ } } } - }, - "Use System Color (%@)" : { - }, "Use text mode to enter JSON." : { diff --git a/TablePro/Resources/Themes/tablepro.dracula.json b/TablePro/Resources/Themes/tablepro.dracula.json index 1148a8f2f9..8b08ec8f2c 100644 --- a/TablePro/Resources/Themes/tablepro.dracula.json +++ b/TablePro/Resources/Themes/tablepro.dracula.json @@ -1,62 +1,47 @@ { "appearance": "dark", "author": "TablePro", - "content": { - "dataGrid": { - "alternateRow": "#21222C", - "background": "#282A36", - "boolFalse": "#FF5555", - "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": { - "background": "#282A36", - "currentLine": "#44475A", - "currentStatement": "#FFFFFF0F", - "cursor": "#F8F8F2", - "invisibles": "#424450", - "lineNumber": "#6272A4", - "selection": "#44475A", - "syntax": { - "comment": "#6272A4", - "function": "#50FA7B", - "keyword": "#FF79C6", - "null": "#FFB86C", - "number": "#BD93F9", - "operator": "#FF79C6", - "string": "#F1FA8C", - "type": "#8BE9FD" - }, - "text": "#F8F8F2" - }, - "panel": { - "background": "#282A36", - "controlBackground": "#343746", - "secondaryText": "#BFC0C9", - "separator": "#44475A", - "tertiaryText": "#6272A4", - "text": "#F8F8F2" + "dataGrid": { + "alternateRow": "#21222C", + "background": "#282A36", + "boolFalse": "#FF5555", + "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": { + "background": "#282A36", + "currentLine": "#44475A", + "currentStatement": "#FFFFFF0F", + "cursor": "#F8F8F2", + "invisibles": "#424450", + "lineNumber": "#6272A4", + "selection": "#44475A", + "syntax": { + "comment": "#6272A4", + "function": "#50FA7B", + "keyword": "#FF79C6", + "null": "#FFB86C", + "number": "#BD93F9", + "operator": "#FF79C6", + "string": "#F1FA8C", + "type": "#8BE9FD" }, - "status": { - "error": "#FF5555", - "success": "#50FA7B", - "warning": "#FFB86C" - } + "text": "#F8F8F2" }, "id": "tablepro.dracula", "name": "Dracula", - "schema": 2 + "schema": 3 } diff --git a/TablePro/Resources/Themes/tablepro.nord.json b/TablePro/Resources/Themes/tablepro.nord.json index 7448128435..7ede3360da 100644 --- a/TablePro/Resources/Themes/tablepro.nord.json +++ b/TablePro/Resources/Themes/tablepro.nord.json @@ -1,62 +1,47 @@ { "appearance": "dark", "author": "TablePro", - "content": { - "dataGrid": { - "alternateRow": "#3B4252", - "background": "#2E3440", - "boolFalse": "#BF616A", - "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": { - "background": "#2E3440", - "currentLine": "#3B4252", - "currentStatement": "#FFFFFF0F", - "cursor": "#88C0D0", - "invisibles": "#3B4252", - "lineNumber": "#4C566A", - "selection": "#434C5E", - "syntax": { - "comment": "#616E88", - "function": "#88C0D0", - "keyword": "#81A1C1", - "null": "#D08770", - "number": "#B48EAD", - "operator": "#81A1C1", - "string": "#A3BE8C", - "type": "#EBCB8B" - }, - "text": "#D8DEE9" - }, - "panel": { - "background": "#2E3440", - "controlBackground": "#3B4252", - "secondaryText": "#9DA5B4", - "separator": "#434C5E", - "tertiaryText": "#616E88", - "text": "#D8DEE9" + "dataGrid": { + "alternateRow": "#3B4252", + "background": "#2E3440", + "boolFalse": "#BF616A", + "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": { + "background": "#2E3440", + "currentLine": "#3B4252", + "currentStatement": "#FFFFFF0F", + "cursor": "#88C0D0", + "invisibles": "#3B4252", + "lineNumber": "#4C566A", + "selection": "#434C5E", + "syntax": { + "comment": "#616E88", + "function": "#88C0D0", + "keyword": "#81A1C1", + "null": "#D08770", + "number": "#B48EAD", + "operator": "#81A1C1", + "string": "#A3BE8C", + "type": "#EBCB8B" }, - "status": { - "error": "#BF616A", - "success": "#A3BE8C", - "warning": "#D08770" - } + "text": "#D8DEE9" }, "id": "tablepro.nord", "name": "Nord", - "schema": 2 + "schema": 3 } diff --git a/TablePro/Theme/BuiltInThemes.swift b/TablePro/Theme/BuiltInThemes.swift index 560c37bd84..39e006fc4c 100644 --- a/TablePro/Theme/BuiltInThemes.swift +++ b/TablePro/Theme/BuiltInThemes.swift @@ -1,9 +1,10 @@ -import Foundation +import AppKit -/// Default Light and Default Dark are Swift, not JSON: they are the fallback every other theme is -/// measured against, so they cannot themselves be a file that fails to load. Their surround slots -/// stay on `system:` values, which keeps the unthemed app pixel-identical to before the theme -/// owned these surfaces and keeps the system's Increase Contrast and vibrancy adaptations. +/// Default Light and Default Dark override nothing but syntax, so every other slot draws in the +/// system color. The syntax values are Xcode's Default (Light) and Default (Dark): SQL `NULL` takes +/// the keyword color the way Swift's `nil` does, and built-in functions and types take Xcode's +/// colors for system-provided functions and types. They are Swift, not files, because every other +/// theme falls back to them and they cannot themselves fail to load. internal enum BuiltInThemes { internal static let defaultLightId = "tablepro.default-light" internal static let defaultDarkId = "tablepro.default-dark" @@ -13,37 +14,13 @@ internal enum BuiltInThemes { name: String(localized: "Default Light"), author: "TablePro", appearance: .light, - editor: EditorThemeColors( - background: .hex("#FFFFFF"), - text: .hex("#000000"), - cursor: .hex("#007AFF"), - selection: .hex("#B4D8FD"), - currentLine: .hex("#007AFF14"), - currentStatement: .hex("#0A0A0A0F"), - lineNumber: .hex("#8E8E93"), - invisibles: .hex("#C7C7CC"), - syntax: SyntaxThemeColors( - keyword: .hex("#0A49A5"), - string: .hex("#C41A16"), - number: .hex("#6C36A9"), - comment: .hex("#007400"), - null: .hex("#C55B00"), - operator: .hex("#000000"), - function: .hex("#326D74"), - type: .hex("#3F6E74") - ) - ), - dataGrid: systemDataGrid( - modified: "#FFD60A4D", - inserted: "#34C7594D", - deleted: "#FF3B304D", - deletedText: "#FF3B3080" - ), - panel: .systemPanel, - status: StatusThemeColors( - success: .hex("#248A3D"), - warning: .hex("#C55B00"), - error: .hex("#D70015") + overrides: syntax( + keyword: "#9B2393", + string: "#C41A16", + number: "#1C00CF", + comment: "#5D6C79", + function: "#6C36A9", + type: "#3900A0" ) ) @@ -52,42 +29,66 @@ internal enum BuiltInThemes { name: String(localized: "Default Dark"), author: "TablePro", appearance: .dark, - editor: EditorThemeColors( - background: .hex("#1E1E1E"), - text: .hex("#D4D4D4"), - cursor: .hex("#007AFF"), - selection: .hex("#264F78"), - currentLine: .hex("#007AFF14"), - currentStatement: .hex("#FFFFFF0F"), - lineNumber: .hex("#858585"), - invisibles: .hex("#4D4D4D"), - syntax: SyntaxThemeColors( - keyword: .hex("#569CD6"), - string: .hex("#CE9178"), - number: .hex("#B5CEA8"), - comment: .hex("#6A9955"), - null: .hex("#FF8C00"), - operator: .hex("#D4D4D4"), - function: .hex("#DCDCAA"), - type: .hex("#4EC9B0") - ) - ), - dataGrid: systemDataGrid( - modified: "#FFD60A4D", - inserted: "#32D74B26", - deleted: "#FF453A26", - deletedText: "#FF453A80" - ), - panel: .systemPanel, - status: StatusThemeColors( - success: .hex("#32D74B"), - warning: .hex("#FF9F0A"), - error: .hex("#FF453A") + overrides: syntax( + keyword: "#FC5FA3", + string: "#FC6A5D", + number: "#D0BF69", + comment: "#6C7986", + function: "#A167E6", + type: "#D0A8FF" ) ) internal static let all: [ThemeDefinition] = [light, dark] + internal static func isDefault(_ id: String) -> Bool { + id == defaultLightId || id == defaultDarkId + } + + /// Xcode's High Contrast (Light) and High Contrast (Dark) syntax values, drawn when the view's + /// appearance is an increased-contrast one. + private static let highContrastLight = syntax( + keyword: "#88007E", + string: "#9B0509", + number: "#1C02CF", + comment: "#1A5301", + function: "#5C2699", + type: "#330090" + ) + + private static let highContrastDark = syntax( + keyword: "#FC6BAA", + string: "#FC7467", + number: "#D0BC56", + comment: "#7CB554", + function: "#C08AFF", + type: "#DEC1FF" + ) + + private static let adaptiveLight = adaptive(standard: light.overrides, highContrast: highContrastLight) + private static let adaptiveDark = adaptive(standard: dark.overrides, highContrast: highContrastDark) + + internal static func adaptiveSyntaxColor(_ slot: ThemeSlot, appearance: ThemeAppearance) -> NSColor? { + switch appearance { + case .light: return adaptiveLight[slot] + case .dark: return adaptiveDark[slot] + } + } + + private static func adaptive( + standard: [ThemeSlot: ThemeColor], + highContrast: [ThemeSlot: ThemeColor] + ) -> [ThemeSlot: NSColor] { + var colors: [ThemeSlot: NSColor] = [:] + for (slot, color) in standard { + let contrast = highContrast[slot] ?? color + colors[slot] = NSColor(name: NSColor.Name("tablepro.syntax.\(slot.rawValue).\(color.hex)")) { appearance in + appearance.isHighContrast ? contrast.nsColor : color.nsColor + } + } + return colors + } + internal static func `default`(for appearance: ThemeAppearance) -> ThemeDefinition { switch appearance { case .light: return light @@ -99,44 +100,23 @@ internal enum BuiltInThemes { `default`(for: appearance).id } - private static func systemDataGrid( - modified: String, - inserted: String, - deleted: String, - deletedText: String - ) -> DataGridThemeColors { - DataGridThemeColors( - background: .system(.controlBackground), - text: .system(.label), - alternateRow: .system(.alternatingContentBackgroundOdd), - headerBackground: .system(.windowBackground), - headerText: .system(.label), - gridLine: .system(.grid), - selection: .system(.selectedContentBackground), - selectionText: .system(.alternateSelectedControlText), - inactiveSelection: .system(.unemphasizedSelectedContentBackground), - focusBorder: .system(.keyboardFocusIndicator), - nullValue: .system(.secondaryLabel), - boolTrue: .system(.label), - boolFalse: .system(.label), - rowNumber: .system(.secondaryLabel), - modified: .hex(modified), - inserted: .hex(inserted), - deleted: .hex(deleted), - deletedText: .hex(deletedText) - ) + private static func syntax( + keyword: String, + string: String, + number: String, + comment: String, + function: String, + type: String + ) -> [ThemeSlot: ThemeColor] { + let values: [ThemeSlot: String] = [ + .syntaxKeyword: keyword, + .syntaxNull: keyword, + .syntaxString: string, + .syntaxNumber: number, + .syntaxComment: comment, + .syntaxFunction: function, + .syntaxType: type, + ] + return values.compactMapValues { try? ThemeColor(validating: $0) } } } - -internal extension PanelThemeColors { - /// The semantic colours these surfaces already named before the theme owned them, so a default - /// theme is invisible against the unthemed app and keeps the system's contrast handling. - static let systemPanel = PanelThemeColors( - background: .system(.controlBackground), - controlBackground: .system(.textBackground), - text: .system(.label), - secondaryText: .system(.secondaryLabel), - tertiaryText: .system(.tertiaryLabel), - separator: .system(.separator) - ) -} diff --git a/TablePro/Theme/ThemeCatalog.swift b/TablePro/Theme/ThemeCatalog.swift index cc01eea962..6dac78568f 100644 --- a/TablePro/Theme/ThemeCatalog.swift +++ b/TablePro/Theme/ThemeCatalog.swift @@ -5,14 +5,24 @@ import os internal struct ThemeCatalogContents: Equatable, Sendable { internal var themes: [ThemeDefinition] internal var rejected: [RejectedThemeRecord] + internal var userFiles: [String: ThemeFile] - internal static let empty = ThemeCatalogContents(themes: BuiltInThemes.all, rejected: []) + internal static let empty = ThemeCatalogContents(themes: BuiltInThemes.all, rejected: [], userFiles: [:]) } -internal struct RejectedThemeRecord: Equatable, Sendable { +/// Where a user theme was read from and the bytes read, so a save or delete can tell that the +/// file changed on disk since and leave the newer version alone. +internal struct ThemeFile: Equatable, Sendable { + internal let url: URL + internal let contents: Data +} + +internal struct RejectedThemeRecord: Equatable, Sendable, Identifiable { internal let path: String internal let fileName: String internal let reason: String + + internal var id: String { path } } internal enum ThemeOrigin: Sendable { @@ -20,9 +30,10 @@ internal enum ThemeOrigin: Sendable { case user } -/// Owns the list. Loading is off the main actor; every mutation updates the in-memory list -/// synchronously before anything activates, because the old storage refreshed through a detached -/// Task and a save was therefore followed by an activation of the stale copy. +/// Owns the list. A user theme is identified by its JSON `id` but lives in whatever file it was +/// loaded from, so a save, rename or delete acts on that file rather than on `.json`; the two +/// differ for any file the user named themselves. Every mutation updates the in-memory list +/// synchronously, so the activation that follows never reads a stale copy. @MainActor internal final class ThemeCatalog: ObservableObject { internal static let shared = ThemeCatalog() @@ -30,6 +41,10 @@ internal final class ThemeCatalog: ObservableObject { @Published internal private(set) var themes: [ThemeDefinition] = BuiltInThemes.all @Published internal private(set) var rejected: [RejectedThemeRecord] = [] + private var userFiles: [String: ThemeFile] = [:] + private let userDirectory: URL + private let bundleDirectory: URL? + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeCatalog") nonisolated private static let bundledOrder = [ @@ -39,10 +54,14 @@ internal final class ThemeCatalog: ObservableObject { "tablepro.nord", ] - /// Loaded before the first activation, not after it. The old catalog refreshed through a - /// detached Task, so the launch activation ran against built-ins alone and a user theme in a - /// slot resolved to the default until something else re-activated it. - private init() { + /// Loaded before the first activation, so a user theme in a slot is in the list the launch + /// activation resolves against. + internal init( + userDirectory: URL = ThemeCatalog.defaultUserDirectory, + bundleDirectory: URL? = Bundle.main.resourceURL + ) { + self.userDirectory = userDirectory + self.bundleDirectory = bundleDirectory reloadSynchronously() } @@ -50,21 +69,28 @@ internal final class ThemeCatalog: ObservableObject { themes.first { $0.id == id } } - internal func reload() async { - let contents = await Task.detached { ThemeCatalog.loadContents() }.value - themes = contents.themes - rejected = contents.rejected + internal func fileURL(for id: String) -> URL? { + userFiles[id]?.url } internal func reloadSynchronously() { - let contents = Self.loadContents() - themes = contents.themes - rejected = contents.rejected + adopt(Self.loadContents(userDirectory: userDirectory, bundleDirectory: bundleDirectory)) } internal func save(_ theme: ThemeDefinition) throws { guard theme.isEditable else { throw ThemeLoadError.reservedIdentifier(theme.id) } - try Self.write(theme, to: Self.userDirectory) + guard ThemeIdentifier.isValid(theme.id) else { throw ThemeLoadError.invalidIdentifier(theme.id) } + + let url: URL + if let file = userFiles[theme.id] { + try ensureUnchanged(file) + url = file.url + } else { + url = availableURL(for: theme.id) + } + let data = try ThemeEncoder.data(for: theme) + try Self.write(data, to: url) + userFiles[theme.id] = ThemeFile(url: url, contents: data) upsert(theme) } @@ -72,20 +98,44 @@ internal final class ThemeCatalog: ObservableObject { guard !id.hasPrefix(ThemeDefinition.builtInPrefix) else { throw ThemeLoadError.reservedIdentifier(id) } - try Self.remove(id: id, from: Self.userDirectory) + if let file = userFiles[id], FileManager.default.fileExists(atPath: file.url.path) { + try ensureUnchanged(file) + try FileManager.default.removeItem(at: file.url) + } + userFiles[id] = nil themes.removeAll { $0.id == id } } + internal func rename(id: String, to name: String) throws { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, var theme = theme(id: id) else { return } + theme.name = trimmed + try save(theme) + } + + internal func duplicate(_ source: ThemeDefinition) throws -> ThemeDefinition { + var copy = source + copy.id = ThemeIdentifier.generated() + copy.name = uniqueName( + String(format: String(localized: "%@ Copy"), source.name) + ) + copy.author = "" + try save(copy) + return copy + } + internal func importTheme(from url: URL) throws -> ThemeDefinition { let document = try ThemeDocument(data: try Data(contentsOf: url)) - var theme = document.resolved() + var theme = document.definition() if !theme.isEditable || containsTheme(id: theme.id) { theme.id = ThemeIdentifier.generated() } + if themes.contains(where: { $0.name == theme.name }) { + theme.name = uniqueName(theme.name) + } - try Self.write(theme, to: Self.userDirectory) - upsert(theme) + try save(theme) return theme } @@ -93,6 +143,47 @@ internal final class ThemeCatalog: ObservableObject { try ThemeEncoder.data(for: theme).write(to: url, options: .atomic) } + /// "Name", then "Name 2", "Name 3", the way Finder numbers copies. + internal func uniqueName(_ base: String) -> String { + let taken = Set(themes.map(\.name)) + guard taken.contains(base) else { return base } + var index = 2 + while taken.contains("\(base) \(index)") { + index += 1 + } + return "\(base) \(index)" + } + + /// `.json`, unless that name already holds another theme or a file that failed to load, + /// which a save must never overwrite. + private func availableURL(for id: String) -> URL { + let taken = Set( + userFiles.values.map(\.url.standardizedFileURL) + + rejected.map { URL(fileURLWithPath: $0.path).standardizedFileURL } + ) + var candidate = userDirectory.appendingPathComponent("\(id).json", isDirectory: false) + var index = 2 + while taken.contains(candidate.standardizedFileURL) || FileManager.default.fileExists(atPath: candidate.path) { + candidate = userDirectory.appendingPathComponent("\(id)-\(index).json", isDirectory: false) + index += 1 + } + return candidate + } + + /// A file edited or replaced outside TablePro since it was read is not overwritten or removed: + /// the catalog reloads it and the caller reports the conflict. + private func ensureUnchanged(_ file: ThemeFile) throws { + guard let current = try? Data(contentsOf: file.url), current != file.contents else { return } + reloadSynchronously() + throw ThemeLoadError.changedOnDisk(file.url.lastPathComponent) + } + + private func adopt(_ contents: ThemeCatalogContents) { + themes = contents.themes + rejected = contents.rejected + userFiles = contents.userFiles + } + private func containsTheme(id: String) -> Bool { themes.contains { $0.id == id } } @@ -103,81 +194,61 @@ internal final class ThemeCatalog: ObservableObject { return } themes.append(theme) + themes.sort(by: Self.displayOrder) } // MARK: - Locations - nonisolated private static let userDirectory: URL = + nonisolated internal static let defaultUserDirectory: URL = AppStorageEnvironment.shared.applicationSupportRoot .appendingPathComponent("TablePro/Themes", isDirectory: true) + internal var themesDirectory: URL { userDirectory } + // MARK: - Disk - nonisolated private static func loadContents() -> ThemeCatalogContents { + nonisolated private static func displayOrder(_ lhs: ThemeDefinition, _ rhs: ThemeDefinition) -> Bool { + let left = bundledOrder.firstIndex(of: lhs.id) ?? Int.max + let right = bundledOrder.firstIndex(of: rhs.id) ?? Int.max + guard left == right else { return left < right } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + + nonisolated private static func loadContents(userDirectory: URL, bundleDirectory: URL?) -> ThemeCatalogContents { var themes = BuiltInThemes.all var rejected: [RejectedThemeRecord] = [] + var userFiles: [String: ThemeFile] = [:] ensureDirectory(userDirectory) - let sources: [(URL, ThemeOrigin)] = [ - (Bundle.main.resourceURL, .bundle), - (userDirectory, .user), - ].compactMap { url, source in url.map { ($0, source) } } - - for (directory, source) in sources { - let loaded = load(from: directory, source: source) - rejected.append(contentsOf: loaded.rejected) - - for theme in loaded.themes { - if let index = themes.firstIndex(where: { $0.id == theme.id }) { - themes[index] = theme - continue + if let bundle = bundleDirectory { + for file in jsonFiles(in: bundle) where file.lastPathComponent.hasPrefix(ThemeDefinition.builtInPrefix) { + do { + let document = try ThemeDocument(data: try Data(contentsOf: file)) + guard !themes.contains(where: { $0.id == document.id }) else { continue } + themes.append(document.definition()) + } catch { + logger.error("Bundled theme \(file.lastPathComponent) rejected: \(error.publicLogShape, privacy: .public)") } - themes.append(theme) } } - themes.sort { lhs, rhs in - let left = bundledOrder.firstIndex(of: lhs.id) ?? Int.max - let right = bundledOrder.firstIndex(of: rhs.id) ?? Int.max - guard left == right else { return left < right } - return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending - } - - return ThemeCatalogContents(themes: themes, rejected: rejected) - } - - nonisolated private static func load( - from directory: URL, - source: ThemeOrigin - ) -> (themes: [ThemeDefinition], rejected: [RejectedThemeRecord]) { - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: directory.path) else { return ([], []) } - - let files: [URL] - do { - files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" } - } catch { - logger.error("Failed to list \(directory.lastPathComponent): \(error)") - return ([], []) - } - - var themes: [ThemeDefinition] = [] - var rejected: [RejectedThemeRecord] = [] + let files = jsonFiles(in: userDirectory) + .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } for file in files { - if source == .bundle, !file.lastPathComponent.hasPrefix(ThemeDefinition.builtInPrefix) { continue } - do { - let document = try ThemeDocument(data: try Data(contentsOf: file)) - try validate(identifier: document.id, from: source) - themes.append(document.resolved()) - } catch { - guard source != .bundle else { - logger.error("Bundled theme \(file.lastPathComponent) rejected: \(error.publicLogShape, privacy: .public)") - continue + let data = try Data(contentsOf: file) + let document = try ThemeDocument(data: data) + guard !document.id.hasPrefix(ThemeDefinition.builtInPrefix) else { + throw ThemeLoadError.reservedIdentifier(document.id) } + if let existing = userFiles[document.id] { + throw ThemeLoadError.duplicateIdentifier(document.id, existingFile: existing.url.lastPathComponent) + } + userFiles[document.id] = ThemeFile(url: file, contents: data) + themes.append(document.definition()) + } catch { rejected.append( RejectedThemeRecord( path: file.path, @@ -188,32 +259,25 @@ internal final class ThemeCatalog: ObservableObject { } } - return (themes, rejected) + themes.sort(by: displayOrder) + return ThemeCatalogContents(themes: themes, rejected: rejected, userFiles: userFiles) } - /// A user file claiming `tablepro.` would shadow the built-in theme, read as read-only and be - /// impossible to delete. - nonisolated private static func validate(identifier: String, from source: ThemeOrigin) throws { - guard ThemeIdentifier.isValid(identifier) else { - throw ThemeLoadError.invalidIdentifier(identifier) - } - guard source == .bundle || !identifier.hasPrefix(ThemeDefinition.builtInPrefix) else { - throw ThemeLoadError.reservedIdentifier(identifier) + nonisolated private static func jsonFiles(in directory: URL) -> [URL] { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: directory.path) else { return [] } + do { + return try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension.lowercased() == "json" } + } catch { + logger.error("Failed to list \(directory.lastPathComponent): \(error)") + return [] } } - nonisolated private static func write(_ theme: ThemeDefinition, to directory: URL) throws { - ensureDirectory(directory) - guard ThemeIdentifier.isValid(theme.id) else { throw ThemeLoadError.invalidIdentifier(theme.id) } - let url = directory.appendingPathComponent("\(theme.id).json", isDirectory: false) - try ThemeEncoder.data(for: theme).write(to: url, options: .atomic) - } - - nonisolated private static func remove(id: String, from directory: URL) throws { - guard ThemeIdentifier.isValid(id) else { throw ThemeLoadError.invalidIdentifier(id) } - let url = directory.appendingPathComponent("\(id).json", isDirectory: false) - guard FileManager.default.fileExists(atPath: url.path) else { return } - try FileManager.default.removeItem(at: url) + nonisolated private static func write(_ data: Data, to url: URL) throws { + ensureDirectory(url.deletingLastPathComponent()) + try data.write(to: url, options: .atomic) } nonisolated private static func ensureDirectory(_ url: URL) { diff --git a/TablePro/Theme/ThemeColor.swift b/TablePro/Theme/ThemeColor.swift new file mode 100644 index 0000000000..2ee79d9d81 --- /dev/null +++ b/TablePro/Theme/ThemeColor.swift @@ -0,0 +1,183 @@ +import AppKit +import SwiftUI + +/// One color a theme declares, held as canonical `#RRGGBB` or `#RRGGBBAA` in sRGB. It is a fixed +/// value by design: a theme that wants the system color leaves the slot out. +internal struct ThemeColor: Hashable, Sendable { + internal let hex: String + internal let nsColor: NSColor + + internal init(validating raw: String) throws { + guard let canonical = HexColor.canonicalize(raw) else { + throw ThemeLoadError.invalidColor(raw) + } + hex = canonical + nsColor = HexColor.color(canonical) + } + + internal init(_ color: NSColor) { + hex = HexColor.string(from: color) + nsColor = HexColor.color(hex) + } + + internal static func == (lhs: ThemeColor, rhs: ThemeColor) -> Bool { + lhs.hex == rhs.hex + } + + internal func hash(into hasher: inout Hasher) { + hasher.combine(hex) + } + + internal var swiftUIColor: Color { + Color(nsColor: nsColor) + } +} + +/// The parser rejects any string it does not consume whole, and only ASCII hex digits: +/// `Character.isHexDigit` is also true for the fullwidth forms, which `Scanner` then refuses. +internal enum HexColor { + private static let digits = Set("0123456789ABCDEFabcdef") + + internal static func canonicalize(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("#") else { return nil } + + let body = trimmed.dropFirst() + guard body.count == 6 || body.count == 8 else { return nil } + guard body.allSatisfy({ digits.contains($0) }) else { return nil } + + return "#" + body.uppercased() + } + + internal static func color(_ canonical: String) -> NSColor { + let body = canonical.dropFirst() + var value: UInt64 = 0 + guard Scanner(string: String(body)).scanHexInt64(&value) else { return .labelColor } + + if body.count == 8 { + return NSColor( + srgbRed: CGFloat((value >> 24) & 0xFF) / 255, + green: CGFloat((value >> 16) & 0xFF) / 255, + blue: CGFloat((value >> 8) & 0xFF) / 255, + alpha: CGFloat(value & 0xFF) / 255 + ) + } + + return NSColor( + srgbRed: CGFloat((value >> 16) & 0xFF) / 255, + green: CGFloat((value >> 8) & 0xFF) / 255, + blue: CGFloat(value & 0xFF) / 255, + alpha: 1 + ) + } + + internal static func string(from color: NSColor) -> String { + guard let converted = color.usingColorSpace(.sRGB) else { return "#808080" } + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + converted.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + let redValue = Int(round(red * 255)) + let greenValue = Int(round(green * 255)) + let blueValue = Int(round(blue * 255)) + + guard alpha < 1 else { + return String(format: "#%02X%02X%02X", redValue, greenValue, blueValue) + } + return String(format: "#%02X%02X%02X%02X", redValue, greenValue, blueValue, Int(round(alpha * 255))) + } +} + +internal extension ThemeSlot { + /// What the slot draws when no theme overrides it: the color AppKit itself uses on that + /// surface. Every value is dynamic, so it resolves against the drawing view's own appearance, + /// accent, highlight and contrast. A tint is built inside a provider, because + /// `withAlphaComponent` on a system color outside one freezes it at the current appearance. + var nativeColor: NSColor { + switch self { + case .editorBackground: return .textBackgroundColor + case .editorText, .syntaxOperator: return .textColor + case .editorCursor: return NativeThemeColors.insertionPoint + case .editorSelection: return .selectedTextBackgroundColor + case .editorCurrentLine: return NativeThemeColors.currentLine + case .editorCurrentStatement: return .quinaryLabel + case .editorLineNumber: return .secondaryLabelColor + case .editorInvisibles: return .tertiaryLabelColor + + case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, + .syntaxNull, .syntaxFunction, .syntaxType: + return .textColor + + 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 + case .gridInserted: return NativeThemeColors.insertedRow + case .gridDeleted: return NativeThemeColors.deletedRow + case .gridDeletedText: return NativeThemeColors.deletedText + } + } +} + +internal enum NativeThemeColors { + /// macOS 14 draws the caret in the Highlight color through `textInsertionPointColor`. Earlier + /// releases draw it in the text color. + static let insertionPoint: NSColor = { + if #available(macOS 14.0, *) { + return .textInsertionPointColor + } + return .textColor + }() + + /// No system color exists for a current-line band; a faint accent keeps it on the user's + /// Accent color the way the selection and focus ring are. + static let currentLine = NSColor(name: NSColor.Name("tablepro.native.currentLine")) { appearance in + NSColor.controlAccentColor.withAlphaComponent(appearance.isDarkTheme ? 0.14 : 0.08) + } + + static let alternateRow = NSColor(name: NSColor.Name("tablepro.native.alternateRow")) { _ in + let colors = NSColor.alternatingContentBackgroundColors + return colors.count > 1 ? colors[1] : .controlBackgroundColor + } + + static let modifiedRow = NSColor(name: NSColor.Name("tablepro.native.modifiedRow")) { _ in + NSColor.systemYellow.withAlphaComponent(0.3) + } + + static let insertedRow = NSColor(name: NSColor.Name("tablepro.native.insertedRow")) { appearance in + NSColor.systemGreen.withAlphaComponent(appearance.isDarkTheme ? 0.15 : 0.3) + } + + static let deletedRow = NSColor(name: NSColor.Name("tablepro.native.deletedRow")) { appearance in + NSColor.systemRed.withAlphaComponent(appearance.isDarkTheme ? 0.15 : 0.3) + } + + static let deletedText = NSColor(name: NSColor.Name("tablepro.native.deletedText")) { _ in + NSColor.systemRed.withAlphaComponent(0.5) + } +} + +internal extension NSAppearance { + var isDarkTheme: Bool { + bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + } + + var isHighContrast: Bool { + let match = bestMatch(from: [ + .aqua, .darkAqua, .accessibilityHighContrastAqua, .accessibilityHighContrastDarkAqua, + ]) + return match == .accessibilityHighContrastAqua || match == .accessibilityHighContrastDarkAqua + } +} diff --git a/TablePro/Theme/ThemeColorValue.swift b/TablePro/Theme/ThemeColorValue.swift deleted file mode 100644 index 2a446ea2f6..0000000000 --- a/TablePro/Theme/ThemeColorValue.swift +++ /dev/null @@ -1,194 +0,0 @@ -import AppKit -import SwiftUI - -internal enum SystemColorName: String, Codable, CaseIterable, Sendable { - case label - case secondaryLabel - case tertiaryLabel - case quaternaryLabel - case text - case textBackground - case selectedText - case selectedTextBackground - case placeholderText - case disabledControlText - case controlBackground - case windowBackground - case underPageBackground - case separator - case grid - case selectedContentBackground - case unemphasizedSelectedContentBackground - case alternateSelectedControlText - case controlAccent - case keyboardFocusIndicator - case alternatingContentBackgroundEven - case alternatingContentBackgroundOdd - case systemRed - case systemGreen - case systemBlue - case systemOrange - case systemYellow - case systemPurple - case systemTeal - case systemGray - - internal var color: NSColor { - switch self { - case .label: return .labelColor - case .secondaryLabel: return .secondaryLabelColor - case .tertiaryLabel: return .tertiaryLabelColor - case .quaternaryLabel: return .quaternaryLabelColor - case .text: return .textColor - case .textBackground: return .textBackgroundColor - case .selectedText: return .selectedTextColor - case .selectedTextBackground: return .selectedTextBackgroundColor - case .placeholderText: return .placeholderTextColor - case .disabledControlText: return .disabledControlTextColor - case .controlBackground: return .controlBackgroundColor - case .windowBackground: return .windowBackgroundColor - case .underPageBackground: return .underPageBackgroundColor - case .separator: return .separatorColor - case .grid: return .gridColor - case .selectedContentBackground: return .selectedContentBackgroundColor - case .unemphasizedSelectedContentBackground: return .unemphasizedSelectedContentBackgroundColor - case .alternateSelectedControlText: return .alternateSelectedControlTextColor - case .controlAccent: return .controlAccentColor - case .keyboardFocusIndicator: return .keyboardFocusIndicatorColor - case .alternatingContentBackgroundEven: return Self.alternatingContentBackground(at: 0) - case .alternatingContentBackgroundOdd: return Self.alternatingContentBackground(at: 1) - case .systemRed: return .systemRed - case .systemGreen: return .systemGreen - case .systemBlue: return .systemBlue - case .systemOrange: return .systemOrange - case .systemYellow: return .systemYellow - case .systemPurple: return .systemPurple - case .systemTeal: return .systemTeal - case .systemGray: return .systemGray - } - } - - private static func alternatingContentBackground(at index: Int) -> NSColor { - let colors = NSColor.alternatingContentBackgroundColors - guard colors.indices.contains(index) else { return .controlBackgroundColor } - return colors[index] - } -} - -/// A theme slot's value. `system` keeps a slot on the AppKit semantic colour its call site used -/// before the theme owned it, so the built-in themes stay pixel-identical to the unthemed app and -/// keep the system's own Increase Contrast and vibrancy adaptations. A custom theme uses `hex`. -internal enum ThemeColorValue: Equatable, Hashable, Sendable { - case hex(String) - case system(SystemColorName) - - internal static let systemPrefix = "system:" - - internal init(validating raw: String) throws { - if raw.hasPrefix(Self.systemPrefix) { - let name = String(raw.dropFirst(Self.systemPrefix.count)) - guard let known = SystemColorName(rawValue: name) else { - throw ThemeLoadError.unknownSystemColor(name) - } - self = .system(known) - return - } - - guard let canonical = HexColor.canonicalize(raw) else { - throw ThemeLoadError.invalidColor(raw) - } - self = .hex(canonical) - } - - internal var rawValue: String { - switch self { - case let .hex(value): return value - case let .system(name): return Self.systemPrefix + name.rawValue - } - } - - internal var nsColor: NSColor { - switch self { - case let .hex(value): return HexColor.color(value) - case let .system(name): return name.color - } - } - - internal var swiftUIColor: Color { - Color(nsColor: nsColor) - } - - internal var isSystem: Bool { - if case .system = self { return true } - return false - } -} - -extension ThemeColorValue: Codable { - internal init(from decoder: Decoder) throws { - let raw = try decoder.singleValueContainer().decode(String.self) - try self.init(validating: raw) - } - - internal func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(rawValue) - } -} - -/// The parser rejects any string it does not consume whole. The previous one asked only whether -/// `Scanner.scanHexInt64` succeeded, which it does on a valid prefix, so `#FF79CG` silently -/// rendered as `#0FF79C` instead of reporting a typo. -internal enum HexColor { - internal static func canonicalize(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("#") else { return nil } - - let digits = trimmed.dropFirst() - guard digits.count == 6 || digits.count == 8 else { return nil } - guard digits.allSatisfy(\.isHexDigit) else { return nil } - - return "#" + digits.uppercased() - } - - internal static func color(_ canonical: String) -> NSColor { - let digits = canonical.dropFirst() - var value: UInt64 = 0 - guard Scanner(string: String(digits)).scanHexInt64(&value) else { return .labelColor } - - if digits.count == 8 { - return NSColor( - srgbRed: CGFloat((value >> 24) & 0xFF) / 255, - green: CGFloat((value >> 16) & 0xFF) / 255, - blue: CGFloat((value >> 8) & 0xFF) / 255, - alpha: CGFloat(value & 0xFF) / 255 - ) - } - - return NSColor( - srgbRed: CGFloat((value >> 16) & 0xFF) / 255, - green: CGFloat((value >> 8) & 0xFF) / 255, - blue: CGFloat(value & 0xFF) / 255, - alpha: 1 - ) - } - - internal static func string(from color: NSColor) -> String { - guard let converted = color.usingColorSpace(.sRGB) else { return "#808080" } - - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - converted.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - - let redValue = Int(round(red * 255)) - let greenValue = Int(round(green * 255)) - let blueValue = Int(round(blue * 255)) - - guard alpha < 1 else { - return String(format: "#%02X%02X%02X", redValue, greenValue, blueValue) - } - return String(format: "#%02X%02X%02X%02X", redValue, greenValue, blueValue, Int(round(alpha * 255))) - } -} diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift index 8b90770250..4b39fda49f 100644 --- a/TablePro/Theme/ThemeDefinition.swift +++ b/TablePro/Theme/ThemeDefinition.swift @@ -1,141 +1,89 @@ -import Foundation +import AppKit internal enum ThemeAppearance: String, Codable, CaseIterable, Sendable { case light case dark } -internal struct SyntaxThemeColors: Equatable, Sendable { - var keyword: ThemeColorValue - var string: ThemeColorValue - var number: ThemeColorValue - var comment: ThemeColorValue - var null: ThemeColorValue - var `operator`: ThemeColorValue - var function: ThemeColorValue - var type: ThemeColorValue -} - -internal struct EditorThemeColors: Equatable, Sendable { - var background: ThemeColorValue - var text: ThemeColorValue - var cursor: ThemeColorValue - var selection: ThemeColorValue - var currentLine: ThemeColorValue - var currentStatement: ThemeColorValue - var lineNumber: ThemeColorValue - var invisibles: ThemeColorValue - var syntax: SyntaxThemeColors -} - -internal struct DataGridThemeColors: Equatable, Sendable { - var background: ThemeColorValue - var text: ThemeColorValue - var alternateRow: ThemeColorValue - var headerBackground: ThemeColorValue - var headerText: ThemeColorValue - var gridLine: ThemeColorValue - var selection: ThemeColorValue - var selectionText: ThemeColorValue - var inactiveSelection: ThemeColorValue - var focusBorder: ThemeColorValue - var nullValue: ThemeColorValue - var boolTrue: ThemeColorValue - var boolFalse: ThemeColorValue - var rowNumber: ThemeColorValue - var modified: ThemeColorValue - var inserted: ThemeColorValue - var deleted: ThemeColorValue - var deletedText: ThemeColorValue -} - -internal struct StatusThemeColors: Equatable, Sendable { - var success: ThemeColorValue - var warning: ThemeColorValue - var error: ThemeColorValue -} - -internal struct PanelThemeColors: Equatable, Sendable { - var background: ThemeColorValue - var controlBackground: ThemeColorValue - var text: ThemeColorValue - var secondaryText: ThemeColorValue - var tertiaryText: ThemeColorValue - var separator: ThemeColorValue -} - +/// A theme is a set of overrides for the two surfaces it may color, the SQL editor and the data +/// grid. A slot it leaves out draws in the color AppKit itself uses there, so Default Light and +/// Default Dark declare nothing but syntax colors and render exactly like a native text view and +/// table, following Accent, Highlight and Increase Contrast. internal struct ThemeDefinition: Identifiable, Equatable, Sendable { var id: String var name: String var author: String var appearance: ThemeAppearance - var editor: EditorThemeColors - var dataGrid: DataGridThemeColors - var panel: PanelThemeColors - var status: StatusThemeColors + var overrides: [ThemeSlot: ThemeColor] internal static let builtInPrefix = "tablepro." internal static let userPrefix = "user." internal var isBuiltIn: Bool { id.hasPrefix(Self.builtInPrefix) } internal var isEditable: Bool { !isBuiltIn } + + internal func isOverridden(_ slot: ThemeSlot) -> Bool { + overrides[slot] != nil + } + + /// Syntax has no system color, so a syntax slot the theme leaves out takes the same-appearance + /// default's value, which switches to Xcode's high-contrast palette under Increase Contrast. + /// Operator falls back to the editor text, overridden or not. + internal func color(for slot: ThemeSlot) -> NSColor { + if slot.group == .syntax, BuiltInThemes.isDefault(id) || overrides[slot] == nil, + let adaptive = BuiltInThemes.adaptiveSyntaxColor(slot, appearance: appearance) { + return adaptive + } + if let override = overrides[slot] { + return override.nsColor + } + if slot == .syntaxOperator { + return color(for: .editorText) + } + return slot.nativeColor + } } /// The single registry every slot goes through. A slot exists here because a call site reads it, -/// which is what `ThemeSlotCoverageTests` enforces: the previous schema let whole groups -/// ship with no reader at all, and nothing caught it. The gate, the theme editor and the document -/// decoder all enumerate this list rather than keeping their own copies. +/// which `ThemeSlotCoverageTests` enforces. The document decoder, the encoder and the theme editor +/// all enumerate this list rather than keeping their own copies. internal enum ThemeSlot: String, CaseIterable, Sendable { - case editorBackground = "content.editor.background" - case editorText = "content.editor.text" - case editorCursor = "content.editor.cursor" - case editorSelection = "content.editor.selection" - case editorCurrentLine = "content.editor.currentLine" - case editorCurrentStatement = "content.editor.currentStatement" - case editorLineNumber = "content.editor.lineNumber" - case editorInvisibles = "content.editor.invisibles" - - case syntaxKeyword = "content.editor.syntax.keyword" - case syntaxString = "content.editor.syntax.string" - case syntaxNumber = "content.editor.syntax.number" - case syntaxComment = "content.editor.syntax.comment" - case syntaxNull = "content.editor.syntax.null" - case syntaxOperator = "content.editor.syntax.operator" - case syntaxFunction = "content.editor.syntax.function" - case syntaxType = "content.editor.syntax.type" - - case gridBackground = "content.dataGrid.background" - case gridText = "content.dataGrid.text" - case gridAlternateRow = "content.dataGrid.alternateRow" - case gridHeaderBackground = "content.dataGrid.headerBackground" - case gridHeaderText = "content.dataGrid.headerText" - case gridLine = "content.dataGrid.gridLine" - case gridSelection = "content.dataGrid.selection" - case gridSelectionText = "content.dataGrid.selectionText" - case gridInactiveSelection = "content.dataGrid.inactiveSelection" - case gridFocusBorder = "content.dataGrid.focusBorder" - case gridNullValue = "content.dataGrid.nullValue" - case gridBoolTrue = "content.dataGrid.boolTrue" - case gridBoolFalse = "content.dataGrid.boolFalse" - case gridRowNumber = "content.dataGrid.rowNumber" - case gridModified = "content.dataGrid.modified" - case gridInserted = "content.dataGrid.inserted" - case gridDeleted = "content.dataGrid.deleted" - case gridDeletedText = "content.dataGrid.deletedText" - - case panelBackground = "content.panel.background" - case panelControlBackground = "content.panel.controlBackground" - case panelText = "content.panel.text" - case panelSecondaryText = "content.panel.secondaryText" - case panelTertiaryText = "content.panel.tertiaryText" - case panelSeparator = "content.panel.separator" - - case statusSuccess = "content.status.success" - case statusWarning = "content.status.warning" - case statusError = "content.status.error" - - - internal var since: Int { 2 } + case editorBackground = "editor.background" + case editorText = "editor.text" + case editorCursor = "editor.cursor" + case editorSelection = "editor.selection" + case editorCurrentLine = "editor.currentLine" + case editorCurrentStatement = "editor.currentStatement" + case editorLineNumber = "editor.lineNumber" + case editorInvisibles = "editor.invisibles" + + case syntaxKeyword = "editor.syntax.keyword" + case syntaxString = "editor.syntax.string" + case syntaxNumber = "editor.syntax.number" + case syntaxComment = "editor.syntax.comment" + case syntaxNull = "editor.syntax.null" + case syntaxOperator = "editor.syntax.operator" + case syntaxFunction = "editor.syntax.function" + case syntaxType = "editor.syntax.type" + + case gridBackground = "dataGrid.background" + case gridText = "dataGrid.text" + case gridAlternateRow = "dataGrid.alternateRow" + case gridHeaderBackground = "dataGrid.headerBackground" + case gridHeaderText = "dataGrid.headerText" + case gridLine = "dataGrid.gridLine" + case gridSelection = "dataGrid.selection" + case gridSelectionText = "dataGrid.selectionText" + case gridInactiveSelection = "dataGrid.inactiveSelection" + case gridFocusBorder = "dataGrid.focusBorder" + case gridNullValue = "dataGrid.nullValue" + case gridBoolTrue = "dataGrid.boolTrue" + case gridBoolFalse = "dataGrid.boolFalse" + case gridRowNumber = "dataGrid.rowNumber" + case gridModified = "dataGrid.modified" + case gridInserted = "dataGrid.inserted" + case gridDeleted = "dataGrid.deleted" + case gridDeletedText = "dataGrid.deletedText" internal var group: ThemeSlotGroup { switch self { @@ -150,63 +98,6 @@ internal enum ThemeSlot: String, CaseIterable, Sendable { .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return .dataGrid - case .panelBackground, .panelControlBackground, .panelText, - .panelSecondaryText, .panelTertiaryText, .panelSeparator: - return .panel - case .statusSuccess, .statusWarning, .statusError: - return .status - } - } - - internal var keyPath: WritableKeyPath { - switch self { - case .editorBackground: return \.editor.background - case .editorText: return \.editor.text - case .editorCursor: return \.editor.cursor - case .editorSelection: return \.editor.selection - case .editorCurrentLine: return \.editor.currentLine - case .editorCurrentStatement: return \.editor.currentStatement - case .editorLineNumber: return \.editor.lineNumber - case .editorInvisibles: return \.editor.invisibles - - case .syntaxKeyword: return \.editor.syntax.keyword - case .syntaxString: return \.editor.syntax.string - case .syntaxNumber: return \.editor.syntax.number - case .syntaxComment: return \.editor.syntax.comment - case .syntaxNull: return \.editor.syntax.null - case .syntaxOperator: return \.editor.syntax.operator - case .syntaxFunction: return \.editor.syntax.function - case .syntaxType: return \.editor.syntax.type - - case .gridBackground: return \.dataGrid.background - case .gridText: return \.dataGrid.text - case .gridAlternateRow: return \.dataGrid.alternateRow - case .gridHeaderBackground: return \.dataGrid.headerBackground - case .gridHeaderText: return \.dataGrid.headerText - case .gridLine: return \.dataGrid.gridLine - case .gridSelection: return \.dataGrid.selection - case .gridSelectionText: return \.dataGrid.selectionText - case .gridInactiveSelection: return \.dataGrid.inactiveSelection - case .gridFocusBorder: return \.dataGrid.focusBorder - case .gridNullValue: return \.dataGrid.nullValue - case .gridBoolTrue: return \.dataGrid.boolTrue - case .gridBoolFalse: return \.dataGrid.boolFalse - case .gridRowNumber: return \.dataGrid.rowNumber - case .gridModified: return \.dataGrid.modified - case .gridInserted: return \.dataGrid.inserted - case .gridDeleted: return \.dataGrid.deleted - case .gridDeletedText: return \.dataGrid.deletedText - - case .panelBackground: return \.panel.background - case .panelControlBackground: return \.panel.controlBackground - case .panelText: return \.panel.text - case .panelSecondaryText: return \.panel.secondaryText - case .panelTertiaryText: return \.panel.tertiaryText - case .panelSeparator: return \.panel.separator - - case .statusSuccess: return \.status.success - case .statusWarning: return \.status.warning - case .statusError: return \.status.error } } @@ -243,15 +134,6 @@ internal enum ThemeSlot: String, CaseIterable, Sendable { case .gridInserted: return String(localized: "Inserted") case .gridDeleted: return String(localized: "Deleted") case .gridDeletedText: return String(localized: "Deleted Text") - case .statusSuccess: return String(localized: "Success") - case .statusWarning: return String(localized: "Warning") - case .statusError: return String(localized: "Error") - case .panelBackground: return String(localized: "Pane Background") - case .panelControlBackground: return String(localized: "Field Background") - case .panelText: return String(localized: "Pane Text") - case .panelSecondaryText: return String(localized: "Secondary Text") - case .panelTertiaryText: return String(localized: "Tertiary Text") - case .panelSeparator: return String(localized: "Separator") } } } @@ -260,16 +142,12 @@ internal enum ThemeSlotGroup: String, CaseIterable, Sendable { case editor case syntax case dataGrid - case panel - case status internal var label: String { switch self { case .editor: return String(localized: "Editor") - case .syntax: return String(localized: "Syntax Colors") + case .syntax: return String(localized: "Syntax") case .dataGrid: return String(localized: "Data Grid") - case .panel: return String(localized: "Panels") - case .status: return String(localized: "Status") } } @@ -279,6 +157,8 @@ internal enum ThemeSlotGroup: String, CaseIterable, Sendable { } internal enum ThemeSchema { - internal static let current = 2 - internal static let oldestSupported = 2 + /// Format 3 dropped the pane and status groups, made every color optional and removed + /// `system:` values. Format 2 never shipped in a release, and format 1 files carried fonts the + /// app now keeps in settings. + internal static let current = 3 } diff --git a/TablePro/Theme/ThemeDocument.swift b/TablePro/Theme/ThemeDocument.swift index 4debffd57a..ddd939fe82 100644 --- a/TablePro/Theme/ThemeDocument.swift +++ b/TablePro/Theme/ThemeDocument.swift @@ -4,6 +4,7 @@ internal indirect enum ThemeJSONNode: Decodable { case string(String) case number(Int) case object([String: ThemeJSONNode]) + case other internal init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() @@ -16,7 +17,11 @@ internal indirect enum ThemeJSONNode: Decodable { self = .number(value) return } - self = .object(try container.decode([String: ThemeJSONNode].self)) + if let value = try? container.decode([String: ThemeJSONNode].self) { + self = .object(value) + return + } + self = .other } internal var stringValue: String? { @@ -29,33 +34,32 @@ internal indirect enum ThemeJSONNode: Decodable { return nil } - internal func flattened(prefix: String = "") -> [String: String] { + /// Every leaf with its dotted path. A leaf that is not a string is kept as `nil`, so the + /// decoder can report it instead of dropping it. + internal func leaves(prefix: String = "") -> [(path: String, value: String?)] { switch self { case let .string(value): - return prefix.isEmpty ? [:] : [prefix: value] - case .number: - return [:] + return [(prefix, value)] + case .number, .other: + return [(prefix, nil)] case let .object(children): - var result: [String: String] = [:] - for (key, child) in children { - let path = prefix.isEmpty ? key : "\(prefix).\(key)" - result.merge(child.flattened(prefix: path)) { _, new in new } + return children.flatMap { key, child in + child.leaves(prefix: prefix.isEmpty ? key : "\(prefix).\(key)") } - return result } } } -/// The theme file format, and the only place a file can be rejected. The previous decoder read -/// every key with `decodeIfPresent` and a Default Light fallback, so an empty object and a VS Code -/// theme both decoded "successfully" and rendered as Default Light under the file's own name. +/// The theme file format, and the only place a file can be rejected. Every color is optional: a +/// slot the file leaves out draws in the system color, or for syntax in the same-appearance +/// default's color. What is refused is anything that would otherwise load as something the author +/// did not write: an unknown key, an unparseable color, missing metadata, another format. internal struct ThemeDocument { - internal let schema: Int internal let id: String internal let name: String internal let author: String internal let appearance: ThemeAppearance - internal let colors: [ThemeSlot: ThemeColorValue] + internal let colors: [ThemeSlot: ThemeColor] private enum MetadataKey: String, CaseIterable { case schema @@ -69,15 +73,17 @@ internal struct ThemeDocument { let root = try JSONDecoder().decode([String: ThemeJSONNode].self, from: data) guard let schema = root[MetadataKey.schema.rawValue]?.intValue else { + if let legacy = root["version"]?.intValue { + throw ThemeLoadError.schemaTooOld(found: legacy, supported: ThemeSchema.current) + } throw ThemeLoadError.missingSchema } - guard schema >= ThemeSchema.oldestSupported else { + guard schema >= ThemeSchema.current else { throw ThemeLoadError.schemaTooOld(found: schema, supported: ThemeSchema.current) } guard schema <= ThemeSchema.current else { throw ThemeLoadError.schemaTooNew(found: schema, supported: ThemeSchema.current) } - self.schema = schema guard let id = root[MetadataKey.id.rawValue]?.stringValue, !id.isEmpty else { throw ThemeLoadError.missingField(MetadataKey.id.rawValue) @@ -87,7 +93,9 @@ internal struct ThemeDocument { } self.id = id - guard let name = root[MetadataKey.name.rawValue]?.stringValue, !name.isEmpty else { + guard let name = root[MetadataKey.name.rawValue]?.stringValue, + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { throw ThemeLoadError.missingField(MetadataKey.name.rawValue) } self.name = name @@ -102,53 +110,37 @@ internal struct ThemeDocument { author = root[MetadataKey.author.rawValue]?.stringValue ?? "" - var flat: [String: String] = [:] - for (key, node) in root where MetadataKey(rawValue: key) == nil { - flat.merge(node.flattened(prefix: key)) { _, new in new } - } + let leaves = root + .filter { MetadataKey(rawValue: $0.key) == nil } + .flatMap { key, node in node.leaves(prefix: key) } + .sorted { $0.path < $1.path } - var parsed: [ThemeSlot: ThemeColorValue] = [:] + var parsed: [ThemeSlot: ThemeColor] = [:] var unknown: [String] = [] - for (path, raw) in flat { - guard let slot = ThemeSlot(rawValue: path) else { - unknown.append(path) + for leaf in leaves { + guard let slot = ThemeSlot(rawValue: leaf.path) else { + unknown.append(leaf.path) continue } - parsed[slot] = try ThemeColorValue(validating: raw) + guard parsed[slot] == nil else { + throw ThemeLoadError.duplicateKey(leaf.path) + } + guard let raw = leaf.value else { + throw ThemeLoadError.invalidColor(leaf.path) + } + parsed[slot] = try ThemeColor(validating: raw) } guard unknown.isEmpty else { - throw ThemeLoadError.unknownKeys(unknown.sorted()) - } - - let missing = ThemeSlot.allCases - .filter { $0.since <= schema && parsed[$0] == nil } - .map(\.rawValue) - - guard missing.isEmpty else { - throw ThemeLoadError.missingSlots(missing.sorted()) + throw ThemeLoadError.unknownKeys(unknown) } colors = parsed } - /// A slot introduced after this file's schema revision takes the same-appearance built-in's - /// value for that exact slot. Every slot the file's own revision declares is required, so this - /// never papers over an omission the author could have made. - internal func resolved() -> ThemeDefinition { - var definition = BuiltInThemes.default(for: appearance) - definition.id = id - definition.name = name - definition.author = author - definition.appearance = appearance - - for slot in ThemeSlot.allCases { - guard let value = colors[slot] else { continue } - definition[keyPath: slot.keyPath] = value - } - - return definition + internal func definition() -> ThemeDefinition { + ThemeDefinition(id: id, name: name, author: author, appearance: appearance, overrides: colors) } } @@ -176,7 +168,8 @@ internal enum ThemeEncoder { ] for slot in ThemeSlot.allCases { - insert(theme[keyPath: slot.keyPath].rawValue, at: slot.rawValue.components(separatedBy: "."), into: &root) + guard let color = theme.overrides[slot] else { continue } + insert(color.hex, at: slot.rawValue.components(separatedBy: "."), into: &root) } return try JSONSerialization.data( diff --git a/TablePro/Theme/ThemeEngine+EditorTheme.swift b/TablePro/Theme/ThemeEngine+EditorTheme.swift new file mode 100644 index 0000000000..0cc57cec08 --- /dev/null +++ b/TablePro/Theme/ThemeEngine+EditorTheme.swift @@ -0,0 +1,43 @@ +import AppKit +import TableProEditorKit + +internal extension ThemeEngine { + /// A slot the theme leaves out arrives as the system color object itself, which the editor resolves when it draws. + /// An Accent, Highlight or contrast change therefore builds an equal `EditorTheme`, and the editor keeps its + /// highlighting rather than restyling the whole document. + func makeEditorTheme() -> EditorTheme { + let editorSettings = AppSettingsManager.shared.editor + let text = EditorTheme.Attribute(color: resolved[.editorText]) + let comment = EditorTheme.Attribute(color: resolved[.syntaxComment]) + let keyword = EditorTheme.Attribute(color: resolved[.syntaxKeyword], bold: true) + let string = EditorTheme.Attribute(color: resolved[.syntaxString]) + let number = EditorTheme.Attribute(color: resolved[.syntaxNumber]) + let variable = EditorTheme.Attribute(color: resolved[.syntaxNull]) + let type = EditorTheme.Attribute(color: resolved[.syntaxType]) + let operatorAttribute = EditorTheme.Attribute(color: resolved[.syntaxOperator]) + let function = EditorTheme.Attribute(color: resolved[.syntaxFunction]) + + return EditorTheme( + text: text, + insertionPoint: resolved.isOverridden(.editorCursor) ? resolved[.editorCursor] : nil, + invisibles: EditorTheme.Attribute(color: resolved[.editorInvisibles]), + background: resolved[.editorBackground], + lineHighlight: editorSettings.highlightCurrentLine ? resolved[.editorCurrentLine] : .clear, + statementHighlight: editorSettings.highlightCurrentStatement ? resolved[.editorCurrentStatement] : .clear, + selection: resolved[.editorSelection], + lineNumber: resolved[.editorLineNumber], + keywords: keyword, + commands: keyword, + types: type, + attributes: variable, + variables: variable, + values: variable, + numbers: number, + strings: string, + characters: string, + comments: comment, + operators: operatorAttribute, + functions: function + ) + } +} diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index e1495cb65b..09cde4af70 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -69,7 +69,7 @@ internal final class ThemeEngine: ObservableObject { internal var activeTheme: ThemeDefinition { pair[effectiveAppearance] } internal var change: ThemeChange { - ThemeChange(revision: revision, appearance: effectiveAppearance) + ThemeChange(revision: revision, appearance: effectiveAppearance, generation: generation) } /// Every control that shows or edits a stored value takes this, so one value reads the same in @@ -82,7 +82,10 @@ internal final class ThemeEngine: ObservableObject { private var mode: AppAppearanceMode = .auto private var typography: TypographySettings = .default + private var generation = 0 + private var systemFingerprint: SystemColorFingerprint? private var appearanceObservation: NSKeyValueObservation? + private var systemColorsObserver: NSObjectProtocol? private var accessibilityObserver: NSObjectProtocol? private var lastAccessibilityScale: CGFloat = 1 @@ -91,19 +94,47 @@ internal final class ThemeEngine: ObservableObject { effectiveAppearance = .light revision = 1 palette = ThemePalette(revision: 1) - resolved = ResolvedTheme(definition: BuiltInThemes.light, appearance: .light) + resolved = ResolvedTheme(definition: BuiltInThemes.light) editorFonts = EditorFontCache(from: .default) dataGridFonts = DataGridFontCache(from: .default) + observeSystemColorChanges() observeAccessibilityChanges() } + /// What the system colors depend on besides the theme. A notification or KVO callback that + /// leaves it unchanged is not a change: `NSApp.appearance = nil` fires the observer even when + /// it was already nil, and a light/dark flip can arrive through more than one channel. + private struct SystemColorFingerprint: Equatable { + let appearance: NSAppearance.Name? + let accent: String + let highlight: String + let increaseContrast: Bool + + @MainActor + static func current() -> SystemColorFingerprint { + let appearance = NSApp?.effectiveAppearance ?? NSAppearance.currentDrawing() + var accent = "" + var highlight = "" + appearance.performAsCurrentDrawingAppearance { + accent = HexColor.string(from: .controlAccentColor) + highlight = HexColor.string(from: .selectedTextBackgroundColor) + } + return SystemColorFingerprint( + appearance: appearance.name, + accent: accent, + highlight: highlight, + increaseContrast: NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast + ) + } + } + // MARK: - Settings entry points internal func apply(mode: AppAppearanceMode, lightThemeId: String, darkThemeId: String) { self.mode = mode applyApplicationAppearance(mode) - updateSystemAppearanceObserver(mode) + observeApplicationAppearance() let selection = ThemeResolver.resolve( mode: mode, @@ -126,7 +157,7 @@ internal final class ThemeEngine: ObservableObject { } /// Called when the catalog changes under a selection that is already live, so a saved edit is - /// visible without the settings round trip that used to re-activate a stale cached copy. + /// visible without a settings round trip. internal func reapply(lightThemeId: String, darkThemeId: String) { apply(mode: mode, lightThemeId: lightThemeId, darkThemeId: darkThemeId) } @@ -146,7 +177,7 @@ internal final class ThemeEngine: ObservableObject { if pairChanged { bumpRevision() } - resolved = ResolvedTheme(definition: activeTheme, appearance: effectiveAppearance) + resolved = ResolvedTheme(definition: activeTheme) publishChange() Self.logger.info("Theme \(self.activeTheme.id, privacy: .public) revision \(self.revision)") @@ -158,83 +189,72 @@ internal final class ThemeEngine: ObservableObject { } private func publishChange() { + generation += 1 + systemFingerprint = SystemColorFingerprint.current() AppEvents.shared.themeChanged.send(change) } + /// A system color change gets new palette objects, because SwiftUI caches a resolved + /// `Color(nsColor:)` by object identity and would keep the old accent otherwise. + private func systemColorsMayHaveChanged() { + let appearance = ThemeResolver.effectiveAppearance(mode: mode, systemIsDark: Self.systemIsDark()) + let appearanceChanged = appearance != effectiveAppearance + guard appearanceChanged || SystemColorFingerprint.current() != systemFingerprint else { return } + + if appearanceChanged { + effectiveAppearance = appearance + resolved = ResolvedTheme(definition: activeTheme) + } + bumpRevision() + publishChange() + } + // MARK: - Application appearance private func applyApplicationAppearance(_ mode: AppAppearanceMode) { + guard let app = NSApp else { return } + let target: NSAppearance.Name? switch mode { - case .light: NSApp?.appearance = NSAppearance(named: .aqua) - case .dark: NSApp?.appearance = NSAppearance(named: .darkAqua) - case .auto: NSApp?.appearance = nil + case .light: target = .aqua + case .dark: target = .darkAqua + case .auto: target = nil } + guard app.appearance?.name != target else { return } + app.appearance = target.flatMap { NSAppearance(named: $0) } } private static func systemIsDark() -> Bool { - NSApp?.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + NSApp?.effectiveAppearance.isDarkTheme ?? false } - /// KVO on `NSApplication.effectiveAppearance` is the channel Apple names in the - /// `NSControlTintDidChangeNotification` deprecation text. Only the static tier and the change - /// signal depend on it: the dynamic slot colours answer for the drawing appearance themselves. - private func updateSystemAppearanceObserver(_ mode: AppAppearanceMode) { - appearanceObservation = nil - guard mode == .auto else { return } - + /// `NSApplication.effectiveAppearance` changes on a light/dark flip and, per the + /// `NSControlTintDidChangeNotification` deprecation text, on an Accent change. Observed in + /// every mode: a forced Light window still needs to re-bake its colors when the accent moves. + private func observeApplicationAppearance() { + guard appearanceObservation == nil else { return } appearanceObservation = NSApp?.observe(\.effectiveAppearance) { [weak self] _, _ in - Task { @MainActor [weak self] in - self?.systemAppearanceDidChange() + guard Thread.isMainThread else { + Task { @MainActor [weak self] in self?.systemColorsMayHaveChanged() } + return + } + MainActor.assumeIsolated { + self?.systemColorsMayHaveChanged() } } } - private func systemAppearanceDidChange() { - guard mode == .auto else { return } - let appearance: ThemeAppearance = Self.systemIsDark() ? .dark : .light - guard appearance != effectiveAppearance else { return } - - effectiveAppearance = appearance - resolved = ResolvedTheme(definition: activeTheme, appearance: appearance) - publishChange() - } - - // MARK: - TableProEditorKit - - internal func makeEditorTheme() -> EditorTheme { - let editorSettings = AppSettingsManager.shared.editor - let text = EditorTheme.Attribute(color: resolved[.editorText]) - let comment = EditorTheme.Attribute(color: resolved[.syntaxComment]) - let keyword = EditorTheme.Attribute(color: resolved[.syntaxKeyword], bold: true) - let string = EditorTheme.Attribute(color: resolved[.syntaxString]) - let number = EditorTheme.Attribute(color: resolved[.syntaxNumber]) - let variable = EditorTheme.Attribute(color: resolved[.syntaxNull]) - let type = EditorTheme.Attribute(color: resolved[.syntaxType]) - let operatorAttribute = EditorTheme.Attribute(color: resolved[.syntaxOperator]) - let function = EditorTheme.Attribute(color: resolved[.syntaxFunction]) - - return EditorTheme( - text: text, - insertionPoint: resolved[.editorCursor], - invisibles: EditorTheme.Attribute(color: resolved[.editorInvisibles]), - background: resolved[.editorBackground], - lineHighlight: editorSettings.highlightCurrentLine ? resolved[.editorCurrentLine] : .clear, - statementHighlight: editorSettings.highlightCurrentStatement ? resolved[.editorCurrentStatement] : .clear, - selection: resolved[.editorSelection], - lineNumber: resolved[.editorLineNumber], - keywords: keyword, - commands: keyword, - types: type, - attributes: variable, - variables: variable, - values: variable, - numbers: number, - strings: string, - characters: string, - comments: comment, - operators: operatorAttribute, - functions: function - ) + /// The Highlight color, and any other system color, changes through this notification rather + /// than through the appearance. + private func observeSystemColorChanges() { + systemColorsObserver = NotificationCenter.default.addObserver( + forName: NSColor.systemColorsDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.systemColorsMayHaveChanged() + } + } } // MARK: - Accessibility @@ -246,17 +266,23 @@ internal final class ThemeEngine: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor [weak self] in + MainActor.assumeIsolated { self?.accessibilityDisplayOptionsDidChange() } } } + /// Increase Contrast changes every system color's resolved value, so baked colors are + /// rebuilt even when the text scale did not move. private func accessibilityDisplayOptionsDidChange() { let scale = EditorFontCache.accessibilityScale() - guard abs(scale - lastAccessibilityScale) > 0.01 else { return } + let scaleChanged = abs(scale - lastAccessibilityScale) > 0.01 lastAccessibilityScale = scale + guard scaleChanged else { + systemColorsMayHaveChanged() + return + } editorFonts = EditorFontCache(from: typography) dataGridFonts = DataGridFontCache(from: typography) bumpRevision() diff --git a/TablePro/Theme/ThemeLoadError.swift b/TablePro/Theme/ThemeLoadError.swift index 9c20973d08..8f9382bf94 100644 --- a/TablePro/Theme/ThemeLoadError.swift +++ b/TablePro/Theme/ThemeLoadError.swift @@ -5,12 +5,13 @@ internal enum ThemeLoadError: LocalizedError, Equatable { case schemaTooOld(found: Int, supported: Int) case schemaTooNew(found: Int, supported: Int) case missingField(String) - case missingSlots([String]) case invalidColor(String) - case unknownSystemColor(String) case unknownKeys([String]) + case duplicateKey(String) case reservedIdentifier(String) case invalidIdentifier(String) + case duplicateIdentifier(String, existingFile: String) + case changedOnDisk(String) internal var errorDescription: String? { switch self { @@ -30,26 +31,28 @@ internal enum ThemeLoadError: LocalizedError, Equatable { ) case let .missingField(name): return String(format: String(localized: "The theme is missing the required field \"%@\"."), name) - case let .missingSlots(names): - return String( - format: String(localized: "The theme is missing %1$d required colors, starting with \"%2$@\"."), - names.count, - names.first ?? "" - ) case let .invalidColor(value): - return String(format: String(localized: "\"%@\" is not a valid color."), value) - case let .unknownSystemColor(name): - return String(format: String(localized: "\"%@\" is not a system color TablePro knows."), name) + return String(format: String(localized: "\"%@\" is not a valid color. Use #RRGGBB or #RRGGBBAA."), value) case let .unknownKeys(names): - return String( - format: String(localized: "The theme declares %1$d colors TablePro does not use, starting with \"%2$@\"."), - names.count, - names.first ?? "" - ) + let shown = ListFormatter.localizedString(byJoining: names.prefix(3).map { "\"\($0)\"" }) + return String(format: String(localized: "TablePro does not use these theme keys: %@."), shown) + case let .duplicateKey(path): + return String(format: String(localized: "The theme sets \"%@\" more than once."), path) case let .reservedIdentifier(identifier): return String(format: String(localized: "\"%@\" is a reserved theme identifier."), identifier) case let .invalidIdentifier(identifier): return String(format: String(localized: "\"%@\" is not a valid theme identifier."), identifier) + case let .changedOnDisk(fileName): + return String( + format: String(localized: "%@ changed on disk since it was opened. The newer file was kept and reloaded."), + fileName + ) + case let .duplicateIdentifier(identifier, existingFile): + return String( + format: String(localized: "The theme identifier \"%1$@\" is already used by %2$@."), + identifier, + existingFile + ) } } } diff --git a/TablePro/Theme/ThemePalette.swift b/TablePro/Theme/ThemePalette.swift index 18d1a88c31..9571bc6eda 100644 --- a/TablePro/Theme/ThemePalette.swift +++ b/TablePro/Theme/ThemePalette.swift @@ -34,9 +34,11 @@ internal final class ThemeSource: Sendable { } } -/// The dynamic tier. One `NSColor(name:dynamicProvider:)` per slot, so a slot resolves against -/// whatever appearance the view is drawing in, including the vibrant appearances inside a sidebar. -/// The revision only names the colour: SwiftUI caches `Color(nsColor:)` by object identity, so a +/// The dynamic tier. One `NSColor(name:dynamicProvider:)` per slot, reading the live pair, so a +/// color an AppKit view already holds answers for the theme in effect and for the appearance the +/// view draws in. A slot the theme leaves out returns the system color itself, which AppKit then +/// resolves for that same appearance, including vibrancy, accent, highlight and contrast. +/// The revision only names the color: SwiftUI caches `Color(nsColor:)` by object identity, so a /// stable object would never repaint on a theme swap even though the provider answers correctly. internal struct ThemePalette { internal let revision: Int @@ -52,8 +54,7 @@ internal struct ThemePalette { for slot in ThemeSlot.allCases { let name = NSColor.Name("tablepro.\(slot.rawValue).\(revision)") built[slot] = NSColor(name: name) { appearance in - let definition = source.pair[ThemeAppearance(matching: appearance)] - return definition[keyPath: slot.keyPath].nsColor + source.pair[ThemeAppearance(matching: appearance)].color(for: slot) } } @@ -61,7 +62,7 @@ internal struct ThemePalette { } internal subscript(slot: ThemeSlot) -> NSColor { - colors[slot] ?? .labelColor + colors[slot] ?? slot.nativeColor } internal func color(_ slot: ThemeSlot) -> Color { @@ -69,66 +70,71 @@ internal struct ThemePalette { } } -/// The static tier. Snapshot consumers (the CodeEdit `EditorTheme`, `CALayer` colours, the data -/// grid's per-cell draw path) resolve once against the appearance the app has actually settled on. -/// They must not resolve a dynamic colour implicitly: outside a draw pass -/// `NSAppearance.currentDrawing()` still reports the old appearance for at least half a second -/// after `NSApp.appearance` changes, and a dynamic `cgColor` costs 254ns against 11ns for a -/// static one, which the grid pays per cell. +/// The active theme's colors for code that holds on to them: the editor theme, a `CALayer`, the +/// grid's per-cell drawing. A slot the theme overrides is a fixed sRGB color; a slot it leaves out +/// is the system color object itself, never a flattened copy, so text drawn with it still resolves +/// at draw time against the view's real appearance. Turning one into a `CGColor` happens in the +/// drawing view's appearance (`cgColor(_:in:)`), never against a hand-built `NSAppearance(named:)`, +/// which has no vibrancy and no increased-contrast variant. internal struct ResolvedTheme: Equatable { internal let definition: ThemeDefinition - internal let appearance: ThemeAppearance private let colors: [ThemeSlot: NSColor] - internal init(definition: ThemeDefinition, appearance: ThemeAppearance) { + internal init(definition: ThemeDefinition) { self.definition = definition - self.appearance = appearance - let nsAppearance = NSAppearance(named: appearance == .dark ? .darkAqua : .aqua) var built: [ThemeSlot: NSColor] = [:] built.reserveCapacity(ThemeSlot.allCases.count) - - let resolve = { - for slot in ThemeSlot.allCases { - let value = definition[keyPath: slot.keyPath] - built[slot] = value.nsColor.usingColorSpace(.sRGB) ?? value.nsColor - } - } - - if let nsAppearance { - nsAppearance.performAsCurrentDrawingAppearance(resolve) - } else { - resolve() + for slot in ThemeSlot.allCases { + built[slot] = definition.color(for: slot) } - colors = built } + internal static func == (lhs: ResolvedTheme, rhs: ResolvedTheme) -> Bool { + lhs.definition == rhs.definition + } + + internal var appearance: ThemeAppearance { definition.appearance } + internal subscript(slot: ThemeSlot) -> NSColor { - colors[slot] ?? .labelColor + colors[slot] ?? slot.nativeColor + } + + internal func isOverridden(_ slot: ThemeSlot) -> Bool { + definition.isOverridden(slot) } internal func color(_ slot: ThemeSlot) -> Color { Color(nsColor: self[slot]) } - internal func cgColor(_ slot: ThemeSlot) -> CGColor { - self[slot].cgColor + /// A view drawing in the other appearance than the active theme (a preview of the other slot) + /// takes that slot's theme, the way a `palette` color would. + internal func cgColor(_ slot: ThemeSlot, in appearance: NSAppearance) -> CGColor { + let color = ThemeAppearance(matching: appearance) == definition.appearance + ? self[slot] + : ThemeSource.shared.pair[ThemeAppearance(matching: appearance)].color(for: slot) + var result: CGColor? + appearance.performAsCurrentDrawingAppearance { + result = color.cgColor + } + return result ?? color.cgColor } } internal extension ThemeAppearance { init(matching appearance: NSAppearance) { - let match = appearance.bestMatch(from: [.aqua, .darkAqua]) - self = match == .darkAqua ? .dark : .light + self = appearance.isDarkTheme ? .dark : .light } } -/// Sent on a theme change and on an effective-appearance change, because the snapshot consumers -/// need both: a forced Light/Dark switch never changes the theme pair, and a theme switch never -/// changes the appearance. +/// Sent whenever a color a view may have baked could have changed: a theme or pair change, a +/// light/dark flip, an Accent or Highlight change, or an Increase Contrast toggle. `generation` +/// moves on every send, so two sends are never equal. internal struct ThemeChange: Equatable, Sendable { internal let revision: Int internal let appearance: ThemeAppearance + internal let generation: Int } diff --git a/TablePro/Theme/ThemedContentSurface.swift b/TablePro/Theme/ThemedContentSurface.swift deleted file mode 100644 index ff8711d799..0000000000 --- a/TablePro/Theme/ThemedContentSurface.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI - -/// Re-resolves `.secondary` and `.tertiary` inside a content pane to the theme's own text levels, -/// which is how 184 hierarchical foreground styles across the content surfaces follow a theme -/// without each one being rewritten. -/// -/// It never branches on the theme. `WorkspacePanes` depends on every pane builder erasing one -/// stable view identity, so an `if` here would tear down the grid, the editor's undo stack and the -/// scroll position on a theme change. It reads the engine inside `body`, so the palette it applies -/// is the current one rather than whatever was captured when the pane was built. -private struct ThemedContentSurface: ViewModifier { - @ObservedObject private var themeEngine = ThemeEngine.shared - func body(content: Content) -> some View { - let palette = themeEngine.palette - - return content.foregroundStyle( - palette.color(.panelText), - palette.color(.panelSecondaryText), - palette.color(.panelTertiaryText) - ) - } -} - -internal extension View { - func themedContent() -> some View { - modifier(ThemedContentSurface()) - } -} diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index c3ad7ed85f..64ae1263f1 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -10,6 +10,7 @@ import TableProGrammars struct AIChatCodeBlockView: View, Equatable { @ObservedObject private var settingsManager = AppSettingsManager.shared + @ObservedObject private var themeEngine = ThemeEngine.shared let code: String let language: String? var prefersLightweightRendering: Bool = false @@ -23,6 +24,7 @@ struct AIChatCodeBlockView: View, Equatable { @State private var isCopied: Bool = false @State private var isEditorReady = false @State private var editorState = SourceEditorState() + @State private var editorConfiguration = makeConfiguration() @State private var measuredWidth: CGFloat = 0 @Environment(\.commandActions) private var actions @@ -107,20 +109,22 @@ struct AIChatCodeBlockView: View, Equatable { private var codeContent: some View { Group { if usesLightweightContent { + // Stands in for the editor while the block streams, so it wears the editor's colors and the block + // does not change color when the editor replaces it. Text(code.isEmpty ? " " : code) .font(Font(Self.editorFont)) .lineSpacing(CodeBlockHeightEstimator.extraLineSpacing(for: Self.editorFont)) - .foregroundStyle(.primary) + .foregroundStyle(themeEngine.palette.color(.editorText)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .frame(minHeight: CodeBlockHeightEstimator.minimumHeight, alignment: .topLeading) - .background(Color(nsColor: .textBackgroundColor)) + .background(themeEngine.palette.color(.editorBackground)) } else { SourceEditor( .constant(code), language: treeSitterLanguage, - configuration: Self.makeConfiguration(), + configuration: editorConfiguration, state: $editorState, foldProvider: FoldProviderResolver.provider(for: treeSitterLanguage) ) @@ -133,6 +137,12 @@ struct AIChatCodeBlockView: View, Equatable { } action: { newWidth in measuredWidth = newWidth } + .onReceive(AppEvents.shared.themeChanged) { _ in + editorConfiguration = Self.makeConfiguration() + } + .onChange(of: settingsManager.editor) { _ in + editorConfiguration = Self.makeConfiguration() + } } private var resolvedLanguage: String? { diff --git a/TablePro/Views/AIChat/AIChatComposerImageChip.swift b/TablePro/Views/AIChat/AIChatComposerImageChip.swift index ad64908e40..76ef9b1970 100644 --- a/TablePro/Views/AIChat/AIChatComposerImageChip.swift +++ b/TablePro/Views/AIChat/AIChatComposerImageChip.swift @@ -24,7 +24,7 @@ struct AIChatComposerImageChip: View { Button(action: onRemove) { Image(systemName: "xmark.circle.fill") .font(.caption) - .foregroundStyle(.white, Color.black.opacity(0.6)) + .foregroundStyle(Color(nsColor: .textBackgroundColor), Color(nsColor: .secondaryLabelColor)) } .buttonStyle(.plain) .padding(2) diff --git a/TablePro/Views/AIChat/ChatComposerView.swift b/TablePro/Views/AIChat/ChatComposerView.swift index 9f5dfc77e0..b887cd3834 100644 --- a/TablePro/Views/AIChat/ChatComposerView.swift +++ b/TablePro/Views/AIChat/ChatComposerView.swift @@ -232,15 +232,7 @@ struct ChatComposerView: View { } private enum IntelligenceShimmer { - static let palette: [Color] = [ - Color(red: 1.0, green: 0.404, blue: 0.471), - Color(red: 1.0, green: 0.553, blue: 0.443), - Color(red: 1.0, green: 0.729, blue: 0.443), - Color(red: 0.961, green: 0.725, blue: 0.918), - Color(red: 0.776, green: 0.525, blue: 1.0), - Color(red: 0.737, green: 0.510, blue: 0.953), - Color(red: 0.553, green: 0.624, blue: 1.0) - ] + static let palette: [Color] = [.pink, .orange, .yellow, .pink, .purple, .indigo, .blue] struct Layer: Identifiable { let id: Int diff --git a/TablePro/Views/Compare/CompareDataPlansView.swift b/TablePro/Views/Compare/CompareDataPlansView.swift index 156c8a3d46..6db59ad448 100644 --- a/TablePro/Views/Compare/CompareDataPlansView.swift +++ b/TablePro/Views/Compare/CompareDataPlansView.swift @@ -277,7 +277,7 @@ if #available(macOS 14.0, *) { if let value { Text(value, format: .number) .monospacedDigit() - .foregroundStyle(countTint(value, kind: kind)) + .selectionAwareTint(countTint(value, kind: kind)) } } diff --git a/TablePro/Views/Compare/CompareResultsView.swift b/TablePro/Views/Compare/CompareResultsView.swift index a1928bedbe..fa6a0e99ba 100644 --- a/TablePro/Views/Compare/CompareResultsView.swift +++ b/TablePro/Views/Compare/CompareResultsView.swift @@ -225,7 +225,7 @@ internal struct CompareResultsView: View { } icon: { Image(systemName: symbolName(for: result)) } - .foregroundStyle(tint(for: result)) + .selectionAwareTint(tint(for: result)) } } diff --git a/TablePro/Views/Compare/CompareStatusStyle.swift b/TablePro/Views/Compare/CompareStatusStyle.swift index 8fdca4718a..360103f6cc 100644 --- a/TablePro/Views/Compare/CompareStatusStyle.swift +++ b/TablePro/Views/Compare/CompareStatusStyle.swift @@ -6,8 +6,8 @@ // // Colour is never the carrier on its own. Every call site pairs the tint with // the symbol and the word, and drops the tint when the system asks for shapes -// instead of colour. Tints resolve through `ThemeEngine` rather than the -// built-in `Color` literals, so a theme owns them the way it owns the grid. +// instead of colour. The tints are system colours under every theme: a theme +// colours only the SQL editor and the data grid. // import SwiftUI @@ -43,18 +43,16 @@ internal enum CompareStatusStyle { } } - @MainActor internal static func tint(for status: TableDiffStatus) -> Color { - let palette = ThemeEngine.shared.palette switch status { case .onlyInSource: - return palette.color(.statusSuccess) + return success case .onlyInTarget: - return palette.color(.statusError) + return error case .differs: - return palette.color(.statusWarning) + return warning case .identical: - return Color(nsColor: .secondaryLabelColor) + return .secondary } } @@ -90,35 +88,30 @@ internal enum CompareStatusStyle { } } - @MainActor internal static func tint(for kind: RowDiffKind) -> Color { - let palette = ThemeEngine.shared.palette switch kind { case .insert: - return palette.color(.statusSuccess) - case .update: - return palette.color(.statusWarning) + return success + case .update, .conflict: + return warning case .delete: - return palette.color(.statusError) + return error case .identical: - return Color(nsColor: .secondaryLabelColor) - case .conflict: - return palette.color(.statusWarning) + return .secondary } } - /// The same tints the data grid paints an inserted, modified or deleted row with, so a row - /// difference here reads the way the same row reads in the grid. - @MainActor + /// The tints a data grid with no theme paints an inserted, modified or deleted row with, so a + /// row difference reads the way the same row reads in the default grid. Not the grid's slots: + /// a theme stops at the grid. internal static func rowTint(for kind: RowDiffKind) -> Color { - let palette = ThemeEngine.shared.palette switch kind { case .insert: - return palette.color(.gridInserted) + return Color(nsColor: NativeThemeColors.insertedRow) case .update: - return palette.color(.gridModified) + return Color(nsColor: NativeThemeColors.modifiedRow) case .delete: - return palette.color(.gridDeleted) + return Color(nsColor: NativeThemeColors.deletedRow) case .identical, .conflict: return .clear } @@ -126,18 +119,7 @@ internal enum CompareStatusStyle { // MARK: - Message tints - @MainActor - internal static var warning: Color { - ThemeEngine.shared.palette.color(.statusWarning) - } - - @MainActor - internal static var error: Color { - ThemeEngine.shared.palette.color(.statusError) - } - - @MainActor - internal static var success: Color { - ThemeEngine.shared.palette.color(.statusSuccess) - } + internal static let warning = Color.orange + internal static let error = Color.red + internal static let success = Color.green } diff --git a/TablePro/Views/Components/HighlightedSQLTextView.swift b/TablePro/Views/Components/HighlightedSQLTextView.swift index cee3306ddf..f2c5e9d4a5 100644 --- a/TablePro/Views/Components/HighlightedSQLTextView.swift +++ b/TablePro/Views/Components/HighlightedSQLTextView.swift @@ -7,10 +7,12 @@ // import AppKit +import Combine import SwiftUI import TableProPluginKit -/// Read-only text view that applies SQL/MQL syntax highlighting via regex +/// A code view, so it wears the editor theme: background, text and syntax. The colors are the palette's dynamic ones, +/// which resolve per appearance when the text view draws. struct HighlightedSQLTextView: NSViewRepresentable { let sql: String var databaseType: DatabaseType = .mysql @@ -33,8 +35,8 @@ struct HighlightedSQLTextView: NSViewRepresentable { textView.isSelectable = true textView.font = themeEngine.editorFonts.font textView.textContainerInset = NSSize(width: 12, height: 12) - textView.backgroundColor = themeEngine.palette[.editorBackground] textView.textColor = themeEngine.palette[.editorText] + applyBackground(to: scrollView, textView: textView) // Disable line wrapping textView.textContainer?.widthTracksTextView = false @@ -44,6 +46,7 @@ struct HighlightedSQLTextView: NSViewRepresentable { ) textView.isHorizontallyResizable = true + context.coordinator.redisplayOnThemeChange(scrollView) return scrollView } @@ -57,8 +60,7 @@ struct HighlightedSQLTextView: NSViewRepresentable { if fontChanged { textView.font = editorFont } - textView.backgroundColor = themeEngine.palette[.editorBackground] - textView.textColor = themeEngine.palette[.editorText] + applyBackground(to: scrollView, textView: textView) if textChanged { textView.string = sql @@ -73,6 +75,14 @@ struct HighlightedSQLTextView: NSViewRepresentable { applyHighlighting(to: textView) } + /// The scroll view paints the same color, so a short query never shows another background below its last line. + private func applyBackground(to scrollView: NSScrollView, textView: NSTextView) { + let background = themeEngine.palette[.editorBackground] + guard textView.backgroundColor != background else { return } + textView.backgroundColor = background + scrollView.backgroundColor = background + } + private var themeRevision: Int { themeEngine.revision } @@ -81,8 +91,21 @@ struct HighlightedSQLTextView: NSViewRepresentable { Coordinator() } + @MainActor final class Coordinator { var revision = -1 + private var themeCancellable: AnyCancellable? + + /// An Accent, Highlight or contrast change moves no published value, so nothing would otherwise redraw the + /// system colors the text view holds. + func redisplayOnThemeChange(_ scrollView: NSScrollView) { + themeCancellable = AppEvents.shared.themeChanged + .receive(on: RunLoop.main) + .sink { [weak scrollView] _ in + scrollView?.needsDisplay = true + scrollView?.documentView?.needsDisplay = true + } + } } // MARK: - Syntax Highlighting diff --git a/TablePro/Views/Components/SQLStatementPreview.swift b/TablePro/Views/Components/SQLStatementPreview.swift index 9b53570a04..dab19a944c 100644 --- a/TablePro/Views/Components/SQLStatementPreview.swift +++ b/TablePro/Views/Components/SQLStatementPreview.swift @@ -21,6 +21,7 @@ struct SQLStatementPreview: View { let databaseType: DatabaseType @State private var editorState: SourceEditorState? + @State private var editorConfiguration = makeConfiguration() var body: some View { switch prepared.mode { @@ -39,10 +40,16 @@ struct SQLStatementPreview: View { return SourceEditor( .constant(text), language: PluginManager.shared.editorLanguage(for: databaseType).treeSitterLanguage, - configuration: SQLStatementPreview.makeConfiguration(), + configuration: editorConfiguration, state: stateBinding, foldProvider: FoldProviderResolver.provider(for: databaseType) ) + .onReceive(AppEvents.shared.themeChanged) { _ in + editorConfiguration = Self.makeConfiguration() + } + .onChange(of: settingsManager.editor) { _ in + editorConfiguration = Self.makeConfiguration() + } .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift index db8e772d14..ed1d0e955f 100644 --- a/TablePro/Views/Connection/SSHProfileEditorView.swift +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -83,7 +83,6 @@ struct SSHProfileEditorView: View { jumpHostsSection } .formStyle(.grouped) - .scrollContentBackground(.hidden) Divider() bottomBar diff --git a/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift b/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift index c794631aca..4db5978e69 100644 --- a/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/AppearancePaneView.swift @@ -36,6 +36,5 @@ struct AppearancePaneView: View { } } .formStyle(.grouped) - .scrollContentBackground(.hidden) } } diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index d6b448153f..2f1957f2c4 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -32,7 +32,6 @@ struct GeneralPaneView: View { authenticationSection } .formStyle(.grouped) - .scrollContentBackground(.hidden) .defaultFocus($nameFocused, true) } diff --git a/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift b/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift index d805f5884d..34653bd4d6 100644 --- a/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift @@ -32,7 +32,6 @@ struct NetworkPaneView: View { } } .formStyle(.grouped) - .scrollContentBackground(.hidden) } private var sslModeBinding: Binding { diff --git a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift index 79e823aaae..d0564e7a4b 100644 --- a/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -35,7 +35,6 @@ struct OptionsPaneView: View { } } .formStyle(.grouped) - .scrollContentBackground(.hidden) } // MARK: - Timeouts diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 36cd06919b..61d8d74e79 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -134,7 +134,6 @@ struct DatabaseSwitcherSheet: View { } } .listStyle(.inset) - .scrollContentBackground(.hidden) .focused($focus, equals: .list) .onChange(of: viewModel.selectedDatabase) { newValue in guard let item = newValue else { return } diff --git a/TablePro/Views/Editor/Folding/FoldPreviewView.swift b/TablePro/Views/Editor/Folding/FoldPreviewView.swift index 2b1fb4d769..fa2c8a72aa 100644 --- a/TablePro/Views/Editor/Folding/FoldPreviewView.swift +++ b/TablePro/Views/Editor/Folding/FoldPreviewView.swift @@ -15,16 +15,16 @@ import TableProGrammars /// back yields plain text. An editor runs the same tree-sitter grammar and the same theme over the block, so the peek /// matches the code it stands for. It is neither editable nor selectable, so peeking never moves the caret. /// -/// The peek draws its own surface. A popover over a light editor renders pure white with no border and no shadow, so -/// content that simply fills it reads as stray text drawn over the code rather than as a panel floating above it. The -/// code keeps the theme's editor background so its colours stay legible, and a hairline marks where the panel ends. +/// A popover over a light editor renders pure white with no border and no shadow, so content that simply fills it +/// reads as stray text drawn over the code rather than as a panel floating above it. The editor draws the theme's +/// background behind the code so its colours stay legible, and a hairline marks where the panel ends. struct FoldPreviewView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let layout: FoldPreviewMetrics.Layout let language: CodeLanguage @State private var text: String @State private var editorState = SourceEditorState() + @State private var configuration = makeConfiguration() init(layout: FoldPreviewMetrics.Layout, language: CodeLanguage) { self.layout = layout @@ -37,10 +37,13 @@ struct FoldPreviewView: View { SourceEditor( $text, language: language, - configuration: Self.configuration, + configuration: configuration, state: $editorState ) .frame(width: layout.size.width, height: layout.size.height) + .onReceive(AppEvents.shared.themeChanged) { _ in + configuration = Self.makeConfiguration() + } if layout.hiddenLineCount > 0 { Divider() @@ -52,7 +55,6 @@ struct FoldPreviewView: View { .padding(.vertical, 5) } } - .background(themeEngine.palette.color(.editorBackground)) .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) @@ -67,11 +69,10 @@ struct FoldPreviewView: View { /// Room for the panel's own edge inside the popover's content area. static let surfaceInset: CGFloat = 1 - private static var configuration: SourceEditorConfiguration { + private static func makeConfiguration() -> SourceEditorConfiguration { SourceEditorConfiguration( appearance: .init( theme: TableProEditorTheme.make(), - useThemeBackground: false, font: ThemeEngine.shared.editorFonts.font, wrapLines: false ), diff --git a/TablePro/Views/Editor/History/HistoryDetailPane.swift b/TablePro/Views/Editor/History/HistoryDetailPane.swift index 39adaff684..9d5650c35f 100644 --- a/TablePro/Views/Editor/History/HistoryDetailPane.swift +++ b/TablePro/Views/Editor/History/HistoryDetailPane.swift @@ -29,7 +29,6 @@ struct HistorySelectedDetailPane: View { } struct HistoryDetailPane: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let entry: QueryHistoryEntry? let connectionLabel: HistoryConnectionLabel? let canRunInNewTab: Bool @@ -65,7 +64,6 @@ struct HistoryDetailPane: View { databaseType: entry.databaseType, accessibilityIdentifier: "query-history-detail-query" ) - .background(themeEngine.palette.color(.editorBackground)) .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() @@ -94,7 +92,7 @@ struct HistoryDetailPane: View { if let errorMessage = entry.errorMessage { RevealedTextView(errorMessage) .font(.caption) - .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) + .foregroundStyle(.red) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } diff --git a/TablePro/Views/Editor/History/HistoryListPane.swift b/TablePro/Views/Editor/History/HistoryListPane.swift index c36f6518d1..59702920c7 100644 --- a/TablePro/Views/Editor/History/HistoryListPane.swift +++ b/TablePro/Views/Editor/History/HistoryListPane.swift @@ -96,7 +96,7 @@ struct HistoryListPane: View { } .padding(.vertical, 6) } - .background(Color(nsColor: .controlBackgroundColor)) + .background(.bar) } private var emptyState: some View { diff --git a/TablePro/Views/Editor/History/HistoryPanelToolbar.swift b/TablePro/Views/Editor/History/HistoryPanelToolbar.swift index e2f45f66cf..c91130a305 100644 --- a/TablePro/Views/Editor/History/HistoryPanelToolbar.swift +++ b/TablePro/Views/Editor/History/HistoryPanelToolbar.swift @@ -34,7 +34,7 @@ struct HistoryPanelToolbar: View { Divider() } - .background(Color(nsColor: .controlBackgroundColor)) + .background(.bar) .onChange(of: state.searchText) { _ in viewModel.scheduleSearchReload() } diff --git a/TablePro/Views/Editor/History/HistoryRowView.swift b/TablePro/Views/Editor/History/HistoryRowView.swift index ff34d2fed7..a8cbd04a1a 100644 --- a/TablePro/Views/Editor/History/HistoryRowView.swift +++ b/TablePro/Views/Editor/History/HistoryRowView.swift @@ -1,7 +1,6 @@ import SwiftUI struct HistoryRowView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let entry: QueryHistoryEntry let connectionLabel: HistoryConnectionLabel? @@ -73,7 +72,7 @@ struct HistoryRowView: View { .foregroundStyle(Color.secondary) } else { Image(systemName: "exclamationmark.circle.fill") - .selectionAwareTint(themeEngine.palette.color(.statusError)) + .selectionAwareTint(.red) } } diff --git a/TablePro/Views/Editor/QueryCompletionAdapter.swift b/TablePro/Views/Editor/QueryCompletionAdapter.swift index aaabab0b7e..91931fcf3e 100644 --- a/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -218,10 +218,7 @@ final class SQLSuggestionEntry: CodeSuggestionEntry { Image(systemName: item.kind.iconName) } - /// The suggestion protocol is nonisolated and the panel only ever reads this on the main - /// thread. The kind is lifted out first so the closure sends a value rather than `self`. var imageColor: Color { - let kind = item.kind - return MainActor.assumeIsolated { Color(nsColor: kind.iconColor) } + Color(nsColor: item.kind.iconColor) } } diff --git a/TablePro/Views/Editor/QueryDiagnosticsController.swift b/TablePro/Views/Editor/QueryDiagnosticsController.swift index 6f16e8a6c1..b229b096dc 100644 --- a/TablePro/Views/Editor/QueryDiagnosticsController.swift +++ b/TablePro/Views/Editor/QueryDiagnosticsController.swift @@ -75,14 +75,6 @@ final class QueryDiagnosticsController { apply(produced, in: controller) } - /// An emphasis bakes its colour into a `CAShapeLayer`, so a theme change inside one appearance - /// leaves an existing underline on the previous colour. `refresh(for:)` cannot repaint it: the - /// diagnostics themselves have not changed, so it returns early. - func reapplyColors(in controller: TextViewController) { - guard !diagnostics.isEmpty else { return } - apply(diagnostics, in: controller) - } - func clear(in controller: TextViewController?) { pendingTask?.cancel() pendingTask = nil @@ -112,8 +104,8 @@ final class QueryDiagnosticsController { private func color(for severity: QueryDiagnostic.Severity) -> NSColor { switch severity { - case .error: return ThemeEngine.shared.palette[.statusError] - case .warning: return ThemeEngine.shared.palette[.statusWarning] + case .error: return .systemRed + case .warning: return .systemOrange } } } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index adc0265432..53e3520c4d 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -26,14 +26,6 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie private static let languageServiceLengthLimit = EditorHighlighting.maxHighlightableCharacters weak var controller: TextViewController? - - /// The editor configuration carries the new colours, but an emphasis already on screen baked - /// its own into a `CAShapeLayer` that nothing else repaints. - func reapplyThemeColors() { - guard let controller else { return } - diagnosticsController.reapplyColors(in: controller) - } - private lazy var diagnosticsController = QueryDiagnosticsController( databaseType: databaseType ) diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 7d8b2a9ee2..b46ec0c1a4 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -52,7 +52,6 @@ struct SQLEditorView: View { @StateObject private var coordinator = SQLEditorCoordinator() @State private var editorConfiguration = makeConfiguration() @State private var favoritesCancellables: Set = [] - @Environment(\.colorScheme) private var colorScheme var body: some View { // Keep callbacks fresh on every parent re-render @@ -136,9 +135,6 @@ struct SQLEditorView: View { .task(id: completionProfileRequest) { await resolveCompletionProfile() } - .onChange(of: colorScheme) { _ in - editorConfiguration = Self.makeConfiguration() - } .onChange(of: settingsManager.editor) { _ in editorConfiguration = Self.makeConfiguration() } @@ -147,7 +143,6 @@ struct SQLEditorView: View { } .onReceive(AppEvents.shared.themeChanged) { _ in editorConfiguration = Self.makeConfiguration() - coordinator.reapplyThemeColors() } .onAppear { initializeEditor() diff --git a/TablePro/Views/Editor/SourceFileDiskChangeBanner.swift b/TablePro/Views/Editor/SourceFileDiskChangeBanner.swift index bc230f1685..15692cd5d7 100644 --- a/TablePro/Views/Editor/SourceFileDiskChangeBanner.swift +++ b/TablePro/Views/Editor/SourceFileDiskChangeBanner.swift @@ -6,7 +6,6 @@ import SwiftUI internal struct SourceFileDiskChangeBanner: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let fileName: String let change: SourceFileDiskChange let onReload: () -> Void @@ -16,7 +15,7 @@ internal struct SourceFileDiskChangeBanner: View { var body: some View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(themeEngine.palette.color(.statusWarning)) + .foregroundStyle(.yellow) .accessibilityHidden(true) Text(message) @@ -42,7 +41,7 @@ internal struct SourceFileDiskChangeBanner: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(themeEngine.palette.color(.statusWarning).opacity(0.12)) + .background(.yellow.opacity(0.12)) } private var message: String { diff --git a/TablePro/Views/Highlight/HighlightRulesPopover.swift b/TablePro/Views/Highlight/HighlightRulesPopover.swift index f9611f769b..108bdfafab 100644 --- a/TablePro/Views/Highlight/HighlightRulesPopover.swift +++ b/TablePro/Views/Highlight/HighlightRulesPopover.swift @@ -94,7 +94,6 @@ struct HighlightRulesPopover: View { .onMove(perform: move) } .listStyle(.plain) - .scrollContentBackground(.hidden) .frame(height: min(CGFloat(rules.count) * Self.rowHeight + 8, Self.maximumListHeight)) } diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index 1bf7ee69e6..b344efab4b 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -84,7 +84,6 @@ struct ImportDialog: View { footerView } - .background(Color(nsColor: .windowBackgroundColor)) .background { WindowAccessor { window in hostWindow = window diff --git a/TablePro/Views/Import/ImportProgressView.swift b/TablePro/Views/Import/ImportProgressView.swift index 8dc4b63366..0d90b34d0d 100644 --- a/TablePro/Views/Import/ImportProgressView.swift +++ b/TablePro/Views/Import/ImportProgressView.swift @@ -51,7 +51,6 @@ struct ImportProgressView: View { } .padding(24) .frame(minWidth: 500) - .background(Color(nsColor: .windowBackgroundColor)) .onExitCommand { showStopConfirmation = true } .alert(String(localized: "Stop Import?"), isPresented: $showStopConfirmation) { Button(String(localized: "Continue"), role: .cancel) {} diff --git a/TablePro/Views/Import/SQLCodePreview.swift b/TablePro/Views/Import/SQLCodePreview.swift index 6cb912bdfd..c332e6590f 100644 --- a/TablePro/Views/Import/SQLCodePreview.swift +++ b/TablePro/Views/Import/SQLCodePreview.swift @@ -12,30 +12,21 @@ import TableProGrammars /// Read-only SQL code preview with syntax highlighting powered by TableProEditorKit struct SQLCodePreview: View { @ObservedObject private var settingsManager = AppSettingsManager.shared - @ObservedObject private var themeEngine = ThemeEngine.shared @Binding var text: String @State private var editorState = SourceEditorState() @State private var editorConfiguration = makeConfiguration() - @Environment(\.colorScheme) private var colorScheme var body: some View { - if text.isEmpty { - ThemeEngine.shared.palette.color(.editorBackground) - } else { - SourceEditor( - $text, - language: .sql, - configuration: editorConfiguration, - state: $editorState, - foldProvider: FoldProviderResolver.provider(for: CodeLanguage.sql) - ) - .onChange(of: colorScheme) { _ in - editorConfiguration = Self.makeConfiguration() - } - .onReceive(AppEvents.shared.themeChanged) { _ in - editorConfiguration = Self.makeConfiguration() - } + SourceEditor( + $text, + language: .sql, + configuration: editorConfiguration, + state: $editorState, + foldProvider: FoldProviderResolver.provider(for: CodeLanguage.sql) + ) + .onReceive(AppEvents.shared.themeChanged) { _ in + editorConfiguration = Self.makeConfiguration() } } diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index 3c142f4d3c..74489347b3 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -575,7 +575,7 @@ private extension View { Circle() .fill(Color(nsColor: .controlColor)) .shadow( - color: .black.opacity(isLightAppearance ? 0.12 : 0), + color: Color(nsColor: .shadowColor).opacity(isLightAppearance ? 0.12 : 0), radius: isLightAppearance ? 1 : 0, y: isLightAppearance ? 0.5 : 0 ) diff --git a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift index 531870fb55..ef3028c3a8 100644 --- a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift +++ b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift @@ -176,7 +176,7 @@ struct ObjectSourceTabView: View { } .padding(.horizontal) .padding(.vertical, 8) - .background(Color(nsColor: .controlBackgroundColor)) + .background(.bar) } @ViewBuilder diff --git a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift index 715de0334d..8470242aa3 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsActivityChart.swift @@ -2,7 +2,6 @@ import Charts import SwiftUI struct QueryInsightsActivityChart: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let buckets: [QueryInsightsActivityBucket] let granularity: QueryInsightsGranularity @@ -54,7 +53,7 @@ struct QueryInsightsActivityChart: View { } .chartForegroundStyleScale([ Outcome.succeeded: Color.accentColor, - Outcome.failed: themeEngine.palette.color(.statusWarning), + Outcome.failed: Color.orange, ]) .chartLegend(position: .top, alignment: .trailing, spacing: 8) .chartYAxis { diff --git a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift index 4ac5220cbb..8cdd2d1e60 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift @@ -9,7 +9,6 @@ enum QueryInsightsGroupMetric { } struct QueryInsightsGroupList: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let groups: [QueryInsightsGroup] let metric: QueryInsightsGroupMetric let onCopy: (QueryInsightsGroup) -> Void @@ -49,7 +48,7 @@ struct QueryInsightsGroupList: View { if let error = group.latestErrorMessage, case .failures = metric { RevealedTextView(error) .font(.caption) - .foregroundStyle(themeEngine.palette.color(.statusWarning)) + .foregroundStyle(.orange) .lineLimit(2) } } @@ -87,7 +86,7 @@ struct QueryInsightsGroupList: View { private func headlineTint(_ group: QueryInsightsGroup) -> Color { switch metric { - case .failures: return themeEngine.palette.color(.statusWarning) + case .failures: return .orange case .duration: return .primary case .callCount: return .primary } @@ -137,7 +136,6 @@ struct QueryInsightsGroupList: View { } struct QueryInsightsRegressionList: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let regressions: [QueryInsightsRegression] let onCopy: (String) -> Void let onLoadInEditor: (String) -> Void @@ -162,7 +160,7 @@ struct QueryInsightsRegressionList: View { .font(.system(.callout, design: .monospaced)) .fontWeight(.medium) .monospacedDigit() - .foregroundStyle(themeEngine.palette.color(.statusWarning)) + .foregroundStyle(.orange) .frame(minWidth: 76, alignment: .trailing) VStack(alignment: .leading, spacing: 3) { diff --git a/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift b/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift index a33d3cbe27..24f099283c 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsSummaryBar.swift @@ -1,7 +1,6 @@ import SwiftUI struct QueryInsightsSummaryBar: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let totals: QueryInsightsTotals var body: some View { @@ -31,7 +30,7 @@ struct QueryInsightsSummaryBar: View { totals.totalCount.formatted() ), systemImage: "exclamationmark.triangle", - tint: totals.failedCount > 0 ? themeEngine.palette.color(.statusWarning) : .secondary + tint: totals.failedCount > 0 ? .orange : .secondary ) metric( label: String(localized: "Average"), diff --git a/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift b/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift index 54882a5645..ec3ddfad92 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsToolbar.swift @@ -31,7 +31,7 @@ struct QueryInsightsToolbar: View { Divider() } - .background(Color(nsColor: .controlBackgroundColor)) + .background(.bar) } private var scopePicker: some View { diff --git a/TablePro/Views/Results/ArrayValueEditorView.swift b/TablePro/Views/Results/ArrayValueEditorView.swift index f40d0e2255..26b1a9e420 100644 --- a/TablePro/Views/Results/ArrayValueEditorView.swift +++ b/TablePro/Views/Results/ArrayValueEditorView.swift @@ -187,7 +187,7 @@ struct ArrayValueEditorView: View { .pickerStyle(.menu) if ArrayValueEditorModel.isDriftedValue(row.element, allowedValues: allowedValues) { Image(systemName: "exclamationmark.triangle") - .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) + .foregroundStyle(.orange) .help(Text("This value is not one of the type's current labels")) .accessibilityLabel(Text("This value is not one of the type's current labels")) } diff --git a/TablePro/Views/Results/CellImagePreviewView.swift b/TablePro/Views/Results/CellImagePreviewView.swift index 96f626f08a..0ce0049284 100644 --- a/TablePro/Views/Results/CellImagePreviewView.swift +++ b/TablePro/Views/Results/CellImagePreviewView.swift @@ -106,7 +106,7 @@ private struct CheckerboardBackground: View { var body: some View { Canvas { context, size in - context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.white)) + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(Color(nsColor: .textBackgroundColor))) let columns = Int((size.width / squareSize).rounded(.up)) let rows = Int((size.height / squareSize).rounded(.up)) for row in 0.. NSColor { switch self { case .foreignKeyNormal, .chevronNormal: return .secondaryLabelColor case .foreignKeyEmphasized, .chevronEmphasized: - return .alternateSelectedControlTextColor + return selectionText case .chevronDisabled: return .tertiaryLabelColor } @@ -79,26 +80,34 @@ enum DataGridCellAccessoryGlyph { } private static var glyphs: [Key: Glyph] = [:] + private static var glyphsRevision: Int? /// Rasterizing resolves the dynamic symbol color, so a cached bitmap belongs to exactly one - /// appearance. Keying on the appearance is what keeps a dark window from being served the - /// light bitmap, and `NSAppearance.currentDrawing()` only reports the drawing appearance while - /// AppKit is inside `draw(_:)`, `updateLayer` or `layout`. + /// appearance and one theme revision. Keying on the appearance is what keeps a dark window from + /// being served the light bitmap, and `NSAppearance.currentDrawing()` only reports the drawing + /// appearance while AppKit is inside `draw(_:)`, `updateLayer` or `layout`. static func image(for role: Role) -> Glyph? { + let engine = ThemeEngine.shared + if glyphsRevision != engine.revision { + glyphs.removeAll() + glyphsRevision = engine.revision + } let key = Key( role: role, appearance: NSAppearance.currentDrawing().name, increasedContrast: NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast ) if let cached = glyphs[key] { return cached } - guard let glyph = make(role) else { return nil } + guard let glyph = make(role, color: role.color(selectionText: engine.resolved[.gridSelectionText])) else { + return nil + } glyphs[key] = glyph return glyph } - private static func make(_ role: Role) -> Glyph? { + private static func make(_ role: Role, color: NSColor) -> Glyph? { let config = NSImage.SymbolConfiguration(pointSize: role.pointSize, weight: .regular) - .applying(.init(hierarchicalColor: role.color)) + .applying(.init(hierarchicalColor: color)) guard let image = NSImage(systemSymbolName: role.symbolName, accessibilityDescription: nil)? .withSymbolConfiguration(config) else { return nil } var rect = CGRect(origin: .zero, size: image.size) diff --git a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift index b8f02d5d90..408913fcc5 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift @@ -28,6 +28,7 @@ struct DataGridCellAppearance: Equatable { let drawsFocusBorder: Bool /// The cell cursor on an unselected row. let drawsFocusRing: Bool + let focusColor: NSColor let checkboxMark: DataGridCheckboxMark? static func resolve( @@ -68,17 +69,16 @@ struct DataGridCellAppearance: Equatable { stateTint = highlightColor?.washColor } - // A find match keeps its own highlight whatever else is true, and the text turns black - // against it. Otherwise a selected row's text takes the selection's own colour, and the - // modified or highlight tint stands down so the selection fill is not painted over. + // The modified or highlight tint stands down on a selected row so the selection fill is + // not painted over; a find match keeps its own highlight whatever else is true. let backgroundTint: NSColor? let textColor: NSColor if let findTint { backgroundTint = findTint - textColor = .black + textColor = palette.findMatchText } else if onEmphasizedSelection { backgroundTint = nil - textColor = .alternateSelectedControlTextColor + textColor = palette.selectionText } else { backgroundTint = stateTint textColor = baseColor @@ -109,6 +109,7 @@ struct DataGridCellAppearance: Equatable { ), drawsFocusBorder: isCursorVisible && onEmphasizedSelection, drawsFocusRing: isCursorVisible && !onEmphasizedSelection, + focusColor: onEmphasizedSelection ? palette.selectionText : palette.focusRing, checkboxMark: kind == .checkbox ? checkboxMark : nil ) } diff --git a/TablePro/Views/Results/Cells/DataGridCellPalette.swift b/TablePro/Views/Results/Cells/DataGridCellPalette.swift index ee04d69fc7..1acae469dc 100644 --- a/TablePro/Views/Results/Cells/DataGridCellPalette.swift +++ b/TablePro/Views/Results/Cells/DataGridCellPalette.swift @@ -18,6 +18,41 @@ struct DataGridCellPalette: Equatable { let deletedRowText: NSColor let modifiedColumnTint: NSColor let findMatchTint: NSColor + let findMatchText: NSColor + let selectionText: NSColor + let focusRing: NSColor + + init( + regularFont: NSFont, + italicFont: NSFont, + mediumFont: NSFont, + text: NSColor, + placeholderText: NSColor, + booleanTrueText: NSColor, + booleanFalseText: NSColor, + rowNumberText: NSColor, + deletedRowText: NSColor, + modifiedColumnTint: NSColor, + findMatchTint: NSColor, + findMatchText: NSColor = DataGridCellPalette.findMatchTextColor, + selectionText: NSColor = .alternateSelectedControlTextColor, + focusRing: NSColor = .keyboardFocusIndicatorColor + ) { + self.regularFont = regularFont + self.italicFont = italicFont + self.mediumFont = mediumFont + self.text = text + self.placeholderText = placeholderText + self.booleanTrueText = booleanTrueText + self.booleanFalseText = booleanFalseText + self.rowNumberText = rowNumberText + self.deletedRowText = deletedRowText + self.modifiedColumnTint = modifiedColumnTint + self.findMatchTint = findMatchTint + self.findMatchText = findMatchText + self.selectionText = selectionText + self.focusRing = focusRing + } static let placeholder = DataGridCellPalette( regularFont: .systemFont(ofSize: NSFont.systemFontSize), @@ -32,10 +67,28 @@ struct DataGridCellPalette: Equatable { modifiedColumnTint: .systemYellow, findMatchTint: .findHighlightColor ) + + /// `NSTextFinder` draws its find indicator as a light bubble in every appearance, so the text on + /// `findHighlightColor` is the light appearance's text color. + static let findMatchTextColor: NSColor = { + var color = NSColor.textColor + NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { + color = NSColor(cgColor: NSColor.textColor.cgColor) ?? .textColor + } + return color + }() + + func rowNumberColor(for visualState: RowVisualState, onEmphasizedSelection: Bool) -> NSColor { + if onEmphasizedSelection { return selectionText } + return visualState.isDeleted ? deletedRowText : rowNumberText + } } extension ThemeEngine { - var dataGridCellPalette: DataGridCellPalette { + /// The row-state tints are dynamic providers, which cost four times a catalog color on every + /// fill and `CTLineDraw`, so they are resolved here once in the appearance the grid draws in. + /// The catalog colors stay dynamic and resolve at draw time. + func dataGridCellPalette(in appearance: NSAppearance) -> DataGridCellPalette { DataGridCellPalette( regularFont: dataGridFonts.regular, italicFont: dataGridFonts.italic, @@ -45,9 +98,16 @@ extension ThemeEngine { booleanTrueText: resolved[.gridBoolTrue], booleanFalseText: resolved[.gridBoolFalse], rowNumberText: resolved[.gridRowNumber], - deletedRowText: resolved[.gridDeletedText], - modifiedColumnTint: resolved[.gridModified], - findMatchTint: .findHighlightColor + deletedRowText: resolvedTint(.gridDeletedText, in: appearance), + modifiedColumnTint: resolvedTint(.gridModified, in: appearance), + findMatchTint: .findHighlightColor, + selectionText: resolved[.gridSelectionText], + focusRing: resolved[.gridFocusBorder] ) } + + private func resolvedTint(_ slot: ThemeSlot, in appearance: NSAppearance) -> NSColor { + guard !resolved.isOverridden(slot) else { return resolved[slot] } + return NSColor(cgColor: resolved.cgColor(slot, in: appearance)) ?? resolved[slot] + } } diff --git a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift index b88e6796bb..9deca9373f 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -13,26 +13,42 @@ final class DataGridCellRegistry { private(set) var nullDisplayString: String private(set) var palette: DataGridCellPalette + private var paletteKey: PaletteKey private var settingsCancellable: AnyCancellable? - private var themeCancellable: AnyCancellable? private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCellView") + private struct PaletteKey: Equatable { + let revision: Int + let generation: Int + let appearance: NSAppearance.Name + } + init() { nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay - palette = ThemeEngine.shared.dataGridCellPalette + let appearance = NSApp?.effectiveAppearance ?? NSAppearance.currentDrawing() + paletteKey = Self.paletteKey(for: appearance) + palette = ThemeEngine.shared.dataGridCellPalette(in: appearance) settingsCancellable = AppEvents.shared.dataGridSettingsChanged .receive(on: RunLoop.main) .sink { [weak self] _ in self?.nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay } - themeCancellable = AppEvents.shared.themeChanged - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.palette = ThemeEngine.shared.dataGridCellPalette - } } + /// Asked by every row as it draws: the tints resolve in the appearance the row draws in, which + /// only the draw knows, and the key makes the call a comparison until the theme or it moves. + func preparePalette(in appearance: NSAppearance) { + let key = Self.paletteKey(for: appearance) + guard key != paletteKey else { return } + paletteKey = key + palette = ThemeEngine.shared.dataGridCellPalette(in: appearance) + } + + private static func paletteKey(for appearance: NSAppearance) -> PaletteKey { + let change = ThemeEngine.shared.change + return PaletteKey(revision: change.revision, generation: change.generation, appearance: appearance.name) + } func makeRowNumberCell( in tableView: NSTableView, @@ -91,7 +107,7 @@ final class DataGridCellRegistry { } func rowNumberColor(for visualState: RowVisualState) -> NSColor { - visualState.isDeleted ? palette.deletedRowText : palette.rowNumberText + palette.rowNumberColor(for: visualState, onEmphasizedSelection: false) } } diff --git a/TablePro/Views/Results/Cells/DataGridCellRenderer.swift b/TablePro/Views/Results/Cells/DataGridCellRenderer.swift index 92f20ccca4..80a2a12962 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRenderer.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRenderer.swift @@ -65,10 +65,8 @@ final class DataGridCellRenderer { } NSGraphicsContext.current?.restoreGraphicsState() - if appearance.drawsFocusBorder { - drawFocusBorder(in: rect) - } else if appearance.drawsFocusRing { - drawFocusRing(in: rect) + if appearance.drawsFocusBorder || appearance.drawsFocusRing { + drawCursor(in: rect, color: appearance.focusColor) } } @@ -147,19 +145,12 @@ final class DataGridCellRenderer { checkboxCell.draw(withFrame: frame, in: controlView) } - private func drawFocusBorder(in rect: NSRect) { + /// A mounted cell got its cursor from AppKit's exterior focus ring; a drawn cell has no view to + /// hang one on, so it is drawn to the same shape. + private func drawCursor(in rect: NSRect, color: NSColor) { let path = NSBezierPath(rect: rect.insetBy(dx: 1, dy: 1)) path.lineWidth = 2 - NSColor.alternateSelectedControlTextColor.setStroke() - path.stroke() - } - - /// The cell cursor on an unselected row. A mounted cell got this from AppKit's exterior focus - /// ring; a drawn cell has no view to hang one on, so it is drawn to the same shape. - private func drawFocusRing(in rect: NSRect) { - let path = NSBezierPath(rect: rect.insetBy(dx: 1, dy: 1)) - path.lineWidth = 2 - ThemeEngine.shared.palette[.gridFocusBorder].setStroke() + color.setStroke() path.stroke() } } diff --git a/TablePro/Views/Results/ColumnCheckList.swift b/TablePro/Views/Results/ColumnCheckList.swift index d64f2ed25c..016f8fe3fa 100644 --- a/TablePro/Views/Results/ColumnCheckList.swift +++ b/TablePro/Views/Results/ColumnCheckList.swift @@ -66,7 +66,6 @@ struct ColumnCheckList: View { } } .listStyle(.plain) - .scrollContentBackground(.hidden) .frame(minHeight: listMinHeight, maxHeight: listMaxHeight) } diff --git a/TablePro/Views/Results/ColumnValueFilterPopover.swift b/TablePro/Views/Results/ColumnValueFilterPopover.swift index ae9db9793d..142be69151 100644 --- a/TablePro/Views/Results/ColumnValueFilterPopover.swift +++ b/TablePro/Views/Results/ColumnValueFilterPopover.swift @@ -6,7 +6,6 @@ import SwiftUI struct ColumnValueFilterPopover: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let columnName: String let loadedRowCount: Int let onApply: (ColumnValueFilter?) -> Void @@ -102,7 +101,7 @@ struct ColumnValueFilterPopover: View { Text(value.label) .lineLimit(1) .truncationMode(.tail) - .foregroundStyle(value.isNull ? themeEngine.palette.color(.gridNullValue) : Color.primary) + .foregroundStyle(value.isNull ? Color.secondary : Color.primary) Spacer(minLength: 8) Text("\(value.count)") .font(.callout.monospacedDigit()) @@ -114,7 +113,6 @@ struct ColumnValueFilterPopover: View { } } .listStyle(.plain) - .scrollContentBackground(.hidden) .frame(height: 200) } diff --git a/TablePro/Views/Results/DataGridBodyChrome.swift b/TablePro/Views/Results/DataGridBodyChrome.swift index 7e417dea94..159abf0a5c 100644 --- a/TablePro/Views/Results/DataGridBodyChrome.swift +++ b/TablePro/Views/Results/DataGridBodyChrome.swift @@ -114,15 +114,20 @@ enum DataGridBodyChrome { return bands } - /// The theme's background and grid line, set on the table rather than read from the theme at - /// each draw: AppKit reads the table's own `backgroundColor` for the area an elastic scroll - /// uncovers, and the separators draw in its `gridColor`. + /// Set on the table rather than read at each draw: AppKit reads the table's own + /// `backgroundColor` for the area an elastic scroll uncovers, and the separators draw in its + /// `gridColor`. A theme that declares neither leaves both at the table's own defaults, + /// `controlBackgroundColor` and `separatorColor`. static func applyThemeColors(to tableView: NSTableView) { - let palette = ThemeEngine.shared.palette - tableView.gridColor = palette[.gridLine] - let background = palette[.gridBackground] - guard tableView.backgroundColor != background else { return } - tableView.backgroundColor = background + let resolved = ThemeEngine.shared.resolved + let gridColor = resolved[.gridLine] + if tableView.gridColor != gridColor { + tableView.gridColor = gridColor + } + let background = resolved[.gridBackground] + if tableView.backgroundColor != background { + tableView.backgroundColor = background + } } /// The alternate stripe a row carries, or nil when the table does not alternate, where a row @@ -132,9 +137,29 @@ enum DataGridBodyChrome { /// which is the pair `NSTableView` itself hands its row views. static func stripeColor(forRow row: Int, of tableView: NSTableView) -> NSColor? { guard tableView.usesAlternatingRowBackgroundColors else { return nil } - return row.isMultiple(of: 2) - ? ThemeEngine.shared.palette[.gridBackground] - : ThemeEngine.shared.palette[.gridAlternateRow] + let resolved = ThemeEngine.shared.resolved + return row.isMultiple(of: 2) ? resolved[.gridBackground] : resolved[.gridAlternateRow] + } + + // MARK: - Selection + + static func selectionSlot(emphasized: Bool) -> ThemeSlot { + emphasized ? .gridSelection : .gridInactiveSelection + } + + /// What a selected row shows, for the surfaces that paint it themselves: the pinned row gutter + /// and a selected column's heading. Without a theme override it is the color `NSTableRowView` + /// fills with, so they match the rows AppKit draws. + static func selectionColor(emphasized: Bool) -> NSColor { + ThemeEngine.shared.resolved[selectionSlot(emphasized: emphasized)] + } + + /// Nil unless the theme declares its own selection; a row then leaves the selection to + /// `NSTableRowView`, which follows Accent and focus. + static func themedSelectionColor(emphasized: Bool) -> NSColor? { + let resolved = ThemeEngine.shared.resolved + let slot = selectionSlot(emphasized: emphasized) + return resolved.isOverridden(slot) ? resolved[slot] : nil } /// What a row view lays down before its tint and selection: its stripe, or the table's own diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 694300528f..b6e91bd44f 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -634,20 +634,40 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func observeThemeChanges() { themeCancellable = AppEvents.shared.themeChanged .receive(on: RunLoop.main) - .sink { [weak self] _ in - if let tableView = self?.tableView { - DataGridBodyChrome.applyThemeColors(to: tableView) - tableView.headerView?.needsDisplay = true - tableView.cornerView?.needsDisplay = true - } - self?.reloadVisibleRowsAndStates() - /// The row-number font is a theme value and it decides the column's width, which - /// the pinned gutter mirrors. Nothing re-measured it on a theme change before, so - /// the width was already going stale here. - self?.resizeRowNumberColumnForCurrentRange() - self?.repaintRowGutter() - self?.selectionController.overlay?.needsDisplay = true - } + .sink { [weak self] _ in self?.themeDidChange() } + } + + /// `themeChanged` also fires for an Accent, Highlight or Increase Contrast change, and none of + /// them moves a value, so this repaints what is on screen and never reloads or refetches. + func themeDidChange() { + guard let tableView else { return } + cellRegistry.preparePalette(in: tableView.effectiveAppearance) + DataGridBodyChrome.applyThemeColors(to: tableView) + tableView.headerView?.needsDisplay = true + tableView.cornerView?.needsDisplay = true + restyleRowNumberCells(in: tableView) + tableView.enumerateAvailableRowViews { rowView, _ in + rowView.needsDisplay = true + (rowView as? DataGridRowView)?.redrawCells() + } + // The row-number font decides the column's width, which the pinned gutter mirrors. + resizeRowNumberColumnForCurrentRange() + repaintRowGutter() + selectionController.overlay?.needsDisplay = true + } + + /// The mounted row-number cells carry the number for accessibility and a row drag image, and + /// take their font and color when mounted, so the ones already on screen are restyled in place. + private func restyleRowNumberCells(in tableView: NSTableView) { + let column = tableView.column(withIdentifier: ColumnIdentitySchema.rowNumberIdentifier) + guard column >= 0 else { return } + let font = ThemeEngine.shared.dataGridFonts.rowNumber + tableView.enumerateAvailableRowViews { _, row in + guard let field = (tableView.view(atColumn: column, row: row, makeIfNecessary: false) as? NSTableCellView)? + .textField else { return } + field.font = font + field.textColor = cellRegistry.rowNumberColor(for: visualState(for: row)) + } } /// The grid mounts no view for a data cell, so a client that attaches mid-session finds a table diff --git a/TablePro/Views/Results/DataGridRowGutterView.swift b/TablePro/Views/Results/DataGridRowGutterView.swift index f0d26e5597..ef89460ad0 100644 --- a/TablePro/Views/Results/DataGridRowGutterView.swift +++ b/TablePro/Views/Results/DataGridRowGutterView.swift @@ -135,6 +135,8 @@ final class DataGridRowGutterView: NSView { ) let font = ThemeEngine.shared.dataGridFonts.rowNumber let pageOffset = coordinator.paginationOffsetProvider() + coordinator.cellRegistry.preparePalette(in: NSAppearance.currentDrawing()) + let palette = coordinator.cellRegistry.palette for band in DataGridBodyChrome.rowBands(in: dirtyRect, of: self, tableView: tableView) { let stripRect = NSRect(x: 0, y: band.rect.minY, width: bounds.width, height: band.rect.height) @@ -153,7 +155,7 @@ final class DataGridRowGutterView: NSView { band.row + pageOffset + 1, in: NSRect(x: cellFrame.minX, y: stripRect.minY, width: cellFrame.width, height: stripRect.height), font: font, - color: numberColor(isSelected: isSelected, emphasized: emphasized, state: state, coordinator: coordinator) + color: palette.rowNumberColor(for: state, onEmphasizedSelection: isSelected && emphasized) ) } @@ -161,9 +163,8 @@ final class DataGridRowGutterView: NSView { } /// What the row under the strip paints, in its order, for `DataGridBodyChrome.fill` to blend over - /// the table's background: `NSTableRowView`'s stripe, then either the selection a `.plain` table - /// with the regular highlight draws, or the tint `DataGridRowView` gives an unselected row. A - /// band past the last row has the stripe alone. + /// the table's background: the row's stripe, then either its selection or the tint + /// `DataGridRowView` gives an unselected row. A band past the last row has the stripe alone. private func rowLayers( row: Int, isSelected: Bool, @@ -176,23 +177,13 @@ final class DataGridRowGutterView: NSView { layers.append(stripe) } if isSelected { - layers.append(emphasized ? .selectedContentBackgroundColor : .unemphasizedSelectedContentBackgroundColor) + layers.append(DataGridBodyChrome.selectionColor(emphasized: emphasized)) } else if let tint = state.tint { layers.append(tint) } return layers } - private func numberColor( - isSelected: Bool, - emphasized: Bool, - state: RowVisualState, - coordinator: TableViewCoordinator - ) -> NSColor { - if isSelected, emphasized { return .alternateSelectedControlTextColor } - return coordinator.cellRegistry.rowNumberColor(for: state) - } - /// Right-aligned inside the cell frame AppKit gives the mounted row-number cell, with the insets /// that cell uses, so the two renderings land on the same pixels at scroll offset zero. private func drawNumber(_ number: Int, in rect: NSRect, font: NSFont, color: NSColor) { diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index f1bf49954b..d08fbb9b49 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -154,15 +154,32 @@ class DataGridRowView: NSTableRowView { /// The columns are still real `NSTableColumn`s, so AppKit answers which of them the area covers /// and where each one sits; only the cell content is drawn rather than mounted. func drawCells(in dirtyRect: NSRect, of view: NSView) { - guard let tableView = coordinator?.tableView else { return } + guard let coordinator, let tableView = coordinator.tableView else { return } + coordinator.cellRegistry.preparePalette(in: NSAppearance.currentDrawing()) let inTableView = view.convert(dirtyRect, to: tableView) let dragged = Self.draggedColumnIndex(of: tableView) + let themedRange = themedRangeColumns() for tableColumnIndex in tableView.columnIndexes(in: inTableView) where tableColumnIndex != dragged { - drawCell(atTableColumnIndex: tableColumnIndex, in: tableView.rect(ofColumn: tableColumnIndex), of: view) + drawCell( + atTableColumnIndex: tableColumnIndex, + in: tableView.rect(ofColumn: tableColumnIndex), + of: view, + onThemedRange: themedRange.contains(tableColumnIndex) + ) } } + /// The columns of an unselected row that a cell range covers with a theme's opaque selection, + /// whose text has to switch to the theme's selection text like a selected row's does. + func themedRangeColumns() -> Set { + guard !isSelected, isEmphasized, + DataGridBodyChrome.themedSelectionColor(emphasized: true) != nil, + let coordinator else { return [] } + let columns = coordinator.selectionController.selection.columns(in: rowIndex) + return Set(columns.compactMap { coordinator.tableColumnIndex(forDisplayPosition: $0) }) + } + /// Draws the column a header drag is moving at the pointer, the way `NSTableView` floats a dragged /// column's cell views: over its neighbours and the separators, its own slot left empty. /// @@ -194,14 +211,19 @@ class DataGridRowView: NSTableRowView { return dragged } - private func drawCell(atTableColumnIndex tableColumnIndex: Int, in rectInTable: NSRect, of view: NSView) { + private func drawCell( + atTableColumnIndex tableColumnIndex: Int, + in rectInTable: NSRect, + of view: NSView, + onThemedRange: Bool = false + ) { guard let coordinator, let tableView = coordinator.tableView, tableColumnIndex < tableView.tableColumns.count, let dataColumn = coordinator.dataColumnIndex(from: tableView.tableColumns[tableColumnIndex].identifier), let appearance = coordinator.cellAppearance( row: rowIndex, columnIndex: dataColumn, - onEmphasizedSelection: isSelected && isEmphasized + onEmphasizedSelection: (isSelected && isEmphasized) || onThemedRange ) else { return } let columnRect = view.convert(rectInTable, from: tableView) @@ -289,12 +311,13 @@ class DataGridRowView: NSTableRowView { dirtyRect.fill() } - /// `NSTableRowView` fills a selected row with the system's selection colour and offers no way - /// to change it, so the theme's own selection is painted here instead. `super` is not called: - /// its fill would sit on top of this one. override func drawSelection(in dirtyRect: NSRect) { guard isSelected else { return } - cellSelectionFill.setFill() + guard let themed = DataGridBodyChrome.themedSelectionColor(emphasized: isEmphasized) else { + super.drawSelection(in: dirtyRect) + return + } + themed.setFill() dirtyRect.fill() } @@ -323,12 +346,13 @@ class DataGridRowView: NSTableRowView { /// `unemphasizedSelectedContentBackgroundColor` is a background colour and is used as one, the /// way AppKit fills a selection in a view that does not hold focus. Thinning it out instead /// left the range at 1.09:1 against a white grid, which is no visible selection at all. The - /// emphasized accent is far too dark to sit behind text these rows do not recolour, so that - /// one goes on as a tint. + /// system's emphasized accent is far too dark to sit behind text these rows do not recolour, so + /// that one goes on as a tint; a theme's selection is chosen to sit behind its own text. private var cellSelectionFill: NSColor { - let palette = ThemeEngine.shared.palette - guard isEmphasized else { return palette[.gridInactiveSelection] } - return palette[.gridSelection].withAlphaComponent(Self.emphasizedCellSelectionAlpha) + let resolved = ThemeEngine.shared.resolved + let slot = DataGridBodyChrome.selectionSlot(emphasized: isEmphasized) + guard isEmphasized, !resolved.isOverridden(slot) else { return resolved[slot] } + return resolved[slot].withAlphaComponent(Self.emphasizedCellSelectionAlpha) } private static let emphasizedCellSelectionAlpha: CGFloat = 0.28 diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 7b1313c660..8d260cd95b 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -70,8 +70,8 @@ extension RowVisualState { /// The wash a row in this state carries, read by the row and by the pinned row gutter over it so /// the two cannot disagree. @MainActor var tint: NSColor? { - if isDeleted { return ThemeEngine.shared.palette[.gridDeleted] } - if isInserted { return ThemeEngine.shared.palette[.gridInserted] } + if isDeleted { return ThemeEngine.shared.resolved[.gridDeleted] } + if isInserted { return ThemeEngine.shared.resolved[.gridInserted] } return highlight.rowColor?.washColor } diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift index 25fed2b5b2..ee1e6910c0 100644 --- a/TablePro/Views/Results/ForeignKeyPickerView.swift +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -142,7 +142,7 @@ struct ForeignKeyPickerView: View { private var content: some View { if let errorMessage { RevealedTextView(errorMessage) - .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) + .foregroundStyle(.red) .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) @@ -189,7 +189,6 @@ struct ForeignKeyPickerView: View { .accessibilityAction { commit(entry) } } .listStyle(.plain) - .scrollContentBackground(.hidden) .frame(height: Self.listHeight) .onChange(of: selection) { newValue in guard let newValue else { return } diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index 9872660233..d836ab5156 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -105,7 +105,7 @@ struct ForeignKeyPreviewView: View { .frame(height: 60) } else if let errorMessage { RevealedTextView(errorMessage) - .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) + .foregroundStyle(.red) .font(.callout) .padding(10) } else if values.isEmpty { diff --git a/TablePro/Views/Results/HexEditorContentView.swift b/TablePro/Views/Results/HexEditorContentView.swift index 2db016cb90..6638db272f 100644 --- a/TablePro/Views/Results/HexEditorContentView.swift +++ b/TablePro/Views/Results/HexEditorContentView.swift @@ -100,11 +100,11 @@ struct HexEditorBody: View { if sourceIsTruncated || isTruncated { Text(String(localized: "Truncated, read only")) .font(.caption) - .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) + .foregroundStyle(.orange) } else if !isValid, !editableHex.isEmpty { Text(String(localized: "Invalid hex")) .font(.caption) - .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) + .foregroundStyle(.red) } Spacer() @@ -228,8 +228,8 @@ private struct HexDumpDisplayView: NSViewRepresentable { textView.isSelectable = true textView.font = font textView.textContainerInset = NSSize(width: 8, height: 8) - textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] - textView.textColor = ThemeEngine.shared.palette[.panelSecondaryText] + textView.backgroundColor = NSColor.textBackgroundColor + textView.textColor = NSColor.secondaryLabelColor textView.string = text return scrollView @@ -240,8 +240,6 @@ private struct HexDumpDisplayView: NSViewRepresentable { if textView.font != font { textView.font = font } - textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] - textView.textColor = ThemeEngine.shared.palette[.panelSecondaryText] if textView.string != text { textView.string = text } @@ -268,8 +266,8 @@ private struct HexInputTextView: NSViewRepresentable { textView.isSelectable = true textView.font = font textView.textContainerInset = NSSize(width: 8, height: 8) - textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] - textView.textColor = ThemeEngine.shared.palette[.panelText] + textView.backgroundColor = NSColor.textBackgroundColor + textView.textColor = NSColor.labelColor textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false @@ -291,8 +289,6 @@ private struct HexInputTextView: NSViewRepresentable { if textView.font != font { textView.font = font } - textView.backgroundColor = ThemeEngine.shared.palette[.panelControlBackground] - textView.textColor = ThemeEngine.shared.palette[.panelText] if textView.string != text, !context.coordinator.isUpdating { textView.string = text } diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index 64cb55a6ce..510ea60822 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -9,7 +9,6 @@ import AppKit import SwiftUI struct InlineErrorBanner: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let message: String var onFixWithAI: (() -> Void)? var onDismiss: (() -> Void)? @@ -22,7 +21,7 @@ struct InlineErrorBanner: View { var body: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(themeEngine.palette.color(.statusError)) + .foregroundStyle(.red) ScrollView(.vertical) { RevealedTextView(message) .font(.subheadline) @@ -64,7 +63,7 @@ struct InlineErrorBanner: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(themeEngine.palette.color(.statusError).opacity(0.08)) + .background(.red.opacity(0.08)) } } diff --git a/TablePro/Views/Results/JSONCodeEditor.swift b/TablePro/Views/Results/JSONCodeEditor.swift index fd93598f40..3a72b81db7 100644 --- a/TablePro/Views/Results/JSONCodeEditor.swift +++ b/TablePro/Views/Results/JSONCodeEditor.swift @@ -23,7 +23,6 @@ internal struct JSONCodeEditor: View { /// text view itself, which is where an accessibility identifier has to land: the SwiftUI /// modifier names the representable and never reaches it. @State private var coordinators: [any TextViewCoordinator] - @Environment(\.colorScheme) private var colorScheme init(text: Binding, isEditable: Bool, accessibilityIdentifier: String? = nil) { self._text = text @@ -43,9 +42,6 @@ internal struct JSONCodeEditor: View { coordinators: coordinators ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: colorScheme) { _ in - rebuildConfiguration() - } .onChange(of: isEditable) { _ in rebuildConfiguration() } diff --git a/TablePro/Views/Results/ResultChartCanvas.swift b/TablePro/Views/Results/ResultChartCanvas.swift index 8ef311630a..e961fa4ce5 100644 --- a/TablePro/Views/Results/ResultChartCanvas.swift +++ b/TablePro/Views/Results/ResultChartCanvas.swift @@ -7,7 +7,6 @@ import Charts import SwiftUI struct ResultChartCanvas: View { - @ObservedObject private var themeEngine = ThemeEngine.shared /// Solid first, then patterns that stay legible in monochrome and to a colour-blind reader, /// because colour alone does not distinguish a series. private static let seriesDashPatterns: [[CGFloat]] = [ @@ -42,27 +41,27 @@ struct ResultChartCanvas: View { .chartYAxis { AxisMarks(position: .leading) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(themeEngine.palette.color(.panelSeparator).opacity(0.55)) + .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(themeEngine.palette.color(.panelSeparator)) + .foregroundStyle(Color(nsColor: .separatorColor)) AxisValueLabel() - .foregroundStyle(themeEngine.palette.color(.panelSecondaryText)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) } } .chartXAxis { AxisMarks(values: .automatic(desiredCount: 7)) { AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(themeEngine.palette.color(.panelSeparator).opacity(0.55)) + .foregroundStyle(Color(nsColor: .separatorColor).opacity(0.55)) AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) - .foregroundStyle(themeEngine.palette.color(.panelSeparator)) + .foregroundStyle(Color(nsColor: .separatorColor)) AxisValueLabel(collisionResolution: .greedy(minimumSpacing: 8)) - .foregroundStyle(themeEngine.palette.color(.panelSecondaryText)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) } } .chartPlotStyle { plotArea in plotArea .background( - themeEngine.palette.color(.panelBackground).opacity(0.45), + Color(nsColor: .controlBackgroundColor).opacity(0.45), in: .rect(cornerRadius: 8) ) } diff --git a/TablePro/Views/Results/ResultChartSelectionCallout.swift b/TablePro/Views/Results/ResultChartSelectionCallout.swift index 35c88e0e36..da85d1a3cd 100644 --- a/TablePro/Views/Results/ResultChartSelectionCallout.swift +++ b/TablePro/Views/Results/ResultChartSelectionCallout.swift @@ -63,7 +63,7 @@ struct ResultChartSelectionCallout: View { RoundedRectangle(cornerRadius: 8) .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 6, y: 2) + .shadow(color: Color(nsColor: .shadowColor).opacity(0.12), radius: 6, y: 2) .accessibilityElement(children: .combine) } diff --git a/TablePro/Views/Results/ResultChartToolbar.swift b/TablePro/Views/Results/ResultChartToolbar.swift index 3eaa09ffd3..3d7834ea3c 100644 --- a/TablePro/Views/Results/ResultChartToolbar.swift +++ b/TablePro/Views/Results/ResultChartToolbar.swift @@ -73,7 +73,7 @@ struct ResultChartToolbar: View { } .padding(.horizontal, 14) .padding(.vertical, 8) - .background(Color(nsColor: .controlBackgroundColor)) + .background(.bar) } @ViewBuilder diff --git a/TablePro/Views/Results/ResultSuccessView.swift b/TablePro/Views/Results/ResultSuccessView.swift index 4be8b13581..facd401a21 100644 --- a/TablePro/Views/Results/ResultSuccessView.swift +++ b/TablePro/Views/Results/ResultSuccessView.swift @@ -10,7 +10,6 @@ import SwiftUI import TableProPluginKit struct ResultSuccessView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let rowsAffected: Int let executionTime: TimeInterval? let statusMessage: String? @@ -62,7 +61,7 @@ struct ResultSuccessView: View { Spacer() Image(systemName: "checkmark.circle.fill") .font(.largeTitle) - .foregroundStyle(themeEngine.palette.color(.statusSuccess)) + .foregroundStyle(.green) Text(primaryMessage) .font(.body) if let time = executionTime { diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift index ba8a028eff..9d85194554 100644 --- a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -32,11 +32,17 @@ final class GridSelectionOverlay: NSView { override var acceptsFirstResponder: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } + override func draw(_ dirtyRect: NSRect) { guard let tableView, let coordinator else { return } let editingCell = activeOverlayCell(in: coordinator) - ThemeEngine.shared.palette[.gridSelection].withAlphaComponent(Self.borderAlpha).setStroke() + let resolved = ThemeEngine.shared.resolved + resolved[.gridSelection].withAlphaComponent(Self.borderAlpha).setStroke() for rect in selection.rectangles { guard let frame = frame(for: rect, in: tableView, coordinator: coordinator) else { continue } guard frame.intersects(dirtyRect) else { continue } @@ -53,7 +59,8 @@ final class GridSelectionOverlay: NSView { selection.rectangles.count > 1 || (selection.rectangles.first?.rows.count ?? 0) > 1 || (selection.rectangles.first?.columns.count ?? 0) > 1, let frame = frame(for: GridRect(cell: active), in: tableView, coordinator: coordinator), frame.intersects(dirtyRect) { - NSColor.controlAccentColor.setStroke() + let activeColor = resolved.isOverridden(.gridFocusBorder) ? resolved[.gridFocusBorder] : .controlAccentColor + activeColor.setStroke() let inset = frame.insetBy(dx: Self.activeCellBorderWidth / 2, dy: Self.activeCellBorderWidth / 2) let path = NSBezierPath(rect: inset) path.lineWidth = Self.activeCellBorderWidth diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index bd49a20b87..22d9022474 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -68,10 +68,7 @@ final class SortableHeaderCell: NSTableHeaderCell { override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { if isColumnSelected { - let fill: NSColor = isEmphasized - ? .selectedContentBackgroundColor - : .unemphasizedSelectedContentBackgroundColor - fill.setFill() + DataGridBodyChrome.selectionColor(emphasized: isEmphasized).setFill() cellFrame.fill() } @@ -173,7 +170,7 @@ final class SortableHeaderCell: NSTableHeaderCell { } private func funnelColor(active: Bool, emphasized: Bool) -> NSColor { - if emphasized { return .alternateSelectedControlTextColor } + if emphasized { return ThemeEngine.shared.resolved[.gridSelectionText] } return active ? .controlAccentColor : .secondaryLabelColor } @@ -196,14 +193,12 @@ final class SortableHeaderCell: NSTableHeaderCell { return headerView.comment(for: self) } - private func foregroundColor(emphasized: Bool) -> NSColor { - emphasized - ? ThemeEngine.shared.palette[.gridSelectionText] - : ThemeEngine.shared.palette[.gridHeaderText] + func foregroundColor(emphasized: Bool) -> NSColor { + ThemeEngine.shared.resolved[emphasized ? .gridSelectionText : .gridHeaderText] } private func commentColor(emphasized: Bool) -> NSColor { - emphasized ? .alternateSelectedControlTextColor : .secondaryLabelColor + emphasized ? ThemeEngine.shared.resolved[.gridSelectionText] : .secondaryLabelColor } private func drawTitle( diff --git a/TablePro/Views/Results/SortableHeaderChrome.swift b/TablePro/Views/Results/SortableHeaderChrome.swift index 18076f1ebc..ebca9744b8 100644 --- a/TablePro/Views/Results/SortableHeaderChrome.swift +++ b/TablePro/Views/Results/SortableHeaderChrome.swift @@ -10,13 +10,23 @@ enum SortableHeaderChrome { static let separatorThickness: CGFloat = 1 static let columnDividerHeight: CGFloat = 16 + /// The cells never draw a background of their own, so the header lays one down under + /// `NSTableHeaderView.draw`; without a theme override it is the table's own background. + static var backgroundColor: NSColor { + ThemeEngine.shared.resolved[.gridHeaderBackground] + } + + static var lineColor: NSColor { + ThemeEngine.shared.resolved[.gridLine] + } + static func fillBackground(_ rect: NSRect) { - ThemeEngine.shared.palette[.gridHeaderBackground].setFill() + backgroundColor.setFill() rect.fill() } static func drawBottomSeparator(in bounds: NSRect) { - ThemeEngine.shared.palette[.gridLine].setFill() + lineColor.setFill() NSRect( x: bounds.minX, y: bounds.maxY - separatorThickness, @@ -27,7 +37,7 @@ enum SortableHeaderChrome { static func drawColumnDivider(in cellFrame: NSRect) { let dividerHeight = min(columnDividerHeight, cellFrame.height) - ThemeEngine.shared.palette[.gridLine].setFill() + lineColor.setFill() NSRect( x: cellFrame.maxX - separatorThickness, y: cellFrame.midY - dividerHeight / 2, diff --git a/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift b/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift index a986c35790..7ad565c076 100644 --- a/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift +++ b/TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift @@ -145,7 +145,7 @@ internal final class TreeOutlineCellView: NSTableCellView, NSTextFieldDelegate { let selectedText = NSColor.alternateSelectedControlTextColor let secondary = isEmphasized ? selectedText.withAlphaComponent(0.7) : NSColor.secondaryLabelColor - keyField.textColor = isEmphasized ? selectedText : ThemeEngine.shared.palette[.syntaxKeyword] + keyField.textColor = isEmphasized ? selectedText : TreeValueTone.key.color separatorField.textColor = secondary visibilityField.textColor = secondary swatch.isOnEmphasizedRow = isEmphasized diff --git a/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift b/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift index 6942605224..faf1e9200f 100644 --- a/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift +++ b/TablePro/Views/Results/TreeOutline/TreeOutlineCoordinator.swift @@ -4,7 +4,6 @@ // import AppKit -import Combine internal struct TreeOutlineContent { let rootNode: Node @@ -37,7 +36,6 @@ internal final class TreeOutlineCoordinator: NSObject, private var fonts: TreeOutlineFonts? private var isApplyingExpansion = false private var menuRows: [Item] = [] - private var themeCancellable: AnyCancellable? internal init(cache: TreeProjectionCache) { self.cache = cache @@ -52,9 +50,6 @@ internal final class TreeOutlineCoordinator: NSObject, let menu = NSMenu() menu.delegate = self outlineView.menu = menu - // A theme swap that keeps the appearance changes no view state, so AppKit repaints nothing. - themeCancellable = AppEvents.shared.themeChanged - .sink { [weak self] _ in self?.reconfigureVisibleCells() } } // MARK: - Content diff --git a/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift b/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift index aaa4102ad3..9ad837e46d 100644 --- a/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift +++ b/TablePro/Views/Results/TreeOutline/TreeValueStyle.swift @@ -7,7 +7,6 @@ import AppKit internal enum TreeValueStyle { /// Fixed system colors do not adapt on an emphasized row the way the label colors do. - @MainActor static func textColor(for tone: TreeValueTone, isEmphasized: Bool) -> NSColor { isEmphasized ? .alternateSelectedControlTextColor : tone.color } @@ -21,7 +20,6 @@ internal enum TreeValueStyle { /// No `.link` attribute: a text field cell draws that run in the link color whatever its /// foreground, blue on the accent color. `TreeValueField` puts it in the field editor only. - @MainActor static func attributedValue( _ content: TreeRowContent, font: NSFont, diff --git a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift index 00323307c1..2db3ce9da3 100644 --- a/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift @@ -101,11 +101,11 @@ internal struct BlobHexEditorView: View { if isTruncated { Text("Truncated, read only") .font(.caption2) - .foregroundStyle(ThemeEngine.shared.palette.color(.statusWarning)) + .foregroundStyle(.orange) } else if BlobFormattingService.shared.parseHex(hexEditText) == nil, !hexEditText.isEmpty { Text("Invalid hex") .font(.caption2) - .foregroundStyle(ThemeEngine.shared.palette.color(.statusError)) + .foregroundStyle(.red) } } } diff --git a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index a6a1c7abaa..1b60064ad2 100644 --- a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -134,7 +134,7 @@ internal struct PendingStatePill: View { .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .topLeading) .padding(.horizontal, 6) .padding(.vertical, 3) - .background(ThemeEngine.shared.palette.color(.panelControlBackground), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift index 7d51c597b1..c3392981ad 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ImageFieldView.swift @@ -35,7 +35,7 @@ internal struct ImageFieldView: View { ) .frame(height: 220) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) .task(id: context.value.wrappedValue) { data = context.value.wrappedValue.storedBytes } diff --git a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift index f38155fa75..c591005f92 100644 --- a/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift @@ -6,7 +6,6 @@ import SwiftUI internal struct JsonEditorView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext var onPopOut: ((String) -> Void)? var isExpanded = false @@ -47,7 +46,7 @@ internal struct JsonEditorView: View { accessibilityIdentifier: "inspector-json-field" ) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(themeEngine.palette.color(.panelSeparator))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) .overlay(alignment: .bottomTrailing) { actionButtons } } /// `newValue`, never a re-read of `context.value.wrappedValue`. An `onChange` action diff --git a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift index e3be223a62..a895de41a3 100644 --- a/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift @@ -27,7 +27,7 @@ internal struct MultiLineEditorView: View { movesFocusOnTab: true ) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(ThemeEngine.shared.palette.color(.panelSeparator))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) .overlay(alignment: .topLeading) { placeholder } .overlay(alignment: .bottomTrailing) { popOutButton } } diff --git a/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift index 3174a5ad86..cf499ad52e 100644 --- a/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/PhpSerializedFieldView.swift @@ -6,7 +6,6 @@ import SwiftUI internal struct PhpSerializedFieldView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext var onPopOut: ((String) -> Void)? var isExpanded = false @@ -19,6 +18,6 @@ internal struct PhpSerializedFieldView: View { .frame(height: isExpanded ? ResizableFieldMetrics.expandedHeight : nil) .frame(minHeight: isExpanded ? nil : 80, maxHeight: isExpanded ? nil : 200) .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(themeEngine.palette.color(.panelSeparator))) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift b/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift index a0f0eb75d0..6438601394 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ResizableEditorContainer.swift @@ -7,7 +7,6 @@ import AppKit import SwiftUI internal struct ResizableEditorContainer: View { - @ObservedObject private var themeEngine = ThemeEngine.shared @Binding var height: Double let range: ClosedRange @@ -40,7 +39,7 @@ internal struct ResizableEditorContainer: View { private var resizeHandle: some View { Capsule() - .fill(themeEngine.palette.color(.panelTertiaryText)) + .fill(Color(nsColor: .tertiaryLabelColor)) .frame(width: 26, height: 4) .opacity(isHandleHovered ? 1 : 0.5) .frame(maxWidth: .infinity, minHeight: 11) diff --git a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift index 29e99ef991..f960266bb5 100644 --- a/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift @@ -6,7 +6,6 @@ import SwiftUI internal struct SingleLineEditorView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext var body: some View { @@ -30,7 +29,7 @@ internal struct SingleLineEditorView: View { .frame(maxWidth: .infinity, minHeight: 16, alignment: .leading) .padding(.horizontal, 6) .padding(.vertical, 4) - .background(themeEngine.palette.color(.panelControlBackground), in: RoundedRectangle(cornerRadius: 5)) - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(themeEngine.palette.color(.panelSeparator))) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) } } diff --git a/TablePro/Views/RowInspector/InspectorFieldListView.swift b/TablePro/Views/RowInspector/InspectorFieldListView.swift index 3c46380b39..e6361909e7 100644 --- a/TablePro/Views/RowInspector/InspectorFieldListView.swift +++ b/TablePro/Views/RowInspector/InspectorFieldListView.swift @@ -136,7 +136,6 @@ internal struct InspectorFieldListView: View { /// `listRowInsets` is consulted, so the pane's content sat at 24pt while its own header sat /// at 10. `.plain` lands at half of `intercellSpacing` and can be corrected onto the edge. .listStyle(.plain) - .scrollContentBackground(.hidden) .modifier(InspectorFieldKeyShortcuts( moveFocus: { forward in moveFocus(within: fields, forward: forward) }, applyStateShortcut: applyStateShortcut diff --git a/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift b/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift index 064992e6b2..33c3b64206 100644 --- a/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONNodeRowView.swift @@ -11,7 +11,6 @@ import SwiftUI struct JSONNodeRowView: View { @ObservedObject private var themeEngine = ThemeEngine.shared let row: JSONDisplayRow - let colors: JSONRowColors let onToggle: () -> Void let onOpenReferencedTable: (JSONForeignKeyRef, String) -> Void /// Classified once per line that is drawn, for the swatch, the link and the menu alike. @@ -36,12 +35,10 @@ struct JSONNodeRowView: View { init( row: JSONDisplayRow, - colors: JSONRowColors, onToggle: @escaping () -> Void, onOpenReferencedTable: @escaping (JSONForeignKeyRef, String) -> Void ) { self.row = row - self.colors = colors self.onToggle = onToggle self.onOpenReferencedTable = onOpenReferencedTable decoration = row.decoration @@ -91,11 +88,11 @@ struct JSONNodeRowView: View { if row.showsKey, let key = row.key.text { Text("\"\(JSONScalarText.escaped(key))\"") .font(valueFont) - .foregroundStyle(colors.key) + .foregroundStyle(JSONRowColors.key) .lineLimit(1) Text(": ") .font(valueFont) - .foregroundStyle(colors.punctuation) + .foregroundStyle(JSONRowColors.punctuation) } token status @@ -139,7 +136,7 @@ struct JSONNodeRowView: View { if case .link(let url) = decoration, case .string(let text) = scalar { Text(Self.linkedText(text, url: url, needsComma: row.needsComma)) .font(valueFont) - .foregroundStyle(colors.color(for: scalar)) + .foregroundStyle(JSONRowColors.color(for: scalar)) .fixedSize(horizontal: false, vertical: true) .environment(\.openURL, Self.openLink) .accessibilityAction(named: Text(String(localized: "Open Link"))) { @@ -148,7 +145,7 @@ struct JSONNodeRowView: View { } else { Text(JSONScalarText.printed(scalar) + (row.needsComma ? "," : "")) .font(valueFont) - .foregroundStyle(colors.color(for: scalar)) + .foregroundStyle(JSONRowColors.color(for: scalar)) .fixedSize(horizontal: false, vertical: true) } } @@ -166,7 +163,7 @@ struct JSONNodeRowView: View { private func punctuation(_ text: String) -> some View { Text(text) .font(valueFont) - .foregroundStyle(colors.punctuation) + .foregroundStyle(JSONRowColors.punctuation) } private func collapsed(open: String, close: String, count: Int) -> some View { @@ -176,7 +173,7 @@ struct JSONNodeRowView: View { ? String(localized: "1 item") : String(format: String(localized: "%d items"), count)) .font(.caption) - .foregroundStyle(colors.placeholder) + .foregroundStyle(JSONRowColors.placeholder) punctuation(close + (row.needsComma ? "," : "")) } } diff --git a/TablePro/Views/RowInspector/JSON/JSONRowColors.swift b/TablePro/Views/RowInspector/JSON/JSONRowColors.swift index 8456937411..5eecf15439 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowColors.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowColors.swift @@ -2,41 +2,25 @@ // JSONRowColors.swift // TablePro // -// Syntax colours for the JSON inspector, taken from the active editor theme. -// import SwiftUI -/// The inspector shows stored values, so its font is the Data Grid Font (`ThemeEngine.valueFont`) -/// while its colours come from the editor palette the SQL editor and the JSON preview already use. -/// Naming a system text style here is what makes a value read differently in the grid and in the -/// inspector the moment the two font settings differ. -struct JSONRowColors { - let key: Color - let string: Color - let number: Color - let literal: Color - let punctuation: Color - let placeholder: Color - - @MainActor - static func current() -> JSONRowColors { - let palette = ThemeEngine.shared.palette - return JSONRowColors( - key: palette.color(.syntaxKeyword), - string: palette.color(.syntaxString), - number: palette.color(.syntaxNumber), - literal: palette.color(.syntaxNull), - punctuation: palette.color(.editorText), - placeholder: palette.color(.syntaxComment) - ) - } +/// The JSON inspector draws a value in the same tone as the JSON tree viewer, so the two never +/// disagree and neither follows the editor theme. +internal enum JSONRowColors { + static let key = Color(nsColor: TreeValueTone.key.color) + static let punctuation = Color.primary + static let placeholder = Color(nsColor: TreeValueTone.muted.color) - func color(for scalar: JSONScalar) -> Color { + static func tone(for scalar: JSONScalar) -> TreeValueTone { switch scalar { - case .string, .binary: string - case .number: number - case .bool, .null: literal + case .string, .binary: .string + case .number: .number + case .bool, .null: .literal } } + + static func color(for scalar: JSONScalar) -> Color { + Color(nsColor: tone(for: scalar).color) + } } diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index 23750beb66..22097d0629 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -9,14 +9,11 @@ import SwiftUI struct JSONRowInspectorView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared @ObservedObject var viewModel: JSONRowInspectorViewModel let snapshot: JSONRowSnapshot? let onOpenReferencedTable: (JSONForeignKeyRef, String) -> Void - @State private var colors = JSONRowColors.current() - var body: some View { VStack(spacing: 0) { if snapshot != nil { @@ -28,7 +25,6 @@ struct JSONRowInspectorView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .onReceive(AppEvents.shared.themeChanged) { _ in colors = JSONRowColors.current() } } // MARK: - Empty State @@ -66,7 +62,7 @@ struct JSONRowInspectorView: View { ) .overlay( RoundedRectangle(cornerRadius: 6) - .strokeBorder(ThemeEngine.shared.palette.color(.statusError).opacity(0.6)) + .strokeBorder(Color.red.opacity(0.6)) .opacity(viewModel.isFilterInvalid ? 1 : 0) ) .help(viewModel.isFilterInvalid @@ -89,7 +85,6 @@ struct JSONRowInspectorView: View { ForEach(rows) { row in JSONNodeRowView( row: row, - colors: colors, onToggle: { viewModel.toggle(row: row) }, onOpenReferencedTable: onOpenReferencedTable ) @@ -99,7 +94,6 @@ struct JSONRowInspectorView: View { .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .topLeading) } - .background(themeEngine.palette.color(.editorBackground)) .accessibilityLabel(String(localized: "Row as JSON")) } } diff --git a/TablePro/Views/ServerDashboard/MetricsBarView.swift b/TablePro/Views/ServerDashboard/MetricsBarView.swift index 40ff79ce34..89f4c503d6 100644 --- a/TablePro/Views/ServerDashboard/MetricsBarView.swift +++ b/TablePro/Views/ServerDashboard/MetricsBarView.swift @@ -1,7 +1,6 @@ import SwiftUI struct MetricsBarView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let metrics: [DashboardMetric] let error: String? @@ -18,7 +17,7 @@ struct MetricsBarView: View { Image(systemName: "exclamationmark.triangle") } .font(.caption) - .foregroundStyle(themeEngine.palette.color(.statusError)) + .foregroundStyle(.red) } } .padding(.horizontal, 12) diff --git a/TablePro/Views/ServerDashboard/SessionsTableView.swift b/TablePro/Views/ServerDashboard/SessionsTableView.swift index 75a33dde0a..86b68ab40c 100644 --- a/TablePro/Views/ServerDashboard/SessionsTableView.swift +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -1,7 +1,6 @@ import SwiftUI struct SessionsTableView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared @ObservedObject var viewModel: ServerDashboardViewModel @State private var selection: Set = [] @@ -20,7 +19,7 @@ struct SessionsTableView: View { Image(systemName: "exclamationmark.triangle") } .font(.caption) - .foregroundStyle(themeEngine.palette.color(.statusError)) + .foregroundStyle(.red) } } .padding(.horizontal, 12) @@ -40,7 +39,7 @@ struct SessionsTableView: View { TableColumn(String(localized: "State"), value: \.state) { session in Text(session.state) - .foregroundStyle(stateColor(session.state)) + .selectionAwareTint(stateColor(session.state)) } .width(min: 60, ideal: 80) @@ -87,10 +86,10 @@ struct SessionsTableView: View { private func stateColor(_ state: String) -> Color { switch state.lowercased() { - case "active", "running": return themeEngine.palette.color(.statusSuccess) + case "active", "running": return .green case "idle": return .secondary - case "idle in transaction": return themeEngine.palette.color(.statusWarning) - case "waiting", "locked": return themeEngine.palette.color(.statusError) + case "idle in transaction": return .orange + case "waiting", "locked": return .red default: return .primary } } diff --git a/TablePro/Views/ServerDashboard/SlowQueryListView.swift b/TablePro/Views/ServerDashboard/SlowQueryListView.swift index c02adf27be..a49a4e90c2 100644 --- a/TablePro/Views/ServerDashboard/SlowQueryListView.swift +++ b/TablePro/Views/ServerDashboard/SlowQueryListView.swift @@ -1,7 +1,6 @@ import SwiftUI struct SlowQueryListView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let queries: [DashboardSlowQuery] let error: String? @@ -20,7 +19,7 @@ struct SlowQueryListView: View { Image(systemName: "exclamationmark.triangle.fill") } .font(.caption) - .foregroundStyle(themeEngine.palette.color(.statusWarning)) + .foregroundStyle(.orange) } } .padding(.horizontal, 12) @@ -40,7 +39,6 @@ struct SlowQueryListView: View { } } .listStyle(.plain) - .scrollContentBackground(.hidden) } } } @@ -50,7 +48,7 @@ struct SlowQueryListView: View { Text(query.duration) .font(.system(.caption, design: .monospaced)) .monospacedDigit() - .foregroundStyle(themeEngine.palette.color(.statusWarning)) + .foregroundStyle(.orange) .frame(width: 50, alignment: .trailing) VStack(alignment: .leading, spacing: 2) { diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 825057a2c2..e59b3f04a2 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -33,7 +33,6 @@ struct AISettingsView: View { } } .formStyle(.grouped) - .scrollContentBackground(.hidden) .task(id: settings.enabled ? settings.providers.map(\.id) : []) { refreshKeyAvailability() } .task(id: AIProviderAccountProbes.types(toProbe: settings)) { await refreshAccountStatus(of: AIProviderAccountProbes.types(toProbe: settings)) diff --git a/TablePro/Views/Settings/Appearance/CustomThemeListModel.swift b/TablePro/Views/Settings/Appearance/CustomThemeListModel.swift new file mode 100644 index 0000000000..eb2bed918a --- /dev/null +++ b/TablePro/Views/Settings/Appearance/CustomThemeListModel.swift @@ -0,0 +1,72 @@ +import Foundation + +internal enum CustomThemeItem: Hashable, Sendable { + case theme(String) + case notLoaded(path: String) +} + +/// The rows of the Custom Themes list: the user's themes in catalog order, then the files the +/// catalog refused, each with its reason. +internal struct CustomThemeListModel: Equatable { + internal let themes: [ThemeDefinition] + internal let notLoaded: [RejectedThemeRecord] + + internal init(themes: [ThemeDefinition], rejected: [RejectedThemeRecord]) { + self.themes = themes.filter(\.isEditable) + notLoaded = rejected + } + + internal var isEmpty: Bool { + themes.isEmpty && notLoaded.isEmpty + } + + internal func theme(for item: CustomThemeItem?) -> ThemeDefinition? { + guard case let .theme(id) = item else { return nil } + return themes.first { $0.id == id } + } + + internal func record(for item: CustomThemeItem?) -> RejectedThemeRecord? { + guard case let .notLoaded(path) = item else { return nil } + return notLoaded.first { $0.path == path } + } + + internal static func appearanceLabel(_ appearance: ThemeAppearance) -> String { + switch appearance { + case .light: return String(localized: "Light") + case .dark: return String(localized: "Dark") + } + } +} + +internal enum ThemeNaming { + /// The name a rename stores, or nil when there is nothing to change. A name another theme + /// already has is numbered the way the catalog numbers copies, because two popup items with + /// one title cannot be told apart. + internal static func renamed(_ proposed: String, current: String, takenNames: Set) -> String? { + let trimmed = proposed.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != current else { return nil } + guard takenNames.contains(trimmed) else { return trimmed } + + var index = 2 + while takenNames.contains("\(trimmed) \(index)") { + index += 1 + } + let numbered = "\(trimmed) \(index)" + return numbered == current ? nil : numbered + } +} + +internal extension AppearanceSettings { + /// A slot that held a theme the user just deleted goes back to its built-in, so the popup does + /// not then report that theme as missing. + func releasing(themeId: String) -> AppearanceSettings { + var updated = self + if updated.preferredLightThemeId == themeId { + updated.preferredLightThemeId = BuiltInThemes.defaultLightId + } + if updated.preferredDarkThemeId == themeId { + updated.preferredDarkThemeId = BuiltInThemes.defaultDarkId + } + return updated + } +} diff --git a/TablePro/Views/Settings/Appearance/CustomThemesSection.swift b/TablePro/Views/Settings/Appearance/CustomThemesSection.swift new file mode 100644 index 0000000000..60edf6d0f0 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/CustomThemesSection.swift @@ -0,0 +1,392 @@ +import AppKit +import os +import SwiftUI +import UniformTypeIdentifiers + +/// The user's theme files, with the add and remove pair and the action pull-down macOS puts under +/// a list. Every action names the theme it acts on: a context menu acts on the row under the +/// pointer, which is not always the selected one. +internal struct CustomThemesSection: View { + @Binding internal var settings: AppearanceSettings + + @ObservedObject private var catalog = ThemeCatalog.shared + + @State private var selection: CustomThemeItem? + @State private var editing: ThemeDefinition? + @State private var renaming: ThemeDefinition? + @State private var renameText = "" + @State private var deleting: ThemeDefinition? + @State private var failure: Failure? + + private struct Failure { + let title: String + let message: String + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "CustomThemesSection") + private static let rowHeight: CGFloat = 34 + private static let barHeight: CGFloat = 28 + + private var model: CustomThemeListModel { + CustomThemeListModel(themes: catalog.themes, rejected: catalog.rejected) + } + + private var selectedTheme: ThemeDefinition? { + model.theme(for: selection) + } + + internal var body: some View { + Section { + list + .sheet(item: $editing) { theme in + ThemeEditorSheet(theme: theme, takenNames: takenNames(excluding: theme.id), save: saveEdited) + } + .alert(failure?.title ?? "", isPresented: isPresented($failure), presenting: failure) { _ in + Button(String(localized: "OK")) {} + } message: { failure in + Text(failure.message) + } + .onAppear { reload() } + // Picks up a file fixed or dropped into the Themes folder while TablePro was in the background. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + guard editing == nil else { return } + reload() + } + } header: { + Text("Custom Themes") + } + } + + // MARK: - List + + private var list: some View { + List(selection: $selection) { + ForEach(model.themes) { theme in + themeRow(theme) + .tag(CustomThemeItem.theme(theme.id)) + } + + if !model.notLoaded.isEmpty { + Section(String(localized: "Not Loaded")) { + ForEach(model.notLoaded) { record in + notLoadedRow(record) + .tag(CustomThemeItem.notLoaded(path: record.path)) + } + } + } + } + .listStyle(.bordered(alternatesRowBackgrounds: false)) + .frame(height: listHeight) + .contextMenu(forSelectionType: CustomThemeItem.self) { items in + rowMenu(for: items.first) + } primaryAction: { items in + guard let theme = model.theme(for: items.first) else { return } + editing = theme + } + .onDeleteCommand { requestDelete(selectedTheme) } + .overlay { + if model.isEmpty { + Text("No Custom Themes") + .foregroundStyle(.secondary) + .padding(.bottom, Self.barHeight) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + footer + } + .alert(String(localized: "Delete Theme"), isPresented: isPresented($deleting), presenting: deleting) { theme in + Button(String(localized: "Delete"), role: .destructive) { delete(theme) } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { theme in + Text(String(format: String(localized: "Are you sure you want to delete \"%@\"?"), theme.name)) + } + } + + private var listHeight: CGFloat { + let rows = CGFloat(max(model.themes.count + model.notLoaded.count, 3)) + let sectionHeader: CGFloat = model.notLoaded.isEmpty ? 0 : 28 + return min(rows * Self.rowHeight + sectionHeader + Self.barHeight + 8, 260) + } + + private func themeRow(_ theme: ThemeDefinition) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(theme.name) + .lineLimit(1) + Text(CustomThemeListModel.appearanceLabel(theme.appearance)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + + private func notLoadedRow(_ record: RejectedThemeRecord) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(record.fileName) + .lineLimit(1) + Text(record.reason) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + .help(record.reason) + } + + /// A contextual menu leaves out what does not apply; the pull-down below dims it instead. + @ViewBuilder + private func rowMenu(for item: CustomThemeItem?) -> some View { + if let theme = model.theme(for: item) { + Button(String(localized: "Edit…")) { editing = theme } + Button(String(localized: "Rename…")) { beginRename(theme) } + Button(String(localized: "Duplicate")) { duplicate(theme) } + Button(String(localized: "Export…")) { export(theme) } + Button(String(localized: "Show in Finder")) { reveal(theme) } + Divider() + Button(String(localized: "Delete…")) { requestDelete(theme) } + } else if let record = model.record(for: item) { + Button(String(localized: "Show in Finder")) { reveal(URL(fileURLWithPath: record.path)) } + } + } + + // MARK: - Footer + + private var footer: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: 0) { + ControlGroup { + addMenu + + Button { + requestDelete(selectedTheme) + } label: { + Label(String(localized: "Delete Theme"), systemImage: "minus") + .labelStyle(.iconOnly) + } + .help(String(localized: "Delete Theme")) + .accessibilityLabel(String(localized: "Delete Theme")) + .accessibilityIdentifier("appearance-theme-remove") + .disabled(selectedTheme == nil) + + actionsMenu + } + .controlGroupStyle(.navigation) + .controlSize(.small) + .fixedSize() + + Spacer() + } + .padding(.horizontal, 4) + .padding(.vertical, 2) + } + .background(.bar) + .alert(String(localized: "Rename Theme"), isPresented: isPresented($renaming), presenting: renaming) { theme in + TextField(String(localized: "Name"), text: $renameText) + .accessibilityIdentifier("appearance-theme-rename-field") + Button(String(localized: "Rename")) { rename(theme) } + Button(String(localized: "Cancel"), role: .cancel) {} + } + } + + /// An icon-only `Menu` is named only through `.contain` plus a label on the menu itself; a + /// label inside its label closure leaves VoiceOver reading "More", or nothing on CI. + private var addMenu: some View { + Menu { + Menu(String(localized: "New Theme Based On")) { + ForEach(catalog.themes.filter(\.isBuiltIn)) { theme in + Button(theme.name) { duplicate(theme) } + } + if !model.themes.isEmpty { + Divider() + ForEach(model.themes) { theme in + Button(theme.name) { duplicate(theme) } + } + } + } + Divider() + Button(String(localized: "Import…"), action: importTheme) + } label: { + Label(String(localized: "Add Theme"), systemImage: "plus") + .labelStyle(.iconOnly) + } + .menuIndicator(.hidden) + .help(String(localized: "Add Theme")) + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Add Theme")) + .accessibilityIdentifier("appearance-theme-add") + } + + private var actionsMenu: some View { + Menu { + Button(String(localized: "Edit…")) { editing = selectedTheme } + .disabled(selectedTheme == nil) + Button(String(localized: "Rename…")) { + if let theme = selectedTheme { beginRename(theme) } + } + .disabled(selectedTheme == nil) + Button(String(localized: "Duplicate")) { + if let theme = selectedTheme { duplicate(theme) } + } + .disabled(selectedTheme == nil) + Button(String(localized: "Export…")) { + if let theme = selectedTheme { export(theme) } + } + .disabled(selectedTheme == nil) + Divider() + Button(String(localized: "Show in Finder"), action: revealSelection) + } label: { + Label(String(localized: "Theme Actions"), systemImage: "ellipsis.circle") + .labelStyle(.iconOnly) + } + .menuIndicator(.hidden) + .help(String(localized: "Theme Actions")) + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Theme Actions")) + .accessibilityIdentifier("appearance-theme-actions") + } + + // MARK: - Actions + + private func duplicate(_ theme: ThemeDefinition) { + perform(String(localized: "Could not create the theme")) { + let copy = try catalog.duplicate(theme) + selection = .theme(copy.id) + } + } + + private func beginRename(_ theme: ThemeDefinition) { + renameText = theme.name + renaming = theme + } + + private func rename(_ theme: ThemeDefinition) { + let taken = takenNames(excluding: theme.id) + guard let name = ThemeNaming.renamed(renameText, current: theme.name, takenNames: taken) else { return } + perform(String(localized: "Could not rename the theme")) { + try catalog.rename(id: theme.id, to: name) + reapply() + } + } + + private func requestDelete(_ theme: ThemeDefinition?) { + guard let theme else { return } + deleting = theme + } + + private func delete(_ theme: ThemeDefinition) { + perform(String(localized: "Could not delete the theme")) { + do { + try catalog.delete(id: theme.id) + } catch { + reapply() + throw error + } + if selection == .theme(theme.id) { + selection = nil + } + let released = settings.releasing(themeId: theme.id) + if released != settings { + settings = released + } else { + reapply() + } + } + } + + private func importTheme() { + guard let window = AlertHelper.resolveWindow(nil) else { return } + + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + perform(String(localized: "Could not import the theme")) { + let imported = try catalog.importTheme(from: url) + selection = .theme(imported.id) + // A saved choice that was missing on this Mac may be the theme just imported. + reapply() + } + } + } + + private func export(_ theme: ThemeDefinition) { + guard let window = AlertHelper.resolveWindow(nil) else { return } + + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = theme.name + ".json" + panel.canCreateDirectories = true + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + perform(String(localized: "Could not export the theme")) { + try catalog.exportTheme(theme, to: url) + } + } + } + + private func revealSelection() { + if let theme = selectedTheme { + reveal(theme) + } else if let record = model.record(for: selection) { + reveal(URL(fileURLWithPath: record.path)) + } else { + NSWorkspace.shared.open(catalog.themesDirectory) + } + } + + private func reveal(_ theme: ThemeDefinition) { + guard let url = catalog.fileURL(for: theme.id) else { + NSWorkspace.shared.open(catalog.themesDirectory) + return + } + reveal(url) + } + + private func reveal(_ url: URL) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + // MARK: - Catalog + + /// A conflict with the file on disk reloads the catalog and throws; the engine still takes the reloaded theme. + private func saveEdited(_ theme: ThemeDefinition) throws { + defer { reapply() } + try catalog.save(theme) + } + + private func reload() { + let before = catalog.themes + catalog.reloadSynchronously() + guard catalog.themes != before else { return } + reapply() + } + + private func reapply() { + ThemeEngine.shared.reapply( + lightThemeId: settings.preferredLightThemeId, + darkThemeId: settings.preferredDarkThemeId + ) + } + + private func takenNames(excluding id: String) -> Set { + Set(catalog.themes.filter { $0.id != id }.map(\.name)) + } + + private func perform(_ title: String, _ work: () throws -> Void) { + do { + try work() + } catch { + Self.logger.error("Theme action failed: \(error.publicLogShape, privacy: .public)") + failure = Failure(title: title, message: error.localizedDescription) + } + } + + private func isPresented(_ value: Binding) -> Binding { + Binding( + get: { value.wrappedValue != nil }, + set: { if !$0 { value.wrappedValue = nil } } + ) + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift b/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift deleted file mode 100644 index 9248e68f87..0000000000 --- a/TablePro/Views/Settings/Appearance/ThemeEditorColorsSection.swift +++ /dev/null @@ -1,116 +0,0 @@ -import AppKit -import os -import SwiftUI - -/// One well per registered slot, driven by `ThemeSlot.allCases`, so a slot the app gains appears -/// here without a second hand-maintained list to keep in step. -internal struct ThemeEditorColorsSection: View { - internal let theme: ThemeDefinition - - @State private var draft: ThemeDefinition? - @State private var saveTask: Task? - - private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorColors") - private static let saveDelay = Duration.milliseconds(250) - - private var edited: ThemeDefinition { - guard let draft, draft.id == theme.id else { return theme } - return draft - } - - internal var body: some View { - Form { - ForEach(ThemeSlotGroup.allCases, id: \.self) { group in - Section(group.label) { - ForEach(group.slots, id: \.self) { slot in - row(for: slot) - } - } - } - } - .formStyle(.grouped) - .onDisappear { flush() } - } - - private func row(for slot: ThemeSlot) -> some View { - let value = edited[keyPath: slot.keyPath] - - return LabeledContent(slot.label) { - HStack(spacing: 8) { - if case let .system(name) = value { - Text(name.rawValue) - .font(.caption) - .foregroundStyle(.secondary) - } - - ColorPicker(slot.label, selection: binding(for: slot), supportsOpacity: true) - .labelsHidden() - } - .contextMenu { - if case .hex = value, case let .system(name) = BuiltInThemes.default(for: theme.appearance)[keyPath: slot.keyPath] { - Button(String(format: String(localized: "Use System Color (%@)"), name.rawValue)) { - write(.system(name), to: slot) - } - } - } - } - } - - private var editedAppearance: NSAppearance? { - NSAppearance(named: theme.appearance == .dark ? .darkAqua : .aqua) - } - - private func resolved(_ value: ThemeColorValue) -> NSColor { - guard value.isSystem, let editedAppearance else { return value.nsColor } - - var color = value.nsColor - editedAppearance.performAsCurrentDrawingAppearance { - color = value.nsColor.usingColorSpace(.sRGB) ?? value.nsColor - } - return color - } - - private func binding(for slot: ThemeSlot) -> Binding { - Binding( - get: { Color(nsColor: resolved(edited[keyPath: slot.keyPath])) }, - set: { newColor in - write(.hex(HexColor.string(from: NSColor(newColor))), to: slot) - } - ) - } - - /// The well emits on every drag tick, and each one used to write the file, rescan the themes - /// directory and re-activate. The draft absorbs the ticks and one save follows the settle. - private func write(_ value: ThemeColorValue, to slot: ThemeSlot) { - var updated = edited - updated[keyPath: slot.keyPath] = value - draft = updated - - saveTask?.cancel() - saveTask = Task { @MainActor in - try? await Task.sleep(for: Self.saveDelay) - guard !Task.isCancelled else { return } - save(updated) - } - } - - private func flush() { - saveTask?.cancel() - saveTask = nil - guard let draft, draft != theme else { return } - save(draft) - } - - private func save(_ updated: ThemeDefinition) { - do { - try ThemeCatalog.shared.save(updated) - let appearance = AppSettingsManager.shared.appearance - ThemeEngine.shared.reapply( - lightThemeId: appearance.preferredLightThemeId, - darkThemeId: appearance.preferredDarkThemeId - ) - } catch { - Self.logger.error("Could not save theme: \(error.publicLogShape, privacy: .public)") - } - } -} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorDraft.swift b/TablePro/Views/Settings/Appearance/ThemeEditorDraft.swift new file mode 100644 index 0000000000..a47a9fe3d9 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorDraft.swift @@ -0,0 +1,47 @@ +import AppKit + +/// The theme an Edit sheet is changing. Each mutation reports whether it changed anything, so the +/// sheet schedules a save only for a real edit. +internal struct ThemeEditorDraft: Equatable { + internal var theme: ThemeDefinition + + /// The color a well shows. A slot the theme leaves out shows the system color it draws in, + /// resolved in the theme's appearance rather than the one Settings is in. + internal func displayColor(for slot: ThemeSlot) -> NSColor { + let color = theme.color(for: slot) + guard !theme.isOverridden(slot), + let appearance = NSAppearance(named: theme.appearance == .dark ? .darkAqua : .aqua) + else { return color } + + var resolved = color + appearance.performAsCurrentDrawingAppearance { + resolved = color.usingColorSpace(.sRGB) ?? color + } + return resolved + } + + /// A well can echo the color it was handed. Taking that echo as an edit would freeze a system + /// color into the file as a fixed value, so a value equal to what the well shows is ignored. + @discardableResult + internal mutating func setColor(_ color: ThemeColor, for slot: ThemeSlot) -> Bool { + if let existing = theme.overrides[slot] { + guard existing != color else { return false } + } else { + guard ThemeColor(displayColor(for: slot)) != color else { return false } + } + theme.overrides[slot] = color + return true + } + + @discardableResult + internal mutating func reset(_ slot: ThemeSlot) -> Bool { + theme.overrides.removeValue(forKey: slot) != nil + } + + @discardableResult + internal mutating func rename(to name: String) -> Bool { + guard name != theme.name else { return false } + theme.name = name + return true + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorSheet.swift b/TablePro/Views/Settings/Appearance/ThemeEditorSheet.swift new file mode 100644 index 0000000000..2dd46c485c --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeEditorSheet.swift @@ -0,0 +1,174 @@ +import os +import SwiftUI + +/// Edits one custom theme in place. Every change is saved shortly after it settles, so Done only +/// closes the sheet; there is no draft to throw away. +internal struct ThemeEditorSheet: View { + @Environment(\.dismiss) private var dismiss + + @State private var draft: ThemeEditorDraft + @State private var lastSaved: ThemeDefinition + @State private var name: String + @State private var saveTask: Task? + @State private var saveError: String? + + private let takenNames: Set + private let save: @MainActor (ThemeDefinition) throws -> Void + + private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorSheet") + // A color well reports every drag tick; one write follows the settle. + private static let saveDelay = Duration.milliseconds(250) + + internal init( + theme: ThemeDefinition, + takenNames: Set, + save: @escaping @MainActor (ThemeDefinition) throws -> Void + ) { + _draft = State(initialValue: ThemeEditorDraft(theme: theme)) + _lastSaved = State(initialValue: theme) + _name = State(initialValue: theme.name) + self.takenNames = takenNames + self.save = save + } + + internal var body: some View { + VStack(spacing: 0) { + ThemePreviewCard(theme: draft.theme) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 4) + + Form { + Section { + TextField(String(localized: "Name"), text: $name) + .onSubmit(commitName) + .accessibilityIdentifier("theme-editor-name") + } + + ForEach(ThemeSlotGroup.allCases, id: \.self) { group in + Section(group.label) { + ForEach(group.slots, id: \.self) { slot in + colorRow(slot) + } + } + } + } + .formStyle(.grouped) + + Divider() + + HStack { + Spacer() + Button(String(localized: "Done"), action: finish) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("theme-editor-done") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + .frame(minWidth: 480, idealWidth: 520, minHeight: 460, idealHeight: 600) + .onExitCommand(perform: finish) + .onDisappear { flush() } + .alert( + String(localized: "Could not save the theme"), + isPresented: Binding(get: { saveError != nil }, set: { if !$0 { saveError = nil } }) + ) { + Button(String(localized: "OK")) {} + } message: { + Text(saveError ?? "") + } + } + + private func colorRow(_ slot: ThemeSlot) -> some View { + LabeledContent(slot.label) { + HStack(spacing: 6) { + if draft.theme.isOverridden(slot) { + Button { + if draft.reset(slot) { scheduleSave() } + } label: { + Label(String(localized: "Use Default"), systemImage: "arrow.uturn.backward") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .help(String(localized: "Use Default")) + .accessibilityLabel(String(localized: "Use Default")) + .accessibilityIdentifier("theme-color-reset-\(slot.rawValue)") + } + + ColorPicker(slot.label, selection: colorBinding(for: slot), supportsOpacity: true) + .labelsHidden() + .accessibilityIdentifier("theme-color-\(slot.rawValue)") + } + } + } + + private func colorBinding(for slot: ThemeSlot) -> Binding { + Binding( + get: { Color(nsColor: draft.displayColor(for: slot)) }, + set: { newColor in + if draft.setColor(ThemeColor(NSColor(newColor)), for: slot) { + scheduleSave() + } + } + ) + } + + private func commitName() { + guard let renamed = ThemeNaming.renamed(name, current: draft.theme.name, takenNames: takenNames) else { + name = draft.theme.name + return + } + name = renamed + if draft.rename(to: renamed) { + scheduleSave() + } + } + + private func scheduleSave() { + saveTask?.cancel() + let delay = Self.saveDelay + saveTask = Task { @MainActor in + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + write(draft.theme) + } + } + + @discardableResult + private func flush() -> Bool { + saveTask?.cancel() + saveTask = nil + guard draft.theme != lastSaved else { return true } + return write(draft.theme) + } + + @discardableResult + private func write(_ theme: ThemeDefinition) -> Bool { + do { + try save(theme) + lastSaved = theme + return true + } catch { + Self.logger.error("Could not save theme: \(error.publicLogShape, privacy: .public)") + saveError = error.localizedDescription + adoptDiskVersionAfterConflict(error, id: theme.id) + return false + } + } + + /// After a conflict the catalog holds the file's newer contents; editing on from the stale draft + /// would overwrite them with the next save. + private func adoptDiskVersionAfterConflict(_ error: Error, id: String) { + guard case .changedOnDisk = error as? ThemeLoadError, + let current = ThemeCatalog.shared.theme(id: id) else { return } + draft = ThemeEditorDraft(theme: current) + lastSaved = current + name = current.name + } + + private func finish() { + commitName() + guard flush() else { return } + dismiss() + } +} diff --git a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift b/TablePro/Views/Settings/Appearance/ThemeEditorView.swift deleted file mode 100644 index c5f8c8a55a..0000000000 --- a/TablePro/Views/Settings/Appearance/ThemeEditorView.swift +++ /dev/null @@ -1,100 +0,0 @@ -import os -import SwiftUI - -/// Edits the theme the slot holds, not the theme in effect. Reading `activeTheme` here meant that -/// with the Mac in light mode and the pane set to Editing: Dark, every edit, duplicate and delete -/// landed on the light theme, and the dark slot was then pointed at it. -internal struct ThemeEditorView: View { - @Binding internal var selectedThemeId: String - internal let slotAppearance: ThemeAppearance - - @State private var errorMessage: String? - @State private var showError = false - - private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEditorView") - - @ObservedObject private var catalog = ThemeCatalog.shared - - private var theme: ThemeDefinition { - catalog.theme(id: selectedThemeId) ?? BuiltInThemes.default(for: slotAppearance) - } - - internal var body: some View { - VStack(spacing: 0) { - header - - Divider() - - if theme.isEditable { - ThemeEditorColorsSection(theme: theme) - } else { - duplicatePrompt - } - } - .alert(String(localized: "Error"), isPresented: $showError) { - Button(String(localized: "OK")) {} - } message: { - if let errorMessage { - Text(errorMessage) - } - } - } - - private var header: some View { - VStack(alignment: .leading, spacing: 2) { - Text(theme.name) - .font(.title3.weight(.semibold)) - - Text(theme.author.isEmpty ? String(localized: "Custom theme") : theme.author) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 10) - } - - private var duplicatePrompt: some View { - VStack(spacing: 12) { - Spacer() - - Image(systemName: "lock.fill") - .font(.title2) - .foregroundStyle(.secondary) - - Text(theme.isBuiltIn - ? String(localized: "This is a built-in theme.") - : String(localized: "This is a registry theme.")) - .font(.body) - .foregroundStyle(.secondary) - - Text("Duplicate it to change its colors.") - .font(.subheadline) - .foregroundStyle(.tertiary) - - Button("Duplicate Theme") { - duplicateAndSelect() - } - .controlSize(.large) - - Spacer() - } - .frame(maxWidth: .infinity) - } - - private func duplicateAndSelect() { - var copy = theme - copy.id = ThemeIdentifier.generated() - copy.name = String(format: String(localized: "%@ (Copy)"), theme.name) - - do { - try catalog.save(copy) - selectedThemeId = copy.id - } catch { - Self.logger.error("Could not duplicate theme: \(error.publicLogShape, privacy: .public)") - errorMessage = error.localizedDescription - showError = true - } - } -} diff --git a/TablePro/Views/Settings/Appearance/ThemeListRowView.swift b/TablePro/Views/Settings/Appearance/ThemeListRowView.swift deleted file mode 100644 index e9db3939da..0000000000 --- a/TablePro/Views/Settings/Appearance/ThemeListRowView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -internal struct ThemeListRowView: View { - let theme: ThemeDefinition - - var body: some View { - HStack(spacing: 8) { - ThemePreviewCard(theme: theme, isActive: false, onSelect: {}, size: .compact) - - VStack(alignment: .leading, spacing: 2) { - Text(theme.name) - .font(.callout) - .lineLimit(1) - - Text(theme.isBuiltIn ? String(localized: "Built-in") : String(localized: "Custom")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 2) - } -} diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift deleted file mode 100644 index e3d16481e8..0000000000 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ /dev/null @@ -1,217 +0,0 @@ -import AppKit -import os -import SwiftUI -import UniformTypeIdentifiers - -/// Every action here acts on the theme the slot has selected. Acting on the active theme instead -/// let a delete in the dark slot write the light theme's id into it, so at dusk the editor turned -/// white inside dark chrome. -internal struct ThemeListView: View { - @Binding internal var selectedThemeId: String - internal var slotAppearance: ThemeAppearance = .light - - @State private var showDeleteConfirmation = false - @State private var errorMessage: String? - @State private var showError = false - - private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeListView") - - @ObservedObject private var catalog = ThemeCatalog.shared - - private var builtInThemes: [ThemeDefinition] { - eligible(catalog.themes.filter(\.isBuiltIn)) - } - - private var customThemes: [ThemeDefinition] { - eligible(catalog.themes.filter(\.isEditable)) - } - - private var selectedTheme: ThemeDefinition? { - catalog.theme(id: selectedThemeId) - } - - private var fallbackThemeId: String { - BuiltInThemes.defaultId(for: slotAppearance) - } - - private func eligible(_ themes: [ThemeDefinition]) -> [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes(themes, slot: slotAppearance, keeping: selectedThemeId) - } - - internal var body: some View { - VStack(spacing: 0) { - List(selection: $selectedThemeId) { - Section("Built-in") { - ForEach(builtInThemes) { theme in - ThemeListRowView(theme: theme).tag(theme.id) - } - } - - if !customThemes.isEmpty { - Section("Custom") { - ForEach(customThemes) { theme in - ThemeListRowView(theme: theme).tag(theme.id) - } - } - } - - if !catalog.rejected.isEmpty { - Section("Not Loaded") { - ForEach(catalog.rejected, id: \.path) { record in - rejectedRow(record) - } - } - } - } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) - - Divider() - - toolbar - } - .alert(String(localized: "Delete Theme"), isPresented: $showDeleteConfirmation) { - Button(String(localized: "Delete"), role: .destructive) { deleteSelectedTheme() } - Button(String(localized: "Cancel"), role: .cancel) {} - } message: { - Text(String( - format: String(localized: "Are you sure you want to delete \"%@\"?"), - selectedTheme?.name ?? "" - )) - } - .alert(String(localized: "Error"), isPresented: $showError) { - Button(String(localized: "OK")) {} - } message: { - if let errorMessage { - Text(errorMessage) - } - } - } - - /// A file the loader refused is shown with its reason rather than dropped in silence, which is - /// what the previous loader did for a malformed theme. - private func rejectedRow(_ record: RejectedThemeRecord) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(record.fileName) - .font(.callout) - .lineLimit(1) - - Text(record.reason) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - .padding(.vertical, 2) - .help(Text(record.reason)) - } - - private var toolbar: some View { - HStack(spacing: 4) { - Menu { - Button(String(localized: "Duplicate Selected")) { duplicateSelectedTheme() } - Divider() - Button(String(localized: "Import…")) { importTheme() } - } label: { - Image(systemName: "plus") - .frame(width: 24, height: 24) - .accessibilityLabel(Text("Add Theme")) - } - .menuIndicator(.hidden) - .buttonStyle(.borderless) - .frame(width: 28) - .help(Text("Add Theme")) - - Button { - showDeleteConfirmation = true - } label: { - Image(systemName: "minus").frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .disabled(selectedTheme?.isEditable != true) - .help(Text("Delete Theme")) - .accessibilityLabel(Text("Delete Theme")) - - Menu { - Button(String(localized: "Duplicate")) { duplicateSelectedTheme() } - Button(String(localized: "Export…")) { exportSelectedTheme() } - } label: { - Image(systemName: "gearshape") - .frame(width: 24, height: 24) - .accessibilityLabel(Text("Theme Actions")) - } - .menuIndicator(.hidden) - .buttonStyle(.borderless) - .frame(width: 28) - .help(Text("Theme Actions")) - - Spacer() - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - - // MARK: - Actions - - private func duplicateSelectedTheme() { - guard let theme = selectedTheme else { return } - var copy = theme - copy.id = ThemeIdentifier.generated() - copy.name = String(format: String(localized: "%@ (Copy)"), theme.name) - - perform { try catalog.save(copy) } then: { selectedThemeId = copy.id } - } - - private func deleteSelectedTheme() { - guard let theme = selectedTheme, theme.isEditable else { return } - perform { try catalog.delete(id: theme.id) } then: { selectedThemeId = fallbackThemeId } - } - - private func exportSelectedTheme() { - guard let theme = selectedTheme, let window = AlertHelper.resolveWindow(nil) else { return } - - let panel = NSSavePanel() - panel.allowedContentTypes = [.json] - panel.nameFieldStringValue = theme.name + ".json" - panel.canCreateDirectories = true - panel.beginSheetModal(for: window) { response in - guard response == .OK, let url = panel.url else { return } - do { - try catalog.exportTheme(theme, to: url) - } catch { - AlertHelper.showErrorSheet( - title: String(localized: "Could not export the theme"), - message: error.localizedDescription, - window: window - ) - } - } - } - - private func importTheme() { - guard let window = AlertHelper.resolveWindow(nil) else { return } - - let panel = NSOpenPanel() - panel.allowedContentTypes = [.json] - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.beginSheetModal(for: window) { response in - guard response == .OK, let url = panel.url else { return } - perform { - let imported = try catalog.importTheme(from: url) - guard imported.appearance == slotAppearance else { return } - selectedThemeId = imported.id - } - } - } - - private func perform(_ work: () throws -> Void, then completion: () -> Void = {}) { - do { - try work() - completion() - } catch { - Self.logger.error("Theme action failed: \(error.publicLogShape, privacy: .public)") - errorMessage = error.localizedDescription - showError = true - } - } -} diff --git a/TablePro/Views/Settings/Appearance/ThemeSlotChoice.swift b/TablePro/Views/Settings/Appearance/ThemeSlotChoice.swift new file mode 100644 index 0000000000..9e7437cbe0 --- /dev/null +++ b/TablePro/Views/Settings/Appearance/ThemeSlotChoice.swift @@ -0,0 +1,62 @@ +import Foundation + +/// What the Light or Dark theme popup offers and shows. The popup shows the theme the engine draws +/// with, so a saved choice this Mac cannot honor (a theme synced from another Mac, a file that no +/// longer loads) is explained by a note and the saved setting is left alone. +internal struct ThemeSlotChoice: Equatable { + internal enum Note: Equatable { + case notOnThisMac(fallback: String) + case wrongAppearance(theme: String, fallback: String) + } + + internal let slot: ThemeAppearance + internal let builtIn: [ThemeDefinition] + internal let custom: [ThemeDefinition] + internal let selectedId: String + internal let note: Note? + + internal init(slot: ThemeAppearance, savedId: String, themes: [ThemeDefinition]) { + self.slot = slot + + let fitting = themes.filter { $0.appearance == slot } + builtIn = fitting.filter(\.isBuiltIn) + custom = fitting.filter(\.isEditable) + + // The engine's own resolver, so the popup can never name a theme other than the one drawn. + let inUse = ThemeResolver.resolve( + mode: slot == .dark ? .dark : .light, + lightThemeId: savedId, + darkThemeId: savedId, + themes: themes, + systemIsDark: slot == .dark + ).active + selectedId = inUse.id + + if inUse.id == savedId { + note = nil + } else if let saved = themes.first(where: { $0.id == savedId }) { + note = .wrongAppearance(theme: saved.name, fallback: inUse.name) + } else { + note = .notOnThisMac(fallback: inUse.name) + } + } + + internal var title: String { + switch slot { + case .light: return String(localized: "Light theme") + case .dark: return String(localized: "Dark theme") + } + } + + internal var noteMessage: String? { + guard let note else { return nil } + switch (note, slot) { + case let (.notOnThisMac(fallback), _): + return String(format: String(localized: "The chosen theme is not on this Mac, so %@ is used."), fallback) + case let (.wrongAppearance(theme, fallback), .light): + return String(format: String(localized: "%1$@ is a dark theme, so %2$@ is used."), theme, fallback) + case let (.wrongAppearance(theme, fallback), .dark): + return String(format: String(localized: "%1$@ is a light theme, so %2$@ is used."), theme, fallback) + } + } +} diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 7a9c0507cc..b66dbd8e90 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -1,97 +1,82 @@ -// -// AppearanceSettingsView.swift -// TablePro -// -// Settings for theme browsing and customization. -// - import SwiftUI -enum ThemeEditSlot: Hashable { - case light - case dark -} +internal struct AppearanceSettingsView: View { + @Binding internal var settings: AppearanceSettings -struct AppearanceSettingsView: View { - @ObservedObject private var themeEngine = ThemeEngine.shared - @Binding var settings: AppearanceSettings - @State private var chosenSlot: ThemeEditSlot? + @ObservedObject private var catalog = ThemeCatalog.shared - /// The slot currently being edited. Defaults to the active appearance so the - /// pane opens on the theme in use, but the user can switch to edit the other - /// slot without changing the app's appearance mode. - private var editSlot: ThemeEditSlot { - chosenSlot ?? (themeEngine.effectiveAppearance == .dark ? .dark : .light) - } - - private var slotAppearance: ThemeAppearance { - editSlot == .dark ? .dark : .light - } - - private var slotThemeBinding: Binding { - Binding( - get: { - editSlot == .dark ? settings.preferredDarkThemeId : settings.preferredLightThemeId - }, - set: { newId in - var updated = settings - if editSlot == .dark { - updated.preferredDarkThemeId = newId - } else { - updated.preferredLightThemeId = newId - } - settings = updated - } - ) - } - - var body: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - Text("Appearance") - .font(.callout) - .foregroundStyle(.secondary) + private static let modes: [AppAppearanceMode] = [.auto, .light, .dark] + internal var body: some View { + Form { + Section { Picker(String(localized: "Appearance"), selection: $settings.appearanceMode) { - ForEach(AppAppearanceMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) + ForEach(Self.modes, id: \.self) { mode in + Text(Self.title(for: mode)).tag(mode) } } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - - Spacer() - - Text("Editing") - .font(.callout) - .foregroundStyle(.secondary) + .accessibilityIdentifier("appearance-mode-picker") + } - Picker(String(localized: "Editing"), selection: Binding(get: { editSlot }, set: { chosenSlot = $0 })) { - Text("Light").tag(ThemeEditSlot.light) - Text("Dark").tag(ThemeEditSlot.dark) - } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() + Section { + themePicker(ThemeSlotChoice(slot: .light, savedId: settings.preferredLightThemeId, themes: catalog.themes)) + themePicker(ThemeSlotChoice(slot: .dark, savedId: settings.preferredDarkThemeId, themes: catalog.themes)) + } footer: { + Text("Themes color the SQL editor and the data grid. Everything else follows the system appearance.") } - .padding(.horizontal, 16) - .padding(.vertical, 8) - Divider() + CustomThemesSection(settings: $settings) + } + .formStyle(.grouped) + } - HSplitView { - ThemeListView(selectedThemeId: slotThemeBinding, slotAppearance: slotAppearance) - .frame(minWidth: 180, idealWidth: 210, maxWidth: 250) + private static func title(for mode: AppAppearanceMode) -> String { + switch mode { + case .auto: return String(localized: "System") + case .light: return String(localized: "Light") + case .dark: return String(localized: "Dark") + } + } - ThemeEditorView(selectedThemeId: slotThemeBinding, slotAppearance: slotAppearance) - .frame(minWidth: 400) + private func themePicker(_ choice: ThemeSlotChoice) -> some View { + Picker(selection: selectionBinding(for: choice)) { + ForEach(choice.builtIn) { theme in + Text(theme.name).tag(theme.id) + } + if !choice.custom.isEmpty { + Divider() + ForEach(choice.custom) { theme in + Text(theme.name).tag(theme.id) + } + } + } label: { + Text(choice.title) + if let note = choice.noteMessage { + Text(note) + .accessibilityIdentifier("appearance-\(choice.slot.rawValue)-theme-note") } } + .accessibilityIdentifier("appearance-\(choice.slot.rawValue)-theme") + } + + /// The popup shows the theme in use, but only a pick writes the setting: a saved id this Mac + /// cannot resolve stays saved for the Mac that has the theme. + private func selectionBinding(for choice: ThemeSlotChoice) -> Binding { + Binding( + get: { choice.selectedId }, + set: { id in + var updated = settings + switch choice.slot { + case .light: updated.preferredLightThemeId = id + case .dark: updated.preferredDarkThemeId = id + } + settings = updated + } + ) } } #Preview { AppearanceSettingsView(settings: .constant(.default)) - .frame(width: 720, height: 500) + .frame(width: 720, height: 560) } diff --git a/TablePro/Views/Settings/DataResultsSettingsView.swift b/TablePro/Views/Settings/DataResultsSettingsView.swift index af0554b2d0..1bb0f30e3e 100644 --- a/TablePro/Views/Settings/DataResultsSettingsView.swift +++ b/TablePro/Views/Settings/DataResultsSettingsView.swift @@ -29,7 +29,6 @@ struct DataResultsSettingsView: View { DataRewindSection(settings: $history) } .formStyle(.grouped) - .scrollContentBackground(.hidden) } } diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index 7475fc4ff7..67f754ff46 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -45,7 +45,6 @@ struct EditorSettingsView: View { } } .formStyle(.grouped) - .scrollContentBackground(.hidden) } } diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 45c530cd9c..db2ece423d 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -207,7 +207,6 @@ struct GeneralSettingsView: View { } } .formStyle(.grouped) - .scrollContentBackground(.hidden) .alert(String(localized: "Reset All Settings"), isPresented: $showResetConfirmation) { Button(String(localized: "Reset"), role: .destructive) { onResetAll() } Button(String(localized: "Cancel"), role: .cancel) {} diff --git a/TablePro/Views/Settings/KeyboardSettingsView.swift b/TablePro/Views/Settings/KeyboardSettingsView.swift index bb54ebbae3..8f7d496536 100644 --- a/TablePro/Views/Settings/KeyboardSettingsView.swift +++ b/TablePro/Views/Settings/KeyboardSettingsView.swift @@ -51,7 +51,6 @@ struct KeyboardSettingsView: View { } } .formStyle(.grouped) - .scrollContentBackground(.hidden) } } diff --git a/TablePro/Views/Settings/License/LicenseSettingsView.swift b/TablePro/Views/Settings/License/LicenseSettingsView.swift index e27aa21459..0d92969b89 100644 --- a/TablePro/Views/Settings/License/LicenseSettingsView.swift +++ b/TablePro/Views/Settings/License/LicenseSettingsView.swift @@ -48,7 +48,6 @@ struct LicenseSettingsView: View { } } .formStyle(.grouped) - .scrollContentBackground(.hidden) } // MARK: - Licensed diff --git a/TablePro/Views/Settings/MCPSettingsView.swift b/TablePro/Views/Settings/MCPSettingsView.swift index 548b625a3f..5a0ccf2f2f 100644 --- a/TablePro/Views/Settings/MCPSettingsView.swift +++ b/TablePro/Views/Settings/MCPSettingsView.swift @@ -9,7 +9,6 @@ struct MCPSettingsView: View { MCPOutsideServersSection() } .formStyle(.grouped) - .scrollContentBackground(.hidden) } } diff --git a/TablePro/Views/Settings/Profiles/CredentialProfileEditorSheet.swift b/TablePro/Views/Settings/Profiles/CredentialProfileEditorSheet.swift index b52efdd09e..1b9738b686 100644 --- a/TablePro/Views/Settings/Profiles/CredentialProfileEditorSheet.swift +++ b/TablePro/Views/Settings/Profiles/CredentialProfileEditorSheet.swift @@ -98,7 +98,6 @@ struct CredentialProfileEditorSheet: View { secureFieldsSection } .formStyle(.grouped) - .scrollContentBackground(.hidden) Divider() bottomBar diff --git a/TablePro/Views/Settings/Profiles/ProfilesSettingsView.swift b/TablePro/Views/Settings/Profiles/ProfilesSettingsView.swift index 434416e460..74542bd7fe 100644 --- a/TablePro/Views/Settings/Profiles/ProfilesSettingsView.swift +++ b/TablePro/Views/Settings/Profiles/ProfilesSettingsView.swift @@ -26,7 +26,6 @@ struct ProfilesSettingsView: View { sshSection } .formStyle(.grouped) - .scrollContentBackground(.hidden) .onAppear(perform: reload) /// The Settings window is kept alive between openings, so a profile created from a /// connection window has to reach this list while it is already on screen. diff --git a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift index 4806d87ff7..7bd91997bc 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift @@ -26,7 +26,6 @@ struct MCPTokenCreateSheet: View { expirationSection } .formStyle(.grouped) - .scrollContentBackground(.hidden) Divider() diff --git a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift index 4ef738b8b1..e360601f05 100644 --- a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift +++ b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift @@ -55,7 +55,6 @@ struct PairingApprovalSheet: View { expirySection } .formStyle(.grouped) - .scrollContentBackground(.hidden) /// The one greedy child, and the reason the sheet needs a bound at all. A grouped /// `Form` is a scroll container, so it answers any proposal with the whole proposed /// height: unbounded, it measured `CGFloat.greatestFiniteMagnitude`, and the window diff --git a/TablePro/Views/Settings/Sections/TypographySection.swift b/TablePro/Views/Settings/Sections/TypographySection.swift index 1817451d6e..e1daec9e3b 100644 --- a/TablePro/Views/Settings/Sections/TypographySection.swift +++ b/TablePro/Views/Settings/Sections/TypographySection.swift @@ -1,20 +1,11 @@ import SwiftUI -/// The two font domains, each on the pane that owns the surfaces it applies to. They used to live -/// inside the theme file, so a zoom shortcut rewrote a whole theme, and a font edit on a built-in -/// forked it into a copy. +/// Fonts are settings, not theme: importing Dracula must never change someone's font or size. internal struct TypographySection: View { internal enum Domain { case editor case dataGrid - internal var title: String { - switch self { - case .editor: return String(localized: "Editor Font") - case .dataGrid: return String(localized: "Data Grid Font") - } - } - internal var caption: String { switch self { case .editor: @@ -42,7 +33,7 @@ internal struct TypographySection: View { } } } header: { - Text(domain.title) + Text("Font") } footer: { Text(domain.caption) } diff --git a/TablePro/Views/Settings/SyncSettingsView.swift b/TablePro/Views/Settings/SyncSettingsView.swift index cc225232ed..9aca20618a 100644 --- a/TablePro/Views/Settings/SyncSettingsView.swift +++ b/TablePro/Views/Settings/SyncSettingsView.swift @@ -25,7 +25,6 @@ struct SyncSettingsView: View { SyncSection() } .formStyle(.grouped) - .scrollContentBackground(.hidden) } } diff --git a/TablePro/Views/Settings/ThemePreviewCard.swift b/TablePro/Views/Settings/ThemePreviewCard.swift index 9f5d7bec03..48c698b7ab 100644 --- a/TablePro/Views/Settings/ThemePreviewCard.swift +++ b/TablePro/Views/Settings/ThemePreviewCard.swift @@ -1,198 +1,191 @@ -// -// ThemePreviewCard.swift -// TablePro -// -// Visual card showing a miniature preview of a theme's color palette. -// - import SwiftUI -struct ThemePreviewCard: View { - enum CardSize { - case standard - case compact - } - - let theme: ThemeDefinition - let isActive: Bool - let onSelect: () -> Void - var size: CardSize = .standard - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - switch size { - case .standard: - standardCard - case .compact: - compactCard - } - } - - // MARK: - Standard Card - - private var standardCard: some View { - Button(action: onSelect) { - VStack(spacing: 4) { - thumbnail - .frame(width: 160, height: 100) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(isActive ? Color.accentColor : Color.clear, lineWidth: 2.5) - ) - .shadow(color: .black.opacity(0.1), radius: 2, y: 1) - - VStack(spacing: 1) { - Text(theme.name) - .font(.subheadline) - .lineLimit(1) - .foregroundStyle(.primary) - - Text(theme.isBuiltIn - ? String(localized: "Built-in") - : String(localized: "Custom")) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - .buttonStyle(.plain) - .frame(width: 160) - } +/// A few lines of SQL and a few grid rows in a theme's colors: the only two surfaces a theme +/// colors. Slots the theme leaves out resolve in the theme's own appearance. +internal struct ThemePreviewCard: View { + internal let theme: ThemeDefinition - // MARK: - Compact Card - - private var compactCard: some View { - thumbnail - .frame(width: 72, height: 45) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .strokeBorder(isActive ? Color.accentColor : Color.clear, lineWidth: 0.5) - ) + private struct Token { + let text: String + let slot: ThemeSlot } - // MARK: - Thumbnail - - private var sidebarStripWidth: CGFloat { - size == .compact ? 22 : 28 + private struct GridCell { + let text: String + let slot: ThemeSlot } - private var codeLineHeight: CGFloat { - size == .compact ? 2.5 : 3 + private static let codeLines: [[Token]] = [ + [Token(text: "-- Active customers", slot: .syntaxComment)], + [ + Token(text: "SELECT", slot: .syntaxKeyword), + Token(text: " name, ", slot: .editorText), + Token(text: "COUNT", slot: .syntaxFunction), + Token(text: "(*) ", slot: .editorText), + Token(text: "AS", slot: .syntaxKeyword), + Token(text: " orders", slot: .editorText), + ], + [ + Token(text: "FROM", slot: .syntaxKeyword), + Token(text: " customers", slot: .editorText), + ], + [ + Token(text: "WHERE", slot: .syntaxKeyword), + Token(text: " country ", slot: .editorText), + Token(text: "=", slot: .syntaxOperator), + Token(text: " 'VN' ", slot: .syntaxString), + Token(text: "AND", slot: .syntaxKeyword), + Token(text: " score ", slot: .editorText), + Token(text: ">", slot: .syntaxOperator), + Token(text: " 42", slot: .syntaxNumber), + ], + [ + Token(text: " AND", slot: .syntaxKeyword), + Token(text: " deleted_at ", slot: .editorText), + Token(text: "IS", slot: .syntaxKeyword), + Token(text: " NULL", slot: .syntaxNull), + Token(text: "::", slot: .syntaxOperator), + Token(text: "TIMESTAMP", slot: .syntaxType), + ], + ] + + private static let currentLine = 3 + private static let selectedLine = 2 + private static let selectedToken = 1 + + private static let gridHeader = ["id", "name", "active"] + private static let gridRows: [[GridCell]] = [ + [ + GridCell(text: "1", slot: .gridText), + GridCell(text: "Ada", slot: .gridText), + GridCell(text: "true", slot: .gridBoolTrue), + ], + [ + GridCell(text: "2", slot: .gridText), + GridCell(text: "NULL", slot: .gridNullValue), + GridCell(text: "false", slot: .gridBoolFalse), + ], + [ + GridCell(text: "3", slot: .gridText), + GridCell(text: "Grace", slot: .gridText), + GridCell(text: "true", slot: .gridBoolTrue), + ], + ] + + private static let selectedRow = 2 + private static let columnWidths: [CGFloat] = [28, 72, 56] + + internal var body: some View { + VStack(spacing: 0) { + editor + grid + } + .font(.system(size: 11, design: .monospaced)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color(nsColor: .separatorColor)) + ) + .environment(\.colorScheme, theme.appearance == .dark ? .dark : .light) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(format: String(localized: "Preview of %@"), theme.name)) } - private var dataGridRowCount: Int { - size == .compact ? 2 : 3 + private func color(_ slot: ThemeSlot) -> Color { + Color(nsColor: theme.color(for: slot)) } - private var dataGridHeight: CGFloat { - size == .compact ? 14 : 28 + // MARK: - Editor + + private var editor: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(Self.codeLines.indices, id: \.self) { index in + HStack(spacing: 8) { + Text(verbatim: "\(index + 1)") + .foregroundStyle(color(.editorLineNumber)) + .frame(width: 14, alignment: .trailing) + codeLine(index) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(index == Self.currentLine ? color(.editorCurrentLine) : Color.clear) + } + } + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(color(.editorBackground)) } - /// The name and kind beside it carry the meaning, so the miniature is decorative. - private var thumbnail: some View { + private func codeLine(_ line: Int) -> some View { HStack(spacing: 0) { - sidebarStrip - .frame(width: sidebarStripWidth) - - VStack(spacing: 0) { - editorArea - dataGridArea + ForEach(Self.codeLines[line].indices, id: \.self) { index in + let token = Self.codeLines[line][index] + let isSelected = line == Self.selectedLine && index == Self.selectedToken + Text(verbatim: token.text) + .foregroundStyle(color(token.slot)) + .background(isSelected ? color(.editorSelection) : Color.clear) + } + if line == Self.currentLine { + Rectangle() + .fill(color(.editorCursor)) + .frame(width: 1.5, height: 12) } } - .environment(\.colorScheme, previewColorScheme) - .accessibilityHidden(true) + .lineLimit(1) + .fixedSize() } - /// The system colours a theme leaves undeclared resolve in the appearance the theme is used - /// in, not in whichever one Settings happens to be showing. - private var previewColorScheme: ColorScheme { - switch theme.appearance { - case .dark: return .dark - case .light: return .light - } - } + // MARK: - Grid - private var sidebarStrip: some View { - ZStack(alignment: .topLeading) { - Rectangle() - .fill(theme.dataGrid.headerBackground.swiftUIColor) - - VStack(alignment: .leading, spacing: size == .compact ? 3 : 4) { - let widths: [CGFloat] = size == .compact - ? [10, 14, 13, 9] - : [14, 18, 17, 12] - ForEach(0..<4, id: \.self) { index in - RoundedRectangle(cornerRadius: 1) - .fill(index == 1 - ? theme.dataGrid.selection.swiftUIColor.opacity(0.6) - : theme.dataGrid.rowNumber.swiftUIColor.opacity(0.6)) - .frame(width: widths[index], height: codeLineHeight) - } + private var grid: some View { + VStack(spacing: 0) { + gridRow( + number: "", + cells: Self.gridHeader.map { GridCell(text: $0, slot: .gridHeaderText) }, + background: color(.gridHeaderBackground) + ) + gridLine + ForEach(Self.gridRows.indices, id: \.self) { index in + let isSelected = index == Self.selectedRow + gridRow( + number: "\(index + 1)", + cells: Self.gridRows[index], + background: rowBackground(index), + textSlot: isSelected ? .gridSelectionText : nil + ) + gridLine } - .padding(.top, size == .compact ? 5 : 8) - .padding(.leading, size == .compact ? 3 : 4) } + .background(color(.gridBackground)) } - private var editorArea: some View { - ZStack(alignment: .topLeading) { - Rectangle() - .fill(theme.editor.background.swiftUIColor) - - VStack(alignment: .leading, spacing: size == .compact ? 3 : 4) { - if size == .compact { - codeLine(widths: [10, 16, 7], - colors: [theme.editor.syntax.keyword, theme.editor.syntax.function, theme.editor.syntax.type]) - codeLine(widths: [7, 22], - colors: [theme.editor.syntax.keyword, theme.editor.syntax.string]) - codeLine(widths: [13, 6, 9], - colors: [theme.editor.syntax.type, theme.editor.syntax.operator, theme.editor.syntax.number]) - } else { - codeLine(widths: [14, 22, 10], - colors: [theme.editor.syntax.keyword, theme.editor.syntax.function, theme.editor.syntax.type]) - codeLine(widths: [10, 30], - colors: [theme.editor.syntax.keyword, theme.editor.syntax.string]) - codeLine(widths: [18, 8, 12], - colors: [theme.editor.syntax.type, theme.editor.syntax.operator, theme.editor.syntax.number]) - codeLine(widths: [26], - colors: [theme.editor.syntax.comment]) - } - } - .padding(.top, size == .compact ? 4 : 6) - .padding(.leading, size == .compact ? 4 : 6) - } + private func rowBackground(_ index: Int) -> Color { + if index == Self.selectedRow { return color(.gridSelection) } + return index.isMultiple(of: 2) ? color(.gridBackground) : color(.gridAlternateRow) } - private func codeLine(widths: [CGFloat], colors: [ThemeColorValue]) -> some View { - HStack(spacing: size == .compact ? 2 : 3) { - ForEach(Array(zip(widths, colors).enumerated()), id: \.offset) { _, pair in - RoundedRectangle(cornerRadius: 1) - .fill(pair.1.swiftUIColor) - .frame(width: pair.0, height: codeLineHeight) + private func gridRow(number: String, cells: [GridCell], background: Color, textSlot: ThemeSlot? = nil) -> some View { + HStack(spacing: 0) { + Text(verbatim: number) + .foregroundStyle(color(textSlot ?? .gridRowNumber)) + .frame(width: 22, alignment: .trailing) + .padding(.trailing, 8) + ForEach(cells.indices, id: \.self) { index in + Text(verbatim: cells[index].text) + .foregroundStyle(color(textSlot ?? cells[index].slot)) + .lineLimit(1) + .frame(width: Self.columnWidths[index], alignment: .leading) } + Spacer(minLength: 0) } + .padding(.vertical, 3) + .background(background) } - private var dataGridArea: some View { - let colors = theme.dataGrid - return VStack(spacing: 0) { - ForEach(0.., databaseType: DatabaseType? = nil) { @@ -33,28 +31,21 @@ struct DDLTextView: View { } var body: some View { - if ddl.isEmpty { - themeEngine.palette.color(.editorBackground) - } else { - SourceEditor( - $text, - language: resolvedLanguage, - configuration: editorConfiguration, - state: $editorState, - foldProvider: foldProvider - ) - .onChange(of: ddl) { newDDL in - text = newDDL - } - .onChange(of: colorScheme) { _ in - editorConfiguration = Self.makeConfiguration(fontSize: fontSize) - } - .onReceive(AppEvents.shared.themeChanged) { _ in - editorConfiguration = Self.makeConfiguration(fontSize: fontSize) - } - .onChange(of: fontSize) { newSize in - editorConfiguration = Self.makeConfiguration(fontSize: newSize) - } + SourceEditor( + $text, + language: resolvedLanguage, + configuration: editorConfiguration, + state: $editorState, + foldProvider: foldProvider + ) + .onChange(of: ddl) { newDDL in + text = newDDL + } + .onReceive(AppEvents.shared.themeChanged) { _ in + editorConfiguration = Self.makeConfiguration(fontSize: fontSize) + } + .onChange(of: fontSize) { newSize in + editorConfiguration = Self.makeConfiguration(fontSize: newSize) } } diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index fd4eec522f..4775081f14 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -94,7 +94,7 @@ extension TableStructureView { if showCopyConfirmation { HStack { Image(systemName: "checkmark.circle.fill") - .foregroundStyle(themeEngine.palette.color(.statusSuccess)) + .foregroundStyle(.green) Text("Copied!") } .transition(.opacity) @@ -117,7 +117,7 @@ extension TableStructureView { .buttonStyle(.bordered) } .padding() - .background(Color(nsColor: .controlBackgroundColor)) + .background(.bar) Divider() diff --git a/TablePro/Views/Structure/TableStructureView+TableComment.swift b/TablePro/Views/Structure/TableStructureView+TableComment.swift index 9d0c14bae3..0e44a2866f 100644 --- a/TablePro/Views/Structure/TableStructureView+TableComment.swift +++ b/TablePro/Views/Structure/TableStructureView+TableComment.swift @@ -53,7 +53,7 @@ internal extension TableStructureView { .accessibilityIdentifier("structure-table-comment") } .padding() - .background(Color(nsColor: .controlBackgroundColor)) + .background(.bar) Divider() } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 024792e4b3..f9e1df3463 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -36,7 +36,6 @@ struct TableStructureView: View { @ObservedObject var toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator? @ObservedObject var selectionState: GridSelectionState - @ObservedObject var themeEngine = ThemeEngine.shared @Environment(\.appServices) var services @@ -568,7 +567,7 @@ struct TableStructureView: View { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) - .foregroundStyle(themeEngine.palette.color(.statusWarning)) + .foregroundStyle(.orange) .accessibilityHidden(true) RevealedTextView(message) .foregroundStyle(.secondary) diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 64175b8ef2..bf880a1214 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -214,7 +214,6 @@ private struct TriggerActionBar: View { } private struct TriggerListPane: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let triggers: [TriggerInfo] @ObservedObject var state: TriggerInspectorState @@ -261,14 +260,13 @@ private struct TriggerListPane: View { private func enabledIndicator(_ trigger: TriggerInfo) -> some View { if let enabled = trigger.enabled { Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") - .foregroundStyle(enabled ? themeEngine.palette.color(.statusSuccess) : Color.secondary) + .selectionAwareTint(enabled ? .green : .secondary) .accessibilityLabel(enabled ? String(localized: "Enabled") : String(localized: "Disabled")) } } } private struct TriggerDetailPane: View { - @ObservedObject private var themeEngine = ThemeEngine.shared let triggers: [TriggerInfo] @ObservedObject var state: TriggerInspectorState let databaseType: DatabaseType @@ -284,7 +282,7 @@ private struct TriggerDetailPane: View { onOpenInEditor: { onOpenInEditor(trigger) } ) } else { - themeEngine.palette.color(.editorBackground) + UnavailableStateView.search(text: state.searchText) } } diff --git a/TablePro/Views/Structure/TriggerEditorView.swift b/TablePro/Views/Structure/TriggerEditorView.swift index 4b93bbdca1..fea732ca49 100644 --- a/TablePro/Views/Structure/TriggerEditorView.swift +++ b/TablePro/Views/Structure/TriggerEditorView.swift @@ -28,7 +28,6 @@ struct TriggerEditorView: View { @State private var editorConfiguration: SourceEditorConfiguration @State private var isApplying = false @State private var errorMessage: String? - @Environment(\.colorScheme) private var colorScheme @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var fontSize: Double = 13 init( @@ -73,9 +72,6 @@ struct TriggerEditorView: View { } } .frame(minWidth: 560, idealWidth: 680, minHeight: 360, idealHeight: 460) - .onChange(of: colorScheme) { _ in - editorConfiguration = Self.makeConfiguration(fontSize: fontSize) - } .onReceive(AppEvents.shared.themeChanged) { _ in editorConfiguration = Self.makeConfiguration(fontSize: fontSize) } diff --git a/TablePro/Views/Welcome/WelcomeActionsPanel.swift b/TablePro/Views/Welcome/WelcomeActionsPanel.swift index 139cb053ed..2d3ce72383 100644 --- a/TablePro/Views/Welcome/WelcomeActionsPanel.swift +++ b/TablePro/Views/Welcome/WelcomeActionsPanel.swift @@ -35,7 +35,7 @@ struct WelcomeActionsPanel: View { Image(nsImage: NSApp.applicationIconImage) .resizable() .frame(width: 96, height: 96) - .shadow(color: .black.opacity(0.18), radius: 8, x: 0, y: 4) + .shadow(color: Color(nsColor: .shadowColor).opacity(0.18), radius: 8, x: 0, y: 4) .accessibilityHidden(true) VStack(spacing: 4) { diff --git a/TablePro/Views/Welcome/WelcomeOutlineRows.swift b/TablePro/Views/Welcome/WelcomeOutlineRows.swift index dace0ad456..7beda6e68d 100644 --- a/TablePro/Views/Welcome/WelcomeOutlineRows.swift +++ b/TablePro/Views/Welcome/WelcomeOutlineRows.swift @@ -45,8 +45,8 @@ internal struct ConnectionTile: View { .scaledToFit() .font(.system(size: size * 0.5, weight: .medium)) .frame(width: size * 0.58, height: size * 0.58) - if identityColor != nil { - image.foregroundStyle(.white) + if let identityColor { + image.foregroundStyle(Color.legibleForeground(on: identityColor.color)) } else { image.selectionAwareTint(type.themeColor) } diff --git a/TableProTests/Models/Settings/EditorZoomTests.swift b/TableProTests/Models/Settings/EditorZoomTests.swift new file mode 100644 index 0000000000..3f225bc270 --- /dev/null +++ b/TableProTests/Models/Settings/EditorZoomTests.swift @@ -0,0 +1,46 @@ +import AppKit +@testable import TablePro +import Testing + +struct EditorZoomTests { + @Test("Zoom moves the editor size one point inside the range") + func zoomSteps() { + let typography = TypographySettings(editorFontSize: 13) + #expect(typography.editorFontSize(zoomedBy: 1) == 14) + #expect(typography.editorFontSize(zoomedBy: -1) == 12) + } + + @Test("Zoom has nowhere to go at either end of the range") + func zoomStopsAtTheLimits() { + let largest = TypographySettings(editorFontSize: TypographySettings.sizeRange.upperBound) + let smallest = TypographySettings(editorFontSize: TypographySettings.sizeRange.lowerBound) + #expect(largest.editorFontSize(zoomedBy: 1) == nil) + #expect(largest.editorFontSize(zoomedBy: -1) == TypographySettings.sizeRange.upperBound - 1) + #expect(smallest.editorFontSize(zoomedBy: -1) == nil) + #expect(smallest.editorFontSize(zoomedBy: 1) == TypographySettings.sizeRange.lowerBound + 1) + } + + @Test("A stored size outside the range zooms from the size that is drawn") + func outOfRangeSizeZoomsFromTheDrawnSize() { + let typography = TypographySettings(editorFontSize: 30) + #expect(typography.editorFontSize(zoomedBy: 1) == nil) + #expect(typography.editorFontSize(zoomedBy: -1) == TypographySettings.sizeRange.upperBound - 1) + } + + @MainActor + @Test("Zoom In and Zoom Out each dim at their own end of the range") + func menuItemsFollowTheRange() { + let zoomIn = #selector(MainSplitViewController.zoomIn(_:)) + let zoomOut = #selector(MainSplitViewController.zoomOut(_:)) + + var context = MenuValidationContext() + context.canZoomOut = true + #expect(MainSplitViewController.resolvedEnablement(zoomIn, context: context) == false) + #expect(MainSplitViewController.resolvedEnablement(zoomOut, context: context) == true) + + context.canZoomIn = true + context.canZoomOut = false + #expect(MainSplitViewController.resolvedEnablement(zoomIn, context: context) == true) + #expect(MainSplitViewController.resolvedEnablement(zoomOut, context: context) == false) + } +} diff --git a/TableProTests/Models/Settings/LegacyThemeFontsTests.swift b/TableProTests/Models/Settings/LegacyThemeFontsTests.swift new file mode 100644 index 0000000000..4322d7701f --- /dev/null +++ b/TableProTests/Models/Settings/LegacyThemeFontsTests.swift @@ -0,0 +1,126 @@ +import Foundation +@testable import TablePro +import Testing + +struct LegacyThemeFontsTests { + private func makeDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-theme-fonts-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + /// The shape TablePro 0.80 wrote: format 1, with the fonts inside the theme. + @discardableResult + private func writeTheme( + id: String, + editorSize: Int? = nil, + dataGridSize: Int? = nil, + editorFamily: String? = nil, + fileName: String? = nil, + in directory: URL + ) throws -> URL { + var fonts: [String: Any] = [:] + fonts["editorFontSize"] = editorSize + fonts["dataGridFontSize"] = dataGridSize + fonts["editorFontFamily"] = editorFamily + + var object: [String: Any] = ["id": id, "name": id, "version": 1, "appearance": "dark"] + if !fonts.isEmpty { + object["fonts"] = fonts + } + + let url = directory.appendingPathComponent(fileName ?? "\(id).json", isDirectory: false) + try JSONSerialization.data(withJSONObject: object).write(to: url) + return url + } + + private func appearance(_ mode: AppAppearanceMode) -> AppearanceSettings { + AppearanceSettings(appearanceMode: mode, preferredLightThemeId: "user.light", preferredDarkThemeId: "user.dark") + } + + @Test("Dark mode carries the fonts of the theme in the dark slot") + func darkModeReadsTheDarkSlot() throws { + let directory = try makeDirectory() + try writeTheme(id: "user.light", editorSize: 12, in: directory) + try writeTheme(id: "user.dark", editorSize: 16, in: directory) + + let carried = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: false, themesDirectory: directory) + #expect(carried.editorFontSize == 16) + } + + @Test("Auto follows the system appearance at the moment of the upgrade") + func autoFollowsTheSystem() throws { + let directory = try makeDirectory() + try writeTheme(id: "user.light", editorSize: 12, in: directory) + try writeTheme(id: "user.dark", editorSize: 16, in: directory) + + let night = LegacyThemeFonts.read(appearance: appearance(.auto), systemIsDark: true, themesDirectory: directory) + let day = LegacyThemeFonts.read(appearance: appearance(.auto), systemIsDark: false, themesDirectory: directory) + #expect(night.editorFontSize == 16) + #expect(day.editorFontSize == 12) + } + + @Test("The other slot fills in when the theme on screen carried no fonts") + func otherSlotFillsIn() throws { + let directory = try makeDirectory() + try writeTheme(id: "user.light", editorSize: 15, in: directory) + try writeTheme(id: "user.dark", in: directory) + + let carried = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: true, themesDirectory: directory) + #expect(carried.editorFontSize == 15) + } + + /// 0.80 stored zoom up to 24 but drew the editor at 11...18 and the grid at 10...18. + @Test("A stored size outside what 0.80 drew carries as the size it drew") + func sizesCarryAsDrawn() throws { + let directory = try makeDirectory() + try writeTheme(id: "user.dark", editorSize: 24, dataGridSize: 9, in: directory) + try writeTheme(id: "user.light", editorSize: 9, dataGridSize: 24, in: directory) + + let dark = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: true, themesDirectory: directory) + #expect(dark.editorFontSize == 18) + #expect(dark.dataGridFontSize == 10) + + let light = LegacyThemeFonts.read(appearance: appearance(.light), systemIsDark: false, themesDirectory: directory) + #expect(light.editorFontSize == 11) + #expect(light.dataGridFontSize == 18) + } + + @Test("A theme file named by the user rather than by its id is still found") + func fileNameNeedNotMatchTheId() throws { + let directory = try makeDirectory() + try writeTheme(id: "user.dark", editorSize: 17, editorFamily: "Menlo", fileName: "My Theme.json", in: directory) + + let carried = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: true, themesDirectory: directory) + #expect(carried.editorFontSize == 17) + #expect(carried.editorFontFamily == "Menlo") + } + + @Test("No old theme files leaves the default fonts") + func nothingToCarry() throws { + let directory = try makeDirectory() + let carried = LegacyThemeFonts.read(appearance: appearance(.dark), systemIsDark: true, themesDirectory: directory) + #expect(carried == .default) + } + + @Test("The first load writes the carried fonts once and leaves the theme file untouched") + func firstLoadWritesOnce() throws { + let directory = try makeDirectory() + let file = try writeTheme(id: "user.dark", editorSize: 16, in: directory) + let original = try Data(contentsOf: file) + + let defaults = try #require(UserDefaults(suiteName: "legacy-theme-fonts-\(UUID().uuidString)")) + let storage = AppSettingsStorage(userDefaults: defaults) + storage.saveAppearance(appearance(.dark)) + + let first = storage.loadTypography(systemIsDark: true, legacyThemesDirectory: directory) + #expect(first.editorFontSize == 16) + let afterLoad = try Data(contentsOf: file) + #expect(afterLoad == original) + + try writeTheme(id: "user.dark", editorSize: 12, in: directory) + let second = storage.loadTypography(systemIsDark: true, legacyThemesDirectory: directory) + #expect(second.editorFontSize == 16) + } +} diff --git a/TableProTests/Models/UI/JSONTreeParserTests.swift b/TableProTests/Models/UI/JSONTreeParserTests.swift index 049991050b..67038374d1 100644 --- a/TableProTests/Models/UI/JSONTreeParserTests.swift +++ b/TableProTests/Models/UI/JSONTreeParserTests.swift @@ -316,17 +316,29 @@ struct JSONTreeParserTests { #expect(marker.decoration == .none) } + @Test("each tone keeps the color its values were drawn in") + func tonesKeepTheirColors() { + expectSystemTones() + } + @MainActor - @Test("each tone takes its color from the theme's syntax and panel slots") - func tonesTakeTheThemeColors() { - let palette = ThemeEngine.shared.palette - #expect(TreeValueTone.container.color == palette[.syntaxKeyword]) - #expect(TreeValueTone.string.color == palette[.syntaxString]) - #expect(TreeValueTone.number.color == palette[.syntaxNumber]) - #expect(TreeValueTone.literal.color == palette[.syntaxNull]) - #expect(TreeValueTone.serialized.color == palette[.syntaxType]) + @Test("A theme that overrides every slot leaves each tone on its system color") + func tonesIgnoreTheTheme() throws { + try PaneThemeFixture.withEverySlotOverridden { + #expect(ThemeEngine.shared.resolved.isOverridden(.syntaxKeyword)) + expectSystemTones() + } + } + + private func expectSystemTones() { + #expect(TreeValueTone.key.color == .systemBlue) + #expect(TreeValueTone.container.color == .systemBlue) + #expect(TreeValueTone.string.color == .systemRed) + #expect(TreeValueTone.number.color == .systemPurple) + #expect(TreeValueTone.literal.color == .systemOrange) + #expect(TreeValueTone.serialized.color == .systemTeal) #expect(TreeValueTone.reference.color == .systemGray) - #expect(TreeValueTone.muted.color == palette[.panelSecondaryText]) + #expect(TreeValueTone.muted.color == .secondaryLabelColor) } // MARK: - Search diff --git a/TableProTests/Theme/LiteralColorGuardTests.swift b/TableProTests/Theme/LiteralColorGuardTests.swift new file mode 100644 index 0000000000..475675673e --- /dev/null +++ b/TableProTests/Theme/LiteralColorGuardTests.swift @@ -0,0 +1,113 @@ +// +// LiteralColorGuardTests.swift +// TableProTests +// +// A hand-set RGB, white or hex color never follows Light and Dark, Accent, Highlight or Increase +// Contrast. Colors are either a system semantic color or a value inside a theme definition. +// + +import Foundation +import Testing + +struct LiteralColorGuardTests { + private static let scannedTrees = ["TablePro", "Packages/TableProEditor/Sources"] + + // Theme definitions are where fixed colors belong. + private static let allowedDirectories = ["TablePro/Theme/"] + + // A plugin's brand color is metadata about the engine, drawn on its glyph. + private static let brandColorFilePrefix = "PluginMetadataRegistry" + private static let brandColorLineMarker = "brandColorHex" + + private static let allowedFiles = [ + // Maps a TablePlus status color from a connection URL onto the connection palette. + "ConnectionURLParser.swift", + // Swatches a CSS color found in the user's own JSON or PHP value. + "RGBAColor+NSColor.swift", + // Derives a gray from a color it is handed, inside the editor package. + "NSColor+Greyscale.swift", + ] + + static func isAllowed(path: String, line: String) -> Bool { + if allowedDirectories.contains(where: { path.hasPrefix($0) }) { + return true + } + let fileName = (path as NSString).lastPathComponent + if allowedFiles.contains(fileName) || fileName.contains("Fixture") { + return true + } + return fileName.hasPrefix(brandColorFilePrefix) && line.contains(brandColorLineMarker) + } + + static func rules() throws -> [ThemeSourceScan.Rule] { + [ + try ThemeSourceScan.Rule("RGB color", #"\b(?:NS)?Color\(red:"#), + try ThemeSourceScan.Rule( + "RGB color", + #"\bNSColor\((?:srgbRed|calibratedRed|deviceRed|displayP3Red):"# + ), + try ThemeSourceScan.Rule("color space color", #"\bColor\(\s*\.(?:sRGB|sRGBLinear|displayP3)\s*,"#), + try ThemeSourceScan.Rule("white color", #"\b(?:NS)?Color\(white:"#), + try ThemeSourceScan.Rule("white color", #"\bNSColor\((?:calibratedWhite|deviceWhite):"#), + try ThemeSourceScan.Rule("hex color", ##""#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?(?![0-9A-Fa-f])"##), + ] + } + + @Test("No literal color outside the theme definitions") + func noLiteralColors() throws { + let rules = try Self.rules() + let tree = try ThemeSourceScan.scan(Self.scannedTrees, rules: rules) + let lines = tree.lines.filter { !Self.isAllowed(path: $0.path, line: $0.text) } + let offenders = ThemeSourceScan.violations(in: lines, rules: rules) + + #expect(tree.fileCount > 1_000, "Scanned only \(tree.fileCount) files; the tree did not resolve") + #expect( + offenders.isEmpty, + """ + These hand-set a color. Use a system color (Color.primary, .secondary, NSColor.labelColor, \ + .systemRed, .separatorColor) or, for the editor and the grid, a theme slot: + \(offenders.joined(separator: "\n")) + """ + ) + } + + @Test("Every allowlisted file still exists") + func allowlistIsCurrent() throws { + var fileNames: Set = [] + _ = try ThemeSourceScan.scan(Self.scannedTrees) { path in + fileNames.insert((path as NSString).lastPathComponent) + return false + } + + for name in Self.allowedFiles { + #expect(fileNames.contains(name), "\(name) is allowlisted but no longer exists") + } + } + + @Test("The guard flags each literal form and passes system colors") + func matcherCatchesAViolation() throws { + let sample = """ + Color(red: 1.0, green: 0.4, blue: 0.47) + NSColor(srgbRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) + context.fill(Path(rect), with: .color(Color(white: 0.88))) + NSColor(calibratedWhite: 0.5, alpha: 1) + let accent = "#FF79C6" + let tint = Color(.sRGB, red: 0.2, green: 0.4, blue: 0.6, opacity: 1) + // Color(red: 0, green: 0, blue: 0) in a comment + let label = NSColor.labelColor + let issue = "#1234 was fixed" + let tooLong = "#FF79C6A" + """ + let lines = ThemeSourceScan.flaggedLines(in: sample, rules: try Self.rules()) + + #expect(lines == [1, 2, 3, 4, 5, 6]) + } + + @Test("Brand colors are allowed only on their own line in the registry") + func brandColorExemptionIsNarrow() { + let registry = "TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift" + #expect(Self.isAllowed(path: registry, line: ##"brandColorHex: "#336791","##)) + #expect(!Self.isAllowed(path: registry, line: ##"let tint = "#336791""##)) + #expect(!Self.isAllowed(path: "TablePro/Views/AIChat/ChatComposerView.swift", line: "Color(red: 1, green: 0, blue: 0)")) + } +} diff --git a/TableProTests/Theme/NativeDefaultsTests.swift b/TableProTests/Theme/NativeDefaultsTests.swift new file mode 100644 index 0000000000..397f8ad5e4 --- /dev/null +++ b/TableProTests/Theme/NativeDefaultsTests.swift @@ -0,0 +1,202 @@ +// +// NativeDefaultsTests.swift +// TableProTests +// +// Default Light and Default Dark have to render exactly like an unthemed NSTextView and +// NSTableView, following Accent, Highlight and Increase Contrast. That holds only while every +// slot they leave out hands back the system color object itself: a hex default, or a copy +// flattened under a hand-built appearance, freezes it. +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct NativeDefaultsTests { + private static let surroundSlots = ThemeSlot.allCases.filter { $0.group != .syntax } + + private static func hex(_ color: NSColor) -> String { + HexColor.string(from: color) + } + + private static func theme( + _ appearance: ThemeAppearance, + overriding overrides: [ThemeSlot: String] + ) throws -> ThemeDefinition { + var colors: [ThemeSlot: ThemeColor] = [:] + for (slot, value) in overrides { + colors[slot] = try ThemeColor(validating: value) + } + return ThemeDefinition(id: "user.native", name: "Native", author: "", appearance: appearance, overrides: colors) + } + + private static func bundledTheme(_ id: String) throws -> ThemeDefinition { + let url = try ThemeSourceScan.repositoryRoot().appendingPathComponent("TablePro/Resources/Themes/\(id).json") + return try ThemeDocument(data: try Data(contentsOf: url)).definition() + } + + // MARK: - Definitions + + @Test("The default themes hand back the native color object for every non-syntax slot") + func defaultsReturnTheNativeObject() { + for theme in BuiltInThemes.all { + for slot in Self.surroundSlots { + #expect(!theme.isOverridden(slot), "\(theme.id) overrides \(slot.rawValue)") + #expect(theme.color(for: slot) === slot.nativeColor, "\(theme.id) replaces native \(slot.rawValue)") + } + } + } + + @Test("Each surface's native color is the one AppKit draws there") + func nativeColorsAreAppKitDefaults() { + for theme in BuiltInThemes.all { + #expect(theme.color(for: .editorBackground) === NSColor.textBackgroundColor) + #expect(theme.color(for: .editorText) === NSColor.textColor) + #expect(theme.color(for: .editorSelection) === NSColor.selectedTextBackgroundColor) + #expect(theme.color(for: .gridBackground) === NSColor.controlBackgroundColor) + #expect(theme.color(for: .gridLine) === NSColor.separatorColor) + #expect(theme.color(for: .gridHeaderText) === NSColor.headerTextColor) + #expect(theme.color(for: .gridSelection) === NSColor.selectedContentBackgroundColor) + #expect(theme.color(for: .gridInactiveSelection) === NSColor.unemphasizedSelectedContentBackgroundColor) + #expect(theme.color(for: .gridFocusBorder) === NSColor.keyboardFocusIndicatorColor) + } + } + + /// macOS 14 draws the caret in the Highlight color, not in the text color. + @Test("The default caret is the system insertion point color") + func caretIsTheSystemInsertionPoint() { + guard #available(macOS 14.0, *) else { return } + for theme in BuiltInThemes.all { + #expect(theme.color(for: .editorCursor) === NSColor.textInsertionPointColor) + } + } + + @Test("Operator falls back to the editor text, native or overridden") + func operatorFollowsEditorText() throws { + for theme in BuiltInThemes.all { + #expect(theme.color(for: .syntaxOperator) === NSColor.textColor) + } + + let textOnly = try Self.theme(.dark, overriding: [.editorText: "#112233"]) + #expect(Self.hex(textOnly.color(for: .syntaxOperator)) == "#112233") + + let both = try Self.theme(.dark, overriding: [.editorText: "#112233", .syntaxOperator: "#445566"]) + #expect(Self.hex(both.color(for: .syntaxOperator)) == "#445566") + } + + @Test("A theme that overrides only the background keeps every other surface native") + func backgroundOnlyThemeKeepsNativeSelection() throws { + let theme = try Self.theme(.dark, overriding: [.editorBackground: "#282A36"]) + + #expect(Self.hex(theme.color(for: .editorBackground)) == "#282A36") + #expect(theme.color(for: .editorSelection) === NSColor.selectedTextBackgroundColor) + #expect(theme.color(for: .editorCursor) === ThemeSlot.editorCursor.nativeColor) + #expect(theme.color(for: .gridSelection) === NSColor.selectedContentBackgroundColor) + #expect(theme.color(for: .gridBackground) === NSColor.controlBackgroundColor) + } + + /// Syntax has no system color, so a theme that leaves one out takes its own appearance's default. + @Test("A syntax slot a theme leaves out takes the same-appearance default") + func missingSyntaxTakesTheSameAppearanceDefault() throws { + let dark = try Self.theme(.dark, overriding: [.editorBackground: "#282A36"]) + let light = try Self.theme(.light, overriding: [.editorBackground: "#FFFFF0"]) + + #expect(Self.hex(dark.color(for: .syntaxKeyword)) == "#FC5FA3") + #expect(Self.hex(light.color(for: .syntaxKeyword)) == "#9B2393") + #expect(Self.hex(light.color(for: .syntaxString)) == "#C41A16") + } + + // MARK: - Resolved theme + + @Test("ResolvedTheme keeps the system color object for every slot the theme leaves out") + func resolvedThemeNeverFlattens() throws { + for theme in BuiltInThemes.all { + let resolved = ResolvedTheme(definition: theme) + for slot in Self.surroundSlots { + #expect(!resolved.isOverridden(slot)) + #expect(resolved[slot] === slot.nativeColor, "\(theme.id) flattens \(slot.rawValue)") + } + } + + let partial = ResolvedTheme(definition: try Self.theme(.dark, overriding: [.gridBackground: "#282A36"])) + #expect(partial.isOverridden(.gridBackground)) + #expect(Self.hex(partial[.gridBackground]) == "#282A36") + #expect(!partial.isOverridden(.gridSelection)) + #expect(partial[.gridSelection] === NSColor.selectedContentBackgroundColor) + } + + @Test("cgColor resolves a system slot inside the appearance it is given") + func cgColorResolvesInTheGivenAppearance() throws { + let resolved = ResolvedTheme(definition: BuiltInThemes.light) + var resolvedComponents: [NSAppearance.Name: [CGFloat]] = [:] + + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + var expected: [CGFloat] = [] + appearance.performAsCurrentDrawingAppearance { + expected = NSColor.textBackgroundColor.cgColor.components ?? [] + } + let actual = resolved.cgColor(.editorBackground, in: appearance).components ?? [] + + #expect(!actual.isEmpty) + #expect(actual == expected, "\(name.rawValue)") + resolvedComponents[name] = actual + } + + #expect(resolvedComponents[.aqua] != resolvedComponents[.darkAqua]) + } + + // MARK: - Palette + + @Test("The Default palette resolves to the native color in Aqua and Dark Aqua") + func defaultPaletteResolvesNatively() throws { + let palette = ThemePalette(revision: 910_001, source: ThemeSource()) + + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + appearance.performAsCurrentDrawingAppearance { + for slot in Self.surroundSlots { + #expect( + Self.hex(palette[slot]) == Self.hex(slot.nativeColor), + "\(slot.rawValue) in \(name.rawValue)" + ) + } + } + } + } + + @Test("The palette takes each appearance's syntax from that appearance's default") + func paletteSyntaxFollowsTheAppearance() throws { + let palette = ThemePalette(revision: 910_002, source: ThemeSource()) + let aqua = try #require(NSAppearance(named: .aqua)) + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + + aqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(palette[.syntaxKeyword]) == "#9B2393") + } + darkAqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(palette[.syntaxKeyword]) == "#FC5FA3") + } + } + + @Test("Dracula colors its surfaces in Dark Aqua and leaves Aqua to the light default") + func draculaPaletteResolvesItsBackground() throws { + let dracula = try Self.bundledTheme("tablepro.dracula") + let source = ThemeSource() + source.update(ThemePair(light: BuiltInThemes.light, dark: dracula)) + let palette = ThemePalette(revision: 910_003, source: source) + let aqua = try #require(NSAppearance(named: .aqua)) + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + + darkAqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(palette[.editorBackground]) == "#282A36") + #expect(Self.hex(palette[.gridBackground]) == "#282A36") + } + aqua.performAsCurrentDrawingAppearance { + #expect(Self.hex(palette[.editorBackground]) == Self.hex(NSColor.textBackgroundColor)) + #expect(Self.hex(palette[.gridSelection]) == Self.hex(NSColor.selectedContentBackgroundColor)) + } + } +} diff --git a/TableProTests/Theme/PaneBackgroundGuardTests.swift b/TableProTests/Theme/PaneBackgroundGuardTests.swift new file mode 100644 index 0000000000..b4c5cc4aa3 --- /dev/null +++ b/TableProTests/Theme/PaneBackgroundGuardTests.swift @@ -0,0 +1,52 @@ +// +// PaneBackgroundGuardTests.swift +// TableProTests +// +// A List, Form or ScrollView draws its own background, and that background follows the system. +// Hiding it to paint another one, or wrapping a pane in the old `themedContent` surface, is how +// panes stopped matching an unthemed Mac app. +// + +import Foundation +import Testing + +struct PaneBackgroundGuardTests { + static func rules() throws -> [ThemeSourceScan.Rule] { + [ + try ThemeSourceScan.Rule("hidden scroll content background", #"\.scrollContentBackground\(\s*\.hidden\s*\)"#), + try ThemeSourceScan.Rule("themed content surface", #"\b[Tt]hemedContent"#), + ] + } + + @Test("No pane hides its control's own background") + func panesKeepTheirOwnBackground() throws { + let rules = try Self.rules() + let tree = try ThemeSourceScan.scan(["TablePro"], rules: rules) + let offenders = ThemeSourceScan.violations(in: tree.lines, rules: rules) + + #expect(tree.fileCount > 1_000, "Scanned only \(tree.fileCount) files; the tree did not resolve") + #expect( + offenders.isEmpty, + """ + Let the List, Form or ScrollView draw its own background: + \(offenders.joined(separator: "\n")) + """ + ) + } + + @Test("The guard flags a hidden background and the themed surface") + func matcherCatchesAViolation() throws { + let sample = """ + Form { content } + .scrollContentBackground(.hidden) + List { rows }.themedContent() + // .scrollContentBackground(.hidden) was removed + ScrollView { rows } + .scrollContentBackground(.visible) + struct ThemedContentSurface: ViewModifier {} + """ + let lines = ThemeSourceScan.flaggedLines(in: sample, rules: try Self.rules()) + + #expect(lines == [2, 3, 7]) + } +} diff --git a/TableProTests/Theme/ThemeCatalogTests.swift b/TableProTests/Theme/ThemeCatalogTests.swift new file mode 100644 index 0000000000..35c323636e --- /dev/null +++ b/TableProTests/Theme/ThemeCatalogTests.swift @@ -0,0 +1,333 @@ +// +// ThemeCatalogTests.swift +// TableProTests +// +// A user theme is identified by its JSON id but lives in whatever file it came from. When the +// catalog wrote and deleted `.json` instead, deleting a theme a user had named themselves +// removed nothing, and editing it wrote a second file that could win on the next launch. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct ThemeCatalogTests { + private struct Sandbox { + let root: URL + var themes: URL { root.appendingPathComponent("Themes", isDirectory: true) } + var incoming: URL { root.appendingPathComponent("Incoming", isDirectory: true) } + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("ThemeCatalogTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: themes, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: incoming, withIntermediateDirectories: true) + } + + @MainActor + func catalog() -> ThemeCatalog { + ThemeCatalog(userDirectory: themes, bundleDirectory: nil) + } + + @discardableResult + func write(_ theme: ThemeDefinition, as fileName: String, in directory: URL? = nil) throws -> URL { + let url = (directory ?? themes).appendingPathComponent(fileName) + try ThemeEncoder.data(for: theme).write(to: url) + return url + } + + func fileNames() throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: themes.path).sorted() + } + + func read(_ fileName: String) throws -> ThemeDefinition { + try ThemeDocument(data: try Data(contentsOf: themes.appendingPathComponent(fileName))).definition() + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + } + + private static func userTheme( + id: String, + name: String, + overrides: [ThemeSlot: String] = [:] + ) throws -> ThemeDefinition { + var colors: [ThemeSlot: ThemeColor] = [:] + for (slot, value) in overrides { + colors[slot] = try ThemeColor(validating: value) + } + return ThemeDefinition(id: id, name: name, author: "Tests", appearance: .dark, overrides: colors) + } + + private static func copyName(of name: String) -> String { + String(format: String(localized: "%@ Copy"), name) + } + + // MARK: - Changed on disk + + @Test("A file edited outside TablePro is not overwritten by a save") + func saveKeepsAFileChangedOnDisk() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "mine.json") + let catalog = sandbox.catalog() + var stale = try #require(catalog.theme(id: "user.mine")) + + try sandbox.write( + try Self.userTheme(id: "user.mine", name: "Mine", overrides: [.editorBackground: "#112233"]), + as: "mine.json" + ) + stale.overrides[.editorText] = try ThemeColor(validating: "#FFFFFF") + + #expect(throws: ThemeLoadError.changedOnDisk("mine.json")) { try catalog.save(stale) } + #expect(try sandbox.read("mine.json").overrides[.editorBackground]?.hex == "#112233") + #expect(catalog.theme(id: "user.mine")?.overrides[.editorBackground]?.hex == "#112233") + + var current = try #require(catalog.theme(id: "user.mine")) + current.overrides[.editorText] = try ThemeColor(validating: "#FFFFFF") + try catalog.save(current) + #expect(try sandbox.read("mine.json").overrides[.editorText]?.hex == "#FFFFFF") + } + + @Test("A file replaced outside TablePro is not deleted") + func deleteKeepsAFileChangedOnDisk() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "mine.json") + let catalog = sandbox.catalog() + + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Replaced"), as: "mine.json") + + #expect(throws: ThemeLoadError.changedOnDisk("mine.json")) { try catalog.delete(id: "user.mine") } + #expect(try sandbox.fileNames() == ["mine.json"]) + #expect(catalog.theme(id: "user.mine")?.name == "Replaced") + } + + // MARK: - File name differs from id + + @Test("A theme is saved back to the file it was loaded from") + func saveRewritesTheLoadedFile() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "My Theme.json") + + let catalog = sandbox.catalog() + #expect(catalog.fileURL(for: "user.mine")?.lastPathComponent == "My Theme.json") + + var edited = try #require(catalog.theme(id: "user.mine")) + edited.overrides[.gridBackground] = try ThemeColor(validating: "#123456") + try catalog.save(edited) + + #expect(try sandbox.fileNames() == ["My Theme.json"]) + #expect(try sandbox.read("My Theme.json").overrides[.gridBackground]?.hex == "#123456") + #expect(sandbox.catalog().theme(id: "user.mine")?.overrides[.gridBackground]?.hex == "#123456") + } + + @Test("A theme is deleted from the file it was loaded from") + func deleteRemovesTheLoadedFile() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.mine", name: "Mine"), as: "My Theme.json") + + let catalog = sandbox.catalog() + try catalog.delete(id: "user.mine") + + #expect(try sandbox.fileNames().isEmpty) + #expect(catalog.theme(id: "user.mine") == nil) + #expect(catalog.fileURL(for: "user.mine") == nil) + #expect(sandbox.catalog().theme(id: "user.mine") == nil) + } + + @Test("Rename rewrites the same file") + func renameRewritesTheSameFile() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.renamed", name: "Before"), as: "Custom Name.json") + + let catalog = sandbox.catalog() + try catalog.rename(id: "user.renamed", to: " After ") + + #expect(try sandbox.fileNames() == ["Custom Name.json"]) + #expect(try sandbox.read("Custom Name.json").name == "After") + #expect(catalog.theme(id: "user.renamed")?.name == "After") + } + + // MARK: - Loading + + @Test("Two files with one id: the first by file name loads, the second is listed as not loaded") + func duplicateIdentifierIsRejected() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.same", name: "First"), as: "a.json") + try sandbox.write(try Self.userTheme(id: "user.same", name: "Second"), as: "b.json") + + let catalog = sandbox.catalog() + let expectedReason = ThemeLoadError.duplicateIdentifier("user.same", existingFile: "a.json").localizedDescription + + #expect(catalog.theme(id: "user.same")?.name == "First") + #expect(catalog.fileURL(for: "user.same")?.lastPathComponent == "a.json") + #expect(catalog.themes.filter { $0.id == "user.same" }.count == 1) + #expect(catalog.rejected.map(\.fileName) == ["b.json"]) + #expect(catalog.rejected.first?.reason == expectedReason) + } + + @Test("A user file with a reserved id is listed as not loaded") + func reservedIdInUserFileIsRejected() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "tablepro.dracula", name: "Fake Dracula"), as: "dracula.json") + try sandbox.write(try Self.userTheme(id: BuiltInThemes.defaultDarkId, name: "Fake Dark"), as: "dark.json") + + let catalog = sandbox.catalog() + + #expect(catalog.theme(id: "tablepro.dracula") == nil) + #expect(catalog.theme(id: BuiltInThemes.defaultDarkId) == BuiltInThemes.dark) + #expect(catalog.rejected.map(\.fileName).sorted() == ["dark.json", "dracula.json"]) + #expect(catalog.rejected.contains { + $0.reason == ThemeLoadError.reservedIdentifier("tablepro.dracula").localizedDescription + }) + } + + @Test("A file in an older format is listed with its reason") + func olderFormatIsListedWithItsReason() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let old: [String: Any] = ["schema": 2, "id": "user.old", "name": "Old", "appearance": "dark"] + try JSONSerialization.data(withJSONObject: old).write(to: sandbox.themes.appendingPathComponent("old.json")) + + let catalog = sandbox.catalog() + + #expect(catalog.theme(id: "user.old") == nil) + #expect(catalog.rejected.first?.reason == ThemeLoadError.schemaTooOld(found: 2, supported: 3).localizedDescription) + } + + @Test("The bundled themes load after the two defaults") + func bundledThemesLoad() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let bundle = try ThemeSourceScan.repositoryRoot().appendingPathComponent("TablePro/Resources/Themes") + + let catalog = ThemeCatalog(userDirectory: sandbox.themes, bundleDirectory: bundle) + let ids = catalog.themes.map(\.id) + + #expect(Array(ids.prefix(2)) == [BuiltInThemes.defaultLightId, BuiltInThemes.defaultDarkId]) + #expect(Set(ids).isSuperset(of: ["tablepro.dracula", "tablepro.nord"])) + #expect(catalog.rejected.isEmpty) + } + + // MARK: - Duplicate + + @Test("Duplicate gives a new user id and a Finder-style name") + func duplicateNamesCopies() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + try sandbox.write(try Self.userTheme(id: "user.ocean", name: "Ocean", overrides: [.gridText: "#E0E0E0"]), as: "ocean.json") + + let catalog = sandbox.catalog() + let source = try #require(catalog.theme(id: "user.ocean")) + let first = try catalog.duplicate(source) + let second = try catalog.duplicate(source) + + #expect(first.id.hasPrefix(ThemeDefinition.userPrefix)) + #expect(second.id.hasPrefix(ThemeDefinition.userPrefix)) + #expect(Set([source.id, first.id, second.id]).count == 3) + #expect(first.name == Self.copyName(of: "Ocean")) + #expect(second.name == Self.copyName(of: "Ocean") + " 2") + #expect(first.overrides == source.overrides) + #expect(first.appearance == source.appearance) + #expect(first.author.isEmpty) + #expect(try sandbox.fileNames().count == 3) + #expect(catalog.fileURL(for: first.id)?.deletingLastPathComponent().lastPathComponent == "Themes") + } + + @Test("Duplicating a built-in theme gives an editable user theme") + func duplicateOfBuiltInIsEditable() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + + let catalog = sandbox.catalog() + let copy = try catalog.duplicate(BuiltInThemes.dark) + + #expect(copy.isEditable) + #expect(copy.name == Self.copyName(of: BuiltInThemes.dark.name)) + #expect(copy.overrides == BuiltInThemes.dark.overrides) + #expect(catalog.theme(id: BuiltInThemes.defaultDarkId) == BuiltInThemes.dark) + } + + // MARK: - Import + + @Test("Import gives a colliding user id a new id and a unique name") + func importRegeneratesACollidingId() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let original = try Self.userTheme(id: "user.taken", name: "Taken") + try sandbox.write(original, as: "taken.json") + let incoming = try Self.userTheme(id: "user.taken", name: "Taken", overrides: [.syntaxKeyword: "#AA0000"]) + let url = try sandbox.write(incoming, as: "Taken.json", in: sandbox.incoming) + + let catalog = sandbox.catalog() + let imported = try catalog.importTheme(from: url) + + #expect(imported.id != "user.taken") + #expect(imported.id.hasPrefix(ThemeDefinition.userPrefix)) + #expect(imported.name == "Taken 2") + #expect(imported.overrides == incoming.overrides) + #expect(try sandbox.read("taken.json") == original) + #expect(catalog.theme(id: "user.taken") == original) + #expect(try sandbox.fileNames().count == 2) + } + + @Test("Import gives a built-in id a new id and a unique name") + func importRegeneratesABuiltInId() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let url = try sandbox.write(BuiltInThemes.dark, as: "Default Dark.json", in: sandbox.incoming) + + let catalog = sandbox.catalog() + let imported = try catalog.importTheme(from: url) + + #expect(imported.id != BuiltInThemes.defaultDarkId) + #expect(imported.isEditable) + #expect(imported.name == BuiltInThemes.dark.name + " 2") + #expect(imported.overrides == BuiltInThemes.dark.overrides) + #expect(catalog.theme(id: BuiltInThemes.defaultDarkId) == BuiltInThemes.dark) + } + + @Test("Import keeps an id and a name nothing else uses") + func importKeepsAFreshIdentity() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let url = try sandbox.write(try Self.userTheme(id: "user.fresh", name: "Fresh"), as: "fresh.json", in: sandbox.incoming) + + let imported = try sandbox.catalog().importTheme(from: url) + + #expect(imported.id == "user.fresh") + #expect(imported.name == "Fresh") + } + + // MARK: - Built-ins + + @Test("A built-in id cannot be saved or deleted") + func builtInsAreReadOnly() throws { + let sandbox = try Sandbox() + defer { sandbox.remove() } + let catalog = sandbox.catalog() + + #expect(throws: ThemeLoadError.reservedIdentifier(BuiltInThemes.defaultDarkId)) { + try catalog.save(BuiltInThemes.dark) + } + #expect(throws: ThemeLoadError.reservedIdentifier(BuiltInThemes.defaultLightId)) { + try catalog.delete(id: BuiltInThemes.defaultLightId) + } + #expect(throws: ThemeLoadError.reservedIdentifier("tablepro.dracula")) { + try catalog.delete(id: "tablepro.dracula") + } + + #expect(try sandbox.fileNames().isEmpty) + #expect(catalog.theme(id: BuiltInThemes.defaultLightId) == BuiltInThemes.light) + #expect(catalog.theme(id: BuiltInThemes.defaultDarkId) == BuiltInThemes.dark) + } +} diff --git a/TableProTests/Theme/ThemeColorReadGuardTests.swift b/TableProTests/Theme/ThemeColorReadGuardTests.swift new file mode 100644 index 0000000000..9b2b551727 --- /dev/null +++ b/TableProTests/Theme/ThemeColorReadGuardTests.swift @@ -0,0 +1,141 @@ +// +// ThemeColorReadGuardTests.swift +// TableProTests +// +// A theme colors the SQL editor, the other code views and the data grid, and nothing else. When +// 41 other files read theme slots, picking Dracula repainted History, the Row Inspector, the +// Server Dashboard and every banner in theme colors instead of the system's. +// + +import Foundation +@testable import TablePro +import Testing + +struct ThemeColorReadGuardTests { + private static let allowedDirectories = [ + // The theme itself, and its editor in Settings. + "TablePro/Theme/", + "TablePro/Views/Settings/Appearance/", + // The SQL editor, and the data grid's cells and selection. + "TablePro/Views/Editor/", + "TablePro/Views/Results/Cells/", + "TablePro/Views/Results/Selection/", + ] + + // The query history drawer is a list pane, not a code view, so it draws in system colors. + private static let deniedDirectories = [ + "TablePro/Views/Editor/History/", + ] + + // The data grid's own files under TablePro/Views/Results/; the satellites around it are panes. + private static let gridFilePrefixes = ["DataGrid", "SortableHeader", "CellOverlay"] + + // Code views that live outside the editor directory, and the theme preview in Settings. + private static let allowedFiles = [ + "HighlightedSQLTextView", + "SQLStatementPreview", + "SQLCodePreview", + "JSONCodeEditor", + "AIChatCodeBlockView", + "DDLTextView", + "TriggerEditorView", + "RewindReviewSheet", + "AgentProposedStatementsView", + "SQLCompletionItem", + "VimCursorManager", + "GhostTextRenderer", + "ThemePreviewCard", + ] + + static func isAllowed(_ path: String) -> Bool { + if deniedDirectories.contains(where: { path.hasPrefix($0) }) { + return false + } + if allowedDirectories.contains(where: { path.hasPrefix($0) }) { + return true + } + let fileName = (path as NSString).lastPathComponent + if path.hasPrefix("TablePro/Views/Results/"), gridFilePrefixes.contains(where: { fileName.hasPrefix($0) }) { + return true + } + return allowedFiles.contains { fileName == "\($0).swift" || fileName.hasPrefix("\($0)+") } + } + + static func rules() throws -> [ThemeSourceScan.Rule] { + [ + try ThemeSourceScan.Rule("palette subscript", #"\bpalette\["#), + try ThemeSourceScan.Rule("palette color", #"\bpalette\.color\("#), + try ThemeSourceScan.Rule("resolved subscript", #"\.resolved\[|\bresolved\[\s*\."#), + try ThemeSourceScan.Rule("resolved color", #"\bresolved\.(?:color|cgColor|isOverridden)\("#), + try ThemeSourceScan.Rule("ThemeSlot", #"\bThemeSlot\b"#), + try ThemeSourceScan.Rule("theme slot", ThemeSourceScan.slotTokenPattern()), + ] + } + + @Test("Only code views and the data grid read theme colors") + func themeColorsStayInCodeViewsAndTheGrid() throws { + let rules = try Self.rules() + let tree = try ThemeSourceScan.scan(["TablePro"], rules: rules) { !Self.isAllowed($0) } + let offenders = ThemeSourceScan.violations(in: tree.lines, rules: rules) + + #expect(tree.fileCount > 1_000, "Scanned only \(tree.fileCount) files; the tree did not resolve") + #expect( + offenders.isEmpty, + """ + These read a theme color outside the code views and the data grid. Use a system color \ + (.primary, .secondary, Color(nsColor: .separatorColor), NSColor.labelColor, .systemRed): + \(offenders.joined(separator: "\n")) + """ + ) + } + + @Test("Every allowlisted file still exists") + func allowlistIsCurrent() throws { + var fileNames: Set = [] + _ = try ThemeSourceScan.scan(["TablePro"]) { path in + fileNames.insert((path as NSString).lastPathComponent) + return false + } + + for name in Self.allowedFiles { + #expect(fileNames.contains("\(name).swift"), "\(name).swift is allowlisted but no longer exists") + } + } + + @Test("The guard flags a theme read and passes plain code") + func matcherCatchesAViolation() throws { + let sample = """ + let fill = themeEngine.palette.color(.panelBackground) + // palette[.gridText] named in a comment + let text = ThemeEngine.shared.resolved[.gridText] + let local = resolved[fieldId] + let request = self.editorText + let tint = activeTheme.color(for: .syntaxKeyword) + """ + let lines = ThemeSourceScan.flaggedLines(in: sample, rules: try Self.rules()) + + #expect(lines == [1, 3, 6]) + + let messages = ThemeSourceScan.violations( + in: ThemeSourceScan.codeLines(of: sample, path: "Pane.swift"), + rules: try Self.rules() + ) + #expect(messages.first?.hasPrefix("Pane.swift:1: ") == true, "\(messages)") + } + + @Test("The allowlist covers the grid and code views and nothing beside them") + func allowlistShape() { + #expect(Self.isAllowed("TablePro/Views/Results/DataGridRowView.swift")) + #expect(Self.isAllowed("TablePro/Views/Results/Extensions/DataGridView+Selection.swift")) + #expect(Self.isAllowed("TablePro/Views/Results/Cells/DataGridCellPalette.swift")) + #expect(Self.isAllowed("TablePro/Views/Editor/SQLEditorView.swift")) + #expect(Self.isAllowed("TablePro/Views/Structure/DDLTextView.swift")) + #expect(Self.isAllowed("TablePro/Theme/ThemeEngine+EditorTheme.swift")) + + #expect(!Self.isAllowed("TablePro/Views/Editor/History/HistoryDetailPane.swift")) + #expect(!Self.isAllowed("TablePro/Views/Results/TreeOutline/TreeOutlineCellView.swift")) + #expect(!Self.isAllowed("TablePro/Views/Results/InlineErrorBanner.swift")) + #expect(!Self.isAllowed("TablePro/Views/RowInspector/JSON/JSONRowColors.swift")) + #expect(!Self.isAllowed("TablePro/Views/Structure/TriggerDetailView.swift")) + } +} diff --git a/TableProTests/Theme/ThemeDocumentTests.swift b/TableProTests/Theme/ThemeDocumentTests.swift index 9e2e54026e..62d04dbfc2 100644 --- a/TableProTests/Theme/ThemeDocumentTests.swift +++ b/TableProTests/Theme/ThemeDocumentTests.swift @@ -2,179 +2,266 @@ // ThemeDocumentTests.swift // TableProTests // -// The previous decoder read every key with `decodeIfPresent` and a Default Light fallback, so an -// empty object and a VS Code theme both decoded "successfully" and rendered as Default Light under -// the file's own name. These pin the gate that replaced it. +// Format 3: every color is optional, a slot left out draws native, and the file is refused for +// anything that would otherwise load as something its author did not write. // +import AppKit import Foundation @testable import TablePro import Testing struct ThemeDocumentTests { - private static func json(_ object: [String: Any]) -> Data { - // swiftlint:disable:next force_try - try! JSONSerialization.data(withJSONObject: object) - } - - private static func completeTheme(appearance: ThemeAppearance = .dark) -> [String: Any] { - var root: [String: Any] = [ - "schema": ThemeSchema.current, - "id": "user.test", - "name": "Test", - "author": "Tests", - "appearance": appearance.rawValue, - ] + private static func data(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object) + } - for slot in ThemeSlot.allCases { - var cursor = root - insert(slot.rawValue.components(separatedBy: "."), value: "#112233", into: &cursor) - root = cursor - } + private static func metadata( + id: String = "user.test", + appearance: ThemeAppearance = .dark, + schema: Any = 3 + ) -> [String: Any] { + ["schema": schema, "id": id, "name": "Test", "appearance": appearance.rawValue] + } + + private static func document(_ object: [String: Any]) throws -> ThemeDocument { + try ThemeDocument(data: try data(object)) + } + private static func withColor(_ value: String, at path: [String]) -> [String: Any] { + var root = metadata() + insert(value, at: path, into: &root) return root } - private static func insert(_ path: [String], value: String, into container: inout [String: Any]) { + private static func insert(_ value: Any, at path: [String], into container: inout [String: Any]) { guard let head = path.first else { return } guard path.count > 1 else { container[head] = value return } var child = container[head] as? [String: Any] ?? [:] - insert(Array(path.dropFirst()), value: value, into: &child) + insert(value, at: Array(path.dropFirst()), into: &child) container[head] = child } - @Test("A complete document decodes") - func completeDocumentDecodes() throws { - let document = try ThemeDocument(data: Self.json(Self.completeTheme())) - #expect(document.id == "user.test") - #expect(document.appearance == .dark) - #expect(document.colors.count == ThemeSlot.allCases.count) + private static func loads(_ object: [String: Any]) -> Bool { + (try? document(object)) != nil } - @Test("An empty object is rejected") - func emptyObjectIsRejected() { - #expect(throws: ThemeLoadError.missingSchema) { - try ThemeDocument(data: Self.json([:])) + private static func refusal(_ object: [String: Any]) throws -> ThemeLoadError? { + do { + _ = try document(object) + return nil + } catch let error as ThemeLoadError { + return error } } - @Test("A file in the old format is rejected") - func oldSchemaIsRejected() { - let old: [String: Any] = [ - "version": 1, - "id": "user.old", - "name": "Old", - "appearance": "dark", - "editor": ["background": "#000000"], - ] + // MARK: - What loads - #expect(throws: ThemeLoadError.missingSchema) { - try ThemeDocument(data: Self.json(old)) - } + @Test("The current format is 3") + func currentFormatIsThree() { + #expect(ThemeSchema.current == 3) } - @Test("A schema from a newer TablePro is rejected") - func newerSchemaIsRejected() { - var root = Self.completeTheme() - root["schema"] = ThemeSchema.current + 1 + @Test("A file with only metadata loads and overrides nothing") + func metadataOnlyLoads() throws { + let document = try Self.document(Self.metadata()) - #expect(throws: ThemeLoadError.schemaTooNew(found: ThemeSchema.current + 1, supported: ThemeSchema.current)) { - try ThemeDocument(data: Self.json(root)) + #expect(document.id == "user.test") + #expect(document.name == "Test") + #expect(document.author.isEmpty) + #expect(document.appearance == .dark) + #expect(document.colors.isEmpty) + + let definition = document.definition() + for slot in ThemeSlot.allCases { + #expect(!definition.isOverridden(slot), "\(slot.rawValue)") } } - @Test("A missing slot is reported, not filled in silently") - func missingSlotIsRejected() throws { - var root = Self.completeTheme() - var content = try #require(root["content"] as? [String: Any]) - var editor = try #require(content["editor"] as? [String: Any]) - editor.removeValue(forKey: "cursor") - content["editor"] = editor - root["content"] = content + @Test("A file sets only the slots it names") + func slotsAreOptional() throws { + var root = Self.metadata() + Self.insert("#FF0000", at: ["editor", "syntax", "keyword"], into: &root) + Self.insert("#00000080", at: ["dataGrid", "background"], into: &root) + + let colors = try Self.document(root).colors.mapValues { $0.hex } + + #expect(colors == [.syntaxKeyword: "#FF0000", .gridBackground: "#00000080"]) + } - #expect(throws: ThemeLoadError.missingSlots([ThemeSlot.editorCursor.rawValue])) { - try ThemeDocument(data: Self.json(root)) + @Test("Every slot path is accepted") + func everySlotPathLoads() throws { + var root = Self.metadata() + for slot in ThemeSlot.allCases { + Self.insert("#123456", at: slot.rawValue.components(separatedBy: "."), into: &root) } + + #expect(try Self.document(root).colors.count == ThemeSlot.allCases.count) } - @Test("A key TablePro does not use is reported") - func unknownKeyIsRejected() throws { - var root = Self.completeTheme() - var content = try #require(root["content"] as? [String: Any]) - content["sidebar"] = ["background": "#000000"] - root["content"] = content + // MARK: - What is refused + + @Test("A key TablePro does not use is refused, and the message names it") + func unknownKeyIsRefused() throws { + var root = Self.metadata() + Self.insert("#000000", at: ["editor", "sidebar"], into: &root) + Self.insert("#000000", at: ["panel", "text"], into: &root) + + let error = try Self.refusal(root) + + #expect(error == .unknownKeys(["editor.sidebar", "panel.text"])) + #expect(error?.localizedDescription.contains("editor.sidebar") == true) + } - #expect(throws: ThemeLoadError.unknownKeys(["content.sidebar.background"])) { - try ThemeDocument(data: Self.json(root)) + /// The pane and status groups of format 2 are gone, so a file that still carries them is + /// refused rather than loaded with those colors dropped. + @Test("Format 2 slot groups are refused") + func formerGroupsAreRefused() throws { + for path in [["panel", "background"], ["status", "warning"], ["content", "editor", "background"]] { + let error = try Self.refusal(Self.withColor("#000000", at: path)) + #expect(error == .unknownKeys([path.joined(separator: ".")]), "\(path)") } } - /// A valid prefix used to satisfy the parser, so `#FF79CG` rendered as `#0FF79C` with nothing - /// logged and nothing shown. - @Test("A color with a typo in it is rejected") - func malformedColorIsRejected() throws { - var root = Self.completeTheme() - var content = try #require(root["content"] as? [String: Any]) - var editor = try #require(content["editor"] as? [String: Any]) - editor["background"] = "#FF79CG" - content["editor"] = editor - root["content"] = content + /// A value that is not a string would otherwise be dropped silently, which loads the slot as + /// native under a key the author did write. + @Test("A key whose value is not a color string is refused") + func nonStringValueIsRefused() { + var unknownNumber = Self.metadata() + unknownNumber["version"] = 1 + #expect(!Self.loads(unknownNumber), "an unknown numeric key loaded") - #expect(throws: ThemeLoadError.invalidColor("#FF79CG")) { - try ThemeDocument(data: Self.json(root)) - } + var numericColor = Self.metadata() + Self.insert(16_777_215, at: ["editor", "background"], into: &numericColor) + #expect(!Self.loads(numericColor), "a numeric color loaded") + } + + @Test("A slot written twice, nested and dotted, is refused") + func duplicateSlotIsRefused() throws { + var root = Self.withColor("#000000", at: ["editor", "background"]) + root["editor.background"] = "#FFFFFF" + + #expect(try Self.refusal(root) == .duplicateKey("editor.background")) } - @Test("An unknown system color name is rejected") - func unknownSystemColorIsRejected() throws { - var root = Self.completeTheme() - var content = try #require(root["content"] as? [String: Any]) - var editor = try #require(content["editor"] as? [String: Any]) - editor["background"] = "system:notARealColor" - content["editor"] = editor - root["content"] = content + @Test("A color that is not ASCII #RRGGBB or #RRGGBBAA is refused") + func malformedColorsAreRefused() throws { + let rejected = [ + "#FF79CG", + "FF0000", + "#FFF", + "#FF00000", + "", + "system:label", + "#\u{FF26}\u{FF26}0000", + ] - #expect(throws: ThemeLoadError.unknownSystemColor("notARealColor")) { - try ThemeDocument(data: Self.json(root)) + for value in rejected { + let error = try Self.refusal(Self.withColor(value, at: ["editor", "background"])) + #expect(error == .invalidColor(value), "\(value)") } } - @Test("A system color reference round-trips") - func systemColorRoundTrips() throws { - var root = Self.completeTheme() - var content = try #require(root["content"] as? [String: Any]) - var editor = try #require(content["editor"] as? [String: Any]) - editor["background"] = "system:textBackground" - content["editor"] = editor - root["content"] = content + @Test("An 8-digit color keeps its alpha") + func alphaColorIsAccepted() throws { + let colors = try Self.document(Self.withColor("#ff79c680", at: ["editor", "selection"])).colors + let color = try #require(colors[.editorSelection]) - let theme = try ThemeDocument(data: Self.json(root)).resolved() - #expect(theme.editor.background == .system(.textBackground)) + #expect(color.hex == "#FF79C680") + #expect(abs(color.nsColor.alphaComponent - CGFloat(0x80) / 255) < 0.001) + } + + @Test("Format 2 and a file with no format are refused") + func otherFormatsAreRefused() throws { + #expect(try Self.refusal(Self.metadata(schema: 2)) == .schemaTooOld(found: 2, supported: 3)) + #expect(try Self.refusal(Self.metadata(schema: 4)) == .schemaTooNew(found: 4, supported: 3)) + #expect(try Self.refusal(Self.metadata(schema: "3")) == .missingSchema) - let reread = try ThemeDocument(data: ThemeEncoder.data(for: theme)).resolved() - #expect(reread.editor.background == .system(.textBackground)) + var unversioned = Self.metadata() + unversioned.removeValue(forKey: "schema") + #expect(try Self.refusal(unversioned) == .missingSchema) + #expect(try Self.refusal([:]) == .missingSchema) } - @Test("Every slot round-trips through the encoder") - func everySlotRoundTrips() throws { - let theme = try ThemeDocument(data: Self.json(Self.completeTheme())).resolved() - let reread = try ThemeDocument(data: ThemeEncoder.data(for: theme)).resolved() + @Test("Missing metadata is refused by name") + func missingMetadataIsRefused() throws { + for key in ["id", "name", "appearance"] { + var root = Self.metadata() + root.removeValue(forKey: key) + #expect(try Self.refusal(root) == .missingField(key), "\(key)") + } + + var unknownAppearance = Self.metadata() + unknownAppearance["appearance"] = "sepia" + #expect(try Self.refusal(unknownAppearance) == .missingField("appearance")) + } - for slot in ThemeSlot.allCases { - #expect(reread[keyPath: slot.keyPath] == theme[keyPath: slot.keyPath], "\(slot.rawValue)") + @Test("An id that is not a plain file-safe name is refused") + func invalidIdIsRefused() throws { + for id in ["user/evil", "../escape", "user..x", "user x", "user.\u{00E9}"] { + #expect(try Self.refusal(Self.metadata(id: id)) == .invalidIdentifier(id), "\(id)") } } - @Test("Hex parsing consumes the whole string") - func hexParsingIsStrict() { - #expect(HexColor.canonicalize("#ff79c6") == "#FF79C6") - #expect(HexColor.canonicalize("#FF79C680") == "#FF79C680") - #expect(HexColor.canonicalize("#FF79CG") == nil) - #expect(HexColor.canonicalize("FF79C6") == nil) - #expect(HexColor.canonicalize("#FFF") == nil) - #expect(HexColor.canonicalize("") == nil) + // MARK: - Encoder + + @Test("The encoder writes metadata and the overrides, nothing else") + func encoderWritesOnlyOverrides() throws { + let theme = ThemeDefinition( + id: "user.sparse", + name: "Sparse", + author: "Tests", + appearance: .light, + overrides: [ + .syntaxKeyword: try ThemeColor(validating: "#AA0000"), + .gridBackground: try ThemeColor(validating: "#FAFAFA"), + ] + ) + + let object = try JSONSerialization.jsonObject(with: try ThemeEncoder.data(for: theme)) + let root = try #require(object as? [String: Any]) + let editor = try #require(root["editor"] as? [String: Any]) + let syntax = try #require(editor["syntax"] as? [String: String]) + let grid = try #require(root["dataGrid"] as? [String: String]) + + #expect(Set(root.keys) == ["schema", "id", "name", "author", "appearance", "editor", "dataGrid"]) + #expect(Set(editor.keys) == ["syntax"]) + #expect(syntax == ["keyword": "#AA0000"]) + #expect(grid == ["background": "#FAFAFA"]) + #expect(root["schema"] as? Int == 3) + } + + @Test("A default theme encodes as syntax only") + func defaultThemeEncodesSyntaxOnly() throws { + let object = try JSONSerialization.jsonObject(with: try ThemeEncoder.data(for: BuiltInThemes.light)) + let root = try #require(object as? [String: Any]) + let editor = try #require(root["editor"] as? [String: Any]) + + #expect(root["dataGrid"] == nil) + #expect(Set(editor.keys) == ["syntax"]) + } + + @Test("A theme round-trips through the encoder") + func encoderRoundTrips() throws { + var overrides: [ThemeSlot: ThemeColor] = [:] + for (index, slot) in ThemeSlot.allCases.enumerated() { + overrides[slot] = try ThemeColor(validating: String(format: "#%02X%02X%02X", index, 255 - index, index * 3)) + } + let full = ThemeDefinition(id: "user.full", name: "Full", author: "Tests", appearance: .dark, overrides: overrides) + let sparse = ThemeDefinition( + id: "user.sparse", + name: "Sparse", + author: "", + appearance: .light, + overrides: [.editorCursor: try ThemeColor(validating: "#11223344")] + ) + + for theme in [full, sparse, BuiltInThemes.light, BuiltInThemes.dark] { + let reread = try ThemeDocument(data: try ThemeEncoder.data(for: theme)).definition() + #expect(reread == theme, "\(theme.id)") + } } } diff --git a/TableProTests/Theme/ThemeSlotCoverageTests.swift b/TableProTests/Theme/ThemeSlotCoverageTests.swift index 83fd5a328e..373fe4d372 100644 --- a/TableProTests/Theme/ThemeSlotCoverageTests.swift +++ b/TableProTests/Theme/ThemeSlotCoverageTests.swift @@ -2,9 +2,9 @@ // ThemeSlotCoverageTests.swift // TableProTests // -// Whole colour groups shipped declared, editable and read by nothing: `sidebar` and `toolbar` had -// no reader at all, `ui` had one, and `ui.accentColor` was declared by every bundled theme and had -// no property to decode into. A slot exists here only because a call site reads it. +// A slot exists only because a call site reads it. Whole colour groups once shipped declared, +// editable and read by nothing, and the substring check that replaced them passed for +// `.editorText` through `self.editorText` and for `.gridSelection` through `.gridSelectionText`. // import Foundation @@ -12,52 +12,73 @@ import Foundation import Testing struct ThemeSlotCoverageTests { - private static let repositoryRoot: URL = { - var url = URL(fileURLWithPath: #filePath) - for _ in 0 ..< 3 { - url.deleteLastPathComponent() - } - return url - }() - - /// The files that declare the slots. Everything else counts as a reader, including - /// `ThemeEngine.makeEditorTheme`, which is where the editor and syntax slots are read. - private static let definitionFiles: Set = [ - "ThemeDefinition.swift", "BuiltInThemes.swift", "ThemeColorValue.swift", - "ThemeDocument.swift", "ThemePalette.swift", "ThemeCatalog.swift", "ThemeResolver.swift", - ] - - private static let appSource: String = { - let directory = repositoryRoot.appendingPathComponent("TablePro", isDirectory: true) - guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else { - return "" - } + /// The bridge that builds the editor theme is the one file under `TablePro/Theme/` that reads + /// slots rather than declaring them; the editor slots have no other reader. + private static let readerInsideThemeDirectory = "TablePro/Theme/ThemeEngine+EditorTheme.swift" - var source = "" - for case let url as URL in enumerator where url.pathExtension == "swift" { - guard !Self.definitionFiles.contains(url.lastPathComponent) else { continue } - source += (try? String(contentsOf: url, encoding: .utf8)) ?? "" - } - return source - }() + private static func isReader(_ path: String) -> Bool { + !path.hasPrefix("TablePro/Theme/") || path == readerInsideThemeDirectory + } - @Test("Every slot is read by app code") - func everySlotHasAReader() { - #expect(!Self.appSource.isEmpty) + private static func referencedSlots(in lines: [ThemeSourceScan.Line]) throws -> Set { + let expression = try NSRegularExpression(pattern: ThemeSourceScan.slotTokenPattern()) + var found: Set = [] + for line in lines { + let range = NSRange(line.text.startIndex..., in: line.text) + for match in expression.matches(in: line.text, range: range) { + guard let name = Range(match.range(at: 1), in: line.text) else { continue } + found.insert(String(line.text[name])) + } + } + return found + } + @Test("Every slot is read as a whole token outside the theme definitions") + func everySlotHasAReader() throws { + let slotRule = try ThemeSourceScan.Rule("theme slot", ThemeSourceScan.slotTokenPattern()) + let tree = try ThemeSourceScan.scan( + ["TablePro", "Packages"], + skipping: ["Tests"], + rules: [slotRule], + including: Self.isReader + ) + let referenced = try Self.referencedSlots(in: tree.lines) + + #expect(tree.fileCount > 500, "Scanned only \(tree.fileCount) files; the tree did not resolve") for slot in ThemeSlot.allCases { - #expect(Self.appSource.contains(".\(String(describing: slot))"), "Nothing reads \(slot.rawValue)") + #expect(referenced.contains(String(describing: slot)), "Nothing outside TablePro/Theme/ reads .\(slot)") } } - @Test("Slot paths are unique and namespaced") + @Test("The token match ignores properties, longer names and comments") + func tokenMatchIsExact() throws { + let sample = """ + let request = self.editorText + let fill = palette[.gridSelectionText] + // resolved[.gridDeleted] was removed + let text = resolved[.syntaxKeyword] + let typed = ThemeSlot.gridLine + """ + let referenced = try Self.referencedSlots(in: ThemeSourceScan.codeLines(of: sample, path: "Sample.swift")) + + #expect(referenced == ["gridSelectionText", "syntaxKeyword", "gridLine"]) + } + + @Test("Slot paths are unique and sit under their group's key") func slotPathsAreWellFormed() { let paths = ThemeSlot.allCases.map(\.rawValue) #expect(Set(paths).count == paths.count) - for path in paths { - #expect(path.hasPrefix("content."), "\(path)") - #expect(path.components(separatedBy: ".").count >= 3, "\(path)") + for slot in ThemeSlot.allCases { + switch slot.group { + case .editor: + #expect(slot.rawValue.hasPrefix("editor."), "\(slot.rawValue)") + #expect(!slot.rawValue.hasPrefix("editor.syntax."), "\(slot.rawValue)") + case .syntax: + #expect(slot.rawValue.hasPrefix("editor.syntax."), "\(slot.rawValue)") + case .dataGrid: + #expect(slot.rawValue.hasPrefix("dataGrid."), "\(slot.rawValue)") + } } } @@ -70,44 +91,70 @@ struct ThemeSlotCoverageTests { // MARK: - Bundled themes - @Test("Every bundled theme passes the gate", arguments: ["tablepro.dracula", "tablepro.nord"]) - func bundledThemesPassTheGate(id: String) throws { - let url = Self.repositoryRoot.appendingPathComponent("TablePro/Resources/Themes/\(id).json") - let document = try ThemeDocument(data: try Data(contentsOf: url)) - - #expect(document.id == id) - #expect(document.colors.count == ThemeSlot.allCases.count) + /// The files on disk are the list, so a third bundled theme is decoded here the day it lands. + private static func bundledThemeFiles() throws -> [URL] { + let directory = try ThemeSourceScan.repositoryRoot().appendingPathComponent("TablePro/Resources/Themes") + return try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } } - /// The bundled themes are the only ones shipped, so a hand-typed colour that never renders is - /// caught here rather than by a user. - @Test("Bundled themes declare a real colour for every slot", arguments: ["tablepro.dracula", "tablepro.nord"]) - func bundledThemesDeclareHexColors(id: String) throws { - let url = Self.repositoryRoot.appendingPathComponent("TablePro/Resources/Themes/\(id).json") - let theme = try ThemeDocument(data: try Data(contentsOf: url)).resolved() - - for slot in ThemeSlot.allCases { - let value = theme[keyPath: slot.keyPath] - #expect(!value.isSystem, "\(id) leaves \(slot.rawValue) on a system colour") + @Test("Every bundled theme decodes, and its id is its file name") + func bundledThemesDecode() throws { + let files = try Self.bundledThemeFiles() + var ids: Set = [] + + for file in files { + let document = try ThemeDocument(data: try Data(contentsOf: file)) + #expect(document.id == file.deletingPathExtension().lastPathComponent) + #expect(document.id.hasPrefix(ThemeDefinition.builtInPrefix)) + #expect(!document.colors.isEmpty, "\(document.id) overrides nothing") + ids.insert(document.id) } - } - /// Default Light and Default Dark keep the surrounds on system colours so the unthemed app is - /// unchanged and keeps the system's Increase Contrast handling. - @Test("The default themes keep the grid surrounds on system colours") - func defaultThemesUseSystemSurrounds() { - for theme in BuiltInThemes.all { - #expect(theme.dataGrid.background.isSystem) - #expect(theme.dataGrid.text.isSystem) - #expect(theme.dataGrid.alternateRow.isSystem) - #expect(theme.dataGrid.selection.isSystem) - #expect(theme.editor.background.isSystem == false) - } + #expect(ids.isSuperset(of: ["tablepro.dracula", "tablepro.nord"])) } - @Test("The default themes declare the appearance of their slot") - func defaultThemesDeclareTheirAppearance() { + // MARK: - Default Light and Default Dark + + private static let syntaxWithoutOperator = Set(ThemeSlotGroup.syntax.slots).subtracting([.syntaxOperator]) + + /// Xcode 27.1 `Default (Light).xccolortheme`: keyword, string, number, comment, system function + /// and system type. SQL `NULL` takes the keyword colour the way Swift's `nil` does. + @Test("Default Light overrides only syntax, with Xcode's Default (Light) values") + func defaultLightIsXcodeDefaultLight() { + let expected: [ThemeSlot: String] = [ + .syntaxKeyword: "#9B2393", + .syntaxString: "#C41A16", + .syntaxNumber: "#1C00CF", + .syntaxComment: "#5D6C79", + .syntaxFunction: "#6C36A9", + .syntaxType: "#3900A0", + .syntaxNull: "#9B2393", + ] + let actual = BuiltInThemes.light.overrides.mapValues { $0.hex } + + #expect(Set(actual.keys) == Self.syntaxWithoutOperator) + #expect(actual == expected) #expect(BuiltInThemes.light.appearance == .light) + #expect(BuiltInThemes.light.id == BuiltInThemes.defaultLightId) + } + + @Test("Default Dark overrides only syntax, with Xcode's Default (Dark) values") + func defaultDarkIsXcodeDefaultDark() { + let expected: [ThemeSlot: String] = [ + .syntaxKeyword: "#FC5FA3", + .syntaxString: "#FC6A5D", + .syntaxNumber: "#D0BF69", + .syntaxComment: "#6C7986", + .syntaxFunction: "#A167E6", + .syntaxType: "#D0A8FF", + .syntaxNull: "#FC5FA3", + ] + let actual = BuiltInThemes.dark.overrides.mapValues { $0.hex } + + #expect(Set(actual.keys) == Self.syntaxWithoutOperator) + #expect(actual == expected) #expect(BuiltInThemes.dark.appearance == .dark) #expect(BuiltInThemes.default(for: .dark).id == BuiltInThemes.defaultDarkId) } diff --git a/TableProTests/Theme/ThemeSlotValidationTests.swift b/TableProTests/Theme/ThemeSlotValidationTests.swift index 57aae11bff..cc20b9e56c 100644 --- a/TableProTests/Theme/ThemeSlotValidationTests.swift +++ b/TableProTests/Theme/ThemeSlotValidationTests.swift @@ -7,89 +7,116 @@ import Testing struct ThemeSlotValidationTests { - @Test("A matching theme fits its slot") - func matchingThemeFits() { - #expect(ThemeSlotValidation.fits(.light, slot: .light)) - #expect(ThemeSlotValidation.fits(.dark, slot: .dark)) - } - - @Test("A contradicting theme does not fit") - func contradictingThemeDoesNotFit() { - #expect(ThemeSlotValidation.fits(.dark, slot: .light) == false) - #expect(ThemeSlotValidation.fits(.light, slot: .dark) == false) - } - - private func theme(_ id: String, _ appearance: ThemeAppearance) -> ThemeDefinition { + private static func theme(_ id: String, _ appearance: ThemeAppearance) -> ThemeDefinition { var copy = BuiltInThemes.default(for: appearance) copy.id = id + copy.name = id return copy } - private var sample: [ThemeDefinition] { - [theme("light", .light), theme("dark", .dark)] - } + private static let sample = [theme("user.light", .light), theme("user.dark", .dark)] - @Test("Only fitting themes stay in the list") - func listIsFiltered() { - let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: nil) - #expect(eligible.map(\.id) == ["light"]) - } + private static var catalog: [ThemeDefinition] { BuiltInThemes.all + sample } - /// The row the user is standing on can never be filtered away, because the alternative was to - /// rewrite their saved theme so the filter came out true. - @Test("A contradicting theme stays listed while it is the one selected") - func selectedContradictingThemeIsKept() { - let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: "dark") - #expect(eligible.map(\.id) == ["light", "dark"]) + private static func resolve( + mode: AppAppearanceMode = .auto, + light: String = BuiltInThemes.defaultLightId, + dark: String = BuiltInThemes.defaultDarkId, + systemIsDark: Bool = false + ) -> ThemeSelection { + ThemeResolver.resolve( + mode: mode, + lightThemeId: light, + darkThemeId: dark, + themes: catalog, + systemIsDark: systemIsDark + ) } - @Test("Keeping a selection does not duplicate a theme that already fits") - func keptSelectionIsNotDuplicated() { - let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: "light") - #expect(eligible.map(\.id) == ["light"]) - } + // MARK: - Resolver - @Test("An unknown selected id adds nothing to the list") - func unknownSelectionAddsNothing() { - let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .dark, keeping: "does.not.exist") - #expect(eligible.map(\.id) == ["dark"]) + @Test("A theme that exists and fits its slot is used") + func fittingThemesResolve() { + let selection = Self.resolve(light: "user.light", dark: "user.dark") + #expect(selection.pair.light.id == "user.light") + #expect(selection.pair.dark.id == "user.dark") } - /// A slot whose theme is missing, rejected, or of the wrong appearance falls back to that - /// slot's own built-in. Falling back to Default Light whichever slot asked painted a white - /// editor inside dark chrome. - @Test("A dark slot that cannot resolve falls back to Default Dark") - func darkSlotFallsBackToDefaultDark() { - let selection = ThemeResolver.resolve( - mode: .dark, - lightThemeId: BuiltInThemes.defaultLightId, - darkThemeId: "user.deleted", - themes: BuiltInThemes.all, - systemIsDark: true - ) - + /// A slot whose theme is missing falls back to that slot's own built-in. Falling back to + /// Default Light whichever slot asked painted a white editor inside dark chrome. + @Test("A missing theme falls back to its own slot's built-in") + func missingThemeFallsBackPerSlot() { + let selection = Self.resolve(light: "user.deleted", dark: "user.deleted") + #expect(selection.pair.light.id == BuiltInThemes.defaultLightId) #expect(selection.pair.dark.id == BuiltInThemes.defaultDarkId) - #expect(selection.active.appearance == .dark) } - @Test("A light theme sitting in the dark slot falls back to Default Dark") - func misfitThemeInDarkSlotFallsBack() { - let selection = ThemeResolver.resolve( - mode: .dark, - lightThemeId: BuiltInThemes.defaultLightId, - darkThemeId: BuiltInThemes.defaultLightId, - themes: BuiltInThemes.all, - systemIsDark: true - ) - + @Test("A theme of the wrong appearance falls back to that slot's built-in") + func misfitThemeFallsBackPerSlot() { + let selection = Self.resolve(light: "user.dark", dark: "user.light") + #expect(selection.pair.light.id == BuiltInThemes.defaultLightId) #expect(selection.pair.dark.id == BuiltInThemes.defaultDarkId) + + let swappedDefaults = Self.resolve(light: BuiltInThemes.defaultDarkId, dark: BuiltInThemes.defaultLightId) + #expect(swappedDefaults.pair.light.id == BuiltInThemes.defaultLightId) + #expect(swappedDefaults.pair.dark.id == BuiltInThemes.defaultDarkId) } - @Test("Auto follows the system appearance") + @Test("Auto follows the system appearance, forced modes ignore it") func autoFollowsSystem() { #expect(ThemeResolver.effectiveAppearance(mode: .auto, systemIsDark: true) == .dark) #expect(ThemeResolver.effectiveAppearance(mode: .auto, systemIsDark: false) == .light) #expect(ThemeResolver.effectiveAppearance(mode: .light, systemIsDark: true) == .light) #expect(ThemeResolver.effectiveAppearance(mode: .dark, systemIsDark: false) == .dark) } + + @Test("The active theme is the pair's theme for the effective appearance") + func activeThemeFollowsTheEffectiveAppearance() { + let dark = Self.resolve(mode: .auto, light: "user.light", dark: "user.dark", systemIsDark: true) + #expect(dark.effectiveAppearance == .dark) + #expect(dark.active.id == "user.dark") + + let light = Self.resolve(mode: .auto, light: "user.light", dark: "user.dark", systemIsDark: false) + #expect(light.effectiveAppearance == .light) + #expect(light.active.id == "user.light") + + let forced = Self.resolve(mode: .light, light: "user.light", dark: "user.dark", systemIsDark: true) + #expect(forced.active.id == "user.light") + } + + // MARK: - Eligible themes + + @Test("A theme fits only the slot of its own appearance") + func fitsMatchesAppearance() { + #expect(ThemeSlotValidation.fits(.light, slot: .light)) + #expect(ThemeSlotValidation.fits(.dark, slot: .dark)) + #expect(!ThemeSlotValidation.fits(.dark, slot: .light)) + #expect(!ThemeSlotValidation.fits(.light, slot: .dark)) + } + + @Test("Only fitting themes are listed") + func listIsFiltered() { + let eligible = ThemeSlotValidation.eligibleThemes(Self.catalog, slot: .light, keeping: nil) + #expect(eligible.map(\.id) == [BuiltInThemes.defaultLightId, "user.light"]) + } + + /// The row the user is standing on is never filtered away, because the alternative was to + /// rewrite their saved theme so the filter came out true. + @Test("A misfit theme stays listed while it is the one selected") + func selectedMisfitIsKept() { + let eligible = ThemeSlotValidation.eligibleThemes(Self.sample, slot: .light, keeping: "user.dark") + #expect(eligible.map(\.id) == ["user.light", "user.dark"]) + } + + @Test("Keeping a fitting selection does not list it twice") + func keptSelectionIsNotDuplicated() { + let eligible = ThemeSlotValidation.eligibleThemes(Self.sample, slot: .light, keeping: "user.light") + #expect(eligible.map(\.id) == ["user.light"]) + } + + @Test("An unknown selected id adds nothing") + func unknownSelectionAddsNothing() { + let eligible = ThemeSlotValidation.eligibleThemes(Self.sample, slot: .dark, keeping: "user.missing") + #expect(eligible.map(\.id) == ["user.dark"]) + } } diff --git a/TableProTests/Theme/ThemeSourceScan.swift b/TableProTests/Theme/ThemeSourceScan.swift new file mode 100644 index 0000000000..053f451b33 --- /dev/null +++ b/TableProTests/Theme/ThemeSourceScan.swift @@ -0,0 +1,234 @@ +// +// ThemeSourceScan.swift +// TableProTests +// + +import Foundation +@testable import TablePro + +/// Reads the real source tree for the theme guard suites. Comments are dropped before matching, +/// because a comment may name exactly what was removed. +enum ThemeSourceScan { + struct Line { + let path: String + let number: Int + let text: String + + var location: String { "\(path):\(number)" } + } + + struct Tree { + let fileCount: Int + let lines: [Line] + } + + struct Rule { + let name: String + let expression: NSRegularExpression + + init(_ name: String, _ pattern: String) throws { + self.name = name + expression = try NSRegularExpression(pattern: pattern) + } + + func matches(_ text: String) -> Bool { + expression.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil + } + } + + enum ScanError: Error { + case repositoryRootNotFound + case treeNotFound(String) + } + + static func repositoryRoot() throws -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 8 { + let manifest = directory.appendingPathComponent("project.yml").path + let themeTree = directory.appendingPathComponent("TablePro/Theme").path + if FileManager.default.fileExists(atPath: manifest), FileManager.default.fileExists(atPath: themeTree) { + return directory + } + directory.deleteLastPathComponent() + } + throw ScanError.repositoryRootNotFound + } + + /// Hidden directories (`.build`, `.swiftpm`) are never source, and `skipping` names more by + /// their last path component, such as a package's `Tests`. A file whose raw text matches none + /// of `rules` is counted but not lexed, which keeps an unoptimized test build fast. + static func scan( + _ trees: [String], + skipping: Set = [], + rules: [Rule] = [], + including include: (String) -> Bool = { _ in true } + ) throws -> Tree { + let root = try repositoryRoot() + var fileCount = 0 + var lines: [Line] = [] + + for tree in trees { + let base = root.appendingPathComponent(tree, isDirectory: true) + guard let enumerator = FileManager.default.enumerator( + at: base, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + throw ScanError.treeNotFound(tree) + } + + for case let url as URL in enumerator { + if skipping.contains(url.lastPathComponent), + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true { + enumerator.skipDescendants() + continue + } + guard url.pathExtension == "swift" else { continue } + + let path = relativePath(of: url, in: base, tree: tree) + guard include(path) else { continue } + + fileCount += 1 + let source = try String(contentsOf: url, encoding: .utf8) + guard rules.isEmpty || rules.contains(where: { $0.matches(source) }) else { continue } + lines += codeLines(of: source, path: path) + } + } + + return Tree(fileCount: fileCount, lines: lines) + } + + static func violations(in lines: [Line], rules: [Rule]) -> [String] { + lines.flatMap { line in + rules + .filter { $0.matches(line.text) } + .map { "\(line.location): \($0.name): \(line.text.trimmingCharacters(in: .whitespaces))" } + } + } + + /// The line numbers of an inline sample that the rules flag, for a guard's self-test. + static func flaggedLines(in sample: String, rules: [Rule]) -> Set { + let lines = codeLines(of: sample, path: "Sample.swift").filter { line in + rules.contains { $0.matches(line.text) } + } + return Set(lines.map(\.number)) + } + + /// `.editorText` or `ThemeSlot.editorText`. A dot after an identifier, `)` or `]` is some + /// other property (`self.editorText`), and the closing word boundary keeps `.gridSelection` + /// from matching inside `.gridSelectionText`. + static func slotTokenPattern(_ slots: [ThemeSlot] = ThemeSlot.allCases) -> String { + let names = slots.map { String(describing: $0) }.joined(separator: "|") + return #"(?:(? [Line] { + var state = LexState() + var lines: [Line] = [] + for (index, raw) in source.components(separatedBy: "\n").enumerated() { + let code = strip(raw, state: &state) + guard !code.trimmingCharacters(in: .whitespaces).isEmpty else { continue } + lines.append(Line(path: path, number: index + 1, text: code)) + } + return lines + } + + private struct LexState { + var inBlockComment = false + var inMultilineString = false + } + + private static let slash = UInt8(ascii: "/") + private static let star = UInt8(ascii: "*") + private static let quote = UInt8(ascii: "\"") + private static let backslash = UInt8(ascii: "\\") + + /// Enough of a lexer to drop `//` and `/* */` comments without cutting a `//` inside a string + /// literal, such as a URL. It works on UTF-8 bytes: every token it looks for is ASCII, and no + /// byte of a multi-byte character is. + private static func strip(_ line: String, state: inout LexState) -> String { + let bytes = Array(line.utf8) + var kept: [UInt8] = [] + kept.reserveCapacity(bytes.count) + var index = 0 + var inString = false + + func pair(_ first: UInt8, _ second: UInt8, at position: Int) -> Bool { + position + 1 < bytes.count && bytes[position] == first && bytes[position + 1] == second + } + + func tripleQuote(at position: Int) -> Bool { + position + 2 < bytes.count + && bytes[position] == quote && bytes[position + 1] == quote && bytes[position + 2] == quote + } + + while index < bytes.count { + let byte = bytes[index] + + if state.inBlockComment { + if pair(star, slash, at: index) { + state.inBlockComment = false + index += 2 + } else { + index += 1 + } + continue + } + + if state.inMultilineString { + if tripleQuote(at: index) { + state.inMultilineString = false + kept += [quote, quote, quote] + index += 3 + } else { + kept.append(byte) + index += 1 + } + continue + } + + if inString { + kept.append(byte) + if byte == backslash, index + 1 < bytes.count { + kept.append(bytes[index + 1]) + index += 2 + continue + } + if byte == quote { + inString = false + } + index += 1 + continue + } + + if pair(slash, slash, at: index) { + break + } + if pair(slash, star, at: index) { + state.inBlockComment = true + index += 2 + continue + } + if tripleQuote(at: index) { + state.inMultilineString = true + kept += [quote, quote, quote] + index += 3 + continue + } + if byte == quote { + inString = true + } + kept.append(byte) + index += 1 + } + + return String(bytes: kept, encoding: .utf8) ?? line + } + + private static func relativePath(of url: URL, in base: URL, tree: String) -> String { + let basePath = base.standardizedFileURL.path + let filePath = url.standardizedFileURL.path + guard filePath.hasPrefix(basePath + "/") else { return url.lastPathComponent } + return tree + filePath.dropFirst(basePath.count) + } +} diff --git a/TableProTests/Theme/ValueFontTests.swift b/TableProTests/Theme/ValueFontTests.swift index c52eb3a836..363cf91d00 100644 --- a/TableProTests/Theme/ValueFontTests.swift +++ b/TableProTests/Theme/ValueFontTests.swift @@ -166,12 +166,12 @@ struct ValueFontTests { /// The sidebar's JSON editor caches its whole configuration in `@State`, so it repaints only when /// something tells it to. It used to watch the colour scheme alone, which left it on the old font - /// after a size, theme or accessibility change until the row was reselected. + /// after a size, theme or accessibility change until the row was reselected. `themeChanged` also + /// fires on a light/dark flip, so it covers what the colour scheme did. @Test("The JSON editor rebuilds its configuration on every appearance change") func jsonEditorWatchesEveryAppearanceChange() throws { let source = try source(of: "TablePro/Views/Results/JSONCodeEditor.swift") - #expect(source.contains("onChange(of: colorScheme)")) /// Observed, not read off the shared instance. The value `onChange(of:)` compares is read /// while the body is built, and a body that does not observe the settings is never rebuilt /// when they change, so `onChange(of: AppSettingsManager.shared.editor)` never fired. diff --git a/TableProTests/Views/Compare/CompareStatusStyleTests.swift b/TableProTests/Views/Compare/CompareStatusStyleTests.swift new file mode 100644 index 0000000000..5dfc1a8b96 --- /dev/null +++ b/TableProTests/Views/Compare/CompareStatusStyleTests.swift @@ -0,0 +1,70 @@ +// +// CompareStatusStyleTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct CompareStatusStyleTests { + @Test("Object and row statuses use the system green, orange and red under any theme") + func statusTintsAreSystemColors() throws { + try PaneThemeFixture.withEverySlotOverridden { + #expect(CompareStatusStyle.tint(for: TableDiffStatus.onlyInSource) == .green) + #expect(CompareStatusStyle.tint(for: TableDiffStatus.onlyInTarget) == .red) + #expect(CompareStatusStyle.tint(for: TableDiffStatus.differs) == .orange) + #expect(CompareStatusStyle.tint(for: TableDiffStatus.identical) == .secondary) + + #expect(CompareStatusStyle.tint(for: RowDiffKind.insert) == .green) + #expect(CompareStatusStyle.tint(for: RowDiffKind.update) == .orange) + #expect(CompareStatusStyle.tint(for: RowDiffKind.delete) == .red) + #expect(CompareStatusStyle.tint(for: RowDiffKind.conflict) == .orange) + #expect(CompareStatusStyle.tint(for: RowDiffKind.identical) == .secondary) + + #expect(CompareStatusStyle.success == .green) + #expect(CompareStatusStyle.warning == .orange) + #expect(CompareStatusStyle.error == .red) + } + } + + /// The text diff in Compare and in Version History paints its lines with these, so a theme that + /// recolors the grid must not reach them. + @Test("A row difference takes the default grid's tint, not the theme's") + func rowTintsAreTheNativeGridTints() throws { + try PaneThemeFixture.withEverySlotOverridden { + #expect(ThemeEngine.shared.resolved.isOverridden(.gridInserted)) + #expect(CompareStatusStyle.rowTint(for: .insert) == Color(nsColor: NativeThemeColors.insertedRow)) + #expect(CompareStatusStyle.rowTint(for: .update) == Color(nsColor: NativeThemeColors.modifiedRow)) + #expect(CompareStatusStyle.rowTint(for: .delete) == Color(nsColor: NativeThemeColors.deletedRow)) + #expect(CompareStatusStyle.rowTint(for: .identical) == .clear) + #expect(CompareStatusStyle.rowTint(for: .conflict) == .clear) + } + } + + @Test("The row tints stay faint system colors in both appearances") + func rowTintsResolvePerAppearance() throws { + let tints: [(NSColor, NSColor)] = [ + (NativeThemeColors.insertedRow, .systemGreen), + (NativeThemeColors.modifiedRow, .systemYellow), + (NativeThemeColors.deletedRow, .systemRed), + ] + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + appearance.performAsCurrentDrawingAppearance { + for (tint, base) in tints { + guard let resolved = tint.usingColorSpace(.sRGB), let system = base.usingColorSpace(.sRGB) else { + Issue.record("\(tint) has no sRGB form") + continue + } + #expect(resolved.alphaComponent < 0.5) + #expect(abs(resolved.redComponent - system.redComponent) < 0.01) + #expect(abs(resolved.greenComponent - system.greenComponent) < 0.01) + #expect(abs(resolved.blueComponent - system.blueComponent) < 0.01) + } + } + } + } +} diff --git a/TableProTests/Views/Editor/CompletionBadgeColorTests.swift b/TableProTests/Views/Editor/CompletionBadgeColorTests.swift new file mode 100644 index 0000000000..beb205be25 --- /dev/null +++ b/TableProTests/Views/Editor/CompletionBadgeColorTests.swift @@ -0,0 +1,22 @@ +// +// CompletionBadgeColorTests.swift +// TableProTests +// +// The completion panel is system chrome. Its kind badges drew in the editor's syntax colors for a +// while, which put table, view and schema in one color and left white glyphs on pale fills in +// Default Dark. +// + +import AppKit +@testable import TablePro +import Testing + +struct CompletionBadgeColorTests { + @Test("Every completion kind has a system color of its own") + func eachKindHasItsOwnSystemColor() { + let colors = SQLCompletionKind.allCases.map(\.iconColor) + + #expect(colors.allSatisfy { $0.type == .catalog }) + #expect(Set(colors).count == SQLCompletionKind.allCases.count) + } +} diff --git a/TableProTests/Views/Editor/EditorNativeBackgroundTests.swift b/TableProTests/Views/Editor/EditorNativeBackgroundTests.swift new file mode 100644 index 0000000000..a9bc761494 --- /dev/null +++ b/TableProTests/Views/Editor/EditorNativeBackgroundTests.swift @@ -0,0 +1,79 @@ +// +// EditorNativeBackgroundTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import TableProEditorKit +import TableProGrammars +import TableProTextEngine +import Testing + +@MainActor +struct EditorNativeBackgroundTests { + private func render(appearance name: NSAppearance.Name) throws -> NSBitmapImageRep { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + let themeAppearance: ThemeAppearance = name == .darkAqua ? .dark : .light + engine.adopt(ThemeSelection(pair: .builtIn, effectiveAppearance: themeAppearance)) + + let controller = TextViewController( + string: "SELECT 1;", + language: .sql, + configuration: SourceEditorConfiguration( + appearance: .init( + theme: engine.makeEditorTheme(), + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + lineHeightMultiple: 1.0, + wrapLines: false, + tabWidth: 4 + ) + ), + cursorPositions: [], + highlightProviders: [], + coordinators: [] + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: name) + controller.loadView() + window.contentView = controller.view + controller.view.layoutSubtreeIfNeeded() + + let view = controller.view + let rep = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: rep) + return rep + } + + private func color(_ rep: NSBitmapImageRep, x: Int, fromTop y: Int) throws -> NSColor { + let scale = rep.pixelsWide / 600 + return try #require(rep.colorAt(x: x * scale, y: y * scale)?.usingColorSpace(.sRGB)) + } + + private func hex(_ color: NSColor) -> String { + String( + format: "#%02X%02X%02X%02X", + Int(round(color.redComponent * 255)), + Int(round(color.greenComponent * 255)), + Int(round(color.blueComponent * 255)), + Int(round(color.alphaComponent * 255)) + ) + } + + @Test("Under the default theme the text area below the text is the same text background as the gutter", + arguments: [NSAppearance.Name.aqua, .darkAqua]) + func textAreaMatchesGutter(appearance: NSAppearance.Name) throws { + let rep = try render(appearance: appearance) + let gutter = hex(try color(rep, x: 8, fromTop: 300)) + let textArea = hex(try color(rep, x: 400, fromTop: 300)) + #expect(textArea == gutter, "text area \(textArea), gutter \(gutter)") + } +} diff --git a/TableProTests/Views/Editor/EditorThemeBridgeTests.swift b/TableProTests/Views/Editor/EditorThemeBridgeTests.swift index 27b975013a..5d039ba8e4 100644 --- a/TableProTests/Views/Editor/EditorThemeBridgeTests.swift +++ b/TableProTests/Views/Editor/EditorThemeBridgeTests.swift @@ -11,21 +11,114 @@ import AppKit import TableProEditorKit import Testing +@MainActor struct EditorThemeBridgeTests { - @MainActor + @Test("Default Light hands the editor the system's own color objects around the text") + func defaultLightSurroundIsSystem() { + expectSystemSurround(makeTheme(.builtIn, appearance: .light)) + } + + @Test("Default Dark hands the editor the system's own color objects around the text") + func defaultDarkSurroundIsSystem() { + expectSystemSurround(makeTheme(.builtIn, appearance: .dark)) + } + + @Test("Default Light syntax is Xcode's Default (Light)") + func defaultLightSyntaxIsXcode() { + let theme = makeTheme(.builtIn, appearance: .light) + + #expect(hex(theme.keywords) == "#9B2393") + #expect(hex(theme.variables) == "#9B2393") + #expect(hex(theme.strings) == "#C41A16") + #expect(hex(theme.numbers) == "#1C00CF") + #expect(hex(theme.comments) == "#5D6C79") + #expect(hex(theme.functions) == "#6C36A9") + #expect(hex(theme.types) == "#3900A0") + #expect(theme.keywords.bold) + } + + @Test("Default Dark syntax is Xcode's Default (Dark)") + func defaultDarkSyntaxIsXcode() { + let theme = makeTheme(.builtIn, appearance: .dark) + + #expect(hex(theme.keywords) == "#FC5FA3") + #expect(hex(theme.variables) == "#FC5FA3") + #expect(hex(theme.strings) == "#FC6A5D") + #expect(hex(theme.numbers) == "#D0BF69") + #expect(hex(theme.comments) == "#6C7986") + #expect(hex(theme.functions) == "#A167E6") + #expect(hex(theme.types) == "#D0A8FF") + } + + @Test("The caret stays the system's unless a theme sets a cursor color") + func caretIsNilUnlessOverridden() throws { + #expect(makeTheme(.builtIn, appearance: .light).insertionPoint == nil) + + var custom = BuiltInThemes.light + custom.id = "test.editor-cursor" + custom.overrides[.editorCursor] = try ThemeColor(validating: "#FF00FF") + let theme = makeTheme(ThemePair(light: custom, dark: BuiltInThemes.dark), appearance: .light) + + let caret = try #require(theme.insertionPoint) + #expect(HexColor.string(from: caret) == "#FF00FF") + } + + @Test("A theme overrides only the slots it declares") + func overrideReplacesOnlyItsSlot() throws { + var custom = BuiltInThemes.dark + custom.id = "test.editor-background" + custom.overrides[.editorBackground] = try ThemeColor(validating: "#282A36") + let theme = makeTheme(ThemePair(light: BuiltInThemes.light, dark: custom), appearance: .dark) + + #expect(HexColor.string(from: theme.background) == "#282A36") + #expect(theme.selection == .selectedTextBackgroundColor) + #expect(theme.text.color == .textColor) + } + + /// Accent, Highlight and Increase Contrast all send `themeChanged`, and every editor rebuilds its + /// theme on it. Only an equal theme keeps that from restyling the whole document each time. + @Test("Building the theme twice gives an equal theme") + func rebuildingIsEqual() { + for appearance in [ThemeAppearance.light, .dark] { + let first = makeTheme(.builtIn, appearance: appearance) + let second = makeTheme(.builtIn, appearance: appearance) + #expect(first == second, "\(appearance)") + } + } + @Test("The theme's operator and function colours reach the editor") func themeCarriesOperatorAndFunctionColors() { - let resolved = ThemeEngine.shared.resolved let theme = ThemeEngine.shared.makeEditorTheme() + let resolved = ThemeEngine.shared.resolved + + #expect(theme.operators.color == resolved[.syntaxOperator]) + #expect(theme.functions.color == resolved[.syntaxFunction]) + } + + private func expectSystemSurround(_ theme: EditorTheme) { + let settings = AppSettingsManager.shared.editor + + #expect(theme.background == .textBackgroundColor) + #expect(theme.background.type == .catalog) + #expect(theme.text.color == .textColor) + #expect(theme.operators.color == .textColor) + #expect(theme.selection == .selectedTextBackgroundColor) + #expect(theme.lineNumber == .secondaryLabelColor) + #expect(theme.invisibles.color == .tertiaryLabelColor) + #expect(theme.lineHighlight == (settings.highlightCurrentLine ? NativeThemeColors.currentLine : .clear)) + #expect(theme.statementHighlight == (settings.highlightCurrentStatement ? .quinaryLabelColor : .clear)) + #expect(theme.insertionPoint == nil) + } - #expect(Self.sameColor(theme.operators.color, resolved[.syntaxOperator])) - #expect(Self.sameColor(theme.functions.color, resolved[.syntaxFunction])) + private func makeTheme(_ pair: ThemePair, appearance: ThemeAppearance) -> EditorTheme { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + engine.adopt(ThemeSelection(pair: pair, effectiveAppearance: appearance)) + return engine.makeEditorTheme() } - private static func sameColor(_ lhs: NSColor, _ rhs: NSColor) -> Bool { - guard let left = lhs.usingColorSpace(.sRGB), let right = rhs.usingColorSpace(.sRGB) else { return false } - return abs(left.redComponent - right.redComponent) < 0.001 - && abs(left.greenComponent - right.greenComponent) < 0.001 - && abs(left.blueComponent - right.blueComponent) < 0.001 + private func hex(_ attribute: EditorTheme.Attribute) -> String { + HexColor.string(from: attribute.color) } } diff --git a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift index cd977b4660..6edc41aaaf 100644 --- a/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift +++ b/TableProTests/Views/Editor/QueryDiagnosticsRefreshTests.swift @@ -193,7 +193,7 @@ struct QueryDiagnosticMessageTests { let emphases = manager.getEmphases(for: QueryDiagnosticsController.emphasisGroup) #expect(emphases.map(\.range) == [range]) - #expect(emphases.first?.style == .underline(color: ThemeEngine.shared.palette[.statusWarning])) + #expect(emphases.first?.style == .underline(color: .systemOrange)) #expect( manager.toolTip(at: try center(of: range, in: controller)) == "Full-width semicolon (U+FF1B). SQL reads only ; as a statement separator." diff --git a/TableProTests/Views/Results/CellOverlayAppearanceTests.swift b/TableProTests/Views/Results/CellOverlayAppearanceTests.swift index 317808f12d..7ec99ced65 100644 --- a/TableProTests/Views/Results/CellOverlayAppearanceTests.swift +++ b/TableProTests/Views/Results/CellOverlayAppearanceTests.swift @@ -92,4 +92,30 @@ struct CellOverlayAppearanceTests { #expect(lightBorder.brightness != darkBorder.brightness) } } + + /// The border is a baked `CGColor`, so a theme change reaches an open editor only by being + /// applied again, which the `themeChanged` subscription does. + @Test("A theme's focus border reaches an open overlay when its colours are reapplied") + func themeFocusBorderIsRebaked() throws { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + engine.adopt(ThemeSelection(pair: .builtIn, effectiveAppearance: .light)) + + let container = makeContainer() + container.appearance = try #require(NSAppearance(named: .aqua)) + let nativeBorder = try #require(container.layer?.borderColor) + + var theme = BuiltInThemes.light + theme.id = "test.overlay-border" + theme.overrides[.gridFocusBorder] = try ThemeColor(validating: "#FF0000") + engine.adopt(ThemeSelection(pair: ThemePair(light: theme, dark: BuiltInThemes.dark), effectiveAppearance: .light)) + container.applyLayerColors() + + let themed = try #require(container.layer?.borderColor.flatMap { NSColor(cgColor: $0)?.usingColorSpace(.sRGB) }) + #expect(themed.redComponent > 0.9) + #expect(themed.greenComponent < 0.1) + #expect(themed.blueComponent < 0.1) + #expect(container.layer?.borderColor != nativeBorder) + } } diff --git a/TableProTests/Views/Results/DataGridBodyChromeTests.swift b/TableProTests/Views/Results/DataGridBodyChromeTests.swift index ee33d85faa..6a167ef0bd 100644 --- a/TableProTests/Views/Results/DataGridBodyChromeTests.swift +++ b/TableProTests/Views/Results/DataGridBodyChromeTests.swift @@ -167,19 +167,51 @@ struct DataGridBodyChromeTests { #expect(!grid.coordinator.presentsColumn(atTableColumnIndex: rowNumber)) } - @Test("The theme's grid line becomes the table's grid colour, which the separators draw in") - func themeGridLineBecomesTheTableGridColor() { + /// `NSTableView`'s own grid colour is `separatorColor`, not `NSColor.gridColor`, which in dark + /// mode is darker than the rows it separates. A theme that declares no grid line has to leave the + /// table on that default, and a table a previous theme recoloured has to go back to it. + @Test("Without a theme override the table keeps AppKit's own grid and background colours") + func defaultThemeKeepsTheTableDefaults() { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + engine.adopt(ThemeSelection(pair: .builtIn, effectiveAppearance: .light)) + + let tableView = NSTableView() + let stock = NSTableView() + tableView.gridColor = .systemRed + tableView.backgroundColor = .systemRed + DataGridBodyChrome.applyThemeColors(to: tableView) + + #expect(tableView.gridColor == .separatorColor) + #expect(tableView.gridColor == stock.gridColor) + #expect(tableView.backgroundColor == .controlBackgroundColor) + #expect(tableView.backgroundColor == stock.backgroundColor) + } + + @Test("A theme's grid line and background become the table's colours") + func themeOverridesBecomeTheTableColors() throws { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + var theme = BuiltInThemes.light + theme.id = "test.body-chrome" + theme.overrides[.gridLine] = try ThemeColor(validating: "#FF0000") + theme.overrides[.gridBackground] = try ThemeColor(validating: "#102030") + engine.adopt(ThemeSelection(pair: ThemePair(light: theme, dark: BuiltInThemes.dark), effectiveAppearance: .light)) + let tableView = NSTableView() DataGridBodyChrome.applyThemeColors(to: tableView) - #expect(tableView.gridColor == ThemeEngine.shared.palette[.gridLine]) - #expect(tableView.backgroundColor == ThemeEngine.shared.palette[.gridBackground]) + #expect(tableView.gridColor == engine.resolved[.gridLine]) + #expect(tableView.backgroundColor == engine.resolved[.gridBackground]) + #expect(tableView.gridColor != .separatorColor) } /// The colour has to come from `tableView.gridColor`, which is the dynamic catalog colour AppKit - /// was filling with, so an appearance change carries the separator with it and there is no - /// second spelling to keep in sync. A hardcoded colour would pass a geometry test and be wrong - /// in dark mode. + /// fills with, so an appearance change carries the separator with it and there is no second + /// spelling to keep in sync. A hardcoded colour would pass a geometry test and be wrong in dark + /// mode. @Test("The separator is drawn in the table view's own grid colour") func separatorUsesTheTableViewGridColor() throws { let grid = makeGrid(columns: ["id", "name"]) diff --git a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift index c77153bd2b..bc28f5f515 100644 --- a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift +++ b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift @@ -39,7 +39,9 @@ struct DataGridCellAppearanceTests { rowNumberText: .systemTeal, deletedRowText: .systemRed, modifiedColumnTint: .systemYellow, - findMatchTint: .systemOrange + findMatchTint: .systemOrange, + selectionText: .systemIndigo, + focusRing: .systemMint ) private func resolve( @@ -120,12 +122,28 @@ struct DataGridCellAppearanceTests { #expect(appearance.backgroundTint == nil) } - @Test("The find match keeps its highlight and turns its text black") + @Test("The find match keeps its highlight and its text takes the find indicator's color") func findMatchWins() { let appearance = resolve(isCurrentFindMatch: true, onEmphasizedSelection: true) #expect(appearance.backgroundTint == palette.findMatchTint) - #expect(appearance.textColor == .black) + #expect(appearance.textColor == palette.findMatchText) + #expect(palette.findMatchText == DataGridCellPalette.findMatchTextColor) + } + + /// `findHighlightColor` is a light yellow in both appearances, so text on it has to stay dark + /// in Dark Mode too, where the label color is white. + @Test("Find match text stays dark in both appearances") + func findMatchTextStaysDark() throws { + for name in [NSAppearance.Name.aqua, .darkAqua] { + let appearance = try #require(NSAppearance(named: name)) + var luminance: CGFloat = 1 + appearance.performAsCurrentDrawingAppearance { + let color = DataGridCellPalette.findMatchTextColor.usingColorSpace(.sRGB) + luminance = color.map { ($0.redComponent + $0.greenComponent + $0.blueComponent) / 3 } ?? 1 + } + #expect(luminance < 0.2, "find match text is \(luminance) in \(name.rawValue)") + } } /// The selection paints the whole row, so a modified cell's tint would be painted over it. @@ -316,8 +334,31 @@ 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 == .black) + #expect(found.textColor == themedPalette.findMatchText) + } + + // MARK: - Selection + + @Test("Without a theme override, a selected cell and its cursor take the system's colors") + func nativeSelectionColors() { + let selected = resolve(isFocused: true, onEmphasizedSelection: true) + let unselected = resolve(isFocused: true) + + #expect(selected.textColor == .alternateSelectedControlTextColor) + #expect(selected.focusColor == .alternateSelectedControlTextColor) + #expect(unselected.focusColor == .keyboardFocusIndicatorColor) + } + + @Test("A theme's selection text reaches a selected cell's text and its cursor") + func themedSelectionColors() { + let selected = resolve(isFocused: true, onEmphasizedSelection: true, palette: themedPalette) + let unselected = resolve(isFocused: true, palette: themedPalette) + + #expect(selected.textColor == .systemIndigo) + #expect(selected.focusColor == .systemIndigo) + #expect(unselected.textColor == .systemPurple) + #expect(unselected.focusColor == .systemMint) } } diff --git a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift index dc4503b99e..3f529463ec 100644 --- a/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift +++ b/TableProTests/Views/Results/DataGridRowNumberRenderingTests.swift @@ -522,8 +522,8 @@ struct DataGridRowNumberRenderingTests { defer { engine.adopt(restore) } var theme = BuiltInThemes.light theme.id = "test.grid-stripes" - theme.dataGrid.background = .hex("#282A36") - theme.dataGrid.alternateRow = .hex("#44475A") + theme.overrides[.gridBackground] = try ThemeColor(validating: "#282A36") + theme.overrides[.gridAlternateRow] = try ThemeColor(validating: "#44475A") engine.adopt(ThemeSelection( pair: ThemePair(light: theme, dark: BuiltInThemes.dark), effectiveAppearance: .light diff --git a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift index 26e5ca3adc..84e1ddf29e 100644 --- a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift +++ b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift @@ -68,10 +68,10 @@ struct DataGridRowTintThemeTests { return try #require(color.usingColorSpace(.sRGB)) } - private func selection(_ base: ThemeDefinition, id: String, deletedHex: String) -> ThemeSelection { + private func selection(_ base: ThemeDefinition, id: String, deletedHex: String) throws -> ThemeSelection { var copy = base copy.id = id - copy.dataGrid.deleted = .hex(deletedHex) + copy.overrides[.gridDeleted] = try ThemeColor(validating: deletedHex) let pair = copy.appearance == .dark ? ThemePair(light: BuiltInThemes.light, dark: copy) @@ -87,11 +87,11 @@ struct DataGridRowTintThemeTests { let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) defer { engine.adopt(restore) } - engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) + try engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) let harness = makeRowView(state: Self.deleted) let firstTint = try renderedTint(of: harness.rowView) - engine.adopt(selection(original, id: "test.tint.blue", deletedHex: "#0000FF")) + try engine.adopt(selection(original, id: "test.tint.blue", deletedHex: "#0000FF")) harness.rowView.invalidateVisualState() let secondTint = try renderedTint(of: harness.rowView) @@ -105,7 +105,7 @@ struct DataGridRowTintThemeTests { let original = engine.activeTheme let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) defer { engine.adopt(restore) } - engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) + try engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) let harness = makeRowView(state: .empty) let before = try renderedTint(of: harness.rowView) @@ -123,7 +123,7 @@ struct DataGridRowTintThemeTests { let original = engine.activeTheme let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) defer { engine.adopt(restore) } - engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) + try engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) let highlight = RowHighlight( rowRule: HighlightRule(columnName: "status", value: "paid", color: .blue), @@ -133,7 +133,7 @@ struct DataGridRowTintThemeTests { let tint = try renderedTint(of: harness.rowView) #expect(tint.redComponent > tint.blueComponent) - #expect(Self.deleted.highlighted(highlight).tint == engine.palette[.gridDeleted]) + #expect(Self.deleted.highlighted(highlight).tint == engine.resolved[.gridDeleted]) #expect(RowVisualState.empty.highlighted(highlight).tint == HighlightColor.blue.washColor) } @@ -144,11 +144,11 @@ struct DataGridRowTintThemeTests { let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) defer { engine.adopt(restore) } - engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) + try engine.adopt(selection(original, id: "test.tint.red", deletedHex: "#FF0000")) let harness = makeRowView(state: .empty) let firstTint = try renderedTint(of: harness.rowView) - engine.adopt(selection(original, id: "test.tint.blue", deletedHex: "#0000FF")) + try engine.adopt(selection(original, id: "test.tint.blue", deletedHex: "#0000FF")) harness.rowView.invalidateVisualState() let secondTint = try renderedTint(of: harness.rowView) diff --git a/TableProTests/Views/Results/DataGridThemeDrawingTests.swift b/TableProTests/Views/Results/DataGridThemeDrawingTests.swift new file mode 100644 index 0000000000..be5ccf1716 --- /dev/null +++ b/TableProTests/Views/Results/DataGridThemeDrawingTests.swift @@ -0,0 +1,317 @@ +// +// DataGridThemeDrawingTests.swift +// TableProTests +// +// Default Light and Default Dark declare no grid color, so the grid has to draw what AppKit draws: +// NSTableRowView's own selection, `headerTextColor`, `separatorColor`. A theme that declares a +// grid color has to reach every surface that shows it, the row-number gutter included. +// + +import AppKit +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class ThemeDrawingLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +/// Overriding a draw method moves a row off AppKit's update-layer path, so this takes the same path +/// as `DataGridRowView` and the two can be held pixel for pixel to each other. +@MainActor +private final class DrawPathRowView: NSTableRowView { + // swiftlint:disable:next unneeded_override + override func drawBackground(in dirtyRect: NSRect) { + super.drawBackground(in: dirtyRect) + } +} + +/// Each test adopts a pair on the shared `ThemeEngine` and restores it in a `defer`. The bodies are +/// synchronous and `@MainActor`, so nothing else on the main actor runs between the two; an `await` +/// added anywhere in between would break that. +@Suite("Data grid drawing follows the theme", .serialized) +@MainActor +struct DataGridThemeDrawingTests { + private static let rowFrame = NSRect(x: 0, y: 0, width: 160, height: 24) + + private struct Grid { + let window: NSWindow + let tableView: KeyHandlingTableView + let coordinator: TableViewCoordinator + } + + private func adopting(_ overrides: [ThemeSlot: String], _ body: () throws -> Void) throws { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + + var theme = BuiltInThemes.light + if !overrides.isEmpty { + theme.id = "test.grid-theme-drawing" + for (slot, hex) in overrides { + theme.overrides[slot] = try ThemeColor(validating: hex) + } + } + engine.adopt(ThemeSelection( + pair: ThemePair(light: theme, dark: BuiltInThemes.dark), + effectiveAppearance: .light + )) + try body() + } + + private func selected(_ row: NSTableRowView, emphasized: Bool) throws -> NSTableRowView { + row.appearance = try #require(NSAppearance(named: .aqua)) + row.wantsLayer = true + row.isSelected = true + row.isEmphasized = emphasized + return row + } + + private func centerColor(of view: NSView) throws -> NSColor { + let rep = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: rep) + let color = try #require(rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)) + return try #require(color.usingColorSpace(.sRGB)) + } + + private func matches(_ lhs: NSColor, _ rhs: NSColor, tolerance: CGFloat = 0.02) -> Bool { + abs(lhs.redComponent - rhs.redComponent) <= tolerance + && abs(lhs.greenComponent - rhs.greenComponent) <= tolerance + && abs(lhs.blueComponent - rhs.blueComponent) <= tolerance + && abs(lhs.alphaComponent - rhs.alphaComponent) <= tolerance + } + + private func cellAppearance(palette: DataGridCellPalette, onEmphasizedSelection: Bool) -> DataGridCellAppearance { + DataGridCellAppearance.resolve( + kind: .text, + content: DataGridCellContent(displayText: "value", rawValue: "value", placeholder: nil), + state: DataGridCellState( + visualState: .empty, + isFocused: true, + isEditable: true, + isLargeDataset: false, + isCurrentFindMatch: false, + row: 0, + columnIndex: 0 + ), + palette: palette, + nullDisplayString: "NULL", + onEmphasizedSelection: onEmphasizedSelection, + hasOverlay: false + ) + } + + private func makeGrid(rows: Int = 3) -> Grid { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: ThemeDrawingLayoutPersister() + ) + let columns = ["id", "name"] + let columnTypes = Array(repeating: ColumnType.text(rawType: "TEXT"), count: columns.count) + let tableRows = TableRows.from( + queryRows: (0.. 0.99, "the focused selection is a wash: \(focused)") + #expect(focused.redComponent > 0.9 && focused.greenComponent < 0.2 && focused.blueComponent < 0.2, "\(focused)") + #expect(unfocused.alphaComponent > 0.99, "the unfocused selection is a wash: \(unfocused)") + #expect(unfocused.blueComponent > 0.9 && unfocused.redComponent < 0.2, "\(unfocused)") + } + } + + @Test("A theme's selection and its text reach the cells and the row-number gutter") + func themedSelectionReachesCellsAndGutter() throws { + try adopting([.gridSelection: "#202020", .gridSelectionText: "#00FF00"]) { + let engine = ThemeEngine.shared + let selectionText = engine.resolved[.gridSelectionText] + let aqua = try #require(NSAppearance(named: .aqua)) + let palette = engine.dataGridCellPalette(in: aqua) + let selectedCell = cellAppearance(palette: palette, onEmphasizedSelection: true) + let plainCell = cellAppearance(palette: palette, onEmphasizedSelection: false) + + #expect(palette.selectionText == selectionText) + #expect(selectedCell.textColor == selectionText) + #expect(selectedCell.focusColor == selectionText) + #expect(plainCell.textColor == palette.text) + #expect(palette.rowNumberColor(for: .empty, onEmphasizedSelection: true) == selectionText) + #expect(DataGridBodyChrome.selectionColor(emphasized: true) == engine.resolved[.gridSelection]) + #expect(DataGridBodyChrome.themedSelectionColor(emphasized: true) == engine.resolved[.gridSelection]) + #expect(DataGridBodyChrome.themedSelectionColor(emphasized: false) == nil) + } + } + + @Test("Cells a range covers under a theme's selection take its selection text, like a selected row") + func themedRangeSelectionRecolorsItsCells() throws { + let grid = makeGrid() + grid.coordinator.selectionController.selectEntireColumn(0, totalRows: 3) + let row = try #require(grid.tableView.rowView(atRow: 1, makeIfNecessary: true) as? DataGridRowView) + row.isEmphasized = true + let firstDataColumn = try #require(grid.coordinator.tableColumnIndex(forDisplayPosition: 0)) + + #expect(row.themedRangeColumns().isEmpty, "Under the default theme a range keeps its tint and the cell text") + + try adopting([.gridSelection: "#000000", .gridSelectionText: "#FFFFFF"]) { + #expect(row.themedRangeColumns() == [firstDataColumn]) + row.isEmphasized = false + #expect(row.themedRangeColumns().isEmpty, "An unfocused range draws normal text, like an unfocused row") + } + } + + // MARK: - Header + + @Test("Under the default theme the header draws in AppKit's header colors") + func defaultHeaderColors() throws { + try adopting([:]) { + let cell = SortableHeaderCell(textCell: "name") + + #expect(cell.foregroundColor(emphasized: false) == .headerTextColor) + #expect(cell.foregroundColor(emphasized: true) == .alternateSelectedControlTextColor) + #expect(SortableHeaderChrome.lineColor == .separatorColor) + #expect(SortableHeaderChrome.backgroundColor == .controlBackgroundColor) + } + } + + @Test("A theme's header text, grid line and selection text reach the header") + func themedHeaderColors() throws { + try adopting([.gridHeaderText: "#FF0000", .gridLine: "#00FF00", .gridSelectionText: "#0000FF"]) { + let engine = ThemeEngine.shared + let cell = SortableHeaderCell(textCell: "name") + + #expect(cell.foregroundColor(emphasized: false) == engine.resolved[.gridHeaderText]) + #expect(cell.foregroundColor(emphasized: true) == engine.resolved[.gridSelectionText]) + #expect(SortableHeaderChrome.lineColor == engine.resolved[.gridLine]) + #expect(SortableHeaderChrome.lineColor != .separatorColor) + } + } + + // MARK: - Theme change + + /// `themeChanged` fires for every Accent, Highlight and Increase Contrast change too. A reload + /// there would drop the selection and every formatted value on each of them. + @Test("A theme change repaints the grid in place, keeping its selection, rows and formatted values") + func themeChangeRepaintsWithoutReloading() throws { + let grid = makeGrid() + try withExtendedLifetime(grid.window) { + grid.tableView.selectRowIndexes(IndexSet(integer: 1), byExtendingSelection: false) + let rowNumberColumn = grid.tableView.column(withIdentifier: ColumnIdentitySchema.rowNumberIdentifier) + let rowView = try #require(grid.tableView.rowView(atRow: 0, makeIfNecessary: false)) + let numberCell = try #require( + grid.tableView.view(atColumn: rowNumberColumn, row: 0, makeIfNecessary: false) as? NSTableCellView + ) + let firstRow = try #require(grid.coordinator.displayRow(at: 0)) + _ = grid.coordinator.cellAppearance(row: 0, columnIndex: 0, onEmphasizedSelection: false) + try #require(grid.coordinator.displayCache.box(forID: firstRow.id) != nil) + + try adopting([.gridLine: "#FF0000", .gridRowNumber: "#00FF00", .gridSelectionText: "#0000FF"]) { + grid.coordinator.themeDidChange() + let engine = ThemeEngine.shared + + #expect(grid.tableView.gridColor == engine.resolved[.gridLine]) + #expect(grid.coordinator.cellRegistry.palette.selectionText == engine.resolved[.gridSelectionText]) + #expect(numberCell.textField?.textColor == engine.resolved[.gridRowNumber]) + #expect(grid.tableView.selectedRowIndexes == IndexSet(integer: 1)) + #expect(grid.tableView.rowView(atRow: 0, makeIfNecessary: false) === rowView) + #expect(grid.coordinator.displayCache.box(forID: firstRow.id) != nil) + } + + try adopting([:]) { + grid.coordinator.themeDidChange() + + #expect(grid.tableView.gridColor == .separatorColor) + #expect(grid.coordinator.cellRegistry.palette.selectionText == .alternateSelectedControlTextColor) + } + } + } +} diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift index 6212959d73..3c3344dda4 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineCellViewTests.swift @@ -85,7 +85,7 @@ struct TreeOutlineCellViewTests { #expect(field.isSelectable) #expect(!field.allowsEditingTextAttributes) #expect(field.stringValue == "\"Acme\"") - #expect(field.textColor == ThemeEngine.shared.palette[.syntaxString]) + #expect(field.textColor == .systemRed) #expect(field.font == TreeOutlineFixture.fonts.value) #expect(cell.accessibilityCustomActions()?.isEmpty ?? true) } @@ -158,8 +158,8 @@ struct TreeOutlineCellViewTests { #expect(plain.valueField.textColor == .alternateSelectedControlTextColor) #expect(plain.keyField.textColor == .alternateSelectedControlTextColor) plain.backgroundStyle = .normal - #expect(plain.valueField.textColor == ThemeEngine.shared.palette[.syntaxNumber]) - #expect(plain.keyField.textColor == ThemeEngine.shared.palette[.syntaxKeyword]) + #expect(plain.valueField.textColor == .systemPurple) + #expect(plain.keyField.textColor == .systemBlue) let link = try cell("site") link.backgroundStyle = .emphasized @@ -168,7 +168,7 @@ struct TreeOutlineCellViewTests { link.backgroundStyle = .normal let normal = link.valueField.attributedStringValue #expect(normal.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor == .linkColor) - #expect(normal.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == ThemeEngine.shared.palette[.syntaxString]) + #expect(normal.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == .systemRed) } @Test("The accessibility action opens the link through the link policy") diff --git a/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift b/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift index 19f6625bb1..ebfbef9ff0 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeOutlineCoordinatorTests.swift @@ -41,28 +41,17 @@ struct TreeOutlineCoordinatorTests { #expect(harness.keys == ["name", "site", "brand", "theme", "ink", "deep", "count"]) } - @Test("A theme change recolors the rows already on screen") - func themeChangeRecolorsVisibleRows() throws { - let engine = ThemeEngine.shared - let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) - defer { engine.adopt(restore) } + @Test("Rows built under a theme that overrides every slot keep their system colors") + func rowsIgnoreTheTheme() throws { + try PaneThemeFixture.withEverySlotOverridden { + let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) + let row = try harness.row("count") + let view = harness.outline.view(atColumn: 0, row: row, makeIfNecessary: false) + let cell = try #require(view as? TreeOutlineCellView) - let harness = try TreeOutlineHarness(json: TreeOutlineFixture.profile) - let row = try harness.row("theme") - let cell = try #require(harness.outline.view(atColumn: 0, row: row, makeIfNecessary: false) as? TreeOutlineCellView) - let before = try #require(cell.keyField.textColor) - - var changed = engine.activeTheme - changed.id = "test.tree.recolor" - changed.editor.syntax.keyword = .hex("#FF0000") - let pair = changed.appearance == .dark - ? ThemePair(light: engine.pair.light, dark: changed) - : ThemePair(light: changed, dark: engine.pair.dark) - engine.adopt(ThemeSelection(pair: pair, effectiveAppearance: changed.appearance)) - - let after = try #require(cell.keyField.textColor) - #expect(after !== before) - #expect(after == engine.palette[.syntaxKeyword]) + #expect(cell.keyField.textColor == .systemBlue) + #expect(cell.valueField.textColor == .systemPurple) + } } @Test("Applying the same content twice reloads nothing") diff --git a/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift b/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift index 679f7f3aa0..8c0a7d01b3 100644 --- a/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift +++ b/TableProTests/Views/Results/TreeOutline/TreeValueStyleTests.swift @@ -33,8 +33,8 @@ struct TreeValueStyleTests { #expect(text.string == "\"https://example.com/docs\"") #expect(TreeValueStyle.linkRange(in: content) == NSRange(location: 1, length: length - 2)) - #expect(color(text, at: 0) == ThemeEngine.shared.palette[.syntaxString]) - #expect(color(text, at: length - 1) == ThemeEngine.shared.palette[.syntaxString]) + #expect(color(text, at: 0) == .systemRed) + #expect(color(text, at: length - 1) == .systemRed) #expect(!isUnderlined(text, at: 0)) #expect(!isUnderlined(text, at: length - 1)) for index in 1 ..< length - 1 { @@ -77,14 +77,7 @@ struct TreeValueStyleTests { @Test("A color and a plain value are one run in the tone color") func otherValuesAreOneRun() throws { - let palette = ThemeEngine.shared.palette - let tones = [ - ("brand", palette[.syntaxString]), - ("name", palette[.syntaxString]), - ("count", palette[.syntaxNumber]), - ("theme", palette[.syntaxKeyword]) - ] - for (key, tone) in tones { + for (key, tone) in [("brand", NSColor.systemRed), ("name", .systemRed), ("count", .systemPurple), ("theme", .systemBlue)] { let content = try content(key) let text = TreeValueStyle.attributedValue(content, font: font, isEmphasized: false) var range = NSRange() @@ -106,7 +99,7 @@ struct TreeValueStyleTests { } } #expect(TreeValueStyle.textColor(for: .number, isEmphasized: true) == .alternateSelectedControlTextColor) - #expect(TreeValueStyle.textColor(for: .number, isEmphasized: false) == ThemeEngine.shared.palette[.syntaxNumber]) + #expect(TreeValueStyle.textColor(for: .number, isEmphasized: false) == .systemPurple) } @Test("The value font and tail truncation are set on the whole string") diff --git a/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift b/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift index 7206b6949e..617cabac26 100644 --- a/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift +++ b/TableProTests/Views/RowInspector/JSONNodeRowViewTests.swift @@ -5,6 +5,7 @@ // What a link line of the JSON inspector prints and where a click on it goes. // +import AppKit import Foundation import SwiftUI import Testing @@ -58,4 +59,23 @@ struct JSONNodeRowViewTests { JSONNodeRowView.openLink(allowed) #expect(opened == [allowed]) } + + @Test("A value takes the tone the JSON tree viewer gives the same kind of value") + func valuesShareTheTreeTones() { + #expect(JSONRowColors.tone(for: .string("a")) == .string) + #expect(JSONRowColors.tone(for: .binary(Data([0x01]))) == .string) + #expect(JSONRowColors.tone(for: .number("1")) == .number) + #expect(JSONRowColors.tone(for: .bool(true)) == .literal) + #expect(JSONRowColors.tone(for: .null) == .literal) + } + + @Test("Punctuation is primary text and every tone is a system color under any theme") + func colorsIgnoreTheTheme() throws { + try PaneThemeFixture.withEverySlotOverridden { + #expect(JSONRowColors.punctuation == .primary) + #expect(JSONRowColors.key == Color(nsColor: .systemBlue)) + #expect(JSONRowColors.placeholder == Color(nsColor: .secondaryLabelColor)) + #expect(JSONRowColors.color(for: .number("1")) == Color(nsColor: .systemPurple)) + } + } } diff --git a/TableProTests/Views/Settings/CustomThemeListModelTests.swift b/TableProTests/Views/Settings/CustomThemeListModelTests.swift new file mode 100644 index 0000000000..a7bfe2c624 --- /dev/null +++ b/TableProTests/Views/Settings/CustomThemeListModelTests.swift @@ -0,0 +1,76 @@ +@testable import TablePro +import Testing + +struct CustomThemeListModelTests { + private func theme(_ id: String, _ name: String, _ appearance: ThemeAppearance) -> ThemeDefinition { + ThemeDefinition(id: id, name: name, author: "", appearance: appearance, overrides: [:]) + } + + private var themes: [ThemeDefinition] { + BuiltInThemes.all + [ + theme("tablepro.dracula", "Dracula", .dark), + theme("user.aaaa0001", "Paper", .light), + theme("user.aaaa0002", "Midnight", .dark), + ] + } + + private let refused = RejectedThemeRecord( + path: "/Themes/Broken.json", + fileName: "Broken.json", + reason: "The file does not declare a theme format version." + ) + + @Test("Only user themes are listed, in catalog order, with refused files kept apart") + func listsUserThemesAndRefusedFiles() { + let model = CustomThemeListModel(themes: themes, rejected: [refused]) + #expect(model.themes.map(\.id) == ["user.aaaa0001", "user.aaaa0002"]) + #expect(model.notLoaded == [refused]) + #expect(!model.isEmpty) + } + + @Test("Built-in themes alone leave the list empty") + func builtInsAloneAreEmpty() { + #expect(CustomThemeListModel(themes: themes.filter(\.isBuiltIn), rejected: []).isEmpty) + } + + @Test("A row resolves to its own theme or file and to nothing else") + func itemsResolve() { + let model = CustomThemeListModel(themes: themes, rejected: [refused]) + #expect(model.theme(for: .theme("user.aaaa0001"))?.name == "Paper") + #expect(model.theme(for: .theme("tablepro.dracula")) == nil) + #expect(model.theme(for: .notLoaded(path: refused.path)) == nil) + #expect(model.theme(for: nil) == nil) + #expect(model.record(for: .notLoaded(path: refused.path)) == refused) + #expect(model.record(for: .theme("user.aaaa0001")) == nil) + } + + @Test("A rename trims, and ignores an empty or unchanged name") + func renameTrimsAndIgnoresNoChange() { + #expect(ThemeNaming.renamed(" Midnight ", current: "Dracula Copy", takenNames: []) == "Midnight") + #expect(ThemeNaming.renamed(" ", current: "Dracula Copy", takenNames: []) == nil) + #expect(ThemeNaming.renamed("Dracula Copy", current: "Dracula Copy", takenNames: []) == nil) + } + + @Test("A rename to a name another theme has is numbered, never duplicated") + func renameNumbersATakenName() { + #expect(ThemeNaming.renamed("Dracula", current: "Mine", takenNames: ["Dracula"]) == "Dracula 2") + #expect(ThemeNaming.renamed("Dracula", current: "Mine", takenNames: ["Dracula", "Dracula 2"]) == "Dracula 3") + #expect(ThemeNaming.renamed("Dracula", current: "Dracula 2", takenNames: ["Dracula"]) == nil) + } + + @Test("Deleting a theme sends only the slots that held it back to their built-ins") + func deletingReleasesOnlyItsSlots() { + let settings = AppearanceSettings( + appearanceMode: .auto, + preferredLightThemeId: "user.aaaa0001", + preferredDarkThemeId: "user.aaaa0002" + ) + + let released = settings.releasing(themeId: "user.aaaa0002") + #expect(released.preferredLightThemeId == "user.aaaa0001") + #expect(released.preferredDarkThemeId == BuiltInThemes.defaultDarkId) + #expect(released.appearanceMode == .auto) + + #expect(settings.releasing(themeId: "user.other") == settings) + } +} diff --git a/TableProTests/Views/Settings/ThemeEditorDraftTests.swift b/TableProTests/Views/Settings/ThemeEditorDraftTests.swift new file mode 100644 index 0000000000..3693533fef --- /dev/null +++ b/TableProTests/Views/Settings/ThemeEditorDraftTests.swift @@ -0,0 +1,84 @@ +import AppKit +@testable import TablePro +import Testing + +@MainActor +struct ThemeEditorDraftTests { + private func midnight() throws -> ThemeDefinition { + var theme = BuiltInThemes.dark + theme.id = "user.aaaa0002" + theme.name = "Midnight" + theme.overrides[.editorBackground] = try ThemeColor(validating: "#282A36") + return theme + } + + @Test("Setting a color overrides the slot, and Use Default removes the override") + func setThenReset() throws { + var draft = try ThemeEditorDraft(theme: midnight()) + let pink = try ThemeColor(validating: "#FF79C6") + + let set = draft.setColor(pink, for: .editorText) + #expect(set) + #expect(draft.theme.overrides[.editorText] == pink) + + let reset = draft.reset(.editorText) + #expect(reset) + #expect(!draft.theme.isOverridden(.editorText)) + + let resetAgain = draft.reset(.editorText) + #expect(!resetAgain) + } + + @Test("Setting the value a slot already holds is not an edit") + func sameOverrideIsNotAnEdit() throws { + var draft = try ThemeEditorDraft(theme: midnight()) + let same = try ThemeColor(validating: "#282A36") + + let changed = draft.setColor(same, for: .editorBackground) + #expect(!changed) + } + + /// A well hands back the color it shows. Storing that echo would freeze a system color into + /// the file, and the slot would stop following Accent and Increase Contrast. + @Test("The system color a well shows for an unset slot is not stored when echoed back") + func echoOfTheSystemColorIsIgnored() throws { + var draft = try ThemeEditorDraft(theme: midnight()) + let shown = ThemeColor(draft.displayColor(for: .gridBackground)) + + let changed = draft.setColor(shown, for: .gridBackground) + #expect(!changed) + #expect(!draft.theme.isOverridden(.gridBackground)) + } + + @Test("An unset slot shows its system color in the theme's appearance, not the app's") + func unsetSlotResolvesInTheThemeAppearance() throws { + let draft = try ThemeEditorDraft(theme: midnight()) + let dark = try #require(NSAppearance(named: .darkAqua)) + var expected = NSColor.controlBackgroundColor + dark.performAsCurrentDrawingAppearance { + expected = NSColor.controlBackgroundColor.usingColorSpace(.sRGB) ?? expected + } + + let shown = ThemeColor(draft.displayColor(for: .gridBackground)) + #expect(shown == ThemeColor(expected)) + } + + @Test("An overridden slot shows its override") + func overriddenSlotShowsItsOverride() throws { + let draft = try ThemeEditorDraft(theme: midnight()) + let shown = ThemeColor(draft.displayColor(for: .editorBackground)) + #expect(shown.hex == "#282A36") + } + + @Test("A rename to the current name is not an edit") + func renameReportsChange() throws { + var draft = try ThemeEditorDraft(theme: midnight()) + + let unchanged = draft.rename(to: "Midnight") + #expect(!unchanged) + + let renamed = draft.rename(to: "Night Owl") + #expect(renamed) + #expect(draft.theme.name == "Night Owl") + } +} diff --git a/TableProTests/Views/Settings/ThemeSlotChoiceTests.swift b/TableProTests/Views/Settings/ThemeSlotChoiceTests.swift new file mode 100644 index 0000000000..2186a3616d --- /dev/null +++ b/TableProTests/Views/Settings/ThemeSlotChoiceTests.swift @@ -0,0 +1,68 @@ +@testable import TablePro +import Testing + +struct ThemeSlotChoiceTests { + private func theme(_ id: String, _ name: String, _ appearance: ThemeAppearance) -> ThemeDefinition { + ThemeDefinition(id: id, name: name, author: "", appearance: appearance, overrides: [:]) + } + + private var catalog: [ThemeDefinition] { + BuiltInThemes.all + [ + theme("tablepro.dracula", "Dracula", .dark), + theme("user.aaaa0001", "Paper", .light), + theme("user.aaaa0002", "Midnight", .dark), + ] + } + + @Test("Each popup lists only its own appearance, built-in themes before custom ones") + func optionsFollowTheSlot() { + let light = ThemeSlotChoice(slot: .light, savedId: BuiltInThemes.defaultLightId, themes: catalog) + #expect(light.builtIn.map(\.id) == [BuiltInThemes.defaultLightId]) + #expect(light.custom.map(\.id) == ["user.aaaa0001"]) + + let dark = ThemeSlotChoice(slot: .dark, savedId: BuiltInThemes.defaultDarkId, themes: catalog) + #expect(dark.builtIn.map(\.id) == [BuiltInThemes.defaultDarkId, "tablepro.dracula"]) + #expect(dark.custom.map(\.id) == ["user.aaaa0002"]) + } + + @Test("A saved theme that is on this Mac is shown with no note") + func presentThemeIsShown() { + let choice = ThemeSlotChoice(slot: .dark, savedId: "user.aaaa0002", themes: catalog) + #expect(choice.selectedId == "user.aaaa0002") + #expect(choice.note == nil) + #expect(choice.noteMessage == nil) + } + + /// Appearance settings sync and theme files do not, so another Mac's choice arrives as an id + /// this Mac has never seen. + @Test("A saved theme this Mac does not have shows the built-in in use and says why") + func missingThemeIsExplained() { + let choice = ThemeSlotChoice(slot: .dark, savedId: "user.synced01", themes: catalog) + #expect(choice.selectedId == BuiltInThemes.defaultDarkId) + #expect(choice.note == .notOnThisMac(fallback: BuiltInThemes.dark.name)) + #expect(choice.noteMessage != nil) + } + + @Test("A saved theme of the other appearance shows the built-in in use and names the theme") + func wrongAppearanceIsExplained() { + let choice = ThemeSlotChoice(slot: .light, savedId: "user.aaaa0002", themes: catalog) + #expect(choice.selectedId == BuiltInThemes.defaultLightId) + #expect(choice.note == .wrongAppearance(theme: "Midnight", fallback: BuiltInThemes.light.name)) + #expect(choice.noteMessage != nil) + } + + @Test("The popup shows the same theme the engine resolves") + func agreesWithTheResolver() { + for savedId in ["user.aaaa0002", "user.missing", "user.aaaa0001", "tablepro.dracula"] { + let choice = ThemeSlotChoice(slot: .dark, savedId: savedId, themes: catalog) + let resolved = ThemeResolver.resolve( + mode: .dark, + lightThemeId: BuiltInThemes.defaultLightId, + darkThemeId: savedId, + themes: catalog, + systemIsDark: true + ) + #expect(choice.selectedId == resolved.active.id, "\(savedId)") + } + } +} diff --git a/TableProTests/Views/Shared/PaneThemeFixture.swift b/TableProTests/Views/Shared/PaneThemeFixture.swift new file mode 100644 index 0000000000..351cf3f4a0 --- /dev/null +++ b/TableProTests/Views/Shared/PaneThemeFixture.swift @@ -0,0 +1,40 @@ +// +// PaneThemeFixture.swift +// TableProTests +// + +import AppKit +@testable import TablePro + +/// A theme that overrides every slot, on the shared engine for the length of `body`. A pane that +/// still reads a slot then takes the override, which is what the native-pane suites look for. +/// +/// Synchronous and main-actor on purpose: nothing else on the main actor runs between adopting the +/// theme and restoring the original, so no suite running in parallel sees it. +@MainActor +internal enum PaneThemeFixture { + static func withEverySlotOverridden(_ body: () throws -> Result) throws -> Result { + let engine = ThemeEngine.shared + let restore = ThemeSelection(pair: engine.pair, effectiveAppearance: engine.effectiveAppearance) + defer { engine.adopt(restore) } + + let pair = ThemePair(light: try theme(.light), dark: try theme(.dark)) + engine.adopt(ThemeSelection(pair: pair, effectiveAppearance: restore.effectiveAppearance)) + return try body() + } + + private static func theme(_ appearance: ThemeAppearance) throws -> ThemeDefinition { + let override = try ThemeColor(validating: "#FF00FF") + var overrides: [ThemeSlot: ThemeColor] = [:] + for slot in ThemeSlot.allCases { + overrides[slot] = override + } + return ThemeDefinition( + id: "user.test.pane-\(appearance.rawValue)", + name: "Pane Test", + author: "", + appearance: appearance, + overrides: overrides + ) + } +} diff --git a/TableProUITests/AppearanceSettingsUITests.swift b/TableProUITests/AppearanceSettingsUITests.swift new file mode 100644 index 0000000000..7c778a6e23 --- /dev/null +++ b/TableProUITests/AppearanceSettingsUITests.swift @@ -0,0 +1,208 @@ +import XCTest + +/// Settings > Appearance, driven the way a person uses it. The app writes theme files into this +/// test's sandbox, so the checks on disk read the folder the app wrote. +final class AppearanceSettingsUITests: UITestCase { + // Theme and menu titles are localized. + private let englishArguments = ["-AppleLanguages", "(en)"] + + func testThemePopupsOfferOnlyTheirOwnAppearance() throws { + let (app, window) = try openAppearancePane() + + let mode = window.popUpButtons["appearance-mode-picker"] + XCTAssertTrue(mode.waitToExist(timeout: 10)) + XCTAssertEqual(mode.value as? String, "System", "A fresh sandbox follows the system appearance") + + let dark = window.popUpButtons["appearance-dark-theme"] + XCTAssertTrue(dark.waitToExist(timeout: 10)) + XCTAssertEqual(dark.value as? String, "Default Dark") + choose("Nord", in: dark) + XCTAssertTrue( + waitForPredicate(timeout: 5) { (dark.value as? String) == "Nord" }, + "Picking Nord should make it the dark theme" + ) + + let light = window.popUpButtons["appearance-light-theme"] + XCTAssertEqual(light.value as? String, "Default Light") + XCTAssertTrue(waitUntilHittable(light, timeout: 10)) + light.click() + XCTAssertTrue(light.menuItems["Default Light"].waitToExist(timeout: 5)) + XCTAssertFalse(light.menuItems["Dracula"].exists, "A dark theme has no place in the Light popup") + XCTAssertFalse(light.menuItems["Nord"].exists, "A dark theme has no place in the Light popup") + app.typeKey(.escape, modifierFlags: []) + + // The UI test defaults domain outlives this test; leave the dark slot as later tests expect it. + choose("Default Dark", in: dark) + XCTAssertTrue(waitForPredicate(timeout: 5) { (dark.value as? String) == "Default Dark" }) + } + + func testCustomThemeFromCreationToDeletion() throws { + let (_, window) = try openAppearancePane() + + createTheme(basedOn: "Dracula", in: window) + let copy = row("Dracula Copy", in: window) + XCTAssertTrue(copy.waitToExist(timeout: 10), "The new theme should be listed") + XCTAssertTrue( + waitForPredicate(timeout: 5) { self.themeDocument(named: "Dracula Copy") != nil }, + "The new theme should be written to the Themes folder" + ) + + rename(selectedThemeTo: "Midnight", in: window) + XCTAssertTrue(row("Midnight", in: window).waitToExist(timeout: 5), "The list should show the new name") + XCTAssertFalse(row("Dracula Copy", in: window).exists) + + let dark = window.popUpButtons["appearance-dark-theme"] + XCTAssertTrue(dark.waitToExist(timeout: 10)) + choose("Midnight", in: dark) + XCTAssertTrue( + waitForPredicate(timeout: 5) { (dark.value as? String) == "Midnight" }, + "A custom dark theme should be offered in the Dark popup" + ) + + clearBackgroundOverride(ofThemeNamed: "Midnight", in: window) + + let midnight = row("Midnight", in: window) + XCTAssertTrue(waitUntilHittable(midnight, timeout: 10)) + midnight.click() + let remove = window.buttons["appearance-theme-remove"] + XCTAssertTrue(remove.waitToExist(timeout: 5)) + XCTAssertEqual(remove.label, "Delete Theme") + XCTAssertTrue(waitForPredicate(timeout: 5) { remove.isEnabled }, "A selected custom theme can be deleted") + remove.click() + + let confirmation = window.sheets.firstMatch + XCTAssertTrue(confirmation.waitToExist(timeout: 5), "Delete should ask first") + confirmation.buttons["Delete"].click() + + XCTAssertTrue(waitForPredicate(timeout: 5) { !midnight.exists }, "The deleted theme should leave the list") + XCTAssertTrue( + waitForPredicate(timeout: 5) { (dark.value as? String) == "Default Dark" }, + "The slot that held the deleted theme should go back to its built-in" + ) + XCTAssertNil(themeDocument(named: "Midnight"), "The theme file should be gone") + } + + // MARK: - Steps + + private func createTheme(basedOn source: String, in window: XCUIElement) { + let add = element("appearance-theme-add", in: window) + XCTAssertTrue(waitUntilHittable(add, timeout: 10), "The Custom Themes list should offer its add menu") + XCTAssertTrue( + add.label == "Add Theme" || add.title == "Add Theme", + "The add menu must be named for VoiceOver: got label '\(add.label)', title '\(add.title)'" + ) + add.click() + + let basedOn = window.menus.firstMatch.menuItems["New Theme Based On"] + XCTAssertTrue(basedOn.waitToExist(timeout: 5)) + basedOn.hover() + let item = basedOn.menuItems[source] + XCTAssertTrue(item.waitToExist(timeout: 5), "New Theme Based On should list \(source)") + item.click() + } + + private func rename(selectedThemeTo name: String, in window: XCUIElement) { + let actions = element("appearance-theme-actions", in: window) + XCTAssertTrue(waitUntilHittable(actions, timeout: 10)) + XCTAssertTrue( + actions.label == "Theme Actions" || actions.title == "Theme Actions", + "The action menu must be named for VoiceOver: got label '\(actions.label)', title '\(actions.title)'" + ) + actions.click() + let renameItem = window.menus.firstMatch.menuItems["Rename…"] + XCTAssertTrue(renameItem.waitToExist(timeout: 5)) + renameItem.click() + + let alert = window.sheets.firstMatch + XCTAssertTrue(alert.waitToExist(timeout: 5), "Rename should ask for the new name") + let field = alert.textFields.firstMatch + XCTAssertTrue(waitUntilHittable(field, timeout: 5)) + field.click() + field.typeKey("a", modifierFlags: .command) + field.typeText(name) + alert.buttons["Rename"].click() + XCTAssertTrue(waitForPredicate(timeout: 5) { !alert.exists }) + } + + private func clearBackgroundOverride(ofThemeNamed name: String, in window: XCUIElement) { + let themeRow = row(name, in: window) + XCTAssertTrue(waitUntilHittable(themeRow, timeout: 10)) + themeRow.doubleClick() + + let sheet = window.sheets.firstMatch + XCTAssertTrue(sheet.waitToExist(timeout: 10), "Double-clicking a theme should open its editor") + + let reset = sheet.buttons["theme-color-reset-editor.background"] + XCTAssertTrue(reset.waitToExist(timeout: 10), "Every color of a Dracula copy starts overridden") + XCTAssertEqual(reset.label, "Use Default") + reset.click() + XCTAssertTrue(waitForPredicate(timeout: 5) { !reset.exists }, "Use Default should clear the override") + XCTAssertTrue( + waitForPredicate(timeout: 5) { + self.themeDocument(named: name) != nil && self.editorColor("background", ofThemeNamed: name) == nil + }, + "The cleared override should be saved" + ) + + // Setting a color goes through the shared color panel, which XCUITest cannot drive reliably; + // ThemeEditorDraftTests covers what a well hands back. + let background = element("theme-color-editor.background", in: sheet) + XCTAssertTrue(waitUntilHittable(background, timeout: 5), "The cleared slot keeps its well") + + let done = sheet.buttons["theme-editor-done"] + XCTAssertTrue(done.waitToExist(timeout: 5)) + done.click() + XCTAssertTrue(waitForPredicate(timeout: 5) { !sheet.exists }, "Done should close the editor") + XCTAssertNil(editorColor("background", ofThemeNamed: name), "Closing the editor keeps the last change") + } + + // MARK: - Helpers + + private func openAppearancePane() throws -> (XCUIApplication, XCUIElement) { + let app = try launchOpeningURL("tablepro://settings/appearance", arguments: englishArguments) + let window = app.windows["settings"] + XCTAssertTrue(window.waitToExist(timeout: 10)) + XCTAssertTrue( + waitForPredicate(timeout: 5) { window.title == "Appearance" }, + "The link should open the Appearance pane, the window is titled \(window.title)" + ) + return (app, window) + } + + private func choose(_ title: String, in popup: XCUIElement) { + XCTAssertTrue(waitUntilHittable(popup, timeout: 10)) + popup.click() + let item = popup.menuItems[title] + XCTAssertTrue(item.waitToExist(timeout: 5), "The popup should offer \(title)") + item.click() + } + + private func element(_ identifier: String, in container: XCUIElement) -> XCUIElement { + container.descendants(matching: .any).matching(identifier: identifier).firstMatch + } + + /// SwiftUI text carries its string in `value` with no label, so a subscript by title finds nothing. + private func row(_ name: String, in window: XCUIElement) -> XCUIElement { + window.staticTexts.matching(NSPredicate(format: "value == %@", name)).firstMatch + } + + private func themeDocument(named name: String) -> [String: Any]? { + guard let root = sandboxRoot else { return nil } + let directory = root.appendingPathComponent("TablePro/Themes", isDirectory: true) + guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { + return nil + } + for file in files where file.pathExtension == "json" { + guard let data = try? Data(contentsOf: file), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["name"] as? String == name + else { continue } + return object + } + return nil + } + + private func editorColor(_ key: String, ofThemeNamed name: String) -> String? { + (themeDocument(named: name)?["editor"] as? [String: Any])?[key] as? String + } +}