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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/rules/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ paths:

# Editor

- **`EditorThemeFactory` builds the `EditorTheme` the editor draws with.** `make(resolved:highlightsCurrentLine:highlightsCurrentStatement:)` is pure over a `ResolvedTheme`. `ThemeEngine.makeEditorTheme()` and `TableProEditorTheme.make()` call it with the live theme and the editor settings; the theme editor preview calls it with a fixed scope's `resolved`. Theme colors and their scope: `.claude/rules/theme.md`.
- **`EditorThemeFactory` builds the `EditorTheme` the editor draws with.** `make(resolved:highlightsCurrentLine:highlightsCurrentStatement:)` is pure over a `ResolvedTheme`. `ThemeEngine.makeEditorTheme()` calls it with the live theme and the editor settings; the theme editor preview calls it with a fixed scope's `resolved`. Theme colors and their scope: `.claude/rules/theme.md`.
- **Aliases and parameters take `editor.text`.** The `variables` capture (aliases, parameters, properties) is a name the user wrote, so it draws in the text color; `attributes` and `values` keep `editor.syntax.null`. Keywords draw bold.
- **A static code view highlights like the editor.** `HighlightedSQLTextView` and chat code blocks color `CodeBlockHighlighter.captures(in:language:)`, the editor's grammar, through the same `EditorTheme`, so a statement reads the same in a preview, a chat block and the editor. Only whole lines before its 10k-character cap get colors. Never add a second highlighter.
- **Fonts are not theme.** `TypographySettings` is device-local and stays out of `AppSettingsCategory.synced`: sync replaces a category wholesale, so an older Mac would reset it and each zoom press would push a record.
- **Two font domains.** A view that also wears the editor's colors (the SQL editor, `JSONCodeEditor`, previews) uses `editorFonts`. Every control that shows or edits a stored value uses `ThemeEngine.valueFont` or `valueFontSwiftUI` (the Data Grid Font), so a value reads the same in the grid, the inspector and a popover; a system text style there looks right only while the two settings are equal. `InspectorFieldRow` sets it for the whole inspector, and its `FieldEditorKind` switch is exhaustive on purpose.
- **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; an AppKit value view (`ValueTextField`, `TextValueEditor`) never sees that environment, so it is handed `valueFont`.
- **Multi-cursor lives in `cursorPositions: [CursorPosition]`.**
- **`TableProEditor` is still in Swift 5 language mode** (`.swiftLanguageMode(.v5)`); moving it to Swift 6 is its own change with its own tests.
10 changes: 5 additions & 5 deletions .claude/rules/theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ paths:

## What a theme colors

