diff --git a/.claude/rules/editor.md b/.claude/rules/editor.md index fcbd890592..a42f4cc720 100644 --- a/.claude/rules/editor.md +++ b/.claude/rules/editor.md @@ -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. diff --git a/.claude/rules/theme.md b/.claude/rules/theme.md index 5f27677cc7..5616bb0a70 100644 --- a/.claude/rules/theme.md +++ b/.claude/rules/theme.md @@ -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. @@ -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. @@ -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`. diff --git a/apple/TablePro/Core/Services/AppServices.swift b/apple/TablePro/Core/Services/AppServices.swift index eabfe1151c..280f339a5d 100644 --- a/apple/TablePro/Core/Services/AppServices.swift +++ b/apple/TablePro/Core/Services/AppServices.swift @@ -1,8 +1,3 @@ -// -// AppServices.swift -// TablePro -// - import SwiftUI import TableProSyncTransport @@ -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( @@ -72,7 +66,6 @@ struct AppServices { copilotService: .shared, mcpServerManager: .shared, syncTracker: .shared, - themeEngine: .shared, welcomeRouter: .shared ) } diff --git a/apple/TablePro/Core/Services/Infrastructure/ContentWindow.swift b/apple/TablePro/Core/Services/Infrastructure/ContentWindow.swift index d3e275cb40..9744a712d7 100644 --- a/apple/TablePro/Core/Services/Infrastructure/ContentWindow.swift +++ b/apple/TablePro/Core/Services/Infrastructure/ContentWindow.swift @@ -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 diff --git a/apple/TablePro/Theme/DetailPaneBackdrop.swift b/apple/TablePro/Theme/DetailPaneBackdrop.swift index b371f2d6cc..b1d9b1200b 100644 --- a/apple/TablePro/Theme/DetailPaneBackdrop.swift +++ b/apple/TablePro/Theme/DetailPaneBackdrop.swift @@ -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 { @@ -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)) - } -} diff --git a/apple/TablePro/Theme/MaterialAccessibility.swift b/apple/TablePro/Theme/MaterialAccessibility.swift index fa0a649ea7..e185645a2b 100644 --- a/apple/TablePro/Theme/MaterialAccessibility.swift +++ b/apple/TablePro/Theme/MaterialAccessibility.swift @@ -2,7 +2,6 @@ import SwiftUI internal enum MaterialRole { case banner - case sidebar case toolbar case inlineControl case scrim @@ -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 { diff --git a/apple/TablePro/Theme/ThemeColor.swift b/apple/TablePro/Theme/ThemeColor.swift index fcaeeb5cc9..f302a86e11 100644 --- a/apple/TablePro/Theme/ThemeColor.swift +++ b/apple/TablePro/Theme/ThemeColor.swift @@ -1,5 +1,4 @@ import AppKit -import SwiftUI import TableProTheme internal extension ThemeColor { @@ -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)) } diff --git a/apple/TablePro/Theme/ThemeEngine.swift b/apple/TablePro/Theme/ThemeEngine.swift index dc60337e31..4c773ce87d 100644 --- a/apple/TablePro/Theme/ThemeEngine.swift +++ b/apple/TablePro/Theme/ThemeEngine.swift @@ -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") diff --git a/apple/TablePro/Theme/ThemePalette.swift b/apple/TablePro/Theme/ThemePalette.swift index b0cc9320bd..5a76dfd62b 100644 --- a/apple/TablePro/Theme/ThemePalette.swift +++ b/apple/TablePro/Theme/ThemePalette.swift @@ -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 @@ -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. @@ -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 } diff --git a/apple/TablePro/Theme/ThemeWindowBackdrop.swift b/apple/TablePro/Theme/ThemeWindowBackdrop.swift index a3ebe621eb..7dd7cfff72 100644 --- a/apple/TablePro/Theme/ThemeWindowBackdrop.swift +++ b/apple/TablePro/Theme/ThemeWindowBackdrop.swift @@ -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 diff --git a/apple/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/apple/TablePro/Views/AIChat/AIChatCodeBlockView.swift index 8e8d41165a..c4e0fe0a95 100644 --- a/apple/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/apple/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -1,8 +1,3 @@ -// -// AIChatCodeBlockView.swift -// TablePro -// - import AppKit import SwiftUI import TableProGrammars @@ -106,12 +101,12 @@ struct AIChatCodeBlockView: View, Equatable { } return Text(code.isEmpty ? AttributedString(" ") : marked ?? AttributedString(code)) .font(Font(font)) - .foregroundStyle(themeEngine.palette.color(.editorText)) + .themeForeground(.editorText, native: Color(nsColor: .textColor)) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) - .background(themeEngine.palette.color(.editorBackground)) + .themeBackground(.editorBackground, native: Color(nsColor: .textBackgroundColor)) .task(id: key) { guard let key, marked == nil else { return } let text = await CodeBlockHighlighter.highlightedCode( diff --git a/apple/TablePro/Views/Compare/CompareWindowContentView.swift b/apple/TablePro/Views/Compare/CompareWindowContentView.swift index adbbe82dda..2fff0ee8d3 100644 --- a/apple/TablePro/Views/Compare/CompareWindowContentView.swift +++ b/apple/TablePro/Views/Compare/CompareWindowContentView.swift @@ -41,7 +41,6 @@ internal struct CompareWindowContentView: View { /// total number of items in a window, the number of selected items". .safeAreaInset(edge: .bottom, spacing: 0) { CompareStatusBar(session: session) - .modifier(DetailPaneStatusBarMaterial()) } // Behind the panes on every macOS version, as the main window's detail pane is: before // macOS 26 the window color does not take the theme. diff --git a/apple/TablePro/Views/Components/MagnifiableCanvasView.swift b/apple/TablePro/Views/Components/MagnifiableCanvasView.swift index 73941228d3..c0812054e0 100644 --- a/apple/TablePro/Views/Components/MagnifiableCanvasView.swift +++ b/apple/TablePro/Views/Components/MagnifiableCanvasView.swift @@ -11,7 +11,6 @@ import AppKit import Combine import SwiftUI -import TableProTheme @MainActor final class DiagramViewportController: ObservableObject { @@ -213,7 +212,6 @@ struct MagnifiableCanvasView: NSViewRepresentable { context.coordinator.document = document context.coordinator.viewport = viewport - context.coordinator.backdrop = DiagramCanvasBackdrop(scrollView: scrollView) viewport.attach(to: scrollView) return scrollView } @@ -237,7 +235,6 @@ struct MagnifiableCanvasView: NSViewRepresentable { coordinator.viewport?.detach(from: scrollView) coordinator.viewport = nil coordinator.document = nil - coordinator.backdrop = nil } } @@ -260,48 +257,5 @@ struct MagnifiableCanvasView: NSViewRepresentable { final class Coordinator { var document: Document? var viewport: DiagramViewportController? - var backdrop: DiagramCanvasBackdrop? - } -} - -/// The canvas draws no background, so the pane behind it shows through. A theme that sets the window -/// background paints it here as well, the same color a diagram export fills behind its tables. -@MainActor -final class DiagramCanvasBackdrop { - private weak var scrollView: NSScrollView? - private var original: (color: NSColor, drawsBackground: Bool)? - private var themeCancellable: AnyCancellable? - - init(scrollView: NSScrollView) { - self.scrollView = scrollView - apply() - themeCancellable = AppEvents.shared.themeChanged - .sink { [weak self] _ in self?.apply() } - } - - var isThemed: Bool { - original != nil - } - - func apply() { - guard let scrollView else { return } - let appearance = scrollView.effectiveAppearance - let theme = ThemeEngine.shared.pair[ThemeAppearance(matching: appearance)] - guard theme.isOverridden(.windowBackdrop, in: appearance) else { - restore(scrollView) - return - } - if original == nil { - original = (scrollView.backgroundColor, scrollView.drawsBackground) - } - scrollView.backgroundColor = ThemeEngine.shared.palette[.windowBackdrop] - scrollView.drawsBackground = true - } - - private func restore(_ scrollView: NSScrollView) { - guard let original else { return } - scrollView.backgroundColor = original.color - scrollView.drawsBackground = original.drawsBackground - self.original = nil } } diff --git a/apple/TablePro/Views/Components/SQLStatementPreview.swift b/apple/TablePro/Views/Components/SQLStatementPreview.swift index afe9e11f9a..f9e6ca751f 100644 --- a/apple/TablePro/Views/Components/SQLStatementPreview.swift +++ b/apple/TablePro/Views/Components/SQLStatementPreview.swift @@ -66,7 +66,7 @@ struct SQLStatementPreview: View { static func makeConfiguration() -> SourceEditorConfiguration { SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: ThemeEngine.shared.editorFonts.font, wrapLines: true ), diff --git a/apple/TablePro/Views/DataFiles/DataFileContentView.swift b/apple/TablePro/Views/DataFiles/DataFileContentView.swift index 7aa19bd116..38367b5496 100644 --- a/apple/TablePro/Views/DataFiles/DataFileContentView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileContentView.swift @@ -28,7 +28,6 @@ struct DataFileContentView: View { } Divider() DataFileStatusBar(controller: controller) - .modifier(DetailPaneStatusBarMaterial()) } .themeBackground(.windowBackdrop, native: Color(nsColor: .windowBackgroundColor)) } diff --git a/apple/TablePro/Views/Editor/Folding/FoldPreviewView.swift b/apple/TablePro/Views/Editor/Folding/FoldPreviewView.swift index fa2c8a72aa..c187261470 100644 --- a/apple/TablePro/Views/Editor/Folding/FoldPreviewView.swift +++ b/apple/TablePro/Views/Editor/Folding/FoldPreviewView.swift @@ -72,7 +72,7 @@ struct FoldPreviewView: View { private static func makeConfiguration() -> SourceEditorConfiguration { SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: ThemeEngine.shared.editorFonts.font, wrapLines: false ), diff --git a/apple/TablePro/Views/Editor/SQLEditorView.swift b/apple/TablePro/Views/Editor/SQLEditorView.swift index b46ec0c1a4..9c0f01e43d 100644 --- a/apple/TablePro/Views/Editor/SQLEditorView.swift +++ b/apple/TablePro/Views/Editor/SQLEditorView.swift @@ -252,7 +252,7 @@ struct SQLEditorView: View { private static func makeConfiguration() -> SourceEditorConfiguration { SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: ThemeEngine.shared.editorFonts.font, wrapLines: AppSettingsManager.shared.editor.wordWrap, tabWidth: AppSettingsManager.shared.editor.clampedTabWidth diff --git a/apple/TablePro/Views/Editor/TableProEditorTheme.swift b/apple/TablePro/Views/Editor/TableProEditorTheme.swift deleted file mode 100644 index d3013b9349..0000000000 --- a/apple/TablePro/Views/Editor/TableProEditorTheme.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// TableProEditorTheme.swift -// TablePro -// -// Adapts ThemeEngine colors to TableProEditorKit's EditorTheme. -// - -import AppKit -import TableProEditorKit - -/// Maps ThemeEngine's active theme to TableProEditorKit's EditorTheme -struct TableProEditorTheme { - @MainActor - static func make() -> EditorTheme { - ThemeEngine.shared.makeEditorTheme() - } -} diff --git a/apple/TablePro/Views/Filter/FilterPanelView.swift b/apple/TablePro/Views/Filter/FilterPanelView.swift index e69efe1545..1f0c86c9d7 100644 --- a/apple/TablePro/Views/Filter/FilterPanelView.swift +++ b/apple/TablePro/Views/Filter/FilterPanelView.swift @@ -1,8 +1,3 @@ -// -// FilterPanelView.swift -// TablePro -// - import SwiftUI import TableProPluginKit @@ -31,7 +26,7 @@ struct FilterPanelView: View { filterList } } - .paneBarBackground(Color(nsColor: .windowBackgroundColor), ignoresSafeAreaEdges: .all) + .paneBarBackground(Color(nsColor: .windowBackgroundColor)) .focusSection() .onExitCommand { closePanelAndFocusGrid() @@ -104,7 +99,7 @@ struct FilterPanelView: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .paneBarBackground(Color(nsColor: .controlBackgroundColor), ignoresSafeAreaEdges: .all) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) .contentShape(Rectangle()) .alert(String(localized: "Save Filter Preset"), isPresented: $showSavePresetAlert) { TextField(String(localized: "Preset Name"), text: $newPresetName) diff --git a/apple/TablePro/Views/Import/SQLCodePreview.swift b/apple/TablePro/Views/Import/SQLCodePreview.swift index c332e6590f..cd7aff1d2c 100644 --- a/apple/TablePro/Views/Import/SQLCodePreview.swift +++ b/apple/TablePro/Views/Import/SQLCodePreview.swift @@ -35,7 +35,7 @@ struct SQLCodePreview: View { private static func makeConfiguration() -> SourceEditorConfiguration { SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: ThemeEngine.shared.editorFonts.font, wrapLines: false ), diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index a8b3b024e7..68e8434bce 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -1162,7 +1162,6 @@ struct MainEditorContentView: View { onStructureAdd: { coordinator.structureActions?.addRow?() }, onStructureRemove: { coordinator.structureActions?.removeRow?() } ) - .modifier(DetailPaneStatusBarMaterial()) } /// Empty unless the resolver says the chooser is on screen, so the bar and the pane agree on diff --git a/apple/TablePro/Views/Main/EditorTabStripSurfaces.swift b/apple/TablePro/Views/Main/EditorTabStripSurfaces.swift index db8ee85625..e2f8557f89 100644 --- a/apple/TablePro/Views/Main/EditorTabStripSurfaces.swift +++ b/apple/TablePro/Views/Main/EditorTabStripSurfaces.swift @@ -19,8 +19,7 @@ internal enum EditorTabStripPalette { /// The track sits in the titlebar, which shows the theme's window color only where `ThemeWindowBackdrop` sets it. internal static var followsWindowBackdrop: Bool { - if #available(macOS 26.0, *) { return true } - return false + ThemeWindowBackdrop.canReachChrome } @MainActor diff --git a/apple/TablePro/Views/Results/ArrayValueEditorView.swift b/apple/TablePro/Views/Results/ArrayValueEditorView.swift index 7bb0f540a0..3e5ea83c49 100644 --- a/apple/TablePro/Views/Results/ArrayValueEditorView.swift +++ b/apple/TablePro/Views/Results/ArrayValueEditorView.swift @@ -1,10 +1,3 @@ -// -// ArrayValueEditorView.swift -// TablePro -// -// Ordered per-element editor for PostgreSQL array columns. -// - import SwiftUI import TableProPluginKit @@ -144,18 +137,17 @@ struct ArrayValueEditorView: View { @ViewBuilder private func scalarField(for row: ArrayEditorRow) -> some View { let element = binding(for: row.id) - TextField( - "", + ValueTextField( text: Binding( get: { guard case .value(let value) = element.wrappedValue else { return "" } return value }, set: { element.wrappedValue = .value($0) } - ) + ), + font: themeEngine.valueFont ) - .textFieldStyle(.roundedBorder) - .font(themeEngine.valueFontSwiftUI) + .valueWell() .disabled(element.wrappedValue == .null) Toggle("NULL", isOn: Binding( get: { element.wrappedValue == .null }, diff --git a/apple/TablePro/Views/Results/JSONCodeEditor.swift b/apple/TablePro/Views/Results/JSONCodeEditor.swift index 3a72b81db7..4c78a44aa4 100644 --- a/apple/TablePro/Views/Results/JSONCodeEditor.swift +++ b/apple/TablePro/Views/Results/JSONCodeEditor.swift @@ -63,7 +63,7 @@ internal struct JSONCodeEditor: View { private static func makeConfiguration(isEditable: Bool) -> SourceEditorConfiguration { SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: ThemeEngine.shared.editorFonts.font, wrapLines: true ), diff --git a/apple/TablePro/Views/Results/ResultChartToolbar.swift b/apple/TablePro/Views/Results/ResultChartToolbar.swift index b4f48e2349..6537582068 100644 --- a/apple/TablePro/Views/Results/ResultChartToolbar.swift +++ b/apple/TablePro/Views/Results/ResultChartToolbar.swift @@ -1,8 +1,3 @@ -// -// ResultChartToolbar.swift -// TablePro -// - import SwiftUI struct ResultChartToolbar: View { @@ -69,7 +64,7 @@ struct ResultChartToolbar: View { } .padding(.horizontal, 14) .padding(.vertical, 8) - .paneBarBackground(.bar, ignoresSafeAreaEdges: .all) + .paneBarBackground() } @ViewBuilder diff --git a/apple/TablePro/Views/Results/StatusBarChrome.swift b/apple/TablePro/Views/Results/StatusBarChrome.swift index 6727132c09..4901bbe05d 100644 --- a/apple/TablePro/Views/Results/StatusBarChrome.swift +++ b/apple/TablePro/Views/Results/StatusBarChrome.swift @@ -1,8 +1,3 @@ -// -// StatusBarChrome.swift -// TablePro -// - import AppKit import SwiftUI @@ -33,16 +28,14 @@ private struct StatusBarMaterial: NSViewRepresentable { func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} } -extension EnvironmentValues { - /// False where the host paints a custom backdrop under the bar: the material does not follow a custom window color. - @Entry var statusBarDrawsMaterial = true -} - +/// The material does not follow a custom window color, so over a themed backdrop the bar draws none. private struct StatusBarChromeModifier: ViewModifier { - @Environment(\.statusBarDrawsMaterial) private var drawsMaterial + @ObservedObject private var themeEngine = ThemeEngine.shared + @Environment(\.colorSchemeContrast) private var contrast func body(content: Content) -> some View { - VStack(spacing: 0) { + let isThemed = ThemeBackdropState.isThemed(highContrast: contrast == .increased) + return VStack(spacing: 0) { Divider() content .padding(.horizontal, StatusBarChrome.horizontalPadding) @@ -50,7 +43,7 @@ private struct StatusBarChromeModifier: ViewModifier { .frame(height: StatusBarChrome.height) } .background { - if drawsMaterial { + if !isThemed { StatusBarMaterial() } } @@ -58,15 +51,10 @@ private struct StatusBarChromeModifier: ViewModifier { } extension View { - /// Fixed height, one material, one separator. Applied by every bottom bar in the app. func statusBarChrome() -> some View { modifier(StatusBarChromeModifier()) } - func statusBarDrawsMaterial(_ drawsMaterial: Bool) -> some View { - environment(\.statusBarDrawsMaterial, drawsMaterial) - } - /// Height only: the glyph keeps its size and its width, so no control gets wider or narrower. func statusBarControlIcon(besideTitle: Bool) -> some View { frame(height: StatusBarChrome.controlIconHeight(besideTitle: besideTitle)) diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift index 2f02de7fc4..61f1e31fe2 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/BlobHexEditorView.swift @@ -1,8 +1,3 @@ -// -// BlobHexEditorView.swift -// TablePro -// - import SwiftUI import TableProTheme @@ -60,7 +55,7 @@ internal struct BlobHexEditorView: View { .frame(maxWidth: .infinity, alignment: .topLeading) } .frame(maxHeight: 120) - .modifier(ThemedDumpWell(isEnabled: drawsWell)) + .modifier(DumpWell(isEnabled: drawsWell)) } /// A value past the cap shows the dump it can show, selectable, rather than a disabled field. @@ -72,11 +67,13 @@ internal struct BlobHexEditorView: View { if isTruncated { readOnlyHexView } else { - TextField("Hex bytes", text: $hexEditText, axis: .vertical) - .font(themeEngine.valueFontSwiftUI) - .textFieldStyle(.roundedBorder) - .lineLimit(3...8) - .autocorrectionDisabled(true) + ValueTextField( + text: $hexEditText, + placeholder: String(localized: "Hex bytes"), + font: themeEngine.valueFont, + lineLimit: 3...8 + ) + .modifier(FieldWell(isEnabled: drawsWell)) .focused($isFocused) .onChange(of: isFocused) { _ in if !isFocused { @@ -145,23 +142,32 @@ internal struct BlobHexEditorView: View { } } -/// The dump sits straight on the inspector natively. Under a theme it takes the well every other -/// value there sits in, so it does not read as system text beside the theme's wells. -private struct ThemedDumpWell: ViewModifier { - @ObservedObject private var themeEngine = ThemeEngine.shared - @Environment(\.colorSchemeContrast) private var contrast - +/// The well every other value sits in, except inside a pane that is already one. +private struct DumpWell: ViewModifier { let isEnabled: Bool - private static let shape = RoundedRectangle(cornerRadius: 5) + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content + .valueWellPadding() + .valueWell() + } else { + content + } + } +} + +/// The editable field draws its own text inset, so its well adds no padding. +private struct FieldWell: ViewModifier { + let isEnabled: Bool + @ViewBuilder func body(content: Content) -> some View { - let fill = isEnabled - ? ThemeShapeStyle.override(.editorBackground, engine: themeEngine, contrast: contrast) - : nil - content - .padding(fill == nil ? 0 : 6) - .background(fill ?? Color.clear, in: Self.shape) - .overlay(Self.shape.strokeBorder(fill == nil ? Color.clear : Color(nsColor: .separatorColor))) + if isEnabled { + content.valueWell() + } else { + content + } } } diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift index 4e37ce54e5..4e9f1536f4 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/SingleLineEditorView.swift @@ -1,21 +1,16 @@ -// -// SingleLineEditorView.swift -// TablePro -// - import SwiftUI import TableProTheme internal struct SingleLineEditorView: View { + @ObservedObject private var themeEngine = ThemeEngine.shared let context: FieldEditorContext var body: some View { if context.isReadOnly { readOnlyValue } else { - TextField(context.placeholderText, text: context.value) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled(true) + ValueTextField(text: context.value, placeholder: context.placeholderText, font: themeEngine.valueFont) + .valueWell() } } @@ -35,8 +30,7 @@ internal struct SingleLineEditorView: View { } .textSelection(.enabled) .frame(maxWidth: .infinity, minHeight: 16, alignment: .leading) - .padding(.horizontal, 6) - .padding(.vertical, 4) + .valueWellPadding() .valueWell() } } diff --git a/apple/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift b/apple/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift index 495b73b519..a3200f0e72 100644 --- a/apple/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift +++ b/apple/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift @@ -1,8 +1,3 @@ -// -// TextValueEditor.swift -// TablePro -// - import AppKit import SwiftUI @@ -59,9 +54,17 @@ internal struct TextValueEditor: NSViewRepresentable { Coordinator(text: $text, movesFocusOnTab: movesFocusOnTab) } - /// AppKit substitutes typed quotes, dashes and text by default. A stored value has to reach - /// the database as the user typed it, so every substitution is off. static func applyPlainTextDefaults(to textView: NSTextView) { + disableSubstitutions(in: textView) + textView.usesFindBar = true + textView.isIncrementalSearchingEnabled = true + textView.allowsUndo = true + } + + /// AppKit substitutes typed quotes, dashes and text by default, the window's shared field editor + /// included. A stored value has to reach the database as the user typed it, so every + /// substitution is off. + static func disableSubstitutions(in textView: NSTextView) { textView.isRichText = false textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false @@ -69,9 +72,6 @@ internal struct TextValueEditor: NSViewRepresentable { textView.isAutomaticSpellingCorrectionEnabled = false textView.isAutomaticLinkDetectionEnabled = false textView.isAutomaticDataDetectionEnabled = false - textView.usesFindBar = true - textView.isIncrementalSearchingEnabled = true - textView.allowsUndo = true } private func applyConfiguration(to scrollView: NSScrollView, textView: NSTextView, coordinator: Coordinator) { diff --git a/apple/TablePro/Views/Shared/FieldEditors/ValueTextField.swift b/apple/TablePro/Views/Shared/FieldEditors/ValueTextField.swift new file mode 100644 index 0000000000..a06182eadf --- /dev/null +++ b/apple/TablePro/Views/Shared/FieldEditors/ValueTextField.swift @@ -0,0 +1,245 @@ +import AppKit +import Combine +import SwiftUI +import TableProTheme + +/// An editable stored value, drawn in the same value well as a read-only or multi-line value. +/// +/// A bezeled field paints its own fill and turns `drawsBackground` off, so no theme color reaches +/// it, and it edits in the window's shared field editor, so neither does the theme's caret. This +/// field is borderless and fills the well; its cell insets the text, rings the well with the +/// system focus ring and hands out a field editor of its own. +internal struct ValueTextField: NSViewRepresentable { + @Binding var text: String + var placeholder: String = "" + var font: NSFont + /// Nil keeps one line. A range wraps and grows between those line counts, then scrolls. + var lineLimit: ClosedRange? + + func makeNSView(context: Context) -> ValueTextFieldView { + let field = ValueTextFieldView(string: text) + field.delegate = context.coordinator + context.coordinator.lastAppliedText = text + configure(field, isEnabled: context.environment.isEnabled) + return field + } + + func updateNSView(_ field: ValueTextFieldView, context: Context) { + context.coordinator.text = $text + configure(field, isEnabled: context.environment.isEnabled) + + guard context.coordinator.lastAppliedText != text else { return } + context.coordinator.lastAppliedText = text + field.replaceText(with: text) + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView field: ValueTextFieldView, context: Context) -> CGSize? { + let width = proposal.width ?? field.fittingSize.width + return CGSize(width: width, height: field.height(forWidth: width, lineLimit: lineLimit)) + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + private func configure(_ field: ValueTextFieldView, isEnabled: Bool) { + if field.font != font { + field.font = font + field.invalidateIntrinsicContentSize() + } + if field.placeholderString != placeholder { + field.placeholderString = placeholder + } + if field.isEnabled != isEnabled { + field.isEnabled = isEnabled + } + field.setWraps(lineLimit != nil) + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var text: Binding + /// What the field was last given, so an update pass that carries the field's own edit back + /// does not reset the caret. + var lastAppliedText = "" + + init(text: Binding) { + self.text = text + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? ValueTextFieldView else { return } + let value = field.stringValue + lastAppliedText = value + text.wrappedValue = value + field.invalidateIntrinsicContentSize() + } + } +} + +internal final class ValueTextFieldView: NSTextField { + /// Its own, because the window's field editor is shared: a caret color written there stays on + /// the next field the user edits, measured. + internal private(set) lazy var ownFieldEditor: NSTextView = { + let editor = ValueFieldEditor() + editor.isFieldEditor = true + editor.allowsUndo = true + TextValueEditor.disableSubstitutions(in: editor) + return editor + }() + + fileprivate private(set) lazy var fieldEditorStyle = ThemeTextViewStyle(textView: ownFieldEditor) + private var themeCancellable: AnyCancellable? + + override class var cellClass: AnyClass? { + get { ValueTextFieldCell.self } + set { super.cellClass = newValue } + } + + convenience init(string: String) { + self.init(frame: .zero) + stringValue = string + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isBezeled = false + isBordered = false + drawsBackground = false + isEditable = true + isSelectable = true + focusRingType = .default + textColor = ThemeScope.live.palette.color(.editorText, otherwise: .textColor) + // The color is dynamic and answers for the theme in effect, but nothing asks it again + // until the field draws. + themeCancellable = ThemeScope.live.changes + .sink { [weak self] _ in self?.needsDisplay = true } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// A borderless field reports 2pt side insets, so SwiftUI would lay it out 4pt wider than the + /// well and its focus ring would miss the well's edge. + override var alignmentRectInsets: NSEdgeInsets { + NSEdgeInsetsZero + } + + /// A value set from outside while the field is being edited, such as Set EMPTY from its menu. + internal func replaceText(with text: String) { + defer { invalidateIntrinsicContentSize() } + guard let editor = currentEditor() as? NSTextView else { + stringValue = text + return + } + let caret = editor.selectedRange().location + editor.string = text + editor.setSelectedRange(NSRange(location: min(caret, (text as NSString).length), length: 0)) + // Typing undo indexes into the text it was recorded against; undoing it here would raise. + editor.undoManager?.removeAllActions() + } + + internal func setWraps(_ wraps: Bool) { + guard let cell = cell as? ValueTextFieldCell, cell.wraps != wraps || cell.usesSingleLineMode == wraps else { return } + cell.usesSingleLineMode = !wraps + cell.wraps = wraps + cell.isScrollable = !wraps + cell.lineBreakMode = wraps ? .byCharWrapping : .byTruncatingTail + invalidateIntrinsicContentSize() + } + + internal func height(forWidth width: CGFloat, lineLimit: ClosedRange?) -> CGFloat { + guard let cell else { return intrinsicContentSize.height } + let inset = ValueWellMetrics.textInset.height * 2 + guard let lineLimit, let font else { + return ceil(cell.cellSize(forBounds: NSRect(x: 0, y: 0, width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)).height) + } + let lineHeight = NSLayoutManager().defaultLineHeight(for: font) + let fitting = cell.cellSize(forBounds: NSRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude)).height + let lowest = lineHeight * CGFloat(lineLimit.lowerBound) + inset + let highest = lineHeight * CGFloat(lineLimit.upperBound) + inset + return ceil(min(max(fitting, lowest), highest)) + } +} + +/// Holds no state of its own: AppKit copies a cell bit for bit, so everything lives on the field. +private final class ValueTextFieldCell: NSTextFieldCell { + override func fieldEditor(for controlView: NSView) -> NSTextView? { + (controlView as? ValueTextFieldView)?.ownFieldEditor + } + + /// AppKit resets the editor's colors from the cell each time editing starts. + override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText { + let configured = super.setUpFieldEditorAttributes(textObj) + (controlView as? ValueTextFieldView)?.fieldEditorStyle.apply() + return configured + } + + override func endEditing(_ textObj: NSText) { + (textObj as? ValueFieldEditor)?.endUndoSession() + super.endEditing(textObj) + } + + override func drawingRect(forBounds rect: NSRect) -> NSRect { + super.drawingRect(forBounds: Self.textRect(in: rect)) + } + + override func cellSize(forBounds rect: NSRect) -> NSSize { + let inset = ValueWellMetrics.textInset + let text = super.cellSize(forBounds: Self.textRect(in: rect)) + return NSSize(width: text.width + inset.width * 2, height: text.height + inset.height * 2) + } + + override func edit(withFrame rect: NSRect, in controlView: NSView, editor textObj: NSText, delegate: Any?, event: NSEvent?) { + super.edit(withFrame: Self.textRect(in: rect), in: controlView, editor: textObj, delegate: delegate, event: event) + } + + override func select( + withFrame rect: NSRect, + in controlView: NSView, + editor textObj: NSText, + delegate: Any?, + start selStart: Int, + length selLength: Int + ) { + super.select( + withFrame: Self.textRect(in: rect), + in: controlView, + editor: textObj, + delegate: delegate, + start: selStart, + length: selLength + ) + } + + override func drawFocusRingMask(withFrame cellFrame: NSRect, in controlView: NSView) { + let radius = ValueWellMetrics.cornerRadius + NSBezierPath(roundedRect: cellFrame, xRadius: radius, yRadius: radius).fill() + } + + override func focusRingMaskBounds(forFrame cellFrame: NSRect, in controlView: NSView) -> NSRect { + cellFrame + } + + private static func textRect(in rect: NSRect) -> NSRect { + let inset = ValueWellMetrics.textInset + guard rect.width.isFinite, rect.height.isFinite else { return rect } + return rect.insetBy(dx: inset.width, dy: inset.height) + } +} + +/// The system field editor keeps typing undo in a manager of its own for one editing session. A +/// custom one would resolve the window's instead, shared with the grid, and leave a typing action +/// pointing into a text that AppKit empties when editing ends: Undo then raises, measured. +private final class ValueFieldEditor: NSTextView { + private let sessionUndoManager = UndoManager() + + override var undoManager: UndoManager? { + sessionUndoManager + } + + func endUndoSession() { + sessionUndoManager.removeAllActions() + } +} diff --git a/apple/TablePro/Views/Shared/FieldEditors/ValueWell.swift b/apple/TablePro/Views/Shared/FieldEditors/ValueWell.swift index a723a315d9..6ad7673da5 100644 --- a/apple/TablePro/Views/Shared/FieldEditors/ValueWell.swift +++ b/apple/TablePro/Views/Shared/FieldEditors/ValueWell.swift @@ -1,8 +1,3 @@ -// -// ValueWell.swift -// TablePro -// - import SwiftUI import TableProTheme @@ -13,12 +8,24 @@ internal extension View { func valueWell(clipsContent: Bool = false) -> some View { modifier(ValueWell(clipsContent: clipsContent)) } + + /// The inset text takes inside the well, so a read-only value and `ValueTextField` line up. + func valueWellPadding() -> some View { + padding(.horizontal, ValueWellMetrics.textInset.width) + .padding(.vertical, ValueWellMetrics.textInset.height) + } +} + +/// Shared with `ValueTextField`, whose focus ring has to hug the well's own shape. +internal enum ValueWellMetrics { + static let cornerRadius: CGFloat = 5 + static let textInset = CGSize(width: 6, height: 4) } private struct ValueWell: ViewModifier { let clipsContent: Bool - private static let shape = RoundedRectangle(cornerRadius: 5) + private static let shape = RoundedRectangle(cornerRadius: ValueWellMetrics.cornerRadius) @ViewBuilder func body(content: Content) -> some View { diff --git a/apple/TablePro/Views/Sidebar/FileConflictDiffSheet.swift b/apple/TablePro/Views/Sidebar/FileConflictDiffSheet.swift index d7ed41f2a2..d84562bb2d 100644 --- a/apple/TablePro/Views/Sidebar/FileConflictDiffSheet.swift +++ b/apple/TablePro/Views/Sidebar/FileConflictDiffSheet.swift @@ -1,8 +1,3 @@ -// -// FileConflictDiffSheet.swift -// TablePro -// - import SwiftUI import TableProTheme @@ -107,7 +102,7 @@ private struct DiffColumnView: View { .padding(.horizontal, 12) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .controlBackgroundColor)) + .themeBackground(.editorBackground, native: Color(nsColor: .controlBackgroundColor)) Divider() diff --git a/apple/TablePro/Views/Structure/CreateTableFormPreview.swift b/apple/TablePro/Views/Structure/CreateTableFormPreview.swift index e902a1da1a..902c2e00d2 100644 --- a/apple/TablePro/Views/Structure/CreateTableFormPreview.swift +++ b/apple/TablePro/Views/Structure/CreateTableFormPreview.swift @@ -4,7 +4,6 @@ // import SwiftUI -import TableProTheme internal struct CreateTableFormPreview: View { internal let preview: CreateTableFormState.Preview @@ -27,8 +26,7 @@ internal struct CreateTableFormPreview: View { } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) - // Stands in for the statement text view, so under a theme it keeps the editor's background. - .themeBackground(.editorBackground, native: Color.clear, ignoresSafeAreaEdges: []) + .valueStandInBackground() } } .accessibilityElement(children: .contain) diff --git a/apple/TablePro/Views/Structure/CreateTableView.swift b/apple/TablePro/Views/Structure/CreateTableView.swift index 206b4dbec3..1c6916cf98 100644 --- a/apple/TablePro/Views/Structure/CreateTableView.swift +++ b/apple/TablePro/Views/Structure/CreateTableView.swift @@ -11,7 +11,6 @@ import Combine import os import SwiftUI import TableProPluginKit -import TableProTheme private enum CreateTableTab: CaseIterable { case columns @@ -608,7 +607,6 @@ struct CreateTableView: View { } } -// Stands in for the statement text view, so under a theme it keeps the editor's background. internal struct CreateTableSQLPreviewStandIn: View { internal enum Content: Equatable { case message(String?) @@ -619,7 +617,7 @@ internal struct CreateTableSQLPreviewStandIn: View { internal var body: some View { stateView - .themeBackground(.editorBackground, native: Color.clear, ignoresSafeAreaEdges: []) + .valueStandInBackground() } @ViewBuilder diff --git a/apple/TablePro/Views/Structure/DDLTextView.swift b/apple/TablePro/Views/Structure/DDLTextView.swift index e7fda71800..8df3363b4c 100644 --- a/apple/TablePro/Views/Structure/DDLTextView.swift +++ b/apple/TablePro/Views/Structure/DDLTextView.swift @@ -64,7 +64,7 @@ struct DDLTextView: View { let font = NSFont.monospacedSystemFont(ofSize: CGFloat(fontSize), weight: .regular) return SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: font, wrapLines: false ), diff --git a/apple/TablePro/Views/Structure/TableStructureView+Schema.swift b/apple/TablePro/Views/Structure/TableStructureView+Schema.swift index 98be94a3e2..b583a973e7 100644 --- a/apple/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/apple/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -10,7 +10,6 @@ import Combine import os import SwiftUI import TableProPluginKit -import TableProTheme import UniformTypeIdentifiers // MARK: - Schema Operations @@ -189,7 +188,6 @@ extension TableStructureView { } } -// Stands in for the DDL text view, so under a theme it keeps the editor's background. internal struct StructureDDLUnavailableView: View { internal var body: some View { VStack(spacing: 8) { @@ -201,6 +199,6 @@ internal struct StructureDDLUnavailableView: View { .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .themeBackground(.editorBackground, native: Color.clear, ignoresSafeAreaEdges: []) + .valueStandInBackground() } } diff --git a/apple/TablePro/Views/Structure/TriggerEditorView.swift b/apple/TablePro/Views/Structure/TriggerEditorView.swift index fea732ca49..a9a8dc7ed8 100644 --- a/apple/TablePro/Views/Structure/TriggerEditorView.swift +++ b/apple/TablePro/Views/Structure/TriggerEditorView.swift @@ -135,7 +135,7 @@ struct TriggerEditorView: View { private static func makeConfiguration(fontSize: Double) -> SourceEditorConfiguration { SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: NSFont.monospacedSystemFont(ofSize: CGFloat(fontSize), weight: .regular), wrapLines: false ), diff --git a/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift b/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift index ca221193d3..7f19473eb0 100644 --- a/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift +++ b/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift @@ -50,7 +50,7 @@ struct PrincipalDetailPane: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } @ViewBuilder @@ -97,6 +97,6 @@ struct PendingChangesBar: View { .font(.subheadline) .padding(.horizontal, 12) .padding(.vertical, 8) - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } } diff --git a/apple/TablePro/Views/UsersRoles/PrincipalListPane.swift b/apple/TablePro/Views/UsersRoles/PrincipalListPane.swift index b205fb6af4..cd73778209 100644 --- a/apple/TablePro/Views/UsersRoles/PrincipalListPane.swift +++ b/apple/TablePro/Views/UsersRoles/PrincipalListPane.swift @@ -26,7 +26,7 @@ struct PrincipalListPane: View { .padding(.vertical, 8) Divider() } - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { @@ -46,7 +46,7 @@ struct PrincipalListPane: View { .padding(.horizontal, 8) .padding(.vertical, 6) } - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } } diff --git a/apple/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift b/apple/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift index 4d5fc01ce3..56ded25be5 100644 --- a/apple/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift +++ b/apple/TablePro/Views/UsersRoles/PrivilegeChecklistView.swift @@ -22,7 +22,7 @@ struct PrivilegeChecklistView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .controlBackgroundColor)) + .paneBarBackground(Color(nsColor: .controlBackgroundColor)) } // MARK: - Header diff --git a/apple/TableProTests/Core/AI/AIConnectionAccessGateTests.swift b/apple/TableProTests/Core/AI/AIConnectionAccessGateTests.swift index a7dfbf7940..0aa3ea6180 100644 --- a/apple/TableProTests/Core/AI/AIConnectionAccessGateTests.swift +++ b/apple/TableProTests/Core/AI/AIConnectionAccessGateTests.swift @@ -346,7 +346,6 @@ internal struct AIConnectionAccessGateSavedPolicyTests { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: live.syncTracker, - themeEngine: live.themeEngine, welcomeRouter: live.welcomeRouter ) } diff --git a/apple/TableProTests/Core/Database/PreConnectScriptConsentTests.swift b/apple/TableProTests/Core/Database/PreConnectScriptConsentTests.swift index 2d6b771d76..1084e84fda 100644 --- a/apple/TableProTests/Core/Database/PreConnectScriptConsentTests.swift +++ b/apple/TableProTests/Core/Database/PreConnectScriptConsentTests.swift @@ -398,7 +398,6 @@ struct PreConnectScriptEntryPointTests { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: live.syncTracker, - themeEngine: live.themeEngine, welcomeRouter: live.welcomeRouter ) } diff --git a/apple/TableProTests/Core/Storage/ConnectionStorageImportTests.swift b/apple/TableProTests/Core/Storage/ConnectionStorageImportTests.swift index 4b6a48675c..1f457a8cbc 100644 --- a/apple/TableProTests/Core/Storage/ConnectionStorageImportTests.swift +++ b/apple/TableProTests/Core/Storage/ConnectionStorageImportTests.swift @@ -82,7 +82,6 @@ struct ConnectionStorageImportTests { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: SyncChangeTracker(metadataStorage: metadata), - themeEngine: live.themeEngine, welcomeRouter: live.welcomeRouter ) } diff --git a/apple/TableProTests/Core/Sync/SyncCoordinatorSQLFavoritePullTests.swift b/apple/TableProTests/Core/Sync/SyncCoordinatorSQLFavoritePullTests.swift index 65cf65c9f5..2fb63fdc47 100644 --- a/apple/TableProTests/Core/Sync/SyncCoordinatorSQLFavoritePullTests.swift +++ b/apple/TableProTests/Core/Sync/SyncCoordinatorSQLFavoritePullTests.swift @@ -179,7 +179,6 @@ struct SyncCoordinatorSQLFavoritePullTests { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: tracker, - themeEngine: live.themeEngine, welcomeRouter: live.welcomeRouter ) return SyncCoordinator(services: services, recordCache: recordCache) diff --git a/apple/TableProTests/Core/Sync/SyncCoordinatorThemeTests.swift b/apple/TableProTests/Core/Sync/SyncCoordinatorThemeTests.swift index 1715d1e02c..dffb6b9fa5 100644 --- a/apple/TableProTests/Core/Sync/SyncCoordinatorThemeTests.swift +++ b/apple/TableProTests/Core/Sync/SyncCoordinatorThemeTests.swift @@ -62,7 +62,7 @@ struct SyncCoordinatorThemeTests { let base = environment.makeCoordinator(transport: transport) let columnLayouts = environment.columnLayouts coordinator = SyncCoordinator( - services: base.services.replacing(appSettings: settings, storage: storage, themeEngine: engine), + services: base.services.replacing(appSettings: settings, storage: storage), recordCache: environment.recordCache, transport: transport, makeNetworkMonitor: { nil }, @@ -414,7 +414,7 @@ private final class ThemeSyncChangeTokenArchive: NSObject, NSSecureCoding { @MainActor private extension AppServices { - func replacing(appSettings: AppSettingsManager, storage: AppSettingsStorage, themeEngine: ThemeEngine) -> AppServices { + func replacing(appSettings: AppSettingsManager, storage: AppSettingsStorage) -> AppServices { AppServices( appEvents: appEvents, appSettings: appSettings, @@ -446,7 +446,6 @@ private extension AppServices { copilotService: copilotService, mcpServerManager: mcpServerManager, syncTracker: syncTracker, - themeEngine: themeEngine, welcomeRouter: welcomeRouter ) } diff --git a/apple/TableProTests/Helpers/ERDiagramLoadFixture.swift b/apple/TableProTests/Helpers/ERDiagramLoadFixture.swift index 87630723e5..34d8583fa6 100644 --- a/apple/TableProTests/Helpers/ERDiagramLoadFixture.swift +++ b/apple/TableProTests/Helpers/ERDiagramLoadFixture.swift @@ -230,7 +230,6 @@ internal struct ERDiagramLoadFixture { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: live.syncTracker, - themeEngine: live.themeEngine, welcomeRouter: live.welcomeRouter ) } diff --git a/apple/TableProTests/Helpers/SyncTestEnvironment.swift b/apple/TableProTests/Helpers/SyncTestEnvironment.swift index fd85e37c20..84bb33d152 100644 --- a/apple/TableProTests/Helpers/SyncTestEnvironment.swift +++ b/apple/TableProTests/Helpers/SyncTestEnvironment.swift @@ -128,7 +128,6 @@ final class SyncTestEnvironment { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: tracker, - themeEngine: live.themeEngine, welcomeRouter: live.welcomeRouter ) return SyncCoordinator( diff --git a/apple/TableProTests/Services/SchemaRefreshCommitCostTests.swift b/apple/TableProTests/Services/SchemaRefreshCommitCostTests.swift index 2656bf7d11..3acf5d90e3 100644 --- a/apple/TableProTests/Services/SchemaRefreshCommitCostTests.swift +++ b/apple/TableProTests/Services/SchemaRefreshCommitCostTests.swift @@ -409,7 +409,6 @@ struct SchemaGroupedRefreshCostTests { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: live.syncTracker, - themeEngine: live.themeEngine, welcomeRouter: live.welcomeRouter ) } diff --git a/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 6e1c0096e5..2159aef692 100644 --- a/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -68,7 +68,6 @@ struct QuickSwitcherViewModelTests { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: live.syncTracker, - themeEngine: live.themeEngine, welcomeRouter: live.welcomeRouter ) } diff --git a/apple/TableProTests/ViewModels/WelcomeViewModelTests.swift b/apple/TableProTests/ViewModels/WelcomeViewModelTests.swift index c6ee35392e..8fd177567d 100644 --- a/apple/TableProTests/ViewModels/WelcomeViewModelTests.swift +++ b/apple/TableProTests/ViewModels/WelcomeViewModelTests.swift @@ -106,7 +106,6 @@ final class WelcomeViewModelTests { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: live.syncTracker, - themeEngine: live.themeEngine, welcomeRouter: welcomeRouter ) } diff --git a/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift b/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift index 49e32a55a4..9f95a0cef3 100644 --- a/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift +++ b/apple/TableProTests/Views/ERDiagram/ERDiagramThemeTests.swift @@ -229,31 +229,6 @@ struct ERDiagramThemeTests { } } - @Test("A theme's window background fills the canvas, and only while the theme sets it") - func canvasBackdropFollowsTheTheme() throws { - try Fixture.preservingEngine { - try Fixture.adopt([:], appearance: .light) - let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) - scrollView.appearance = try appearance(.aqua) - scrollView.drawsBackground = false - let original = scrollView.backgroundColor - let backdrop = DiagramCanvasBackdrop(scrollView: scrollView) - - #expect(!scrollView.drawsBackground) - #expect(!backdrop.isThemed) - - try Fixture.adopt(Self.themed, appearance: .light) - #expect(scrollView.drawsBackground) - #expect(Fixture.hex(Fixture.baked(scrollView.backgroundColor, in: try appearance(.aqua))) == "#1E2030") - #expect(backdrop.isThemed) - - try Fixture.adopt([:], appearance: .light) - #expect(!scrollView.drawsBackground) - #expect(scrollView.backgroundColor == original) - withExtendedLifetime(backdrop) {} - } - } - // MARK: - Export @Test("An export takes the canvas's appearance rather than one built from the color scheme") diff --git a/apple/TableProTests/Views/Editor/FoldCommandBehaviourTests.swift b/apple/TableProTests/Views/Editor/FoldCommandBehaviourTests.swift index b467fc25c5..4814165157 100644 --- a/apple/TableProTests/Views/Editor/FoldCommandBehaviourTests.swift +++ b/apple/TableProTests/Views/Editor/FoldCommandBehaviourTests.swift @@ -25,7 +25,7 @@ struct FoldCommandBehaviourTests { ) -> TextViewController { let configuration = SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: .monospacedSystemFont(ofSize: 12, weight: .regular), wrapLines: false ), diff --git a/apple/TableProTests/Views/Editor/FoldGutterLayoutTests.swift b/apple/TableProTests/Views/Editor/FoldGutterLayoutTests.swift index 24d018510c..c2eae63793 100644 --- a/apple/TableProTests/Views/Editor/FoldGutterLayoutTests.swift +++ b/apple/TableProTests/Views/Editor/FoldGutterLayoutTests.swift @@ -30,7 +30,7 @@ struct FoldGutterLayoutTests { private func gutterInset(peripherals: SourceEditorConfiguration.Peripherals, text: String) -> CGFloat { let configuration = SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: .monospacedSystemFont(ofSize: 12, weight: .regular), wrapLines: false ), diff --git a/apple/TableProTests/Views/Editor/FoldPreviewHitTestTests.swift b/apple/TableProTests/Views/Editor/FoldPreviewHitTestTests.swift index 7d0fa9d8cd..a12cb2958c 100644 --- a/apple/TableProTests/Views/Editor/FoldPreviewHitTestTests.swift +++ b/apple/TableProTests/Views/Editor/FoldPreviewHitTestTests.swift @@ -22,7 +22,7 @@ struct FoldPreviewHitTestTests { private func makeController() -> TextViewController { let configuration = SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: .monospacedSystemFont(ofSize: 12, weight: .regular), wrapLines: false ), diff --git a/apple/TableProTests/Views/Editor/SQLFoldPerformanceGuardTests.swift b/apple/TableProTests/Views/Editor/SQLFoldPerformanceGuardTests.swift index 148da185c3..dfbcf5cf42 100644 --- a/apple/TableProTests/Views/Editor/SQLFoldPerformanceGuardTests.swift +++ b/apple/TableProTests/Views/Editor/SQLFoldPerformanceGuardTests.swift @@ -23,7 +23,7 @@ struct SQLFoldPerformanceGuardTests { private func makeController(text: String, foldingSizeLimit: Int) -> TextViewController { let configuration = SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: .monospacedSystemFont(ofSize: 12, weight: .regular), wrapLines: false ), diff --git a/apple/TableProTests/Views/Editor/StatementNavigationCommandTests.swift b/apple/TableProTests/Views/Editor/StatementNavigationCommandTests.swift index 9a690b7f6e..637131d5d4 100644 --- a/apple/TableProTests/Views/Editor/StatementNavigationCommandTests.swift +++ b/apple/TableProTests/Views/Editor/StatementNavigationCommandTests.swift @@ -22,7 +22,7 @@ struct StatementNavigationCommandTests { private func makeController(text: String) -> TextViewController { let configuration = SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: .monospacedSystemFont(ofSize: 12, weight: .regular), wrapLines: false ), diff --git a/apple/TableProTests/Views/Editor/StatementRunControllerTests.swift b/apple/TableProTests/Views/Editor/StatementRunControllerTests.swift index 8a07ff59de..249dff0232 100644 --- a/apple/TableProTests/Views/Editor/StatementRunControllerTests.swift +++ b/apple/TableProTests/Views/Editor/StatementRunControllerTests.swift @@ -22,7 +22,7 @@ struct StatementRunControllerTests { private func makeController(text: String) -> TextViewController { let configuration = SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: .monospacedSystemFont(ofSize: 12, weight: .regular), wrapLines: false ), diff --git a/apple/TableProTests/Views/Main/DetailPaneBackdropTests.swift b/apple/TableProTests/Views/Main/DetailPaneBackdropTests.swift index 6b8514eff3..c82560aaa6 100644 --- a/apple/TableProTests/Views/Main/DetailPaneBackdropTests.swift +++ b/apple/TableProTests/Views/Main/DetailPaneBackdropTests.swift @@ -29,10 +29,15 @@ struct DetailPaneBackdropTests { try ThemeTestFixture.filled(.background, colorScheme: .dark) } + private static func containsMaterial(_ view: NSView) -> Bool { + view.subviews.contains { $0 is NSVisualEffectView || containsMaterial($0) } + } + @Test("The toolbar takes the theme on macOS 26 and later only") func gateFollowsTheBackdropRelease() { let backdropRelease = OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0) - #expect(DetailPaneBackdrop.extendsUnderToolbar == ProcessInfo.processInfo.isOperatingSystemAtLeast(backdropRelease)) + #expect(ThemeWindowBackdrop.canReachChrome == ProcessInfo.processInfo.isOperatingSystemAtLeast(backdropRelease)) + #expect(DetailPaneBackdrop.extendsUnderToolbar == ThemeWindowBackdrop.canReachChrome) } @Test("The native backdrop always reaches every edge") @@ -66,6 +71,32 @@ struct DetailPaneBackdropTests { } } + @Test("A status bar on the detail backdrop draws its material exactly while the backdrop is unthemed") + func statusBarMaterialFollowsTheBackdrop() throws { + let window = try ThemeTestFixture.pair([.windowBackdrop: ThemeTestFixture.windowHex], on: .dark) + let content = try ThemeTestFixture.pair([ + .editorBackground: ThemeTestFixture.editorHex, + .gridBackground: ThemeTestFixture.gridHex, + ], on: .dark) + ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let host = ThemeTestFixture.hostingView( + VStack(spacing: 0) { + Color.clear + Text(verbatim: "1 row").statusBarChrome() + } + .modifier(DetailPaneBackdropFill()), + size: NSSize(width: 160, height: 80) + ) + let cases = [("default", ThemePair.builtIn, false), ("window", window, true), ("content", content, false)] + for (name, theme, isThemed) in cases { + ThemeTestFixture.adopt(theme, appearance: .dark) + host.layoutSubtreeIfNeeded() + #expect(DetailPaneBackdrop.isThemed(contrast: .standard) == isThemed, "\(name) theme") + #expect(Self.containsMaterial(host) == !isThemed, "\(name) theme") + } + } + } + @Test("The default pair fills under the toolbar with the system background on both sides of the gate") func defaultFillsUnderTheToolbar() throws { try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { diff --git a/apple/TableProTests/Views/Results/StatusBarChromeTests.swift b/apple/TableProTests/Views/Results/StatusBarChromeTests.swift index 8c17e391a7..12d1d03f08 100644 --- a/apple/TableProTests/Views/Results/StatusBarChromeTests.swift +++ b/apple/TableProTests/Views/Results/StatusBarChromeTests.swift @@ -29,32 +29,29 @@ struct StatusBarChromeTests { Text(verbatim: "1 row").statusBarChrome() } - @Test("A bar draws its material by default") + @Test("A bar draws its material under the default pair") func materialByDefault() { - #expect(Self.drawsMaterial(Self.host(Self.bar))) - } - - @Test("A bar whose host paints the backdrop draws no material") - func noMaterialOverAPaintedBackdrop() { - #expect(!Self.drawsMaterial(Self.host(Self.bar.statusBarDrawsMaterial(false)))) + ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + #expect(Self.drawsMaterial(Self.host(Self.bar))) + } } - @Test("A bar outside the detail pane keeps its material under a window theme") - func otherBarsKeepTheirMaterial() throws { + @Test("A bar over a themed backdrop draws no material") + func noMaterialOverAThemedBackdrop() throws { try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { - #expect(Self.drawsMaterial(Self.host(Self.bar))) + #expect(!Self.drawsMaterial(Self.host(Self.bar))) } } - @Test("The result bar drops its material while the window slot themes the detail backdrop, and gets it back after") - func resultBarFollowsTheDetailBackdrop() throws { + @Test("A bar drops its material while the window slot themes the backdrop, and gets it back after") + func barFollowsTheBackdrop() throws { let window = try ThemeTestFixture.pair([.windowBackdrop: ThemeTestFixture.windowHex], on: .dark) let content = try ThemeTestFixture.pair([ .editorBackground: ThemeTestFixture.editorHex, .gridBackground: ThemeTestFixture.gridHex, ], on: .dark) - try ThemeTestFixture.adopting(.builtIn, appearance: .dark) { - let host = Self.host(Self.bar.modifier(DetailPaneStatusBarMaterial())) + ThemeTestFixture.adopting(.builtIn, appearance: .dark) { + let host = Self.host(Self.bar) #expect(Self.drawsMaterial(host), "default pair") ThemeTestFixture.adopt(window, appearance: .dark) @@ -68,10 +65,10 @@ struct StatusBarChromeTests { } } - @Test("The result bar keeps its material under Increase Contrast, where the backdrop stays native") + @Test("A bar keeps its material under Increase Contrast, where the backdrop stays native") func increaseContrastKeepsTheMaterial() throws { try ThemeTestFixture.adopting([.windowBackdrop: ThemeTestFixture.windowHex], appearance: .dark) { - #expect(!DetailPaneBackdrop.isThemed(contrast: .increased)) + #expect(!ThemeBackdropState.isThemed(highContrast: true)) } } } diff --git a/apple/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift b/apple/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift index 4a2644df92..84a39723b5 100644 --- a/apple/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift +++ b/apple/TableProTests/Views/Results/TreeOutline/TreeValueToneThemeTests.swift @@ -29,7 +29,7 @@ struct TreeValueToneThemeTests { language: .json, configuration: SourceEditorConfiguration( appearance: .init( - theme: TableProEditorTheme.make(), + theme: ThemeEngine.shared.makeEditorTheme(), font: .monospacedSystemFont(ofSize: 12, weight: .regular), wrapLines: true ) @@ -78,7 +78,7 @@ struct TreeValueToneThemeTests { try ThemeTestFixture.adopting(themed ? TreeOutlineFixture.tones : [:], appearance: .light) { #expect(ThemeTestFixture.hex(TreeValueTone.serialized.color) == ThemeTestFixture.hex(try textViewColor("type"))) #expect(ThemeTestFixture.hex(TreeValueTone.reference.color) == ThemeTestFixture.hex(try textViewColor("comment"))) - let lineNumber = try #require(TableProEditorTheme.make().lineNumber) + let lineNumber = try #require(ThemeEngine.shared.makeEditorTheme().lineNumber) #expect(ThemeTestFixture.hex(TreeValueTone.muted.color) == ThemeTestFixture.hex(lineNumber)) } } diff --git a/apple/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift b/apple/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift index c9ed846e0b..b60f847094 100644 --- a/apple/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift +++ b/apple/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift @@ -7,8 +7,10 @@ // import AppKit +import Combine import SwiftUI @testable import TablePro +import TableProTestSupport import TableProTheme import Testing @@ -71,6 +73,239 @@ struct InspectorValueThemeTests { } } + // MARK: - Editable values + + private func editableField(_ value: Binding, kind: FieldEditorKind) -> FieldEditorContent { + let context = FieldEditorContext( + columnName: "note", + columnType: .text(rawType: "TEXT"), + isLongText: false, + value: value, + originalValue: value.wrappedValue, + valueState: .value(value.wrappedValue), + isReadOnly: false + ) + return FieldEditorContent(context: context, kind: kind, databaseType: .sqlite) + } + + private func editableField(_ value: String, kind: FieldEditorKind = .singleLine) -> FieldEditorContent { + editableField(.constant(value), kind: kind) + } + + /// `ImageRenderer` draws a placeholder for an AppKit view, so these render the hosting window. + private func hostedWellPixel(_ view: some View, size: CGSize = wellSize) throws -> NSColor { + let window = ThemeTestFixture.host(view, size: NSSize(width: size.width, height: size.height)) + let rep = try #require(ThemeTestFixture.snapshot(window)) + let scale = CGFloat(rep.pixelsWide) / size.width + let x = rep.pixelsWide - Int((12 * scale).rounded()) + return try #require(ThemeTestFixture.pixel(rep, x: x, y: rep.pixelsHigh / 2)) + } + + private static func valueFields(in view: NSView?) -> [ValueTextFieldView] { + guard let view else { return [] } + if let field = view as? ValueTextFieldView { return [field] } + return view.subviews.flatMap { valueFields(in: $0) } + } + + private func hostedValueField(_ view: some View) throws -> (NSWindow, ValueTextFieldView) { + let window = ThemeTestFixture.host(view, size: NSSize(width: Self.wellSize.width, height: Self.wellSize.height)) + return (window, try #require(Self.valueFields(in: window.contentView).first)) + } + + @Test("Under the default pair an editable value sits in the same native well as a read-only one") + func editableValueDefaultSitsInTheNativeWell() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let native = try ThemeTestFixture.filled(.textBackgroundColor, colorScheme: .light) + let drawn = try hostedWellPixel(editableField("a")) + #expect(ThemeTestFixture.matches(drawn, native), "\(drawn) against \(native)") + } + } + + @Test("Under a theme an editable value sits on the editor background, not a system bezel") + func editableValueTakesTheEditorBackground() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground], appearance: .light) { + let themed = try ThemeTestFixture.filled(hex: Self.editorBackground, colorScheme: .light) + let drawn = try hostedWellPixel(editableField("a")) + #expect(ThemeTestFixture.matches(drawn, themed), "\(drawn) against \(themed)") + } + } + + @Test("An editable binary value sits on the editor background under a theme") + func editableHexTakesTheEditorBackground() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground], appearance: .light) { + let themed = try ThemeTestFixture.filled(hex: Self.editorBackground, colorScheme: .light) + let drawn = try hostedWellPixel(editableField("Hello", kind: .blobHex), size: Self.dumpSize) + #expect(ThemeTestFixture.matches(drawn, themed), "\(drawn) against \(themed)") + } + } + + @Test("An editable value draws the system text color under the default pair and the editor text under a theme") + func editableValueTextFollowsTheTheme() throws { + try ThemeTestFixture.adopting([:], appearance: .light) { + let (window, valueField) = try hostedValueField(editableField("a")) + #expect(ThemeTestFixture.hex(valueField.textColor) == ThemeTestFixture.hex(NSColor.textColor)) + withExtendedLifetime(window) {} + } + try ThemeTestFixture.adopting([.editorText: "#657B83"], appearance: .light) { + let (window, valueField) = try hostedValueField(editableField("a")) + #expect(ThemeTestFixture.hex(valueField.textColor) == "#657B83") + withExtendedLifetime(window) {} + } + } + + @Test("An editable value edits in a field editor of its own, on the theme's caret and selection, with no substitutions") + func editableValueHasItsOwnThemedFieldEditor() throws { + try ThemeTestFixture.adopting([.editorCursor: "#D33682", .editorSelection: "#EEE8D5"], appearance: .light) { + let (window, valueField) = try hostedValueField(editableField("a")) + #expect(window.makeFirstResponder(valueField)) + let editor = try #require(valueField.currentEditor() as? NSTextView) + + #expect(editor === valueField.ownFieldEditor) + #expect(ThemeTestFixture.hex(editor.insertionPointColor) == "#D33682") + #expect(ThemeTestFixture.hex(editor.selectedTextAttributes[.backgroundColor] as? NSColor) == "#EEE8D5") + #expect(!editor.isAutomaticTextReplacementEnabled) + #expect(!editor.isAutomaticQuoteSubstitutionEnabled) + } + } + + @Test("The theme's caret stays in the value field: a stock field in the same window keeps the system editor") + func editableValueCaretDoesNotLeak() throws { + try ThemeTestFixture.adopting([.editorCursor: "#D33682"], appearance: .light) { + let (window, valueField) = try hostedValueField(editableField("a")) + let stock = NSTextField(string: "stock") + window.contentView?.addSubview(stock) + + #expect(window.makeFirstResponder(valueField)) + #expect(window.makeFirstResponder(stock)) + let stockEditor = try #require(stock.currentEditor() as? NSTextView) + #expect(stockEditor !== valueField.ownFieldEditor) + #expect(ThemeTestFixture.hex(stockEditor.insertionPointColor) != "#D33682") + } + } + + @Test("Typing in an editable value reaches its binding on every keystroke") + func editableValueWritesThrough() throws { + final class Box { var value = "a" } + let box = Box() + let binding = Binding(get: { box.value }, set: { box.value = $0 }) + let (window, valueField) = try hostedValueField(editableField(binding, kind: .singleLine)) + #expect(window.makeFirstResponder(valueField)) + let editor = try #require(valueField.currentEditor() as? NSTextView) + editor.setSelectedRange(NSRange(location: 1, length: 0)) + editor.insertText("b", replacementRange: NSRange(location: NSNotFound, length: 0)) + #expect(box.value == "ab") + } + + @Test("Typing undo lives in the editing session, so the window's undo stack holds nothing once the field lets go") + func editableValueUndoStaysInTheSession() throws { + let (window, valueField) = try hostedValueField(editableField("abc")) + #expect(window.makeFirstResponder(valueField)) + let editor = try #require(valueField.currentEditor() as? NSTextView) + editor.setSelectedRange(NSRange(location: 3, length: 0)) + editor.insertText("def", replacementRange: NSRange(location: NSNotFound, length: 0)) + + #expect(editor.undoManager !== window.undoManager) + #expect(editor.undoManager?.canUndo == true) + #expect(window.makeFirstResponder(nil)) + #expect(window.undoManager?.canUndo != true) + #expect(editor.undoManager?.canUndo == false) + } + + @Test("A value replaced from outside while the field is edited drops the typing undo recorded against the old text") + func externalReplacementDropsTypingUndo() async throws { + final class Model: ObservableObject { @Published var value = "abc" } + struct Host: View { + @ObservedObject var model: Model + var body: some View { + ValueTextField(text: $model.value, font: .systemFont(ofSize: 13)).valueWell() + } + } + let model = Model() + let (window, valueField) = try hostedValueField(Host(model: model)) + #expect(window.makeFirstResponder(valueField)) + let editor = try #require(valueField.currentEditor() as? NSTextView) + editor.setSelectedRange(NSRange(location: 3, length: 0)) + editor.insertText("def", replacementRange: NSRange(location: NSNotFound, length: 0)) + #expect(editor.undoManager?.canUndo == true) + + model.value = "" + try await waitUntil { editor.string.isEmpty } + #expect(editor.undoManager?.canUndo == false) + withExtendedLifetime(window) {} + } + + @Test("An editable value fills its well exactly, so the focus ring and the text inset match the read-only value") + func editableValueFillsItsWell() throws { + let (window, valueField) = try hostedValueField(editableField("a")) + #expect(abs(valueField.frame.width - Self.wellSize.width) < 0.5, "\(valueField.frame)") + #expect(valueField.frame.minX >= -0.5, "\(valueField.frame)") + withExtendedLifetime(window) {} + } + + @Test("A disabled editable value takes no edits") + func disabledEditableValueIsNotEnabled() throws { + let (window, valueField) = try hostedValueField(editableField("a").disabled(true)) + #expect(!valueField.isEnabled) + withExtendedLifetime(window) {} + } + + @Test("An editable binary value inside the image field's own well draws no second one") + func editableHexInsideAWellDrawsNone() throws { + try ThemeTestFixture.adopting([.editorBackground: Self.editorBackground], appearance: .light) { + let context = FieldEditorContext( + columnName: "note", + columnType: .text(rawType: "TEXT"), + isLongText: false, + value: .constant("Hello"), + originalValue: "Hello", + valueState: .value("Hello"), + isReadOnly: false + ) + let drawn = try hostedWellPixel(BlobHexEditorView(context: context, drawsWell: false), size: Self.dumpSize) + #expect(drawn.alphaComponent < 0.02, "drew \(drawn)") + } + } + + /// SwiftUI reads a first responder change on a later pass of the run loop. + private func nextRunLoopTurn() async throws { + final class Flag { var isSet = false } + let flag = Flag() + DispatchQueue.main.async { flag.isSet = true } + try await waitUntil { flag.isSet } + } + + @Test("An edited binary value commits once the field lets go of focus") + func editableHexCommitsOnBlur() async throws { + final class Box { var committed: Data? } + let box = Box() + let context = FieldEditorContext( + columnName: "payload", + columnType: .text(rawType: "TEXT"), + isLongText: false, + value: .constant("Hi"), + originalValue: "Hi", + valueState: .value("Hi"), + isReadOnly: false, + commitBytes: { box.committed = $0 } + ) + let window = ThemeTestFixture.host( + BlobHexEditorView(context: context), + size: NSSize(width: Self.dumpSize.width, height: Self.dumpSize.height) + ) + let valueField = try #require(Self.valueFields(in: window.contentView).first) + + #expect(window.makeFirstResponder(valueField)) + try await nextRunLoopTurn() + let editor = try #require(valueField.currentEditor() as? NSTextView) + editor.selectAll(nil) + editor.insertText("41 42", replacementRange: NSRange(location: NSNotFound, length: 0)) + try await nextRunLoopTurn() + + #expect(box.committed == nil) + #expect(window.makeFirstResponder(nil)) + try await waitUntil { box.committed == Data([0x41, 0x42]) } + } + // MARK: - Multi-line private func hostedTextView(_ view: some View) throws -> (NSWindow, NSTextView) { @@ -146,11 +381,12 @@ struct InspectorValueThemeTests { BlobHexEditorView(context: context("Hello, world"), drawsWell: drawsWell) } - @Test("Under the default pair a read-only binary dump sits straight on the inspector, with no well") - func dumpDefaultHasNoWell() throws { + @Test("Under the default pair a read-only binary dump sits in the native value well, like the values beside it") + func dumpDefaultSitsInTheNativeWell() throws { try ThemeTestFixture.adopting([:], appearance: .light) { + let native = try ThemeTestFixture.filled(.textBackgroundColor, colorScheme: .light) let margin = try dumpMarginPixel(dump()) - #expect(margin.alphaComponent < 0.02, "drew \(margin)") + #expect(ThemeTestFixture.matches(margin, native), "\(margin) against \(native)") } } diff --git a/apple/changelog.d/3582.changed.md b/apple/changelog.d/3582.changed.md new file mode 100644 index 0000000000..8f5e79a008 --- /dev/null +++ b/apple/changelog.d/3582.changed.md @@ -0,0 +1 @@ +Editable inspector values and the binary dump sit in the same value well as read-only values in every theme. diff --git a/apple/changelog.d/3582.fixed.md b/apple/changelog.d/3582.fixed.md new file mode 100644 index 0000000000..4a3560a550 --- /dev/null +++ b/apple/changelog.d/3582.fixed.md @@ -0,0 +1 @@ +Editable values in the row inspector and array editor ignored the theme and drew a system box.