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
1 change: 1 addition & 0 deletions .claude/rules/data-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ paths:
- **Cells are drawn, not mounted.** `tableView(_:viewFor:row:)` returns nil for data columns and `DataGridRowView` draws the visible cells, and every column stays attached. Never hide off-screen columns with `NSTableColumn.isHidden`: each write costs O(attached columns).
- **Find data columns through `DataGridColumnPool.presentsColumn` and `firstPresentedColumnIndex`**, never a fixed index into `tableColumns` (the row-number column and pool slots sit there too).
- **The header draws all of its own chrome** through `SortableHeaderChrome`: the cell never calls `super.draw`, and `highlightedTableColumn` stays unset, because AppKit paints a fixed-height band that does not fit the taller header. Sort state goes through `SortableHeaderView.applySortState(_:schema:)`, which also sets each header cell's accessibility sort direction.
- **Row selection is `NSTableRowView`'s own** unless the theme overrides `gridSelection` or `gridInactiveSelection`, so the default theme follows Accent and focus. The gutter and a selected column heading read `DataGridBodyChrome.selectionColor(emphasized:)` so they match the row. A theme change repaints visible rows; it never reloads data.
- **`gridStyleMask` stays empty; `DataGridBodyChrome` draws column separators and the stripes.** AppKit's vertical grid lines add one subview per column and make every layout pass quadratic. Take separator positions from `presentsColumn` and `rect(ofColumn:)`.
- **Scroll the grid with `NSView.scroll(_:)`**, never `clipView.scroll(to:)` plus `reflectScrolledClipView`, which leaves the header behind.
- **Accessibility goes through `DataGridCellAccessibilityView`**, mounted for on-screen rows once `DataGridAccessibility.isActive`; `NSTableView` builds its cell tree from views only. Keep that path working whenever row drawing changes.
Expand Down
8 changes: 4 additions & 4 deletions .claude/rules/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ paths:
# Editor and theme