- **A theme colors content and the window backdrop, never chrome.** Content is every code view, the data grid, value text and wells, value trees, diffs, record tables, diagrams, charts, and the states that stand in for any of them. Chrome is controls, Accent, Highlight, the sidebar, inspector frame, toolbar, titlebar, tab strip and bars, Settings, Welcome, the connection form, sheet and popover chrome, and SwiftUI `List`, `Form` and `Table` backgrounds. `NSApp.appearance` is the only lever on chrome.
- **A theme colors content and the window backdrop, never chrome.** Content is every code view, the data grid, value text and wells (an editable value included, which is why it is a `ValueTextField` and not a bezeled control), value trees, diffs, record tables, diagrams, charts, and the states that stand in for any of them. Chrome is controls, Accent, Highlight, the sidebar, inspector frame, toolbar, titlebar, tab strip and bars, Settings, Welcome, the connection form, sheet and popover chrome, and SwiftUI `List`, `Form` and `Table` backgrounds. `NSApp.appearance` is the only lever on chrome.
- **Chrome never reads a slot.** It asks `ThemeBackdropState.isThemed(in:scope:)` or `isThemed(highContrast:scope:)` whether the backdrop behind it is themed, and answers with a system style:
- A bar over a pane takes `.paneBarBackground(_:ignoresSafeAreaEdges:)` with the native style it drew before (`.bar` by default, or a flat `controlBackgroundColor` or `windowBackgroundColor`). It draws that style while the backdrop is unthemed and nothing while it is themed, because a material over a themed pane reads as a system band. No edges by default, so a top bar never reaches under the toolbar.
- A bottom status bar drops its material through `DetailPaneStatusBarMaterial`, which sets `statusBarDrawsMaterial(_:)` for `StatusBarChrome`.
- A bar over a pane takes `.paneBarBackground(_:ignoresSafeAreaEdges:)` with the native style it drew before (`.bar` by default, or a flat `controlBackgroundColor` or `windowBackgroundColor`). It draws that style while the backdrop is unthemed and nothing while it is themed, because a material over a themed pane reads as a system band. No edges by default, and a top bar keeps that default, so it never reaches under the toolbar.
- A bottom status bar takes `.statusBarChrome()`. `StatusBarChrome` asks `ThemeBackdropState` itself and drops its material over a themed backdrop, so a host sets nothing.
- The editor tab strip track draws `secondarySystemFill` over a themed backdrop on macOS 26 and later, where the track sits on the theme's window color, and its unthemed fill otherwise. Never a palette color.
- **A code field takes the editor theme wherever it appears.** Startup Commands, Pre-Connect Script, a favorite's query, Rewind, Agent SQL, the deep-link Startup SQL and the tunnel command preview wear `editor.*`; the form or sheet around them stays system.
- **The slot family follows what the surface shows.** Code, values, value trees and wells: `editor.*`, with tree tones from `editor.syntax.*`. Records, schema objects, diagram nodes, chart plots and states that stand in for a grid: `dataGrid.*`. Diff rows: `dataGrid.inserted`, `modified` and `deleted` over the editor background and text. A region behind panes: `window.background`. Identity, severity and status hues stay system.
Expand All @@ -40,7 +40,7 @@ paths:

- **`ThemeScope` is where a carrier reads.** `ThemeScope.live` forwards to `ThemeEngine.shared` and `AppEvents.shared.themeChanged`. `ThemeScope.fixed(_:)` draws one definition in both appearances on its own `ThemeSource` and never reports a change; a new definition is a new scope. Only the theme editor preview passes a fixed scope. A scoped carrier reads `activeTheme`, `palette`, `resolved` and `changes` through its scope, never through `ThemeEngine.shared`. The SwiftUI modifiers read the engine.
- **Two color tiers.** `palette[slot]` and `palette.color(_:otherwise:)` are dynamic colors over the scope's theme pair; use them wherever a view holds a color. `palette.color` returns a new object on every call, so never compare it to skip a write. Each `ThemeSource` names its colors apart, so a fixed scope's color never equals a live one. `resolved[slot]` is the active theme's color or the system color object itself, never a flattened copy; bake it into a `CGColor` only inside the drawing view's appearance (`resolved.cgColor(_:otherwise:in:)`), and check `resolved.isOverridden(_:)` before replacing a native drawing path (row selection, caret, header).
- **The carriers.** SwiftUI: `.themeBackground(_:native:)`, `.themeForeground(_:native:)`, `CodeWell`, `.valueWell(clipsContent:)`, `TextValueEditor(style: .code)`, `.gridStandInBackground(isActive:)`, `.valueStandInBackground()` (a state that takes the place of a value's tree or text, on `editor.background`) and `.geometryMapWell()` (the map pane's well). AppKit, each taking a `scope:`: `ThemeTextViewStyle` on an `NSTextView`; `ThemeTableChrome` (`.records` or `.code`) on a content `NSTableView` or `NSOutlineView`, never a source list, with `ThemeTableRowView` rows and `ThemeTableChrome.HostingCell` cells; `DataGridView(themeScope:)`. Window: `ThemeWindowBackdrop` and `DetailColumnBackdrop` (Windows, below).
- **The carriers.** SwiftUI: `.themeBackground(_:native:)`, `.themeForeground(_:native:)`, `CodeWell`, `.valueWell(clipsContent:)`, `ValueTextField` (an editable value: a borderless field filling a `.valueWell()`, with its own field editor on `ThemeTextViewStyle`), `TextValueEditor(style: .code)`, `.gridStandInBackground(isActive:)`, `.valueStandInBackground()` (a state that takes the place of a value's tree or text, on `editor.background`) and `.geometryMapWell()` (the map pane's well). AppKit, each taking a `scope:`: `ThemeTextViewStyle` on an `NSTextView`; `ThemeTableChrome` (`.records` or `.code`) on a content `NSTableView` or `NSOutlineView`, never a source list, with `ThemeTableRowView` rows and `ThemeTableChrome.HostingCell` cells; `DataGridView(themeScope:)`. Window: `ThemeWindowBackdrop` and `DetailColumnBackdrop` (Windows, below).
- **A themed records header is drawn by the theme.** `NSTableHeaderView` paints its own band over anything beneath it, so while `dataGrid.headerBackground` or `headerText` is themed, `ThemeTableChrome` swaps in `ThemeTableHeaderView` and `ThemeTableCornerView`, and puts the table's own back once the theme stops. The data grid's header: `.claude/rules/data-grid.md`.
- **Hold every AppKit carrier strongly**, as a stored property on its owner. Its subscription and its row views keep weak references, so a carrier held only in a local stops at the first theme change.
- **A theme change has to reach the view.** `AppEvents.shared.themeChanged` fires when the active pair changes (a selection, a catalog edit or sync, a draft preview), on a light/dark flip, an Accent or Highlight change, an Increase Contrast toggle and a font change. A scoped carrier subscribes to its scope's `changes`. A SwiftUI view that reads the engine observes it (`@ObservedObject private var themeEngine = ThemeEngine.shared`), because SwiftUI caches `Color(nsColor:)` by object identity; an AppKit view that bakes a color re-bakes on it. Never call `withAlphaComponent` on a system color outside an `NSColor(name:dynamicProvider:)`: it freezes the color.
Expand All @@ -49,7 +49,7 @@ paths:

## Windows and the backdrop

- **The four content windows are `ContentWindow`s**: main (`TabWindowController`), Data File, Compare & Sync and the value viewers. Each holds a `ThemeWindowBackdrop`, which on macOS 26 and later sets `NSWindow.backgroundColor` to `window.background` only while the theme sets it and puts the window's own color back when it stops; the sidebar, inspector and toolbar glass take their tone from it. Before macOS 26 the window keeps its own color.
- **The four content windows are `ContentWindow`s**: main (`TabWindowController`), Data File, Compare & Sync and the value viewers. Each holds a `ThemeWindowBackdrop`, which on macOS 26 and later sets `NSWindow.backgroundColor` to `window.background` only while the theme sets it and puts the window's own color back when it stops; the sidebar, inspector and toolbar glass take their tone from it. Before macOS 26 the window keeps its own color. `ThemeWindowBackdrop.canReachChrome` is that gate; the titlebar policy, `DetailPaneBackdrop.extendsUnderToolbar` and `EditorTabStripPalette.followsWindowBackdrop` read it rather than an `#available` of their own.
- **One titlebar policy, in `ContentWindow`.** On macOS 26 and later the titlebar is transparent, so the backdrop reaches the toolbar; in full screen with the toolbar hidden it keeps its background, since it then slides down over the content. Before macOS 26 it stays opaque: a transparent titlebar there swaps its material for a flat fill, which fills chrome. A new content window subclasses `ContentWindow`. Toolbar separators stay each window's own.
- **The detail column sits on `window.background` on every macOS version.** `MainSplitViewController` installs `DetailColumnBackdrop` under the detail panes through `WorkspacePaneHost.installBackdrop(_:)`, so Connecting, Unavailable, the agent and every other state get it. Before macOS 26 it stops below the toolbar, which keeps the system color. The backdrop passes clicks through. `DetailPaneBackdropFill` goes only there; a pane that draws nothing shows it. The Compare & Sync and Data File roots fill with `.themeBackground(.windowBackdrop, native:)`.
- **Never fill chrome.** No fill on the sidebar, inspector frame, toolbar or titlebar, and no `.tint` with a theme color. AppKit keeps its own selection unless the theme sets the selection slot. SwiftLint's `pane_hidden_background` bans `.scrollContentBackground(.hidden)` and `.listRowBackground(` in app code, and `window_background_outside_backdrop` bans a window color write outside `ThemeWindowBackdrop`.
Expand Down
7 changes: 0 additions & 7 deletions apple/TablePro/Core/Services/AppServices.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
//
// AppServices.swift
// TablePro
//