- **`ThemeEngine` is the single source of theme colors**; `ThemeEngine.makeEditorTheme()` builds the `TableProEditorTheme` adapter.
- **A slot exists only with a reader.** `ThemeSlot` is the one registry the gate, the theme editor and the decoder enumerate, and `ThemeSlotCoverageTests` fails a slot nothing reads. A theme file must declare every slot of its schema, as `#hex` or `system:<name>`.
- **Two color tiers.** `palette[slot]` is a dynamic color that answers for the theme in effect; use it wherever a view holds a color. `resolved[slot]` is a snapshot for code that bakes one (`CALayer`, the editor theme, the grid's cell drawing), paired with an `AppEvents.shared.themeChanged` subscription.
- **A theme change has to reach the view.** A SwiftUI view that reads the engine observes it (`@ObservedObject private var themeEngine = ThemeEngine.shared`), because SwiftUI caches `Color(nsColor:)` by object identity; an AppKit view subscribes to `themeChanged` and redisplays.
- **A theme colors content, never chrome.** `NSApp.appearance` is the only lever on the sidebar, the trailing pane, the editor tab strip and the window background: a theme declares `light` or `dark` and the frame follows. Painting them means covering views AppKit owns (the sidebar's `NSGlassEffectView`, the titlebar band, the source-list selection), so every slot is namespaced `content.` and none is added for chrome.
- **A theme colors two surfaces: code views and the data grid.** Code views are the SQL editor and every other `SourceEditor` or SQL text view (JSON editor, DDL, trigger, previews, AI code blocks, history). Every other pane, Settings and all chrome use semantic system colors directly and never read the theme. `NSApp.appearance` is the only lever on chrome.
- **A slot exists only with a reader.** `ThemeSlot` is the one registry the decoder, encoder and theme editor enumerate, and `ThemeSlotCoverageTests` fails a slot nothing reads. Every slot is optional: a theme that leaves one out draws `ThemeSlot.nativeColor`, the color AppKit itself uses there. Default Light and Default Dark override syntax only, with Xcode's Default values.
- **Two color tiers.** `palette[slot]` is a dynamic color over the live theme pair; use it wherever a view holds a color. `resolved[slot]` is the active theme's override or the system color object itself, never a flattened copy; turn it into a `CGColor` only inside the drawing view's appearance (`resolved.cgColor(_:in:)`), and check `resolved.isOverridden(_:)` before replacing a native drawing path (row selection, caret, header). Never call `withAlphaComponent` on a system color outside an `NSColor(name:dynamicProvider:)`: it freezes the color.
- **A theme change has to reach the view.** `AppEvents.shared.themeChanged` fires on a theme change, a light/dark flip, an Accent or Highlight change and an Increase Contrast toggle. A SwiftUI view that reads the engine observes it (`@ObservedObject private var themeEngine = ThemeEngine.shared`), because SwiftUI caches `Color(nsColor:)` by object identity; an AppKit view that bakes a color subscribes to `themeChanged` and re-bakes.
- **Fonts are not theme.** `TypographySettings` is device-local and stays out of `AppSettingsCategory.synced`: sync replaces a category wholesale, so an older Mac would reset it and each zoom press would push a record.
- **Two font domains.** A view that also wears the editor's colors (the SQL editor, `JSONCodeEditor`, previews) uses `editorFonts`. Every control that shows or edits a stored value uses `ThemeEngine.valueFont` or `valueFontSwiftUI` (the Data Grid Font), so a value reads the same in the grid, the inspector and a popover; a system text style there looks right only while the two settings are equal. `InspectorFieldRow` sets it for the whole inspector, and its `FieldEditorKind` switch is exhaustive on purpose.
- **Multi-cursor lives in `cursorPositions: [CursorPosition]`.**
Expand Down
12 changes: 7 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Header, grid line, selection and focus colors in the theme editor.
- Panel and status colors in the theme editor, covering the results, inspector, structure, compare and query plan panes.
- Themes that name a system color for a slot, so the built-in themes keep the system's own contrast settings.
- Light theme and Dark theme pickers, and New Theme Based On, Rename and Show in Finder for custom themes in Settings > Appearance.
- Reason shown in Settings > Appearance for a theme file that could not be loaded.
- Setting in Settings > General to hide the version and Check for Updates on the Welcome window.
- Install Parquet… in the Export dialog's format menu when the Parquet plugin is not installed.
- Cloud Firestore and Firebase Realtime Database as a registry plugin over their REST APIs. (#2504)

### Changed

- Theme file format 2. Themes written for earlier versions are not read and need to be recreated.
- Default Light and Default Dark draw the editor and data grid in the system colors and use Xcode's default syntax colors.
- Themes color only the SQL editor, other code views and the data grid. Every other pane keeps the system colors.
- Theme file format 3, where every color is optional. Themes written for earlier versions are not read and need to be recreated.
- Settings > Editor grouped into Display, Gutter and Editing.
- Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac.
- Editor font size range of 10 to 24 points everywhere, including zoom.
Expand Down Expand Up @@ -63,9 +64,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Theme editor changing the active theme instead of the theme selected for the slot being edited.
- Malformed theme files loading as Default Light under their own name.
- Color with a typo in it rendering as a different color instead of being reported.
- Content panes outside the editor and the data grid ignoring the theme.
- Editor and data grid colors not following a change to the Accent color, Highlight color or Increase Contrast.
- Selected text in the editor ignoring the Highlight color, and the cursor ignoring the Accent color.
- Zoom In and Zoom Out staying enabled at the largest and smallest font size.
- JSON and PHP tree values colored differently from the same values in the row inspector.
- Autocomplete icon colors ignoring the theme.
- Theme color wells and the theme preview unreadable to VoiceOver.
- Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background.
- GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ final class SuggestionViewModel: ObservableObject {
/// The items to be displayed in the window
@Published var items: [CodeSuggestionEntry] = []
@Published var selectedIndex: Int = 0
@Published var themeBackground: NSColor = .windowBackgroundColor
@Published var themeTextColor: NSColor = .labelColor

var itemsRequestTask: Task<Void, Never>?
weak var activeTextView: TextViewController?
Expand Down Expand Up @@ -76,28 +74,6 @@ final class SuggestionViewModel: ObservableObject {
}
}

func updateTheme(from textView: TextViewController) {
themeTextColor = textView.theme.text.color
switch textView.systemAppearance {
case .aqua:
let color = textView.theme.background
if color != .clear {
themeBackground = NSColor(
red: color.redComponent * 0.95,
green: color.greenComponent * 0.95,
blue: color.blueComponent * 0.95,
alpha: 1.0
)
} else {
themeBackground = .windowBackgroundColor
}
case .darkAqua:
themeBackground = textView.theme.background
default:
break
}
}

func showCompletions(
textView: TextViewController,
delegate: CodeSuggestionDelegate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,21 @@ struct CodeSuggestionLabelView: View {
static let horizontalPadding: CGFloat = 13

let suggestion: CodeSuggestionEntry
let labelColor: NSColor
let secondaryLabelColor: NSColor
let font: NSFont
var isSelected: Bool = false

private var effectiveLabelColor: Color {
if isSelected {
return .white
return Color(nsColor: .alternateSelectedControlTextColor)
}
return suggestion.deprecated ? Color(secondaryLabelColor) : Color(labelColor)
return Color(nsColor: suggestion.deprecated ? NSColor.secondaryLabelColor : NSColor.labelColor)
}

private var effectiveSecondaryColor: Color {
if isSelected {
return Color.white.opacity(0.7)
return Color(nsColor: .alternateSelectedControlTextColor).opacity(0.7)
}
return Color(secondaryLabelColor)
return Color(nsColor: .secondaryLabelColor)
}

// swiftlint:disable shorthand_operator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ struct SuggestionContentView: View {
}
}
.frame(width: contentWidth)
.background(Color(nsColor: model.themeBackground))
.background(SuggestionPanelBackground())
.clipShape(RoundedRectangle(cornerRadius: 8.5))
.contentShape(Rectangle())
.onTapGesture {
Expand Down Expand Up @@ -67,8 +67,6 @@ struct SuggestionContentView: View {
private func suggestionRow(index: Int, item: CodeSuggestionEntry) -> some View {
CodeSuggestionLabelView(
suggestion: item,
labelColor: model.themeTextColor,
secondaryLabelColor: model.themeTextColor.withAlphaComponent(0.5),
font: model.activeTextView?.font ?? .systemFont(ofSize: 12),
isSelected: index == model.selectedIndex
)
Expand Down Expand Up @@ -125,3 +123,26 @@ struct SuggestionContentView: View {
.padding(.vertical, 10)
}
}

/// The panel's window is clear, so a SwiftUI material would have nothing behind it to blur. A visual effect view
/// blending behind the window samples the desktop and the editor the way a menu does.
struct SuggestionPanelBackground: NSViewRepresentable {
static func makeView() -> NSVisualEffectView {
let view = DecorativeVisualEffectView()
view.material = .menu
view.blendingMode = .behindWindow
view.state = .active
return view
}

func makeNSView(context: Context) -> NSVisualEffectView {
Self.makeView()
}

func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}

/// A click on the panel's background belongs to the SwiftUI tap that dismisses the panel, not to this view.
private final class DecorativeVisualEffectView: NSVisualEffectView {
override func hitTest(_ point: NSPoint) -> NSView? { nil }
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,6 @@ public final class SuggestionController: NSWindowController {
cursorPosition: cursorPosition,
isManualTrigger: isManualTrigger
) { parentWindow, cursorRect in
self.model.updateTheme(from: textView)

if asPopover {
self.popover?.close()
self.popover = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import AppKit
import Combine
import TableProTextEngine

extension TextViewController {
Expand Down Expand Up @@ -158,12 +159,39 @@ extension TextViewController {
.store(in: &cancellables)
}

/// A Highlight color or Increase Contrast change reaches no view's `viewDidChangeEffectiveAppearance`, and the
/// editor's system colors are only resolved when drawn, so it redraws what holds them.
func setUpSystemColorsObserver() {
NotificationCenter.default.publisher(for: NSColor.systemColorsDidChangeNotification)
.merge(
with: NSWorkspace.shared.notificationCenter.publisher(
for: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification
)
)
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.redisplayColors()
}
.store(in: &cancellables)
}

func redisplayColors() {
guard isViewLoaded, let textView else { return }
textView.emphasisManager?.refreshColors()
textView.needsDisplay = true
textView.subviews.forEach { $0.needsDisplay = true }
gutterView?.needsDisplay = true
gutterView?.foldingRibbon.needsDisplay = true
gutterView?.statementRunRibbon.needsDisplay = true
}

func setUpOberservers() {
setUpOnScrollChangeObserver()
setUpOnScrollViewFrameChangeObserver()
setUpTextViewFrameChangeObserver()
setUpSelectionChangedObserver()
setUpAppearanceChangedObserver()
setUpSystemColorsObserver()
}

/// Asked before any link of any editor's chain, whichever of its views holds focus, for a key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,16 @@ extension NSColor {
}
}
}

/// `withAlphaComponent` on a system or provider color resolves it once, for the appearance current at the call,
/// and keeps that value. Inside a provider it resolves again for every appearance the color is drawn in.
func withDynamicAlpha(_ alpha: CGFloat) -> NSColor {
NSColor(name: nil) { appearance in
var tinted = self
appearance.performAsCurrentDrawingAppearance {
tinted = self.withAlphaComponent(alpha)
}
return tinted
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,18 @@ class Highlighter: NSObject {

// MARK: - Public

/// Invalidates all text in the editor. Useful for updating themes.
/// Invalidates all text in the editor, so every provider is asked for its captures again.
func invalidate() {
highlightProviders.forEach { $0.invalidate() }
}

/// A theme change moves no capture, so the captures already found are styled again in place. Asking the providers
/// again would leave the text unstyled until they answer.
func restyle() {
guard let textView else { return }
styleContainerDidUpdate(in: NSRange(location: 0, length: textView.textStorage.length))
}

func invalidate(_ set: IndexSet) {
highlightProviders.forEach { $0.invalidate(set) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,10 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate {
)
}

private static let warningColor = NSColor.systemRed.withDynamicAlpha(0.3)

private func warningCharacterStyle(for character: UInt16) -> InvisibleCharacterStyle? {
guard warningCharacters.contains(character) else { return nil }
return .emphasize(color: .systemRed.withAlphaComponent(0.3))
return .emphasize(color: Self.warningColor)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
/// For fold storage and querying, see ``LineFoldStorage``. For fold calculation see ``LineFoldCalculator``
/// and ``LineFoldProvider``. For drawing see ``LineFoldRibbonView``.
@MainActor
class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject {

Check warning on line 21 in Packages/TableProEditor/Sources/TableProEditorKit/LineFolding/Model/LineFoldModel.swift

View workflow job for this annotation

GitHub Actions / Build for testing

conformance of 'LineFoldModel' to protocol 'NSTextStorageDelegate' crosses into main actor-isolated code and can cause data races; this is an error in the Swift 6 language mode
static let emphasisId = "lineFolding"

/// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent``
Expand Down Expand Up @@ -330,13 +330,13 @@

// MARK: - LineFoldPlaceholderDelegate

extension LineFoldModel: LineFoldPlaceholderDelegate {

Check warning on line 333 in Packages/TableProEditor/Sources/TableProEditorKit/LineFolding/Model/LineFoldModel.swift

View workflow job for this annotation

GitHub Actions / Build for testing

conformance of 'LineFoldModel' to protocol 'LineFoldPlaceholderDelegate' crosses into main actor-isolated code and can cause data races; this is an error in the Swift 6 language mode
func placeholderBackgroundColor() -> NSColor {
controller?.configuration.appearance.theme.invisibles.color ?? .lightGray
controller?.configuration.appearance.theme.invisibles.color ?? .tertiaryLabelColor
}

func placeholderTextColor() -> NSColor {
controller?.configuration.appearance.theme.text.color.withAlphaComponent(0.35) ?? .tertiaryLabelColor
controller?.configuration.appearance.theme.text.color.withDynamicAlpha(0.35) ?? .tertiaryLabelColor
}

func placeholderSelectedColor() -> NSColor {
Expand Down
Loading
Loading