import SwiftUI
import TableProSyncTransport

Expand Down Expand Up @@ -38,7 +33,6 @@ struct AppServices {
let copilotService: CopilotService
let mcpServerManager: MCPServerManager
let syncTracker: SyncChangeTracker
let themeEngine: ThemeEngine
let welcomeRouter: WelcomeRouter

static let live = AppServices(
Expand Down Expand Up @@ -72,7 +66,6 @@ struct AppServices {
copilotService: .shared,
mcpServerManager: .shared,
syncTracker: .shared,
themeEngine: .shared,
welcomeRouter: .shared
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ internal class ContentWindow: NSWindow {

// The full-screen hooks run either side of the style mask flipping, so they name the state they move to.
internal func applyTitlebarPolicy(isFullScreen: Bool? = nil) {
// Before macOS 26 a transparent titlebar swaps its material for a flat fill, which would fill chrome.
guard #available(macOS 26.0, *) else { return }
guard ThemeWindowBackdrop.canReachChrome else { return }
titlebarAppearsTransparent = Self.titlebarIsTransparent(
isFullScreen: isFullScreen ?? styleMask.contains(.fullScreen),
toolbarVisible: toolbar?.isVisible ?? false
Expand Down
13 changes: 1 addition & 12 deletions apple/TablePro/Theme/DetailPaneBackdrop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import TableProTheme
@MainActor
internal enum DetailPaneBackdrop {
internal static var extendsUnderToolbar: Bool {
if #available(macOS 26.0, *) { return true }
return false
ThemeWindowBackdrop.canReachChrome
}

internal static func isThemed(contrast: ColorSchemeContrast) -> Bool {
Expand All @@ -31,13 +30,3 @@ internal struct DetailPaneBackdropFill: ViewModifier {
)
}
}

/// The bar's material does not follow a custom window color, so over a themed backdrop it draws none.
internal struct DetailPaneStatusBarMaterial: ViewModifier {
@ObservedObject private var themeEngine = ThemeEngine.shared
@Environment(\.colorSchemeContrast) private var contrast

internal func body(content: Content) -> some View {
content.statusBarDrawsMaterial(!DetailPaneBackdrop.isThemed(contrast: contrast))
}
}
5 changes: 2 additions & 3 deletions apple/TablePro/Theme/MaterialAccessibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import SwiftUI

internal enum MaterialRole {
case banner
case sidebar
case toolbar
case inlineControl
case scrim
Expand All @@ -11,13 +10,13 @@ internal enum MaterialRole {
switch self {
case .banner, .toolbar, .inlineControl:
Color(nsColor: .controlBackgroundColor)
case .sidebar, .scrim:
case .scrim:
Color(nsColor: .windowBackgroundColor)
}
}
}

/// The one rule for both settings, so a view that answers them by hand cannot drift from the six
/// The one rule for both settings, so a view that answers them by hand cannot drift from the views
/// that answer them through `themeMaterial`. The editor tab strip is that view: glass has no
/// `Material` to swap, so it leaves glass behind for its own opaque surfaces instead.
internal enum SolidSurfacePreference {
Expand Down
5 changes: 0 additions & 5 deletions apple/TablePro/Theme/ThemeColor.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import AppKit
import SwiftUI
import TableProTheme

internal extension ThemeColor {
Expand All @@ -26,10 +25,6 @@ internal extension ThemeColor {
self.init(red: Self.byte(red), green: Self.byte(green), blue: Self.byte(blue), alpha: Self.byte(alpha))
}

var swiftUIColor: Color {
Color(nsColor: nsColor)
}

private static func byte(_ component: CGFloat) -> UInt8 {
UInt8(min(max((component * 255).rounded(), 0), 255))
}
Expand Down
1 change: 0 additions & 1 deletion apple/TablePro/Theme/ThemeEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ internal final class ThemeEngine: ObservableObject {
/// the grid cell, its inline editor, the row inspector, a cell popover and a pop-out window.
internal var valueFont: NSFont { dataGridFonts.regular }
internal var valueFontSwiftUI: Font { Font(valueFont) }
internal var valueFontEmphasizedSwiftUI: Font { Font(dataGridFonts.medium) }

nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeEngine")

Expand Down
9 changes: 0 additions & 9 deletions apple/TablePro/Theme/ThemePalette.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import AppKit
import os
import SwiftUI
import TableProTheme

/// The live pair every dynamic slot colour reads through. A colour object an AppKit view is
Expand Down Expand Up @@ -67,10 +66,6 @@ internal struct ThemePalette {
colors[slot] ?? slot.nativeColor
}

internal func color(_ slot: ThemeSlot) -> Color {
Color(nsColor: self[slot])
}

/// For a surface that has its own system color today: the theme's color in each appearance whose
/// theme sets the slot, and `native` itself, still dynamic, in the other.
/// Unnamed, because two dynamic colors with one name compare equal whatever they draw.
Expand Down Expand Up @@ -121,10 +116,6 @@ internal struct ResolvedTheme: Equatable {
definition.isOverridden(slot)
}

internal func color(_ slot: ThemeSlot) -> Color {
Color(nsColor: self[slot])
}

/// The theme's color when the active theme colors the slot, else `native` itself.
internal func color(_ slot: ThemeSlot, otherwise native: NSColor) -> NSColor {
guard isOverridden(slot) else { return native }
Expand Down
14 changes: 10 additions & 4 deletions apple/TablePro/Theme/ThemeWindowBackdrop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,33 @@ import Combine
import TableProTheme

/// Fills a content window's backdrop with the theme's window background, which the sidebar,
/// inspector and toolbar glass take their tone from on macOS 26 and later. Earlier sidebars blend
/// with the desktop instead, so there the window keeps its own color.
/// inspector and toolbar glass take their tone from.
///
/// The window gets a color only while the active theme sets one: any color other than a system one
/// removes the window's own content-background material, so a default theme leaves it untouched.
@MainActor
internal final class ThemeWindowBackdrop {
/// Before macOS 26 the sidebar blends with the desktop, and a transparent titlebar swaps its
/// material for a flat fill that would fill chrome, so the window keeps its own color there.
nonisolated internal static var canReachChrome: Bool {
if #available(macOS 26.0, *) { return true }
return false
}

private weak var window: NSWindow?
private var original: NSColor?
private var themeCancellable: AnyCancellable?

internal init(window: NSWindow) {
self.window = window
guard #available(macOS 26.0, *) else { return }
guard Self.canReachChrome else { return }
apply()
themeCancellable = AppEvents.shared.themeChanged
.sink { [weak self] _ in self?.apply() }
}

internal func apply() {
guard #available(macOS 26.0, *), let window else { return }
guard Self.canReachChrome, let window else { return }
guard ThemeBackdropState.isThemed(in: window.effectiveAppearance) else {
restore(window)
return
Expand Down
Loading
Loading