diff --git a/.claude/rules/data-grid.md b/.claude/rules/data-grid.md index 3a88677def..0686c9cf17 100644 --- a/.claude/rules/data-grid.md +++ b/.claude/rules/data-grid.md @@ -1,21 +1,68 @@ --- paths: - - "apple/TablePro/Views/Results/**/*" + - "apple/TablePro/Views/DataGrid/**/*" - "apple/TablePro/Core/DataGrid/**/*" + - "apple/TablePro/Views/Results/**/*" + - "apple/TableProTests/Views/DataGrid/**/*" + - "apple/TableProTests/Core/DataGrid/**/*" - "apple/TableProTests/Views/Results/**/*" --- # Data grid -- **Selection indices are display positions**, not `TableRows.rows` indices. Resolve them through `DisplayRowMapping` (or `TableViewCoordinator.displayRow(at:)` and `tableRowsIndex(forDisplayRow:)`) before reading or changing a row; they match only while no sort or value filter applies. -- **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. -- **A themed header draws its own cells.** `NSTableHeaderView.draw` paints AppKit's band over the header fill before the cells, so while `dataGrid.headerBackground` is themed `SortableHeaderView` draws each column's header cell itself, the dragged one included; an unthemed header keeps `super.draw`. -- **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:scope:)` so they match the row. A theme change repaints visible rows; it never reloads data. -- **A translucent theme selection is flattened once.** The row, the gutter and a heading sit on three different bases, so `DataGridBodyChrome.themedSelectionColor(emphasized:scope:)` lays the selection over the grid background and all three paint that opaque color with any alpha. -- **A range outline stays apart from its fill.** Natively a multi-cell range is outlined in the selection color at 70%. Under a themed `dataGrid.selection` that outline would vanish into the opaque fill, so `GridSelectionOverlay.rangeBorderColor(_:)` takes the active cell's border color instead (`dataGrid.focusBorder`, else Accent) at 70%. -- **The grid reads its colors through its `ThemeScope`.** `DataGridView(themeScope:)` hands it to `TableViewCoordinator`, and every grid color read goes through `coordinator.themeScope`, `KeyHandlingTableView.themeScope` and `SortableHeaderView.themeScope` included. `ThemeEngine.shared` supplies only the fonts and the cache counters that follow a system color change. A new scope repaints in place with no reload. Read `scope.resolved` once per draw, not once per cell. Scopes: `.claude/rules/theme.md`. -- **`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. +Three layers, knowledge pointing one way: an owner's adapter, then `DataGridModel` (`Core/DataGrid`), then the AppKit view (`Views/DataGrid`). The value editors and viewers stay in `Views/Results`. + +## Model + +- **`DataGridModel` holds the grid's state** and outlives every mount: column set, row order, selection, sort, value filter, column layout, display formats, find and the edit session. Its owner holds it strongly; the data tab keeps one per tab on `MainContentCoordinator`. +- **An owner talks to the model, never to the view.** It implements `DataGridDataSource` and `DataGridActions` in an adapter class and calls `reloadRows()` when its rows were replaced or `apply(_ delta:)` when they changed. The model keeps the selection by identity and sends the mounted view one `DataGridChange`. +- **Rows are `RowID`, columns are `DataGridColumnID`.** `DataGridRowOrder` maps display rows, and `DataGridColumnSet` is the only converter between data index, display position and pane. A bare `Int` is a display position, valid only inside one call: capture the IDs before a popover, a sheet or an `await`. +- **Every edit is one `model.commit(_:actionName:)`** of `DataGridCellEdit`s, taken by IDs when the gesture began. A command over many cells is one call, so one undo step. +- **The selection is `model.selection`, changed through `GridSelectionReducer`**, a pure engine in `Core/DataGrid/Engine` with its own tests. The tables' `selectedRowIndexes` mirror the rows it spans; a change AppKit makes itself (Up, Down, type-select) goes through `rowsSelectedNatively` first. +- **A selection says what it is in `GridSelection.Mode`**: a click selects the row with the cell cursor on the clicked cell (`.cursor`), a drag, Shift or Command gesture makes a cell range (`.cells`), and row numbers or Select All pick rows (`.rows`). Copy, Delete and the row commands read the mode, never the rectangle's shape. +- **What a grid offers is `configuration.commands`.** Menus and validation read it, because a defaulted protocol method cannot be detected at runtime. +- **State persists through `model.store`.** Only the data tab of a real, non-temporary table has `layoutScope` `.table`; every other grid is `.ephemeral` and never writes a table's saved layout or display formats. + +## Panes + +- **Two tables, one grid.** The main pane's `DataGridTableView` holds every column, the row numbers first. The frozen pane is a second table laid over the main pane's leading edge, showing the row numbers and the frozen columns. Keys, find, copy and VoiceOver work on the main table. +- **The main table is the only first responder.** The frozen table refuses it and hands its presses to the controller. `DataGridContainerView.gridHasFocus` is the one focus flag: both panes' row views take `isEmphasized` from it, and so does the active-cell border. +- **Row height, row count, reloads, inserts and removes reach both panes in one call**, so their document heights never differ. +- **Vertical scroll stays in step through bounds-change notifications**: each pane's clip moves the other with `clipView.scroll(to:)` and `reflectScrolledClipView`, only when its y differs. Anywhere else scroll with `NSView.scroll(_:)`: moving a clip's x that way leaves the header behind. +- **The frozen pane forwards every wheel event to the main scroll view**, so one momentum engine moves both. +- **Frozen columns take at most two thirds of the grid** (`DataGridMetrics.maximumFrozenShare`). Columns past that scroll, and Freeze Up to This Column is refused in the menu. +- **`DataGridTableView.scrollToVisible(_:)` widens the rect leftward by the frozen width**, so a revealed column never parks under the frozen pane. +- **A column moves only within its side of the freeze line.** A freeze or unfreeze rebuilds both panes' column lists from the set, writing width and position explicitly: remove-then-add alone strands a column at the end and resets its width. + +## Cells + +- **Cells are drawn, not mounted.** `DataGridRowView` draws its pane's cells through `DataGridCellRenderer`. `tableView(_:viewFor:row:)` returns nil except for the cell being edited and the accessibility stand-ins: a view per cell costs 45 ms a frame at 1,000 rows by 200 columns against 1.5 ms drawn. +- **A change repaints, it never reloads.** It reaches pixels as `needsDisplay` on the row views it names. `reloadData(forRowIndexes:columnIndexes:)` only mounts or unmounts a cell view, since reloading an edited row ends the edit. A full `reloadData()` drops the table selection without telling anyone, so the controller writes it back. +- **Every column stays attached.** Never hide an off-screen column with `NSTableColumn.isHidden`: each write costs O(attached columns). Columns come from each pane's pool, keyed by `DataGridColumnID`, so a re-run of the same result does not flicker (#951). +- **Resolve a table column through `DataGridPane.slot(atTableIndex:columns:)` and `tableIndex(forDisplayPosition:columns:)`**, never a fixed index into `tableColumns`: the row-number column sits first and the frozen pane holds a subset. +- **`gridStyleMask` stays empty.** AppKit's vertical grid lines keep a subview per column (518 ms a layout at 500 columns). `DataGridBodyChrome` draws the separators in each row, and the stripes and separators past the last row. +- **Selection is the system's.** `drawSelection(in:)` fills the whole row in a row selection and the selected columns' span in a cell range: `selectedContentBackgroundColor` when emphasized, else `unemphasizedSelectedContentBackgroundColor`. The active cell is an inner stroke in `keyboardFocusIndicatorColor`, 2 pt with focus and 1 pt without, in the selected text color over an emphasized fill. No outline goes round a range. +- **Never draw the active cell with the focus ring API.** From a row view the ring loses its top and bottom; on an `NSTableView` it draws nothing. +- **Colors come from `model.themeScope`**, read once per draw. A theme, Accent or Increase Contrast change repaints the visible rows and never reloads data. `ThemeEngine.shared` supplies only the fonts. No theme slot colors the header, the selection, selected text or the active-cell border (`.claude/rules/theme.md`). +- **The row view draws the column a header drag is moving**, at the header's `draggedDistance`: drawn cells have no views for AppKit to float. + +## Header + +- **Stock `NSTableHeaderView` and `NSTableHeaderCell`.** `DataGridHeaderView` overrides only `menu(for:)` and `viewWillDraw`, which repaints the body on each step of a column drag. AppKit draws the primary sort arrow; the controller sets each header cell's `accessibilitySortDirection`, which AppKit leaves unknown. +- **The model's sort is the source.** `tableView.sortDescriptors` is written from `store.sortState` under `isApplyingSortDescriptors`, because an assignment inside the callback fires it again. A plain click keeps only the clicked key and flips it on a repeat; an Option-click keeps the key AppKit appended (`HeaderSortCycle`). Shift-click reaches no sort callback, and Command-click selects the column. +- **Header text is the column name.** The type, the comment and, while several keys sort, the sort order go in `headerToolTip`; an active value filter adds a funnel symbol attachment to the title, in the header text color. + +## Keyboard, mouse and menus + +- **Behavior comes from responder actions, never key codes.** `keyDown` sends Return, Enter, Tab, Backtab, Delete, Forward Delete, Left, Right, and Shift-Up or Shift-Down over a cell range through `interpretKeyEvents`. Everything else goes to `super`, which keeps AppKit's row movement and type-select. +- **A modifier test masks `.capsLock` and `.function`**: compare within `[.command, .shift, .option, .control]` only. Forward Delete carries `.function`. +- **Copy, Paste, Delete, Select All, Find, Copy Special, Set Value and zoom are actions the grid answers itself**, with `NSMenuItemValidation`, so they work in every window. The context menu's Paste takes the keyboard's path. A window's own handler runs only while the grid is not focused. +- **The context menu is built from `clickedRow` and `clickedColumn`** in `menuNeedsUpdate(_:)`. It acts on the selection when the clicked cell is inside it, else on the clicked cell or row; the selection never moves. +- **A press in either pane resolves to one `GridCoord`** through the container and the column set. A drag autoscrolls the main pane and crosses panes. + +## Accessibility + +- **AppKit builds the AXCell tree from cell views only**, so while `DataGridAccessibility.isActive` the main table mounts a `DataGridAccessibilityCellView` (identifier `data-grid-cell`) for each on-screen cell. It draws nothing and takes no clicks. Keep that path working whenever row drawing changes. +- **A label reads "Row N, column M: value"**: N is the row number on screen, page offset included, and M the display position. UI tests find cells by it (`.claude/rules/ui-tests.md`, The grid). +- **The frozen pane is hidden from accessibility.** A frozen column's stand-in in the main table reports the frame the frozen pane shows it at. +- **Only the viewport mounts.** Mounting for every row starved the main thread behind a 1,000-row page (#2392); rows scrolled into view remount. diff --git a/.claude/rules/theme.md b/.claude/rules/theme.md index a09169dd46..d552dbcc44 100644 --- a/.claude/rules/theme.md +++ b/.claude/rules/theme.md @@ -19,8 +19,9 @@ paths: - `TableProTheme` is Foundation only, so the iOS app can adopt it; AppKit and SwiftUI code stays in `apple/TablePro/Theme`. - **`ThemeSlot` is the one registry.** A slot exists only while a view reads it: removing its last reader removes the slot. -- `isOverridden(_:)` means the slot draws a theme color, set or derived: ask it before swapping a native drawing path (row selection, caret, header). Only the theme editor asks `hasExplicitColor(_:)`. -- **Adding a slot bumps `ThemeSchema.current`.** The decoder reads every schema from 3 up and refuses a newer one or an unknown key, rather than load a file without colors its author wrote. The bump renames the sync content kind (Sync). +- `isOverridden(_:)` means the slot draws a theme color, set or derived: ask it before swapping a native drawing path (a code table's selection, the caret). Only the theme editor asks `hasExplicitColor(_:)`. +- **Adding a slot bumps `ThemeSchema.current`.** `ThemeFileFormat` reads only the current schema: an older or newer one, or an unknown key, is refused rather than loaded without colors its author wrote, so a bump stops every older file from loading. The bump renames the sync content kind (Sync). +- **Removing a slot never bumps the schema.** Its key joins `ThemeFileFormat.retiredKeys`, which the decoder drops, so a file that still carries it loads without it. - **An imported file is untrusted.** `ThemeFileImporter` checks the size before it reads, mints a fresh id every time, and never follows a VS Code `include` out of the file's folder. ## Reading colors @@ -28,7 +29,7 @@ paths: - **A scoped carrier reads through its `ThemeScope`, never `ThemeEngine.shared`.** Only the theme editor preview uses `.fixed(_:)`, and a new definition is a new scope. - `palette.color(_:otherwise:)` returns a new object on every call: never compare it to skip a write. Bake `resolved` into a `CGColor` only inside the drawing view's appearance (`resolved.cgColor(_:otherwise:in:)`). - **Hold every AppKit carrier** (`ThemeTextViewStyle`, `ThemeTableChrome`, never on a source list) **as a stored property.** Its subscription and row views hold it weakly, so one held in a local stops at the first theme change. -- `NSTableHeaderView` paints over anything beneath it, so `ThemeTableChrome` swaps in `ThemeTableHeaderView` while the header is themed. +- **Header and selection are always the system's.** No slot colors a table header, a records row's selection or its selected text, or the data grid's active-cell border, so they follow Accent, focus and Increase Contrast. `ThemeTableChrome` never replaces a header; only a code table recolors its selection, with `editor.selection`. - **A theme change has to reach the view.** SwiftUI caches `Color(nsColor:)` by object identity, so a view reading the engine holds `@ObservedObject private var themeEngine = ThemeEngine.shared`; an AppKit view that bakes a color re-bakes on `AppEvents.shared.themeChanged`. `withAlphaComponent` on a system color outside `NSColor(name:dynamicProvider:)` freezes it. - **No theme color on selectable-row text**: it does not turn white on the emphasized selection. Never put `.themeForeground` on a container of selectable rows; `ThemeTableChrome.recordsTextSlot(...)` picks a records cell's slot. diff --git a/.claude/rules/ui-tests.md b/.claude/rules/ui-tests.md index 4b86f08513..554dcf40de 100644 --- a/.claude/rules/ui-tests.md +++ b/.claude/rules/ui-tests.md @@ -52,7 +52,9 @@ The UI plan holds end-to-end flows only. Swift Testing rules, isolation and shar ## The grid - Every grid read and click goes through `FixtureGrid` (`cell`, `value`, `waitForValue`, `clickCell`, `editCell`, `clickHeader`) or `gridPoint(in:of:dy:)`. -- `FixtureGrid` matches a cell by the identifier `DataGridCellAccessibilityView`, the leaf that carries the value, not the AXCell wrapper AppKit puts around it. +- `dataGrid(in:)` matches the main table, `data-grid`, which publishes every column. The frozen pane laid over its leading edge, `data-grid-frozen`, is hidden from accessibility. +- `FixtureGrid` matches a cell by the identifier `data-grid-cell`, the leaf that carries the value, not the AXCell wrapper AppKit puts around it, and by the label `Row r, column c: `. Both count from 1: `r` is the row number on screen, page offset included, and `c` the column's display position, so hiding or moving a column renumbers the ones after it. +- A plain `clickHeader` sorts by that column alone and flips it on the next click; `clickHeader(_:modifiers: .option)` adds it as another key. A right-click opens the header or cell menu and never moves the selection. - `FixtureFile.rows` and `FixtureFile.execute` read and write the fixture database directly, to set up or check data without the UI. - Never call `click()` on a grid element. Rows and cells read as obscured by their columns, a row element has an empty frame so an offset click lands on the window's close button, and a header never becomes hittable. The app side is in `.claude/rules/data-grid.md`. - A point offset from the grid measures from its top edge, above the header. Take `dy` from a row's frame (`row.frame.midY - grid.frame.minY`); a small constant hits the header and opens the column menu. diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeImporter.swift b/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeImporter.swift index 1e9b13c5b3..0274e07f98 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeImporter.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeImporter.swift @@ -167,7 +167,7 @@ private extension ThemeDefinition { switch slot.fallback { case .system, .defaultSyntax: continue - case .slot, .derived, .paired: + case .slot, .derived: var unset = result unset.overrides[slot] = nil if unset.value(for: slot) == result.value(for: slot) { diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift b/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift index f59fdb5a22..0f0365a41c 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/Import/VSCodeThemeMapping.swift @@ -22,9 +22,7 @@ private extension VSCodeColorSource { } internal extension ThemeSlot { - // An empty list leaves the slot to its fallback. So does a source list that stops short: the - // alternate row, the header, the header text, the bools and the selection text are derived by - // `fallback` from the background, text and selection, which is what VS Code paints there too. + // A list that stops short leaves the slot to `fallback`, which derives the stripe and the bools the way VS Code paints them. var vscodeSources: [VSCodeColorSource] { switch self { case .windowBackdrop: @@ -84,20 +82,8 @@ internal extension ThemeSlot { return [.color("editor.foreground")] case .gridAlternateRow: return [.color("keybindingTable.rowsBackground"), .color("tree.tableOddRowsBackground")] - case .gridHeaderBackground: - return [.color("keybindingTable.headerBackground"), .color("tree.tableOddRowsBackground")] - case .gridHeaderText: - return [.color("foreground")] case .gridLine: return [.color("tree.tableColumnsBorder")] - case .gridSelection: - return [.color("list.activeSelectionBackground")] - case .gridSelectionText: - return [.color("list.activeSelectionForeground")] - case .gridInactiveSelection: - return [.color("list.inactiveSelectionBackground")] - case .gridFocusBorder: - return [.color("list.focusOutline"), .color("focusBorder")] case .gridNullValue: // The grid draws NULL as placeholder text. `descriptionForeground` equals the text color in // One Dark Pro, so NULL would not read as absent. diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeDefinition.swift b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeDefinition.swift index cb71b8ac5a..06ed516b66 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeDefinition.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeDefinition.swift @@ -1,8 +1,5 @@ import Foundation -/// A theme is a set of colors for the content views and the window backdrop. A slot it leaves out -/// draws what its `ThemeSlot.fallback` says, so Default Light and Default Dark declare nothing and -/// render exactly like a native app, following Accent, Highlight and Increase Contrast. public struct ThemeDefinition: Identifiable, Equatable, Sendable { public var id: String public var name: String @@ -35,26 +32,23 @@ public struct ThemeDefinition: Identifiable, Equatable, Sendable { !isBuiltIn } - /// The file sets this slot. Only the theme editor asks: a derived slot draws a theme color the - /// file never wrote, and an explicit selection text draws nothing without a selection. + // Only the theme editor asks: a derived slot draws a theme color the file never wrote. public func hasExplicitColor(_ slot: ThemeSlot) -> Bool { overrides[slot] != nil } - /// The format has no increased-contrast variant, so a slot that yields to Increase Contrast draws - /// native there even where the theme sets it. + // The format has no increased-contrast variant, so a yielding slot draws native there. public func value(for slot: ThemeSlot, highContrast: Bool = false) -> ThemeSlotValue { if slot.yieldsToIncreasedContrast, highContrast { return .system(slot) } - if let explicit = overrides[slot], allowsExplicitColor(for: slot, highContrast: highContrast) { + if let explicit = overrides[slot] { return .themed(slot.requiresOpaque ? explicit.opaque : explicit) } return fallbackValue(for: slot, highContrast: highContrast) } - /// The slot draws a theme color, set or derived. Every surface that swaps a native drawing path - /// for the theme's asks this. + // Set or derived. Every surface that swaps a native drawing path for the theme's asks this. public func isOverridden(_ slot: ThemeSlot, highContrast: Bool) -> Bool { value(for: slot, highContrast: highContrast).isThemed } @@ -63,11 +57,6 @@ public struct ThemeDefinition: Identifiable, Equatable, Sendable { isOverridden(slot, highContrast: false) } - private func allowsExplicitColor(for slot: ThemeSlot, highContrast: Bool) -> Bool { - guard case let .paired(partner, _) = slot.fallback else { return true } - return value(for: partner, highContrast: highContrast).isThemed - } - private func fallbackValue(for slot: ThemeSlot, highContrast: Bool) -> ThemeSlotValue { switch slot.fallback { case .system: @@ -80,16 +69,11 @@ public struct ThemeDefinition: Identifiable, Equatable, Sendable { case let .derived(base, rule): guard let baseColor = value(for: base, highContrast: highContrast).themedColor else { return .system(slot) } return .themed(derive(rule, from: baseColor, highContrast: highContrast)) - case let .paired(partner, fallback): - guard value(for: partner, highContrast: highContrast).isThemed else { return .system(slot) } - return value(for: fallback, highContrast: highContrast) } } private func derive(_ rule: ThemeDerivationRule, from base: ThemeColor, highContrast: Bool) -> ThemeColor { switch rule { - case .same: - return base case let .stripe(toward): let target = value(for: toward, highContrast: highContrast).themedColor ?? (appearance == .dark ? .white : .black) diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift index 26f6da07cf..a8ad66013d 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift @@ -1,11 +1,6 @@ import Foundation -/// Format 3 dropped the pane and status groups, made every color optional and removed `system:` -/// values. Format 2 never shipped in a release, and format 1 files carried fonts the app now keeps in -/// settings. -/// -/// Adding a slot bumps `current`. The decoder keeps reading every older format, and an unknown key -/// stays an error: a file is refused rather than loaded without colors its author wrote. +// Removing a slot never bumps it: the slot's key joins `ThemeFileFormat.retiredKeys`. public enum ThemeSchema { public static let current = 3 } @@ -15,7 +10,7 @@ public enum ThemeIdentifier { private static let pattern = #"^[A-Za-z0-9._-]+$"# - /// Plain enough to be a file name on every file system TablePro writes to. + // Plain enough to be a file name on every file system TablePro writes to. public static func isValid(_ id: String) -> Bool { id.utf8.count <= maximumLength && id.range(of: pattern, options: .regularExpression) != nil @@ -27,21 +22,24 @@ public enum ThemeIdentifier { } } -/// The theme file format, and the only place a file can be rejected. Every color is optional: a -/// slot the file leaves out draws its fallback. What is refused is anything that would otherwise -/// load as something the author did not write: an unknown key, an unparseable color, missing -/// metadata, another format. -/// -/// Foundation keeps the first of two equal keys at one level, so that duplicate cannot be seen. +// Foundation keeps the first of two equal keys at one level, so that duplicate cannot be seen. public enum ThemeFileFormat { public static let maximumTextLength = 100 - /// What `decode` does with a name or author that is not one line of at most `maximumTextLength`. + // Removed slots: a file that still carries one loads without it. + static let retiredKeys: Set = [ + "dataGrid.headerBackground", + "dataGrid.headerText", + "dataGrid.selection", + "dataGrid.selectionText", + "dataGrid.inactiveSelection", + "dataGrid.focusBorder", + ] + public enum TextPolicy: Sendable { - /// Refuses it: a file someone picked, and every record another Mac sent. + // A file someone picked, and every record another Mac sent. case strict - /// Folds it onto one line and cuts it at the cap. TablePro 0.81 wrote names and authors with - /// neither limit, and a theme it saved still loads; the next save writes the repaired text. + // TablePro 0.81 wrote names and authors with no limit; the next save writes the repaired text. case repaired } @@ -97,7 +95,6 @@ public enum ThemeFileFormat { ) } - /// Writes what `decode` reads back unchanged: a trimmed name and author, and opaque fills. public static func encode(_ theme: ThemeDefinition) throws -> Data { guard ThemeIdentifier.isValid(theme.id) else { throw ThemeLoadError.invalidIdentifier(theme.id) } let name = try validatedName(theme.name, policy: .strict) @@ -124,8 +121,7 @@ public enum ThemeFileFormat { // MARK: - Reading - /// `JSONDecoder` rather than `JSONSerialization`: its underlying error names the character and - /// where it is, which is what a person fixing the file by hand needs. + // `JSONDecoder` over `JSONSerialization`: its error names the character and where it is. private static func parse(_ data: Data) throws -> ThemeJSONNode { do { return try JSONDecoder().decode(ThemeJSONNode.self, from: data) @@ -141,6 +137,7 @@ public enum ThemeFileFormat { let leaves = root .filter { MetadataKey(rawValue: $0.key) == nil } .flatMap { key, node in node.leaves(prefix: key) } + .filter { !retiredKeys.contains($0.path) } .sorted { $0.path < $1.path } var parsed: [ThemeSlot: ThemeColor] = [:] @@ -182,7 +179,7 @@ public enum ThemeFileFormat { return repaired(text) } - /// Every scalar `isSingleLine` refuses splits words, so the next save's strict check passes. + // Every scalar `isSingleLine` refuses splits words, so the next save's strict check passes. private static func repaired(_ text: String) -> String { let separators = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) let words = text.components(separatedBy: separators).filter { !$0.isEmpty } @@ -194,8 +191,7 @@ public enum ThemeFileFormat { text.trimmingCharacters(in: .whitespacesAndNewlines) } - /// `controlCharacters` holds the ASCII line breaks; `newlines` adds the Unicode line and paragraph - /// separators. + // `newlines` adds the Unicode line and paragraph separators to the ASCII breaks. private static func isSingleLine(_ text: String) -> Bool { !text.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) || CharacterSet.newlines.contains($0) } } @@ -216,9 +212,7 @@ public enum ThemeFileFormat { } } -/// A JSON value as far as the format cares. A number is kept only as an integer, which is all the -/// schema field holds; anything else that is not a string or an object is `other`, so the decoder -/// can name it rather than drop it. +// Anything not a string, an integer or an object is `other`, so the decoder names it rather than drop it. private indirect enum ThemeJSONNode: Decodable { case string(String) case number(Int) @@ -253,7 +247,7 @@ private indirect enum ThemeJSONNode: Decodable { return value } - /// Every leaf with its dotted path. A leaf that is not a string is kept as `nil`. + // A leaf that is not a string is kept as `nil`. func leaves(prefix: String) -> [(path: String, value: String?)] { switch self { case let .string(value): diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift index 266db47654..29a24bf997 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift @@ -1,8 +1,5 @@ import Foundation -/// The single registry every slot goes through. A slot exists here because a call site reads it. -/// The decoder, the encoder and the theme editor all enumerate this list rather than keeping their -/// own copies. public enum ThemeSlot: String, CaseIterable, Sendable { case windowBackdrop = "window.background" @@ -27,13 +24,7 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case gridBackground = "dataGrid.background" case gridText = "dataGrid.text" case gridAlternateRow = "dataGrid.alternateRow" - case gridHeaderBackground = "dataGrid.headerBackground" - case gridHeaderText = "dataGrid.headerText" case gridLine = "dataGrid.gridLine" - case gridSelection = "dataGrid.selection" - case gridSelectionText = "dataGrid.selectionText" - case gridInactiveSelection = "dataGrid.inactiveSelection" - case gridFocusBorder = "dataGrid.focusBorder" case gridNullValue = "dataGrid.nullValue" case gridBoolTrue = "dataGrid.boolTrue" case gridBoolFalse = "dataGrid.boolFalse" @@ -43,7 +34,6 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case gridDeleted = "dataGrid.deleted" case gridDeletedText = "dataGrid.deletedText" - /// The group of the file format: the slot's dotted path starts with the group's key. public var group: ThemeSlotGroup { switch self { case .windowBackdrop: @@ -54,15 +44,13 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType: return .syntax - case .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, - .gridLine, .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder, + case .gridBackground, .gridText, .gridAlternateRow, .gridLine, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return .dataGrid } } - /// Where the theme editor lists the slot. public var section: ThemeSlotSection { switch self { case .windowBackdrop: @@ -73,50 +61,43 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType: return .syntax - case .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, - .gridLine, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber: + case .gridBackground, .gridText, .gridAlternateRow, .gridLine, + .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber: return .dataGrid - case .gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder: - return .selection case .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return .changes } } - /// Only the backdrop: content slots keep the theme under Increase Contrast, as they always have. + // Content keeps the theme under Increase Contrast; only the backdrop yields. public var yieldsToIncreasedContrast: Bool { group == .window } - /// Fills that sit under everything else. A translucent window color shows the desktop through the - /// window, so these never carry alpha. + // A translucent fill under everything else would show the desktop through the window. public var requiresOpaque: Bool { switch self { - case .windowBackdrop, .editorBackground, .gridBackground, .gridHeaderBackground: + case .windowBackdrop, .editorBackground, .gridBackground: return true case .editorText, .editorCursor, .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles, .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType, - .gridText, .gridAlternateRow, .gridHeaderText, .gridLine, .gridSelection, .gridSelectionText, - .gridInactiveSelection, .gridFocusBorder, .gridNullValue, .gridBoolTrue, .gridBoolFalse, + .gridText, .gridAlternateRow, .gridLine, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return false } } - /// Washes drawn over content, where an alpha channel is the point. public var offersOpacity: Bool { switch self { case .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorInvisibles, - .gridAlternateRow, .gridSelection, .gridInactiveSelection, - .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: + .gridAlternateRow, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return true case .windowBackdrop, .editorBackground, .editorText, .editorCursor, .editorLineNumber, .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, .syntaxNull, .syntaxOperator, .syntaxFunction, .syntaxType, - .gridBackground, .gridText, .gridHeaderBackground, .gridHeaderText, .gridLine, - .gridSelectionText, .gridFocusBorder, .gridNullValue, .gridBoolTrue, .gridBoolFalse, + .gridBackground, .gridText, .gridLine, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber: return false } @@ -127,7 +108,7 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case .windowBackdrop, .editorBackground, .gridBackground: return String(localized: "Background") case .editorText, .gridText: return String(localized: "Text") case .editorCursor: return String(localized: "Cursor") - case .editorSelection, .gridSelection: return String(localized: "Selection") + case .editorSelection: return String(localized: "Selection") case .editorCurrentLine: return String(localized: "Current Line") case .editorCurrentStatement: return String(localized: "Current Statement") case .editorLineNumber: return String(localized: "Line Number") @@ -141,12 +122,7 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case .syntaxFunction: return String(localized: "Function") case .syntaxType: return String(localized: "Type") case .gridAlternateRow: return String(localized: "Alternate Row") - case .gridHeaderBackground: return String(localized: "Header Background") - case .gridHeaderText: return String(localized: "Header Text") case .gridLine: return String(localized: "Grid Line") - case .gridSelectionText: return String(localized: "Selected Text") - case .gridInactiveSelection: return String(localized: "Inactive Selection") - case .gridFocusBorder: return String(localized: "Focus Border") case .gridNullValue: return String(localized: "NULL Value") case .gridBoolTrue: return String(localized: "Bool True") case .gridBoolFalse: return String(localized: "Bool False") @@ -158,8 +134,7 @@ public enum ThemeSlot: String, CaseIterable, Sendable { } } - /// `label` repeats across groups ("Background" three times), so VoiceOver gets the group too. - /// Whole phrases, because word order differs between languages. + // `label` repeats across groups, so VoiceOver gets whole phrases with the group in them. public var accessibilityLabel: String { switch self { case .windowBackdrop: return String(localized: "Window Background") @@ -182,13 +157,7 @@ public enum ThemeSlot: String, CaseIterable, Sendable { case .gridBackground: return String(localized: "Data Grid Background") case .gridText: return String(localized: "Data Grid Text") case .gridAlternateRow: return String(localized: "Data Grid Alternate Row") - case .gridHeaderBackground: return String(localized: "Data Grid Header Background") - case .gridHeaderText: return String(localized: "Data Grid Header Text") case .gridLine: return String(localized: "Data Grid Line") - case .gridSelection: return String(localized: "Data Grid Selection") - case .gridSelectionText: return String(localized: "Data Grid Selected Text") - case .gridInactiveSelection: return String(localized: "Data Grid Inactive Selection") - case .gridFocusBorder: return String(localized: "Data Grid Focus Border") case .gridNullValue: return String(localized: "Data Grid NULL Value") case .gridBoolTrue: return String(localized: "Data Grid Bool True") case .gridBoolFalse: return String(localized: "Data Grid Bool False") diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift index 1bcb69f835..9a0cce3b3a 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotFallback.swift @@ -1,40 +1,31 @@ import Foundation -/// What a slot draws when the theme leaves it out. public enum ThemeSlotFallback: Equatable, Sendable { - /// The slot's native color. case system - /// The same-appearance default's syntax color: syntax has no system color. + // Syntax has no system color, so it takes the same-appearance default's. case defaultSyntax - /// The other slot's theme color, else the slot's native color. + // The other slot's theme color, else the slot's native color. case slot(ThemeSlot) - /// Built from `base` while the theme sets it, else the slot's native color. + // Built from `base` while the theme sets it, else the slot's native color. case derived(base: ThemeSlot, rule: ThemeDerivationRule) - /// Only while the theme sets `with`: the slot's own color, else `fallback`'s value. Otherwise the - /// slot's native color, whatever the theme sets. - case paired(with: ThemeSlot, fallback: ThemeSlot) } public enum ThemeDerivationRule: Equatable, Sendable { - /// The base moved `stripeFraction` toward `toward`'s theme color, or toward black in a light theme - /// and white in a dark one. + // Toward `toward`'s theme color, else toward black in a light theme and white in a dark one. case stripe(toward: ThemeSlot) - /// The base itself. - case same - /// AppKit's alternate stripe sits about 4% from its row background in both appearances. + // AppKit's alternate stripe sits about 4% from its row background in both appearances. public static let stripeFraction = 0.04 } public extension ThemeSlot { - /// Exhaustive, so a new slot cannot ship without a rule. A rule only names slots that fall back to - /// the system themselves, so resolution never chains. + // A rule only names slots that fall back to the system themselves, so resolution never chains. var fallback: ThemeSlotFallback { switch self { case .windowBackdrop, .editorBackground, .editorText, .editorCursor, .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorLineNumber, .editorInvisibles, - .gridBackground, .gridText, .gridLine, .gridSelection, .gridInactiveSelection, .gridFocusBorder, + .gridBackground, .gridText, .gridLine, .gridNullValue, .gridRowNumber, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText: return .system case .syntaxKeyword, .syntaxString, .syntaxNumber, .syntaxComment, @@ -42,18 +33,11 @@ public extension ThemeSlot { return .defaultSyntax case .syntaxOperator: return .slot(.editorText) - // A bool or a header label in labelColor next to themed cell text reads as a different table. - case .gridBoolTrue, .gridBoolFalse, .gridHeaderText: + // A bool in labelColor next to themed cell text reads as a different table. + case .gridBoolTrue, .gridBoolFalse: return .slot(.gridText) case .gridAlternateRow: return .derived(base: .gridBackground, rule: .stripe(toward: .gridText)) - // Natively the header and the rows share controlBackgroundColor. - case .gridHeaderBackground: - return .derived(base: .gridBackground, rule: .same) - // AppKit's own selection keeps its white text, so a theme's selection text only means - // something over the theme's selection. - case .gridSelectionText: - return .paired(with: .gridSelection, fallback: .gridText) } } } diff --git a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift index b1e2fc3606..67e87176f3 100644 --- a/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift +++ b/apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlotSection.swift @@ -1,13 +1,11 @@ import Foundation -/// The theme editor's grouping. It splits the data grid's file-format group so selection and row -/// change colors, which are chosen together, sit together. +// The theme editor's grouping: the row change colors, chosen together, get a section of their own. public enum ThemeSlotSection: String, CaseIterable, Sendable { case window case editor case syntax case dataGrid - case selection case changes public var label: String { @@ -16,7 +14,6 @@ public enum ThemeSlotSection: String, CaseIterable, Sendable { case .editor: return String(localized: "Editor") case .syntax: return String(localized: "Syntax") case .dataGrid: return String(localized: "Data Grid") - case .selection: return String(localized: "Selection") case .changes: return String(localized: "Changes") } } diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift index 5e4172abb3..1c6739c747 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeImportTests.swift @@ -135,14 +135,13 @@ struct VSCodeThemeImportTests { #expect(dark.hex(.editorText) == nil) } - @Test("The four fills nothing themed sits under are flattened; washes keep their alpha") + @Test("The three fills nothing themed sits under are flattened; washes keep their alpha") func opaqueSlotsAreFlattened() throws { let theme = try Self.theme( """ {"type": "dark", "colors": { "editor.background": "#20406080", "editorPane.background": "#FFFFFF33", - "keybindingTable.headerBackground": "#FFFFFF1A", "editor.selectionBackground": "#3050A080", "keybindingTable.rowsBackground": "#FFFFFF0A" }} @@ -154,7 +153,6 @@ struct VSCodeThemeImportTests { #expect(theme.overrides[.editorBackground] == editor) #expect(theme.overrides[.gridBackground] == editor) #expect(theme.overrides[.windowBackdrop] == ThemeColor(red: 255, green: 255, blue: 255, alpha: 0x33).composited(over: editor)) - #expect(theme.overrides[.gridHeaderBackground] == ThemeColor(red: 255, green: 255, blue: 255, alpha: 0x1A).composited(over: editor)) for slot in ThemeSlot.allCases where slot.requiresOpaque { #expect(theme.overrides[slot]?.isOpaque == true, "\(slot)") } @@ -168,34 +166,32 @@ struct VSCodeThemeImportTests { """ {"colors": { "editor.background": "#202020", - "editor.foreground": "#E0E0E0", - "foreground": "#E0E0E0", - "keybindingTable.headerBackground": "#202020", - "list.activeSelectionForeground": "#FFFFFF" + "editor.foreground": "#E0E0E0" }, "tokenColors": [{"scope": "constant.language.boolean", "settings": {"foreground": "#E0E0E0"}}]} """ ) - #expect(theme.hex(.gridHeaderText) == nil) - #expect(theme.hex(.gridHeaderBackground) == nil) - #expect(theme.hex(.gridSelectionText) == nil) #expect(theme.hex(.gridBoolTrue) == nil) #expect(theme.hex(.gridBoolFalse) == nil) - #expect(theme.value(for: .gridHeaderText) == .themed(ThemeColor(red: 0xE0, green: 0xE0, blue: 0xE0))) - #expect(theme.value(for: .gridHeaderBackground) == .themed(ThemeColor(red: 0x20, green: 0x20, blue: 0x20))) + #expect(theme.value(for: .gridBoolTrue) == .themed(ThemeColor(red: 0xE0, green: 0xE0, blue: 0xE0))) } - @Test("Selection text is kept while the theme also sets the selection and the two differ from the text") - func pairedSelectionTextIsKept() throws { + @Test("A VS Code theme's list selection, focus and table header colors import nothing: those stay the system's") + func selectionAndHeaderColorsImportNothing() throws { let theme = try Self.theme( """ {"colors": {"editor.background": "#202020", "editor.foreground": "#E0E0E0", - "list.activeSelectionBackground": "#0050A0", "list.activeSelectionForeground": "#FFFFFF"}} + "list.activeSelectionBackground": "#0050A0", "list.activeSelectionForeground": "#FFFFFF", + "list.inactiveSelectionBackground": "#00305080", "list.focusOutline": "#0080FF", + "focusBorder": "#3366FF", "keybindingTable.headerBackground": "#2A2A2A", "foreground": "#C0C0C0"}} """ ) + let imported = Set(theme.overrides.values.map(\.hex)) - #expect(theme.hex(.gridSelection) == "#0050A0") - #expect(theme.hex(.gridSelectionText) == "#FFFFFF") + for hex in ["#0050A0", "#FFFFFF", "#00305080", "#0080FF", "#3366FF", "#2A2A2A", "#C0C0C0"] { + #expect(!imported.contains(hex), "\(hex)") + } + #expect(theme.hex(.gridBackground) == "#202020") } @Test("An imported theme survives the TablePro format unchanged") diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift index 2e7f5fa335..62040133ce 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/Import/VSCodeThemeMappingTests.swift @@ -19,15 +19,8 @@ struct VSCodeThemeMappingTests { "editorLineNumber.foreground": "#808080", "editorWhitespace.foreground": "#404040", "keybindingTable.rowsBackground": "#252525", - "keybindingTable.headerBackground": "#2A2A2A", "tree.tableOddRowsBackground": "#999999", - "foreground": "#C0C0C0", "tree.tableColumnsBorder": "#303030", - "list.activeSelectionBackground": "#0050A0", - "list.activeSelectionForeground": "#FFFFFF", - "list.inactiveSelectionBackground": "#00305080", - "list.focusOutline": "#0080FF", - "focusBorder": "#999999", "input.placeholderForeground": "#707070", "gitDecoration.modifiedResourceForeground": "#E0C000", "diffEditor.insertedLineBackground": "#00FF0020", @@ -73,13 +66,7 @@ struct VSCodeThemeMappingTests { case .gridBackground: return "#202020" case .gridText: return "#E0E0E0" case .gridAlternateRow: return "#252525" - case .gridHeaderBackground: return "#2A2A2A" - case .gridHeaderText: return "#C0C0C0" case .gridLine: return "#303030" - case .gridSelection: return "#0050A0" - case .gridSelectionText: return "#FFFFFF" - case .gridInactiveSelection: return "#00305080" - case .gridFocusBorder: return "#0080FF" case .gridNullValue: return "#707070" case .gridBoolTrue: return "#C00009" case .gridBoolFalse: return "#C0000A" @@ -100,8 +87,6 @@ struct VSCodeThemeMappingTests { "editor.lineHighlightBorder": "#E0E0E0", "tree.tableOddRowsBackground": "#00000008", "foreground": "#404040", - "focusBorder": "#3366FF", - "list.activeSelectionForeground": "#FFFFFF", "diffEditor.insertedTextBackground": "#00AA0033", "diffEditor.removedTextBackground": "#AA000033" }, "tokenColors": [ @@ -132,15 +117,7 @@ struct VSCodeThemeMappingTests { case .gridBackground: return "#F8F8F8" case .gridText: return "#202020" case .gridAlternateRow: return "#00000008" - // #00000008 flattened over #F8F8F8. - case .gridHeaderBackground: return "#F0F0F0" - case .gridHeaderText: return "#404040" case .gridLine: return nil - case .gridSelection: return nil - // Without a theme selection AppKit keeps its own white text, so the color is dropped. - case .gridSelectionText: return nil - case .gridInactiveSelection: return nil - case .gridFocusBorder: return "#3366FF" case .gridNullValue: return "#40404080" case .gridBoolTrue: return nil case .gridBoolFalse: return nil diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift index 6b0c0dc2a7..136641224c 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeFileFormatTests.swift @@ -2,8 +2,6 @@ import Foundation import TableProTheme import Testing -/// Format 3: every color is optional, a slot left out draws its fallback, and the file is refused for -/// anything that would otherwise load as something its author did not write. struct ThemeFileFormatTests { private static func data(_ object: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: object) @@ -80,11 +78,11 @@ struct ThemeFileFormatTests { func slotsAreOptional() throws { var root = Self.metadata() Self.insert("#FF0000", at: ["editor", "syntax", "keyword"], into: &root) - Self.insert("#00000080", at: ["dataGrid", "selection"], into: &root) + Self.insert("#00000080", at: ["dataGrid", "alternateRow"], into: &root) let colors = try Self.decode(root).overrides.mapValues(\.hex) - #expect(colors == [.syntaxKeyword: "#FF0000", .gridSelection: "#00000080"]) + #expect(colors == [.syntaxKeyword: "#FF0000", .gridAlternateRow: "#00000080"]) } @Test("Every slot path is accepted") @@ -107,7 +105,7 @@ struct ThemeFileFormatTests { } @Test("A fill that must be opaque loads without its alpha, and is written without it", arguments: [ - ThemeSlot.windowBackdrop, .editorBackground, .gridBackground, .gridHeaderBackground, + ThemeSlot.windowBackdrop, .editorBackground, .gridBackground, ]) func opaqueSlotsDropAlphaOnLoad(slot: ThemeSlot) throws { let theme = try Self.decode(Self.withColor("#282A3680", at: slot.rawValue.components(separatedBy: "."))) @@ -119,6 +117,50 @@ struct ThemeFileFormatTests { #expect(reread.overrides[slot]?.hex == "#282A36") } + @Test("A file that still carries the retired header and selection keys loads without them, and saves without them") + func retiredKeysAreDropped() throws { + let text = """ + { + "appearance": "dark", + "author": "Dracula Theme", + "dataGrid": { + "background": "#282A36", + "focusBorder": "#BD93F9", + "headerBackground": "#21222C", + "headerText": "#F8F8F2", + "inactiveSelection": "#44475A80", + "selection": "#44475A", + "selectionText": "#F8F8F2", + "text": "#F8F8F2" + }, + "editor": {"selection": "#44475A"}, + "id": "user.saved-before", + "name": "Saved Before", + "schema": 3 + } + """ + + let theme = try ThemeFileFormat.decode(Data(text.utf8)) + let colors = theme.overrides.mapValues(\.hex) + + #expect(colors == [.gridBackground: "#282A36", .gridText: "#F8F8F2", .editorSelection: "#44475A"]) + let object = try JSONSerialization.jsonObject(with: try ThemeFileFormat.encode(theme)) + let grid = try #require((object as? [String: Any])?["dataGrid"] as? [String: String]) + #expect(grid == ["background": "#282A36", "text": "#F8F8F2"]) + } + + @Test("A retired key is dropped whatever its value, nested or dotted, and only the retired keys are") + func onlyRetiredKeysAreDropped() throws { + var root = Self.metadata() + Self.insert("not a color", at: ["dataGrid", "selection"], into: &root) + Self.insert(16_777_215, at: ["dataGrid", "focusBorder"], into: &root) + root["dataGrid.headerText"] = "#FFFFFF" + #expect(try Self.decode(root).overrides.isEmpty) + + Self.insert("#000000", at: ["dataGrid", "selectionBorder"], into: &root) + #expect(try Self.refusal(root) == .unknownKeys(["dataGrid.selectionBorder"])) + } + @Test("Name and author are trimmed") func nameAndAuthorAreTrimmed() throws { var root = Self.metadata() @@ -179,8 +221,7 @@ struct ThemeFileFormatTests { #expect(error?.localizedDescription.contains("editor.sidebar") == true) } - /// The pane and status groups of format 2 are gone, so a file that still carries them is - /// refused rather than loaded with those colors dropped. + // Refused rather than loaded with those colors dropped. @Test("Format 2 slot groups are refused") func formerGroupsAreRefused() throws { for path in [["panel", "background"], ["status", "warning"], ["content", "editor", "background"]] { @@ -189,8 +230,7 @@ struct ThemeFileFormatTests { } } - /// A value that is not a string would otherwise be dropped silently, which loads the slot as - /// native under a key the author did write. + // Dropped silently, the slot would load as native under a key the author did write. @Test("A key whose value is not a color string is refused") func nonStringValueIsRefused() { var unknownNumber = Self.metadata() diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift index 34baeefc82..8379816385 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotFallbackTests.swift @@ -3,7 +3,7 @@ import TableProTheme import Testing struct ThemeSlotFallbackTests { - /// A typo gives a color no theme draws, so the expectation it sits in fails. + // A typo gives a color no theme draws, so the expectation it sits in fails. private static func color(_ hex: String) -> ThemeColor { ThemeColor(hex: hex) ?? ThemeColor(red: 1, green: 2, blue: 3, alpha: 4) } @@ -44,13 +44,7 @@ struct ThemeSlotFallbackTests { .gridBackground: .system, .gridText: .system, .gridAlternateRow: .derived(base: .gridBackground, rule: .stripe(toward: .gridText)), - .gridHeaderBackground: .derived(base: .gridBackground, rule: .same), - .gridHeaderText: .slot(.gridText), .gridLine: .system, - .gridSelection: .system, - .gridSelectionText: .paired(with: .gridSelection, fallback: .gridText), - .gridInactiveSelection: .system, - .gridFocusBorder: .system, .gridNullValue: .system, .gridBoolTrue: .slot(.gridText), .gridBoolFalse: .slot(.gridText), @@ -71,7 +65,7 @@ struct ThemeSlotFallbackTests { #expect(Set(Self.expected.keys) == Set(ThemeSlot.allCases)) } - /// A rule that named another dependent slot would chain, and two such rules could loop. + // A rule that named another dependent slot would chain, and two such rules could loop. @Test("A rule only names slots that fall back to the system themselves") func rulesNeverChain() { for slot in ThemeSlot.allCases { @@ -79,9 +73,7 @@ struct ThemeSlotFallbackTests { switch slot.fallback { case .system, .defaultSyntax: named = [] case let .slot(source): named = [source] - case let .derived(base, rule): - if case let .stripe(toward) = rule { named = [base, toward] } else { named = [base] } - case let .paired(partner, fallback): named = [partner, fallback] + case let .derived(base, .stripe(toward)): named = [base, toward] } for other in named { #expect(other.fallback == .system, "\(slot.rawValue) names \(other.rawValue)") @@ -107,7 +99,7 @@ struct ThemeSlotFallbackTests { } } - @Test("An explicit color draws as set wherever its rule allows it") + @Test("An explicit color draws as set") func explicitColorDraws() throws { let theme = try Self.theme(.dark, [.syntaxKeyword: "#FF79C6", .gridAlternateRow: "#21222C80", .editorCursor: "#F8F8F2"]) #expect(theme.value(for: .syntaxKeyword) == .themed(Self.color("#FF79C6"))) @@ -119,17 +111,15 @@ struct ThemeSlotFallbackTests { // MARK: - Derived slots - @Test("A light background-only theme stripes and heads the grid from its own background") + @Test("A light background-only theme stripes the grid from its own background") func backgroundOnlyLight() throws { let theme = try Self.theme(.light, [.gridBackground: "#FDF6E3"]) #expect(theme.value(for: .gridAlternateRow) == .themed(Self.color("#F3ECDA"))) - #expect(theme.value(for: .gridHeaderBackground) == .themed(Self.color("#FDF6E3"))) #expect(theme.isOverridden(.gridAlternateRow)) - #expect(theme.isOverridden(.gridHeaderBackground)) #expect(!theme.hasExplicitColor(.gridAlternateRow)) #expect(theme.value(for: .gridText) == .system(.gridText)) - #expect(theme.value(for: .gridSelection) == .system(.gridSelection)) + #expect(theme.value(for: .gridLine) == .system(.gridLine)) } @Test("A dark background-only theme stripes toward white") @@ -137,7 +127,6 @@ struct ThemeSlotFallbackTests { let theme = try Self.theme(.dark, [.gridBackground: "#282A36"]) #expect(theme.value(for: .gridAlternateRow) == .themed(Self.color("#31333E"))) - #expect(theme.value(for: .gridHeaderBackground) == .themed(Self.color("#282A36"))) } @Test("The stripe moves toward the theme's own grid text when it sets one") @@ -149,50 +138,25 @@ struct ThemeSlotFallbackTests { #expect(stripe.isOpaque) } - @Test("Without a themed background the derived slots stay native, even with themed text") + @Test("Without a themed background the stripe stays native, even with themed text") func derivedNeedsTheBase() throws { let theme = try Self.theme(.light, [.gridText: "#112233"]) #expect(theme.value(for: .gridAlternateRow) == .system(.gridAlternateRow)) - #expect(theme.value(for: .gridHeaderBackground) == .system(.gridHeaderBackground)) } - @Test("An explicit alternate row and header win over the derived ones") + @Test("An explicit alternate row wins over the derived one") func explicitBeatsDerived() throws { - let theme = try Self.theme(.light, [.gridBackground: "#FDF6E3", .gridAlternateRow: "#EEE8D5", .gridHeaderBackground: "#E0DAC8"]) + let theme = try Self.theme(.light, [.gridBackground: "#FDF6E3", .gridAlternateRow: "#EEE8D5"]) #expect(theme.value(for: .gridAlternateRow) == .themed(Self.color("#EEE8D5"))) - #expect(theme.value(for: .gridHeaderBackground) == .themed(Self.color("#E0DAC8"))) - } - - // MARK: - Selection text - - @Test("Selection text without a themed selection stays native, ignoring the file") - func selectionTextNeedsASelection() throws { - let theme = try Self.theme(.dark, [.gridSelectionText: "#00FF00", .gridText: "#FF0000"]) - #expect(theme.value(for: .gridSelectionText) == .system(.gridSelectionText)) - #expect(!theme.isOverridden(.gridSelectionText)) - #expect(theme.hasExplicitColor(.gridSelectionText)) - } - - @Test("Selection text over a themed selection is the file's, else the grid text's value") - func selectionTextPairsWithTheSelection() throws { - let explicit = try Self.theme(.dark, [.gridSelection: "#44475A", .gridSelectionText: "#00FF00"]) - #expect(explicit.value(for: .gridSelectionText) == .themed(Self.color("#00FF00"))) - - let textOnly = try Self.theme(.dark, [.gridSelection: "#44475A", .gridText: "#F8F8F2"]) - #expect(textOnly.value(for: .gridSelectionText) == .themed(Self.color("#F8F8F2"))) - - let nativeText = try Self.theme(.dark, [.gridSelection: "#44475A"]) - #expect(nativeText.value(for: .gridSelectionText) == .system(.gridText)) - #expect(!nativeText.isOverridden(.gridSelectionText)) } // MARK: - Slot fallbacks - @Test("Bools and the header text follow themed grid text, and stay native without it") + @Test("Bools follow themed grid text, and stay native without it") func gridTextFollowers() throws { let themed = try Self.theme(.dark, [.gridText: "#F8F8F2"]) let text = Self.color("#F8F8F2") - for slot in [ThemeSlot.gridBoolTrue, .gridBoolFalse, .gridHeaderText] { + for slot in [ThemeSlot.gridBoolTrue, .gridBoolFalse] { #expect(themed.value(for: slot) == .themed(text), "\(slot.rawValue)") #expect(ThemeDefinition.defaultDark.value(for: slot) == .system(slot), "\(slot.rawValue)") } diff --git a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift index ffa4e3f785..d8edcf5f54 100644 --- a/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProThemeTests/ThemeSlotTests.swift @@ -23,9 +23,9 @@ struct ThemeSlotTests { } } - @Test("The registry holds 35 slots") + @Test("The registry holds 29 slots") func slotCount() { - #expect(ThemeSlot.allCases.count == 35) + #expect(ThemeSlot.allCases.count == 29) } @Test("Every slot belongs to exactly one group, listed in registry order") @@ -44,20 +44,29 @@ struct ThemeSlotTests { #expect(sectioned.count == ThemeSlot.allCases.count) } - @Test("The editor sections split the data grid into rows, selection and changes") + @Test("The editor sections split the data grid into rows and changes") func sectionsSplitTheGrid() { + #expect(ThemeSlotSection.allCases == [.window, .editor, .syntax, .dataGrid, .changes]) #expect(ThemeSlotSection.window.slots == [.windowBackdrop]) #expect(ThemeSlotSection.editor.slots == ThemeSlotGroup.editor.slots) #expect(ThemeSlotSection.syntax.slots == ThemeSlotGroup.syntax.slots) #expect(ThemeSlotSection.dataGrid.slots == [ - .gridBackground, .gridText, .gridAlternateRow, .gridHeaderBackground, .gridHeaderText, + .gridBackground, .gridText, .gridAlternateRow, .gridLine, .gridNullValue, .gridBoolTrue, .gridBoolFalse, .gridRowNumber, ]) - #expect(ThemeSlotSection.selection.slots == [.gridSelection, .gridSelectionText, .gridInactiveSelection, .gridFocusBorder]) #expect(ThemeSlotSection.changes.slots == [.gridModified, .gridInserted, .gridDeleted, .gridDeletedText]) } - /// The theme editor lists one section per group in this order, so the window leads it. + @Test("Header and selection colors are the system's, so no slot names them") + func noHeaderOrSelectionSlot() { + for path in [ + "dataGrid.headerBackground", "dataGrid.headerText", "dataGrid.selection", + "dataGrid.selectionText", "dataGrid.inactiveSelection", "dataGrid.focusBorder", + ] { + #expect(ThemeSlot(rawValue: path) == nil, "\(path)") + } + } + @Test("The window group comes first and holds only the window background") func windowGroupLeads() { #expect(ThemeSlotGroup.allCases.first == .window) @@ -76,16 +85,14 @@ struct ThemeSlotTests { #expect(slot.accessibilityLabel.contains(slot.label), "\(slot.rawValue)") } #expect(ThemeSlot.editorBackground.accessibilityLabel == "Editor Background") - #expect(ThemeSlot.gridSelection.accessibilityLabel == "Data Grid Selection") + #expect(ThemeSlot.gridAlternateRow.accessibilityLabel == "Data Grid Alternate Row") #expect(ThemeSlot.windowBackdrop.accessibilityLabel == "Window Background") } @Test("Only the fills under everything else require an opaque color") func opaqueSlots() { let opaque = Set(ThemeSlot.allCases.filter { $0.requiresOpaque }) - #expect(opaque == [ - .windowBackdrop, .editorBackground, .gridBackground, .gridHeaderBackground, - ]) + #expect(opaque == [.windowBackdrop, .editorBackground, .gridBackground]) } @Test("Opacity is offered on the washes and never on a slot that must be opaque") @@ -93,8 +100,7 @@ struct ThemeSlotTests { let translucent = Set(ThemeSlot.allCases.filter { $0.offersOpacity }) #expect(translucent == [ .editorSelection, .editorCurrentLine, .editorCurrentStatement, .editorInvisibles, - .gridAlternateRow, .gridSelection, .gridInactiveSelection, - .gridModified, .gridInserted, .gridDeleted, .gridDeletedText, + .gridAlternateRow, .gridModified, .gridInserted, .gridDeleted, .gridDeletedText, ]) for slot in ThemeSlot.allCases { #expect(!(slot.offersOpacity && slot.requiresOpaque), "\(slot.rawValue)") diff --git a/apple/TablePro/Core/Coordinators/FindCoordinator.swift b/apple/TablePro/Core/Coordinators/FindCoordinator.swift index c796b1f32e..146c4ac2af 100644 --- a/apple/TablePro/Core/Coordinators/FindCoordinator.swift +++ b/apple/TablePro/Core/Coordinators/FindCoordinator.swift @@ -3,130 +3,67 @@ // TablePro // -import Combine import Foundation -import SwiftUI @MainActor -final class FindCoordinator: ObservableObject { +final class FindCoordinator { unowned let parent: MainContentCoordinator - private var searchDebounce: DispatchWorkItem? init(parent: MainContentCoordinator) { self.parent = parent } - var selectedTabFindState: TabFindState? { - parent.tabManager.selectedTab?.findState + private var model: DataGridModel? { + parent.activeGridModel } - func show() { - mutateSelectedTabFindState { $0.isVisible = true } + var hasActiveFind: Bool { + guard let find = model?.find else { return false } + return find.isVisible && !find.matches.isEmpty } - func dismiss() { - searchDebounce?.cancel() - searchDebounce = nil - mutateSelectedTabFindState { - $0.isVisible = false - $0.term = "" - $0.clearMatches() - } - parent.dataTabDelegate?.tableViewCoordinator?.applyFindMatch(nil) - } - - /// The scan is synchronous and main-actor bound, and a wide table is the expensive case: - /// CLAUDE.md flags 500+ columns as the real data-grid cost, so a 1,000-row page is hundreds of - /// thousands of range searches per keystroke. Coalescing means one scan per pause, not per key. - func setTerm(_ term: String) { - mutateSelectedTabFindState { $0.term = term } - searchDebounce?.cancel() - let work = DispatchWorkItem { [weak self] in self?.runSearch() } - searchDebounce = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: work) - } - - func stepForward() { - mutateSelectedTabFindState { $0.stepForward() } - syncGridToCurrentMatch() + func showFind(replace: Bool = false) { + model?.showFind(replace: replace) } - func stepBackward() { - mutateSelectedTabFindState { $0.stepBackward() } - syncGridToCurrentMatch() + func findNext() { + model?.findNext() } - /// Promotes the term into a server-side filter so the answer covers rows this page never - /// loaded. It builds its own filter rows rather than going through `addFilter`, which would - /// consult the user's default-column setting, and it never touches the filters they already - /// applied. - func escalateToAllRows() { - guard let tab = parent.tabManager.selectedTab else { return } - let term = tab.findState.term.trimmingCharacters(in: .whitespacesAndNewlines) - guard !term.isEmpty else { return } - - let buffer = parent.tabSessionRegistry.tableRows(for: tab.id) - let readsAnyColumn = parent.connection.type.patternFiltersReadAnyColumn - let searchable = buffer.columns.indices.filter { index in - guard index < buffer.columnTypes.count else { return true } - return FindMatcher.isServerSearchable(buffer.columnTypes[index], patternReadsAnyColumn: readsAnyColumn) - } - guard !searchable.isEmpty else { return } - - dismiss() - parent.filterCoordinator.applyCrossColumnSearch( - term: term, - columns: searchable.map { buffer.columns[$0] } - ) + func findPrevious() { + model?.findPrevious() } - func runSearch() { - guard let tab = parent.tabManager.selectedTab, - let grid = parent.dataTabDelegate?.tableViewCoordinator - else { return } - - let term = tab.findState.term.trimmingCharacters(in: .whitespacesAndNewlines) - guard !term.isEmpty else { - invalidateMatches() - grid.applyFindMatch(nil) - return - } - - let matches = grid.runFind(term: term) - let anchor = grid.selectedDisplayRow ?? 0 - mutateSelectedTabFindState { state in - state.matches = matches - state.scope = self.scope(for: tab) - state.currentMatchIndex = FindMatcher.nearestMatchIndex(to: anchor, in: matches) - } - syncGridToCurrentMatch() + func useSelectionForFind() { + model?.useSelectionForFind() } - private func syncGridToCurrentMatch() { - guard let grid = parent.dataTabDelegate?.tableViewCoordinator else { return } - grid.applyFindMatch(parent.tabManager.selectedTab?.findState.currentMatch) - } + // MARK: - Search All Rows - /// Rows were replaced underneath the find bar, so the match list describes rows that no - /// longer exist. Keep the term and the bar, drop what is stale. - func invalidateMatches() { - mutateSelectedTabFindState { $0.clearMatches() } + func offersSearchAllRows(forTab tabID: UUID) -> Bool { + // The search replaces the filter set with one OR group, so it never runs over filters the user applied. + guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabID }), + tab.tabType == .table, + parent.supportsMatchAnyFilters, + tab.filterState.filters.isEmpty else { return false } + let rows = parent.tabSessionRegistry.tableRows(for: tabID) + return tab.pagination.hasUnloadedRows(loadedRowCount: rows.rows.count) && !serverSearchableColumns(in: rows).isEmpty } - /// `hasMoreRows` is the query-tab truncation flag and is never set for a table tab, which pages - /// through `currentPage`/`totalPages` instead. Reading only that flag made every table tab claim - /// its page was the whole table, which is the exact answer this feature exists to avoid. - func scope(for tab: QueryTab) -> FindScope { - let loadedRowCount = parent.tabSessionRegistry.tableRows(for: tab.id).rows.count - let hasUnloadedRows = tab.pagination.hasMoreRows - || tab.pagination.canGoToNextPage(loadedRowCount: loadedRowCount) - return hasUnloadedRows ? .pageOfMore : .allRowsLoaded + // Its own filter rows: addFilter would apply the user's default-column setting. + func searchAllRows(term: String, tabID: UUID) { + guard parent.tabManager.selectedTabId == tabID else { return } + let columns = serverSearchableColumns(in: parent.tabSessionRegistry.tableRows(for: tabID)) + guard !columns.isEmpty else { return } + parent.filterCoordinator.applyCrossColumnSearch(term: term, columns: columns) } - private func mutateSelectedTabFindState(_ mutate: (inout TabFindState) -> Void) { - guard let index = parent.tabManager.selectedTabIndex else { return } - var newState = parent.tabManager.tabs[index].findState - mutate(&newState) - parent.tabManager.mutate(at: index) { $0.findState = newState } + func serverSearchableColumns(in rows: TableRows) -> [String] { + let readsAnyColumn = parent.connection.type.patternFiltersReadAnyColumn + return rows.columns.indices.compactMap { index in + let columnType = index < rows.columnTypes.count ? rows.columnTypes[index] : nil + guard FindMatcher.isServerSearchable(columnType, patternReadsAnyColumn: readsAnyColumn) else { return nil } + return rows.columns[index] + } } } diff --git a/apple/TablePro/Core/Coordinators/FindMatcher.swift b/apple/TablePro/Core/Coordinators/FindMatcher.swift index 5c9cfae430..5932ba42ed 100644 --- a/apple/TablePro/Core/Coordinators/FindMatcher.swift +++ b/apple/TablePro/Core/Coordinators/FindMatcher.swift @@ -6,9 +6,7 @@ import Foundation enum FindMatcher { - /// A binary column is searchable once a display format has turned its bytes into characters. - /// Left as hex it is not, which is why the exclusion was unconditional while hex was the only - /// thing a binary column could show. + // A binary column is searchable once a display format has turned its bytes into characters; hex is not. static func isSearchable(_ columnType: ColumnType?, displayFormat: ValueDisplayFormat? = nil) -> Bool { switch columnType { case .blob: displayFormat?.rendersBinaryAsText == true @@ -17,11 +15,8 @@ enum FindMatcher { } } - /// Columns a `LIKE` comparison can be built against without a cast. Postgres has no implicit - /// integer to text conversion, so `id ILIKE '%abc%'` fails the whole query rather than matching - /// nothing. Client-side matching is looser on purpose: it compares the text already on screen. - /// An engine whose pattern filters read a scalar column by its shown text searches those too. A nested value's - /// JSON is text only on screen. + // Postgres has no implicit integer to text cast, so `id ILIKE '%abc%'` fails the whole query instead of matching + // nothing. An engine whose patterns read a scalar column by its shown text searches those too; JSON is text only on screen. static func isServerSearchable(_ columnType: ColumnType?, patternReadsAnyColumn: Bool = false) -> Bool { if patternReadsAnyColumn, columnType?.isJsonType != true { return isSearchable(columnType) } return switch columnType { @@ -29,34 +24,4 @@ enum FindMatcher { default: false } } - - static func matches( - term: String, - displayRowCount: Int, - columnCount: Int, - isColumnSearchable: (Int) -> Bool, - cellText: (Int, Int) -> String? - ) -> [FindMatch] { - let needle = term.trimmingCharacters(in: .whitespacesAndNewlines) - guard !needle.isEmpty, displayRowCount > 0, columnCount > 0 else { return [] } - - let searchableColumns = (0 ..< columnCount).filter(isColumnSearchable) - guard !searchableColumns.isEmpty else { return [] } - - var found: [FindMatch] = [] - for displayRow in 0 ..< displayRowCount { - for column in searchableColumns { - guard let text = cellText(displayRow, column), !text.isEmpty else { continue } - guard text.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil else { continue } - found.append(FindMatch(displayRow: displayRow, columnIndex: column)) - } - } - return found - } - - static func nearestMatchIndex(to displayRow: Int, in matches: [FindMatch]) -> Int? { - guard !matches.isEmpty else { return nil } - if let forward = matches.firstIndex(where: { $0.displayRow >= displayRow }) { return forward } - return 0 - } } diff --git a/apple/TablePro/Core/Coordinators/PaginationCoordinator.swift b/apple/TablePro/Core/Coordinators/PaginationCoordinator.swift index 00e7e4e257..820863abb4 100644 --- a/apple/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/apple/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -51,7 +51,7 @@ final class PaginationCoordinator: ObservableObject { paginateIfPossible { $0.updatePageSize(pageSize) } } - /// Every page move asks this, because an engine that cannot skip rows has only the first page. + // An engine that cannot skip rows has only the first page. private var canSeek: Bool { parent.paginationCapability.allowsSeeking } @@ -64,12 +64,7 @@ final class PaginationCoordinator: ObservableObject { paginateIfPossible(where: condition, mutate: mutate) } - /// Only ever sized from a real count. - /// - /// It used to accept the driver's estimate, so a table MySQL guessed at 420,000 rows loaded - /// `LIMIT 420000` and silently dropped the rest while the bar reported the page as complete. - /// `Count Exactly` in the status bar is the route to an exact total, and it sits next to the - /// estimate that makes this unavailable. + // Only sized from a real count: the driver's estimate (MySQL guessed 420,000) loaded a LIMIT that silently dropped rows. func showAllRows() { guard canSeek, let (tab, _) = parent.tabManager.selectedTabAndIndex, @@ -151,8 +146,7 @@ final class PaginationCoordinator: ObservableObject { // MARK: - Cancel Current Query - /// Stop and `Cmd+.` act on the tab the user is looking at. A window-wide stop is what let one - /// tab's Stop roll back the batch another tab was running. + // Acts on the visible tab: a window-wide stop let one tab's Stop roll back another tab's batch. func cancelCurrentQuery() { guard let tabId = parent.tabManager.selectedTabId else { return } parent.stopExecution(for: tabId) @@ -171,8 +165,7 @@ final class PaginationCoordinator: ObservableObject { let schemaName = tab.tableContext.schemaName let filters = tab.filterState.hasAppliedFilters ? tab.filterState.appliedFilters : [] let logicMode = tab.filterState.filterLogicMode - /// A browse narrowed by the plugin's own search runs that search instead of the table - /// filters, so the count has to read the same search or it counts the whole table. + // A plugin search replaces the table filters in a narrowed browse, so the count must read it or it counts the whole table. let browseFilters = parent.filterCoordinator.activeBrowseSearch(for: tab.filterState)?.pluginQueryFilters let isNonSQL = PluginManager.shared.editorLanguage(for: parent.connection.type) != .sql let queryColumns = parent.queryColumns(for: tab) @@ -181,9 +174,7 @@ final class PaginationCoordinator: ObservableObject { columns: queryColumns.columns, columnTypes: queryColumns.columnTypes ) - /// Taking the task slot supersedes whatever automatic count held it, so this claims that - /// count's flag too. Leaving it set would strand it: the superseded task's completion finds - /// the token changed and correctly declines to clear a successor's state. + // Claims the superseded automatic count's flag too: its task finds the token changed and won't clear a successor's state. parent.tabManager.mutate(at: index) { tab in tab.pagination.isCountingExact = true tab.pagination.isCountPending = false @@ -202,13 +193,9 @@ final class PaginationCoordinator: ObservableObject { countSQL: countSQL ) - /// The flag says a count is running, so it has to clear on every way out. Returning - /// early on cancellation left it set, and a page turn cancels this task, so turning a - /// page during a long COUNT(*) used to leave a spinner that never stopped and a - /// `Count Exactly` that never came back for that tab. + // Clears on every exit: returning early on cancel left a page turn during COUNT(*) with an endless spinner and no Count Exactly. let isCurrent = !Task.isCancelled && parent.tabExecution.isSameContent(contentEpoch, for: tabId) - /// Cancelling through `Cmd+.` clears the flag and lets a second count start, so a late - /// first task would otherwise stop the second one's spinner while its query still runs. + // `Cmd+.` clears the flag and allows a second count, so a late first task must not stop the second one's spinner. let ownsIndicator = parent.releaseExactCount(for: tabId, token: token) parent.clearRowCountTask(for: tabId, token: token) parent.tabManager.mutate(tabId: tabId) { tab in @@ -240,8 +227,7 @@ final class PaginationCoordinator: ObservableObject { } } - /// The user asked for this count, so a failure is shown on the tab rather than dropped: an engine whose count - /// only its driver can run, such as a throttled DynamoDB scan, has no other answer to fall back on. + // User-requested, so a failure shows on the tab: an engine only its driver can count (throttled DynamoDB scan) has no fallback. private static func exactRowCount( scope: DatabaseScope, tableName: String, @@ -268,8 +254,7 @@ final class PaginationCoordinator: ObservableObject { // MARK: - Fetch All Rows - /// The scope is read before the confirmation alert, so a database change made while - /// the alert is open cannot send the tab's own query somewhere else. + // The scope is read before the alert, so a database change while it is open can't redirect the tab's query. func fetchAllRows() { guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !tab.pagination.isLoadingMore, @@ -308,12 +293,7 @@ final class PaginationCoordinator: ObservableObject { } } - /// Only the driver work runs inside the lease. Applying the rows to the tab stays - /// outside it, because the connection's driver gate is not reentrant. - /// - /// The rows belong to the result the fetch was started on. A result switch leaves the content - /// epoch alone, so the fetch is fenced on the result set as well, or the full row set lands on - /// whichever result is showing when it arrives, normalized to that result's column count. + // Only driver work runs in the lease (the gate isn't reentrant). Fenced on the result set too: a result switch keeps the epoch. internal func performFetchAll(tabId: UUID, baseQuery: String, scope: DatabaseScope) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } guard !parent.tabManager.tabs[idx].pagination.isLoadingMore else { return } @@ -324,10 +304,7 @@ final class PaginationCoordinator: ObservableObject { parent.tabManager.mutate(at: idx) { $0.pagination.isLoadingMore = true } - /// Fetch All extends the result already on screen instead of replacing it, so it validates - /// against the tab's content epoch and cannot claim the tab: claiming mints a new epoch and - /// would discard its own rows. It registers as unclaimed work instead, which is what keeps - /// the titlebar reporting it, and releases that on every exit including cancellation. + // Can't claim the tab: claiming mints a new epoch and drops its own rows. Unclaimed work keeps the titlebar reporting it. let workToken = parent.tabExecution.beginUnclaimedWork(for: tabId) let owner = TabQueryTaskOwner.unclaimedWork(tabId: tabId, token: workToken) let lease = DriverLeaseOwner() @@ -335,9 +312,7 @@ final class PaginationCoordinator: ObservableObject { let lengthColumns = isTableTab ? parent.unloadedValueLengthColumns(for: parent.tabManager.tabs[idx]) : [] let startedAt = ContinuousClock.Instant.now - /// Both releases belong to the whole task rather than to its exits: the cancelled path used - /// to clear the loading flag and bare return, leaving a finished fetch installed under the - /// tab's id, and the next `installQueryTask` read it as a live displaced entry and ended it. + // Both releases cover the whole task: a cancelled bare return left a finished fetch installed, which `installQueryTask` then ended. let fetchAllTask = Task { [weak self, parent] in defer { parent.tabExecution.endUnclaimedWork(workToken, for: tabId) @@ -364,12 +339,7 @@ final class PaginationCoordinator: ObservableObject { progressLog.info("[fetchAll] rows=\(result.rows.count) fetchTime=\(String(format: "%.3f", fetchTime))s") guard !Task.isCancelled else { - /// Every other exit from this function clears the flag, and this one used to - /// bare return, so a fetch-all cancelled after its rows had already arrived - /// left the tab showing "Loading…" for good with Fetch All hidden, healed - /// only by re-running the query. Deterministic on any driver whose - /// `cancelQuery()` is the PluginKit no-op default, because the fetch always - /// runs to completion there and returns straight into this guard. + // Clears the flag like every exit: a bare return stuck the tab on Loading after a late cancel (no-op `cancelQuery()` drivers). await MainActor.run { [weak self] in self?.parent.tabManager.mutate(tabId: tabId) { tab in tab.pagination.isLoadingMore = false @@ -388,7 +358,7 @@ final class PaginationCoordinator: ObservableObject { } guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let replaceDelta = parent.mutateActiveTableRows(for: tabId) { rows in + parent.mutateActiveTableRows(for: tabId) { rows in rows.replace( rows: result.rows, rowLocators: isTableTab ? result.rowLocators : nil, @@ -402,7 +372,6 @@ final class PaginationCoordinator: ObservableObject { tab.pagination.resetLoadMore() tab.display.activeResultSet?.isTruncated = false } - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(replaceDelta) parent.toolbarState.recordQueryTiming(result.resolvedTiming, for: tabId) let totalTime = CFAbsoluteTimeGetCurrent() - start diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index fa85298a11..241ff38ad0 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -30,7 +30,6 @@ extension QueryExecutionCoordinator { return cap } - /// The text to send for a tab's read and the cap the app keeps on its result. func resolveStatement(sql: String, tabType: TabType, bypassLimit: Bool = false) -> LeadingRowsStatement { LeadingRowsStatement.resolve( sql, @@ -51,8 +50,7 @@ extension QueryExecutionCoordinator { ) } - /// History belongs to the database the tab actually ran on, not to wherever the - /// sidebar happens to point when the entry is written. + // History belongs to the database the tab ran on, not wherever the sidebar points when the entry is written. func historyDatabaseName(tabId: UUID) -> String { guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { return parent.browseDatabaseName @@ -65,16 +63,13 @@ extension QueryExecutionCoordinator { return parent.scope(for: tab)?.schema } - /// A table tab's SQL is generated by the app for browsing, paging, sorting and filtering. - /// It is recorded so the user can audit what was sent, but it is not something they authored. + // A table tab's SQL is app-generated: recorded so the user can audit it, but not something they authored. func historySource(tabId: UUID) -> QueryHistorySource { guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { return .editor } return tab.tabType == .table ? .tableBrowse : .editor } - /// Never after the table's definition changed: the keys, defaults, generated columns and row - /// match policy the tab holds describe the table before the change, and reusing them builds the - /// next edit against the old definition. + // Never after a definition change: the cached keys, defaults, generated columns and match policy describe the old table. func isMetadataCached(tabId: UUID, tableName: String) -> Bool { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }), !parent.tabSessionRegistry.needsDefinition(tabId) @@ -100,12 +95,7 @@ extension QueryExecutionCoordinator { return cached } - /// Deliberately does not set `foreignKeysFetched`. The prefetch answers which columns carry the - /// arrow, which is all the first paint needs; defaults, primary keys, nullability and comments - /// still come from the table's own metadata fetch, and claiming they had arrived would let - /// `isMetadataCached` skip it. - /// - /// The table load started this scope's read beside its rows; a miss here starts it for a load that did not. + // Leaves `foreignKeysFetched` unset: the prefetch covers only FK arrows, and claiming more lets `isMetadataCached` skip it. private func prefetchedForeignKeys(tabIndex: Int, tableName: String) -> [String: ForeignKeyInfo]? { guard tabIndex < parent.tabManager.tabs.count, let scope = parent.scope(for: parent.tabManager.tabs[tabIndex]) @@ -131,13 +121,7 @@ extension QueryExecutionCoordinator { var foreignKeysFetched = false } - /// A rerun answered from cache carries no metadata of its own, so it takes the snapshot the - /// caller captured when it made that cache decision. That includes the non-writable set, which - /// `configureForTable` clears on every execution and only a schema fetch refills. - /// - /// The snapshot is never re-read from the session here. Reading it at this point reads whichever - /// result is active by then, and selecting a pinned result while the rerun was in flight made - /// the rerun adopt that other result's identity and non-writable sets. + // The caller's snapshot, never re-read: selecting a pinned result mid-rerun gave the rerun that result's identity. private func resolveDisplayMetadata( metadata: ParsedSchemaMetadata?, columns: [String], @@ -174,9 +158,7 @@ extension QueryExecutionCoordinator { return resolved } - /// The keys a committed result is edited by. Keys the tab held for the same table carry over - /// while its definition is unchanged, until phase 2 reports them. After a definition change they - /// describe the table as it was, so they are never carried into the new result. + // Same-table keys carry over until phase 2 reports, but never across a definition change: they describe the old table. static func resolvedPrimaryKeys( reported: [String]?, engineDefault: String?, @@ -276,9 +258,7 @@ extension QueryExecutionCoordinator { let definitionChanged = parent.tabSessionRegistry.needsDefinition(existingTabId) parent.flushBufferToActiveResult(tabId: existingTabId, pinnedOnly: true) parent.setActiveTableRows(newTableRows, for: existingTabId, viewport: viewport, source: source) - /// A count that started before the change can land after it, while the tab is out of sight, - /// and put the old total back. Kept, a total above the automatic-count threshold stops the - /// count this read launches, so paging stays bounded by the table as it was. + // A pre-change count can land late with the old total; a kept total over the auto-count threshold stops this read's count. let answeredRowsChange = parent.tabSessionRegistry.recordRead(read, for: existingTabId) let usesBrowseSearch = parent.filterCoordinator.usesBrowseSearch @@ -468,9 +448,7 @@ extension QueryExecutionCoordinator { let contentEpoch = parent.tabExecution.contentEpoch(for: tabId) let resultSetId = parent.tabManager.tabs.first { $0.id == tabId }?.display.activeResultSetId Task(priority: .utility) { [weak self, parent] in - /// The caller marked the total pending when it committed to this phase. Every way out of - /// here that does not reach `resolveRowCount` has to take that back, or the tab reports a - /// count that is never coming and never offers `Count Exactly` again. + // The caller marked the total pending; every exit that skips `resolveRowCount` must release it, or Count Exactly never returns. guard let self else { parent.releaseCountPending(for: tabId) return @@ -490,8 +468,7 @@ extension QueryExecutionCoordinator { if let schema { applySchemaMetadata(schema, tabId: tabId, tableName: tableName, resultSetId: resultSetId) } - /// A retarget or a re-execution that overtook this one owns the tab's state now, and - /// it raised its own pending mark. Clearing here would release the successor's. + // A retarget or newer run owns the tab now with its own pending mark; clearing here would release the successor's. guard parent.tabExecution.isSameContent(contentEpoch, for: tabId) else { return } resolveRowCount( tableName: tableName, @@ -516,11 +493,7 @@ extension QueryExecutionCoordinator { } } - /// Stamps the active result with the table its rows came from. - /// - /// `QueryTab.tableContext` always describes the newest execution, so a result the user later - /// clicks back to has to carry its own identity or an edit made on it is written against - /// whatever ran last (#2243). + // `QueryTab.tableContext` tracks the newest run, so each result carries its own origin or edits hit whatever ran last (#2243). func captureOrigin( tabIndex: Int, tabId: UUID, @@ -542,10 +515,7 @@ extension QueryExecutionCoordinator { ) } - /// Phase-2 metadata belongs to the result its fetch was launched for, not to whatever the tab - /// happens to be showing when it lands. A query tab can hold several results and the user can - /// switch between them while the schema round trips, so keying on the tab's current table name - /// paints one table's foreign keys, defaults and primary keys onto another table's rows. + // Keyed on the result the fetch was for: keying on the tab's table name painted one table's FKs and keys onto another's rows. private func resultStillActive(_ tabId: UUID, _ resultSetId: UUID?) -> Bool { parent.tabManager.tabs.contains { $0.id == tabId && $0.display.activeResultSetId == resultSetId } } @@ -564,9 +534,7 @@ extension QueryExecutionCoordinator { ) { let parsed = parseSchemaMetadata(schema) guard resultStillActive(tabId, resultSetId) else { - /// The result this was fetched for is still there, the user is just looking at another - /// one. Dropping the metadata left it with no account of which columns the server owns, - /// and nothing re-fetches on the way back, so the result stayed that way for good. + // Dropping it left the result with no account of server-owned columns, and nothing re-fetches on the way back. applyPhase2MetadataToInactiveResult(parsed: parsed, tabId: tabId, resultSetId: resultSetId) helpersLogger.info("[fk] phase2 applied to an inactive result table=\(tableName, privacy: .private(mask: .hash))") return @@ -621,9 +589,6 @@ extension QueryExecutionCoordinator { return .columnsReplaced } parent.tabManager.mutate(tabId: tabId) { $0.metadataVersion += 1 } - if isActiveTab(tabId) { - parent.dataTabDelegate?.tableViewCoordinator?.refreshCellPresentations() - } } private func applyPhase2Metadata(parsed: ParsedSchemaMetadata, tabId: UUID) { @@ -666,9 +631,10 @@ extension QueryExecutionCoordinator { parent.changeManager.setRowMatchPolicy(parsed.rowMatchPolicy) } + // The metadata delta rebuilt the model's columns; the foreign key arrows need the rows redrawn. let refreshed = isActiveTab(tabId) if refreshed { - parent.dataTabDelegate?.tableViewCoordinator?.refreshCellPresentations() + parent.existingGridModel(forTab: tabId)?.repaintAllRows() } helpersLogger.info( "[fk] phase2 applied tab=\(tabId, privacy: .public) fks=\(parsed.columnForeignKeys?.count ?? -1) defaults=\(parsed.columnDefaults.count) activeTabRefreshed=\(refreshed)" @@ -687,9 +653,7 @@ extension QueryExecutionCoordinator { ) } - /// Validates against the tab's content identity, not the execution claim. The claim is settled - /// the moment phase 1 is applied, which happens before phase 2 is even dispatched, so a claim - /// check here is false on every run and the count never lands. + // Checks content identity, not the execution claim: the claim settles before phase 2 dispatches, so the count never landed. func resolveRowCount( tableName: String, tabId: UUID, @@ -742,9 +706,7 @@ extension QueryExecutionCoordinator { ) await MainActor.run { - /// The flag says a count is running, so it has to clear on every way out, including - /// the ones that apply nothing. Only the task that still owns the slot may clear it, - /// or a superseded task would release the successor that replaced it. + // Clears the running flag on every exit, but only for the slot's owner, or a superseded task releases its successor. let ownsCount = parent.clearRowCountTask(for: tabId, token: token) let isCurrent = parent.tabExecution.isSameContent(contentEpoch, for: tabId) parent.tabManager.mutate(tabId: tabId) { tab in @@ -759,8 +721,7 @@ extension QueryExecutionCoordinator { parent.setRowCountTask(task, token: token, for: tabId) } - /// Runs the plan the caller resolved. Split out so `resolveRowCount` has one exit that clears - /// `isCountPending`, rather than an early return per plan that leaves it set. + // Split out so `resolveRowCount` has one exit that clears `isCountPending`. private static func rowCountOutcome( plan: RowCountPlan, count exactCount: ExactCountInput?, @@ -816,13 +777,7 @@ extension QueryExecutionCoordinator { } } - /// `countsAutomatically` is `PluginManager.countsRowsAutomatically(for:)`: an engine that cannot skip rows, or - /// whose count is a billed scan, is only counted when the user asks. - /// - /// `browseSearch` is the search the browse runs in place of the table filters, such as a Redis key - /// pattern. The table's estimate measures the whole database, not the keys that search lists, so it is - /// never the answer. A search change retires the total, which is why nothing is cleared here: a page - /// turn after `Count Exactly` keeps that count. + // A browse search never takes the table estimate (it covers the whole database); not clearing keeps Count Exactly across pages. static func rowCountPlan( isNonSQL: Bool, filterState: TabFilterState, @@ -849,8 +804,7 @@ extension QueryExecutionCoordinator { return exceedsThreshold ? .skip : .exactCount(filtered: false) } - /// Reports a failure that belongs to this tab. Ownership of the window's task handle and the - /// spinner is settled by the caller before this runs, so nothing here touches them. + // The caller settles the window's task handle and spinner before this runs, so nothing here touches them. func handleQueryExecutionError( _ error: Error, sql: String, @@ -887,9 +841,7 @@ extension QueryExecutionCoordinator { } extension QueryExecutionCoordinator { - /// Shows a failure on the tab it belongs to without recording a statement that never ran, so a - /// step that fails before the tab's query, such as the database switch in front of it, leaves - /// no history row and nothing for Fix with AI to rewrite. + // Records no history: a step failing before the tab's query (such as the database switch) leaves nothing for Fix with AI. func presentTabFailure( _ message: String, announcing diagnosis: String, @@ -901,14 +853,12 @@ extension QueryExecutionCoordinator { tab.pagination.isLoading = false recordingExecution(&tab) - // The banner lives at the top of the results pane, so a collapsed pane hides the only - // thing telling the user their query failed. Every success path opens it the same way. + // The error banner sits at the top of the results pane, so a collapsed pane would hide the only sign the query failed. if tab.display.isResultsCollapsed { tab.display.isResultsCollapsed = false } } - // The toolbar mirrors the selected tab, so a failure on a tab in the background describes - // itself on its own tab and leaves the window chrome to whatever is actually on screen. + // The toolbar mirrors the selected tab, so a background tab's failure leaves the window chrome alone. if parent.tabManager.selectedTabId == tabId { parent.toolbarState.isResultsCollapsed = false parent.toolbarState.clearQueryTiming(forTab: tabId) @@ -936,13 +886,7 @@ enum RowCountOutcome: Equatable { case count(Int, isApproximate: Bool) case clear - /// An estimate of zero or less means "unknown", never "this table is empty": an un-analyzed - /// table reports 0 or -1 while holding millions of rows. An exact zero is trustworthy. - /// - /// Returns nil for "no answer", which is not the same as an answer of nothing. Phase 1 has - /// usually already put an estimate on screen by the time this lands, so an unusable estimate - /// must leave that alone rather than blank it. Only `.clear` means the count is genuinely - /// gone, which is what a filter change asks for. + // Nil is no answer, not zero: an estimate <= 0 is unknown (un-analyzed tables report 0 or -1), so phase 1's estimate stays. var appliedTotal: (total: Int?, isApproximate: Bool)? { switch self { case .clear: diff --git a/apple/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift b/apple/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift index e20e625615..8d6ce18209 100644 --- a/apple/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift +++ b/apple/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift @@ -10,9 +10,7 @@ import TableProPluginKit extension RowEditingCoordinator { // MARK: - Sidebar Transaction - /// Edits made in the row inspector belong to the selected tab, so they run on that - /// tab's database. The scope is read before the authorization prompt, which awaits a - /// sheet and Touch ID and gives the selection time to move somewhere else. + // Scope is read before the authorization prompt (sheet and Touch ID), which gives the selection time to move. func executeSidebarChanges(steps: [DataWriteStep]) async throws { guard let scope = parent.selectedTabScope else { throw DatabaseError.notConnected @@ -68,71 +66,31 @@ extension RowEditingCoordinator { parent.resumeDeferredTableRefresh() } - /// Puts the loaded rows back the way the server last reported them. - /// - /// An edit is written straight into the tab's `TableRows` as well as being recorded, so - /// clearing the change records alone leaves the edited values on screen with nothing tracking - /// them, and the next edit captures an unsaved value as its baseline. A discard that re-queries - /// replaces the buffer wholesale and needs none of this; one that does not has to undo it here. + // Edits are written into the loaded rows too; a discard that does not re-query must undo them here or they stay untracked. func restoreRowBufferToOriginals() { - var deltas: [Delta] = [] - if let (tab, _) = parent.tabManager.selectedTabAndIndex, - let tableRows = parent.tabSessionRegistry.existingTableRows(for: tab.id) { - let tabId = tab.id - let insertedIDs = parent.changeManager.insertedRowIDs - var restoredCells: [(rowID: RowID, columnIndex: Int)] = [] - var absentCells: Set = [] - let edits = parent.changeManager.getOriginalValues().compactMap { original in - tableRows.index(of: original.rowID).map { storageRow -> (row: Int, column: Int, value: PluginCellValue) in - restoredCells.append((rowID: original.rowID, columnIndex: original.columnIndex)) - if original.isAbsent { - absentCells.insert(CellPosition(row: storageRow, column: original.columnIndex)) - } - return (row: storageRow, column: original.columnIndex, value: original.value) - } - } - if !edits.isEmpty { - let editDelta = parent.mutateActiveTableRows(for: tabId) { rows in - rows.editMany(edits, absentCells: absentCells) - } - /// `editMany` names the rows it changed by their position in storage, and the grid - /// reads a delta's rows as display positions. - if editDelta != .none { - deltas.append(restoredCellsDelta(restoredCells, in: tableRows)) - } - } - if !insertedIDs.isEmpty { - let removeDelta = parent.mutateActiveTableRows(for: tabId) { rows in - rows.remove(rowIDs: insertedIDs) - } - if removeDelta != .none { - deltas.append(removeDelta) + guard let (tab, _) = parent.tabManager.selectedTabAndIndex, + let tableRows = parent.tabSessionRegistry.existingTableRows(for: tab.id) else { + parent.gridDisplayRevision &+= 1 + return + } + let tabId = tab.id + let insertedIDs = parent.changeManager.insertedRowIDs + var absentCells: Set = [] + let edits = parent.changeManager.getOriginalValues().compactMap { original in + tableRows.index(of: original.rowID).map { storageRow -> (row: Int, column: Int, value: PluginCellValue) in + if original.isAbsent { + absentCells.insert(CellPosition(row: storageRow, column: original.columnIndex)) } + return (row: storageRow, column: original.columnIndex, value: original.value) } } - - for delta in deltas { - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(delta) + if !edits.isEmpty { + parent.mutateActiveTableRows(for: tabId) { rows in rows.editMany(edits, absentCells: absentCells) } } - /// The row inspector reads its fields from the buffer this just rewrote, and nothing else - /// in `InspectorTrigger` moves on a discard. - parent.gridDisplayRevision &+= 1 - } - - private func restoredCellsDelta( - _ cells: [(rowID: RowID, columnIndex: Int)], - in tableRows: TableRows - ) -> Delta { - let displayIDs = parent.activeGridDisplayIDs - var positions: Set = [] - for cell in cells { - guard let displayRow = DisplayRowMapping.displayIndex( - forRowID: cell.rowID, - displayIDs: displayIDs, - in: tableRows - ) else { continue } - positions.insert(CellPosition(row: displayRow, column: cell.columnIndex)) + if !insertedIDs.isEmpty { + parent.mutateActiveTableRows(for: tabId) { rows in rows.remove(rowIDs: insertedIDs) } } - return positions.isEmpty ? .none : .cellsChanged(positions) + // The inspector reads its fields from the rows this rewrote, and nothing else it watches moves on a discard. + parent.gridDisplayRevision &+= 1 } } diff --git a/apple/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/apple/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 1924913fec..6d1197fa6b 100644 --- a/apple/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/apple/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -11,11 +11,7 @@ import TableProPluginKit final class RowEditingCoordinator: ObservableObject { unowned let parent: MainContentCoordinator - /// A save is between assembling its statements and hearing back. - /// - /// Nothing clears the pending changes until the write returns, so a second Cmd+S inside the - /// round trip finds them still there, assembles the same statements again and commits them - /// twice. Over a slow link that is easy to do by accident. + // Nothing clears the pending changes until the write returns, so a second Cmd+S in the round trip would commit twice. private(set) var isSaveInFlight = false init(parent: MainContentCoordinator) { @@ -32,9 +28,7 @@ final class RowEditingCoordinator: ObservableObject { isSaveInFlight = false } - /// A row command selects the row it just made so the grid can highlight it and scroll to it. - /// No other mode has a grid to point at, and the JSON view reads the same selection as "show - /// only these rows", so writing it there collapses the document to the one new row. + // A row command selects the row it made. The JSON view reads the selection as "show only these rows". private var selectionPointsTheGrid: Bool { parent.tabManager.selectedTab?.display.resultsViewMode == .data } @@ -47,12 +41,11 @@ final class RowEditingCoordinator: ObservableObject { tab.tableContext.tableName != nil else { return } let tabId = tab.id - /// A new row is pre-filled from the schema's account of which columns the server owns, so - /// staging one before that account exists writes NULL into an identity column. + // Pre-filled from the schema's account of which columns the server owns; without it an identity gets NULL. let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) guard tableRows.hasAuthoritativeSchema else { return } - parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + parent.commitActiveGridEdit() let uuidKeys = uuidKeyForms(in: tableRows, tab: tab) var addResult: RowOperationsManager.AddNewRowResult? @@ -65,17 +58,18 @@ final class RowEditingCoordinator: ObservableObject { guard let result = addResult else { return } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) selectAndEditInsertedRow(result.rowID, tabId: tabId, skipping: result.prefilledColumns) } - func deleteSelectedRows(indices: Set) { + func deleteRows(_ rowIDs: [RowID]) { guard parent.canEditActiveResult, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, - !indices.isEmpty else { return } + !rowIDs.isEmpty else { return } let tabId = tab.id - let displayIDs = parent.activeGridDisplayIDs + let model = parent.gridModel(forTab: tabId) + let displayIndices = Set(rowIDs.compactMap { model.displayIndex(of: $0) }) + let displayCount = model.rowCount var deleteResult = RowOperationsManager.DeleteRowsResult( nextRowToSelect: -1, @@ -83,9 +77,10 @@ final class RowEditingCoordinator: ObservableObject { delta: .none ) parent.mutateActiveTableRows(for: tabId) { rows in - let result = parent.rowOperationsManager.deleteSelectedRows( - selectedIndices: indices, - displayIDs: displayIDs, + let result = parent.rowOperationsManager.deleteRows( + rowIDs, + selectedDisplayIndices: displayIndices, + displayCount: displayCount, tableRows: &rows ) deleteResult = result @@ -95,24 +90,27 @@ final class RowEditingCoordinator: ObservableObject { guard deleteResult.stagedRowCount > 0 else { return } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } + model.repaintRows(rowIDs) - if !deleteResult.physicallyRemovedIndices.isEmpty { - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(deleteResult.delta) + guard selectionPointsTheGrid else { return } + if let next = model.rowID(at: deleteResult.nextRowToSelect) { + model.selectRows([next]) } else { - parent.dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() + model.clearSelection() } + } - guard selectionPointsTheGrid else { return } - let displayCount = parent.activeGridDisplayIDs?.count - ?? parent.tabSessionRegistry.tableRows(for: tabId).count - if deleteResult.nextRowToSelect >= 0 && deleteResult.nextRowToSelect < displayCount { - parent.selectionState.indices = [deleteResult.nextRowToSelect] - } else { - parent.selectionState.indices.removeAll() + func undoRowDeletions(_ rowIDs: [RowID]) { + guard parent.canEditActiveResult, let tabId = parent.tabManager.selectedTabId else { return } + let deleted = rowIDs.filter { parent.changeManager.isRowDeleted($0) } + guard !deleted.isEmpty else { return } + for rowID in deleted { + parent.changeManager.undoRowDeletion(rowID: rowID) } + parent.existingGridModel(forTab: tabId)?.repaintRows(deleted) } - func duplicateSelectedRow(index: Int) { + func duplicateRow(_ rowID: RowID) { guard parent.canEditActiveResult, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, tab.tableContext.tableName != nil else { return } @@ -120,14 +118,12 @@ final class RowEditingCoordinator: ObservableObject { let tabId = tab.id let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) guard tableRows.hasAuthoritativeSchema, - let storageIndex = DisplayRowMapping.rowIndex( - forDisplay: index, displayIDs: parent.activeGridDisplayIDs, in: tableRows - ) else { return } - guard parent.unloadedValuesAreLoaded(displayRows: [index], retry: { [weak self] in - self?.duplicateSelectedRow(index: index) + let storageIndex = tableRows.index(of: rowID) else { return } + guard parent.unloadedValuesAreLoaded(rowIDs: [rowID], retry: { [weak self] in + self?.duplicateRow(rowID) }) else { return } - parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + parent.commitActiveGridEdit() let uuidKeys = uuidKeyForms(in: tableRows, tab: tab) var dupResult: RowOperationsManager.AddNewRowResult? @@ -144,18 +140,33 @@ final class RowEditingCoordinator: ObservableObject { guard let result = dupResult else { return } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) selectAndEditInsertedRow(result.rowID, tabId: tabId, skipping: result.prefilledColumns) } - /// The caret skips a key the row was given a fresh UUID for, so the first keystroke does not replace it. + // The caret skips a key the row was given a fresh UUID for, so the first keystroke does not replace it. private func selectAndEditInsertedRow(_ rowID: RowID, tabId: UUID, skipping prefilled: Set) { guard selectionPointsTheGrid, - let displayIndex = displayIndex(of: rowID, tabId: tabId) else { return } - parent.selectionState.indices = [displayIndex] - parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn( - displayRow: displayIndex, skipping: prefilled - ) + let model = parent.existingGridModel(forTab: tabId), + let displayRow = model.displayIndex(of: rowID) else { return } + let row = model.rows.row(withID: rowID) + let typable = model.columns.columns.filter { model.isColumnWritable($0) && Self.takesTypedText($0, in: row) } + // Column 0 is where the row starts, not always where it can be typed into: the server may own it. + guard let column = typable.first(where: { !prefilled.contains($0.dataIndex) }) ?? typable.first ?? model.columns.columns.first, + let displayColumn = model.columns.displayPosition(of: column.id) else { + model.selectRows([rowID]) + model.reveal(rowID: rowID) + return + } + model.selectCell(GridCoord(row: displayRow, displayColumn: displayColumn)) + model.reveal(rowID: rowID, columnID: column.id) + if typable.contains(where: { $0.id == column.id }) { + model.beginEditing(rowID: rowID, columnID: column.id) + } + } + + // The grid's text editor refuses bytes, so a BLOB column would take the caret and open nothing. + private static func takesTypedText(_ column: DataGridColumn, in row: Row?) -> Bool { + column.type?.isBlobType != true && row?[column.dataIndex].asBytes == nil } private func uuidKeyForms(in tableRows: TableRows, tab: QueryTab) -> [Int: UUIDCellForm] { @@ -170,9 +181,7 @@ final class RowEditingCoordinator: ObservableObject { ) } - /// The key columns a new, duplicated or pasted row gets a fresh UUID in: a UUID key the server does not fill, - /// whose `__DEFAULT__` would leave the key out of the INSERT. Asked of `SetValueOptions`, so a foreign key, a - /// read-only column or a writer that cannot store UUID text gets none, exactly as Generate UUID is hidden. + // A UUID key the server does not fill; asked of SetValueOptions, so it is skipped wherever Generate UUID is hidden. static func uuidKeyForms( in tableRows: TableRows, primaryKeyColumns: [String], @@ -214,36 +223,12 @@ final class RowEditingCoordinator: ObservableObject { return nil } - private func displayIndex(of rowID: RowID, tabId: UUID) -> Int? { - DisplayRowMapping.displayIndex( - forRowID: rowID, - displayIDs: parent.activeGridDisplayIDs, - in: parent.tabSessionRegistry.tableRows(for: tabId) - ) - } - - private func displayIndices(of rowIDs: Set, tabId: UUID) -> Set { - guard let displayIDs = parent.activeGridDisplayIDs else { - let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) - return Set(rowIDs.compactMap { tableRows.index(of: $0) }) - } - return Set(displayIDs.indices.filter { rowIDs.contains(displayIDs[$0]) }) - } - - /// Ends the typed run an inspector field was building, so the next thing to register an undo - /// step lands after it rather than inside it. + // The next undo step then lands after the typed run, not inside it. func endInspectorEditRun() { parent.changeManager.endCoalescedUndoRun() } - /// Stages a row inspector field edit exactly as the grid's own cell editor stages one: the - /// change, the shared `TableRows` buffer both surfaces read, and the repaint. - /// - /// The rows are named by `RowID` rather than by the display positions the selection carries, - /// and each contributes its own `oldValue`. A shared one is wrong the moment a multi-row - /// selection disagrees on the field, where the inspector holds no value for it at all. - /// - /// `isExpression` stages `value`, SQL from `CellExpressionVocabulary`, to be written unquoted. + // By RowID, each row with its own oldValue: a shared one is wrong when a multi-row selection disagrees on the field. func stageInspectorFieldEdit( columnIndex: Int, value: PluginCellValue, @@ -259,18 +244,12 @@ final class RowEditingCoordinator: ObservableObject { ) } - /// A value of its own for each row, one undo step for all of them. + // One undo step for all rows. func stageInspectorFieldValues(columnIndex: Int, valuesByRow: [RowID: PluginCellValue]) { stageInspectorEdits(valuesByRow: valuesByRow, columnIndex: columnIndex, continuity: .discrete) } - /// Puts a field back to the values the inspector was configured with. - /// - /// A field the selected rows disagree on has no value of its own, and clearing it asks for each - /// row's own value back rather than for one value across all of them. - /// - /// `absentRowIDs` are the rows that had no field for the column, which it goes back to missing, and - /// `expressionRowIDs` the rows whose value was staged SQL, which goes back as SQL rather than as text. + // Disagreeing rows get their own values back; absentRowIDs go back to missing, expressionRowIDs back to SQL. func revertInspectorFieldEdit( columnIndex: Int, valuesByRow: [RowID: PluginCellValue], @@ -286,8 +265,7 @@ final class RowEditingCoordinator: ObservableObject { ) } - /// Takes the field out of every row the inspector is showing, for an engine that tells a - /// missing field from NULL. + // For an engine that tells a missing field from NULL. func stageInspectorFieldRemoval(columnIndex: Int, rowIDs: [RowID]) { guard parent.changeManager.supportsFieldRemoval else { return } stageInspectorEdits( @@ -313,14 +291,11 @@ final class RowEditingCoordinator: ObservableObject { let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) guard tableRows.columns.indices.contains(columnIndex) else { return } let columnName = tableRows.columns[columnIndex] - /// Before the rows are touched, not after: a column the server owns is refused when the - /// change is recorded, and editing here first would paint a value into the grid that no - /// statement will ever carry. + // Checked before touching rows: a server-owned column is refused later, so the grid would show a value nothing writes. guard parent.changeManager.isColumnWritable(columnName) else { return } var edits: [(row: Int, column: Int, value: PluginCellValue)] = [] var removedCells: Set = [] - var editedRowIDs: Set = [] for (rowID, value) in valuesByRow { guard let storageRow = tableRows.index(of: rowID) else { continue } let row = tableRows.rows[storageRow] @@ -362,35 +337,42 @@ final class RowEditingCoordinator: ObservableObject { if removesField { removedCells.insert(CellPosition(row: storageRow, column: columnIndex)) } - editedRowIDs.insert(rowID) } guard !edits.isEmpty else { return } parent.mutateActiveTableRows(for: tabId) { rows in rows.editMany(edits, absentCells: removedCells) } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - repaintInspectorEdit(rowIDs: editedRowIDs, columnIndex: columnIndex, in: tableRows) parent.inspectorRowContentChanged.send() } - /// `editMany` reports the rows it changed by their position in storage, and the grid reads a - /// delta's rows as display positions, so a sort or a value filter would repaint the wrong ones. - /// - /// Resolved in one pass over the display order rather than a search per row, because that order - /// is as long as the result. - private func repaintInspectorEdit(rowIDs: Set, columnIndex: Int, in tableRows: TableRows) { - var positions: Set = [] - if let displayIDs = parent.activeGridDisplayIDs { - for (displayRow, rowID) in displayIDs.enumerated() where rowIDs.contains(rowID) { - positions.insert(CellPosition(row: displayRow, column: columnIndex)) - } - } else { - for rowID in rowIDs { - guard let displayRow = tableRows.index(of: rowID) else { continue } - positions.insert(CellPosition(row: displayRow, column: columnIndex)) + // One user command, one undo step named for it; the buffer write reaches the grid as one delta. + func commitGridEdits(_ resolved: [DataGridResolvedEdit], actionName: String, tabId: UUID) -> Bool { + let changeManager = parent.changeManager + let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) + var edits: [(row: Int, column: Int, value: PluginCellValue)] = [] + var removedCells: Set = [] + for edit in resolved { + guard let storageRow = tableRows.index(of: edit.rowID) else { continue } + changeManager.recordCellChange( + rowID: edit.rowID, + columnIndex: edit.dataIndex, + columnName: edit.columnName, + oldValue: edit.oldValue, + newValue: edit.newValue, + originalRow: edit.originalRow, + absence: edit.absence, + isExpression: edit.isExpression + ) + edits.append((row: storageRow, column: edit.dataIndex, value: edit.newValue)) + if edit.absence.isAbsent { + removedCells.insert(CellPosition(row: storageRow, column: edit.dataIndex)) } } - guard !positions.isEmpty else { return } - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(.cellsChanged(positions)) + guard !edits.isEmpty else { return false } + changeManager.undoManagerProvider?()?.setActionName(actionName) + parent.mutateActiveTableRows(for: tabId) { rows in rows.editMany(edits, absentCells: removedCells) } + parent.tabManager.mutate(tabId: tabId) { $0.hasUserInteraction = true } + return true } func handleUndoResult(_ result: UndoResult) { @@ -398,79 +380,65 @@ final class RowEditingCoordinator: ObservableObject { let tabId = tab.id - var application = RowOperationsManager.UndoApplicationResult(adjustedSelection: nil, delta: .none) + var application = RowOperationsManager.UndoApplicationResult(clearsSelection: false, delta: .none) parent.mutateActiveTableRows(for: tabId) { rows in let applied = parent.rowOperationsManager.applyUndoResult(result, tableRows: &rows) application = applied return applied.delta } - if let adjustedSelection = application.adjustedSelection { - parent.selectionState.indices = adjustedSelection + let model = parent.existingGridModel(forTab: tabId) + if application.clearsSelection { + model?.clearSelection() } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - parent.dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(application.delta) - /// The row inspector reads its fields from the `TableRows` this just rewrote, and nothing - /// else in `InspectorTrigger` moves on an undo. + model?.repaintAllRows() + // The inspector reads its fields from the rows this rewrote, and nothing else it watches moves on an undo. parent.gridDisplayRevision &+= 1 } - func copySelectedRowsToClipboard(indices: Set) { - guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !indices.isEmpty else { return } - guard parent.unloadedValuesAreLoaded(displayRows: indices, retry: { [weak self] in - self?.copySelectedRowsToClipboard(indices: indices) + func copyRowsToClipboard(_ rowIDs: [RowID], includeHeaders: Bool = false) { + guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !rowIDs.isEmpty else { return } + guard parent.unloadedValuesAreLoaded(rowIDs: rowIDs, retry: { [weak self] in + self?.copyRowsToClipboard(rowIDs, includeHeaders: includeHeaders) }) else { return } - let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id) - parent.rowOperationsManager.copySelectedRowsToClipboard( - selectedIndices: indices, - tableRows: tableRows, - displayIDs: parent.activeGridDisplayIDs, - visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices() - ) - } - - func copySelectedRowsWithHeaders(indices: Set) { - guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !indices.isEmpty else { return } - guard parent.unloadedValuesAreLoaded(displayRows: indices, retry: { [weak self] in - self?.copySelectedRowsWithHeaders(indices: indices) - }) else { return } - let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id) - parent.rowOperationsManager.copySelectedRowsToClipboard( - selectedIndices: indices, - tableRows: tableRows, - displayIDs: parent.activeGridDisplayIDs, - includeHeaders: true, - visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices() + parent.rowOperationsManager.copyRowsToClipboard( + rowIDs, + tableRows: parent.tabSessionRegistry.tableRows(for: tab.id), + includeHeaders: includeHeaders, + visibleColumnIndices: presentedColumnIndices(tabId: tab.id) ) } - func copySelectedRowsAsJson(indices: Set) { - guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !indices.isEmpty else { return } - guard parent.unloadedValuesAreLoaded(displayRows: indices, retry: { [weak self] in - self?.copySelectedRowsAsJson(indices: indices) + func copyRowsAsJson(_ rowIDs: [RowID]) { + guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !rowIDs.isEmpty else { return } + guard parent.unloadedValuesAreLoaded(rowIDs: rowIDs, retry: { [weak self] in + self?.copyRowsAsJson(rowIDs) }) else { return } + let model = parent.gridModel(forTab: tab.id) let output = ResultJsonSerializer.serialize( tableRows: parent.tabSessionRegistry.tableRows(for: tab.id), - displayIDs: parent.activeGridDisplayIDs, - selectedDisplayIndices: indices, - columns: VisibleColumnProjection( - indices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices() - ) + displayIDs: model.displayedRowIDs, + selectedDisplayIndices: Set(rowIDs.compactMap { model.displayIndex(of: $0) }), + columns: VisibleColumnProjection(indices: presentedColumnIndices(tabId: tab.id)) ) guard output.rowCount > 0 else { return } ClipboardService.shared.writeText(output.json) } + // The columns the grid presents, in its order: hidden ones go, moved ones follow. + private func presentedColumnIndices(tabId: UUID) -> [Int] { + parent.gridModel(forTab: tabId).columns.columns.map(\.dataIndex) + } + func pasteRows() { guard parent.canEditActiveResult, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, tab.tabType == .table else { return } let tabId = tab.id - /// Pasted rows hand the columns the server owns back to it, which only the schema names, so pasting waits for - /// it the way Add Row does rather than staging an identity value the save would send. + // Pasted rows hand the server-owned columns back to it, which only the schema names, so this waits for it. let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) guard tableRows.hasAuthoritativeSchema else { return } let columns = tableRows.columns @@ -490,23 +458,9 @@ final class RowEditingCoordinator: ObservableObject { guard !pasteResult.pastedRows.isEmpty else { return } - let newIndices = displayIndices(of: Set(pasteResult.pastedRows.map(\.rowID)), tabId: tabId) + parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } if selectionPointsTheGrid { - parent.selectionState.indices = newIndices + parent.existingGridModel(forTab: tabId)?.selectRows(pasteResult.pastedRows.map(\.rowID)) } - - parent.tabManager.mutate(at: tabIndex) { tab in - tab.selectedRowIndices = newIndices - /// The pasted rows are the selection now. Left behind, the stored rectangle would - /// outrank them in `selectedDisplayRows` and come back instead of them. - tab.cellSelection = .empty - tab.hasUserInteraction = true - } - parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(pasteResult.delta) - } - - func updateCellInTab(rowIndex: Int, columnIndex: Int, value: PluginCellValue) { - guard let (_, tabIndex) = parent.tabManager.selectedTabAndIndex else { return } - parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } } } diff --git a/apple/TablePro/Core/DataFiles/DataFileController+Editing.swift b/apple/TablePro/Core/DataFiles/DataFileController+Editing.swift index d31806303a..6a0229f272 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController+Editing.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController+Editing.swift @@ -45,8 +45,12 @@ extension DataFileController { } func setCell(pageRow: Int, column: Int, text: String?) { - guard isEditable, let table, let key = key(forPageRow: pageRow), - columnNames.ids.indices.contains(column) else { return } + guard let key = key(forPageRow: pageRow) else { return } + setCell(key: key, column: column, text: text) + } + + func setCell(key: Int, column: Int, text: String?) { + guard isEditable, let table, columnNames.ids.indices.contains(column) else { return } let id = columnNames.ids[column] guard let tabularColumn = table.column(id) else { return } let original = table.cell(key: key, column: tabularColumn) @@ -61,8 +65,7 @@ extension DataFileController { commit(updated, actionName: String(localized: "Edit Cell")) } - /// The cell edits made inside `work` are one undo step and one page refresh, rather than a whole page rebuilt - /// for every cell of a paste. + // One undo step and one page refresh for every cell edit made inside `work`. func batchCellEdits(actionName: String, _ work: () -> Void) { guard pendingCellEdits == nil else { work() @@ -241,9 +244,10 @@ extension DataFileController { } else { kindOverrides[id] = nil } - gridCoordinator?.selectionSummaryTracker.rulesDidChange() if sortState.isSorting || !filterState.appliedFilters.isEmpty { runQuery() + } else { + gridModel.summaryRulesDidChange() } } } diff --git a/apple/TablePro/Core/DataFiles/DataFileController+Find.swift b/apple/TablePro/Core/DataFiles/DataFileController+Find.swift index 44982cec56..994ca66d45 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController+Find.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController+Find.swift @@ -7,204 +7,111 @@ import Foundation import TableProTabular import TableProTabularIO -struct DataFileFindMatch: Equatable, Hashable { - let key: Int - let column: TabularColumnID -} - -struct DataFileFindState: Equatable { - var isVisible = false - var isReplaceVisible = false - var text = "" - var replacement = "" - var matchesCase = false - var matchesWholeWords = false - var isRegularExpression = false - var scopeColumn: TabularColumnID? - var matches: [DataFileFindMatch] = [] - var currentIndex: Int? - var isSearching = false - var isPatternInvalid = false - - var hasQuery: Bool { !text.isEmpty } -} - +// The window's Find menu items land on the grid's model, which owns the bar. Its search and replace +// cover the whole file through the methods below, not only the loaded page. extension DataFileController { func showFind(replacing: Bool) { - find.isVisible = true - if replacing, isEditable { - find.isReplaceVisible = true - } - if find.hasQuery { - runFind() - } + gridModel.showFind(replace: replacing) } func hideFind() { - find = DataFileFindState( - matchesCase: find.matchesCase, - matchesWholeWords: find.matchesWholeWords, - isRegularExpression: find.isRegularExpression - ) - findTask?.cancel() - gridCoordinator?.applyFindMatch(nil) + gridModel.hideFind() } - func scopeFind(to column: TabularColumnID) { - find.scopeColumn = column - showFind(replacing: true) + func findNext() { + gridModel.findNext() } - func scheduleFind() { - findDebounceTask?.cancel() - findDebounceTask = Task { [weak self] in - try? await Task.sleep(for: Self.queryDebounce) - guard !Task.isCancelled else { return } - self?.runFind() - } + func findPrevious() { + gridModel.findPrevious() } - func findQuery() -> TabularFindQuery { - let hidden = columnLayout.hiddenColumns - let columns: [TabularColumnID] - if let scope = find.scopeColumn, columnNames.ids.contains(scope) { - columns = [scope] - } else { - columns = zip(columnNames.ids, columnNames.displayNames).filter { !hidden.contains($0.1) }.map(\.0) - } - return TabularFindQuery( - text: find.text, - matchesCase: find.matchesCase, - matchesWholeWords: find.matchesWholeWords, - isRegularExpression: find.isRegularExpression, - columns: columns - ) + func useSelectionForFind() { + gridModel.useSelectionForFind() } - func runFind() { - findTask?.cancel() - guard let table, find.hasQuery else { - find.matches = [] - find.currentIndex = nil - find.isPatternInvalid = false - gridCoordinator?.applyFindMatch(nil) - return - } - let query = findQuery() - guard TabularFindMatcher.validate(query) else { - find.isPatternInvalid = true - find.matches = [] - find.currentIndex = nil - gridCoordinator?.applyFindMatch(nil) - return - } - find.isPatternInvalid = false - find.isSearching = true - let keys = visibleKeys() - let activityID = beginActivity(title: String(localized: "Finding…"), isMutation: false) - let reporter = progressReporter(for: activityID) - findTask = Task { [weak self] in - do { - let found = try await TabularFinder.findAll(query, keys: keys, in: table, progress: reporter) - let matches = found.map { DataFileFindMatch(key: $0.key, column: $0.column) } - self?.finishFind(matches, activityID: activityID) - } catch { - self?.endActivity(activityID) - if !error.isDataFileCancellation { - self?.find.isSearching = false - } - } + // The grid finds only in the columns it shows, so a hidden column is shown first. + func scopeFind(to column: TabularColumnID) { + guard let id = findColumns.first(where: { $0.tabular == column })?.grid else { return } + if let name = columnNames.name(for: column), columnLayout.hiddenColumns.contains(name) { + columnLayout.hiddenColumns.remove(name) } + gridModel.findInColumn(id, replace: true) } - private func finishFind(_ matches: [DataFileFindMatch], activityID: UUID) { - endActivity(activityID) - find.isSearching = false - find.matches = matches - find.currentIndex = matches.isEmpty ? nil : 0 - showCurrentMatch() - } + // MARK: - Whole file - func findNext() { - guard !find.matches.isEmpty else { - runFind() - return - } - find.currentIndex = ((find.currentIndex ?? -1) + 1) % find.matches.count - showCurrentMatch() - } - - func findPrevious() { - guard !find.matches.isEmpty else { - runFind() - return + func findMatches(_ query: GridDatasetFindQuery) async throws -> [GridFindState.Match] { + guard let table, let tabularQuery = tabularFindQuery(for: query) else { return [] } + let gridIDs = Dictionary(findColumns.map { ($0.tabular, $0.grid) }, uniquingKeysWith: { first, _ in first }) + let keys = visibleKeys() + // A running mutation keeps the activity, so its progress and busy state stay on screen. + let activityID = activity?.isMutation == true + ? nil + : beginActivity(title: String(localized: "Finding…"), isMutation: false) + defer { + if let activityID { + endActivity(activityID) + } } - let count = find.matches.count - find.currentIndex = ((find.currentIndex ?? 0) - 1 + count) % count - showCurrentMatch() - } - - func showCurrentMatch() { - guard let index = find.currentIndex, find.matches.indices.contains(index) else { - gridCoordinator?.applyFindMatch(nil) - return + let progress: @Sendable (Double) -> Void + if let activityID { + progress = progressReporter(for: activityID) + } else { + progress = { _ in } } - let match = find.matches[index] - reveal(key: match.key, selecting: false) - guard let pageRow = pageKeys.firstIndex(of: match.key), let column = columnNames.index(of: match.column) else { - return + let found = try await TabularFinder.findAll(tabularQuery, keys: keys, in: table, progress: progress) + return found.compactMap { match in + gridIDs[match.column].map { GridFindState.Match(rowID: .existing(match.key), columnID: $0) } } - gridCoordinator?.applyFindMatch(FindMatch(displayRow: pageRow, columnIndex: column)) - } - - func useSelectionForFind() { - guard let table, let cell = activeCell(), let column = table.column(cell.column) else { return } - find.text = table.cell(key: cell.key, column: column).text - showFind(replacing: false) - runFind() } - func replaceCurrent() { - guard isEditable, let index = find.currentIndex, find.matches.indices.contains(index), - let table, let matcher = try? TabularFindMatcher(findQuery()) else { return } - let match = find.matches[index] - guard let column = table.column(match.column) else { return } - let original = table.cell(key: match.key, column: column) - let replaced = matcher.replacing(in: original.text, with: find.replacement) - guard replaced.replacements > 0 else { - findNext() - return - } + func replaceMatch(_ match: GridFindState.Match, query: GridDatasetFindQuery, with template: String) -> Bool { + guard isEditable, !hasMutationInFlight, + case .existing(let key) = match.rowID, + let table, + let id = findColumns.first(where: { $0.grid == match.columnID })?.tabular, + let column = table.column(id), + let tabularQuery = tabularFindQuery(for: query), + let matcher = try? TabularFindMatcher(tabularQuery) else { return false } + let original = table.cell(key: key, column: column) + guard !original.kind.isNullLike else { return false } + let replaced = matcher.replacing(in: original.text, with: template) + guard replaced.replacements > 0, replaced.text != original.text else { return false } var updated = table - updated.setCells([(key: match.key, columnID: match.column, cell: editedCell(replaced.text, replacing: original, column: match.column))]) + updated.setCells([(key: key, columnID: id, cell: editedCell(replaced.text, replacing: original, column: id))]) commit(updated, actionName: String(localized: "Replace")) - find.matches.remove(at: index) - find.currentIndex = find.matches.isEmpty ? nil : min(index, find.matches.count - 1) - showCurrentMatch() + return true } - func replaceAll() { - guard isEditable, find.hasQuery else { return } - let query = findQuery() - let template = find.replacement + func replaceAll(_ query: GridDatasetFindQuery, with template: String) { + guard let tabularQuery = tabularFindQuery(for: query) else { return } let keys = visibleKeys() runMutation(title: String(localized: "Replacing…"), actionName: String(localized: "Replace All")) { table, progress in - let result = try await TabularFinder.replaceAll(query, with: template, keys: keys, in: table, progress: progress) + let result = try await TabularFinder.replaceAll(tabularQuery, with: template, keys: keys, in: table, progress: progress) + let message = GridFindEngine.replacementMessage(replacements: result.replacements, cells: result.changedCells) guard result.changedCells > 0 else { - return DataFileMutationOutcome(table: nil, message: String(localized: "No matches to replace.")) + return DataFileMutationOutcome(table: nil, message: message) } - return DataFileMutationOutcome( - table: Self.applying(result.values, to: table), - message: Self.replacementMessage(replacements: result.replacements, cells: result.changedCells) - ) + return DataFileMutationOutcome(table: Self.applying(result.values, to: table), message: message) } } - nonisolated static func replacementMessage(replacements: Int, cells: Int) -> String { - String( - format: String(localized: "Replaced %1$@ in %2$@."), - DataFileCountPhrase.matches(replacements), - DataFileCountPhrase.cells(cells) + // The grid names a column by its display name, the table by its id. + private var findColumns: [(grid: DataGridColumnID, tabular: TabularColumnID)] { + zip(DataGridColumnSet.columnIDs(for: columnNames.displayNames), columnNames.ids).map { (grid: $0, tabular: $1) } + } + + private func tabularFindQuery(for query: GridDatasetFindQuery) -> TabularFindQuery? { + let tabularIDs = Dictionary(findColumns.map { ($0.grid, $0.tabular) }, uniquingKeysWith: { first, _ in first }) + let columns = query.columns.compactMap { tabularIDs[$0] } + guard !columns.isEmpty else { return nil } + return TabularFindQuery( + text: query.term, + matchesCase: query.options.matchesCase, + matchesWholeWords: query.options.matchesWholeWord, + isRegularExpression: query.options.usesRegularExpression, + columns: columns ) } } diff --git a/apple/TablePro/Core/DataFiles/DataFileController+Mutations.swift b/apple/TablePro/Core/DataFiles/DataFileController+Mutations.swift index 25f2298f97..7c5b4ef6a0 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController+Mutations.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController+Mutations.swift @@ -69,9 +69,6 @@ extension DataFileController { if let message = outcome.message { showMessage(message) } - if find.isVisible, find.hasQuery { - runFind() - } } private func abandonMutation(error: Error, activityID: UUID) { diff --git a/apple/TablePro/Core/DataFiles/DataFileController+Query.swift b/apple/TablePro/Core/DataFiles/DataFileController+Query.swift index f1edcfa1a4..3218ddafc6 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController+Query.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController+Query.swift @@ -63,7 +63,7 @@ extension DataFileController { filterState.executedFilters = filterState.appliedFilters let predicate = currentPredicate() let sortKeys = currentSortKeys() - selectedRowIndices = [] + gridModel.clearSelection() pageOffset = 0 guard !predicate.isTrivial || !sortKeys.isEmpty else { setQueryTask(nil) diff --git a/apple/TablePro/Core/DataFiles/DataFileController+Selection.swift b/apple/TablePro/Core/DataFiles/DataFileController+Selection.swift index ec0897ad61..487d9a9f25 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController+Selection.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController+Selection.swift @@ -3,17 +3,15 @@ // TablePro // -import Foundation +import AppKit import TableProTabular -/// One column and the rows a command changes in it. struct DataFileColumnCells: Equatable, Sendable { let column: TabularColumnID let keys: [Int] } -/// Each column keeps its own rows, so a selection of separate cells changes those cells and not the rows and columns -/// between them. +// Each column keeps its own rows, so a selection of separate cells changes those cells and not the ones between them. struct DataFileCellTarget: Equatable { let cells: [DataFileColumnCells] let coversWholeColumns: Bool @@ -25,28 +23,32 @@ struct DataFileCellTarget: Equatable { extension DataFileController { var gridSelection: GridSelection { - gridCoordinator?.selectionController.selection ?? .empty + gridModel.selection + } + + func columnID(atDisplayPosition position: Int) -> TabularColumnID? { + guard let dataIndex = gridModel.columns.dataIndex(atDisplayPosition: position), + columnNames.ids.indices.contains(dataIndex) else { return nil } + return columnNames.ids[dataIndex] } func selectedColumnIDs() -> [TabularColumnID] { - guard let coordinator = gridCoordinator else { return [] } - return coordinator.dataColumnIndices(in: gridSelection.columns).compactMap { index in - columnNames.ids.indices.contains(index) ? columnNames.ids[index] : nil - } + gridSelection.columns.compactMap { columnID(atDisplayPosition: $0) } + } + + // Nil for a hidden column or an unmounted grid. + func headerAnchor(for id: TabularColumnID) -> (view: NSView, rect: NSRect)? { + guard let dataIndex = columnNames.index(of: id), + let position = gridModel.columns.displayPosition(ofDataIndex: dataIndex), + let column = gridModel.columns.column(at: position) else { return nil } + return gridModel.anchor(for: column) } func activeCell() -> (key: Int, column: TabularColumnID)? { - guard let coordinator = gridCoordinator else { return nil } - if let active = gridSelection.activeCell { - guard let key = key(forPageRow: active.row), - let column = coordinator.dataColumnIndex(atDisplayPosition: active.displayColumn), - columnNames.ids.indices.contains(column) else { return nil } - return (key, columnNames.ids[column]) - } - guard let focused = (coordinator.tableView as? KeyHandlingTableView)?.pasteAnchorCell(), - let key = key(forPageRow: focused.row), - columnNames.ids.indices.contains(focused.column) else { return nil } - return (key, columnNames.ids[focused.column]) + guard let active = gridModel.activeCell, + case .existing(let key) = active.rowID, + columnNames.ids.indices.contains(active.column.dataIndex) else { return nil } + return (key, columnNames.ids[active.column.dataIndex]) } func cellTarget(explicitColumn: TabularColumnID? = nil) -> DataFileCellTarget? { @@ -57,45 +59,33 @@ extension DataFileController { if !columns.isEmpty { return .wholeColumns(columns, keys: visibleKeys()) } - guard let coordinator = gridCoordinator else { return nil } - guard !gridSelection.isEmpty else { return focusedCellTarget() } - var pairs: [(column: Int, pageRow: Int)] = [] - for row in gridSelection.affectedRows { - for column in coordinator.dataColumnIndices(in: gridSelection.columns(in: row)) { - pairs.append((column: column, pageRow: row)) + let selection = gridSelection + // A click or picked rows span every column; the commands act on the cursor's column of each row. + guard !selection.isEmpty, !selection.isRows else { return focusedCellTarget() } + var cells: [(column: TabularColumnID, key: Int)] = [] + for row in selection.affectedRows { + guard let key = key(forPageRow: row) else { continue } + for position in selection.columns(in: row) { + guard let column = columnID(atDisplayPosition: position) else { continue } + cells.append((column: column, key: key)) } } - return cellTarget(pairs: pairs) + return cellTarget(cells: cells) } - /// The cells the grid resolved, read the way `cellTarget()` reads a selection: whole selected columns cover every - /// visible row, and anything else covers those cells only. - func cellTarget(for targets: [SetValueTarget]) -> DataFileCellTarget? { - guard let target = cellTarget(pairs: targets.map { (column: $0.columnIndex, pageRow: $0.displayRow) }) else { - return nil - } - let wholeColumns = selectedColumnIDs() - let columns = target.cells.map(\.column) - guard !wholeColumns.isEmpty, Set(columns).isSubset(of: Set(wholeColumns)) else { return target } - return .wholeColumns(columns, keys: visibleKeys()) - } - - private func cellTarget(pairs: [(column: Int, pageRow: Int)]) -> DataFileCellTarget? { - var order: [Int] = [] - var keysByColumn: [Int: [Int]] = [:] - var seen: [Int: Set] = [:] - for pair in pairs where columnNames.ids.indices.contains(pair.column) { - guard let key = key(forPageRow: pair.pageRow), seen[pair.column, default: []].insert(key).inserted else { - continue - } - if keysByColumn[pair.column] == nil { - order.append(pair.column) + func cellTarget(cells: [(column: TabularColumnID, key: Int)]) -> DataFileCellTarget? { + var order: [TabularColumnID] = [] + var keysByColumn: [TabularColumnID: [Int]] = [:] + var seen: [TabularColumnID: Set] = [:] + for cell in cells where seen[cell.column, default: []].insert(cell.key).inserted { + if keysByColumn[cell.column] == nil { + order.append(cell.column) } - keysByColumn[pair.column, default: []].append(key) + keysByColumn[cell.column, default: []].append(cell.key) } guard !order.isEmpty else { return nil } - let cells = order.map { DataFileColumnCells(column: columnNames.ids[$0], keys: keysByColumn[$0] ?? []) } - return DataFileCellTarget(cells: cells, coversWholeColumns: false) + let targets = order.map { DataFileColumnCells(column: $0, keys: keysByColumn[$0] ?? []) } + return DataFileCellTarget(cells: targets, coversWholeColumns: false) } private func focusedCellTarget() -> DataFileCellTarget? { diff --git a/apple/TablePro/Core/DataFiles/DataFileController+Undo.swift b/apple/TablePro/Core/DataFiles/DataFileController+Undo.swift index 01a637146b..e517cce37e 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController+Undo.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController+Undo.swift @@ -88,8 +88,5 @@ extension DataFileController { } else { runQuery() } - if find.isVisible, find.hasQuery { - scheduleFind() - } } } diff --git a/apple/TablePro/Core/DataFiles/DataFileController.swift b/apple/TablePro/Core/DataFiles/DataFileController.swift index fd23f77560..b21c574439 100644 --- a/apple/TablePro/Core/DataFiles/DataFileController.swift +++ b/apple/TablePro/Core/DataFiles/DataFileController.swift @@ -47,7 +47,12 @@ final class DataFileController: ObservableObject { @Published var filterState = TabFilterState() @Published var searchText = "" - @Published var sortState = SortState() + @Published var sortState = SortState() { + didSet { + guard gridModel.store.sortState != sortState else { return } + gridModel.store.sortState = sortState + } + } var sortReferences: [DataFileSortReference] = [] @Published private(set) var displayKeys: [Int]? @Published private(set) var isQueryRunning = false @@ -56,21 +61,21 @@ final class DataFileController: ObservableObject { @Published var pageSize: Int @Published private(set) var tableRows = TableRows() @Published private(set) var pageRevision = 0 - @Published var selectedRowIndices: Set = [] - @Published var columnLayout = ColumnLayoutState() + // The page rows the grid's selection covers, published for the status bar and the row details. + @Published private(set) var selectedRowIndices: Set = [] @Published var isInspectorVisible = false - @Published var find = DataFileFindState() @Published private(set) var activity: DataFileActivity? @Published var statusMessage: String? let changeManager: DataFileChangeManager let anyChangeManager: AnyChangeManager - let selectionSummary = SelectionSummaryState() + let selectionSummary: SelectionSummaryState + let gridModel: DataGridModel + let gridAdapter: DataFileGridAdapter weak var undoManager: UndoManager? - weak var gridCoordinator: TableViewCoordinator? var onEdited: (() -> Void)? - /// Set while `batchCellEdits` runs, so the cell edits made inside it land as one commit. + // Set while `batchCellEdits` runs, so the cell edits made inside it land as one commit. var pendingCellEdits: [(key: Int, columnID: TabularColumnID, cell: TabularCell)]? private(set) var kind: DataFileKind? @@ -81,8 +86,6 @@ final class DataFileController: ObservableObject { private var queryTask: Task? private var mutationTask: Task? private var queryDebounceTask: Task? - var findTask: Task? - var findDebounceTask: Task? var transferTask: Task? private var activityRevealTask: Task? private var messageTask: Task? @@ -93,6 +96,39 @@ final class DataFileController: ObservableObject { pageSize = max(1, AppSettingsManager.shared.dataGrid.defaultPageSize) changeManager = DataFileChangeManager() anyChangeManager = AnyChangeManager(changeManager) + let summary = SelectionSummaryState() + selectionSummary = summary + gridModel = DataGridModel(selectionSummary: summary) + gridAdapter = DataFileGridAdapter(model: gridModel) + gridAdapter.controller = self + gridModel.$selectionRevision + .sink { [weak self] _ in self?.gridSelectionDidChange() } + .store(in: &cancellables) + } + + // The grid's column layout is the one home of hidden columns, widths and order. + var columnLayout: ColumnLayoutState { + get { gridModel.store.columnLayout } + set { + guard newValue != gridModel.store.columnLayout else { return } + objectWillChange.send() + gridModel.updateColumnLayout { $0 = newValue } + } + } + + private func gridSelectionDidChange() { + let rows = Set(gridModel.selection.affectedRows.filter { $0 < pageKeys.count }) + guard rows != selectedRowIndices else { return } + selectedRowIndices = rows + } + + func selectRows(pageRows: Set) { + let rowIDs = pageRows.sorted().compactMap { key(forPageRow: $0) }.map { RowID.existing($0) } + if rowIDs.isEmpty { + gridModel.clearSelection() + } else { + gridModel.selectRows(rowIDs) + } } var isEditable: Bool { @@ -141,9 +177,8 @@ final class DataFileController: ObservableObject { await queryDebounceTask?.value await queryTask?.value await mutationTask?.value - await findDebounceTask?.value - await findTask?.value await transferTask?.value + await gridModel.findSearchSettled() } private func performLoad(_ request: DataFileLoadRequest) async { @@ -198,7 +233,6 @@ final class DataFileController: ObservableObject { private func loadSheet(at index: Int) { guard let workbook = content?.workbook, let workbookSheet = sheets[index].workbookSheet else { return } queryTask?.cancel() - findTask?.cancel() loadTask?.cancel() table = nil loadState = .loading @@ -235,10 +269,11 @@ final class DataFileController: ObservableObject { sortReferences = [] displayKeys = nil pageOffset = 0 - selectedRowIndices = [] + gridModel.clearSelection() refreshColumnNames() refreshPage() loadState = .loaded + gridModel.findDatasetDidChange() } func refreshColumnNames() { @@ -291,10 +326,12 @@ final class DataFileController: ObservableObject { } func refreshPage() { + let previousColumns = tableRows.columns guard let table else { tableRows = TableRows() pageKeys = [] changeManager.bumpReload() + gridModel.reloadHeldRows(columnsChanged: !previousColumns.isEmpty) return } let visible = visibleRowCount @@ -338,14 +375,14 @@ final class DataFileController: ObservableObject { columnNullable: Dictionary(uniqueKeysWithValues: names.map { ($0, holdsNull) }) ) changeManager.bumpReload() - gridCoordinator?.applyDelta(.fullReplace) + gridModel.reloadHeldRows(columnsChanged: previousColumns != names || gridModel.columns.isEmpty) } func goToPage(offsetBy delta: Int) { let target = pageOffset + delta * pageSize guard target >= 0, target < max(visibleRowCount, 1) else { return } pageOffset = target - selectedRowIndices = [] + gridModel.clearSelection() refreshPage() } @@ -359,15 +396,8 @@ final class DataFileController: ObservableObject { } let row = position - pageOffset guard selecting, row >= 0, row < pageKeys.count else { return } - selectedRowIndices = [row] - DispatchQueue.main.async { [weak self] in - guard let coordinator = self?.gridCoordinator, let tableView = coordinator.tableView, - row < tableView.numberOfRows else { return } - coordinator.isApplyingProgrammaticRowSelection = true - tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - coordinator.isApplyingProgrammaticRowSelection = false - tableView.scrollRowToVisible(row) - } + gridModel.selectRows([.existing(key)]) + gridModel.reveal(rowID: .existing(key)) } func showMessage(_ message: String) { @@ -414,8 +444,8 @@ final class DataFileController: ObservableObject { func cancelActivity() { queryTask?.cancel() mutationTask?.cancel() - findTask?.cancel() transferTask?.cancel() + gridModel.stopFindSearch() } func tearDown() { @@ -423,11 +453,10 @@ final class DataFileController: ObservableObject { queryTask?.cancel() mutationTask?.cancel() queryDebounceTask?.cancel() - findTask?.cancel() - findDebounceTask?.cancel() transferTask?.cancel() activityRevealTask?.cancel() messageTask?.cancel() + gridModel.stopFindSearch() workingCopy = nil } @@ -468,14 +497,17 @@ final class DataFileController: ObservableObject { func setDisplayKeys(_ keys: [Int]?) { displayKeys = keys + gridModel.findDatasetDidChange() } + // Every edit, mutation and undo lands here, so find searches the file again. func replaceTable(_ newTable: TabularTable) { table = newTable if sheets.indices.contains(selectedSheetIndex) { sheets[selectedSheetIndex].table = newTable } refreshColumnNames() + gridModel.findDatasetDidChange() } func setInferredKind(_ kind: TabularInferredKind, for id: TabularColumnID) { diff --git a/apple/TablePro/Core/DataFiles/DataFileCountPhrase.swift b/apple/TablePro/Core/DataFiles/DataFileCountPhrase.swift index 8ebebf27d6..bc6b4a708e 100644 --- a/apple/TablePro/Core/DataFiles/DataFileCountPhrase.swift +++ b/apple/TablePro/Core/DataFiles/DataFileCountPhrase.swift @@ -20,10 +20,6 @@ enum DataFileCountPhrase { count == 1 ? String(localized: "1 cell") : String(format: String(localized: "%@ cells"), count.formatted()) } - static func matches(_ count: Int) -> String { - count == 1 ? String(localized: "1 match") : String(format: String(localized: "%@ matches"), count.formatted()) - } - static func duplicateRows(_ count: Int) -> String { count == 1 ? String(localized: "1 duplicate row") diff --git a/apple/TablePro/Core/DataGrid/DataGridDisplayState.swift b/apple/TablePro/Core/DataGrid/DataGridDisplayState.swift index b4f23d2920..53e13177b9 100644 --- a/apple/TablePro/Core/DataGrid/DataGridDisplayState.swift +++ b/apple/TablePro/Core/DataGrid/DataGridDisplayState.swift @@ -2,53 +2,12 @@ // DataGridDisplayState.swift // TablePro // -// The formatted-cell cache and viewport anchor a tab keeps while its grid is unmounted. -// -import AppKit import Foundation -/// What a display cache was built from. -/// -/// A cache that outlives its grid has to carry the inputs that decided its text, because nothing -/// invalidates it while no grid is mounted to notice them change. -struct DataGridDisplayIdentity: Equatable { - let bufferEpoch: Int - let resultSetId: UUID? - let columns: [String] - let columnTypes: [ColumnType] - let displayFormats: [ValueDisplayFormat?] - let dateFormat: DateFormatOption - let nullDisplay: String - let smartValueDetection: Bool - /// A Unix-timestamp column renders an instant in the reader's own zone, so text derived before - /// the Mac's zone moved is wrong. Nothing else in this identity moves with it. - let systemTimeZoneGeneration: Int -} - -/// Whether the rows under a mounted grid were replaced, as opposed to the grid being rebuilt over -/// the same rows. A remount is not a content change and must not discard formatted text. -struct DataGridContentIdentity: Equatable { - let reloadVersion: Int - let contentRevision: Int -} - -/// The per-result state a mounted grid derives, kept by an owner that outlives the view. -/// -/// SwiftUI destroys `TableViewCoordinator` whenever the grid leaves the view tree, and the editor -/// mounts the grid as `.id(tab.id)`, so every tab switch threw away a whole result's formatted text -/// and rebuilt it: `CellDisplayFormatter` over every loaded row of every column, on the main actor, -/// growing with the rows the tab held. The owner keeps this the same way it already keeps the value -/// filter and the resolved display order. (#2424) +// Formatted text kept by the model, so a remount of the grid draws from it instead of formatting every row again (#2424). @MainActor final class DataGridDisplayState { let cache = RowDisplayCache() - var contentIdentity: DataGridContentIdentity? - var firstVisibleRow: Int = 0 - /// The two derived values a coordinator compares against to decide the cache is stale. A fresh - /// coordinator starts both empty, so without carrying them the first update of every remount - /// reports a schema and a format change and clears the text it was just handed. - var identitySchema: ColumnIdentitySchema? - var displayFormats: [ValueDisplayFormat?]? var highlightRuleSetKey: HighlightRuleSet.Key? } diff --git a/apple/TablePro/Core/DataGrid/DataGridModel+Find.swift b/apple/TablePro/Core/DataGrid/DataGridModel+Find.swift new file mode 100644 index 0000000000..8ff206ca42 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/DataGridModel+Find.swift @@ -0,0 +1,433 @@ +import Foundation +import TableProPluginKit + +// An owner holding more rows than the grid loaded (a data file's other pages) answers find for all of them. +// A match's rowID is the one its row carries once loaded; the owner calls findDatasetDidChange() when its rows change. +@MainActor +protocol DataGridDatasetFinding: AnyObject { + // Every match in display order, row then column; progress and cancel go through the owner's activity UI. + func gridFindAll(_ query: GridDatasetFindQuery) async throws -> [GridFindState.Match] + func gridDisplayPosition(of rowID: RowID) -> Int? + // Loads the row's page without selecting anything; false when the row is gone. + func gridLoadRow(_ rowID: RowID) -> Bool + // One undoable step; false when the cell no longer holds the term. + func gridReplace(_ match: GridFindState.Match, query: GridDatasetFindQuery, with template: String) -> Bool + // One undoable step over every match, with the owner's progress, cancel and outcome message. + func gridReplaceAll(_ query: GridDatasetFindQuery, with template: String) +} + +struct GridDatasetFindQuery: Equatable { + // A literal term arrives trimmed, as the loaded-rows search trims it. + let term: String + let options: GridFindState.Options + // In display order. + let columns: [DataGridColumnID] +} + +extension DataGridModel { + var datasetFinder: (any DataGridDatasetFinding)? { + get { find.datasetFinder } + set { find.datasetFinder = newValue } + } + + var canReplaceInFind: Bool { + configuration.isEditable && actions != nil + } + + // A dataset finder already searches every row. + var findCoversPageOnly: Bool { + datasetFinder == nil && dataSource?.gridHasUnloadedRows == true + } + + var offersSearchAllRows: Bool { + datasetFinder == nil && configuration.commands.contains(.searchAllRows) && actions != nil + } + + var hasFindTerm: Bool { + if find.options.usesRegularExpression { return !find.term.isEmpty } + return !find.term.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var canReplaceCurrentFindMatch: Bool { + guard canReplaceInFind, + let match = find.currentMatch, + let column = columns.column(id: match.columnID) else { return false } + return isColumnWritable(column) + } + + // MARK: - Showing + + func showFind(replace: Bool = false) { + if replace, canReplaceInFind { + find.showsReplace = true + } + let wasVisible = find.isVisible + find.isVisible = true + if !wasVisible { + refreshFind() + } + } + + func findInColumn(_ columnID: DataGridColumnID, replace: Bool = false) { + find.options.columnScope = columnID + if replace, canReplaceInFind { + find.showsReplace = true + } + find.isVisible = true + findQueryDidChange() + } + + func hideFind() { + guard find.isVisible else { return } + find.reset() + find.options.columnScope = nil + find.showsReplace = false + find.isVisible = false + focus() + } + + // MARK: - Searching + + // The term or an option changed: search again and select the first match from the cursor. + func findQueryDidChange() { + guard find.isVisible else { return } + if datasetFinder != nil { + searchDataset(revealsFirstMatch: true) + return + } + let source = findSource + let result = GridFindEngine.search(find.term, options: find.options, in: source) + let current = GridFindEngine.index( + atOrAfter: selection.activeCell, + in: GridFindEngine.positions(of: result.matches, in: source) + ) + find.setResults(result.matches, current: current, problem: result.problem) + revealCurrentFindMatch() + } + + // The rows changed under the bar: keep the current match when it survives and leave the selection alone. + func refreshFind() { + guard find.isVisible else { return } + if datasetFinder != nil { + // Another page changes the grid's rows, not the owner's; only another column set searches again. + guard datasetFindQuery != find.datasetQuery else { return } + searchDataset(revealsFirstMatch: false) + return + } + let source = findSource + let previous = find.currentMatch + let result = GridFindEngine.search(find.term, options: find.options, in: source) + let current: Int? + if let previous, let kept = result.matches.firstIndex(of: previous) { + current = kept + } else { + current = GridFindEngine.index( + atOrAfter: selection.activeCell, + in: GridFindEngine.positions(of: result.matches, in: source) + ) + } + find.setResults(result.matches, current: current, problem: result.problem) + } + + // The owner's rows changed (an edit, an undo, a filter): search them again, keeping the current match. + func findDatasetDidChange() { + guard find.isVisible, datasetFinder != nil, find.datasetQuery != nil else { return } + // One change can arrive in several calls; the search starts once the owner has finished it. + find.deferDatasetSearch(Task { [weak self] in + guard let self, !Task.isCancelled else { return } + guard self.datasetFinder != nil, self.find.isVisible else { + self.find.cancelDatasetSearch() + return + } + self.searchDataset(revealsFirstMatch: false) + }) + } + + func stopFindSearch() { + find.stopDatasetSearch() + } + + // Returns once no whole-dataset search is waiting or running. + func findSearchSettled() async { + while let task = find.datasetSearch { + await task.value + if find.datasetSearch == task { return } + } + } + + func findNext() { + stepFind(forward: true) + } + + func findPrevious() { + stepFind(forward: false) + } + + func useSelectionForFind() { + let source = findSource + guard let active = activeCell, let row = source.rows.row(withID: active.rowID) else { return } + // A dataset finder matches the stored value, which the cell shows capped to one line. + let shown = datasetFinder == nil + ? GridFindEngine.searchableText(in: row, column: active.column, source: source) + : storedText(in: row, column: active.column) + guard let text = shown else { return } + find.term = find.options.usesRegularExpression ? NSRegularExpression.escapedPattern(for: text) : text + find.isVisible = true + findQueryDidChange() + } + + // The server matches the plain term in any column, ignoring case; any other option would change what it finds. + var searchAllRowsKeepsTheOptions: Bool { + find.options == GridFindState.Options() + } + + func searchAllRows() { + guard offersSearchAllRows, searchAllRowsKeepsTheOptions, let actions else { return } + let term = find.term.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return } + hideFind() + actions.gridSearchAllRows(term: term) + } + + // MARK: - Replacing + + func replaceCurrentMatch() { + guard canReplaceInFind, find.problem == nil, let match = find.currentMatch, commitEditing() else { return } + if let finder = datasetFinder { + replaceDatasetMatch(match, finder: finder) + return + } + let source = findSource + let replacement = GridFindEngine.replacements( + for: [match], + term: find.term, + options: find.options, + template: find.replacement, + in: source, + isWritable: { isColumnWritable($0) } + ) + guard !replacement.edits.isEmpty else { + findNext() + return + } + let replaced = GridFindEngine.position(of: match, in: source) + guard commit(replacement.edits, actionName: String(localized: "Replace")) else { return } + refreshFind() + guard !find.matches.isEmpty else { return } + let positions = GridFindEngine.positions(of: find.matches, in: findSource) + find.setCurrentIndex(replaced.flatMap { GridFindEngine.index(after: $0, in: positions) } ?? 0) + revealCurrentFindMatch() + } + + // Nil when nothing was committed here: a dataset finder commits and reports the outcome itself. + // An empty replacement when every match sits where Replace cannot write. + @discardableResult + func replaceAllMatches() -> GridFindEngine.Replacement? { + guard canReplaceInFind, commitEditing() else { return nil } + if let finder = datasetFinder { + replaceAllDatasetMatches(finder: finder) + return nil + } + let source = findSource + let result = GridFindEngine.search(find.term, options: find.options, in: source) + guard result.problem == nil else { return nil } + let replacement = GridFindEngine.replacements( + for: result.matches, + term: find.term, + options: find.options, + template: find.replacement, + in: source, + isWritable: { isColumnWritable($0) } + ) + guard !replacement.edits.isEmpty else { return replacement } + guard commit(replacement.edits, actionName: String(localized: "Replace All")) else { return nil } + refreshFind() + return replacement + } + + // MARK: - Private + + private var findSource: GridFindEngine.Source { + GridFindEngine.Source( + rows: rows, + order: order, + columns: columns, + displayFormats: store.displayFormats, + databaseType: configuration.traits.databaseType, + revealsInvisibleCharacters: AppSettingsManager.shared.dataGrid.showInvisibleCharacters + ) + } + + private func stepFind(forward: Bool) { + guard find.isVisible else { return } + if let finder = datasetFinder { + stepDatasetFind(finder, forward: forward) + return + } + if find.matches.isEmpty { + refreshFind() + } + guard !find.matches.isEmpty else { return } + let index = GridFindEngine.step( + from: find.currentIndex, + active: selection.activeCell, + positions: GridFindEngine.positions(of: find.matches, in: findSource), + forward: forward + ) + find.setCurrentIndex(index) + revealCurrentFindMatch() + } + + // The match becomes the active cell, so Copy, Return and the arrow keys act on the found column. + private func revealCurrentFindMatch() { + guard let match = find.currentMatch else { return } + if displayIndex(of: match.rowID) == nil { + guard let finder = datasetFinder, finder.gridLoadRow(match.rowID) else { return } + } + guard let row = displayIndex(of: match.rowID), + let column = columns.displayPosition(of: match.columnID) else { return } + selectCell(GridCoord(row: row, displayColumn: column)) + reveal(rowID: match.rowID, columnID: match.columnID) + } + + private func storedText(in row: Row, column: DataGridColumn) -> String? { + guard column.dataIndex < row.values.count, + case .text(let text) = row.values[column.dataIndex], + !text.isEmpty else { return nil } + return text + } + + // MARK: - Dataset + + private var datasetFindQuery: GridDatasetFindQuery? { + guard hasFindTerm else { return nil } + let term = find.options.usesRegularExpression + ? find.term + : find.term.trimmingCharacters(in: .whitespacesAndNewlines) + return GridDatasetFindQuery( + term: term, + options: find.options, + columns: GridFindEngine.searchedColumns(options: find.options, in: findSource).map(\.id) + ) + } + + private func searchDataset(revealsFirstMatch: Bool) { + let query = datasetFindQuery + let generation = find.beginDatasetSearch(query, clearsResults: revealsFirstMatch || query == nil) + guard let query, !query.columns.isEmpty else { + find.setResults([], current: nil, problem: nil) + return + } + if let problem = GridFindEngine.problem(term: query.term, options: query.options) { + find.setResults([], current: nil, problem: problem) + return + } + find.attachDatasetSearch(Task { [weak self] in + guard !Task.isCancelled, let finder = self?.datasetFinder else { + self?.find.abandonDatasetSearch(generation: generation) + return + } + do { + let found = try await finder.gridFindAll(query) + self?.finishDatasetSearch(found, generation: generation, revealsFirstMatch: revealsFirstMatch) + } catch { + self?.find.abandonDatasetSearch(generation: generation) + } + }, generation: generation) + } + + private func finishDatasetSearch(_ found: [GridFindState.Match], generation: Int, revealsFirstMatch: Bool) { + guard find.isCurrentDatasetSearch(generation) else { return } + var current: Int? + if let finder = datasetFinder, !found.isEmpty { + let position = { (index: Int) in self.datasetPosition(of: found[index], finder: finder) } + if let previous = find.currentMatch, + let cell = datasetPosition(of: previous, finder: finder), + let index = GridFindEngine.sortedIndex(atOrAfter: cell, count: found.count, position: position), + found[index] == previous { + current = index + } else if let cursor = datasetCursor(finder) { + current = GridFindEngine.sortedIndex(atOrAfter: cursor, count: found.count, position: position) + } else { + current = 0 + } + } + find.finishDatasetSearch(found, current: current, generation: generation) + if revealsFirstMatch { + revealCurrentFindMatch() + } + } + + private func stepDatasetFind(_ finder: any DataGridDatasetFinding, forward: Bool) { + let matches = find.matches + guard !matches.isEmpty else { + // A search that finished empty stays empty until the owner reports a change. + if !find.isSearching, find.searchStopped || datasetFindQuery != find.datasetQuery { + searchDataset(revealsFirstMatch: true) + } + return + } + let count = matches.count + let position = { (index: Int) in self.datasetPosition(of: matches[index], finder: finder) } + let next: Int? + if let active = selection.activeCell, currentFindMatchCell != active, let cursor = datasetCursor(finder) { + next = forward + ? GridFindEngine.sortedIndex(after: cursor, count: count, position: position) + : GridFindEngine.sortedIndex(before: cursor, count: count, position: position) + } else if let current = find.currentIndex { + next = forward ? (current + 1) % count : (current - 1 + count) % count + } else { + next = forward ? 0 : count - 1 + } + find.setCurrentIndex(next) + revealCurrentFindMatch() + } + + private func replaceDatasetMatch(_ match: GridFindState.Match, finder: any DataGridDatasetFinding) { + guard let index = find.currentIndex, + let query = datasetFindQuery, + let column = columns.column(id: match.columnID), + isColumnWritable(column) else { return } + guard finder.gridReplace(match, query: query, with: find.replacement) else { + findNext() + return + } + // The owner's change searches again in the background; until then the list drops the replaced cell. + find.removeMatch(at: index) + revealCurrentFindMatch() + } + + private func replaceAllDatasetMatches(finder: any DataGridDatasetFinding) { + guard let query = datasetFindQuery, + GridFindEngine.problem(term: query.term, options: query.options) == nil else { return } + let writable = query.columns.filter { id in + columns.column(id: id).map { isColumnWritable($0) } ?? false + } + guard !writable.isEmpty else { return } + finder.gridReplaceAll( + GridDatasetFindQuery(term: query.term, options: query.options, columns: writable), + with: find.replacement + ) + } + + private var currentFindMatchCell: GridCoord? { + guard let match = find.currentMatch, + let row = displayIndex(of: match.rowID), + let column = columns.displayPosition(of: match.columnID) else { return nil } + return GridCoord(row: row, displayColumn: column) + } + + private func datasetPosition(of match: GridFindState.Match, finder: any DataGridDatasetFinding) -> GridCoord? { + guard let row = finder.gridDisplayPosition(of: match.rowID), + let column = columns.displayPosition(of: match.columnID) else { return nil } + return GridCoord(row: row, displayColumn: column) + } + + // The active cell in the owner's display order. + private func datasetCursor(_ finder: any DataGridDatasetFinding) -> GridCoord? { + guard let active = selection.activeCell, + let rowID = rowID(at: active.row), + let row = finder.gridDisplayPosition(of: rowID) else { return nil } + return GridCoord(row: row, displayColumn: active.displayColumn) + } +} diff --git a/apple/TablePro/Core/DataGrid/DataGridModel.swift b/apple/TablePro/Core/DataGrid/DataGridModel.swift new file mode 100644 index 0000000000..9e9cd17f68 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/DataGridModel.swift @@ -0,0 +1,500 @@ +import AppKit +import Combine +import TableProPluginKit +import TableProTheme + +@MainActor +protocol DataGridMountedView: AnyObject { + func apply(_ change: DataGridChange) + func selectionDidChange() + func configurationDidChange() + func reveal(row: Int, displayColumn: Int?) + func revealColumn(_ displayColumn: Int) + func sortDidChange() + func summaryRulesDidChange() + func viewportSample() -> (row: Int, offset: CGFloat)? + @discardableResult func beginEditing(row: Int, displayColumn: Int) -> Bool + var isEditing: Bool { get } + func commitEditing() -> Bool + func discardEditing() + @discardableResult func focus() -> Bool + // Where a popover about a column (statistics, value filter) points. + func anchor(forColumn displayColumn: Int) -> (view: NSView, rect: NSRect)? +} + +@MainActor +final class DataGridModel: ObservableObject { + let id = UUID() + weak var dataSource: (any DataGridDataSource)? + weak var actions: (any DataGridActions)? + let store: any DataGridStateStore + let display = DataGridDisplayState() + let find = GridFindState() + let selectionSummary: SelectionSummaryState? + + var configuration: DataGridConfiguration { + didSet { + guard configuration != oldValue else { return } + mountedView?.configurationDidChange() + editabilityRevision &+= 1 + } + } + + var themeScope: ThemeScope { + didSet { mountedView?.configurationDidChange() } + } + + private(set) var columns: DataGridColumnSet = .empty + private(set) var order: DataGridRowOrder = .unfiltered + private(set) var selection: GridSelection = .empty + + @Published private(set) var displayRevision = 0 + @Published private(set) var selectionRevision = 0 + @Published private(set) var editabilityRevision = 0 + + private(set) weak var mountedView: (any DataGridMountedView)? + private var orderRevision = 0 + + init( + store: any DataGridStateStore = InMemoryDataGridStateStore(), + configuration: DataGridConfiguration = DataGridConfiguration(), + themeScope: ThemeScope = .live, + selectionSummary: SelectionSummaryState? = nil + ) { + self.store = store + self.configuration = configuration + self.themeScope = themeScope + self.selectionSummary = selectionSummary + } + + // MARK: - Rows + + var rows: TableRows { dataSource?.gridRows ?? TableRows() } + var changeState: AnyChangeManager? { dataSource?.gridChangeState } + var rowCount: Int { order.count(in: rows) } + + func rowID(at displayIndex: Int) -> RowID? { + order.rowID(at: displayIndex, in: rows) + } + + func row(at displayIndex: Int) -> Row? { + order.row(at: displayIndex, in: rows) + } + + func displayIndex(of rowID: RowID) -> Int? { + order.displayIndex(of: rowID, in: rows) + } + + func reloadRows() { + let current = rows + store.valueFilter = store.valueFilter.remapped(toColumns: current.columns) + let rebuilt = DataGridColumnSet(tableRows: current, layout: store.columnLayout) + if rebuilt != columns { + columns = rebuilt + mountedView?.apply(.columnsChanged) + } + order = resolveOrder(in: current) + display.cache.removeAll() + restoreSelection(in: current) + displayRevision &+= 1 + mountedView?.apply(.reload) + refreshFind() + } + + func apply(_ delta: Delta) { + if delta == .fullReplace { + reloadRows() + return + } + let current = rows + if delta.changesRowSet || delta == .columnsReplaced { + if delta == .columnsReplaced { + columns = DataGridColumnSet(tableRows: current, layout: store.columnLayout) + } + let previous = order + order = resolveOrder(in: current) + let change = DataGridChange.from(delta, order: previous, rows: current, columns: columns) + if change == .reload || change == .columnsChanged { + display.cache.removeAll() + } + restoreSelection(in: current) + displayRevision &+= 1 + mountedView?.apply(change) + refreshFind() + return + } + let change = DataGridChange.from(delta, order: order, rows: current, columns: columns) + guard !change.isNone else { return } + if case .rowsUpdated(let displayRows, _) = change { + for displayRow in displayRows { + if let id = order.rowID(at: displayRow, in: current) { + display.cache.clearValues(forID: id) + } + } + } + // An edited row stays where the filter put it until the filter or the row set changes (#2667). + mountedView?.apply(change) + refreshFind() + } + + private func resolveOrder(in rows: TableRows) -> DataGridRowOrder { + let ids = GridDisplayOrderResolver.resolve( + tableRows: rows, + valueFilter: store.valueFilter, + displayFormats: store.displayFormats, + databaseType: configuration.traits.databaseType + ) + orderRevision &+= 1 + return DataGridRowOrder(filteredIDs: ids, revision: orderRevision) + } + + // MARK: - Selection + + var selectedRowIDs: [RowID] { + order.rowIDs(atDisplayIndexes: selection.affectedRows, in: rows) + } + + var activeCell: (rowID: RowID, column: DataGridColumn)? { + guard let active = selection.activeCell, + let rowID = order.rowID(at: active.row, in: rows), + let column = columns.column(at: active.displayColumn) else { return nil } + return (rowID, column) + } + + func setSelection(_ newSelection: GridSelection) { + let clamped = newSelection.clamped(rowLimit: rowCount, columnLimit: columns.count) + guard clamped != selection else { return } + selection = clamped + store.selectionSnapshot = GridSelectionSnapshot(clamped, order: order, rows: rows, columns: columns) + selectionRevision &+= 1 + mountedView?.selectionDidChange() + } + + // Selects the cell's row and puts the cursor on the cell, which is what a click does. + func selectCell(_ cell: GridCoord) { + setSelection(GridSelectionReducer.cursor(cell, in: GridSelectionContext(rowCount: rowCount, columnCount: columns.count))) + } + + func selectRows(_ rowIDs: [RowID]) { + let current = rows + var rects: [GridRect] = [] + let lastColumn = max(columns.count - 1, 0) + for range in order.displayIndexes(of: rowIDs, in: current).rangeView { + rects.append(GridRect(rows: range.lowerBound...(range.upperBound - 1), columns: 0...lastColumn)) + } + let active = rects.first.map { GridCoord(row: $0.rows.lowerBound, displayColumn: selection.activeCell?.displayColumn ?? 0) } + setSelection(GridSelection(rectangles: rects, activeCell: active, anchor: active, mode: rects.isEmpty ? .cells : .rows)) + } + + func clearSelection() { + setSelection(.empty) + } + + // The change that follows tells the view: a selection moved ahead of it would end an open edit. + private func restoreSelection(in rows: TableRows) { + let resolved = store.selectionSnapshot + .resolved(order: order, rows: rows, columns: columns) + .clamped(rowLimit: order.count(in: rows), columnLimit: columns.count) + guard resolved != selection else { return } + selection = resolved + selectionRevision &+= 1 + } + + // MARK: - View state + + func setSort(_ sort: SortState) { + guard sort != store.sortState else { return } + store.sortState = sort + actions?.gridSortDidChange(sort) + mountedView?.sortDidChange() + } + + // An owner changed or declined a sort outside the header; the header re-reads it. + func sortStateDidChange() { + mountedView?.sortDidChange() + } + + func setValueFilter(_ filter: GridValueFilterState) { + guard filter != store.valueFilter else { return } + guard let actions else { + applyValueFilter(filter) + return + } + actions.gridConfirmDisplayOrderChange { [weak self] in + self?.applyValueFilter(filter) + } + } + + private func applyValueFilter(_ filter: GridValueFilterState) { + store.valueFilter = filter + let current = rows + order = resolveOrder(in: current) + restoreSelection(in: current) + displayRevision &+= 1 + mountedView?.apply(.reload) + refreshFind() + } + + func updateColumnLayout(_ mutate: (inout ColumnLayoutState) -> Void) { + var layout = store.columnLayout + mutate(&layout) + guard layout != store.columnLayout else { return } + store.columnLayout = layout + let current = rows + let rebuilt = DataGridColumnSet(tableRows: current, layout: layout) + if rebuilt != columns { + let snapshot = store.selectionSnapshot + columns = rebuilt + selection = snapshot.resolved(order: order, rows: current, columns: rebuilt) + .clamped(rowLimit: rowCount, columnLimit: rebuilt.count) + selectionRevision &+= 1 + mountedView?.apply(.columnsChanged) + mountedView?.selectionDidChange() + refreshFind() + } else { + mountedView?.apply(.columnsChanged) + } + } + + func setDisplayFormat(_ format: ValueDisplayFormat?, for column: DataGridColumn) { + let current = rows + let previous = displayFormat(for: column) + guard previous != format else { return } + let formats = DisplayFormatArray.setting( + format, at: column.dataIndex, in: store.displayFormats, columnCount: current.columns.count + ) + // A filter stores the shown text, so the values the user picked follow the new format. + if let remapped = remappedValueFilter(column: column, from: previous, to: format, in: current) { + store.valueFilter = remapped + } + store.displayFormats = formats + display.cache.removeAll() + if store.valueFilter.isActive { + order = resolveOrder(in: current) + restoreSelection(in: current) + displayRevision &+= 1 + mountedView?.apply(.reload) + } else { + mountedView?.apply(.rowsUpdated(IndexSet(0.. GridValueFilterState? { + let dataIndex = column.dataIndex + guard let existing = store.valueFilter.filter(forColumn: dataIndex) else { return nil } + let databaseType = configuration.traits.databaseType + var removed = Set() + var added = Set() + for row in rows.rows where dataIndex < row.values.count { + let raw = row.values[dataIndex] + guard raw != .null else { continue } + let old = GridDisplayOrderResolver.displayValue( + raw, columnType: column.type, displayFormat: oldFormat, databaseType: databaseType + ) + guard existing.selectedValues.contains(old) else { continue } + let new = GridDisplayOrderResolver.displayValue( + raw, columnType: column.type, displayFormat: newFormat, databaseType: databaseType + ) + if old != new { + removed.insert(old) + added.insert(new) + } + } + var selected = existing.selectedValues + selected.subtract(removed) + selected.formUnion(added) + guard selected != existing.selectedValues else { return nil } + var filter = store.valueFilter + filter.set( + ColumnValueFilter(selectedValues: selected, includesNull: existing.includesNull), + columnName: column.name, + forColumn: dataIndex + ) + return filter + } + + func displayFormat(for column: DataGridColumn) -> ValueDisplayFormat? { + let formats = store.displayFormats + return column.dataIndex < formats.count ? formats[column.dataIndex] : nil + } + + // MARK: - Editing + + func isColumnWritable(_ column: DataGridColumn) -> Bool { + guard configuration.isEditable else { return false } + let name = column.name + if configuration.lockedColumns.contains(name) { return false } + if configuration.traits.immutableColumns.contains(name) { return false } + if changeState?.generatedColumns.contains(name) == true { return false } + return true + } + + var isEditing: Bool { mountedView?.isEditing ?? false } + + // Owners that deferred work while an editor was open resume here. + var onEditingEnded: (@MainActor () -> Void)? + + func editingDidEnd() { + onEditingEnded?() + } + + @discardableResult + func beginEditing(rowID: RowID, columnID: DataGridColumnID) -> Bool { + guard let row = displayIndex(of: rowID), let column = columns.displayPosition(of: columnID) else { return false } + return mountedView?.beginEditing(row: row, displayColumn: column) ?? false + } + + // Owners call this before anything that repoints or replaces what an edit was made against. + @discardableResult + func commitEditing() -> Bool { + mountedView?.commitEditing() ?? true + } + + func discardEditing() { + mountedView?.discardEditing() + } + + @discardableResult + func commit(_ edits: [DataGridCellEdit], actionName: String) -> Bool { + guard let actions else { return false } + let current = rows + let resolved = edits.compactMap { resolve($0, in: current) } + guard !resolved.isEmpty else { return false } + return actions.gridCommit(resolved, actionName: actionName) + } + + private func resolve(_ edit: DataGridCellEdit, in rows: TableRows) -> DataGridResolvedEdit? { + guard let column = columns.column(id: edit.columnID) ?? columnOutsidePresentation(edit.columnID, in: rows), + isColumnWritable(column), + let storageIndex = rows.index(of: edit.rowID) else { return nil } + let row = rows.rows[storageIndex] + let dataIndex = column.dataIndex + guard dataIndex < row.values.count, changeState?.isRowDeleted(edit.rowID) != true else { return nil } + guard row.unloadedByteCount(dataIndex) == nil else { return nil } + + let newValue: PluginCellValue + let isExpression: Bool + var removesField = false + switch edit.value { + case .enteredText(let text): + if let text, + let databaseType = configuration.traits.databaseType, + let typed = CellEntry.typed( + text, + columnType: rows.editingColumnType(at: dataIndex), + databaseType: databaseType, + writer: configuration.traits.cellWriterSupport + ) { + newValue = typed.value + isExpression = typed.isExpression + } else if let entered = rows.enteredValue(text, forColumn: dataIndex) { + newValue = entered + isExpression = false + } else { + return nil + } + case .value(let value, let expression): + newValue = value + isExpression = expression + case .removeField: + guard configuration.traits.supportsFieldRemoval else { return nil } + newValue = .null + isExpression = false + removesField = true + } + + let oldValue = row.values[dataIndex] + let absence = FieldAbsence( + wasAbsent: row.isAbsent(dataIndex), + isAbsent: removesField, + originalRow: row.absentColumns + ) + let wasExpression = changeState?.isExpression(rowID: row.id, columnIndex: dataIndex) ?? false + guard oldValue != newValue || absence.wasAbsent != absence.isAbsent || wasExpression != isExpression else { + return nil + } + return DataGridResolvedEdit( + rowID: row.id, + dataIndex: dataIndex, + columnName: column.name, + oldValue: oldValue, + newValue: newValue, + originalRow: Array(row.values), + absence: absence, + isExpression: isExpression + ) + } + + // An owner-built edit can target a column the user hid. + private func columnOutsidePresentation(_ id: DataGridColumnID, in rows: TableRows) -> DataGridColumn? { + let ids = DataGridColumnSet.columnIDs(for: rows.columns) + guard let dataIndex = ids.firstIndex(of: id) else { return nil } + return DataGridColumn( + id: id, + dataIndex: dataIndex, + type: dataIndex < rows.columnTypes.count ? rows.columnTypes[dataIndex] : nil, + comment: rows.columnComments[id.name] + ) + } + + // MARK: - Mounting + + func attach(_ view: any DataGridMountedView) { + mountedView = view + } + + func detach(_ view: any DataGridMountedView) { + guard mountedView === view else { return } + mountedView = nil + } + + var isMounted: Bool { mountedView != nil } + + func revealColumn(_ id: DataGridColumnID) { + guard let position = columns.displayPosition(of: id) else { return } + mountedView?.revealColumn(position) + } + + func viewportSample() -> (row: Int, offset: CGFloat)? { + mountedView?.viewportSample() + } + + // A pending-change mark or a tint changed with no value under it. + func repaintRows(_ rowIDs: some Sequence) { + guard let mountedView else { return } + let indexes = order.displayIndexes(of: rowIDs, in: rows) + guard !indexes.isEmpty else { return } + mountedView.apply(.rowsUpdated(indexes, columns: nil)) + } + + // A column's summary rule moved with no value under it, so only the selection summary is computed again. + func summaryRulesDidChange() { + mountedView?.summaryRulesDidChange() + } + + func repaintAllRows() { + guard let mountedView, rowCount > 0 else { return } + mountedView.apply(.rowsUpdated(IndexSet(integersIn: 0.. (view: NSView, rect: NSRect)? { + guard let position = columns.displayPosition(of: column.id) else { return nil } + return mountedView?.anchor(forColumn: position) + } + + func reveal(rowID: RowID, columnID: DataGridColumnID? = nil) { + guard let row = displayIndex(of: rowID) else { return } + mountedView?.reveal(row: row, displayColumn: columnID.flatMap { columns.displayPosition(of: $0) }) + } + + @discardableResult + func focus() -> Bool { + mountedView?.focus() ?? false + } +} diff --git a/apple/TablePro/Core/DataGrid/DataGridOwnerProtocols.swift b/apple/TablePro/Core/DataGrid/DataGridOwnerProtocols.swift new file mode 100644 index 0000000000..7844ea8b62 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/DataGridOwnerProtocols.swift @@ -0,0 +1,178 @@ +import AppKit +import TableProPluginKit + +enum DataGridCommand: Hashable, CaseIterable { + case addRow + case duplicateRows + case deleteRows + case undoDelete + case pasteRows + case moveRows + case filterWithColumn + case hideColumns + case showRowsAsJSON + case exportResults + case clearResults + case searchAllRows + case navigateForeignKey + case highlightValues + case displayAs + case freezeColumns + case valueFilter + case copyAsSQL + case loadUnloadedValues +} + +enum DataGridLayoutScope: Equatable { + case table(ColumnLayoutTableKey) + case ephemeral +} + +struct DataGridEngineTraits: Equatable { + var databaseType: DatabaseType? + var immutableColumns: Set = [] + var usesTrueFalseBooleans = false + var stringLiteralPrefix: String = "" + var cellWriterSupport: CellWriterSupport = .none + var supportsFieldRemoval = false + // The database the rows came from, for the editors that read it (FK picker and preview, escaping). + var connectionScope: DatabaseScope? + // The Structure and Create Table type pickers list the connection's own types. + var looksUpUserDefinedTypes = false + + static let none = DataGridEngineTraits() +} + +@MainActor +protocol DataGridDataSource: AnyObject { + var gridRows: TableRows { get } + var gridChangeState: AnyChangeManager { get } + var gridRowNumberOffset: Int { get } + var gridHighlightRules: [HighlightRule] { get } + // The rows are one page of more, so Find counts what this page holds. + var gridHasUnloadedRows: Bool { get } + + func gridVisualState(for rowID: RowID) -> RowVisualState? + func gridTextTone(for rowID: RowID, column: DataGridColumn) -> NSColor? + func gridAccessibilityText(for rowID: RowID, column: DataGridColumn) -> String? + func gridCheckboxState(for rowID: RowID, column: DataGridColumn) -> Bool? + func gridMenuOptions(for rowID: RowID, column: DataGridColumn) -> [GridMenuOption]? + func gridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? +} + +extension DataGridDataSource { + var gridRowNumberOffset: Int { 0 } + var gridHighlightRules: [HighlightRule] { [] } + var gridHasUnloadedRows: Bool { false } + func gridVisualState(for rowID: RowID) -> RowVisualState? { nil } + func gridTextTone(for rowID: RowID, column: DataGridColumn) -> NSColor? { nil } + func gridAccessibilityText(for rowID: RowID, column: DataGridColumn) -> String? { nil } + func gridCheckboxState(for rowID: RowID, column: DataGridColumn) -> Bool? { nil } + func gridMenuOptions(for rowID: RowID, column: DataGridColumn) -> [GridMenuOption]? { nil } + func gridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? { nil } +} + +@MainActor +protocol DataGridActions: AnyObject { + // The owner records one undoable step, updates its rows and reports back through model.apply(_:). + func gridCommit(_ edits: [DataGridResolvedEdit], actionName: String) -> Bool + func gridDeleteRows(_ rowIDs: [RowID]) + // True when the owner wrote the pasteboard itself (a format only it knows); false lets the grid copy. + func gridCopyRows(_ rowIDs: [RowID]) -> Bool + // True when the owner ran its own Set Cells to Value over these cells (beyond the loaded page). + func gridSetCellsToValue(_ cells: [(rowID: RowID, column: DataGridColumn)]) -> Bool + func gridUndoDelete(_ rowIDs: [RowID]) + func gridAddRow() + func gridDuplicateRows(_ rowIDs: [RowID]) + func gridCanPasteRows() -> Bool + func gridPasteRows() + func gridMoveRow(from source: RowID, toDisplayIndex destination: Int) + func gridSortDidChange(_ sort: SortState) + func gridConfirmDisplayOrderChange(then apply: @escaping @MainActor () -> Void) + func gridFilterWithColumn(_ column: DataGridColumn) + func gridHideColumn(_ column: DataGridColumn) + func gridShowAllColumns() + func gridNavigateForeignKey(value: String, info: ForeignKeyInfo, intent: ReferenceOpenIntent) + func gridShowRowsAsJSON(_ rowIDs: [RowID]) + func gridExportResults() + func gridClearResults() + func gridSearchAllRows(term: String) + func gridSetCheckbox(_ isOn: Bool, rowIDs: [RowID], column: DataGridColumn) + func gridLoadUnloadedValues(rowIDs: [RowID], columns: IndexSet?, completion: @escaping @MainActor (Bool) -> Void) + var gridSQLExporter: (any DataGridSQLExporting)? { get } + + func gridColumnMenuItems(for column: DataGridColumn) -> [NSMenuItem] + // `rowIDs` are the rows the menu acts on; `clickedRow` is the one right-clicked, inside them or alone. + func gridRowMenuItems(for rowIDs: [RowID], clickedRow: RowID) -> [NSMenuItem] + func gridCellMenuItems(for rowID: RowID, column: DataGridColumn) -> [NSMenuItem] + func gridEmptySpaceMenu() -> NSMenu? + func gridHighlightValuesMenuItem(for column: DataGridColumn) -> NSMenuItem? +} + +extension DataGridActions { + func gridDeleteRows(_ rowIDs: [RowID]) {} + func gridCopyRows(_ rowIDs: [RowID]) -> Bool { false } + func gridSetCellsToValue(_ cells: [(rowID: RowID, column: DataGridColumn)]) -> Bool { false } + func gridUndoDelete(_ rowIDs: [RowID]) {} + func gridAddRow() {} + func gridDuplicateRows(_ rowIDs: [RowID]) {} + func gridCanPasteRows() -> Bool { false } + func gridPasteRows() {} + func gridMoveRow(from source: RowID, toDisplayIndex destination: Int) {} + func gridSortDidChange(_ sort: SortState) {} + func gridConfirmDisplayOrderChange(then apply: @escaping @MainActor () -> Void) { apply() } + func gridFilterWithColumn(_ column: DataGridColumn) {} + func gridHideColumn(_ column: DataGridColumn) {} + func gridShowAllColumns() {} + func gridNavigateForeignKey(value: String, info: ForeignKeyInfo, intent: ReferenceOpenIntent) {} + func gridShowRowsAsJSON(_ rowIDs: [RowID]) {} + func gridExportResults() {} + func gridClearResults() {} + func gridSearchAllRows(term: String) {} + func gridSetCheckbox(_ isOn: Bool, rowIDs: [RowID], column: DataGridColumn) {} + func gridLoadUnloadedValues(rowIDs: [RowID], columns: IndexSet?, completion: @escaping @MainActor (Bool) -> Void) { + completion(false) + } + var gridSQLExporter: (any DataGridSQLExporting)? { nil } + func gridColumnMenuItems(for column: DataGridColumn) -> [NSMenuItem] { [] } + func gridRowMenuItems(for rowIDs: [RowID], clickedRow: RowID) -> [NSMenuItem] { [] } + func gridCellMenuItems(for rowID: RowID, column: DataGridColumn) -> [NSMenuItem] { [] } + func gridEmptySpaceMenu() -> NSMenu? { nil } + func gridHighlightValuesMenuItem(for column: DataGridColumn) -> NSMenuItem? { nil } +} + +@MainActor +protocol DataGridSQLExporting: AnyObject { + func canCopyAsInsert(_ rows: TableRows) -> Bool + func canCopyAsUpdate(_ rows: TableRows) -> Bool + func insertStatements(for rowIDs: [RowID], columns: [DataGridColumn], in rows: TableRows) -> String? + func updateStatements(for rowIDs: [RowID], columns: [DataGridColumn], in rows: TableRows) -> String? + func inClause(values: [PluginCellValue], column: DataGridColumn) -> String? +} + +@MainActor +protocol DataGridStateStore: AnyObject { + var sortState: SortState { get set } + var valueFilter: GridValueFilterState { get set } + var columnLayout: ColumnLayoutState { get set } + var displayFormats: [ValueDisplayFormat?] { get set } + var selectionSnapshot: GridSelectionSnapshot { get set } + var viewportAnchor: GridViewportPlacement? { get set } + var layoutScope: DataGridLayoutScope { get } +} + +@MainActor +final class InMemoryDataGridStateStore: DataGridStateStore { + var sortState = SortState() + var valueFilter = GridValueFilterState() + var columnLayout = ColumnLayoutState() + var displayFormats: [ValueDisplayFormat?] = [] + var selectionSnapshot = GridSelectionSnapshot.empty + var viewportAnchor: GridViewportPlacement? + let layoutScope: DataGridLayoutScope = .ephemeral + + init(columnLayout: ColumnLayoutState = ColumnLayoutState(), sortState: SortState = SortState()) { + self.columnLayout = columnLayout + self.sortState = sortState + } +} diff --git a/apple/TablePro/Core/DataGrid/Engine/DataGridCellEdit.swift b/apple/TablePro/Core/DataGrid/Engine/DataGridCellEdit.swift new file mode 100644 index 0000000000..40cfb86e13 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Engine/DataGridCellEdit.swift @@ -0,0 +1,25 @@ +import Foundation +import TableProPluginKit + +enum DataGridEditValue: Equatable { + case enteredText(String?) + case value(PluginCellValue, isExpression: Bool) + case removeField +} + +struct DataGridCellEdit: Equatable { + let rowID: RowID + let columnID: DataGridColumnID + let value: DataGridEditValue +} + +struct DataGridResolvedEdit: Equatable { + let rowID: RowID + let dataIndex: Int + let columnName: String + let oldValue: PluginCellValue + let newValue: PluginCellValue + let originalRow: [PluginCellValue] + let absence: FieldAbsence + let isExpression: Bool +} diff --git a/apple/TablePro/Core/DataGrid/Engine/DataGridChange.swift b/apple/TablePro/Core/DataGrid/Engine/DataGridChange.swift new file mode 100644 index 0000000000..6b02a404af --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Engine/DataGridChange.swift @@ -0,0 +1,69 @@ +import Foundation + +enum DataGridChange: Equatable { + case reload + case columnsChanged + case rowsInserted(IndexSet) + case rowsRemoved(IndexSet) + case rowsUpdated(IndexSet, columns: IndexSet?) + + static let none = DataGridChange.rowsUpdated([], columns: []) + + var isNone: Bool { self == .none } + + // Every row from the first inserted or removed index to the end now sits at a new index. + func movedRows(rowCount: Int) -> IndexSet { + switch self { + case .rowsInserted(let indexes), .rowsRemoved(let indexes): + guard let first = indexes.first, first < rowCount else { return [] } + return IndexSet(integersIn: first.. DataGridChange { + switch delta { + case .fullReplace: + return .reload + case .columnsReplaced: + return .columnsChanged + case .rowsInserted(let storage): + guard !order.isFiltered else { return .reload } + return .rowsInserted(storage) + case .rowsRemoved(let storage): + guard !order.isFiltered else { return .reload } + return .rowsRemoved(storage) + case .cellChanged(let row, let column): + return cells([CellPosition(row: row, column: column)], order: order, rows: rows, columns: columns) + case .cellsChanged(let positions): + return cells(positions, order: order, rows: rows, columns: columns) + } + } + + private static func cells( + _ positions: some Collection, + order: DataGridRowOrder, + rows: TableRows, + columns: DataGridColumnSet + ) -> DataGridChange { + guard !positions.isEmpty else { return .none } + var displayRows = IndexSet() + var displayColumns = IndexSet() + for position in positions { + guard let displayRow = order.displayIndex(ofStorageIndex: position.row, in: rows) else { continue } + displayRows.insert(displayRow) + if let displayColumn = columns.displayPosition(ofDataIndex: position.column) { + displayColumns.insert(displayColumn) + } + } + guard !displayRows.isEmpty else { return .none } + return .rowsUpdated(displayRows, columns: displayColumns) + } +} diff --git a/apple/TablePro/Core/DataGrid/Engine/DataGridColumnSet.swift b/apple/TablePro/Core/DataGrid/Engine/DataGridColumnSet.swift new file mode 100644 index 0000000000..0615572490 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Engine/DataGridColumnSet.swift @@ -0,0 +1,135 @@ +import Foundation +import TableProPluginKit + +struct DataGridColumnID: Hashable, Sendable { + let name: String + let occurrence: Int + + // The occurrence leads, so a column named "a#1" never takes the second "a" column's key. + var stringValue: String { + "\(occurrence)#\(name)" + } +} + +struct DataGridColumn: Equatable { + let id: DataGridColumnID + let dataIndex: Int + let type: ColumnType? + let comment: String? + + var name: String { id.name } +} + +struct DataGridColumnSet: Equatable { + private(set) var columns: [DataGridColumn] + private(set) var frozenCount: Int + + private var positionByDataIndex: [Int: Int] + private var positionByID: [DataGridColumnID: Int] + + static let empty = DataGridColumnSet(columns: [], frozenCount: 0) + + init(columns: [DataGridColumn], frozenCount: Int) { + self.columns = columns + self.frozenCount = max(0, min(frozenCount, columns.count)) + var byData: [Int: Int] = [:] + var byID: [DataGridColumnID: Int] = [:] + byData.reserveCapacity(columns.count) + byID.reserveCapacity(columns.count) + for (position, column) in columns.enumerated() { + byData[column.dataIndex] = position + byID[column.id] = position + } + self.positionByDataIndex = byData + self.positionByID = byID + } + + init(tableRows: TableRows, layout: ColumnLayoutState) { + let ids = Self.columnIDs(for: tableRows.columns) + var all: [DataGridColumn] = [] + all.reserveCapacity(ids.count) + for (dataIndex, id) in ids.enumerated() where !layout.hiddenColumns.contains(id.name) { + all.append(DataGridColumn( + id: id, + dataIndex: dataIndex, + type: dataIndex < tableRows.columnTypes.count ? tableRows.columnTypes[dataIndex] : nil, + comment: tableRows.columnComments[id.name] + )) + } + let ordered = Self.applyingOrder(layout.columnOrder, to: all) + self.init(columns: ordered, frozenCount: layout.frozenColumnCount) + } + + static func columnIDs(for names: [String]) -> [DataGridColumnID] { + var seen: [String: Int] = [:] + return names.map { name in + let occurrence = seen[name, default: 0] + seen[name] = occurrence + 1 + return DataGridColumnID(name: name, occurrence: occurrence) + } + } + + private static func applyingOrder(_ order: [String]?, to columns: [DataGridColumn]) -> [DataGridColumn] { + guard let order, !order.isEmpty else { return columns } + var buckets: [String: [DataGridColumn]] = [:] + for column in columns { + buckets[column.name, default: []].append(column) + } + var result: [DataGridColumn] = [] + result.reserveCapacity(columns.count) + for name in order { + guard var bucket = buckets[name], !bucket.isEmpty else { continue } + result.append(bucket.removeFirst()) + buckets[name] = bucket + } + let placed = Set(result.map(\.id)) + result.append(contentsOf: columns.filter { !placed.contains($0.id) }) + return result + } + + var count: Int { columns.count } + var isEmpty: Bool { columns.isEmpty } + + var frozenColumns: ArraySlice { columns.prefix(frozenCount) } + + func column(at displayPosition: Int) -> DataGridColumn? { + guard displayPosition >= 0, displayPosition < columns.count else { return nil } + return columns[displayPosition] + } + + func column(id: DataGridColumnID) -> DataGridColumn? { + positionByID[id].map { columns[$0] } + } + + func displayPosition(ofDataIndex dataIndex: Int) -> Int? { + positionByDataIndex[dataIndex] + } + + func displayPosition(of id: DataGridColumnID) -> Int? { + positionByID[id] + } + + func dataIndex(atDisplayPosition displayPosition: Int) -> Int? { + column(at: displayPosition)?.dataIndex + } + + func isFrozen(displayPosition: Int) -> Bool { + displayPosition >= 0 && displayPosition < frozenCount + } + + var orderedNames: [String] { columns.map(\.name) } + + func withFrozenCount(_ count: Int) -> DataGridColumnSet { + DataGridColumnSet(columns: columns, frozenCount: count) + } + + func moving(from source: Int, to destination: Int) -> DataGridColumnSet { + guard source != destination, columns.indices.contains(source), columns.indices.contains(destination) else { + return self + } + var reordered = columns + let moved = reordered.remove(at: source) + reordered.insert(moved, at: destination) + return DataGridColumnSet(columns: reordered, frozenCount: frozenCount) + } +} diff --git a/apple/TablePro/Core/DataGrid/Engine/DataGridRowOrder.swift b/apple/TablePro/Core/DataGrid/Engine/DataGridRowOrder.swift new file mode 100644 index 0000000000..a1caa7c2c0 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Engine/DataGridRowOrder.swift @@ -0,0 +1,69 @@ +import Foundation + +struct DataGridRowOrder: Equatable { + private(set) var filteredIDs: [RowID]? + private var displayIndexByID: [RowID: Int]? + private(set) var revision: Int + + static let unfiltered = DataGridRowOrder(filteredIDs: nil, revision: 0) + + init(filteredIDs: [RowID]?, revision: Int) { + self.filteredIDs = filteredIDs + self.revision = revision + if let filteredIDs { + var index: [RowID: Int] = [:] + index.reserveCapacity(filteredIDs.count) + for (position, id) in filteredIDs.enumerated() { + index[id] = position + } + displayIndexByID = index + } else { + displayIndexByID = nil + } + } + + var isFiltered: Bool { filteredIDs != nil } + + func count(in tableRows: TableRows) -> Int { + filteredIDs?.count ?? tableRows.rows.count + } + + func rowID(at displayIndex: Int, in tableRows: TableRows) -> RowID? { + if let filteredIDs { + guard displayIndex >= 0, displayIndex < filteredIDs.count else { return nil } + return filteredIDs[displayIndex] + } + guard displayIndex >= 0, displayIndex < tableRows.rows.count else { return nil } + return tableRows.rows[displayIndex].id + } + + func storageIndex(at displayIndex: Int, in tableRows: TableRows) -> Int? { + guard let id = rowID(at: displayIndex, in: tableRows) else { return nil } + return tableRows.index(of: id) + } + + func row(at displayIndex: Int, in tableRows: TableRows) -> Row? { + storageIndex(at: displayIndex, in: tableRows).map { tableRows.rows[$0] } + } + + func displayIndex(of id: RowID, in tableRows: TableRows) -> Int? { + if let displayIndexByID { + return displayIndexByID[id] + } + return tableRows.index(of: id) + } + + func displayIndex(ofStorageIndex storageIndex: Int, in tableRows: TableRows) -> Int? { + guard storageIndex >= 0, storageIndex < tableRows.rows.count else { return nil } + guard isFiltered else { return storageIndex } + return displayIndex(of: tableRows.rows[storageIndex].id, in: tableRows) + } + + func rowIDs(atDisplayIndexes indexes: IndexSet, in tableRows: TableRows) -> [RowID] { + indexes.compactMap { rowID(at: $0, in: tableRows) } + } + + func displayIndexes(of ids: some Sequence, in tableRows: TableRows) -> IndexSet { + IndexSet(ids.compactMap { displayIndex(of: $0, in: tableRows) }) + } +} diff --git a/apple/TablePro/Core/DataGrid/Engine/DataGridValueTypes.swift b/apple/TablePro/Core/DataGrid/Engine/DataGridValueTypes.swift new file mode 100644 index 0000000000..83e62ab17f --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Engine/DataGridValueTypes.swift @@ -0,0 +1,82 @@ +import Foundation + +struct CellPosition: Hashable { + let row: Int + let column: Int +} + +enum DataGridChangeVocabulary: Equatable { + case pendingEdit + case comparison + case resultComparison +} + +struct RowVisualState: Equatable { + let isDeleted: Bool + let isInserted: Bool + let modifiedColumns: Set + let struckColumns: Set + // Pending cells staged as SQL, which draw like a pending DEFAULT. + let expressionColumns: Set + let vocabulary: DataGridChangeVocabulary + let highlight: RowHighlight + + init( + isDeleted: Bool, + isInserted: Bool, + modifiedColumns: Set, + struckColumns: Set = [], + expressionColumns: Set = [], + vocabulary: DataGridChangeVocabulary = .pendingEdit, + highlight: RowHighlight = .none + ) { + self.isDeleted = isDeleted + self.isInserted = isInserted + self.modifiedColumns = modifiedColumns + self.struckColumns = struckColumns + self.expressionColumns = expressionColumns + self.vocabulary = vocabulary + self.highlight = highlight + } + + func isModified(columnIndex: Int) -> Bool { + modifiedColumns.contains(columnIndex) + } + + func isStruck(columnIndex: Int) -> Bool { + struckColumns.contains(columnIndex) + } + + func isExpression(columnIndex: Int) -> Bool { + expressionColumns.contains(columnIndex) + } + + func highlighted(_ highlight: RowHighlight) -> RowVisualState { + RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: modifiedColumns, + struckColumns: struckColumns, + expressionColumns: expressionColumns, + vocabulary: vocabulary, + highlight: highlight + ) + } + + static let empty = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: []) +} + +extension RowVisualState { + func cellHighlightColor(forColumn column: Int) -> HighlightColor? { + guard !isDeleted, !isInserted else { return nil } + return highlight.cellRule(forColumn: column)?.color + } + + func drawnHighlightRule(forColumn column: Int) -> HighlightRule? { + guard !isDeleted, !isInserted else { return nil } + if !isModified(columnIndex: column), let cellRule = highlight.cellRule(forColumn: column) { + return cellRule + } + return highlight.rowRule + } +} diff --git a/apple/TablePro/Core/DataGrid/Engine/DisplayFormatArray.swift b/apple/TablePro/Core/DataGrid/Engine/DisplayFormatArray.swift new file mode 100644 index 0000000000..35452e62b1 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Engine/DisplayFormatArray.swift @@ -0,0 +1,18 @@ +import Foundation + +enum DisplayFormatArray { + // Readers index the array by column, so it always covers every column of the result. + static func setting( + _ format: ValueDisplayFormat?, + at columnIndex: Int, + in existing: [ValueDisplayFormat?], + columnCount: Int + ) -> [ValueDisplayFormat?] { + var formats = existing + while formats.count < max(columnCount, columnIndex + 1) { + formats.append(nil) + } + formats[columnIndex] = format + return formats + } +} diff --git a/apple/TablePro/Core/DataGrid/Engine/GridFindEngine.swift b/apple/TablePro/Core/DataGrid/Engine/GridFindEngine.swift new file mode 100644 index 0000000000..b9fbfbed40 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Engine/GridFindEngine.swift @@ -0,0 +1,355 @@ +import Foundation +import TableProPluginKit + +struct GridFindMatcher { + struct InvalidPattern: Error {} + + private enum Strategy { + case literal(needle: String, options: String.CompareOptions) + case expression(NSRegularExpression) + } + + private let strategy: Strategy + private let expandsTemplates: Bool + + // Nil when the term finds nothing. A literal term is trimmed; a regular expression is taken as typed. + static func make(term: String, options: GridFindState.Options) throws -> GridFindMatcher? { + if options.usesRegularExpression { + guard !term.isEmpty else { return nil } + return try compiled(pattern: term, options: options, expandsTemplates: true) + } + let needle = term.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return nil } + if options.matchesWholeWord { + return try compiled( + pattern: NSRegularExpression.escapedPattern(for: needle), + options: options, + expandsTemplates: false + ) + } + let compare: String.CompareOptions = options.matchesCase ? [] : [.caseInsensitive, .diacriticInsensitive] + return GridFindMatcher(strategy: .literal(needle: needle, options: compare), expandsTemplates: false) + } + + private static func compiled( + pattern: String, + options: GridFindState.Options, + expandsTemplates: Bool + ) throws -> GridFindMatcher { + let wrapped = options.matchesWholeWord ? "\\b(?:\(pattern))\\b" : pattern + let expression: NSRegularExpression + do { + expression = try NSRegularExpression(pattern: wrapped, options: options.matchesCase ? [] : [.caseInsensitive]) + } catch { + throw InvalidPattern() + } + return GridFindMatcher(strategy: .expression(expression), expandsTemplates: expandsTemplates) + } + + func matches(_ text: String) -> Bool { + switch strategy { + case .literal(let needle, let options): + return text.range(of: needle, options: options) != nil + case .expression(let expression): + return !Self.nonEmptyMatches(of: expression, in: text, limit: 1).isEmpty + } + } + + func replacing(in text: String, with template: String) -> (text: String, replacements: Int) { + switch strategy { + case .literal(let needle, let options): + return Self.replacingLiteral(needle, options: options, in: text, with: template) + case .expression(let expression): + let found = Self.nonEmptyMatches(of: expression, in: text, limit: nil) + guard !found.isEmpty else { return (text, 0) } + let effectiveTemplate = expandsTemplates ? template : NSRegularExpression.escapedTemplate(for: template) + let result = NSMutableString(string: text) + var offset = 0 + for match in found { + // Capture groups are read from the original text, where the match's ranges still point. + let replacement = expression.replacementString(for: match, in: text, offset: 0, template: effectiveTemplate) + result.replaceCharacters( + in: NSRange(location: match.range.location + offset, length: match.range.length), + with: replacement + ) + offset += (replacement as NSString).length - match.range.length + } + return (result as String, found.count) + } + } + + private static func replacingLiteral( + _ needle: String, + options: String.CompareOptions, + in text: String, + with template: String + ) -> (text: String, replacements: Int) { + var result = "" + var count = 0 + var cursor = text.startIndex + while cursor < text.endIndex, + let range = text.range(of: needle, options: options, range: cursor.. 0 else { return (text, 0) } + result.append(contentsOf: text[cursor...]) + return (result, count) + } + + // A pattern like `x*` matches the empty string in every cell, which highlights nothing. + private static func nonEmptyMatches( + of expression: NSRegularExpression, + in text: String, + limit: Int? + ) -> [NSTextCheckingResult] { + var found: [NSTextCheckingResult] = [] + expression.enumerateMatches(in: text, range: NSRange(text.startIndex..., in: text)) { match, _, stop in + guard let match, match.range.length > 0 else { return } + found.append(match) + if let limit, found.count >= limit { + stop.pointee = true + } + } + return found + } +} + +@MainActor +enum GridFindEngine { + struct Source { + let rows: TableRows + let order: DataGridRowOrder + let columns: DataGridColumnSet + var displayFormats: [ValueDisplayFormat?] = [] + var databaseType: DatabaseType? + var revealsInvisibleCharacters = false + } + + struct Result: Equatable { + var matches: [GridFindState.Match] = [] + var problem: String? + } + + struct Replacement: Equatable { + var edits: [DataGridCellEdit] = [] + var replacements = 0 + + var cells: Int { edits.count } + } + + static var invalidPatternProblem: String { + String(localized: "Invalid regular expression") + } + + static func problem(term: String, options: GridFindState.Options) -> String? { + do { + _ = try GridFindMatcher.make(term: term, options: options) + return nil + } catch { + return invalidPatternProblem + } + } + + static func search(_ term: String, options: GridFindState.Options, in source: Source) -> Result { + let matcher: GridFindMatcher? + do { + matcher = try GridFindMatcher.make(term: term, options: options) + } catch { + return Result(problem: invalidPatternProblem) + } + guard let matcher else { return Result() } + let searched = searchedColumns(options: options, in: source) + guard !searched.isEmpty else { return Result() } + + var found: [GridFindState.Match] = [] + let rowCount = source.order.count(in: source.rows) + for displayRow in 0.. [DataGridColumn] { + let candidates: [DataGridColumn] + if let scope = options.columnScope, let scoped = source.columns.column(id: scope) { + candidates = [scoped] + } else { + candidates = source.columns.columns + } + return candidates.filter { column in + FindMatcher.isSearchable(column.type, displayFormat: displayFormat(for: column, in: source)) + } + } + + // Nil for NULL, an empty cell, and bytes the column's format could not decode, which show as hex. + static func searchableText(in row: Row, column: DataGridColumn, source: Source) -> String? { + let dataIndex = column.dataIndex + guard dataIndex < row.values.count else { return nil } + let rawValue = row.values[dataIndex] + let format = displayFormat(for: column, in: source) + switch rawValue { + case .null: + return nil + case .text: + break + case .bytes(let data): + guard let format, + format.isApplicable(to: column.type, databaseType: source.databaseType), + ValueDisplayFormatter.apply(data, format: format, columnType: column.type) != nil else { return nil } + } + let shown = GridDisplayOrderResolver.displayValue( + rawValue, + columnType: column.type, + displayFormat: format, + databaseType: source.databaseType + ) + guard !shown.isEmpty else { return nil } + return source.revealsInvisibleCharacters ? shown : shown.shownWithoutMarks + } + + // Replaces in the stored text, not the shown one: a cell shows a capped, single-line copy of its value. + static func replacements( + for matches: [GridFindState.Match], + term: String, + options: GridFindState.Options, + template: String, + in source: Source, + isWritable: (DataGridColumn) -> Bool + ) -> Replacement { + guard let matcher = try? GridFindMatcher.make(term: term, options: options) else { return Replacement() } + var result = Replacement() + for match in matches { + guard let column = source.columns.column(id: match.columnID), + isWritable(column), + let row = source.rows.row(withID: match.rowID), + column.dataIndex < row.values.count, + case .text(let original) = row.values[column.dataIndex] else { continue } + let replaced = matcher.replacing(in: original, with: template) + guard replaced.replacements > 0, replaced.text != original else { continue } + result.edits.append(DataGridCellEdit( + rowID: match.rowID, + columnID: match.columnID, + value: .enteredText(replaced.text) + )) + result.replacements += replaced.replacements + } + return result + } + + static func position(of match: GridFindState.Match, in source: Source) -> GridCoord? { + guard let row = source.order.displayIndex(of: match.rowID, in: source.rows), + let column = source.columns.displayPosition(of: match.columnID) else { return nil } + return GridCoord(row: row, displayColumn: column) + } + + static func positions(of matches: [GridFindState.Match], in source: Source) -> [GridCoord?] { + matches.map { position(of: $0, in: source) } + } + + // MARK: - Stepping + + static func index(atOrAfter cell: GridCoord?, in positions: [GridCoord?]) -> Int? { + guard !positions.isEmpty else { return nil } + guard let cell else { return 0 } + return positions.firstIndex { candidate in + candidate.map { !precedes($0, cell) } ?? false + } ?? 0 + } + + static func index(after cell: GridCoord, in positions: [GridCoord?]) -> Int? { + guard !positions.isEmpty else { return nil } + return positions.firstIndex { candidate in + candidate.map { precedes(cell, $0) } ?? false + } ?? 0 + } + + static func index(before cell: GridCoord, in positions: [GridCoord?]) -> Int? { + guard !positions.isEmpty else { return nil } + return positions.lastIndex { candidate in + candidate.map { precedes($0, cell) } ?? false + } ?? positions.count - 1 + } + + // Steps from the current match, or from the cursor once the user has moved it off that match. + static func step( + from current: Int?, + active: GridCoord?, + positions: [GridCoord?], + forward: Bool + ) -> Int? { + guard !positions.isEmpty else { return nil } + let currentPosition = current.flatMap { positions.indices.contains($0) ? positions[$0] : nil } + if let active, currentPosition != active { + return forward ? index(after: active, in: positions) : index(before: active, in: positions) + } + guard let current, positions.indices.contains(current) else { + return forward ? 0 : positions.count - 1 + } + let count = positions.count + return forward ? (current + 1) % count : (current - 1 + count) % count + } + + // MARK: - Stepping through an owner's dataset + + // A dataset's matches arrive in display order; bisection keeps a step to a few position lookups. + static func sortedIndex(atOrAfter cell: GridCoord, count: Int, position: (Int) -> GridCoord?) -> Int? { + guard count > 0 else { return nil } + let index = partition(count: count) { candidate in position(candidate).map { precedes($0, cell) } ?? true } + return index < count ? index : 0 + } + + static func sortedIndex(after cell: GridCoord, count: Int, position: (Int) -> GridCoord?) -> Int? { + guard count > 0 else { return nil } + let index = partition(count: count) { candidate in position(candidate).map { !precedes(cell, $0) } ?? true } + return index < count ? index : 0 + } + + static func sortedIndex(before cell: GridCoord, count: Int, position: (Int) -> GridCoord?) -> Int? { + guard count > 0 else { return nil } + let index = partition(count: count) { candidate in position(candidate).map { precedes($0, cell) } ?? true } + return index > 0 ? index - 1 : count - 1 + } + + private static func partition(count: Int, isBelow: (Int) -> Bool) -> Int { + var low = 0 + var high = count + while low < high { + let middle = low + (high - low) / 2 + if isBelow(middle) { + low = middle + 1 + } else { + high = middle + } + } + return low + } + + nonisolated static func replacementMessage(replacements: Int, cells: Int) -> String { + guard cells > 0 else { return String(localized: "No matches to replace.") } + let matchPhrase = replacements == 1 + ? String(localized: "1 match") + : String(format: String(localized: "%@ matches"), replacements.formatted()) + let cellPhrase = cells == 1 + ? String(localized: "1 cell") + : String(format: String(localized: "%@ cells"), cells.formatted()) + return String(format: String(localized: "Replaced %1$@ in %2$@."), matchPhrase, cellPhrase) + } + + private static func displayFormat(for column: DataGridColumn, in source: Source) -> ValueDisplayFormat? { + column.dataIndex < source.displayFormats.count ? source.displayFormats[column.dataIndex] : nil + } + + private static func precedes(_ lhs: GridCoord, _ rhs: GridCoord) -> Bool { + lhs.row < rhs.row || (lhs.row == rhs.row && lhs.displayColumn < rhs.displayColumn) + } +} diff --git a/apple/TablePro/Core/DataGrid/Engine/GridSelectionReducer.swift b/apple/TablePro/Core/DataGrid/Engine/GridSelectionReducer.swift new file mode 100644 index 0000000000..06ef7c2957 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Engine/GridSelectionReducer.swift @@ -0,0 +1,474 @@ +import Foundation + +struct GridSelectionContext: Equatable { + let rowCount: Int + let columnCount: Int + + var isEmpty: Bool { rowCount <= 0 || columnCount <= 0 } + + fileprivate var allRows: ClosedRange { 0...max(rowCount - 1, 0) } + fileprivate var allColumns: ClosedRange { 0...max(columnCount - 1, 0) } + + fileprivate func clamp(_ cell: GridCoord) -> GridCoord { + GridCoord( + row: min(max(cell.row, 0), max(rowCount - 1, 0)), + displayColumn: min(max(cell.displayColumn, 0), max(columnCount - 1, 0)) + ) + } + + fileprivate func fit(_ selection: GridSelection) -> GridSelection { + selection.clamped(rowLimit: rowCount, columnLimit: columnCount) + } +} + +enum GridMoveDirection: Equatable { + case up + case down + case left + case right +} + +struct GridPointerModifiers: OptionSet { + let rawValue: Int + + static let shift = GridPointerModifiers(rawValue: 1 << 0) + static let command = GridPointerModifiers(rawValue: 1 << 1) +} + +struct GridSelectionDrag: Equatable { + enum Mode: Equatable { + case replace + case additive + case rows + } + + let origin: GridCoord + let base: GridSelection + let mode: Mode +} + +enum GridSelectionReducer { + static func single(_ cell: GridCoord) -> GridSelection { + GridSelection(rectangles: [GridRect(cell: cell)], activeCell: cell, anchor: cell) + } + + // A cell click selects its row natively and puts the cursor on the cell; a range forms only by drag or Shift. + static func cursor(_ cell: GridCoord, in c: GridSelectionContext) -> GridSelection { + guard !c.isEmpty else { return .empty } + let target = c.clamp(cell) + return GridSelection( + rectangles: [GridRect(rows: target.row...target.row, columns: c.allColumns)], + activeCell: target, + anchor: target, + mode: .cursor + ) + } + + // MARK: - Pointer + + static func press( + _ s: GridSelection, + at cell: GridCoord, + modifiers: GridPointerModifiers, + in c: GridSelectionContext + ) -> (GridSelection, GridSelectionDrag) { + guard !c.isEmpty else { + return (.empty, GridSelectionDrag(origin: cell, base: .empty, mode: .replace)) + } + let target = c.clamp(cell) + let current = c.fit(s) + switch Gesture(modifiers) { + case .toggle: + let base = cellBase(of: current, in: c) + let drag = GridSelectionDrag(origin: target, base: base, mode: .additive) + return (c.fit(toggling(target, in: base)), drag) + case .extend: + let anchor = current.anchor ?? current.activeCell ?? target + let drag = GridSelectionDrag(origin: anchor, base: .empty, mode: .replace) + guard anchor != target else { return (cursor(target, in: c), drag) } + return (c.fit(range(from: anchor, to: target)), drag) + case .replace: + return (cursor(target, in: c), GridSelectionDrag(origin: target, base: .empty, mode: .replace)) + } + } + + // A drag back onto its origin gives the press's own result, so a Command-press that never moves stays a toggle. + static func drag(_ d: GridSelectionDrag, to cell: GridCoord, in c: GridSelectionContext) -> GridSelection { + guard !c.isEmpty else { return .empty } + let target = c.clamp(cell) + let origin = c.clamp(d.origin) + let base = c.fit(d.base) + switch d.mode { + case .replace: + guard target != origin else { return cursor(origin, in: c) } + return c.fit(range(from: origin, to: target)) + case .additive: + guard target != origin else { return c.fit(toggling(origin, in: base)) } + return c.fit(GridSelection( + rectangles: base.rectangles + [GridRect.between(origin, target)], + activeCell: target, + anchor: origin, + columns: base.columns + )) + case .rows: + guard target.row != origin.row else { + return picking(c.fit(togglingRow(origin.row, in: base, activeColumn: origin.displayColumn, c))) + } + return picking(c.fit(addingRows( + min(origin.row, target.row)...max(origin.row, target.row), + to: base, + active: GridCoord(row: target.row, displayColumn: origin.displayColumn), + anchor: origin, + c + ))) + } + } + + static func rowHeaderPress( + _ s: GridSelection, + row: Int, + modifiers: GridPointerModifiers, + in c: GridSelectionContext + ) -> (GridSelection, GridSelectionDrag) { + guard !c.isEmpty else { + return (.empty, GridSelectionDrag(origin: GridCoord(row: row, displayColumn: 0), base: .empty, mode: .rows)) + } + let current = c.fit(s) + let column = activeColumn(of: current, in: c) + let target = c.clamp(GridCoord(row: row, displayColumn: column)) + switch Gesture(modifiers) { + case .toggle: + let drag = GridSelectionDrag(origin: target, base: current, mode: .rows) + return (picking(c.fit(togglingRow(target.row, in: current, activeColumn: column, c))), drag) + case .extend: + let anchor = GridCoord(row: current.anchor?.row ?? current.activeCell?.row ?? target.row, displayColumn: column) + let drag = GridSelectionDrag(origin: anchor, base: .empty, mode: .rows) + let rows = min(anchor.row, target.row)...max(anchor.row, target.row) + return (picking(c.fit(addingRows(rows, to: .empty, active: target, anchor: anchor, c))), drag) + case .replace: + let drag = GridSelectionDrag(origin: target, base: .empty, mode: .rows) + return (picking(c.fit(addingRows(target.row...target.row, to: .empty, active: target, anchor: target, c))), drag) + } + } + + // A plain row number press AppKit tracks for a drag. A reorder moves one row, so there the pressed row is picked alone. + static func rowNumberDragPress( + _ s: GridSelection, + row: Int, + keepsPickedRows: Bool, + in c: GridSelectionContext + ) -> GridSelection { + let current = c.fit(s) + if keepsPickedRows, current.picksRows, current.affectedRows.contains(row) { + return current + } + return rowHeaderPress(current, row: row, modifiers: [], in: c).0 + } + + static func columnHeaderCommandClick( + _ s: GridSelection, + column: Int, + modifiers: GridPointerModifiers, + in c: GridSelectionContext + ) -> GridSelection { + let current = c.fit(s) + guard !c.isEmpty, column >= 0, column < c.columnCount else { return current } + let base = cellBase(of: current, in: c) + if modifiers.contains(.shift), + let from = base.anchor?.displayColumn ?? base.activeCell?.displayColumn, + from != column { + return c.fit(addingColumns(from: from, to: column, in: base, c)) + } + if base.columns.contains(column) { + return c.fit(removingColumn(column, from: base, c)) + } + let addition = GridSelection.column(column, totalRows: c.rowCount) + return c.fit(base.isEmpty ? addition : base.union(addition)) + } + + // MARK: - Keyboard + + static func move( + _ s: GridSelection, + _ direction: GridMoveDirection, + extending: Bool, + toEdge: Bool, + in c: GridSelectionContext + ) -> GridSelection { + guard !c.isEmpty else { return .empty } + let current = c.fit(s) + guard let active = cursor(of: current, in: c) else { + return cursor(GridCoord(row: 0, displayColumn: 0), in: c) + } + let next = step(from: active, direction, toEdge: toEdge, in: c) + guard extending else { + // Left and Right over picked rows move the cursor and keep the rows, as the cursor did before. + if current.isRows, next.row == active.row, current.affectedRows.contains(next.row) { + return GridSelection(rectangles: current.rectangles, activeCell: next, anchor: next, mode: .cursor) + } + return cursor(next, in: c) + } + let anchor = current.anchor ?? active + guard anchor != next else { return cursor(next, in: c) } + return c.fit(range(from: anchor, to: next)) + } + + static func moveToColumnEdge( + _ s: GridSelection, + last: Bool, + extending: Bool, + in c: GridSelectionContext + ) -> GridSelection { + move(s, last ? .right : .left, extending: extending, toEdge: true, in: c) + } + + // With no cell to start from the selection comes back unchanged, which tells the view to leave the grid. + static func tab(_ s: GridSelection, forward: Bool, in c: GridSelectionContext) -> GridSelection { + guard !c.isEmpty else { return .empty } + let current = c.fit(s) + guard let active = cursor(of: current, in: c) else { return current } + let next: GridCoord + if forward { + if active.displayColumn + 1 < c.columnCount { + next = GridCoord(row: active.row, displayColumn: active.displayColumn + 1) + } else if active.row + 1 < c.rowCount { + next = GridCoord(row: active.row + 1, displayColumn: 0) + } else { + next = active + } + } else { + if active.displayColumn > 0 { + next = GridCoord(row: active.row, displayColumn: active.displayColumn - 1) + } else if active.row > 0 { + next = GridCoord(row: active.row - 1, displayColumn: c.columnCount - 1) + } else { + next = active + } + } + return cursor(next, in: c) + } + + // Select All picks every row and leaves no cursor, as NSTableView's own selectAll does. + static func selectAll(_ s: GridSelection, in c: GridSelectionContext) -> GridSelection { + guard !c.isEmpty else { return .empty } + return GridSelection( + rectangles: [GridRect(rows: c.allRows, columns: c.allColumns)], + activeCell: nil, + anchor: nil, + mode: .rows + ) + } + + static func widenToRows(_ s: GridSelection, in c: GridSelectionContext) -> GridSelection { + guard !c.isEmpty else { return .empty } + let current = c.fit(s) + let rows = current.affectedRows + guard let firstRow = rows.first else { return current } + let cursorCell = cursor(of: current, in: c) ?? GridCoord(row: firstRow, displayColumn: 0) + let active = rows.contains(cursorCell.row) + ? cursorCell + : GridCoord(row: firstRow, displayColumn: cursorCell.displayColumn) + let anchor = current.anchor.flatMap { rows.contains($0.row) ? $0 : nil } ?? active + return picking(c.fit(GridSelection(rectangles: rowRectangles(rows, c), activeCell: active, anchor: anchor))) + } + + // Without a context, whole rows are guessed from the shape: every rectangle spans column 0 to one shared last column. + // Escape drops a cell range back to its cursor's row, then the rows to nothing. + static func cancel(_ s: GridSelection, in c: GridSelectionContext) -> GridSelection { + guard !s.isEmpty, !s.isRows else { return .empty } + guard let active = s.activeCell ?? s.anchor ?? s.rectangles.first.map(topLeft) else { return .empty } + return cursor(active, in: c) + } + + static func rowsSelectedNatively( + _ rows: IndexSet, + previous: GridSelection, + in c: GridSelectionContext + ) -> GridSelection { + guard !c.isEmpty else { return .empty } + let valid = rows.filteredIndexSet { $0 >= 0 && $0 < c.rowCount } + guard let firstRow = valid.first, let lastRow = valid.last else { return .empty } + let column = activeColumn(of: previous, in: c) + let previousRows = previous.affectedRows + let row: Int + if let added = valid.subtracting(previousRows).last { + row = added + } else if let lost = previousRows.subtracting(valid).last { + row = lost > lastRow ? lastRow : firstRow + } else if let kept = previous.activeCell?.row, valid.contains(kept) { + row = kept + } else { + row = firstRow + } + let active = GridCoord(row: row, displayColumn: column) + let anchor = previous.anchor.flatMap { valid.contains($0.row) ? GridCoord(row: $0.row, displayColumn: column) : nil } + return GridSelection( + rectangles: rowRectangles(valid, c), + activeCell: previous.activeCell == nil && previous.picksRows ? nil : active, + anchor: anchor ?? active, + mode: previous.picksRows ? .rows : .cursor + ) + } + + // NSTableView answers -1 for a point past either end, so the point decides which end. + static func dragIndex(hit: Int, coordinate: CGFloat, first: Int, last: Int, lastMaxEdge: CGFloat) -> Int? { + guard first >= 0, last >= first else { return nil } + guard hit < 0 else { return min(max(hit, first), last) } + return coordinate >= lastMaxEdge ? last : first + } +} + +// MARK: - Helpers + +private enum Gesture { + case replace + case extend + case toggle + + init(_ modifiers: GridPointerModifiers) { + switch (modifiers.contains(.shift), modifiers.contains(.command)) { + case (false, true): self = .toggle + case (true, false): self = .extend + default: self = .replace + } + } +} + +private extension GridSelectionReducer { + static func picking(_ s: GridSelection) -> GridSelection { + var picked = s + picked.mode = s.isEmpty ? .cells : .rows + return picked + } + + // A Command-click on a cell or a header adds to the cells already chosen; a clicked row lends only its cursor cell. + static func cellBase(of s: GridSelection, in c: GridSelectionContext) -> GridSelection { + switch s.mode { + case .cells: + return s + case .cursor: + return s.activeCell.map(single) ?? .empty + case .rows: + return .empty + } + } + + static func range(from anchor: GridCoord, to active: GridCoord) -> GridSelection { + GridSelection(rectangles: [GridRect.between(anchor, active)], activeCell: active, anchor: anchor) + } + + static func topLeft(_ rect: GridRect) -> GridCoord { + GridCoord(row: rect.rows.lowerBound, displayColumn: rect.columns.lowerBound) + } + + static func cursor(of s: GridSelection, in c: GridSelectionContext) -> GridCoord? { + guard let cell = s.activeCell ?? s.anchor ?? s.rectangles.first.map(topLeft) else { return nil } + return c.clamp(cell) + } + + static func activeColumn(of s: GridSelection, in c: GridSelectionContext) -> Int { + let column = s.activeCell?.displayColumn ?? s.anchor?.displayColumn ?? 0 + return min(max(column, 0), max(c.columnCount - 1, 0)) + } + + static func step( + from cell: GridCoord, + _ direction: GridMoveDirection, + toEdge: Bool, + in c: GridSelectionContext + ) -> GridCoord { + switch direction { + case .up: + return GridCoord(row: toEdge ? 0 : max(0, cell.row - 1), displayColumn: cell.displayColumn) + case .down: + return GridCoord(row: toEdge ? c.rowCount - 1 : min(c.rowCount - 1, cell.row + 1), displayColumn: cell.displayColumn) + case .left: + return GridCoord(row: cell.row, displayColumn: toEdge ? 0 : max(0, cell.displayColumn - 1)) + case .right: + return GridCoord( + row: cell.row, + displayColumn: toEdge ? c.columnCount - 1 : min(c.columnCount - 1, cell.displayColumn + 1) + ) + } + } + + static func toggling(_ cell: GridCoord, in base: GridSelection) -> GridSelection { + if base.contains(cell) { + return base.removing(cell: cell) + } + return GridSelection( + rectangles: base.rectangles + [GridRect(cell: cell)], + activeCell: cell, + anchor: cell, + columns: base.columns + ) + } + + static func rowRectangles(_ rows: IndexSet, _ c: GridSelectionContext) -> [GridRect] { + rows.rangeView.map { GridRect(rows: $0.lowerBound...($0.upperBound - 1), columns: c.allColumns) } + } + + static func addingRows( + _ rows: ClosedRange, + to base: GridSelection, + active: GridCoord, + anchor: GridCoord, + _ c: GridSelectionContext + ) -> GridSelection { + GridSelection( + rectangles: base.rectangles + [GridRect(rows: rows, columns: c.allColumns)], + activeCell: active, + anchor: anchor, + columns: base.columns + ) + } + + static func togglingRow(_ row: Int, in base: GridSelection, activeColumn: Int, _ c: GridSelectionContext) -> GridSelection { + let cell = GridCoord(row: row, displayColumn: activeColumn) + guard base.columns(in: row).contains(integersIn: 0.. GridCoord? { + guard let coord, kept.contains(where: { $0.contains(coord) }) else { return nil } + return coord + } + let active = inside(base.activeCell) ?? topLeft(last) + // A picked column lost a row, so it no longer spans the grid. + return GridSelection(rectangles: kept, activeCell: active, anchor: inside(base.anchor) ?? active) + } + + static func addingColumns(from: Int, to column: Int, in base: GridSelection, _ c: GridSelectionContext) -> GridSelection { + let span = min(from, column)...max(from, column) + let added = span.filter { !base.columns.contains($0) } + return GridSelection( + rectangles: base.rectangles + added.map { GridRect(rows: c.allRows, columns: $0...$0) }, + activeCell: GridCoord(row: 0, displayColumn: column), + anchor: GridCoord(row: 0, displayColumn: from), + columns: base.columns.union(IndexSet(integersIn: span)) + ) + } + + static func removingColumn(_ column: Int, from base: GridSelection, _ c: GridSelectionContext) -> GridSelection { + let dropped = GridRect(rows: c.allRows, columns: column...column) + let kept = base.rectangles.filter { $0 != dropped } + guard let last = kept.last else { return .empty } + var markers = base.columns + markers.remove(column) + let active = topLeft(last) + return GridSelection(rectangles: kept, activeCell: active, anchor: active, columns: markers) + } +} diff --git a/apple/TablePro/Core/DataGrid/Engine/GridSelectionSnapshot.swift b/apple/TablePro/Core/DataGrid/Engine/GridSelectionSnapshot.swift new file mode 100644 index 0000000000..14e8eb047c --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Engine/GridSelectionSnapshot.swift @@ -0,0 +1,101 @@ +import Foundation + +// GridSelection names display positions; this survives reloads, filters, reorders and remounts. +struct GridSelectionSnapshot: Equatable { + struct Cell: Hashable { + let rowID: RowID + let columnID: DataGridColumnID + } + + var rowIDs: [RowID] + var rectangles: [Rectangle] + var activeCell: Cell? + var anchor: Cell? + var pickedColumnIDs: [DataGridColumnID] = [] + var mode: GridSelection.Mode = .cells + + struct Rectangle: Hashable { + let rowIDs: [RowID] + let columnIDs: [DataGridColumnID] + } + + static let empty = GridSelectionSnapshot(rowIDs: [], rectangles: [], activeCell: nil, anchor: nil) + + var isEmpty: Bool { rowIDs.isEmpty && activeCell == nil } + + // Rows named without rectangles are whole rows. + init(rowIDs: [RowID], rectangles: [Rectangle], activeCell: Cell?, anchor: Cell?) { + self.rowIDs = rowIDs + self.rectangles = rectangles + self.activeCell = activeCell + self.anchor = anchor + self.mode = rectangles.isEmpty && !rowIDs.isEmpty ? .rows : .cells + } + + init( + _ selection: GridSelection, + order: DataGridRowOrder, + rows: TableRows, + columns: DataGridColumnSet + ) { + func cell(_ coord: GridCoord?) -> Cell? { + guard let coord, + let rowID = order.rowID(at: coord.row, in: rows), + let column = columns.column(at: coord.displayColumn) else { return nil } + return Cell(rowID: rowID, columnID: column.id) + } + self.rowIDs = order.rowIDs(atDisplayIndexes: selection.affectedRows, in: rows) + self.rectangles = selection.rectangles.map { rect in + Rectangle( + rowIDs: rect.rows.compactMap { order.rowID(at: $0, in: rows) }, + columnIDs: rect.columns.compactMap { columns.column(at: $0)?.id } + ) + } + self.activeCell = cell(selection.activeCell) + self.anchor = cell(selection.anchor) + self.pickedColumnIDs = selection.columns.compactMap { columns.column(at: $0)?.id } + self.mode = selection.mode + } + + func resolved(order: DataGridRowOrder, rows: TableRows, columns: DataGridColumnSet) -> GridSelection { + func coord(_ cell: Cell?) -> GridCoord? { + guard let cell, + let row = order.displayIndex(of: cell.rowID, in: rows), + let column = columns.displayPosition(of: cell.columnID) else { return nil } + return GridCoord(row: row, displayColumn: column) + } + var resolvedRects: [GridRect] = [] + // Rows span every column the grid presents later too, a column added or shown included. + if mode != .cells { + if !columns.isEmpty { + let all = 0...(columns.count - 1) + for range in order.displayIndexes(of: rowIDs, in: rows).rangeView { + resolvedRects.append(GridRect(rows: range.lowerBound...(range.upperBound - 1), columns: all)) + } + } + } else { + // Rows and columns that are no longer next to each other split the block, so no cell joins it. + for rectangle in rectangles { + let displayRows = order.displayIndexes(of: rectangle.rowIDs, in: rows) + let displayColumns = IndexSet(rectangle.columnIDs.compactMap { columns.displayPosition(of: $0) }) + for rowRun in displayRows.rangeView { + for columnRun in displayColumns.rangeView { + resolvedRects.append(GridRect( + rows: rowRun.lowerBound...(rowRun.upperBound - 1), + columns: columnRun.lowerBound...(columnRun.upperBound - 1) + )) + } + } + } + } + let active = coord(activeCell) + ?? resolvedRects.first.map { GridCoord(row: $0.rows.lowerBound, displayColumn: $0.columns.lowerBound) } + return GridSelection( + rectangles: resolvedRects, + activeCell: active, + anchor: coord(anchor) ?? active, + columns: IndexSet(pickedColumnIDs.compactMap { columns.displayPosition(of: $0) }), + mode: mode + ) + } +} diff --git a/apple/TablePro/Core/DataGrid/GridFindState.swift b/apple/TablePro/Core/DataGrid/GridFindState.swift new file mode 100644 index 0000000000..955e70e629 --- /dev/null +++ b/apple/TablePro/Core/DataGrid/GridFindState.swift @@ -0,0 +1,129 @@ +import Combine +import Foundation + +@MainActor +final class GridFindState: ObservableObject { + struct Options: Equatable { + var matchesCase = false + var matchesWholeWord = false + var usesRegularExpression = false + var columnScope: DataGridColumnID? + } + + struct Match: Hashable { + let rowID: RowID + let columnID: DataGridColumnID + } + + @Published var isVisible = false + @Published var showsReplace = false + @Published var term = "" + @Published var replacement = "" + @Published var options = Options() + @Published private(set) var matches: [Match] = [] + @Published private(set) var currentIndex: Int? + @Published private(set) var problem: String? + @Published private(set) var isSearching = false + // The user stopped a whole-dataset search before it finished. + @Published private(set) var searchStopped = false + + weak var datasetFinder: (any DataGridDatasetFinding)? + private(set) var datasetQuery: GridDatasetFindQuery? + private(set) var datasetSearch: Task? + private var datasetGeneration = 0 + + var currentMatch: Match? { + guard let currentIndex, matches.indices.contains(currentIndex) else { return nil } + return matches[currentIndex] + } + + func setResults(_ matches: [Match], current: Int?, problem: String?) { + self.matches = matches + self.currentIndex = current.flatMap { matches.indices.contains($0) ? $0 : nil } + self.problem = problem + } + + func setCurrentIndex(_ index: Int?) { + currentIndex = index.flatMap { matches.indices.contains($0) ? $0 : nil } + } + + // The next match takes the removed one's place, wrapping to the first. + func removeMatch(at index: Int) { + guard matches.indices.contains(index) else { return } + matches.remove(at: index) + currentIndex = matches.isEmpty ? nil : (index < matches.count ? index : 0) + } + + func reset() { + cancelDatasetSearch() + datasetQuery = nil + searchStopped = false + term = "" + replacement = "" + matches = [] + currentIndex = nil + problem = nil + } + + // MARK: - Whole-dataset search + + // The search hands this generation back when it finishes; any later start or cancel makes it stale. + func beginDatasetSearch(_ query: GridDatasetFindQuery?, clearsResults: Bool) -> Int { + cancelDatasetSearch() + datasetQuery = query + searchStopped = false + problem = nil + if clearsResults { + matches = [] + currentIndex = nil + } + return datasetGeneration + } + + func attachDatasetSearch(_ task: Task, generation: Int) { + guard generation == datasetGeneration else { + task.cancel() + return + } + datasetSearch = task + isSearching = true + } + + // A change the owner reports waits for the owner to finish it; a later change replaces the wait. + func deferDatasetSearch(_ task: Task) { + datasetSearch?.cancel() + datasetGeneration &+= 1 + datasetSearch = task + } + + func isCurrentDatasetSearch(_ generation: Int) -> Bool { + generation == datasetGeneration + } + + func finishDatasetSearch(_ found: [Match], current: Int?, generation: Int) { + guard generation == datasetGeneration else { return } + datasetSearch = nil + isSearching = false + setResults(found, current: current, problem: nil) + } + + func abandonDatasetSearch(generation: Int) { + guard generation == datasetGeneration else { return } + datasetSearch = nil + isSearching = false + searchStopped = true + } + + func stopDatasetSearch() { + guard datasetSearch != nil else { return } + cancelDatasetSearch() + searchStopped = true + } + + func cancelDatasetSearch() { + datasetSearch?.cancel() + datasetSearch = nil + datasetGeneration &+= 1 + isSearching = false + } +} diff --git a/apple/TablePro/Core/DataGrid/GridViewportResolver.swift b/apple/TablePro/Core/DataGrid/GridViewportResolver.swift index f148d718b2..91dd9d2d77 100644 --- a/apple/TablePro/Core/DataGrid/GridViewportResolver.swift +++ b/apple/TablePro/Core/DataGrid/GridViewportResolver.swift @@ -15,7 +15,7 @@ internal struct GridViewportSnapshot: Equatable { static let top = GridViewportSnapshot(firstVisiblePosition: 0, firstVisibleOffset: 0, firstVisibleKey: nil) - /// The selection alone, for a re-read that starts at the first row but keeps what is selected. + // For a re-read that starts at the first row but keeps what is selected. var selectionOnly: GridViewportSnapshot { GridViewportSnapshot(firstVisiblePosition: 0, firstVisibleOffset: 0, firstVisibleKey: nil, selectedKeys: selectedKeys) } @@ -65,8 +65,9 @@ internal enum GridViewportResolver { isCellModified: (RowID, Int) -> Bool = { _, _ in false } ) -> GridViewportSnapshot { let isKeyed = !keyColumns.isEmpty && tableRows.count <= keyedRowLimit + let order = DataGridRowOrder(filteredIDs: displayIDs, revision: 0) func key(_ displayRow: Int) -> [String: String]? { - DisplayRowMapping.row(forDisplay: displayRow, displayIDs: displayIDs, in: tableRows) + order.row(at: displayRow, in: tableRows) .flatMap { savedKey(of: $0, in: tableRows, keyColumns: keyColumns, isCellModified: isCellModified) } } return GridViewportSnapshot( @@ -139,7 +140,7 @@ internal enum GridViewportResolver { ) } - /// The snapshot counted display positions, which a value filter makes differ from storage. + // The snapshot counted display positions, which a value filter makes differ from storage. let shown = displayIDs ?? incoming.rows.map(\.id) guard !shown.isEmpty else { return .firstRow } let position = min(snapshot.firstVisiblePosition, shown.count - 1) @@ -151,8 +152,7 @@ internal enum GridViewportResolver { ) } - /// One pass over the incoming rows, so restoring a large selection does not rescan them per row. - /// A key that repeats matches nothing, as in `uniqueRow(matching:in:)`. + // One pass, so a large selection does not rescan per row; a repeated key matches nothing, as in uniqueRow(matching:in:). private static func rows( matching keys: [[String: String]], in incoming: TableRows, diff --git a/apple/TablePro/Core/Menu/EditMenuBuilder.swift b/apple/TablePro/Core/Menu/EditMenuBuilder.swift index 92defe5812..f2b61bf80c 100644 --- a/apple/TablePro/Core/Menu/EditMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/EditMenuBuilder.swift @@ -5,14 +5,13 @@ import AppKit -/// Start Dictation and Emoji & Symbols are omitted on purpose: the system appends -/// them to the Edit menu itself. +// Start Dictation and Emoji & Symbols are omitted on purpose: the system appends them to the Edit menu itself. @MainActor enum EditMenuBuilder { - /// Retained for the menu's lifetime, which is the app's: `NSMenu.delegate` is unowned. + // Retained for the app's lifetime: `NSMenu.delegate` is unowned. private static let delegate = EditMenuDelegate() - /// A key of its own: French names this menu "Édition" but an Edit button "Modifier". + // A key of its own: French names this menu "Édition" but an Edit button "Modifier". static var title: String { String(localized: "menu.edit", defaultValue: "Edit", comment: "The Edit menu in the menu bar") } @@ -95,10 +94,7 @@ enum EditMenuBuilder { action: #selector(MainSplitViewController.insertDocument(_:)) ), MenuItemFactory.separator, - /// Not Undo, and deliberately not next to it. Undo takes back an edit the user has not - /// saved yet and costs nothing; this one writes to the database, needs review and can - /// be refused. The HIG scopes Undo to the current document's content, and every Apple - /// reversal of something already committed is a separate, named command. + // Not Undo, and not next to it: this writes to the database and can be refused; the HIG scopes Undo to uncommitted content. MenuItemFactory.item( String(localized: "Restore Previous Values…"), action: #selector(MainSplitViewController.restorePreviousValues(_:)), @@ -132,19 +128,19 @@ enum EditMenuBuilder { MenuItemFactory.separator, MenuItemFactory.item( String(localized: "Copy Rows"), - action: #selector(MainSplitViewController.copySelectedRows(_:)), + action: #selector(GridRowCopyCommandResponding.copySelectedRows(_:)), shortcut: .copyRowsExplicit, keyboard: keyboard ), MenuItemFactory.item( String(localized: "Copy with Headers"), - action: #selector(MainSplitViewController.copyRowsWithHeaders(_:)), + action: #selector(GridRowCopyCommandResponding.copyRowsWithHeaders(_:)), shortcut: .copyWithHeaders, keyboard: keyboard ), MenuItemFactory.item( String(localized: "Copy as JSON"), - action: #selector(MainSplitViewController.copyRowsAsJson(_:)), + action: #selector(GridRowCopyCommandResponding.copyRowsAsJson(_:)), shortcut: .copyAsJson, keyboard: keyboard ), diff --git a/apple/TablePro/Core/Menu/GridCopyCommandResponding.swift b/apple/TablePro/Core/Menu/GridCopyCommandResponding.swift index 817306eb56..e293cc1eb9 100644 --- a/apple/TablePro/Core/Menu/GridCopyCommandResponding.swift +++ b/apple/TablePro/Core/Menu/GridCopyCommandResponding.swift @@ -16,3 +16,11 @@ import AppKit func copyRowsAsInsertStatements(_ sender: Any?) func copyRowsAsUpdateStatements(_ sender: Any?) } + +// Copy Rows, Copy with Headers and Copy as JSON. A focused grid answers for its own selection in any window. +@MainActor +@objc protocol GridRowCopyCommandResponding { + func copySelectedRows(_ sender: Any?) + func copyRowsWithHeaders(_ sender: Any?) + func copyRowsAsJson(_ sender: Any?) +} diff --git a/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift b/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift index 4adc66e8e4..fb798aa997 100644 --- a/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift +++ b/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift @@ -6,14 +6,7 @@ import AppKit import Foundation -/// Reads live app state into the value objects a script sees. -/// -/// The scriptable objects stay dumb on purpose: they hold strings and codes, and every question that -/// needs the running app is answered here. That keeps one place to check when asking what a script -/// can see, which for a database client is the question that matters. -/// -/// A connection whose **External Clients** level is Blocked is not listed at all, so a script cannot -/// discover its name, host or database. `ExternalConnectionDirectory` drops it, the same list MCP reads. +// The one place a script's view of the app is decided. A Blocked External Clients connection is dropped, the same list MCP reads. @MainActor internal enum ScriptingSnapshot { // MARK: - Connections @@ -26,14 +19,7 @@ internal enum ScriptingSnapshot { connections().first { $0.connectionId == id } } - /// Whether a script may see this connection at all. - /// - /// Asked again on every path that can reach a connection without having resolved it through - /// `connections()` first. `current tab` is the one that can: it starts from the front window, - /// not from the element list, so without this a Blocked connection on screen would hand a script - /// its tab and, through the tab, its rows. - /// Read from storage rather than from the session, because nothing reconciles the level onto a - /// live session, and a connection deleted while its session is still up is not visible at all. + // Rechecked on paths that skip `connections()` (current tab). Read from storage: nothing syncs the level onto a live session. internal static func isVisibleToScripts(connectionId: UUID) -> Bool { guard let stored = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) else { @@ -42,8 +28,6 @@ internal enum ScriptingSnapshot { return stored.externalAccess != .blocked } - /// The connection the frontmost window is showing, which is what an unqualified script means by - /// "the one I am looking at". internal static func currentConnection() -> ScriptConnection? { guard let id = frontmostCoordinator()?.connectionId else { return nil } return connection(withId: id) @@ -76,12 +60,7 @@ internal enum ScriptingSnapshot { ) } - /// Waits for a table tab to appear after asking for it to be opened. - /// - /// Opening a tab runs through window creation and a connect, neither of which the router waits - /// on, so a command that returned immediately would hand the script a reference to nothing. The - /// deadline is what turns a connection that never opens into an error the script can catch - /// rather than a wait that only ends at AppleScript's own two-minute timeout. + // The router doesn't wait on window creation or connect; the deadline beats AppleScript's two-minute timeout with a catchable error. internal static func awaitTab( connectionId: UUID, tableName: String, @@ -103,10 +82,7 @@ internal enum ScriptingSnapshot { return nil } - /// The table name alone is not an identity. Another database or schema on the same connection - /// can have a table of the same name already open, and returning that one hands the script a - /// reference to rows it did not ask for. A database or schema the script left out means the one - /// being browsed, which is where the open put the tab, not any of them. + // Name alone isn't identity: a same-named table in another database or schema may be open. Omitted parts mean the browsed one. private static func matches( _ tab: ScriptTab, tableName: String, @@ -148,10 +124,7 @@ internal enum ScriptingSnapshot { // MARK: - Results - /// The rows a tab is showing, or only the ones selected in its grid. - /// - /// Selection indices are display positions, not indices into the row buffer, so they are - /// resolved through `DisplayedResultReader` rather than used to subscript anything. + // Selection indices are display positions, so they resolve through `DisplayedResultReader` and never subscript the buffer. internal static func result( ofTab tabId: UUID, connectionId: UUID, @@ -164,24 +137,15 @@ internal enum ScriptingSnapshot { return ScriptResultEncoder.empty() } - /// The live grid only answers for the tab it is mounted in, and only while a data grid owns - /// its selection: in Structure or Chart mode those indices belong to the schema grid or are - /// left over from the last data grid, and applying them to the row buffer returns unrelated - /// rows. Every other tab's selection is the one persisted on the tab itself. + // Each data tab's model keeps its own selection, mounted or not, so every tab answers for itself. let isSelectedTab = coordinator.tabManager.selectedTabId == tabId - let ownsLiveSelection = isSelectedTab && GridSelectionOwner.resolve( - tabType: tab.tabType, - resultsViewMode: tab.display.resultsViewMode - ) == .dataGrid - let selected = selectedOnly - ? (ownsLiveSelection ? coordinator.selectionState.indices : tab.selectedDisplayRows) - : [] + let selection = coordinator.existingGridModel(forTab: tabId)?.selection.affectedRows ?? IndexSet() + let selected = selectedOnly ? Set(selection) : [] if selectedOnly, selected.isEmpty { return ScriptResultEncoder.empty() } - /// A row marked for deletion is still in the buffer and is deliberately not in the result the - /// grid is showing, so it is not in what a script reads either. + // A row marked for deletion stays in the buffer but not in the grid's result, so a script doesn't read it either. let deleted = isSelectedTab ? coordinator.changeManager.deletedRowIDs : tab.pendingChanges.deletedRowIDs @@ -223,10 +187,7 @@ internal enum ScriptingSnapshot { } } - /// One window hosts several connections, so every one of their coordinators reports the same - /// `contentWindow` and picking the first match returns an arbitrary one. `coordinator(forWindow:)` - /// asks the window which workspace it is showing, which is the only thing "the one I am looking - /// at" can mean. + // One window hosts several connections that share `contentWindow`; `coordinator(forWindow:)` asks which workspace it shows. private static func frontmostCoordinator() -> MainContentCoordinator? { for window in [NSApp.keyWindow, NSApp.mainWindow].compactMap({ $0 }) { if let match = MainContentCoordinator.coordinator(forWindow: window) { diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift index 5c18c5f4ba..a44b67e21c 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift @@ -5,15 +5,10 @@ import AppKit -/// `undo:`, `redo:`, `copy:`, `paste:` and `delete:` are declared here only as the fallback a -/// window reaches when no more specific responder claimed them. The SQL editor's -/// text view and the data grid implement the same selectors and sit closer to the -/// first responder, so AppKit stops there first. -/// -/// `paste:` has to be one of them. AppKit disables a menu item that no responder in the chain -/// implements, and a disabled item still owns its key equivalent, so with focus anywhere that -/// does not paste (the sidebar, the workspace rail, a toolbar control) Command+V was swallowed -/// for the whole window with nothing to explain it. +// The main window answers Copy Rows, Copy with Headers and Copy as JSON for a grid that does not have focus. +extension MainSplitViewController: GridRowCopyCommandResponding {} + +// Fallback responders; paste: must be here, or AppKit disables Paste and the disabled item swallows Cmd+V window-wide. extension MainSplitViewController { @objc func undo(_ sender: Any?) { commandActions?.undoChange() @@ -57,12 +52,7 @@ extension MainSplitViewController { NSApp.sendAction(#selector(NSResponder.cancelOperation(_:)), to: nil, from: nil) } - /// One Find command, and the responder chain decides what finding means. A focused editor declares - /// the same selectors on its own `TextViewController`, closer to the first responder, so it searches - /// its own text without this class knowing which editor is focused. Reaching here means no editor - /// holds focus: an active table result grid filters its rows, and every other view hands the find - /// to the editor the window is built around, so Cmd+F still reaches the query text while the grid - /// or sidebar has focus. + // Reached when no editor or grid has focus: a table tab's grid finds, else the window's editor, so sidebar Cmd+F works. @objc func performFind(_ sender: Any?) { guard commandActions?.canUseTableResultCommands == true else { EditorEventRouter.shared.showFindPanelForKeyWindow() @@ -71,14 +61,21 @@ extension MainSplitViewController { commandActions?.showFindBar() } - /// Find and Replace and Use Selection for Find are the editor's alone: the result grid's find bar - /// has no replacement field, and a selected cell is a value rather than a search term. + // The grid's find bar has a replacement field and reads a selected cell as a term, so both follow Find. @objc func performFindAndReplace(_ sender: Any?) { - EditorEventRouter.shared.showFindAndReplacePanelForKeyWindow() + guard commandActions?.canUseTableResultCommands == true else { + EditorEventRouter.shared.showFindAndReplacePanelForKeyWindow() + return + } + commandActions?.showFindBar(replace: true) } @objc func useSelectionForFind(_ sender: Any?) { - EditorEventRouter.shared.useSelectionForFindInKeyWindow() + guard menuValidationContext.usesGridSelectionForFind else { + EditorEventRouter.shared.useSelectionForFindInKeyWindow() + return + } + commandActions?.useGridSelectionForFind() } @objc func findNext(_ sender: Any?) { diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 98e7d2f74d..94cef339a1 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -5,120 +5,85 @@ import AppKit -/// Everything the menu bar needs to decide whether a command applies, captured once -/// per validation pass. Keeping it a plain value keeps `isEnabled` pure and testable, -/// the same split `ToolbarContextResolver` uses for the toolbar. +// A plain value keeps `isEnabled` pure and testable, the same split `ToolbarContextResolver` uses for the toolbar. struct MenuValidationContext: Equatable { - /// Comes from the window's own `ConnectionWindowPhase`, never from the presence of a - /// coordinator: the coordinator deliberately outlives a lost session so a reconnect keeps - /// the user's tabs, which made every connection-scoped command stay lit while dialing. - /// True whenever the window is showing a connection, connected or not, so a pane that - /// failed to dial can still be dismissed. + // True for any connection on screen, connected or not, so a pane that failed to dial can still be dismissed. var hasSelectedWorkspace = false + // From `ConnectionWindowPhase`, not the coordinator, which outlives a lost session and kept commands lit while dialing. var isConnected = false - /// Whether the connection on screen is showing its agent rather than its objects. The session - /// commands are the rail's, and the rail is only there in Agent mode. + // Session commands belong to the rail, which only Agent mode shows. var isAgentMode = false - /// The session a session command acts on: the one its menu item names, or the one the rail has - /// highlighted. Nil when there is none, which is what dims Open, Close and Delete Session. + // The session the menu item names, else the rail's highlighted one; nil dims Open, Close and Delete Session. var agentSessionTarget: AgentSessionStatus? - /// Whether the connection on screen has an assistant conversation for its three commands to act - /// on. False until something opens the assistant, and false with the AI feature off, which is - /// what the pane's own menu already says by dimming the same three. + // False until the assistant is opened, and with AI off, matching how the pane's own menu dims the same three. var hasAssistantConversation = false - /// Whether any conversation has been stored, which is what Conversation History lists and what - /// Clear Recents throws away. Separate from the one above: a conversation started and never sent - /// in has a model and nothing to switch to. + // Separate from `hasAssistantConversation`: a conversation started and never sent has a model and nothing to switch to. var hasStoredConversations = false var isReadOnly = false var canUseTableResultCommands = false var canUseGridFindCommands = false - /// Jump to Column reads the mounted data grid, so it needs one on screen with columns to list. var canJumpToColumn = false - /// Each Focus command names a pane, so each needs that pane to exist and to hold a view that can - /// take the keyboard. A command that focuses nothing is a command that should be dimmed. var canFocusObjectList = false var canFocusEditor = false var canFocusResults = false var canFocusInspector = false var canFocusAssistant = false var canPresentHighlightRules = false - /// Save As writes the selected tab's SQL, so it needs a query tab and not merely a connection. var isQueryTab = false - /// Export Results exports the selected tab's rows, so an empty grid has nothing to offer. var hasResultRows = false var canExportDataDictionary = false var isCurrentTabEditable = false - /// Add Row and Duplicate Row stage `DEFAULT` for every column the server fills in, which only - /// the table's own schema names. Until it lands, the result set's own metadata reports far less, - /// and an identity column would be staged as NULL that the server refuses. + // Add and Duplicate Row stage DEFAULT from the table schema; result metadata alone stages NULL the server refuses for identity. var isCurrentTabSchemaResolved = false var canRestorePreviousValues = false - /// Insert Document writes a whole document, which only an engine that stores documents offers, - /// on a collection tab with no staged grid edits. var canInsertDocument = false - /// Edit Document also needs exactly one selected row that the driver can find its document by. var canEditDocument = false var isQueryExecuting = false - /// Whether Stop still has something to act on. A batch whose `COMMIT` is on the wire is - /// executing and unstoppable at the same time, and `Cmd+.` must dim rather than fire into it. + // A batch whose COMMIT is on the wire is executing yet unstoppable, and Cmd+. must dim rather than fire into it. var isQueryStoppable = false var hasQueryText = false var canRunAIQueryActions = false var canClearQuery = false var canClearResults = false var hasPendingChanges = false + // Save commits an edit still open in the grid before it looks for changes. + var hasOpenGridEdit = false var hasDataPendingChanges = false var hasRowSelection = false - /// Copy with headers and copy as JSON read the result grid's columns, so they need the data - /// grid's selection specifically, not the structure grid's. + // Copy with headers and Copy as JSON read the data grid's columns, so the structure grid's selection does not count. var hasDataGridRowSelection = false var hasTableSelection = false - /// Whether every selected object is one the engine can truncate. Separate from - /// `hasTableSelection` because a view is a perfectly good selection and a hopeless truncate. + // Separate from `hasTableSelection`: a view is a valid selection but cannot be truncated. var canTruncateSelectedTables = false - /// Whether every selected object is one the engine has a drop statement for. An engine with - /// no DDL for it must not be offered Delete, or the app invents SQL it cannot run. + // An engine with no drop DDL for the object must not offer Delete, or the app invents SQL it cannot run. var canDropSelectedTables = false - /// An editable tab only answers Delete when a row is selected. Without the row check the item - /// stayed enabled over a grid with no selection, fell through to the sidebar's drop path and - /// did nothing there. + // Without the row check Delete stayed lit with no selection and fell through to the sidebar's drop path, doing nothing. var canDeleteSelectedRows: Bool { isCurrentTabEditable && hasRowSelection } - /// Whether the window-level `paste:` fallback would actually paste. AppKit hands a disabled - /// item its key equivalent regardless, so an item enabled over a handler that returns at its - /// first guard swallows Command+V with no feedback. + // AppKit sends the key equivalent anyway, so enabling over a handler that bails at its first guard swallows Cmd+V silently. var canPasteRows = false var canCloseOtherTabs = false var canCloseTabsForOtherDatabases = false var canCloseAllTabs = false var canPinEditorTab = false var canMoveTabToConnection = false - /// How many editor tabs the connection on screen has open. var editorTabCount = 0 - /// The tab a Select Tab item names, read off the item being validated. Nil when the item is not - /// one of them. + // Read off the Select Tab item being validated; nil for any other item. var requestedTabNumber: Int? - /// Whether the connections this window can show hold two tabs between them, which is the least - /// Control-Tab needs to switch anywhere. + // Two tabs across the connections this window can show, the least Control-Tab needs to switch anywhere. var hasRecentTabToSwitchTo = false - /// Whether the window sits in a window tab group, where Control-Tab falls back to switching the - /// window's tabs when there is no editor tab to switch to. + // Control-Tab falls back to switching the window's tabs when there is no editor tab to switch to. var hasOtherWindowTabs = false var canPinResultTab = false var canCompareResult = false - /// The selected tab's browse history. Separate flags rather than one, because Back and Forward - /// run out independently and an item that is disabled has to say which one it is. + // Separate flags: Back and Forward run out independently and a disabled item has to say which one it is. var canNavigateBack = false var canNavigateForward = false - /// First, Previous, Next and Last Page, which an engine that cannot skip rows never offers. + // An engine that cannot skip rows never offers the page commands. var canNavigatePages = false var canSaveAsFavorite = false var canSwitchSidebarLayout = false var canToggleWorkspaceRail = false - /// Whether the connection's driver is holding an operating-system resource it can hand back - /// without ending the session. Only the embedded engines that lock their database file answer - /// yes, so the command is absent for every server-backed connection rather than present and - /// disabled: a command that can never apply to a connection is not a command it is missing. + // Only embedded engines that lock their database file answer yes, so server connections never show the command at all. var canReleaseFileLock = false var canShowTableStructure = false var canEditViewDefinition = false @@ -150,10 +115,9 @@ struct MenuValidationContext: Equatable { var supportsServerDashboard = false var supportsUserManagement = false var supportsSchemaSwitching = false - /// Whether the engine declares an EXPLAIN variant. Read through the same rule the editor bar - /// uses, so the menu item cannot run a statement the bar's button refuses to. + // Same rule as the editor bar, so the menu cannot run a statement the bar's button refuses. var supportsExplain = false - /// Whether the engine's query language has a formatter, the rule the editor bar's Format button uses. + // Same rule as the editor bar's Format button. var supportsFormatting = false var hasSessionContexts = false var canFilterDatabases = false @@ -161,26 +125,15 @@ struct MenuValidationContext: Equatable { var hasDatabaseFilter = false var canFilterSchemas = false var hasSchemaFilter = false + + // The validation and the window's handler both read it, so a lit Use Selection for Find never lands on an editor with no selection. + var usesGridSelectionForFind: Bool { + !hasSelectionForFind && isConnected && canUseGridFindCommands && hasDataGridRowSelection + } } extension MainSplitViewController: NSMenuItemValidation { - /// A command that reaches the database carries `isConnected` even when it already has a - /// selection or tab condition of its own. Those conditions are not a substitute for it: a - /// window that is not connected shows the connecting or unavailable pane with its sidebar and - /// inspector collapsed, while the coordinator keeps the last tab and selection it saw so a - /// reconnect can restore them. Without it, Truncate Table and Delete stay lit over an error - /// screen, pointed at a session that is gone. - /// - /// This runs only when the window's content view controller is the responder that claimed the - /// selector, so a command a nearer responder implements is answered by that responder instead and - /// never reaches here. The Find commands rely on that: a focused editor claims and validates them - /// itself, so `hasEditorForFind` only ever decides the unfocused fallback. - /// What this window has to say about a command, or nil when the command is not its to decide. - /// - /// Nil is the whole point. A menu item whose selector this controller implements and that has no - /// arm here is a command that stays enabled over a window that cannot run it, and the suite is - /// green either way: that shipped as Clear Selection, lit on a window with nothing selected and - /// nothing to clear. `MenuValidationCoverageTests` reads the nil to say so. + // Nil: not this window's call. An unhandled selector stays lit (Clear Selection did); MenuValidationCoverageTests catches it. static func resolvedEnablement(_ selector: Selector, context: MenuValidationContext) -> Bool? { if context.isAgentMode, browseContentSelectors.contains(selector) { return false } if let find = isFindCommandEnabled(selector, context: context) { return find } @@ -197,14 +150,14 @@ extension MainSplitViewController: NSMenuItemValidation { #selector(showNextResult(_:)), #selector(closeResultTab(_:)), #selector(focusSidebarFilter(_:)), - #selector(showERDiagram(_:)), - #selector(previewFKReference(_:)): + #selector(showERDiagram(_:)): return context.isConnected - /// Each of these moves the selection of a strip, so each needs one on screen, which Agent mode - /// does not show, with a tab for the command to reach. Left on `isConnected` alone they - /// changed the selected tab behind the conversation, and did nothing at all with one tab or - /// for a number past the last tab. + // The focused data grid answers and validates this itself; reaching the window means no grid has focus. + case #selector(previewFKReference(_:)): + return false + + // Agent mode hides the tab strip; on `isConnected` alone these switched tabs behind the conversation or did nothing. case #selector(selectNumberedTab(_:)): guard context.isConnected, !context.isAgentMode, context.editorTabCount > 1 else { return false } guard let number = context.requestedTabNumber else { return true } @@ -217,7 +170,7 @@ extension MainSplitViewController: NSMenuItemValidation { return context.isConnected && context.canNavigatePages case #selector(saveDocument(_:)): - return context.isConnected && !context.isReadOnly && context.hasPendingChanges + return context.isConnected && !context.isReadOnly && (context.hasPendingChanges || context.hasOpenGridEdit) case #selector(saveDocumentAs(_:)): return context.isConnected && context.isQueryTab case #selector(exportQueryResults(_:)): @@ -226,17 +179,12 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(exportDataDictionary(_:)): return context.isConnected && context.canExportDataDictionary - /// AppKit validated New Tab for free while it was its own selector. - /// `NSWindow.validateUserInterfaceItem` only speaks to the native ones, so this is - /// ours to enable and disable now. Close went back to `performClose:`, which every - /// window validates for itself. + // `NSWindow.validateUserInterfaceItem` only validates native selectors, so New Tab is ours to enable. case #selector(newEditorTab(_:)): return context.isConnected case #selector(closeConnection(_:)): return context.hasSelectedWorkspace - /// Not `isConnected`, unlike the rest of the Database menu. The switcher lists the app's - /// open connections and the user's saved ones, needs nothing from the session, and is the - /// command that leaves a connection that has stopped working. + // Not `isConnected`: the switcher needs nothing from the session and is how the user leaves a connection that stopped working. case #selector(switchConnection(_:)): return context.hasSelectedWorkspace case #selector(selectNextEditorTab(_:)), #selector(selectPreviousEditorTab(_:)): @@ -261,18 +209,13 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(restoreDatabase(_:)): return context.isConnected && context.supportsRestore && !context.isReadOnly - /// Reachable while the connection is still dialling: agent mode draws the prompt the user - /// typed, which is exactly what they are waiting with, so gating on `isConnected` would make - /// the command dead in the one state it is most wanted. + // Not `isConnected`: Agent mode shows the typed prompt while dialling, the state this command is most wanted in. case #selector(setContentModeFromMenu(_:)), #selector(toggleContentModeFromMenu(_:)): return context.hasSelectedWorkspace && AppSettingsManager.shared.ai.enabled case #selector(previewSQL(_:)): return context.isConnected && context.hasDataPendingChanges - /// The results pane belongs to the query editor. The shipped rule was `isConnected` alone, - /// so the command was lit on the seven kinds that have no results pane and `toggleResults` - /// then wrote a collapse flag with no tab-kind guard behind it. This is the rule the - /// toolbar's own item answers by. + // Only query tabs have a results pane; `isConnected` alone lit it on seven kinds and wrote an unguarded collapse flag. case #selector(toggleResults(_:)): return context.isConnected && context.isQueryTab @@ -356,12 +299,7 @@ extension MainSplitViewController: NSMenuItemValidation { } } - /// What the window is pointed at inside the connection: which database, which schema, which - /// session context, and which databases the tree shows at all. Each is a chooser the driver may - /// not offer, so each follows its own capability rather than the session alone. - /// - /// Answered before the main switch rather than inside it, because that switch is at its length - /// limit and this is a domain of its own. + // Outside the main switch, which is at its length limit; each chooser follows its own driver capability. private static func isContainerCommandEnabled( _ selector: Selector, context: MenuValidationContext @@ -388,8 +326,7 @@ extension MainSplitViewController: NSMenuItemValidation { } } - /// The Database menu's creation commands, which the sidebar's add button also sends through the - /// responder chain. New Folder records local state only, so read-only does not dim it. + // The sidebar's add button sends these too. New Folder records local state only, so read-only does not dim it. private static func isCreationCommandEnabled( _ selector: Selector, context: MenuValidationContext @@ -412,12 +349,7 @@ extension MainSplitViewController: NSMenuItemValidation { } } - /// The commands the window answers for itself rather than on behalf of the connection it shows. - /// - /// Each Focus command follows the pane it names, so one that would focus nothing is dimmed - /// rather than silently doing nothing: `makeFirstResponder` accepts a view that cannot take the - /// keyboard and reports success. Agent mode's session commands are the window's own too, and are - /// answered by the helper below rather than inline, because this switch is at its length limit. + // A Focus command that would focus nothing is dimmed: `makeFirstResponder` accepts a view that cannot take the keyboard. private static func isWindowCommandEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool? { switch selector { case #selector(toggleWorkspaceRail(_:)), @@ -431,21 +363,17 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(focusInspector(_:)): return context.canFocusInspector case #selector(focusAssistant(_:)): return context.canFocusAssistant - /// Not `isConnected`: the SQL text is what moves, so a source that lost its connection can - /// still hand it over. + // Not `isConnected`: the SQL text is what moves, so a source that lost its connection can still hand it over. case #selector(moveTabToConnection(_:)): return context.canMoveTabToConnection - /// Escape clears the selection wherever one is, so the command needs a window showing a - /// connection and something that can hold a selection, not merely a window. + // Needs a connection on screen, which is what can hold a selection, not merely a window. case #selector(clearSelection(_:)): return context.isConnected default: return isAgentSessionCommandEnabled(selector, context: context) } } - /// Agent mode's session commands, which need the rail on screen and, New Session apart, a session - /// to act on. None of them needs a live connection: the rail stands in every phase, a session - /// outlives the connection's, and a conversation is worth reading with the database down. + // No live connection needed: the rail stands in every phase and a session outlives its connection. private static func isAgentSessionCommandEnabled( _ selector: Selector, context: MenuValidationContext @@ -462,13 +390,7 @@ extension MainSplitViewController: NSMenuItemValidation { } } - /// The assistant's three conversation commands, which answer in both modes: the conversation is - /// one thing shown two ways, in the trailing pane while browsing and in the content column in - /// Agent mode, so a command that acts on it applies wherever it is drawn. - /// - /// Each needs the assistant to have been opened, because that is what creates the model they - /// write to. Switching and clearing need a stored conversation on top of that, which is the same - /// pair of conditions the pane header's own menu is dimmed by. + // Answers in both modes (one conversation, two surfaces), dimmed on the same conditions as the pane header's menu. private static func isConversationCommandEnabled( _ selector: Selector, context: MenuValidationContext @@ -483,24 +405,7 @@ extension MainSplitViewController: NSMenuItemValidation { } } - /// The commands that act on the browse content, which Agent mode does not mount. - /// - /// Every one of them but the two document commands and Move Tab to Connection has a toolbar twin - /// whose `ToolbarContextResolver` arm answers no in Agent mode, and the menu bar is where most of them - /// now live, so leaving them lit here would be the same defect one surface deeper: Refresh over a - /// grid that is not there, Save over a commit gate frozen at the moment the mode changed, Command Y - /// flipping a persisted flag for a drawer that is not mounted, and New Tab opening a tab behind - /// the conversation. The document commands live on the Edit menu and on the grid's row menu, and - /// the row menu goes away with the grid, so the Edit menu is their only route in Agent mode, where - /// the coordinator still holds the row the user last selected. - /// - /// A set rather than an arm each, because the rule is one rule. `MenuContentModeParityTests` - /// holds the two surfaces' answers together and derives this list back out of the toolbar, so a - /// browse-only item added there without an entry here fails rather than ships enabled. - /// - /// What is deliberately not here: Switch Connection, Close Connection, Safe Mode, the two mode - /// commands, the session commands and the conversation commands. Each of those acts on the - /// window or on the session, both of which Agent mode still has. + // Agent mode does not mount browse content. MenuContentModeParityTests derives this set from the toolbar. private static let browseContentSelectors: Set = [ #selector(refreshDatabase(_:)), #selector(saveDocument(_:)), @@ -515,9 +420,7 @@ extension MainSplitViewController: NSMenuItemValidation { #selector(openQuickSwitcher(_:)), #selector(exportTables(_:)), #selector(exportDataDictionary(_:)), - /// Both spellings of one command: the leaf that takes the driver's first format, and the - /// row of the list that names another. One without the other would leave the list live over - /// a leaf that is dim. + // Both spellings of one command; one without the other leaves the format list live over a dim leaf. #selector(importData(_:)), #selector(importDataFormat(_:)), #selector(showServerDashboard(_:)), @@ -528,31 +431,25 @@ extension MainSplitViewController: NSMenuItemValidation { #selector(buildJoinQuery(_:)), ] - /// What AppKit is told. A command this window does not own is left enabled, which is what keeps - /// `performClose:` and the rest of the system's own items working. + // A command this window does not own stays enabled, which keeps `performClose:` and the system's own items working. static func isEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool { resolvedEnablement(selector, context: context) ?? true } - /// The Edit menu's Find commands, which are the window's last-resort answer. A focused editor claims and - /// validates them itself, so what these decide is only what happens when nothing nearer took the selector: - /// Find falls back to the result grid's find bar, and the two editor-only commands dim. + // Fallback only: a focused editor or grid claims and validates Find itself, so this decides the result grid's find bar. private static func isFindCommandEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool? { switch selector { - case #selector(performFind(_:)): + case #selector(performFind(_:)), #selector(performFindAndReplace(_:)): return context.hasEditorForFind || (context.isConnected && context.canUseGridFindCommands) case #selector(findNext(_:)), #selector(findPrevious(_:)): return context.hasEditorForFind || context.hasActiveGridFind - case #selector(performFindAndReplace(_:)): - return context.hasEditorForFind case #selector(useSelectionForFind(_:)): - return context.hasSelectionForFind + return context.hasSelectionForFind || context.usesGridSelectionForFind default: return nil } } - /// The commands that act on the selected tab's editor and the statements in it. private static func isQueryCommandEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool? { switch selector { case #selector(executeQuery(_:)), @@ -593,9 +490,7 @@ extension MainSplitViewController: NSMenuItemValidation { } } - /// The commands that act on the object selected in the sidebar. They answer on the same facts - /// the sidebar's own contextual menu reads, so a command the sidebar omits is dimmed here rather - /// than enabled over an object it cannot act on. + // Same facts as the sidebar's contextual menu, so a command the sidebar omits is dimmed here too. private static func objectCommandIsEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool { guard context.isConnected else { return false } switch selector { @@ -618,13 +513,7 @@ extension MainSplitViewController: NSMenuItemValidation { } } - /// The workspace-rail facts come from the window in both branches. They are true of the window, - /// not of the connection it happens to be showing, and reading them off a connection that has - /// no coordinator left disabled the only menu route to the window's other connections. - /// - /// Focus Assistant is the window's too. A window opened straight into Agent mode draws the - /// conversation in its content column and never mounts the browse content that sets up the - /// command actions, so read from them, the one command that reaches its composer was dimmed. + // Rail facts and Focus Assistant come from the window: off the connection they dimmed with no coordinator or in Agent mode. var menuValidationContext: MenuValidationContext { let conversations = assistantConversationModel guard let actions = commandActions else { @@ -672,6 +561,7 @@ extension MainSplitViewController: NSMenuItemValidation { canClearQuery: actions.canClearQuery, canClearResults: actions.canClearResults, hasPendingChanges: actions.hasPendingChanges, + hasOpenGridEdit: actions.hasOpenGridEdit, hasDataPendingChanges: actions.hasDataPendingChanges, hasRowSelection: actions.hasRowSelection, hasDataGridRowSelection: actions.hasDataGridRowSelection, @@ -740,10 +630,7 @@ extension MainSplitViewController: NSMenuItemValidation { func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { applyDynamicTitle(to: menuItem) guard let action = menuItem.action else { return false } - /// AppKit asks this method for the View menu and `validateUserInterfaceItem` for everything - /// else, so a rule that lives in only one of them holds for only half the routes to the - /// command. The sidebar is the window's and stands in every phase; the two trailing - /// surfaces need a session to open and none to close. + // AppKit asks this for the View menu and `validateUserInterfaceItem` for the rest, so a rule must live in both. if action == #selector(toggleSidebar(_:)) { return true } if action == #selector(toggleInspector(_:)) { return canToggleTrailingPane } if action == #selector(toggleAssistant(_:)) { return canToggleAssistant } @@ -757,14 +644,7 @@ extension MainSplitViewController: NSMenuItemValidation { return Self.isEnabled(action, context: menuValidationContext(naming: menuItem)) } - /// The window's context, with the session a session command acts on taken from the item rather - /// than from the rail: a menu that lists a connection's sessions names one in each of its items, - /// and every other route acts on the one the rail has highlighted. - /// - /// Keyed on the action rather than on the type in `representedObject`. A conversation row carries - /// a `UUID` too, and reading that one as a session id resolved a session that does not exist and - /// wrote its absence over the rail's own highlight, so a conversation row in an open menu decided - /// what the session commands beside it reported. + // Keyed on the action, not the `representedObject` type: a conversation row's UUID was read as a session id. private func menuValidationContext(naming menuItem: NSMenuItem) -> MenuValidationContext { var context = menuValidationContext guard let action = menuItem.action else { return context } @@ -776,8 +656,7 @@ extension MainSplitViewController: NSMenuItemValidation { return context } - /// The commands whose subject is a session, and the only ones that may read a session id out of - /// a menu item. + // The only selectors that may read a session id out of a menu item. private static let agentSessionSelectors: Set = [ #selector(openAgentSession(_:)), #selector(closeAgentSession(_:)), @@ -790,17 +669,13 @@ extension MainSplitViewController: NSMenuItemValidation { return contentMode == mode } - /// Assigning a title or state that has not changed still posts an item-changed notification, - /// which makes an open menu re-lay-out and cancel tracking. Validation runs on every menu - /// update, so the writes have to be conditional or the menu bar flickers and a click on an - /// item dismisses the menu instead of firing it. + // Write only on change: any title or state write re-lays out an open menu and cancels tracking, so a click dismissed it. private func applyDynamicTitle(to menuItem: NSMenuItem) { guard let action = menuItem.action else { return } switch action { case #selector(toggleSidebar(_:)): setTitle(isSidebarCollapsed ? "Show Sidebar" : "Hide Sidebar", on: menuItem) - /// Both read the surface the pane is drawing, so in Agent mode the pane toggle names the - /// result column it opens and closes instead of offering to hide an inspector nobody sees. + // In Agent mode the pane toggle names the result column instead of an inspector nobody sees. case #selector(toggleInspector(_:)): setResolvedTitle(TrailingPaneCommandResolver.paneToggleTitle(trailingPaneCommandContext), on: menuItem) case #selector(toggleAssistant(_:)): @@ -838,9 +713,7 @@ extension MainSplitViewController: NSMenuItemValidation { commandActions?.openContainerSwitcherTitle ?? String(localized: "Open Database…"), on: menuItem ) - /// The driver names this one, because what it gives back differs: DuckDB's file lock is - /// not a server's connection slot. The fallback is what the disabled item reads as for - /// every connection that holds nothing. + // Driver-named because what it releases differs (DuckDB's file lock is not a server slot). case #selector(releaseFileLock(_:)): setResolvedTitle( ConnectionFileLockAction.commandTitle(connectionId: workspaces.selectedConnectionId) @@ -864,17 +737,14 @@ extension MainSplitViewController: NSMenuItemValidation { } } - /// The item carries its mode in `representedObject`, so enablement has to see the item rather - /// than the selector the shared table keys on. + // The mode is in `representedObject`, so enablement needs the item, not the selector the shared table keys on. private func canShowResultView(_ menuItem: NSMenuItem) -> Bool { guard let raw = menuItem.representedObject as? String, let mode = ResultsViewMode(rawValue: raw) else { return false } return commandActions?.availableResultsViewModes.contains(mode) ?? false } - /// Read through the same status the list is built from, so an entry the floor rules out cannot - /// validate as a choice. The connection's own floor is blind to Agent mode, and asking it enabled - /// a weaker level the write would then hold at Alert. + // Uses the status the list is built from: the connection's own floor is blind to Agent mode and enabled a level held at Alert. private func canChooseSafeModeLevel(_ menuItem: NSMenuItem) -> Bool { guard isConnected, let raw = menuItem.representedObject as? String, diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+QueryMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+QueryMenuActions.swift index c622b24fec..fa38a3ed6b 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+QueryMenuActions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+QueryMenuActions.swift @@ -74,9 +74,8 @@ extension MainSplitViewController { commandActions?.clearResults() } - @objc func previewFKReference(_ sender: Any?) { - commandActions?.previewFKReference() - } + // Declared for the menu item; the focused data grid implements the same selector closer to the first responder. + @objc func previewFKReference(_ sender: Any?) {} @objc func goToFirstPage(_ sender: Any?) { commandActions?.goToFirstPage() diff --git a/apple/TablePro/Core/Services/Query/RowOperationsManager.swift b/apple/TablePro/Core/Services/Query/RowOperationsManager.swift index f062f9c749..2d6592b141 100644 --- a/apple/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/apple/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -13,7 +13,7 @@ final class RowOperationsManager { let rowID: RowID let values: [PluginCellValue] let delta: Delta - /// Key columns given a fresh UUID, which the caret skips. + // Key columns given a fresh UUID, which the caret skips. var prefilledColumns: Set = [] } @@ -35,7 +35,7 @@ final class RowOperationsManager { } struct UndoApplicationResult { - let adjustedSelection: Set? + let clearsSelection: Bool let delta: Delta } @@ -45,12 +45,7 @@ final class RowOperationsManager { self.changeManager = changeManager } - /// A new row holds DEFAULT where the server assigns the value and NULL elsewhere. On an engine - /// that tells a missing field from NULL those other fields start missing instead, so a document - /// saved as it was added holds only what the user typed. - /// - /// `uuidKeyForms` are the UUID keys the server does not fill, by column. Each gets a fresh UUID, since NULL - /// there is a key the INSERT is refused for. + // On engines that tell missing from NULL, non-default fields start missing; unfilled UUID keys get a fresh UUID, not NULL. func addNewRow(tableRows: inout TableRows, uuidKeyForms: [Int: UUIDCellForm] = [:]) -> AddNewRowResult? { var newRowValues: [PluginCellValue] = [] var absentColumns: Set = [] @@ -71,7 +66,7 @@ final class RowOperationsManager { ) } - /// A pending source row keeps the SQL staged in it, so a copy of `now()` is `now()` again, not the text. + // A pending source row keeps its staged SQL, so a copy of now() is now() again, not the text. func duplicateRow( sourceRowIndex: Int, tableRows: inout TableRows, @@ -85,8 +80,7 @@ final class RowOperationsManager { var expressionColumns = changeManager.expressionColumns(forRow: source.id).filter { $0 < newValues.count } var prefilled: Set = [] - /// An identity column is not always the primary key, and copying its value verbatim is - /// what the server rejects. + // An identity column isn't always the primary key, and the server rejects a copied identity value. let resetColumns = changeManager.primaryKeyColumns + Array(tableRows.generatedColumns) + Array(tableRows.columnIdentity.keys) @@ -130,24 +124,23 @@ final class RowOperationsManager { return AddNewRowResult(rowID: rowID, values: values, delta: delta, prefilledColumns: prefilledColumns) } - func deleteSelectedRows( - selectedIndices: Set, - displayIDs: [RowID]? = nil, + // `selectedDisplayIndices` and `displayCount` only place the row selected afterwards. + func deleteRows( + _ rowIDs: [RowID], + selectedDisplayIndices: Set, + displayCount displayCountBefore: Int, tableRows: inout TableRows ) -> DeleteRowsResult { - guard !selectedIndices.isEmpty else { + guard !rowIDs.isEmpty else { return DeleteRowsResult(nextRowToSelect: -1, physicallyRemovedIndices: [], delta: .none) } - let displayCountBefore = displayIDs?.count ?? tableRows.count var insertedRowsToRemove: [InsertedRowLocation] = [] var existingRowsToDelete: [(rowID: RowID, originalRow: [PluginCellValue])] = [] var deletedAbsentColumns: [RowID: Set] = [:] - for displayIndex in selectedIndices.sorted(by: >) { - guard let storageIndex = DisplayRowMapping.rowIndex( - forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows - ) else { continue } + let storageIndices = Set(rowIDs.compactMap { tableRows.index(of: $0) }) + for storageIndex in storageIndices.sorted(by: >) { let row = tableRows.rows[storageIndex] if row.id.isInserted { insertedRowsToRemove.append(InsertedRowLocation(rowID: row.id, storageIndex: storageIndex)) @@ -171,7 +164,7 @@ final class RowOperationsManager { return DeleteRowsResult( nextRowToSelect: Self.nextRowToSelect( - afterDeleting: selectedIndices, + afterDeleting: selectedDisplayIndices, removedCount: insertedRowsToRemove.count, displayCountAfter: displayCountBefore - insertedRowsToRemove.count ), @@ -201,20 +194,20 @@ final class RowOperationsManager { switch result.action { case .cellEdit(let rowID, let columnIndex, _, let previousValue, _, _, let absence, _): guard let storageRow = tableRows.index(of: rowID) else { - return UndoApplicationResult(adjustedSelection: nil, delta: .none) + return UndoApplicationResult(clearsSelection: false, delta: .none) } let delta = tableRows.edit( row: storageRow, column: columnIndex, value: previousValue, isAbsent: absence.wasAbsent ) - return UndoApplicationResult(adjustedSelection: nil, delta: delta) + return UndoApplicationResult(clearsSelection: false, delta: delta) case .rowInsertion(let rowID, _): if result.needsRowRemoval { let delta = tableRows.remove(rowIDs: [rowID]) guard delta != .none else { - return UndoApplicationResult(adjustedSelection: nil, delta: .none) + return UndoApplicationResult(clearsSelection: false, delta: .none) } - return UndoApplicationResult(adjustedSelection: Set(), delta: delta) + return UndoApplicationResult(clearsSelection: true, delta: delta) } if result.needsRowRestore { let values = result.restoreRow @@ -222,27 +215,27 @@ final class RowOperationsManager { let delta = tableRows.appendInsertedRow( id: rowID, values: values, absentColumns: result.restoreAbsentColumns ) - return UndoApplicationResult(adjustedSelection: nil, delta: delta) + return UndoApplicationResult(clearsSelection: false, delta: delta) } - return UndoApplicationResult(adjustedSelection: nil, delta: .none) + return UndoApplicationResult(clearsSelection: false, delta: .none) case .rowDeletion, .batchRowDeletion: - return UndoApplicationResult(adjustedSelection: nil, delta: result.delta) + return UndoApplicationResult(clearsSelection: false, delta: result.delta) case .batchRowInsertion(let rows, let rowValues, let rowAbsentColumns, _): if result.needsRowRemoval { let delta = tableRows.remove(rowIDs: Set(rows.map(\.rowID))) - return UndoApplicationResult(adjustedSelection: nil, delta: delta) + return UndoApplicationResult(clearsSelection: false, delta: delta) } if result.needsRowRestore { return UndoApplicationResult( - adjustedSelection: nil, + clearsSelection: false, delta: restoreInsertedRows( rows, values: rowValues, absentColumns: rowAbsentColumns, into: &tableRows ) ) } - return UndoApplicationResult(adjustedSelection: nil, delta: .none) + return UndoApplicationResult(clearsSelection: false, delta: .none) } } @@ -268,17 +261,16 @@ final class RowOperationsManager { return insertedIndices.isEmpty ? .none : .rowsInserted(insertedIndices) } - func copySelectedRowsToClipboard( - selectedIndices: Set, + // `rowIDs` come in the order the grid shows them. + func copyRowsToClipboard( + _ rowIDs: [RowID], tableRows: TableRows, - displayIDs: [RowID]? = nil, includeHeaders: Bool = false, visibleColumnIndices: [Int]? = nil ) { - guard !selectedIndices.isEmpty else { return } + guard !rowIDs.isEmpty else { return } - let sortedIndices = selectedIndices.sorted() - let totalSelected = sortedIndices.count + let totalSelected = rowIDs.count let isTruncated = totalSelected > Self.maxClipboardRows if isTruncated { @@ -287,15 +279,15 @@ final class RowOperationsManager { ) } - let indicesToCopy = isTruncated ? Array(sortedIndices.prefix(Self.maxClipboardRows)) : sortedIndices + let idsToCopy = isTruncated ? Array(rowIDs.prefix(Self.maxClipboardRows)) : rowIDs let projection = VisibleColumnProjection(indices: visibleColumnIndices) let columns = projection.columns(tableRows.columns) let estimatedRowLength = max(columns.count, 1) * 12 var result = "" - result.reserveCapacity(indicesToCopy.count * estimatedRowLength) + result.reserveCapacity(idsToCopy.count * estimatedRowLength) var copiedRows: [Row] = [] - copiedRows.reserveCapacity(indicesToCopy.count) + copiedRows.reserveCapacity(idsToCopy.count) if includeHeaders, !columns.isEmpty { for (colIdx, col) in columns.enumerated() { @@ -304,9 +296,8 @@ final class RowOperationsManager { } } - for displayIndex in indicesToCopy { - guard let row = DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) - else { continue } + for rowID in idsToCopy { + guard let row = tableRows.row(withID: rowID) else { continue } if !result.isEmpty { result.append("\n") } copiedRows.append(row) for (colIdx, cell) in projection.values(Array(row.values)).enumerated() { @@ -387,8 +378,7 @@ final class RowOperationsManager { } } - /// Copied rows keep the fields they did not have, and a column the copy did not carry is a - /// field the pasted row does not have either, on an engine that tells that apart from NULL. + // On engines that tell missing from NULL, a column the copy didn't carry is a field the pasted row lacks too. private static func reconcileStructuredRows( _ payload: GridRowsClipboardPayload, schema: TableSchema, @@ -428,9 +418,7 @@ final class RowOperationsManager { } } - /// Cells the grid copied, as wide as the table, become rows field by field, so a value holding a - /// tab or a line break stays one value and a NULL stays NULL. An empty field is read for its - /// column, as a typed one is, and a field the copy marked as SQL stays SQL. + // Field by field, so a value holding a tab or line break stays one value, NULL stays NULL and SQL stays SQL. private static func rows( fromCopiedCells cells: GridCellsClipboardPayload, schema: TableSchema, @@ -455,8 +443,7 @@ final class RowOperationsManager { } } - /// Text says nothing about which fields a row lacks, so on an engine that tells a missing field - /// from NULL a pasted NULL leaves the field out, which is what a new row does with it too. + // Text can't say which fields a row lacks, so a pasted NULL leaves the field out, as a new row does. private static func nullsAsMissingFields(_ row: ParsedRow) -> ParsedRow { var missing = row missing.absentColumns = Set(row.values.indices.filter { row.values[$0].isNull }) @@ -493,8 +480,7 @@ final class RowOperationsManager { return containsComma ? CSVRowParser() : TSVRowParser() } - /// Every paste path resets the key to `__DEFAULT__` before it gets here. A UUID key the server does not - /// fill takes a fresh UUID in its place, one per row. + // Every paste path resets the key to __DEFAULT__ first; an unfilled UUID key then takes a fresh UUID per row. private func insertParsedRows( _ parsedRows: [ParsedRow], uuidKeyForms: [Int: UUIDCellForm], @@ -503,9 +489,7 @@ final class RowOperationsManager { var pastedRowInfo: [PastedRowInfo] = [] var insertedIndices = IndexSet() - /// A pasted row arrives whole, so it carries values for columns the server owns too. They - /// never reach the cell-edit boundary that refuses them, and the statement generator drops - /// them silently, so the grid showed a pasted identity value the row was never saved with. + // Pasted server-owned values skip the cell-edit check and the generator drops them, so the grid showed unsaved values. let serverOwned = tableRows.columns.enumerated().filter { _, name in tableRows.generatedColumns.contains(name) || tableRows.columnIdentity[name] != nil }.map(\.offset) diff --git a/apple/TablePro/Core/Services/Query/SelectionSummaryCalculator.swift b/apple/TablePro/Core/Services/Query/SelectionSummaryCalculator.swift index 688d76967c..7470c95bb8 100644 --- a/apple/TablePro/Core/Services/Query/SelectionSummaryCalculator.swift +++ b/apple/TablePro/Core/Services/Query/SelectionSummaryCalculator.swift @@ -18,7 +18,8 @@ actor SelectionSummaryCalculator { func summarize(_ input: SelectionSummaryInput) throws(CancellationError) -> SelectionSummary { var tally = Tally(input: input) - let displayRowCount = input.displayIDs?.count ?? input.tableRows.count + let order = DataGridRowOrder(filteredIDs: input.displayIDs, revision: 0) + let displayRowCount = order.count(in: input.tableRows) var budget = 0 for band in Self.rowBands(of: input.selection, rowLimit: displayRowCount) { let columns = input.selection.columns(in: band.lowerBound).compactMap { position -> BandColumn? in @@ -33,12 +34,7 @@ actor SelectionSummaryCalculator { budget = Self.cancellationInterval } budget -= columns.count - guard let storageIndex = DisplayRowMapping.rowIndex( - forDisplay: displayRow, - displayIDs: input.displayIDs, - in: input.tableRows - ) else { continue } - let row = input.tableRows.rows[storageIndex] + guard let row = order.row(at: displayRow, in: input.tableRows) else { continue } guard !input.deletedRowIDs.contains(row.id) else { continue } for column in columns { tally.add(row[column.dataIndex], column: column.dataIndex, rule: column.rule, rowID: row.id) @@ -48,8 +44,7 @@ actor SelectionSummaryCalculator { return tally.summary(coversWholeColumn: !input.selection.columns.isEmpty) } - /// Every rectangle's row bounds are breakpoints, so the rectangles covering a band are the same - /// on each of its rows: one column union per band counts an overlapped cell once. + // Rectangle row bounds are breakpoints, so one column union per band counts an overlapped cell once. static func rowBands(of selection: GridSelection, rowLimit: Int) -> [Range] { guard rowLimit > 0 else { return [] } var bounds = Set() @@ -106,16 +101,14 @@ private struct Tally { } } - /// The accumulator trims only ASCII blanks. Column Statistics also takes a number padded with - /// other Unicode spaces, and the two must agree on the same cells. + // The accumulator trims only ASCII blanks; Column Statistics takes other Unicode spaces too, and both must agree. private mutating func addNumber(_ text: String) -> Bool { if numbers.add(text) { return true } guard !text.utf8.allSatisfy({ $0 < 0x80 }) else { return false } return numbers.add(text.trimmingCharacters(in: .whitespaces)) } - /// The default marker is the grid's own placeholder only where an edit put it; anywhere else - /// it is text the table holds. + // The default marker is the grid's placeholder only where an edit put it; elsewhere it is text the table holds. private func isEmpty(_ text: String, column: Int, rowID: RowID) -> Bool { if text.isEmpty { return emptyTextIsEmpty } guard text.utf8.count == Self.defaultMarkerLength, text == PluginCellValue.defaultMarkerText else { diff --git a/apple/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift b/apple/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift index fb178a7eaf..869817eb65 100644 --- a/apple/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift +++ b/apple/TablePro/Core/Utilities/SQL/DisplayedResultReader.swift @@ -6,26 +6,16 @@ import Foundation import TableProPluginKit -/// The rows a result tab is showing, in the order it is showing them. -/// -/// Anything that reads a result outside the grid itself has to answer the same three questions, and -/// get all three right: display positions are not storage indices once a value filter is on, hidden -/// and reordered columns are the reader's business too, and a row marked for deletion is in the -/// buffer but not in the result. `ResultJsonSerializer` answered them for JSON; AppleScript needs -/// the same answers as values, so they live here and JSON is one encoding of the output. +// Filtered display order, hidden and moved columns, no deleted rows: JSON and AppleScript read results through here. internal enum DisplayedResultReader { internal struct Output { internal let columns: [String] internal let columnTypes: [ColumnType] internal let rows: [[PluginCellValue]] - /// How many rows were left out because they are marked for deletion. internal let skippedDeletedCount: Int } - /// - Parameter selectedDisplayIndices: display positions to narrow to. Empty means every - /// displayed row, which is what an untouched result set shows. - /// - Parameter deletedRowIDs: rows marked for deletion but not yet saved. - /// Empty, the default, reads every row. + // Empty selectedDisplayIndices means every displayed row. internal static func read( tableRows: TableRows, displayIDs: [RowID]?, @@ -40,11 +30,10 @@ internal enum DisplayedResultReader { positions = selectedDisplayIndices.sorted() } + let order = DataGridRowOrder(filteredIDs: displayIDs, revision: 0) var skippedDeleted = 0 let rows: [[PluginCellValue]] = positions.compactMap { displayIndex in - guard let row = DisplayRowMapping.row( - forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows - ) else { return nil } + guard let row = order.row(at: displayIndex, in: tableRows) else { return nil } guard !deletedRowIDs.contains(row.id) else { skippedDeleted += 1 return nil diff --git a/apple/TablePro/Models/Query/QueryResultPresentation.swift b/apple/TablePro/Models/Query/QueryResultPresentation.swift index 45b5c5f522..d960d8b15d 100644 --- a/apple/TablePro/Models/Query/QueryResultPresentation.swift +++ b/apple/TablePro/Models/Query/QueryResultPresentation.swift @@ -6,18 +6,10 @@ import Foundation import TableProPluginKit -/// What the results pane draws. -/// -/// One value rather than a nest of conditionals. `MainEditorContentView.resultsSection` used to -/// decide this inside its own body: a switch over `ResultsViewMode` whose five arms each repeated -/// the result-set chrome, wrapped around a five-way `if/else if/else` that chose between the -/// success view, an empty `Spacer`, the no-rows view and the grid. Three of those arms tested -/// `lastExecutedAt != nil && !isExecuting` with slightly different companions, so the states were -/// only accidentally exclusive and none of them could be checked without mounting SwiftUI. +// One value instead of conditionals in the view body, so the states stay exclusive and testable without mounting SwiftUI. enum QueryResultContent: Equatable { - /// Nothing has run and there is nothing to show. case idle - /// A fetch is in flight with no loaded buffer to draw under it. + // In flight with no loaded buffer to draw under it. case executing case structure(tableName: String) case queryPlan @@ -25,26 +17,22 @@ enum QueryResultContent: Equatable { case map case json case grid - /// Columns came back and no rows did, which is a result rather than an absence. + // Columns but no rows: a result, not an absence. case noRows(executionTime: TimeInterval?) - /// A statement that reports work done rather than rows: INSERT, UPDATE, DDL, and whatever it printed on the - /// server, which for a PL/SQL block is usually the point of running it. + // INSERT, UPDATE, DDL. The server output is usually the point of running a PL/SQL block. case statementSucceeded( rowsAffected: Int, executionTime: TimeInterval?, statusMessage: String?, serverOutput: PluginServerOutput ) - /// What a statement that returned rows printed on the server, on its own in Output mode. + // What a statement that returned rows printed on the server, shown alone in Output mode. case serverOutput(PluginServerOutput) - /// The mode draws the loaded buffer and the buffer is empty, so the mode cannot draw. + // The mode draws the loaded buffer, and the buffer is empty. case unavailable(mode: ResultsViewMode) } -/// Everything the results pane needs in order to decide what it is, gathered before any view exists. -/// -/// A plain struct rather than a `QueryTab`, so the whole state matrix is reachable from a test -/// without a tab manager, a coordinator or a session registry behind it. +// A plain struct, not a `QueryTab`, so tests reach every state without a tab manager, coordinator or session registry. struct QueryResultInputs: Equatable { var tabType: TabType = .query var viewMode: ResultsViewMode = .data @@ -54,14 +42,13 @@ struct QueryResultInputs: Equatable { var isExplainResult = false var hasActiveResultSet = false var resultSetCount = 0 - /// The active result reported columns. A result with none is a statement that did work. + // A result with no columns is a statement that did work. var activeResultHasColumns = false var activeResultRowsAffected = 0 var activeResultExecutionTime: TimeInterval? var activeResultStatusMessage: String? var activeResultServerOutput: PluginServerOutput = .none - /// A failed result carries its own message, which outlives the tab's. Pin a failure, run - /// something that works, and `executionErrorMessage` is cleared while this one is not. + // Outlives the tab's message: pin a failure, run something that works, and only `executionErrorMessage` is cleared. var activeResultErrorMessage: String? var loadedColumnCount = 0 var loadedRowCount = 0 @@ -71,25 +58,14 @@ struct QueryResultInputs: Equatable { var executionStatusMessage: String? var hasAppliedFilters = false var isFilterPanelVisible = false - var isFindBarVisible = false } -/// The whole results pane, resolved from tab state before any view exists. -/// -/// Pure by design, the way `ResultStatusModel` and `ResultSetPolicy` already are: every branch -/// the pane can take is decidable from `QueryResultInputs` alone. +// Pure: every branch is decidable from `QueryResultInputs` alone, like `ResultStatusModel` and `ResultSetPolicy`. struct QueryResultPresentation: Equatable { let content: QueryResultContent - /// The result-set chooser in the status bar. One result needs no chooser, and the structure - /// editor is not a result at all. let showsResultSetSelector: Bool let showsFilterChrome: Bool - let showsFindBar: Bool - /// Always. A plan used to give the bar up, which was harmless while the deleted strip carried - /// the result chooser above it, and would now leave a plan with no way to be switched away from - /// or pinned. The plan pane's own chrome is a header, so the bar under it is a footer rather - /// than a second header; what the bar gives up for a plan is its row readout, decided in - /// `ResultStatusModel` from `StatusBarSnapshot.isQueryPlan`. + // Always: a plan without the bar can't be switched away from or pinned. Its row readout hides via `StatusBarSnapshot.isQueryPlan`. let showsStatusBar: Bool let showsErrorBanner: Bool @@ -97,16 +73,11 @@ struct QueryResultPresentation: Equatable { content = Self.resolveContent(inputs) showsResultSetSelector = Self.resolvesResultSetSelector(inputs) showsFilterChrome = Self.resolvesFilterChrome(inputs) - showsFindBar = inputs.isFindBarVisible - && inputs.tabType == .table - && inputs.viewMode.showsFindBar showsStatusBar = true showsErrorBanner = Self.resolvedError(inputs) != nil } - /// The error the pane is actually showing. The active result's own message wins, because it - /// describes the result on screen; the tab's is the fallback for a failure that produced no - /// result set at all. + // The active result's message wins since it describes the result on screen; the tab's covers a failure with no result set. static func resolvedError(_ inputs: QueryResultInputs) -> String? { inputs.activeResultErrorMessage ?? inputs.executionErrorMessage } @@ -119,9 +90,7 @@ struct QueryResultPresentation: Equatable { if inputs.isExplainResult { return .queryPlan } - /// A table's grid is its own placeholder, so it stays mounted while the first page loads. A - /// retarget empties the buffer before the fetch claims the tab, and dropping the grid there - /// blanked the pane for the whole fetch and took the grid's first responder with it. + // A table's grid stays mounted while its first page loads: dropping it on a retarget blanked the pane and took first responder. if inputs.isExecuting, inputs.loadedColumnCount == 0, !Self.keepsGridWhileLoading(inputs) { return .executing } @@ -130,16 +99,12 @@ struct QueryResultPresentation: Equatable { return .serverOutput(inputs.activeResultServerOutput) } - /// Ahead of the idle rule below, because these two modes say why they are empty rather - /// than going blank: a reader who switched to Chart before running anything is told to run - /// something, which is the one thing a blank pane cannot say. + // Before the idle rule: Chart and Map say why they are empty, so a reader who switched before running is told to run something. if inputs.viewMode == .chart || inputs.viewMode == .map { guard inputs.hasActiveResultSet else { return .unavailable(mode: inputs.viewMode) } } - /// A query tab that has never run anything has no result to draw. A table tab does: it - /// describes a table whether or not its rows have arrived, which is why the gate names the - /// tab type rather than the buffer. + // Gated on tab type, not the buffer: a table tab describes its table whether or not its rows have arrived. if inputs.tabType == .query, !inputs.hasExecuted, inputs.loadedColumnCount == 0, @@ -165,9 +130,7 @@ struct QueryResultPresentation: Equatable { } } - /// The three outcomes a finished execution can have that are not a grid of rows. Answered in one - /// place so they stay mutually exclusive, which they only were by accident while each arm of the - /// old switch retested `hasExecuted && !isExecuting` beside a different companion condition. + // One place for the three non-grid outcomes, so they stay mutually exclusive. private static func resolveSettledResult(_ inputs: QueryResultInputs) -> QueryResultContent? { guard inputs.hasExecuted, !inputs.isExecuting else { return nil } @@ -182,8 +145,7 @@ struct QueryResultPresentation: Equatable { guard inputs.loadedColumnCount == 0 else { return resolveEmptyRows(inputs) } guard resolvedError(inputs) == nil else { return nil } - /// A failed table load leaves no result set, and dismissing its banner removes the error, so - /// this state is a table that has not loaded rather than a statement that ran. + // A failed table load leaves no result set and dismissing its banner clears the error, so this is an unloaded table. guard inputs.resultSetCount > 0 else { return inputs.tabType == .table ? nil : .idle } return .statementSucceeded( @@ -198,22 +160,14 @@ struct QueryResultPresentation: Equatable { inputs.tabType == .table && inputs.viewMode == .data } - /// Columns without rows. A filtered table that filtered everything away keeps the grid, because - /// the filter chrome is what the reader needs in order to get their rows back. + // A filtered table that filtered everything away keeps the grid: the filter chrome is how the reader gets rows back. private static func resolveEmptyRows(_ inputs: QueryResultInputs) -> QueryResultContent? { guard inputs.tabType == .query else { return nil } guard inputs.loadedRowCount == 0, !inputs.hasAppliedFilters else { return nil } return .noRows(executionTime: inputs.activeResultExecutionTime ?? inputs.executionTime) } - /// The same condition the deleted strip used, deliberately. - /// - /// It would be tempting to hide the chooser at a single result, since its title then says - /// nothing the pane does not. But Pin, Unpin and Close live in its menu, and pinning a result - /// before re-running is exactly the workflow that matters when there is one result on screen. - /// Hiding it there would leave the View menu as the only route to pinning, which is the - /// capability the strip existed to offer. The chooser costs no height to keep: it is a control - /// inside a bar that is already on screen, which is the whole difference from a 32pt band. + // Shown even for one result: Pin, Unpin and Close live in its menu, and it costs no height inside the status bar. private static func resolvesResultSetSelector(_ inputs: QueryResultInputs) -> Bool { guard inputs.tabType == .query else { return false } guard inputs.viewMode != .structure else { return false } diff --git a/apple/TablePro/Models/Query/QueryTab.swift b/apple/TablePro/Models/Query/QueryTab.swift index d15a8e9dce..bdcbb9fc3d 100644 --- a/apple/TablePro/Models/Query/QueryTab.swift +++ b/apple/TablePro/Models/Query/QueryTab.swift @@ -3,23 +3,17 @@ import Observation import os import TableProPluginKit -/// `CaseIterable` so the View menu builds its Result View submenu from the case list rather than -/// from a hand copy of it. Availability per tab is a different question and stays with -/// `ResultsModeAvailability`, which `CaseIterable` cannot answer. +// CaseIterable feeds the View menu's Result View submenu; per-tab availability stays with ResultsModeAvailability. enum ResultsViewMode: String, CaseIterable, Equatable { case data case structure case json case chart case map - /// What the statement printed on the server, such as Oracle's `DBMS_OUTPUT`. Offered only for a result that - /// printed something. + // Server output such as Oracle's DBMS_OUTPUT, offered only for a result that printed something. case output - /// How much of the loaded result the mode is showing, and how to load more. A chart draws the - /// same buffer the grid does, so it needs the same scope controls: a warning that the chart is - /// incomplete is only useful next to the control that completes it. A map draws that same - /// buffer, so the same argument puts it here. Output is not drawn from the buffer at all. + // Chart and map draw the grid's buffer, so they need its scope controls; Output is not drawn from the buffer. var showsResultScope: Bool { self != .structure && self != .output } @@ -32,27 +26,17 @@ enum ResultsViewMode: String, CaseIterable, Equatable { self == .data || self == .json } - /// Map is deliberately out. A value filter set in Data mode still narrows the map, because the - /// map draws the display order rather than the whole buffer, but the filter chrome belongs - /// beside the grid it filters. + // Map is out on purpose: a Data-mode filter still narrows it, but the filter chrome belongs beside the grid. var showsRowFilters: Bool { self == .data || self == .json } - /// The find bar reads the data grid's cells through the grid's own coordinator, which exists - /// only while the grid is mounted, so it can find nothing in any other mode. JSON mode carries - /// its own search inside the tree view. + // The find bar searches the mounted grid; JSON mode carries its own search inside the tree view. var showsFindBar: Bool { self == .data } - /// Whether a row selection means anything in this mode, and so whether the status bar should - /// report one. - /// - /// Deliberately its own question rather than a reuse of `showsColumnControls`, which is about - /// column chrome. Map both reads the selection, to highlight the chosen shape, and writes it, - /// when a shape is clicked, so a count the bar refuses to print is a count the reader cannot - /// see anywhere. + // Not showsColumnControls: Map reads and writes the selection, so a count the bar hides is one the reader cannot see. var reportsRowSelection: Bool { self == .data || self == .json || self == .map } @@ -71,35 +55,13 @@ struct QueryTab: Identifiable, Equatable { var display: TabDisplayState var pendingChanges: TabChangeSnapshot - var selectedRowIndices: Set - /// The cell rectangle the reader last had selected, kept for the same reason and in the same - /// place as `valueFilter` below. - /// - /// `selectedRowIndices` cannot stand in for it. `publishRowSelection` projects a rectangle down - /// to `affectedRows`, which keeps the rows and discards the columns, and that projection does - /// not invert: restoring from it would widen a three-column block into whole rows, and Copy - /// would then copy whole rows instead of the block. (#2667) - var cellSelection: GridSelection - /// The display rows this tab has selected, however the reader selected them. - /// - /// A cell drag pins `selectedRowIndices` to its anchor row, so the rectangle is the only one of - /// the two that knows the whole span. Everything that reinstates a tab's selection reads this - /// rather than picking between the two fields itself. - var selectedDisplayRows: Set { - cellSelection.isEmpty ? selectedRowIndices : Set(cellSelection.affectedRows) - } + // The grid's selection by row and column identity, carried when the tab moves to another window. + var selectionSnapshot: GridSelectionSnapshot var sortState: SortState var filterState: TabFilterState - var findState: TabFindState var columnLayout: ColumnLayoutState - /// The per-column value filter, which narrows the loaded rows without re-querying. - /// - /// It belongs to the tab rather than to the grid because it is the only thing that makes the - /// displayed order differ from storage order, and every reader of that order has to work while - /// the grid is unmounted: JSON mode, the row inspector, and the Edit menu's row commands all - /// run with no `DataGridView` in the view tree. Living on the grid's SwiftUI coordinator meant - /// switching result mode did not hide the order, it deleted it. (#2251) + // On the tab, not the grid: JSON mode, the inspector and the Edit menu's row commands read the order unmounted (#2251). var valueFilter: GridValueFilterState var sessionHighlightRules: [HighlightRule] = [] var pagination: PaginationState @@ -112,42 +74,28 @@ struct QueryTab: Identifiable, Equatable { var loadEpoch: Int = 0 var pendingRestoredSort: [PersistedSortColumn]? - /// The source the saved sort was written with, held until the first load resolves the columns. + // Held until the first load resolves the columns. var restoredSortSource: SortSource = .unset var restoredPage: Int? - /// The page size `restoredPage` was measured in. A page index means nothing without it: the - /// offset is recomputed as `(page - 1) * pageSize`, so reading the index in a different size - /// lands the tab on rows it was never showing. + // A page index means nothing without its size: the offset is (page - 1) * pageSize. var restoredPageSize: Int? var restoredRowAnchor: [String: String]? var restoredCursorOffset: Int? var restoredCursorLength: Int? - /// A statement the reader has asked to be taken to, set when they select the result it produced. - /// - /// Deliberately not `restoredCursorOffset`. That pair means "the selection this tab was left with, to apply once - /// when its editor mounts": the editor refuses it after its services are installed, and the tab-switch capture - /// only records a caret while both are nil, so borrowing them would make the second jump do nothing and stop the - /// switch capture forever. This is an event on a mounted editor, and it is cleared the moment one consumes it. - /// - /// An anchor rather than a range, because it is resolved against the editor's own text, which the tab's binding - /// can lag behind. + // Not restoredCursorOffset, which the editor refuses once mounted; an anchor because the tab's binding can lag the editor. var pendingStatementJump: StatementAnchor? - /// The regions the reader has collapsed in this tab. The editor is a view onto this, not its owner. + // The editor is a view onto this, not its owner. var collapsedFoldRanges: [Range]? - /// A fold range that still fits the query it was recorded against, or `nil` when it does not. - /// - /// The bounds are checked before the range is formed. A persisted file can hold anything, and `30..<10` traps - /// rather than producing an empty range, so a saved pair that arrives inverted would bring the app down on load. + // Bounds checked first: a persisted file can hold an inverted pair, and 30..<10 traps instead of forming an empty range. private static func foldRange(lower: Int, upper: Int, limit: Int) -> Range? { guard lower >= 0, upper > lower, upper <= limit else { return nil } return lower.. [Range]? { guard let bounds, bounds.count >= 2 else { return nil } let limit = (query as NSString).length @@ -195,11 +143,9 @@ struct QueryTab: Identifiable, Equatable { self.tableContext = TabTableContext(tableName: tableName, isEditable: tabType == .table) self.display = TabDisplayState() self.pendingChanges = TabChangeSnapshot() - self.selectedRowIndices = [] - self.cellSelection = .empty + self.selectionSnapshot = .empty self.sortState = SortState() self.filterState = TabFilterState() - self.findState = TabFindState() self.columnLayout = ColumnLayoutState() self.valueFilter = GridValueFilterState() self.pagination = PaginationState() @@ -246,16 +192,12 @@ struct QueryTab: Identifiable, Equatable { versionHistorySubject: persisted.versionHistorySubject ) self.pendingChanges = TabChangeSnapshot() - self.selectedRowIndices = [] - self.cellSelection = .empty - /// A saved sort with no columns is the user's Don't Sort, and it never reaches the pending - /// path because there is nothing there to resolve. Seeding the source here is what carries - /// it past `wantsDefaultSort` on the first load. + self.selectionSnapshot = .empty + // Seeding the source carries a saved empty sort (the user's Don't Sort) past wantsDefaultSort on the first load. self.sortState = (persisted.sortColumns?.isEmpty ?? true) && persisted.sortSource == .user ? SortState(columns: [], source: .user) : SortState() self.filterState = TabFilterState() - self.findState = TabFindState() self.columnLayout = ColumnLayoutState( columnWidths: persisted.columnWidths ?? [:], columnContentWidths: persisted.columnContentWidths @@ -270,9 +212,7 @@ struct QueryTab: Identifiable, Equatable { self.paginationVersion = 0 self.loadEpoch = 0 self.pendingRestoredSort = persisted.sortColumns - /// A file written before `sortSource` existed carries no answer, so it decodes to the - /// behaviour it was written under: saved columns were always the user's, and no saved - /// columns meant nothing had decided. + // A file from before sortSource decodes to its old behaviour: saved columns were the user's, none meant undecided. self.restoredSortSource = persisted.sortSource ?? ((persisted.sortColumns?.isEmpty == false) ? .user : .unset) let clampedPageSize = persisted.restoredPageSize @@ -292,12 +232,7 @@ struct QueryTab: Identifiable, Equatable { self.collapsedFoldRanges = Self.clampedFoldRanges(persisted.collapsedFoldRanges, in: persisted.query) } - /// A page number counts pages of the size it was taken in, so clamping the size without - /// rescaling the page moves the tab by the ratio between the two. A tab saved on page 2 of - /// 5,000,000 rows came back as page 2 of 100,000 and opened 4,900,000 rows short of the rows it - /// had been showing. Rescale to the page that still holds the first row the tab was on, which is - /// the same arithmetic `PaginationState.updatePageSize` does when the user changes the size by - /// hand. + // Rescale to the page holding the same first row, as PaginationState.updatePageSize does, or the tab lands rows short. private static func restoredPage( _ page: Int?, savedPageSize: Int?, @@ -309,8 +244,7 @@ struct QueryTab: Identifiable, Equatable { savedPageSize != appliedPageSize, savedPageSize > 0, appliedPageSize > 0 else { return requested } - /// Both numbers come off disk, so their product is not trusted to fit. A position that - /// cannot be computed is not a position, and the start of the table is the honest answer. + // Both numbers come off disk, so an overflowing product falls back to the start of the table. let (offset, overflowed) = (requested - 1).multipliedReportingOverflow(by: savedPageSize) guard !overflowed else { return 1 } return offset / appliedPageSize + 1 @@ -332,8 +266,7 @@ struct QueryTab: Identifiable, Equatable { return pluginQuery } - /// Keyed by engine: Elasticsearch, Typesense and Weaviate also highlight as JavaScript and etcd as a - /// command line, and none of them runs these. Without their plugin they reach the dialect, which throws. + // Keyed by engine: Elasticsearch, Typesense, Weaviate and etcd reach the dialect without their plugin, which throws. switch databaseType { case .mongodb: return "\(MongoCollectionAccessor.expression(for: tableName)).find({}).limit(\(pageSize))" @@ -368,14 +301,7 @@ struct QueryTab: Identifiable, Equatable { func toPersistedTab() -> PersistedTab { let persistedQuery = content.query - // A restored tab holds its saved sort and page in the pending fields until it is selected, - // because only the selected tab runs the first load that consumes them. Every save maps - // every tab through here, so re-emitting what has not been consumed yet is what keeps an - // unactivated tab's view state alive. `cursorOffset` and `columnWidths` already do this. - // - // The fallback holds only until the tab has actually run. After that the live state is the - // truth, and a pending value that outranked it would pin a page the user has since left - // with no way to correct it. + // Only the selected tab consumes the pending sort and page, so saves re-emit them until it has run; then live state wins. let carriesPendingState = tabType == .table && execution.lastExecutedAt == nil let persistedSort: [PersistedSortColumn]? = { @@ -383,9 +309,7 @@ struct QueryTab: Identifiable, Equatable { guard resolved.isEmpty else { return resolved } return carriesPendingState ? pendingRestoredSort : nil }() - /// Written whatever the columns came to, because the source is the whole answer for an empty - /// sort: `.user` with no columns is the user's Don't Sort, and gating it the way the columns - /// are gated would drop that the moment the tab had run once. + // Written whatever the columns, since .user with no columns is the user's Don't Sort. let persistedSortSource: SortSource = { if sortState.source != .unset { return sortState.source } return carriesPendingState ? restoredSortSource : .unset diff --git a/apple/TablePro/Models/Query/QueryTabManager.swift b/apple/TablePro/Models/Query/QueryTabManager.swift index ea17731754..4202f2df36 100644 --- a/apple/TablePro/Models/Query/QueryTabManager.swift +++ b/apple/TablePro/Models/Query/QueryTabManager.swift @@ -8,7 +8,6 @@ import Foundation import os import TableProUtilities -/// Manager for query tabs @MainActor final class QueryTabManager: ObservableObject { @Published var tabs: [QueryTab] = [] { @@ -25,11 +24,7 @@ final class QueryTabManager: ObservableObject { } } - /// Every writer of the selection comes through here, which makes this the one place that sees - /// every tab the user switches to: a click in the strip, Command 1 to 9, a new tab, a table - /// opened from the sidebar, a reopened tab and the neighbour a close lands on alike. A view - /// modifier could not stand in for it, because a connection the window is not showing has its - /// views unparented. + // Every selection writer lands here; a view modifier can't, as a connection the window isn't showing has no views. @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } @@ -39,30 +34,19 @@ final class QueryTabManager: ObservableObject { } } - /// What a tab's entry is decided by: the database on an engine that switches databases, the - /// schema on one that only switches schemas. Nil where the engine switches neither, and every - /// tab is in the connection's one entry. + // The database or schema, whichever the engine switches; nil when it switches neither and every tab shares one entry. let containerTarget: ContainerSwitchTarget? - /// The connections-strip entry this window shows: the strip lists only that container's tabs, - /// plus the ones that name no container. Nil shows every tab. - /// - /// A switch never closes a tab, it only changes which entry is in front; the tabs of the others - /// are kept and come back with their entry. + // The strip lists this container's tabs plus unbound ones (nil: all). Switching entries never closes a tab. @Published private(set) var visibleContainer: String? - /// Told when the selection carried the window into another entry, such as a tab picked from - /// the recent-tabs list or rebound to another database, so the connection can browse it too. + // Fired when a selection carried the window into another entry, so the connection browses it too. var onVisibleContainerReconciled: (() -> Void)? private(set) var containerTabHistory = ContainerTabHistory() private var _visibleTabs: [QueryTab]? - /// Whether the window shows this connection's tabs, in the key window. The window sets it, for - /// every connection it hosts at once. Only then is a selection a tab the user switched to: a - /// restore that finishes for a connection in the background selects a tab nobody has seen, and - /// recording it would send the next Control-Tab there. Coming to the front records the selected - /// tab, because the user is now looking at it. + // Only a frontmost selection is recorded: a background restore's tab would otherwise become the next Control-Tab target. var isFrontmost = false { didSet { guard isFrontmost != oldValue else { return } @@ -74,9 +58,7 @@ final class QueryTabManager: ObservableObject { var pendingFocusTabId: UUID? - /// When each open tab was last the selected one, as a sequence number rather than a time, so - /// two selections in one run-loop turn still order. The sequence is shared by every tab manager - /// so a window hosting several connections can order all of their tabs together. + // A shared sequence, not a time: two selections in one turn still order, across every connection in the window. private(set) var activationSequence: [UUID: UInt64] = [:] private static var lastActivationSequence: UInt64 = 0 private var activationRecordPending = false @@ -99,9 +81,7 @@ final class QueryTabManager: ObservableObject { self.tabSessionRegistry = tabSessionRegistry } - /// Closing a tab used to mean closing its window, because a tab was a window. Selection - /// lands on the tab that took its place in the strip, so the pane never blanks while the entry - /// still has tabs. An entry left with none shows its empty state rather than another entry's tab. + // Selection lands on the tab that took its strip slot; an entry left empty shows its empty state, not another's tab. func closeTab(id: UUID) { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } let wasSelected = selectedTab?.id == id @@ -116,15 +96,7 @@ final class QueryTabManager: ObservableObject { selectedTabId = remaining[stripIndex].id } - /// Reorders the strip. A pure permutation: the same tabs, a different order, and the selection - /// stays on whichever tab it was on rather than on the position that tab vacated. - /// - /// `destination` is a position in the strip, which lists one entry's tabs, so the move lands - /// beside the strip neighbour it was dropped next to wherever the other entries' tabs sit. - /// - /// The array is rebuilt and assigned once rather than mutated twice, because `tabs.didSet` - /// bumps `tabStructureVersion` and republishes the workspace anchors on every write, and the - /// state between a `remove` and an `insert` is a tab list the user never had. + // Built and assigned once: each tabs write bumps tabStructureVersion and republishes anchors for a list never shown. func moveTab(id: UUID, to destination: Int) { let strip = visibleTabs guard let source = strip.firstIndex(where: { $0.id == id }) else { return } @@ -141,8 +113,6 @@ final class QueryTabManager: ObservableObject { tabs = reordered } - /// Whether `tab` can move one place in `offset`'s direction, so a menu item can dim instead of - /// being offered and doing nothing. func canMoveTab(id: UUID, by offset: Int) -> Bool { let strip = visibleTabs guard let index = strip.firstIndex(where: { $0.id == id }) else { return false } @@ -160,22 +130,12 @@ final class QueryTabManager: ObservableObject { moveTab(id: id, to: index + offset) } - /// Keeps a preview tab, so the next table opened from the sidebar lands in a tab of its own - /// instead of replacing this one. The single mutator of `isPreview`: every promotion path, - /// the sidebar's, the editor's and the tab strip's, comes through here. - /// - /// Promotion never reorders. A tab that moves as it is kept would be a different feature, - /// which is what pinning is in the editors that offer both. - /// - /// One way, deliberately. Nothing turns a kept tab back into a preview, so a tab the user - /// asked to hold on to cannot be thrown away by a later click. + // Sole mutator of isPreview; one way on purpose, so a kept tab can't be thrown away by a later sidebar click. func promotePreviewTab(id: UUID) { guard let index = tabs.firstIndex(where: { $0.id == id }), tabs[index].isPreview else { return } mutate(at: index) { $0.isPreview = false } } - /// Whether keeping this tab would change anything, so a menu item can dim rather than be - /// offered and do nothing. func canPromotePreviewTab(id: UUID) -> Bool { tabs.first { $0.id == id }?.isPreview ?? false } @@ -237,10 +197,7 @@ final class QueryTabManager: ObservableObject { // MARK: - Recency - /// The tab is recorded once the turn settles, not at each write. Opening a table into another - /// connection brings that connection forward and then selects the new tab in the same turn, so - /// the tab it was showing is never drawn; recorded as it passed through, it would be what the - /// next Control-Tab went back to. + // Recorded once the turn settles: opening into another connection selects twice in one turn, the first never drawn. private func scheduleActivationRecord() { guard isFrontmost, !activationRecordPending else { return } activationRecordPending = true @@ -273,9 +230,7 @@ final class QueryTabManager: ObservableObject { } } - /// The workspace rail lists a container while a tab holds it open, so it reloads when - /// that set can have changed. Every other tab mutation, a keystroke above all, leaves - /// the anchors identical and publishes nothing. + // Publishes only when the set of held-open containers can change; a keystroke leaves the anchors identical. private func publishAnchorChange(oldTabs: [QueryTab], newTabs: [QueryTab]) { guard WorkspaceAnchoring.anchors(in: oldTabs) != WorkspaceAnchoring.anchors(in: newTabs) else { return @@ -323,7 +278,6 @@ final class QueryTabManager: ObservableObject { // MARK: - Tab Naming - /// Next "Query N" title based on existing tabs across all windows. static func nextQueryTitle(existingTabs: [QueryTab]) -> String { let maxNumber = existingTabs .filter { $0.tabType == .query } @@ -397,27 +351,13 @@ final class QueryTabManager: ObservableObject { } } - /// A file that is already open is shown, not reloaded over. - /// - /// Opening it again is a request to look at it, so the buffer is replaced only when there is - /// nothing of the user's in it. A tab with unsaved edits keeps them: `setText` on the editor - /// resets its storage, so the replacement was not undoable and nothing asked first. The one - /// path that does replace a dirty buffer is the file-changed-on-disk banner, which asks. - /// - /// The baseline moves with the buffer. Writing the text without it left the tab reading as - /// dirty against content it had just loaded, and armed the same banner for a change it had - /// already taken. + // Shown, not reloaded over: setText isn't undoable, so a dirty buffer is kept. The baseline moves with the text. private func adoptReopenedFile(at index: Int, content: String, stamp: FileStamp?, encoding: FileTextEncoding?) { - /// An unknown baseline is not a licence to replace what the tab holds. `isFileDirty` reads a - /// missing one as clean, so without this a tab that never learned what its file said would - /// be overwritten by the very check meant to protect it. + // isFileDirty reads a missing baseline as clean, so an unknown baseline must not license overwriting the tab. guard tabs[index].content.savedFileContent != nil, !tabs[index].content.isFileDirty else { return } FileTabBaseline.adopt(text: content, stamp: stamp, encoding: encoding, into: &tabs[index].content) } - /// Take an already-built tab, such as one rebuilt from the recently closed history, rather than - /// minting a fresh one. Selecting it drives the window title, toolbar, and persistence through - /// the usual `selectedTabId` observation. func adoptTab(_ tab: QueryTab, claimFocus: Bool = false) { var tab = tab bindToVisibleContainer(&tab) @@ -437,16 +377,12 @@ final class QueryTabManager: ObservableObject { _ isPreview: Bool ) -> Void)? - /// Fired the instant a tab stops being about the table it was about. Whoever owns execution - /// listens here rather than at the navigation call sites, because a retarget that forgets to - /// invalidate is exactly how a finished query paints its rows into a tab showing something else. + // Execution owners listen here, not at call sites: a retarget that skipped invalidation painted stale rows into the tab. @Published var onTabRetargeted: ((UUID) -> Void)? @Published var onTableSchemaResolved: ((_ tableName: String, _ databaseName: String, _ schemaName: String) -> Void)? - /// `objectType` carries what the object is, where the caller knew it, alongside the `isView` - /// the callers that know only that much pass. Whoever records the open needs the kind: a - /// MariaDB sequence recorded as "not a table" came back as a view and was offered Drop View. + // The recorder needs the kind: a MariaDB sequence recorded as "not a table" came back as a view offering Drop View. private func notifyTableOpened( tableName: String, schemaName: String?, databaseName: String, isView: Bool, objectType: TableInfo.TableType?, isPreview: Bool @@ -454,9 +390,7 @@ final class QueryTabManager: ObservableObject { onTableOpened?(tableName, schemaName, databaseName, isView, objectType, isPreview) } - /// A table tab opened before its schema was known was reported without one, so whoever recorded - /// that open learns the schema when the tab does. Otherwise the record keeps naming the table in - /// whichever schema is browsed when it is next used. + // The open was recorded before the schema was known; without this the record follows whichever schema is browsed. func adoptResolvedSchema(_ schemaName: String, at index: Int) { guard tabs.indices.contains(index) else { return } tabs[index].tableContext.schemaName = schemaName @@ -464,10 +398,7 @@ final class QueryTabManager: ObservableObject { onTableSchemaResolved?(tableName, tabs[index].tableContext.databaseName, schemaName) } - /// The tab already showing this table, preferring the selected one. - /// - /// A table can legitimately hold more than one tab now, so plain array order would send a - /// sidebar click on the table you are already looking at to the other copy of it. + // Prefers the selected tab: a table can hold several tabs, and array order sent a sidebar click to the other copy. func tabShowingTable( named tableName: String, databaseName: String, schemaName: String?, isTemporary: Bool = false ) -> QueryTab? { @@ -482,11 +413,7 @@ final class QueryTabManager: ObservableObject { return tabs.first(where: matches) } - /// - Parameter allowsDuplicate: `true` when the caller asked for a tab of its own, so a table - /// that is already open gets a second one instead of the existing tab being reselected. - /// "Open in New Tab" means what it says only if this reaches here. - /// - Returns: `true` when a tab was created, `false` when an existing one was reselected. - /// Callers that carry per-tab payload state must not write it onto a tab they did not create. + // Returns false when an existing tab was reselected: callers must not write per-tab payload onto a tab they didn't create. @discardableResult func addTableTab( tableName: String, @@ -560,8 +487,7 @@ final class QueryTabManager: ObservableObject { selectedTabId = newTab.id } - /// `schemaName` is the schema the diagram draws, which on an engine whose entries are schemas - /// is the entry the tab belongs to, whichever one is on screen when it opens. + // schemaName is the schema drawn; on engines whose entries are schemas it is also the tab's entry. func addERDiagramTab(schemaKey: String, databaseName: String = "", schemaName: String? = nil) { let tabTitle = String(localized: "ER Diagram") var newTab = QueryTab(title: tabTitle, tabType: .erDiagram) @@ -601,8 +527,6 @@ final class QueryTabManager: ObservableObject { selectedTabId = newTab.id } - /// One tab per object, so opening the same routine twice returns to the tab already showing - /// it, the way opening the same table does. func addObjectSourceTab(objectRef: DatabaseObjectRef) { if let existing = tabs.first(where: { $0.tabType == .objectSource && $0.display.objectRef?.namesSameObject(as: objectRef) == true @@ -635,8 +559,6 @@ final class QueryTabManager: ObservableObject { selectedTabId = newTab.id } - /// One listing per database and schema, so asking again for the same tables returns to the tab - /// already showing them, the way opening the same table does. @discardableResult func addTableListingTab(title: String, databaseName: String, schemaName: String?) -> UUID { let schemaName = schemaName.flatMap { $0.isEmpty ? nil : $0 } @@ -658,8 +580,7 @@ final class QueryTabManager: ObservableObject { return newTab.id } - /// The sidebar section's own name for the kind, qualified by the schema on an engine that lists - /// tables per schema, where two listings in one database would otherwise share a label. + // Qualified by schema where tables are listed per schema, or two listings in one database would share a label. static func tableListingTitle(kindTitle: String, schemaName: String?) -> String { guard let schemaName, !schemaName.isEmpty else { return kindTitle } return String(format: String(localized: "%@: %@"), kindTitle, schemaName) @@ -700,9 +621,7 @@ final class QueryTabManager: ObservableObject { selectedTabId = newTab.id } - /// Replace the currently selected tab's content with a new table. - /// - Returns: `true` if the replacement happened (caller should run the query), - /// `false` if there is no selected tab. + // Returns false when no tab is selected; on true the caller runs the query. @discardableResult func replaceTabContent( tableName: String, databaseType: DatabaseType = .mysql, @@ -732,8 +651,7 @@ final class QueryTabManager: ObservableObject { var tab = tabs[selectedIndex] tab.tabType = .table tab.title = Self.tabTitle(name: tableName, schema: schemaName, databaseType: databaseType) - /// Built whole rather than field by field: a field left over describes the previous table, - /// and its primary keys were what a keyless table's edits matched rows on. + // Built whole: a leftover field describes the previous table, and its primary keys matched a keyless table's edits. tab.tableContext = TabTableContext( tableName: tableName, databaseName: databaseName, @@ -743,8 +661,7 @@ final class QueryTabManager: ObservableObject { objectType: objectType, isTemporary: isTemporary ) - /// Fresh for the same reason. A query tab keeps the file it was opened from, and a table - /// carried that binding, so Save wrote the table's query into the file. + // Fresh too: a query tab keeps its file binding, so a retargeted table's Save wrote its query into that file. tab.content = TabQueryContent(query: query) tab.schemaVersion += 1 tab.execution.executionTime = nil @@ -756,17 +673,14 @@ final class QueryTabManager: ObservableObject { tab.display.activeResultSetId = nil tab.sortState = SortState() tab.restoredSortSource = .unset - tab.selectedRowIndices = [] - tab.cellSelection = .empty + tab.selectionSnapshot = .empty tab.pendingChanges = TabChangeSnapshot() tab.hasUserInteraction = false tab.filterState = TabFilterState() tab.valueFilter = GridValueFilterState() tab.columnLayout = ColumnLayoutState() tab.pagination = PaginationState(pageSize: pageSize) - // Retargeting points the tab at a different table, so a restore that has not been consumed - // yet describes rows this tab no longer shows. Left behind, it is persisted against the new - // table and applied to it on the next launch. + // An unconsumed restore describes the old rows; kept, it persists and applies to the new table on the next launch. tab.pendingRestoredSort = nil tab.restoredPage = nil tab.restoredPageSize = nil @@ -819,7 +733,6 @@ final class QueryTabManager: ObservableObject { // MARK: - Visible Container extension QueryTabManager { - /// The tabs the strip lists: the visible entry's, plus the ones that name no container. var visibleTabs: [QueryTab] { if let cached = _visibleTabs { return cached } let listed = tabs.filter(isVisible) @@ -833,19 +746,13 @@ extension QueryTabManager { WorkspaceAnchoring.containerName(of: tab, target: containerTarget) } - /// A tab that names no container belongs to no entry, so every entry shows it: a server - /// dashboard, or a query tab on an engine whose query tabs run against whatever is browsed. + // A tab naming no container (a server dashboard, a query tab on a browse-following engine) shows in every entry. func isVisible(_ tab: QueryTab) -> Bool { guard let visibleContainer, let container = containerName(of: tab) else { return true } return container == visibleContainer } - /// Puts an entry in front. The selection stays when it is already in that entry, lands on the - /// tab last used there otherwise, and on nothing when the entry holds no tab of its own, which - /// is the entry's empty state rather than a tab from somewhere else. - /// - /// It never moves the browse cursor. Whoever moved the cursor calls this, or calls it first and - /// moves the cursor after. + // Selection stays, falls back to the entry's last-used tab, or clears. Never moves the browse cursor; callers do that. func show(container: String?) { guard containerTarget != nil else { return } let container = container.flatMap { $0.isEmpty ? nil : $0 } @@ -857,20 +764,14 @@ extension QueryTabManager { selectedTabId = landing } - /// A new window's only tab. Its entry is put in front before the tab is selected, so a window - /// built around a tab never reads as the user picking a tab in another entry, which would move - /// the connection's database on its own. + // Entry goes in front before selecting, so a new window never reads as a user pick that moves the database. func seed(with tab: QueryTab) { tabs = [tab] show(container: containerName(of: tab) ?? visibleContainer) selectedTabId = tab.id } - /// A tab is bound to the database, or the schema on an engine whose entries are schemas, it was - /// opened in. One a caller opened without naming it, such as a New Tab on a schema engine, - /// Favorites or Open in Editor, gets the visible entry's. Left unbound it ran against whatever - /// the connection browsed next, and picking a tab of another entry moves that: a statement - /// typed against one schema would run against the other. + // Unbound tabs ran against whatever the connection browsed next, so a statement typed for one schema ran on another. func bindToVisibleContainer(_ tab: inout QueryTab) { guard let visibleContainer, Self.bindsToContainer(tab.tabType), @@ -886,16 +787,13 @@ extension QueryTabManager { } } - /// Where a tab opened without naming a database goes: the entry on screen. The browse cursor - /// lags it while a switch to that entry is on its way, and a tab bound to the cursor then would - /// take the window straight back to the entry the user just left. + // The entry on screen, not the browse cursor, which lags a switch and would pull the window back to the old entry. func openingDatabase(browsing browseDatabase: String) -> String { guard containerTarget == .database, let visibleContainer else { return browseDatabase } return visibleContainer } - /// Restored tabs from before tabs were bound to their entry: they ran against the container the - /// connection was on, which is the one the restore lands in. + // Tabs from before entry binding ran against the connection's container, which is the one the restore lands in. func bindUnboundTabs() { guard visibleContainer != nil, tabs.contains(where: { Self.bindsToContainer($0.tabType) && containerName(of: $0) == nil }) @@ -907,7 +805,7 @@ extension QueryTabManager { tabs = bound } - /// Server-wide tabs answer for the whole connection, so they belong to every entry. + // Server-wide tabs answer for the whole connection, so they belong to every entry. private static func bindsToContainer(_ type: TabType) -> Bool { switch type { case .query, .table, .createTable, .erDiagram, .objectSource, .tableListing: @@ -934,9 +832,7 @@ extension QueryTabManager { return visibleTabs.last?.id } - /// Selecting a tab of another entry, or moving the selected tab into one, takes the window to - /// that entry: the selected tab is always one the strip lists. A selection that names a tab not - /// in the list yet is the moment inside a close or an adoption, and is left alone. + // The selected tab is always listed; a selection naming an unlisted tab is mid close or adoption and left alone. fileprivate func reconcileVisibleContainer() { guard containerTarget != nil, let id = selectedTabId, diff --git a/apple/TablePro/Models/Query/QueryTabState.swift b/apple/TablePro/Models/Query/QueryTabState.swift index 3f99be903a..42fa43be7a 100644 --- a/apple/TablePro/Models/Query/QueryTabState.swift +++ b/apple/TablePro/Models/Query/QueryTabState.swift @@ -8,12 +8,6 @@ import Foundation import TableProPluginKit import TableProUtilities -@MainActor -final class GridSelectionState: ObservableObject { - @Published var indices: Set = [] -} - -/// Type of tab enum TabType: Equatable, Codable, Hashable { case query case table @@ -24,12 +18,10 @@ enum TabType: Equatable, Codable, Hashable { case insights case objectSource case versionHistory - /// The tables of one database or schema, as a read-only grid. Built from the catalog each time it - /// loads, never from SQL saved with the tab. + // Read-only grid of one database's or schema's tables, rebuilt from the catalog on each load, never from saved SQL. case tableListing } -/// Minimal representation of a tab for persistence struct PersistedTab: Codable { let id: UUID let title: String @@ -37,9 +29,7 @@ struct PersistedTab: Codable { let tabType: TabType let tableName: String? var isView: Bool = false - /// The object's own kind, as its `TableInfo.TableType` raw value. Optional and a raw String so a - /// file written before this existed decodes to nil, and a spelling a newer build invents decodes - /// to nil too rather than throwing and taking the whole tab aggregate with it. + // Optional raw String so old files and kinds a newer build invents decode to nil instead of failing the whole tab set. var objectTypeRawValue: String? var databaseName: String = "" var schemaName: String? @@ -50,9 +40,7 @@ struct PersistedTab: Codable { var versionHistorySubject: VersionHistorySubject? var queryParameters: [QueryParameter]? var sortColumns: [PersistedSortColumn]? - /// Who chose the saved order. Absent in every file written before this existed, which decodes - /// back to the behaviour those files were written under: `.user` when columns were saved, - /// `.unset` when none were. + // Nil in files written before this existed: decodes to .user when columns were saved, .unset when none were. var sortSource: SortSource? var restoredPage: Int? var restoredPageSize: Int? @@ -65,7 +53,7 @@ struct PersistedTab: Codable { var isPinned: Bool = false var isTemporary: Bool = false - /// Set when the query was too large for the tab-state JSON and lives in a sidecar file. + // Set when the query was too large for the tab-state JSON and lives in a sidecar file. var overflowFileName: String? init( @@ -220,7 +208,6 @@ enum SortDirection: String, Equatable, Codable, CaseIterable, Identifiable { } } -/// A single column in a multi-column sort struct SortColumn: Equatable { var columnIndex: Int var direction: SortDirection @@ -233,25 +220,19 @@ struct SortColumn: Equatable { } } -/// A sort column captured for cross-launch persistence, keyed by name so it survives column reordering +// Keyed by name so it survives column reordering across launches. struct PersistedSortColumn: Codable, Equatable { let columnName: String let direction: SortDirection } -/// Who decided the order the rows are in. -/// -/// Three answers, not two, and the third is what makes "Don't Sort" stick. An empty `SortState` -/// used to mean both "nothing has decided yet" and "the user turned sorting off", so -/// `wantsDefaultSort` could not tell them apart and wrote the app default back over an explicit -/// clear on the next first load. `.unset` is the former; `columns: [], source: .user` is the latter. +// .unset vs .user with no columns tells "nothing decided yet" from "user turned sorting off", so Don't Sort sticks. enum SortSource: String, Equatable, Codable { case unset case user case defaultSort } -/// Tracks sorting state for a table (supports multi-column sort) struct SortState: Equatable { var columns: [SortColumn] = [] var source: SortSource = .unset @@ -263,8 +244,7 @@ struct SortState: Equatable { var isSorting: Bool { !columns.isEmpty } - /// The sort in the name-keyed shape both persistence and navigation history store it in. A - /// column with no name cannot be resolved back against a re-fetched result, so it is dropped. + // A column with no name cannot be resolved against a re-fetched result, so it is dropped. var persistedColumns: [PersistedSortColumn] { columns.compactMap { column in guard let name = column.columnName else { return nil } @@ -277,31 +257,18 @@ struct SortState: Equatable { var direction: SortDirection { columns.first?.direction ?? .ascending } } -/// Tracks pagination state for navigating large datasets struct PaginationState: Equatable { - var totalRowCount: Int? // Total rows in table (from COUNT(*)) - var pageSize: Int // Rows per page (passed from manager/coordinator) - var currentPage: Int = 1 // Current page number (1-based) - var currentOffset: Int = 0 // Current OFFSET for SQL query - /// A fetch is in flight for the rows this state describes. - /// - /// Raised synchronously by whatever discards the tab's rows, in the same block that discards - /// them, and lowered when the rows land or the attempt fails. It cannot be derived from - /// `TabExecutionRegistry.isExecuting`: retargeting a tab clears the row buffer synchronously - /// but only *schedules* the load, so the claim arrives a main-actor turn later and the status - /// bar can render an empty buffer that nothing has yet called a load. + var totalRowCount: Int? + var pageSize: Int + var currentPage: Int = 1 + var currentOffset: Int = 0 + // Raised where rows are discarded; not derived from TabExecutionRegistry, whose claim lands a main-actor turn later. var isLoading: Bool = false - var isApproximateRowCount: Bool = false // True when totalRowCount is from fast estimate - var isCountingExact: Bool = false // True while a user-requested exact count is running - /// The message a failed `Count Exactly` put on the tab, so a later count that succeeds takes - /// down its own error and no other. + var isApproximateRowCount: Bool = false + var isCountingExact: Bool = false + // From a failed Count Exactly, kept so a later successful count clears only this error. var exactCountError: String? - /// An automatic row count is running, so the total on screen is not the one this page settles on. - /// - /// Separate from `isCountingExact`, which the user asked for and which owns the spinner. This - /// one is silent: it exists so `Count Exactly` is not offered against a total that is about to - /// be replaced anyway. The execution claim cannot answer this, because it settles the moment - /// phase 1 applies, before the count is even dispatched. + // Silent automatic count in flight, so Count Exactly is not offered; the execution claim settles before it dispatches. var isCountPending: Bool = false // Result truncation state (query tabs) @@ -310,16 +277,13 @@ struct PaginationState: Equatable { var baseQueryForMore: String? var baseQueryParameterValues: [String?]? - /// The query and its bindings are one fact, so they are replaced together. Setting the query - /// alone left the previous run's bindings in place, and Fetch All then sent them with a query - /// that no longer had placeholders, which the server rejects. + // Set together: stale bindings sent with a placeholder-free query made Fetch All fail on the server. mutating func setBaseQueryForMore(_ sql: String?, parameterValues: [String?]?) { baseQueryForMore = sql baseQueryParameterValues = parameterValues } - /// Default page size constant (used when no explicit value is provided) - /// Note: For new tabs, callers should pass AppSettingsManager.shared.dataGrid.defaultPageSize + // Fallback only; new tabs pass AppSettingsManager.shared.dataGrid.defaultPageSize. static let defaultPageSize = 1_000 init( @@ -338,36 +302,26 @@ struct PaginationState: Equatable { // MARK: - Computed Properties - /// Total number of pages - /// - /// The ceiling is taken with `quotientAndRemainder` rather than `(total + pageSize - 1) / pageSize` - /// because the custom rows-per-page field used to accept `Int.max`, and the addition then trapped - /// on overflow the next time the status bar rendered. + // quotientAndRemainder, not (total + pageSize - 1): the rows-per-page field once accepted Int.max and overflowed. var totalPages: Int { guard let total = totalRowCount, total > 0, pageSize > 0 else { return 1 } let (quotient, remainder) = total.quotientAndRemainder(dividingBy: pageSize) return remainder == 0 ? quotient : quotient + 1 } - /// Any asynchronous work this state is still waiting on. var isBusy: Bool { isLoading || isLoadingMore || isCountingExact || isCountPending } - /// Whether the total is a real count rather than a driver estimate. - /// - /// An estimate cannot bound navigation. MySQL's `TABLE_ROWS` under-reports InnoDB routinely, and - /// an estimate kept as the total left every row past it unreachable behind a disabled Next. + // An estimate cannot bound navigation: MySQL's TABLE_ROWS under-reports InnoDB and stranded rows behind a disabled Next. var hasExactRowCount: Bool { totalRowCount != nil && !isApproximateRowCount } - /// Whether any total is available, exact or estimated. var hasRowCountTotal: Bool { totalRowCount != nil } - /// Whether there is a next page available var hasNextPage: Bool { currentPage < totalPages } @@ -381,21 +335,20 @@ struct PaginationState: Equatable { return loadedRowCount >= pageSize } - /// Whether there is a previous page available + // A table tab pages through currentPage; hasMoreRows is a query result cut short. + func hasUnloadedRows(loadedRowCount: Int) -> Bool { + hasMoreRows || canGoToNextPage(loadedRowCount: loadedRowCount) + } + var hasPreviousPage: Bool { currentPage > 1 } - /// Starting row number for current page (1-based) var rangeStart: Int { currentOffset + 1 } - /// Ending row number for the current page, from the rows the page actually returned. - /// - /// Deriving it from `pageSize` fabricated a range the grid never showed: a table whose driver - /// estimate said a million rows but which returned twelve reported "1-1000 of ~1,000,000 rows". - /// The loaded count is the only number that describes what is on screen. + // From loaded rows, not pageSize: an estimate of 1M that returned 12 rows showed "1-1000 of ~1,000,000 rows". func rangeEnd(loadedRowCount: Int) -> Int { currentOffset + max(loadedRowCount, 0) } @@ -431,52 +384,34 @@ struct PaginationState: Equatable { setPage(totalPages) } - /// A page beyond the last is refused only when the last one is actually known. With an estimate - /// there is no trustworthy upper bound, and refusing on one strands the rows past it. + // Pages past the last are refused only for an exact total; an estimate is no trustworthy upper bound. mutating func goToPage(_ page: Int) { guard page > 0, hasRowCountTotal else { return } guard !hasExactRowCount || page <= totalPages else { return } setPage(page) } - /// Applies a total the app derived on its own, rather than one the user asked for. - /// - /// An estimate never replaces an exact count. The automatic count re-runs after every execution, - /// including a page turn, and on a driver whose cheap count is an estimate that silently undid a - /// `Count Exactly` and brought the button back on the next page. Retiring an exact count is a - /// deliberate act, so it belongs to the paths that ask for fresh data, not to this one. - /// A nil total blanks it, which is what a filter change asks for and is never an estimate. + // Never replaces an exact count: the auto count re-runs on every page turn and undid Count Exactly. Nil blanks it. mutating func applyDerivedRowCount(_ total: Int?, isApproximate: Bool) { guard !isApproximate || !hasExactRowCount else { return } totalRowCount = total isApproximateRowCount = isApproximate } - /// Drops the total so the next execution derives it again from scratch. - /// - /// For the paths that change the row set or ask for it fresh, where the count on screen is no - /// longer describing the table. Clearing the number rather than flagging it approximate matters: - /// `rowCountPlan` skips counting a table whose total already exceeds the count threshold, so a - /// real count left in place and merely relabelled would keep its `~` forever, with `Last page` - /// disabled and no `Count Exactly` to put it right. Cleared, the tab counts exactly the way a - /// freshly opened one does. + // Cleared, not marked approximate: rowCountPlan skips counting a total above the threshold, so the ~ would stick. mutating func retireDerivedRowCount() { totalRowCount = nil isApproximateRowCount = false } - /// Reset pagination to first page mutating func reset() { currentPage = 1 currentOffset = 0 isLoading = false - /// A count belonging to the rows being replaced is not this tab's business any more. Left - /// set, a superseded attempt's mark would suppress `Count Exactly` on the new table until - /// something else happened to clear it. + // A superseded count's mark would otherwise suppress Count Exactly on the new table. isCountPending = false } - /// Reset result truncation state mutating func resetLoadMore() { hasMoreRows = false isLoadingMore = false @@ -484,18 +419,13 @@ struct PaginationState: Equatable { baseQueryParameterValues = nil } - /// Update page size (limit), keeping the first visible row inside the new page. - /// - /// `setPage` is what re-derives the offset. Assigning `currentPage` alone left the old offset in - /// place, so changing 20 to 100 on page 3 ran `LIMIT 100 OFFSET 40` while the indicator read - /// "1 / N" and First and Previous went inert. + // Goes through setPage to re-derive the offset; setting currentPage alone ran LIMIT 100 OFFSET 40 shown as page 1. mutating func updatePageSize(_ newSize: Int) { guard newSize > 0 else { return } pageSize = newSize setPage((currentOffset / pageSize) + 1) } - /// Update offset directly and recalculate page mutating func updateOffset(_ newOffset: Int) { guard newOffset >= 0 else { return } currentOffset = newOffset @@ -503,7 +433,6 @@ struct PaginationState: Equatable { } } -/// Stores column layout (widths and order) within a tab session struct ColumnLayoutState: Equatable { var columnWidths: [String: CGFloat] = [:] var columnContentWidths: [String: CGFloat]? @@ -512,20 +441,13 @@ struct ColumnLayoutState: Equatable { // Leading columns in display order, a count so a result that repeats a name stays unambiguous. var frozenColumnCount: Int = 0 - /// Splices a captured order into the stored one, keeping names the capture never saw. - /// - /// A capture only lists the columns the query returned, and hiding a column takes it out of - /// that projection. Overwriting with the capture therefore drops the hidden column's position, - /// and showing it again appends it at the end, which reorders a grid the user never touched. - /// Names the capture does cover take its order; the rest hold their slots. + // A capture omits hidden columns, so names it doesn't cover keep their slots instead of jumping to the end when shown. static func mergedColumnOrder(current: [String]?, incoming: [String]?) -> [String]? { guard let incoming else { return current } guard let current, !current.isEmpty else { return incoming } let incomingSet = Set(incoming) - // Only a narrowing of the same column set is a partial capture worth splicing. Anything - // else is a different result, and merging there would accumulate names from a table the - // layout no longer describes. + // Only a narrowing of the same column set is a partial capture; anything else is a different result. guard incomingSet.isSubset(of: Set(current)) else { return incoming } var remaining = incoming.makeIterator() var merged: [String] = [] @@ -552,8 +474,7 @@ struct ColumnLayoutState: Equatable { frozenColumnCount = other.frozenColumnCount } - /// Drops the geometry outright. Reset is not a capture, so it must not go through the merge, - /// which deliberately keeps a stored order when a capture carries none. + // Reset is not a capture, so it skips the merge, which keeps a stored order when a capture carries none. mutating func resetGeometry() { columnWidths = [:] columnContentWidths = nil @@ -568,9 +489,7 @@ struct ColumnLayoutState: Equatable { } } -/// Deliberately has no `isExecuting`. Busy state is derived from `TabExecutionRegistry`, because a -/// stored flag could not be reset by a tab retarget and so kept a retargeted tab busy forever, -/// silently swallowing every later navigation. +// No isExecuting on purpose: busy comes from TabExecutionRegistry, as a stored flag kept retargeted tabs busy forever. struct TabExecutionState: Equatable { var executionTime: TimeInterval? var statusMessage: String? @@ -579,14 +498,10 @@ struct TabExecutionState: Equatable { var rowsAffected: Int = 0 var lastExecutedAt: Date? - /// Set when work on this tab finished somewhere the user could not see it, cleared when they - /// select the tab. This is the channel that survives a missed banner, a denied notification - /// permission and a Focus mode, none of which the app can do anything about. + // Set when work finished out of sight, cleared on select; survives missed banners, denied notifications and Focus. var finishedUnseenAt: Date? - /// Hand-written, and every field the UI draws from has to be in it. `finishedUnseenAt` drives - /// a dot in the tab strip, so leaving it out here would mean the dot never appeared: SwiftUI - /// compares the tab and sees no change. + // Hand-written: every field the UI draws must be compared, or SwiftUI sees no change (finishedUnseenAt drives a dot). static func == (lhs: TabExecutionState, rhs: TabExecutionState) -> Bool { lhs.executionTime == rhs.executionTime && lhs.statusMessage == rhs.statusMessage @@ -604,19 +519,10 @@ struct TabTableContext: Equatable { var isEditable: Bool = false var isView: Bool = false - /// The object's own kind, carried beside `isView` rather than replacing it. - /// - /// `isView` is a read-only mark that a dozen Bool-only carriers already speak (deeplinks, the URL - /// parser, scripting, recents), and a tab saved by an older build can carry it false over a - /// materialized view. It cannot say which of seven kinds the object is. This can, and only the - /// kind says which *structure* edits the object accepts. (#2726) - /// - /// Nil on a tab restored from a file written before this existed, and on any path that never - /// learned the kind; `resolvedObjectKind()` falls back to what `isView` can still tell us. + // Beside isView, which can't tell seven kinds apart; only the kind says which structure edits the object accepts (#2726). var objectType: TableInfo.TableType? - // Read on the session that holds it, and browsed only: a staged row edit would land on whatever table the name - // reaches once the temporary one is dropped. + // Browse only: a staged row edit would land on whatever table the name reaches once the temporary one is dropped. var isTemporary = false func resolvedObjectKind() -> TableInfo.TableType { @@ -629,18 +535,14 @@ struct TabTableContext: Equatable { var primaryKeyColumn: String? { primaryKeyColumns.first } - /// A tab opened without an explicit database carries an empty name and follows the window's - /// browse cursor, so comparing the stored value against a real database name never matches. + // An empty name means the tab follows the window's browse cursor. func resolvedDatabaseName(browsing browseDatabaseName: String) -> String { databaseName.isEmpty ? browseDatabaseName : databaseName } } struct TabQueryContent: Equatable { - /// Holds the query text behind a reference. SwiftUI's attribute-graph diff compares a value by walking its memory - /// layout, so a stored `String` field made it normalize the whole multi-megabyte query (NFC) on every view update. - /// Storing the text in a class makes that walk compare an 8-byte pointer instead. Value semantics are preserved - /// because the setter replaces the box. + // Boxed: SwiftUI's graph diff walks a stored String, which NFC-normalized multi-MB queries on every view update. private final class QueryStorage: Sendable { let text: String init(_ text: String) { self.text = text } @@ -694,9 +596,7 @@ struct TabQueryContent: Equatable { } static func == (lhs: TabQueryContent, rhs: TabQueryContent) -> Bool { - // Cheap scalar fields short-circuit first. The query and saved-file text can be multiple megabytes and are - // bridged from NSTextStorage, so they are compared last and with `sameText` to avoid Swift's canonical Unicode - // comparison (O(n) on the bridged text); the same-box identity check makes an unchanged query O(1). + // Cheap fields first; multi-MB text last, via box identity and sameText to skip canonical Unicode comparison. lhs.isParameterPanelVisible == rhs.isParameterPanelVisible && lhs.diskChange == rhs.diskChange && lhs.dismissedDiskChange == rhs.dismissedDiskChange @@ -708,7 +608,7 @@ struct TabQueryContent: Equatable { && sameText(lhs.savedFileContent, rhs.savedFileContent) } - /// Literal text equality that skips Swift's canonical Unicode comparison, returning in O(1) when the lengths differ. + // Literal comparison, skipping Swift's canonical Unicode equality; O(1) when the lengths differ. private static func sameText(_ lhs: String?, _ rhs: String?) -> Bool { switch (lhs, rhs) { case (nil, nil): @@ -723,12 +623,7 @@ struct TabQueryContent: Equatable { struct TabDisplayState: Equatable { var resultsViewMode: ResultsViewMode = .data - /// The geometry columns of the result currently installed, decided where the rows are installed - /// rather than at each reader. - /// - /// Deciding it costs a sample of the column's values, and the status bar asks on every render - /// while the View menu asks on every validation, so it is answered once per result. Nothing but - /// `MainContentCoordinator.installTableRows` writes it. + // Set once per result in installTableRows: detection samples values, and the status bar and View menu ask constantly. var spatialColumns: [SpatialColumn] = [] var erDiagramSchemaKey: String? var objectRef: DatabaseObjectRef? @@ -755,7 +650,6 @@ struct TabDisplayState: Equatable { leaveOutputModeWithoutOutput() } - /// A re-read of one result takes its place, label and pin included, and leaves the others alone. @MainActor mutating func replaceResult(_ outgoingId: UUID, with result: ResultSet) { guard let index = resultSets.firstIndex(where: { $0.id == outgoingId }) else { return } @@ -773,9 +667,7 @@ struct TabDisplayState: Equatable { leaveOutputModeWithoutOutput() } - /// Output mode shows what the active result printed, so it cannot outlive a result that printed nothing. The - /// rows of a new result are installed, and their modes reconciled, before the result itself replaces the old - /// one, which is too early to see the new result's output. + // Output mode can't outlive a silent result; checked here since row install reconciles modes before the result swap. @MainActor private mutating func leaveOutputModeWithoutOutput() { guard resultsViewMode == .output, activeResultSet?.serverOutput.isEmpty != false else { return } diff --git a/apple/TablePro/Models/UI/DataGridConfiguration.swift b/apple/TablePro/Models/UI/DataGridConfiguration.swift index f42660aec7..e6313ae64d 100644 --- a/apple/TablePro/Models/UI/DataGridConfiguration.swift +++ b/apple/TablePro/Models/UI/DataGridConfiguration.swift @@ -2,65 +2,47 @@ // DataGridConfiguration.swift // TablePro // -// Configuration struct for DataGridView, replacing individual config properties. -// import Foundation +// What a grid offers. Where its rows came from lives in `traits`; where its layout is saved lives in its store. struct DataGridConfiguration: Equatable { var dropdownColumns: Set? var typePickerColumns: Set? var customDropdownOptions: [Int: [GridMenuOption]]? - var connectionId: UUID? - var databaseType: DatabaseType? - var tableName: String? - var databaseName: String? - var schemaName: String? var primaryKeyColumns: [String] = [] - var tabType: TabType? var showRowNumbers: Bool = true - var hiddenColumns: Set = [] - /// Columns drawn as a checkbox whose state the delegate owns, for a grid that reviews rows rather - /// than edits them. Keyed by index, because the column is the grid's own and has no name a table - /// column could share. + // Keyed by index: the column is the grid's own and has no name a table column could share. var checkboxColumns: Set = [] - /// Whether the grid offers the commands that belong to a result: filtering by a column, column - /// hiding, export and the JSON view. A grid whose rows are a comparison answers none of them, and - /// a command that does nothing is worse than one that is not there. - var supportsColumnCommands: Bool = true - var supportsValueFilter: Bool = true - var filterMenuColumnTypes: [ColumnType]? - - /// Headings whose cells this grid must not let the user change, even though the rest of the grid - /// is editable. The Structure tab sets it per object kind: PostgreSQL takes a view's - /// `RENAME COLUMN` and `SET DEFAULT` and refuses its `SET NOT NULL` and `SET DATA TYPE`, so Name - /// and Default stay open while Nullable and Type lock. Name-keyed because that is the handle - /// `isColumnWritable` already works in. (#2726) + // PostgreSQL renames a view's column and sets its default but refuses its NOT NULL and type (#2726). var lockedColumns: Set = [] - /// This grid shows rows of a result, so the Data Grid sort preferences apply to it. - /// - /// `tabType` cannot answer this: the Structure grid runs inside a `.table` tab and lists columns, - /// and a query result runs in a `.query` tab and is as much the data grid as a table is. The - /// question is what the grid holds, not which tab it sits in. + // A result's rows, not a list of columns: the Data Grid sort preferences apply. var appliesRowSortPreferences: Bool = false - // Records: a table, a query, a file or a listing. Off on the Structure grids, whose Copy writes column and - // index definitions a paste reads back, and which have no INSERT or UPDATE to offer. + // Off on the Structure grids, whose Copy writes definitions a paste reads back. var appliesRowCopyFormat: Bool = false - /// Whether a heading click or the heading menu orders the rows. A Cassandra table cannot be ordered by an - /// arbitrary column, so its table grid offers no sort rather than one the server refuses. + // A Cassandra table cannot be ordered by an arbitrary column. var supportsColumnSort: Bool = true - /// Why these rows cannot be written back, when they cannot. A grid that silently refuses every - /// keystroke reads as broken, so the reason rides with the configuration and the grid shows it. + // A grid that refuses every keystroke silently reads as broken. var editRefusalMessage: String? - /// Keyed by heading; a column not named here keeps `DataGridMetrics.dataColumnMinWidth`. var columnMinimumWidths: [String: CGFloat] = [:] + + var isEditable: Bool = false + + var commands: Set = [] + + var traits: DataGridEngineTraits = .none + + var rowReorder: DataGridRowReorder = .disabled + + // VoiceOver's name for the grid ("Active Sessions"); nil reads "Data grid". + var accessibilityLabel: String? } diff --git a/apple/TablePro/Models/UI/FindCounterText.swift b/apple/TablePro/Models/UI/FindCounterText.swift new file mode 100644 index 0000000000..908416f657 --- /dev/null +++ b/apple/TablePro/Models/UI/FindCounterText.swift @@ -0,0 +1,40 @@ +// +// FindCounterText.swift +// TablePro +// + +import Foundation + +enum FindScope: Equatable { + case allRowsLoaded + case pageOfMore +} + +enum FindCounterText { + static func text(matchCount: Int, currentIndex: Int?, scope: FindScope) -> String { + guard matchCount > 0 else { return emptyText(scope: scope) } + + let position = (currentIndex ?? 0) + 1 + switch scope { + case .allRowsLoaded: + return String( + format: String(localized: "%1$@ of %2$@"), + position.formatted(), + matchCount.formatted() + ) + case .pageOfMore: + return String( + format: String(localized: "%1$@ of %2$@ on this page"), + position.formatted(), + matchCount.formatted() + ) + } + } + + private static func emptyText(scope: FindScope) -> String { + switch scope { + case .allRowsLoaded: String(localized: "No matches") + case .pageOfMore: String(localized: "Not on this page") + } + } +} diff --git a/apple/TablePro/Models/UI/GridSelection.swift b/apple/TablePro/Models/UI/GridSelection.swift index fa307d8c56..6cc2c6a2a4 100644 --- a/apple/TablePro/Models/UI/GridSelection.swift +++ b/apple/TablePro/Models/UI/GridSelection.swift @@ -4,15 +4,21 @@ struct GridSelection: Equatable { var rectangles: [GridRect] var activeCell: GridCoord? var anchor: GridCoord? - /// The display positions the user picked *as columns*, by clicking their headings. - /// - /// Recorded rather than re-derived, because geometry cannot tell the gestures apart. A - /// rectangle spanning every row is what a heading click builds, and equally what Select All, - /// Shift+Space and an ordinary cell drag build whenever the page is short enough. Reading the - /// intent back out of the shape painted the whole heading row as selected on Cmd+A, hid a - /// swept block's own outline, and armed the data file window's Delete Column on every column of - /// the file. + // Recorded, not read from shape: a full-height rectangle is also Select All, Shift+Space or a drag on a short page. var columns: IndexSet = [] + // Geometry can't tell the pick: an all-columns range on a narrow grid is still cells, and a click's pick is one cell. + enum Mode: Equatable { + case cells + // A cell click: its row is selected and the cursor sits on the cell. + case cursor + // Row numbers, Select All, Shift+Space. + case rows + } + + var mode: Mode = .cells + + var picksRows: Bool { mode == .rows } + var isRows: Bool { mode != .cells } static let empty = GridSelection(rectangles: [], activeCell: nil, anchor: nil) @@ -34,8 +40,7 @@ struct GridSelection: Equatable { return set } - /// The display positions the selection covers. Convert with - /// `TableViewCoordinator.dataColumnIndices(in:)` before indexing anything that holds values. + // Display positions: convert with TableViewCoordinator.dataColumnIndices(in:) before indexing anything holding values. var affectedColumns: IndexSet { var set = IndexSet() for rect in rectangles { @@ -59,7 +64,6 @@ struct GridSelection: Equatable { return GridRect(rows: minRow...maxRow, columns: minColumn...maxColumn) } - /// The display positions covered on one row. func columns(in row: Int) -> IndexSet { var set = IndexSet() for rect in rectangles where rect.rows.contains(row) { @@ -68,13 +72,7 @@ struct GridSelection: Equatable { return set } - /// The part of this selection that still fits a grid of the given size, or `.empty` when none - /// of it does. - /// - /// A selection restored onto a remounted grid describes the result it was made against, and the - /// row count can have shrunk in between. `NSTableView.selectRowIndexes` is all-or-nothing on an - /// out-of-range member, measured, so an unclamped restore selects nothing at all rather than the - /// rows that do still exist. + // NSTableView.selectRowIndexes selects nothing if any index is out of range (measured), and a restored result can shrink. func clamped(rowLimit: Int, columnLimit: Int) -> GridSelection { let fitted = rectangles.compactMap { $0.clamped(rowLimit: rowLimit, columnLimit: columnLimit) } guard !fitted.isEmpty else { return .empty } @@ -84,14 +82,7 @@ struct GridSelection: Equatable { coord.displayColumn >= 0, coord.displayColumn < columnLimit else { return nil } return coord } - /// A marker only survives while its block still covers the column. The row count can have - /// grown as well as shrunk, and a result that gained rows leaves the old block short of the - /// end: keeping the marker then told the heading and the column commands they had a whole - /// column while the fill, the copy and the affected rows stopped short, and - /// `removeEntireColumn` could no longer match the stale block to take it back off. - /// - /// Checking the geometry here is not the inference this field replaced. It validates a - /// recorded intent against the result now in front of it, rather than inventing one. + // A marker survives only while its block still covers the column: a grown result left a stale marker short of the end. let stillWholeColumns = columns.filteredIndexSet { position in guard position >= 0, position < columnLimit else { return false } return fitted.contains { rect in @@ -102,7 +93,8 @@ struct GridSelection: Equatable { rectangles: fitted, activeCell: fit(activeCell), anchor: fit(anchor), - columns: stillWholeColumns + columns: stillWholeColumns, + mode: mode ) } @@ -111,7 +103,8 @@ struct GridSelection: Equatable { rectangles: rectangles + other.rectangles, activeCell: other.activeCell ?? activeCell, anchor: other.anchor ?? anchor, - columns: columns.union(other.columns) + columns: columns.union(other.columns), + mode: mode == other.mode ? mode : .cells ) } @@ -119,8 +112,7 @@ struct GridSelection: Equatable { GridSelection(rectangles: [rect], activeCell: active, anchor: anchor) } - /// One whole column the user picked by its heading, carrying the position as well as the block, - /// so the heading knows it was chosen rather than merely covered. + // Carries the position, so the heading knows it was chosen rather than merely covered. static func column(_ displayColumn: Int, totalRows: Int) -> GridSelection { let anchor = GridCoord(row: 0, displayColumn: displayColumn) return GridSelection( @@ -133,8 +125,7 @@ struct GridSelection: Equatable { } internal extension GridSelection { - /// Rectangles overlap (a Cmd+drag over a block, a picked column under a swept one), so summing - /// their areas counts a cell twice. Walked by row band, so a whole-column pick costs one band. + // Rectangles overlap, so areas can't be summed; walked by row band, so a whole-column pick costs one band. var uniqueCellCount: Int { if rectangles.count == 1, let only = rectangles.first { return only.rows.count * only.columns.count @@ -168,81 +159,10 @@ internal extension GridSelection { markers.remove(cell.displayColumn) return rebuilt(rectangles: remaining, markers: markers, active: activeCell, origin: anchor) } - - /// `indices` are positions in the grown grid, as `NSTableView.insertRows(at:)` takes them. - static func row(_ row: Int, afterInserting indices: IndexSet) -> Int { - var row = row - for range in indices.rangeView { - guard range.lowerBound <= row else { break } - row += range.count - } - return row - } - - /// `indices` are positions before the removal, as `NSTableView.removeRows(at:)` takes them. Nil - /// when the row itself was removed. - static func row(_ row: Int, afterRemoving indices: IndexSet) -> Int? { - guard !indices.contains(row) else { return nil } - return row - indices.count(in: 0.. GridSelection { - guard !isEmpty, !indices.isEmpty, newRowCount > 0 else { return self } - let oldRowCount = newRowCount - indices.count - func shiftedRow(_ row: Int) -> Int { - Self.row(row, afterInserting: indices) - } - func shiftedCoord(_ coord: GridCoord?) -> GridCoord? { - coord.map { GridCoord(row: shiftedRow($0.row), displayColumn: $0.displayColumn) } - } - let moved = rectangles.map { rect -> GridRect in - if isPickedColumn(rect, rowCount: oldRowCount) { - return GridRect(rows: 0...(newRowCount - 1), columns: rect.columns) - } - return GridRect( - rows: shiftedRow(rect.rows.lowerBound)...shiftedRow(rect.rows.upperBound), - columns: rect.columns - ) - } - return rebuilt( - rectangles: moved, - markers: wholeColumns(in: moved, rowCount: newRowCount), - active: shiftedCoord(activeCell), - origin: shiftedCoord(anchor) - ) - } - - func removingRows(_ indices: IndexSet, newRowCount: Int) -> GridSelection { - guard !isEmpty, !indices.isEmpty else { return self } - guard newRowCount > 0 else { return .empty } - func shiftedRow(_ row: Int) -> Int? { - Self.row(row, afterRemoving: indices) - } - func shiftedCoord(_ coord: GridCoord?) -> GridCoord? { - guard let coord, let row = shiftedRow(coord.row) else { return nil } - return GridCoord(row: row, displayColumn: coord.displayColumn) - } - let kept = rectangles.compactMap { rect -> GridRect? in - var survivors = IndexSet(integersIn: rect.rows.lowerBound...rect.rows.upperBound) - survivors.subtract(indices) - guard let first = survivors.first, - let last = survivors.last, - let lower = shiftedRow(first), - let upper = shiftedRow(last) else { return nil } - return GridRect(rows: lower...upper, columns: rect.columns) - } - return rebuilt( - rectangles: kept, - markers: wholeColumns(in: kept, rowCount: newRowCount), - active: shiftedCoord(activeCell), - origin: shiftedCoord(anchor) - ) - } } private extension GridSelection { - /// Every rectangle's row bounds are breakpoints, so the rectangles covering a band are the same - /// on each of its rows and one column union serves the whole band. + // Rectangle row bounds are breakpoints, so one column union serves every row of a band. func forEachRowBand(_ body: (Range, IndexSet) -> Void) { var breakpoints = rectangles.flatMap { [$0.rows.lowerBound, $0.rows.upperBound + 1] } breakpoints.sort() @@ -263,23 +183,7 @@ private extension GridSelection { } } - func isPickedColumn(_ rect: GridRect, rowCount: Int) -> Bool { - !columns.isEmpty - && columns.contains(integersIn: rect.columns.lowerBound...rect.columns.upperBound) - && rect.rows.lowerBound <= 0 - && rect.rows.upperBound >= rowCount - 1 - } - - func wholeColumns(in rects: [GridRect], rowCount: Int) -> IndexSet { - columns.filteredIndexSet { position in - rects.contains { rect in - rect.columns.contains(position) && rect.rows.lowerBound <= 0 && rect.rows.upperBound >= rowCount - 1 - } - } - } - - /// A cursor left outside the selection would be drawn on a cell that is no longer selected, so - /// it moves to the last rectangle, as the Cmd+click toggle has always done. + // A cursor outside the selection would draw on an unselected cell, so it moves to the last rectangle (as Cmd+click does). func rebuilt(rectangles kept: [GridRect], markers: IndexSet, active: GridCoord?, origin: GridCoord?) -> GridSelection { guard let last = kept.last else { return .empty } func inside(_ coord: GridCoord?) -> GridCoord? { diff --git a/apple/TablePro/Models/UI/GridSelectionOwner.swift b/apple/TablePro/Models/UI/GridSelectionOwner.swift index 02510af884..b20f998971 100644 --- a/apple/TablePro/Models/UI/GridSelectionOwner.swift +++ b/apple/TablePro/Models/UI/GridSelectionOwner.swift @@ -2,22 +2,16 @@ // GridSelectionOwner.swift // TablePro // -// Resolves which grid owns the row selection in `GridSelectionState`. The data grid, -// the structure grid, and the new-table grid all publish into that one channel, so -// every consumer has to agree on whose display positions the indices are. -// import Foundation +// Which grid's model holds the selection the inspector, the Edit menu and the status bar read. internal enum GridSelectionOwner: Equatable { case dataGrid case schemaGrid case none - /// The mode arm is an exhaustive switch rather than an exception list, so a mode added later - /// cannot compile without saying whose display positions its selection is. It used to name - /// `.chart` alone in an if-chain, which meant any new mode fell through to the `tabType` - /// switch below and silently claimed the data grid's channel. + // An exhaustive switch over the mode, so a mode added later has to say whose selection it shows. static func resolve(tabType: TabType?, resultsViewMode: ResultsViewMode?) -> GridSelectionOwner { guard let tabType else { return .none } if tabType == .createTable { return .schemaGrid } @@ -27,8 +21,7 @@ internal enum GridSelectionOwner: Equatable { case .structure: return .schemaGrid case .chart, .output: - /// Nothing in a chart or in printed output selects a row, so the indices left over - /// from the grid are nobody's. + // Nothing in a chart or in printed output selects a row. return .none case .data, .json, .map: break @@ -37,9 +30,7 @@ internal enum GridSelectionOwner: Equatable { switch tabType { case .table, .query: - /// Map writes into this channel itself: clicking a shape selects that row, and the - /// indices it writes are the data grid's display positions, resolved through - /// `DisplayRowMapping`. + // Map selects through the same model: a clicked shape selects its row. return .dataGrid case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, .versionHistory, .tableListing: diff --git a/apple/TablePro/Models/UI/MultiRowEditState.swift b/apple/TablePro/Models/UI/MultiRowEditState.swift index 2d98137185..294d697f48 100644 --- a/apple/TablePro/Models/UI/MultiRowEditState.swift +++ b/apple/TablePro/Models/UI/MultiRowEditState.swift @@ -15,47 +15,38 @@ struct FieldEditState: Identifiable { let isLongText: Bool let isJson: Bool - /// Every selected cell that has a value holds bytes, one character per byte in `originalValue`. - /// The column type cannot say so: a spatial column is text on one engine and a blob on another. + // Read from the values, not the column type: a spatial column is text on one engine and a blob on another. var isBinaryValue: Bool = false var isPrimaryKey: Bool = false var isForeignKey: Bool = false - /// Set when the owning grid dictates the editor instead of the column type. + // Set when the owning grid dictates the editor instead of the column type. var editor: FieldEditorKind? - /// Which editor the field's own type and value ask for, resolved once here. - /// - /// Resolving it costs a full `JSONSerialization` parse of the value and a PHP-serialized parse - /// after it, and `FieldEditorResolver` was reached from two view bodies per field. Every hover, - /// every inspector tab switch and every pending-edit keystroke re-parsed every value in the row. + // Resolved once: it costs a JSONSerialization and a PHP-serialized parse, and view bodies re-parsed it on every hover. var resolvedEditor: FieldEditorKind? - /// A schema field has no data type, so it offers no type badge and no NULL or DEFAULT state. + // A schema field has no data type, so it offers no type badge and no NULL or DEFAULT state. var isSchemaField: Bool = false - /// The server owns the value, or refuses to change it on this kind of object, so the field is - /// shown without an editor. Refusing the edit further down instead would leave a pending value - /// here that nothing can clear, and the inspector would go on reporting an unsaved change that - /// Save never writes. + // Shown without an editor: refusing the edit further down left a pending value here that Save never writes. var isServerOwned: Bool = false - /// False for a NOT NULL column. Nil from the schema reads as nullable, as the grid's menus do. + // Nil from the schema reads as nullable, as the grid's menus do. var isNullable = true - /// Leaving the column out of an INSERT makes the server fill it, which is what Set DEFAULT gives. + // Leaving the column out of an INSERT makes the server fill it, which is what Set DEFAULT gives. var serverAssignsValue = false var displayFormat: ValueDisplayFormat? - /// A value the selected rows hold. A lengthless MySQL `CHAR` takes the compact UUID form only when its values do. + // A lengthless MySQL CHAR takes the compact UUID form only when its values do. var sampleText: String? var declaredType: String? - /// The edit is recorded elsewhere: by the grid that owns a schema row, or in the change manager for rows - /// that now disagree, which no single pending value can stand for. + // Recorded by the grid owning a schema row, or the change manager for rows that disagree, which no pending value can hold. var hasCommittedEdit: Bool = false var originalValue: String? @@ -68,14 +59,12 @@ struct FieldEditState: Identifiable { var isPendingDefault: Bool - /// `pendingValue` is SQL from `CellExpressionVocabulary`, staged to be written unquoted. + // pendingValue is SQL from CellExpressionVocabulary, staged to be written unquoted. var isPendingExpression = false - /// No selected row has a field for the column, which only an engine that tells a missing field - /// from NULL reports. + // Only an engine that tells a missing field from NULL reports this. var isAbsent = false - /// The user asked for the field to be taken out of the row. var isPendingRemoval = false // A selected row's value is still on the server, so the field shows no value and takes no edit. @@ -101,16 +90,14 @@ enum FieldEditContinuity { case discrete } -/// A field the inspector writes itself, for the save that runs when no pending change holds its -/// edits. +// For the save that runs when no pending change holds the inspector's edits. struct InspectorFieldEdit: Equatable { let columnIndex: Int let columnName: String let newValue: String? - /// The edit takes the field out of the row, which `newValue` cannot say: it reads nil for NULL - /// as well. + // newValue cannot say this: it reads nil for NULL as well. var removesField = false - /// `newValue` is SQL picked from the field's menu, written unquoted. + // newValue is SQL picked from the field's menu, written unquoted. var isExpression = false } @@ -118,51 +105,39 @@ struct InspectorFieldEdit: Equatable { final class MultiRowEditState: ObservableObject { @Published var fields: [FieldEditState] = [] - /// A field's new literal value, and whether it arrived a character at a time. Typing is folded into one - /// undo step; choosing NULL, DEFAULT or a picker value is its own step. + // Typing folds into one undo step; choosing NULL, DEFAULT or a picker value is its own step. @Published var onFieldChanged: ((Int, PluginCellValue, FieldEditContinuity) -> Void)? - /// A field set to SQL the database evaluates, picked from its menu or typed. `onFieldChanged` cannot carry - /// that, and a plain `.text` must never be read as SQL. + // A plain .text must never be read as SQL, so an expression takes its own callback. @Published var onFieldExpressionChanged: ((Int, String, FieldEditContinuity) -> Void)? - /// A value of its own for each row, such as a fresh UUID: one value over several rows would repeat a key. + // A value per row, such as a fresh UUID: one value over several rows would repeat a key. @Published var onFieldValuesChanged: ((Int, [RowID: PluginCellValue]) -> Void)? - /// A field cleared back to what the rows held, when they disagree on it, some of them have - /// no field for it, or some held SQL. It has no single value to send, so it asks for each row's own - /// configured value instead, for the field to go missing again on the rows that lacked it (the first set), - /// and for SQL again on the rows that held it (the second). + // Disagreeing rows have no single value: each row's own value, plus the rows to make absent again and to restore SQL on. @Published var onFieldReverted: ((Int, [RowID: PluginCellValue], Set, Set) -> Void)? - /// Remove Field on a field, for the rows the selection was configured with. @Published var onFieldRemoved: ((Int, [RowID]) -> Void)? - /// A value window still open over a selection that has moved on. It names the rows it was - /// opened for, because the fields it was opened from are gone. The flag says the value is SQL. + // A value window outliving its selection names its rows, since its fields are gone; the flag says the value is SQL. @Published var onDetachedFieldChanged: ((Int, PluginCellValue, [RowID], Bool) -> Void)? - @Published private(set) var selectedRowIndices: Set = [] - - /// The rows an edit is staged against, captured when the selection was configured. - /// - /// `selectedRowIndices` are display positions, and a commit that resolves them when the - /// keystroke arrives writes into whatever row the sort, the value filter or a later selection - /// left at that position. + // The rows an edit is staged against, captured when the selection was configured, never a display position. @Published private(set) var rowIDs: [RowID] = [] + // The schema row the fields came from, for a grid whose rows have no identity. + private var schemaDisplayRow: Int? @Published private(set) var allRows: [[String?]] = [] - /// The columns each row in `allRows` has no field for. + // Per row of allRows: the columns it has no field for. @Published private(set) var absentCells: [Set] = [] @Published private(set) var unloadedCells: [Set] = [] - /// The columns each row in `allRows` holds staged SQL in. + // Per row of allRows: the columns holding staged SQL. @Published private(set) var expressionCells: [Set] = [] @Published private(set) var columns: [String] = [] @Published private(set) var columnTypes: [ColumnType] = [] private var nonNullableColumns: Set = [] - /// The engine whose vocabulary typed text is read against, and what the table's writer takes. Nil and - /// `.none` offer no Set Value command and promote nothing. + // Nil and .none offer no Set Value command and promote nothing. private(set) var databaseType: DatabaseType? private(set) var cellWriter: CellWriterSupport = .none private(set) var rowsAreInserted = false @@ -182,8 +157,7 @@ final class MultiRowEditState: ObservableObject { } func configure( - selectedRowIndices: Set, - rowIDs: [RowID] = [], + rowIDs: [RowID], allRows: [[String?]], absentCells: [Set] = [], unloadedCells: [Set] = [], @@ -203,9 +177,9 @@ final class MultiRowEditState: ObservableObject { cellWriter: CellWriterSupport = .none ) { let columnsChanged = self.columns != columns - let selectionChanged = self.selectedRowIndices != selectedRowIndices + let selectionChanged = self.rowIDs != rowIDs || schemaDisplayRow != nil - self.selectedRowIndices = selectedRowIndices + schemaDisplayRow = nil self.rowIDs = rowIDs self.allRows = allRows self.absentCells = absentCells @@ -267,11 +241,7 @@ final class MultiRowEditState: ObservableObject { } } - /// A column edited elsewhere, in the grid or by an earlier inspector edit. A modified field that is now - /// missing is one an edit removed, and one that now reads NULL is a pending NULL: written as an empty - /// pending value it read as `''`, and the boolean picker showed false. Rows that now disagree stay - /// Multiple values: an empty pending value there read as one value, and typing into it looked like - /// editing a single cell. + // Edited elsewhere: as an empty pending value a NULL read as '' and disagreeing rows looked like one cell. if externallyModifiedColumns.contains(colIndex), pendingValue == nil, !isPendingNull, !isPendingDefault, !isPendingRemoval { if isAbsent { @@ -318,10 +288,7 @@ final class MultiRowEditState: ObservableObject { if let preservedId { newField.id = preservedId } - /// The column's display format decides the editor here exactly as it does in the grid. - /// Without it the inspector resolved from the raw value alone, so a column the user had - /// set to JSON or PHP-serialized opened a plain text field in the inspector while the - /// grid rendered it structured. + // Same display format as the grid, or a column set to JSON or PHP-serialized opened a plain text field here. newField.resolvedEditor = FieldEditorResolver.resolve( for: columnTypeEnum, isLongText: isLongText, @@ -336,8 +303,7 @@ final class MultiRowEditState: ObservableObject { self.fields = newFields } - /// The columns whose selected cells are bytes. One text cell rules a column out, because a - /// binary field commits bytes to every selected row, and a column of NULLs alone has none. + // One text cell rules a column out, since a binary field commits bytes to every row; an all-NULL column has none. static func binaryColumns(in rows: [[PluginCellValue]]) -> Set { var holdsBytes: [Bool] = [] var holdsText: [Bool] = [] @@ -361,8 +327,7 @@ final class MultiRowEditState: ObservableObject { return Set(holdsBytes.indices.filter { holdsBytes[$0] && !holdsText[$0] }) } - // A value holding an invisible character takes the text view only while marks are on, so a field - // shown when the setting changes is resolved again. + // An invisible-character value takes the text view only while marks are on, so a setting change re-resolves. private func adoptInvisibleCharacterSetting() { let reveals = AppSettingsManager.shared.dataGrid.showInvisibleCharacters guard reveals != revealsInvisibleCharacters else { return } @@ -383,14 +348,12 @@ final class MultiRowEditState: ObservableObject { } } - /// Configure state for a single schema row supplied by the grid that owns the selection. - /// Field ids survive a reconfigure of the same row so a commit does not rebuild the - /// editors and drop focus while the user is still moving between fields. + // Field ids survive a reconfigure of the same row so a commit does not rebuild the editors and drop focus. func configure(schemaFields: [InspectorRowField], displayRow: Int) { let names = schemaFields.map(\.name) - let reusedIds = selectedRowIndices == [displayRow] && columns == names ? fields.map(\.id) : [] + let reusedIds = schemaDisplayRow == displayRow && columns == names ? fields.map(\.id) : [] - selectedRowIndices = [displayRow] + schemaDisplayRow = displayRow rowIDs = [] absentCells = [] unloadedCells = [] @@ -433,14 +396,13 @@ final class MultiRowEditState: ObservableObject { } } - /// What a field's editor is showing right now, read from the store rather than from a copy an - /// earlier render captured. This is what a field's value binding answers. + // Read from the store, not a copy an earlier render captured; a field's value binding answers with this. func currentText(at index: Int) -> String { guard fields.indices.contains(index) else { return "" } return FieldValueState.resolve(fields[index]).editableText } - /// What Set Value can do to the field, the same answer the grid's menu gives for the column. + // Same answer the grid's menu gives for the column. func setValueCommands(for field: FieldEditState, isWritable: Bool) -> [SetValueCommand] { setValueContext(for: field, isWritable: isWritable).map(SetValueOptions.commands(for:)) ?? [] } @@ -479,25 +441,20 @@ final class MultiRowEditState: ObservableObject { var pending = Self.resolvePendingValue(value, original: original, isJson: fields[index].isJson) let expression = pending.flatMap { typedExpression($0, columnType: fields[index].columnTypeEnum) } var staged = pending.map { PluginCellValue.entered($0, columnType: fields[index].columnTypeEnum) } - /// A NOT NULL column whose type has no empty value holds nothing an emptied field could - /// mean, so the field goes back to its stored value, as the grid's cell does. + // NOT NULL with no empty value for the type: the field goes back to its stored value, as the grid's cell does. if staged == .null, !fields[index].isNullable { pending = nil staged = nil } resetPending(at: index) fields[index].pendingValue = pending - /// The field keeps its empty text while the change it stages is NULL: swapping the editor - /// for the pending NULL pill would take the field away in the middle of typing a new value. + // The field keeps its empty text while staging NULL; the NULL pill would take it away mid-typing. if let expression { onFieldExpressionChanged?(index, expression, .typing) } else if let staged { onFieldChanged?(index, staged, .typing) } else if hadPendingEdit { - /// `originalValue` is nil for three different situations, and only one of them is a - /// value: a stored NULL, a selection whose rows do not agree, and a field the rows do - /// not have. Sending it as one value wrote NULL into every selected row when the user - /// cleared a field they all disagreed on, which the field then reported as unedited. + // originalValue nil is a stored NULL, disagreeing rows or a missing field; one value wrote NULL into every row. let absentRows = configuredAbsentRows(atColumn: index) let expressionRows = configuredExpressionRows(atColumn: index) if fields[index].hasMultipleValues || !absentRows.isEmpty || !expressionRows.isEmpty { @@ -508,8 +465,7 @@ final class MultiRowEditState: ObservableObject { } } - /// The vocabulary's SQL for typed text, as the grid reads a typed cell. A value the rows were configured - /// with never comes through here, so stored text that spells a function stays text. + // Only typed text comes here, so stored text that spells a function stays text. private func typedExpression(_ text: String, columnType: ColumnType?) -> String? { guard let databaseType, let entry = CellEntry.typed(text, columnType: columnType, databaseType: databaseType, writer: cellWriter), @@ -518,8 +474,7 @@ final class MultiRowEditState: ObservableObject { return sql } - /// What each row held when the selection was configured, which is what a field with no value of - /// its own reverts to. + // What a field with no value of its own reverts to. private func configuredValues(atColumn index: Int) -> [RowID: PluginCellValue] { var values: [RowID: PluginCellValue] = [:] for (rowID, row) in zip(rowIDs, allRows) where row.indices.contains(index) { @@ -528,8 +483,7 @@ final class MultiRowEditState: ObservableObject { return values } - /// A binary field holds its bytes one character per byte. Sent back as text, that string is - /// staged over the blob as a real change, and SQLite cuts it at the first NUL. + // Sent back as text, a binary field's string is staged over the blob as a change, and SQLite cuts it at the first NUL. private func storedCell(_ value: String?, atColumn index: Int) -> PluginCellValue { guard let value else { return .null } guard fields.indices.contains(index), fields[index].isBinaryValue else { return .text(value) } @@ -544,11 +498,7 @@ final class MultiRowEditState: ObservableObject { Set(rowIDs.indices.filter { expressionCells[safe: $0]?.contains(index) == true }.map { rowIDs[$0] }) } - /// A commit from a detached value window, which outlives the selection it was opened from. - /// - /// While that selection is still the one on screen this is an ordinary field edit. Once it has - /// moved the fields no longer describe those rows, so the value goes straight to the rows the - /// window was opened for rather than into whatever is selected now. + // Once the selection has moved, the value goes to the rows the window was opened for, not to what is selected now. func updateDetachedField(columnIndex: Int, rowIDs: [RowID], value: String?) { guard !rowIDs.isEmpty else { return } if self.rowIDs == rowIDs, fields.indices.contains(columnIndex) { @@ -600,11 +550,7 @@ final class MultiRowEditState: ObservableObject { onFieldChanged?(index, .null, .discrete) } - /// Takes the field out of every selected row. The value is gone as well, so it reads as - /// neither NULL nor any value. - /// - /// Rows that had no field for it when the selection was configured are back as they were, - /// which leaves nothing pending, exactly as typing a field's own value back does. + // Rows that had no field to begin with are back as they were, so nothing is pending for them. func removeField(at index: Int) { guard index < fields.count, !rowIDs.isEmpty else { return } resetPending(at: index) @@ -627,8 +573,7 @@ final class MultiRowEditState: ObservableObject { onFieldExpressionChanged?(index, expression.sql, .discrete) } - /// Several rows each get a UUID of their own, so the field then reads Multiple values, and typing over it - /// writes the typed value to every row as on any field the rows disagree on. + // Each row gets its own UUID, so the field then reads Multiple values. func generateUUID(at index: Int, version: UUIDVersion, generator: UUIDValueGenerator = .shared) { guard fields.indices.contains(index), let context = setValueContext(for: fields[index], isWritable: true), @@ -651,8 +596,7 @@ final class MultiRowEditState: ObservableObject { rowIDs.map { ($0, form.value(for: generator.make(version))) }, uniquingKeysWith: { first, _ in first } ) - /// The rows hold these values now, so clearing a later edit goes back to them, as it does once the - /// inspector is rebuilt from the rows. + // Clearing a later edit goes back to these values, as it does once the inspector is rebuilt from the rows. for (position, rowID) in rowIDs.enumerated() { guard let value = values[rowID], allRows.indices.contains(position), allRows[position].indices.contains(index) else { continue } @@ -703,7 +647,7 @@ final class MultiRowEditState: ObservableObject { onFieldReverted = nil onFieldRemoved = nil onDetachedFieldChanged = nil - selectedRowIndices = [] + schemaDisplayRow = nil rowIDs = [] allRows = [] absentCells = [] diff --git a/apple/TablePro/Models/UI/TabFindState.swift b/apple/TablePro/Models/UI/TabFindState.swift deleted file mode 100644 index ae248fc7e2..0000000000 --- a/apple/TablePro/Models/UI/TabFindState.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// TabFindState.swift -// TablePro -// - -import Foundation - -struct FindMatch: Equatable, Hashable { - let displayRow: Int - let columnIndex: Int -} - -enum FindScope: Equatable { - case allRowsLoaded - case pageOfMore -} - -struct TabFindState: Equatable { - var term: String - var isVisible: Bool - var matches: [FindMatch] - var currentMatchIndex: Int? - var scope: FindScope - - init(isVisible: Bool = false) { - term = "" - self.isVisible = isVisible - matches = [] - currentMatchIndex = nil - scope = .allRowsLoaded - } - - var currentMatch: FindMatch? { - guard let index = currentMatchIndex, index >= 0, index < matches.count else { return nil } - return matches[index] - } - - var canEscalateToAllRows: Bool { - scope == .pageOfMore && !term.isEmpty - } - - mutating func clearMatches() { - matches = [] - currentMatchIndex = nil - } - - mutating func stepForward() { - guard !matches.isEmpty else { return } - guard let index = currentMatchIndex else { - currentMatchIndex = 0 - return - } - currentMatchIndex = (index + 1) % matches.count - } - - mutating func stepBackward() { - guard !matches.isEmpty else { return } - guard let index = currentMatchIndex else { - currentMatchIndex = matches.count - 1 - return - } - currentMatchIndex = (index - 1 + matches.count) % matches.count - } -} - -enum FindCounterText { - static func text(matchCount: Int, currentIndex: Int?, scope: FindScope) -> String { - guard matchCount > 0 else { return emptyText(scope: scope) } - - let position = (currentIndex ?? 0) + 1 - switch scope { - case .allRowsLoaded: - return String( - format: String(localized: "%1$@ of %2$@"), - position.formatted(), - matchCount.formatted() - ) - case .pageOfMore: - return String( - format: String(localized: "%1$@ of %2$@ on this page"), - position.formatted(), - matchCount.formatted() - ) - } - } - - private static func emptyText(scope: FindScope) -> String { - switch scope { - case .allRowsLoaded: String(localized: "No matches") - case .pageOfMore: String(localized: "Not on this page") - } - } -} diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index eda57dfcda..adfb8a9735 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -22889,6 +22889,9 @@ } } } + }, + "All Columns" : { + }, "All columns visible" : { "localizations" : { @@ -158219,6 +158222,9 @@ }, "Search Icons" : { + }, + "Search In" : { + }, "Search index %1$@ uses %2$@. Change or drop that search index first, then save again." : { "extractionState" : "manual", @@ -158454,6 +158460,9 @@ } } } + }, + "Search stopped" : { + }, "Search tables, views, databases, queries…" : { "localizations" : { @@ -168631,6 +168640,16 @@ } } }, + "Sort key %d of %d, %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sort key %1$d of %2$d, %3$@" + } + } + } + }, "Sorted ascending" : { "localizations" : { "fr" : { diff --git a/apple/TablePro/Resources/Themes/tablepro.catppuccin-latte.json b/apple/TablePro/Resources/Themes/tablepro.catppuccin-latte.json index 8cf7be7217..385ce65b3c 100644 --- a/apple/TablePro/Resources/Themes/tablepro.catppuccin-latte.json +++ b/apple/TablePro/Resources/Themes/tablepro.catppuccin-latte.json @@ -8,17 +8,11 @@ "boolTrue": "#40A02B", "deleted": "#D20F3926", "deletedText": "#D20F3980", - "focusBorder": "#8839EF", "gridLine": "#BCC0CC", - "headerBackground": "#E6E9EF", - "headerText": "#4C4F69", - "inactiveSelection": "#CCD0DA", "inserted": "#40A02B26", "modified": "#DF8E1D26", "nullValue": "#7C7F93", "rowNumber": "#8C8FA1", - "selection": "#CCD0DA", - "selectionText": "#4C4F69", "text": "#4C4F69" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json b/apple/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json index 2ce5a2d0e8..3a72c559ef 100644 --- a/apple/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json +++ b/apple/TablePro/Resources/Themes/tablepro.catppuccin-mocha.json @@ -8,17 +8,11 @@ "boolTrue": "#A6E3A1", "deleted": "#F38BA826", "deletedText": "#F38BA880", - "focusBorder": "#CBA6F7", "gridLine": "#45475A", - "headerBackground": "#181825", - "headerText": "#CDD6F4", - "inactiveSelection": "#313244", "inserted": "#A6E3A126", "modified": "#F9E2AF26", "nullValue": "#9399B2", "rowNumber": "#7F849C", - "selection": "#313244", - "selectionText": "#CDD6F4", "text": "#CDD6F4" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.dracula.json b/apple/TablePro/Resources/Themes/tablepro.dracula.json index 94a1575329..c1bf304da2 100644 --- a/apple/TablePro/Resources/Themes/tablepro.dracula.json +++ b/apple/TablePro/Resources/Themes/tablepro.dracula.json @@ -8,17 +8,11 @@ "boolTrue": "#50FA7B", "deleted": "#FF555526", "deletedText": "#FF555580", - "focusBorder": "#BD93F9", "gridLine": "#44475A", - "headerBackground": "#21222C", - "headerText": "#F8F8F2", - "inactiveSelection": "#44475A80", "inserted": "#50FA7B26", "modified": "#FFB86C4D", "nullValue": "#6272A4", "rowNumber": "#6272A4", - "selection": "#44475A", - "selectionText": "#F8F8F2", "text": "#F8F8F2" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.github-dark.json b/apple/TablePro/Resources/Themes/tablepro.github-dark.json index cb921d592e..c941acf3d1 100644 --- a/apple/TablePro/Resources/Themes/tablepro.github-dark.json +++ b/apple/TablePro/Resources/Themes/tablepro.github-dark.json @@ -8,17 +8,11 @@ "boolTrue": "#3FB950", "deleted": "#F8514926", "deletedText": "#F8514980", - "focusBorder": "#1F6FEB", "gridLine": "#30363D", - "headerBackground": "#161B22", - "headerText": "#E6EDF3", - "inactiveSelection": "#343941", "inserted": "#2EA04326", "modified": "#BB800926", "nullValue": "#8B949E", "rowNumber": "#6E7681", - "selection": "#142744", - "selectionText": "#E6EDF3", "text": "#E6EDF3" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.github-light.json b/apple/TablePro/Resources/Themes/tablepro.github-light.json index b18d295e08..24f5eeb8ea 100644 --- a/apple/TablePro/Resources/Themes/tablepro.github-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.github-light.json @@ -8,17 +8,11 @@ "boolTrue": "#1A7F37", "deleted": "#FF818226", "deletedText": "#CF222E80", - "focusBorder": "#0969DA", "gridLine": "#D0D7DE", - "headerBackground": "#F6F8FA", - "headerText": "#1F2328", - "inactiveSelection": "#EFF1F3", "inserted": "#4AC26B26", "modified": "#D4A72C26", "nullValue": "#6E7781", "rowNumber": "#8C959F", - "selection": "#CEE1F8", - "selectionText": "#1F2328", "text": "#1F2328" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.gruvbox-dark.json b/apple/TablePro/Resources/Themes/tablepro.gruvbox-dark.json index 7cb0d25f12..0a9fa77e46 100644 --- a/apple/TablePro/Resources/Themes/tablepro.gruvbox-dark.json +++ b/apple/TablePro/Resources/Themes/tablepro.gruvbox-dark.json @@ -8,17 +8,11 @@ "boolTrue": "#B8BB26", "deleted": "#FB493426", "deletedText": "#FB493480", - "focusBorder": "#689D6A", "gridLine": "#504945", - "headerBackground": "#32302F", - "headerText": "#EBDBB2", - "inactiveSelection": "#3C3836", "inserted": "#B8BB2626", "modified": "#D7992126", "nullValue": "#928374", "rowNumber": "#665C54", - "selection": "#384539", - "selectionText": "#EBDBB2", "text": "#EBDBB2" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.gruvbox-light.json b/apple/TablePro/Resources/Themes/tablepro.gruvbox-light.json index 2ef6cd9cf9..cab686b6db 100644 --- a/apple/TablePro/Resources/Themes/tablepro.gruvbox-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.gruvbox-light.json @@ -8,17 +8,11 @@ "boolTrue": "#79740E", "deleted": "#9D000626", "deletedText": "#9D000680", - "focusBorder": "#427B58", "gridLine": "#D5C4A1", - "headerBackground": "#F2E5BC", - "headerText": "#3C3836", - "inactiveSelection": "#EBDBB2", "inserted": "#79740E26", "modified": "#D7992126", "nullValue": "#928374", "rowNumber": "#BDAE93", - "selection": "#D6DCB0", - "selectionText": "#3C3836", "text": "#3C3836" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.nord.json b/apple/TablePro/Resources/Themes/tablepro.nord.json index a2b30e1b3b..5ec322af04 100644 --- a/apple/TablePro/Resources/Themes/tablepro.nord.json +++ b/apple/TablePro/Resources/Themes/tablepro.nord.json @@ -8,17 +8,11 @@ "boolTrue": "#A3BE8C", "deleted": "#BF616A26", "deletedText": "#BF616A80", - "focusBorder": "#88C0D0", "gridLine": "#434C5E", - "headerBackground": "#3B4252", - "headerText": "#D8DEE9", - "inactiveSelection": "#434C5E80", "inserted": "#A3BE8C26", "modified": "#EBCB8B4D", "nullValue": "#4C566A", "rowNumber": "#4C566A", - "selection": "#434C5E", - "selectionText": "#ECEFF4", "text": "#D8DEE9" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.one-dark.json b/apple/TablePro/Resources/Themes/tablepro.one-dark.json index f9098d550a..c350fa8510 100644 --- a/apple/TablePro/Resources/Themes/tablepro.one-dark.json +++ b/apple/TablePro/Resources/Themes/tablepro.one-dark.json @@ -8,17 +8,11 @@ "boolTrue": "#98C379", "deleted": "#E0525226", "deletedText": "#E06C7580", - "focusBorder": "#528BFF", "gridLine": "#3E4452", - "headerBackground": "#21252B", - "headerText": "#ABB2BF", - "inactiveSelection": "#323842", "inserted": "#43D08A26", "modified": "#E0C28526", "nullValue": "#7F848E", "rowNumber": "#636D83", - "selection": "#3E4451", - "selectionText": "#D7DAE0", "text": "#ABB2BF" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.one-light.json b/apple/TablePro/Resources/Themes/tablepro.one-light.json index 0af9b52720..63451b4990 100644 --- a/apple/TablePro/Resources/Themes/tablepro.one-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.one-light.json @@ -8,17 +8,11 @@ "boolTrue": "#50A14F", "deleted": "#FF141426", "deletedText": "#E4564980", - "focusBorder": "#526FFF", "gridLine": "#DBDBDC", - "headerBackground": "#EAEAEB", - "headerText": "#383A42", - "inactiveSelection": "#DBDBDC", "inserted": "#2DB44826", "modified": "#F2A60D26", "nullValue": "#A0A1A7", "rowNumber": "#9D9D9F", - "selection": "#DBDBDC", - "selectionText": "#232324", "text": "#383A42" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.solarized-dark.json b/apple/TablePro/Resources/Themes/tablepro.solarized-dark.json index 7f7374fcab..07b0431b68 100644 --- a/apple/TablePro/Resources/Themes/tablepro.solarized-dark.json +++ b/apple/TablePro/Resources/Themes/tablepro.solarized-dark.json @@ -8,17 +8,11 @@ "boolTrue": "#859900", "deleted": "#DC322F26", "deletedText": "#DC322F80", - "focusBorder": "#2AA198", "gridLine": "#23464F", - "headerBackground": "#00212B", - "headerText": "#93A1A1", - "inactiveSelection": "#003846", "inserted": "#85990026", "modified": "#B5890026", "nullValue": "#586E75", "rowNumber": "#586E75", - "selection": "#005A6F", - "selectionText": "#EEE8D5", "text": "#839496" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.solarized-light.json b/apple/TablePro/Resources/Themes/tablepro.solarized-light.json index 9ff51235cf..5b8e6e2967 100644 --- a/apple/TablePro/Resources/Themes/tablepro.solarized-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.solarized-light.json @@ -8,17 +8,11 @@ "boolTrue": "#859900", "deleted": "#DC322F26", "deletedText": "#DC322F80", - "focusBorder": "#268BD2", "gridLine": "#DDD6C1", - "headerBackground": "#EEE8D5", - "headerText": "#586E75", - "inactiveSelection": "#EEE0B5", "inserted": "#85990026", "modified": "#B5890026", "nullValue": "#93A1A1", "rowNumber": "#93A1A1", - "selection": "#DFCA88", - "selectionText": "#073642", "text": "#657B83" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.tokyo-night-light.json b/apple/TablePro/Resources/Themes/tablepro.tokyo-night-light.json index 02342393c3..d99ba60db0 100644 --- a/apple/TablePro/Resources/Themes/tablepro.tokyo-night-light.json +++ b/apple/TablePro/Resources/Themes/tablepro.tokyo-night-light.json @@ -8,17 +8,11 @@ "boolTrue": "#385F0D", "deleted": "#E8686826", "deletedText": "#8C435180", - "focusBorder": "#2959AA", "gridLine": "#C1C2C7", - "headerBackground": "#D6D8DF", - "headerText": "#343B59", - "inactiveSelection": "#C9CBD6", "inserted": "#2D9C9126", "modified": "#8F5E1526", "nullValue": "#888B94", "rowNumber": "#9DA0AB", - "selection": "#ACB0BF", - "selectionText": "#343B59", "text": "#343B59" }, "editor": { diff --git a/apple/TablePro/Resources/Themes/tablepro.tokyo-night.json b/apple/TablePro/Resources/Themes/tablepro.tokyo-night.json index 3fa07811f3..5dcca7b6ae 100644 --- a/apple/TablePro/Resources/Themes/tablepro.tokyo-night.json +++ b/apple/TablePro/Resources/Themes/tablepro.tokyo-night.json @@ -8,17 +8,11 @@ "boolTrue": "#9ECE6A", "deleted": "#DB4B4B26", "deletedText": "#F7768E80", - "focusBorder": "#7AA2F7", "gridLine": "#232433", - "headerBackground": "#16161E", - "headerText": "#A9B1D6", - "inactiveSelection": "#222433", "inserted": "#41A6B526", "modified": "#E0AF6826", "nullValue": "#51597D", "rowNumber": "#363B54", - "selection": "#2B2F41", - "selectionText": "#A9B1D6", "text": "#A9B1D6" }, "editor": { diff --git a/apple/TablePro/Theme/ThemeColor.swift b/apple/TablePro/Theme/ThemeColor.swift index f302a86e11..49ed7e7f06 100644 --- a/apple/TablePro/Theme/ThemeColor.swift +++ b/apple/TablePro/Theme/ThemeColor.swift @@ -11,7 +11,7 @@ internal extension ThemeColor { ) } - /// A color that cannot convert to sRGB, a pattern for one, becomes mid grey rather than failing. + // A color with no sRGB form, a pattern for one, becomes mid grey rather than failing. init(_ color: NSColor) { guard let converted = color.usingColorSpace(.sRGB) else { self.init(red: 0x80, green: 0x80, blue: 0x80) @@ -41,10 +41,7 @@ internal enum HexColor { } internal extension ThemeSlot { - /// What the slot draws when no theme overrides it: the color AppKit itself uses on that - /// surface. Every value is dynamic, so it resolves against the drawing view's own appearance, - /// accent, highlight and contrast. A tint is built inside a provider, because - /// `withAlphaComponent` on a system color outside one freezes it at the current appearance. + // A tint is built inside a provider: `withAlphaComponent` on a system color outside one freezes it. var nativeColor: NSColor { switch self { case .windowBackdrop: return .windowBackgroundColor @@ -65,13 +62,7 @@ internal extension ThemeSlot { case .gridBackground: return .controlBackgroundColor case .gridText, .gridBoolTrue, .gridBoolFalse: return .labelColor case .gridAlternateRow: return NativeThemeColors.alternateRow - case .gridHeaderBackground: return .controlBackgroundColor - case .gridHeaderText: return .headerTextColor case .gridLine: return .separatorColor - case .gridSelection: return .selectedContentBackgroundColor - case .gridSelectionText: return .alternateSelectedControlTextColor - case .gridInactiveSelection: return .unemphasizedSelectedContentBackgroundColor - case .gridFocusBorder: return .keyboardFocusIndicatorColor case .gridNullValue: return .secondaryLabelColor case .gridRowNumber: return .secondaryLabelColor case .gridModified: return NativeThemeColors.modifiedRow @@ -83,8 +74,7 @@ internal extension ThemeSlot { } internal enum NativeThemeColors { - /// macOS 14 draws the caret in the Highlight color through `textInsertionPointColor`. Earlier - /// releases draw it in the text color. + // macOS 14 draws the caret in the Highlight color; earlier releases in the text color. static let insertionPoint: NSColor = { if #available(macOS 14.0, *) { return .textInsertionPointColor @@ -92,8 +82,7 @@ internal enum NativeThemeColors { return .textColor }() - /// No system color exists for a current-line band; a faint accent keeps it on the user's - /// Accent color the way the selection and focus ring are. + // No system color exists for a current-line band; a faint accent follows Accent like the selection. static let currentLine = NSColor(name: NSColor.Name("tablepro.native.currentLine")) { appearance in NSColor.controlAccentColor.withAlphaComponent(appearance.isDarkTheme ? 0.14 : 0.08) } diff --git a/apple/TablePro/Theme/ThemeTableChrome.swift b/apple/TablePro/Theme/ThemeTableChrome.swift index 2b62a0d772..b65bf2a13c 100644 --- a/apple/TablePro/Theme/ThemeTableChrome.swift +++ b/apple/TablePro/Theme/ThemeTableChrome.swift @@ -3,20 +3,11 @@ import Combine import SwiftUI import TableProTheme -/// Themes an `NSTableView` or `NSOutlineView` that shows content. Never a source list: its selection -/// and background belong to the sidebar glass, which draws them without calling the row view. -/// -/// A property is written only while the active theme sets its slot, and gets back the value it had -/// once the theme stops, so under a default theme the table is exactly what AppKit draws. Rows take -/// the theme's background and selection through a `ThemeTableRowDrawing` row, and only while the -/// theme sets one of them: a row class that overrides a drawing method leaves AppKit's layer-backed -/// selection, the vibrant material in a popover among them. +// Never on a source list: the sidebar glass draws its selection and background without calling the row view. @MainActor internal final class ThemeTableChrome { internal enum Family { - /// Records and schema objects, on the data grid's slots. case records - /// Values and code, on the editor's slots. case code internal var background: ThemeSlot { @@ -40,17 +31,10 @@ internal final class ThemeTableChrome { } } - /// The data grid's header slots. A code table's header, where it has one, stays AppKit's. - internal var headerSlots: [ThemeSlot] { + // A records table's selection is always the system's, as the data grid's is. + internal var selection: ThemeSlot? { switch self { - case .records: return [.gridHeaderBackground, .gridHeaderText] - case .code: return [] - } - } - - internal func selection(emphasized: Bool) -> ThemeSlot { - switch self { - case .records: return emphasized ? .gridSelection : .gridInactiveSelection + case .records: return nil case .code: return .editorSelection } } @@ -64,12 +48,9 @@ internal final class ThemeTableChrome { internal let family: Family internal let scope: ThemeScope - /// True while the theme sets the rows' background or a selection, which only a themed row class - /// draws. A delegate returns AppKit's own row view otherwise. + // A row class that overrides a drawing method loses AppKit's layer-backed selection, so it is used only while needed. internal private(set) var themesRows = false - /// True while the rows are rebuilt for a new row class. The expansion and selection AppKit - /// reports meanwhile are the ones the table already had. internal private(set) var isRebuildingRows = false private weak var tableView: NSTableView? @@ -77,11 +58,9 @@ internal final class ThemeTableChrome { private weak var themedScrollView: NSScrollView? private var scrollViewDrewBackground = true private var originalGridColor: NSColor? - private var originalHeaderView: NSTableHeaderView? - private var originalCornerView: NSView? private var themeCancellable: AnyCancellable? - /// Attach before the table builds its rows, and once it is inside its scroll view. + // Attach before the table builds its rows, and once it is inside its scroll view. internal init(tableView: NSTableView, family: Family, scope: ThemeScope = .live) { self.tableView = tableView self.family = family @@ -95,18 +74,12 @@ internal final class ThemeTableChrome { backgroundOriginals != nil } - /// True while the table's header is a `ThemeTableHeaderView` in place of the one its owner gave it. - internal var isHeaderThemed: Bool { - originalHeaderView != nil - } - internal func apply() { restyle() guard let tableView, hasStaleRows(in: tableView) else { return } rebuildRows(of: tableView) } - /// Nil while the theme leaves the rows alone, so the table builds AppKit's own row view. internal func makeRowView(in tableView: NSTableView) -> ThemeTableRowView? { guard themesRows else { return nil } let reused = tableView.makeView(withIdentifier: ThemeTableRowView.reuseIdentifier, owner: nil) @@ -116,8 +89,7 @@ internal final class ThemeTableChrome { return rowView } - /// Nil while the theme leaves the background alone, and the row draws AppKit's. A themed table - /// stripes its rows itself: AppKit's stripes are the system pair whatever the table's color. + // AppKit's stripes are the system pair whatever the table's color, so a themed table stripes itself. internal func rowBackgroundColor(forRow row: Int) -> NSColor? { guard let originals = backgroundOriginals, row >= 0 else { return nil } let palette = scope.palette @@ -127,15 +99,13 @@ internal final class ThemeTableChrome { return palette[alternate] } - /// Nil unless the theme sets its own selection, so a default theme keeps AppKit's, which follows - /// Accent and focus. - internal func selectionColor(emphasized: Bool) -> NSColor? { + internal func selectionColor(emphasized _: Bool) -> NSColor? { + guard let slot = family.selection else { return nil } let resolved = scope.resolved - let slot = family.selection(emphasized: emphasized) return resolved.isOverridden(slot) ? resolved[slot] : nil } - internal func isOnThemedSelection(_ rowView: NSTableRowView) -> Bool { + private func isOnThemedSelection(_ rowView: NSTableRowView) -> Bool { rowView.isSelected && selectionColor(emphasized: rowView.isEmphasized) != nil } @@ -145,13 +115,10 @@ internal final class ThemeTableChrome { let backgroundSlots = [family.background] + [family.alternateRow].compactMap { $0 } applyBackground(to: tableView, themed: backgroundSlots.contains { resolved.isOverridden($0) }) applyGridColor(to: tableView, themed: family.gridLine.map { resolved.isOverridden($0) } ?? false) - applyHeader(to: tableView, themed: family.headerSlots.contains { resolved.isOverridden($0) }) - let selectionSlots = [family.selection(emphasized: true), family.selection(emphasized: false)] - themesRows = isBackgroundThemed || selectionSlots.contains { resolved.isOverridden($0) } + themesRows = isBackgroundThemed || family.selection.map { resolved.isOverridden($0) } ?? false tableView.needsDisplay = true tableView.enumerateAvailableRowViews { rowView, _ in rowView.needsDisplay = true - (rowView as? ThemeTableRowView)?.publishSelection() } } @@ -164,8 +131,8 @@ internal final class ThemeTableChrome { return isStale } - /// AppKit asks for a row view only when it builds a row, so a new class needs a reload. An - /// outline drops its selection on a reload, and the clip view clamps while rows are rebuilt. + // AppKit asks for a row view only when it builds a row, so a new class needs a reload. An outline + // drops its selection on a reload, and the clip view clamps while rows are rebuilt. private func rebuildRows(of tableView: NSTableView) { isRebuildingRows = true defer { isRebuildingRows = false } @@ -200,8 +167,7 @@ internal final class ThemeTableChrome { outlineView.selectRowIndexes(rows, byExtendingSelection: false) } - /// The scroll view's own background is the content-background material; a themed table drops it - /// so the material never shows through an elastic scroll. + // The scroll view's own background is the content-background material, which an elastic scroll would show. private func applyBackground(to tableView: NSTableView, themed: Bool) { guard themed else { restoreBackground(of: tableView) @@ -251,124 +217,18 @@ internal final class ThemeTableChrome { } tableView.gridColor = scope.palette[slot] } - - /// AppKit's header paints its own band over anything beneath it, so a themed header is ours and - /// the owner's comes back when the theme stops coloring it. - private func applyHeader(to tableView: NSTableView, themed: Bool) { - guard themed else { - restoreHeader(of: tableView) - return - } - if let header = tableView.headerView as? ThemeTableHeaderView, header.chrome === self { - header.needsDisplay = true - tableView.cornerView?.needsDisplay = true - return - } - guard let original = tableView.headerView else { return } - originalHeaderView = original - originalCornerView = tableView.cornerView - - let header = ThemeTableHeaderView(frame: original.frame) - header.chrome = self - header.menu = original.menu - tableView.headerView = header - let corner = ThemeTableCornerView(frame: originalCornerView?.frame ?? .zero) - corner.chrome = self - tableView.cornerView = corner - } - - private func restoreHeader(of tableView: NSTableView) { - guard let original = originalHeaderView else { return } - tableView.headerView = original - tableView.cornerView = originalCornerView - originalHeaderView = nil - originalCornerView = nil - } -} - -// MARK: - Header - -/// Drawn like the data grid's header. Each title goes through its cell's interior drawing, which -/// keeps AppKit's alignment and truncation. The sort indicator is drawn here because AppKit draws it -/// from the header view's own pass, which this replaces. -internal final class ThemeTableHeaderView: NSTableHeaderView { - internal weak var chrome: ThemeTableChrome? - - override internal func draw(_ dirtyRect: NSRect) { - guard let chrome, let tableView else { - super.draw(dirtyRect) - return - } - let scope = chrome.scope - SortableHeaderChrome.fillBackground(dirtyRect, scope: scope) - let textColor = scope.resolved[.gridHeaderText] - let dragged = draggedColumn - for index in tableView.tableColumns.indices where index != dragged { - drawHeading(ofColumn: index, at: headerRect(ofColumn: index), in: dirtyRect, textColor: textColor, scope: scope) - } - // A column being dragged follows the pointer over its neighbours, as AppKit's header draws it. - if dragged >= 0, dragged < tableView.tableColumns.count { - let floating = headerRect(ofColumn: dragged).offsetBy(dx: draggedDistance, dy: 0) - if floating.intersects(dirtyRect) { - SortableHeaderChrome.fillBackground(floating, scope: scope) - } - drawHeading(ofColumn: dragged, at: floating, in: dirtyRect, textColor: textColor, scope: scope) - } - SortableHeaderChrome.drawBottomSeparator(in: bounds, scope: scope) - } - - private func drawHeading(ofColumn index: Int, at heading: NSRect, in dirtyRect: NSRect, textColor: NSColor, scope: ThemeScope) { - guard let tableView, heading.width > 0, heading.intersects(dirtyRect) else { return } - let column = tableView.tableColumns[index] - let cell = column.headerCell - let nativeTextColor = cell.textColor - cell.textColor = textColor - if let sort = sortState(of: column, in: tableView) { - var title = heading - title.size.width = max(0, cell.sortIndicatorRect(forBounds: heading).minX - heading.minX) - cell.drawInterior(withFrame: title, in: self) - cell.drawSortIndicator(withFrame: heading, in: self, ascending: sort.ascending, priority: sort.priority) - } else { - cell.drawInterior(withFrame: heading, in: self) - } - cell.textColor = nativeTextColor - SortableHeaderChrome.drawColumnDivider(in: heading, scope: scope) - } - - /// AppKit shows only the primary sort key's indicator. - private func sortState(of column: NSTableColumn, in tableView: NSTableView) -> (ascending: Bool, priority: Int)? { - guard let key = column.sortDescriptorPrototype?.key, - let primary = tableView.sortDescriptors.first, primary.key == key - else { return nil } - return (primary.ascending, 0) - } -} - -/// The square over the vertical scroller beside a themed header, which AppKit would paint in its own -/// header band. -internal final class ThemeTableCornerView: NSView { - internal weak var chrome: ThemeTableChrome? - - override internal var isFlipped: Bool { true } - - override internal func draw(_ dirtyRect: NSRect) { - let scope = chrome?.scope ?? .live - SortableHeaderChrome.fillBackground(dirtyRect, scope: scope) - SortableHeaderChrome.drawBottomSeparator(in: bounds, scope: scope) - } } // MARK: - Row drawing -/// A row class that draws through its `ThemeTableChrome`. Each row class forwards its drawing -/// overrides here, so they exist only on a class the table uses while the theme draws its rows. +// Each row class forwards its drawing overrides here, so they exist only on a class the table uses +// while the theme draws its rows. @MainActor internal protocol ThemeTableRowDrawing: NSTableRowView { var chrome: ThemeTableChrome? { get set } } internal extension ThemeTableChrome { - /// False while the theme leaves the row's background alone, and the row draws AppKit's. func drawBackground(of rowView: NSTableRowView, in dirtyRect: NSRect) -> Bool { guard !rowView.isGroupRowStyle, let tableView = rowView.superview as? NSTableView, @@ -379,8 +239,7 @@ internal extension ThemeTableChrome { return true } - /// AppKit's selection recolored rather than replaced, so it keeps the shape the table style gives - /// it: inset and rounded in an inset table, full width in a plain one. + // AppKit's selection recolored rather than replaced keeps the shape the table style gives it. func drawSelection(of rowView: NSTableRowView, in dirtyRect: NSRect, native: (NSRect) -> Void) { guard let fill = selectionColor(emphasized: rowView.isEmphasized), let context = NSGraphicsContext.current?.cgContext @@ -397,28 +256,16 @@ internal extension ThemeTableChrome { context.restoreGState() } - /// A theme picks its selection to sit behind its own text, so the text does not flip to white. + // A theme picks its selection to sit behind its own text, so the text does not flip to white. func interiorBackgroundStyle(of rowView: NSTableRowView, native: NSView.BackgroundStyle) -> NSView.BackgroundStyle { isOnThemedSelection(rowView) ? .normal : native } } -/// The row a `ThemeTableChrome` hands out while the theme draws the rows. It tells its records cells -/// when they sit on the theme's selection. internal final class ThemeTableRowView: NSTableRowView, ThemeTableRowDrawing { internal static let reuseIdentifier = NSUserInterfaceItemIdentifier("ThemeTableRow") - internal weak var chrome: ThemeTableChrome? { - didSet { publishSelection() } - } - - override internal var isSelected: Bool { - didSet { publishSelection() } - } - - override internal var isEmphasized: Bool { - didSet { publishSelection() } - } + internal weak var chrome: ThemeTableChrome? override internal func drawBackground(in dirtyRect: NSRect) { guard chrome?.drawBackground(of: self, in: dirtyRect) == true else { @@ -439,53 +286,25 @@ internal final class ThemeTableRowView: NSTableRowView, ThemeTableRowDrawing { let native = super.interiorBackgroundStyle return chrome?.interiorBackgroundStyle(of: self, native: native) ?? native } - - internal var isOnThemedSelection: Bool { - chrome?.isOnThemedSelection(self) ?? false - } - - internal func publishSelection() { - let isThemed = isOnThemedSelection - for case let cell as ThemeTableChrome.HostingCell in subviews { - cell.isOnThemedSelection = isThemed - } - } } // MARK: - Records cell text internal extension ThemeTableChrome { - /// The slot a records cell's text takes, or nil for the cell's own hierarchical text, which - /// turns white on AppKit's emphasized selection. + // Nil keeps the cell's own hierarchical text, which turns white on AppKit's emphasized selection. static func recordsTextSlot( - onThemedSelection: Bool, onEmphasizedSelection: Bool, highContrast: Bool, scope: ThemeScope = .live ) -> ThemeSlot? { guard !onEmphasizedSelection else { return nil } - let theme = scope.activeTheme - if onThemedSelection, theme.isOverridden(.gridSelectionText, highContrast: highContrast) { - return .gridSelectionText - } - return theme.isOverridden(.gridText, highContrast: highContrast) ? .gridText : nil + return scope.activeTheme.isOverridden(.gridText, highContrast: highContrast) ? .gridText : nil } - /// A SwiftUI cell of a records table. Its text takes the theme's grid text, and the selection text - /// while its `ThemeTableRowView` sits on the theme's selection. final class HostingCell: NSHostingView { - private let selection = RecordsCellSelection() private var scope = ThemeScope.live - internal var isOnThemedSelection: Bool { - get { selection.isThemed } - set { - guard selection.isThemed != newValue else { return } - selection.isThemed = newValue - } - } - - /// `scope` is the one the table's chrome reads, so the text matches the rows. + // Pass the scope the table's chrome reads, so the text matches the rows. internal static func make( _ identifier: NSUserInterfaceItemIdentifier, in tableView: NSTableView, @@ -506,24 +325,12 @@ internal extension ThemeTableChrome { } internal func show(_ content: some View) { - rootView = AnyView(content.modifier(RecordsCellText(selection: selection, scope: scope))) - } - - /// A reused cell can land in another row, or in AppKit's own row view once the theme stops. - override internal func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - isOnThemedSelection = (superview as? ThemeTableRowView)?.isOnThemedSelection ?? false + rootView = AnyView(content.modifier(RecordsCellText(scope: scope))) } } } -@MainActor -private final class RecordsCellSelection: ObservableObject { - @Published var isThemed = false -} - private struct RecordsCellText: ViewModifier { - @ObservedObject var selection: RecordsCellSelection @ObservedObject private var themeEngine = ThemeEngine.shared @Environment(\.colorSchemeContrast) private var contrast @@ -531,32 +338,23 @@ private struct RecordsCellText: ViewModifier { func body(content: Content) -> some View { if #available(macOS 14, *) { - content.modifier( - ProminenceAwareRecordsCellText( - isOnThemedSelection: selection.isThemed, - scope: scope, - contrast: contrast - ) - ) + content.modifier(ProminenceAwareRecordsCellText(scope: scope, contrast: contrast)) } else { content } } } -/// macOS 13 has no `backgroundProminence`, so a cell there cannot tell AppKit's emphasized selection -/// apart and keeps its system text. +// macOS 13 has no `backgroundProminence`, so a cell there cannot see AppKit's emphasized selection and keeps its system text. @available(macOS 14, *) private struct ProminenceAwareRecordsCellText: ViewModifier { @Environment(\.backgroundProminence) private var prominence - let isOnThemedSelection: Bool let scope: ThemeScope let contrast: ColorSchemeContrast func body(content: Content) -> some View { let slot = ThemeTableChrome.recordsTextSlot( - onThemedSelection: isOnThemedSelection, onEmphasizedSelection: prominence == .increased, highContrast: contrast == .increased, scope: scope diff --git a/apple/TablePro/ViewModels/TableListingViewModel.swift b/apple/TablePro/ViewModels/TableListingViewModel.swift index 48a63019db..81cb17dc03 100644 --- a/apple/TablePro/ViewModels/TableListingViewModel.swift +++ b/apple/TablePro/ViewModels/TableListingViewModel.swift @@ -6,21 +6,12 @@ import Combine import Foundation -/// The rows of one listing tab, and what the reader did with its grid. -/// -/// A refresh keeps the rows on screen until new ones land, and a failed refresh keeps them too. A load -/// that a newer one has superseded, or that was cancelled, commits nothing, whatever it came back with. @MainActor internal final class TableListingViewModel: ObservableObject { @Published private(set) var state: MetadataLoadState = .idle - /// Moves each time a load lands, so the grid redraws rows that changed under the same tab. - @Published private(set) var revision = 0 - @Published var sortState = SortState() - @Published var selectedRows: Set = [] - @Published var columnLayout = ColumnLayoutState() - let changeManager = AnyChangeManager(DataChangeManager()) - let gridDelegate = HeldRowsGridDelegate() + // Outlives the tab's view, so the sort, the selection and the column widths survive a tab switch. + let grid = HeldRowsGridAdapter(configuration: DataGridConfiguration(), sortsHeldRows: true) private let scope: DatabaseScope private let loader: TableListingLoader @@ -39,16 +30,11 @@ internal final class TableListingViewModel: ObservableObject { self.scope = scope self.loader = loader self.metadataProvider = metadataProvider - /// Heard here rather than by the view, which exists only while its tab is selected: asking again for - /// a listing in a tab behind another one selects it and refreshes it in the same turn. refreshSubscription = refreshRequests .filter { $0 == tabId } .sink { [weak self] _ in self?.reload() } - gridDelegate.onSortStateChanged = { [weak self] in self?.sortState = $0 } } - /// The view's task runs again every time the tab comes back, so a listing already on screen or on its - /// way is left alone. A cancelled load settles back to idle, and runs again here. @discardableResult func loadIfNeeded() -> Task? { guard case .idle = state else { return nil } @@ -73,7 +59,6 @@ internal final class TableListingViewModel: ObservableObject { return task } - /// For a closed tab: its load is left to nobody, and nothing it returns may land. func cancel() { loadTask?.cancel() generation &+= 1 @@ -85,14 +70,12 @@ internal final class TableListingViewModel: ObservableObject { private func commit(_ outcome: MetadataFetchOutcome, token: Int) { guard token == generation else { return } state = state.settled(by: outcome, discardingValue: false) - revision &+= 1 + if case .loaded(let listing) = state { + grid.setRows(listing.rows) + } } } -/// The listing models of a window's tabs, kept for as long as their tab is open. -/// -/// Only the selected tab's view exists, so a model the view held went with every switch to another tab, -/// and the rows, the sort and the column widths with it, and coming back listed the tables again. @MainActor internal final class TableListingModelStore { private var models: [UUID: TableListingViewModel] = [:] @@ -106,8 +89,6 @@ internal final class TableListingModelStore { return model } - /// A closed tab's load is cancelled rather than left running for nobody on a metadata connection the - /// sidebar and the next listing wait on. func retainTabs(_ openTabIds: Set) { for (tabId, model) in models where !openTabIds.contains(tabId) { model.cancel() diff --git a/apple/TablePro/Views/Agent/AgentResultRowsView.swift b/apple/TablePro/Views/Agent/AgentResultRowsView.swift index cbd8bb3d51..d644b8706a 100644 --- a/apple/TablePro/Views/Agent/AgentResultRowsView.swift +++ b/apple/TablePro/Views/Agent/AgentResultRowsView.swift @@ -6,34 +6,16 @@ import SwiftUI import TableProPluginKit -/// The rows a session read, drawn by the app's own data grid. -/// -/// Read-only, and modelled on `CompareRowGrid`, which is the other place a grid reviews rows it does -/// not own: a change manager of its own and a column layout held in `@State` so nothing reaches -/// `FileColumnLayoutPersister`. A `DataGridConfiguration` naming only the database type keeps -/// `columnLayoutKey` nil, so an agent result can never write into a real table's saved column -/// widths. -/// -/// Sorting is answered here rather than announced and dropped. The rows are held in full and there -/// is no query to re-run, so `TableRowsSorting` puts them in order and the grid redraws; a grid -/// whose headers move a sort indicator that changes nothing is worse than one that cannot sort. -/// -/// Drawing rows by hand here was the alternative and it is worse in ways that are not obvious: the -/// grid already gives type-aware formatting, the Data Grid font (rather than a system text style, -/// which is the two-font-domain defect), column separators that scale, an accessibility cell tree, -/// selection and copy. None of that is worth reimplementing beside the real one. +// The rows a session read, drawn read-only by the app's own grid. They are held in full and there is no +// query to re-run, so a header click sorts them here. internal struct AgentResultRowsView: View { internal let runs: [AgentQueryRun] - /// Decodes each run once. The pane owns it, so the answer survives a redraw and a sort. internal let artifacts: AgentArtifactCache internal let connection: DatabaseConnection? - @State private var changeManager = AnyChangeManager(DataChangeManager()) - @State private var selectedRows: Set = [] - @State private var columnLayout = ColumnLayoutState() @State private var selectedRunId: String? - @State private var sortState = SortState() - @StateObject private var gridDelegate = HeldRowsGridDelegate() + @State private var loadedRunId: String? + @StateObject private var grid = HeldRowsGridAdapter(configuration: DataGridConfiguration(), sortsHeldRows: true) var body: some View { if runs.isEmpty { @@ -51,10 +33,6 @@ internal struct AgentResultRowsView: View { Divider() result(for: run) } - .onChange(of: run.id) { _ in - selectedRows = [] - sortState = SortState() - } } } @@ -76,14 +54,11 @@ internal struct AgentResultRowsView: View { ) } - /// Four answers where there used to be two, because "nothing to draw" was four different things - /// and the pane said the same sentence for each: a write that changed rows read as a query that - /// had matched none. @ViewBuilder private func result(for run: AgentQueryRun) -> some View { switch artifacts.payload(for: run) { case .rows(let decoded): - grid(decoded, for: run) + rowsGrid(decoded, for: run) case .noRows: state( title: String(localized: "No Rows"), @@ -105,40 +80,41 @@ internal struct AgentResultRowsView: View { } } - private func grid(_ decoded: TableRows, for run: AgentQueryRun) -> some View { - let rows = TableRowsSorting.sorted(decoded, by: sortState) - return DataGridView( - tableRowsProvider: { rows }, - changeManager: changeManager, - isEditable: false, - configuration: DataGridConfiguration( - databaseType: connection?.type, - showRowNumbers: true, - supportsColumnCommands: false, - supportsValueFilter: false, - appliesRowCopyFormat: true - ), - delegate: gridDelegate, - selectedRowIndices: $selectedRows, - sortState: $sortState, - columnLayout: $columnLayout, - contentRevision: contentRevision(for: run) - ) - .onAppear { - gridDelegate.onSortStateChanged = { sortState = $0 } + private func rowsGrid(_ decoded: TableRows, for run: AgentQueryRun) -> some View { + // task(id:) runs with this body's values; onChange(of:) would run with the previous body's (measured). + DataGridView(model: grid.model) + .task(id: run.id) { load(decoded, for: run) } + } + + // Another run starts with no selection, sort or display formats; the same run keeps them across a remount. + private func load(_ decoded: TableRows, for run: AgentQueryRun) { + grid.model.configuration = configuration + guard loadedRunId != run.id else { return } + if loadedRunId != nil { + grid.model.clearSelection() + grid.showSort(SortState()) + grid.model.store.displayFormats = [] } + loadedRunId = run.id + grid.setRows(decoded) + } + + private var configuration: DataGridConfiguration { + var configuration = DataGridConfiguration() + configuration.traits = DataGridEngineTraits(databaseType: connection?.type) + configuration.showRowNumbers = true + configuration.supportsValueFilter = false + configuration.appliesRowCopyFormat = true + configuration.commands = [.freezeColumns, .displayAs] + return configuration } - /// On the grid's background, so the pane keeps one color between a run with rows and one without. private func state(title: String, systemImage: String, description: String) -> some View { UnavailableStateView(title, systemImage: systemImage, description: Text(description)) .frame(maxWidth: .infinity, maxHeight: .infinity) .gridStandInBackground() } - /// Grouped, because a count is read at a glance and "1,204" is legible where "1204" has to be - /// counted. A statement that reports no count at all is one from a tool that answers in the - /// bridge's shape without sending one. private static func changeSummary(_ rowsAffected: Int?) -> String { guard let rowsAffected else { return String(localized: "The statement returned no rows to show.") @@ -152,18 +128,6 @@ internal struct AgentResultRowsView: View { return String(format: template, rowsAffected.formatted(.number.grouping(.automatic))) } - /// Moves whenever the rows the grid should be drawing move, which a sort does without changing - /// the run. - private func contentRevision(for run: AgentQueryRun) -> Int { - var hasher = Hasher() - hasher.combine(run.id) - for column in sortState.columns { - hasher.combine(column.columnIndex) - hasher.combine(column.direction) - } - return hasher.finalize() - } - private func summary(of sql: String) -> String { let flattened = sql .replacingOccurrences(of: "\n", with: " ") diff --git a/apple/TablePro/Views/Compare/CompareRowGrid.swift b/apple/TablePro/Views/Compare/CompareRowGrid.swift index 386c82e9d1..80ed3e5c35 100644 --- a/apple/TablePro/Views/Compare/CompareRowGrid.swift +++ b/apple/TablePro/Views/Compare/CompareRowGrid.swift @@ -2,17 +2,8 @@ // CompareRowGrid.swift // TablePro // -// One table's row differences as rows: every column, the source line over the -// target line for a changed row, and the differing cells marked. -// -// The data grid draws it, read-only, so a table of any width costs what the -// grid costs rather than a view per cell. Its marks are the grid's own: a row -// only in the source is underlined as an inserted row is, a row only in the -// target is struck through, and a changed value is underlined on the source -// line and struck through on the target line. Each carries a tint as a second -// cue and reads to VoiceOver in comparison words, not in editing ones. -// +import Combine import SwiftUI import TableProPluginKit @@ -23,7 +14,7 @@ internal struct CompareRowGridLine { } @MainActor -internal final class CompareRowGridModel: DataGridViewDelegate { +internal final class CompareRowGridModel: ObservableObject { internal static let includeColumn = 0 internal static let leadingColumnCount = 3 @@ -35,17 +26,37 @@ internal final class CompareRowGridModel: DataGridViewDelegate { let entryCount: Int } + internal let grid = HeldRowsGridAdapter(configuration: CompareRowGridModel.configuration(databaseType: nil)) private(set) var lines: [CompareRowGridLine] = [] - private(set) var tableRows = TableRows() - private var states: [RowVisualState] = [] private var loadedKey: LoadKey? private var planId = "" private weak var session: CompareSyncSession? - /// A grid row is a display position, which stops matching the line order as soon as a column's - /// value filter narrows the grid, so every row the grid names is resolved back through it. - private weak var coordinator: TableViewCoordinator? + internal init() { + grid.checkboxState = { [weak self] rowID, column in + self?.checkboxState(rowID: rowID, column: column) + } + grid.checkboxChange = { [weak self] isOn, rowIDs, column in + self?.setIncluded(isOn, rowIDs: rowIDs, column: column) + } + } + + internal var model: DataGridModel { grid.model } + internal var tableRows: TableRows { grid.heldRows } + + private static func configuration(databaseType: DatabaseType?) -> DataGridConfiguration { + var configuration = DataGridConfiguration() + configuration.traits = DataGridEngineTraits(databaseType: databaseType) + configuration.showRowNumbers = false + configuration.checkboxColumns = [includeColumn] + configuration.supportsValueFilter = false + configuration.supportsColumnSort = false + configuration.appliesRowCopyFormat = true + configuration.commands = [.freezeColumns, .displayAs] + return configuration + } + @discardableResult internal func ensureLoaded( key: LoadKey, plan: DataComparePlan, @@ -53,16 +64,25 @@ internal final class CompareRowGridModel: DataGridViewDelegate { session: CompareSyncSession ) -> TableRows { self.session = session + grid.model.configuration = Self.configuration(databaseType: session.source?.databaseType) guard loadedKey != key else { return tableRows } + // A row is a line of this load, so a selection made against another load names nothing here. + if loadedKey != nil { + grid.model.clearSelection() + } loadedKey = key planId = plan.id lines = entries.flatMap(Self.lines(for:)) let columns = plan.columnNames - states = lines.map { Self.visualState(for: $0, columns: columns) } - tableRows = TableRows( - rows: ContiguousArray(lines.enumerated().map { index, line in - Row(id: .existing(index), values: Self.values(for: line, columns: columns)) - }), + var states: [RowID: RowVisualState] = [:] + let rows = ContiguousArray(lines.enumerated().map { index, line -> Row in + let rowID = RowID.existing(index) + states[rowID] = Self.visualState(for: line, columns: columns) + return Row(id: rowID, values: Self.values(for: line, columns: columns)) + }) + grid.visualStates = states + let loaded = TableRows( + rows: rows, columns: [ String(localized: "Include"), String(localized: "Change"), @@ -71,9 +91,22 @@ internal final class CompareRowGridModel: DataGridViewDelegate { columnTypes: Array(repeating: ColumnType.text(rawType: nil), count: Self.leadingColumnCount) + plan.columns.map { Self.displayType(for: $0) } ) + // Display formats are kept by data index, so another table's columns start with raw values. + if loaded.columns != tableRows.columns || loaded.columnTypes != tableRows.columnTypes { + grid.model.store.displayFormats = [] + } + grid.setRows(loaded) return tableRows } + // An exclusion changed from the menu or from Space: the checkboxes repaint, the rows and selection stay. + internal func redrawCheckboxes(forLines lineIndexes: [Int]? = nil) { + let indexes = lineIndexes ?? Array(lines.indices) + grid.redrawCells(Set(indexes.compactMap { line -> CellPosition? in + grid.storageIndex(of: .existing(line)).map { CellPosition(row: $0, column: Self.includeColumn) } + })) + } + private static func displayType(for column: CompareColumn) -> ColumnType { displayType(source: column.sourceColumnType, target: column.targetColumnType) } @@ -138,42 +171,24 @@ internal final class CompareRowGridModel: DataGridViewDelegate { return values } - private func lineIndex(forDisplayRow row: Int) -> Int? { - let index = coordinator?.tableRowsIndex(forDisplayRow: row) ?? row - return lines.indices.contains(index) ? index : nil + private func lineIndex(of rowID: RowID) -> Int? { + guard case .existing(let index) = rowID, lines.indices.contains(index) else { return nil } + return index } - // MARK: - DataGridViewDelegate - - internal func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { - coordinator = tableViewCoordinator - } - - internal func dataGridVisualState(forRow row: Int) -> RowVisualState? { - guard let index = lineIndex(forDisplayRow: row), states.indices.contains(index) else { return nil } - return states[index] - } - - internal func dataGridCheckboxState(row: Int, column: Int) -> Bool? { - guard column == Self.includeColumn, let index = lineIndex(forDisplayRow: row) else { return nil } + private func checkboxState(rowID: RowID, column: DataGridColumn) -> Bool? { + guard column.dataIndex == Self.includeColumn, let index = lineIndex(of: rowID) else { return nil } let line = lines[index] guard line.opensEntry, line.entry.kind.isDifference, let session, let plan = session.dataPlans.first(where: { $0.id == planId }) else { return nil } return session.isRowIncluded(line.entry, in: plan) } - // The delegate's default copies nothing, which left Copy and Select All then Copy silent on this grid. - internal func dataGridCopyRows(_ indices: Set) { - coordinator?.copyRowsAsTSV(at: indices, includeHeaders: false) - } - - internal func dataGridSetCheckbox(_ isOn: Bool, rows: IndexSet, column: Int) { - guard column == Self.includeColumn, let session else { return } - let entries = rows.compactMap { row -> RowDiffEntry? in - guard let index = lineIndex(forDisplayRow: row), lines[index].opensEntry else { return nil } - return lines[index].entry - } - session.setRowsIncluded(isOn, entries: entries, planId: planId) + private func setIncluded(_ isOn: Bool, rowIDs: [RowID], column: DataGridColumn) { + guard column.dataIndex == Self.includeColumn, let session else { return } + let indexes = rowIDs.compactMap(lineIndex(of:)).filter { lines[$0].opensEntry } + session.setRowsIncluded(isOn, entries: indexes.map { lines[$0].entry }, planId: planId) + redrawCheckboxes(forLines: indexes) } } @@ -183,46 +198,27 @@ internal struct CompareRowGrid: View { internal let filter: RowDiffFilter internal let entries: [RowDiffEntry] - @State private var model = CompareRowGridModel() - @State private var changeManager = AnyChangeManager(DataChangeManager()) - @State private var selectedRows: Set = [] - @State private var columnLayout = ColumnLayoutState() + @StateObject private var model = CompareRowGridModel() - internal var body: some View { - let key = CompareRowGridModel.LoadKey( + private var key: CompareRowGridModel.LoadKey { + CompareRowGridModel.LoadKey( planId: plan.id, answer: plan.summary?.answerIdentity, columns: plan.columnNames, filter: filter, entryCount: entries.count ) - let model = model - let plan = plan - let entries = entries - let session = session - return DataGridView( - tableRowsProvider: { model.ensureLoaded(key: key, plan: plan, entries: entries, session: session) }, - changeManager: changeManager, - isEditable: false, - configuration: DataGridConfiguration( - databaseType: session.source?.databaseType, - showRowNumbers: false, - checkboxColumns: [CompareRowGridModel.includeColumn], - supportsColumnCommands: false, - supportsValueFilter: false, - supportsColumnSort: false - ), - delegate: model, - selectedRowIndices: $selectedRows, - sortState: .constant(SortState()), - columnLayout: $columnLayout, - /// The exclusions ride along, so including or excluding every listed row from the menu - /// repaints the checkboxes rather than leaving them until the next click or scroll. - contentRevision: key.hashValue ^ plan.excludedRowKeys.hashValue - ) - .onChange(of: key) { _ in - selectedRows = [] - } + } + + // task(id:) runs with this body's values; onChange(of:) would run with the previous body's (measured). + internal var body: some View { + DataGridView(model: model.model) + .task(id: key) { load() } + .onChange(of: plan.excludedRowKeys) { _ in model.redrawCheckboxes() } + } + + private func load() { + model.ensureLoaded(key: key, plan: plan, entries: entries, session: session) } } diff --git a/apple/TablePro/Views/DataFiles/DataFileColumnMenuBuilder.swift b/apple/TablePro/Views/DataFiles/DataFileColumnMenuBuilder.swift index 36f711b5f9..d7429d3f6d 100644 --- a/apple/TablePro/Views/DataFiles/DataFileColumnMenuBuilder.swift +++ b/apple/TablePro/Views/DataFiles/DataFileColumnMenuBuilder.swift @@ -50,10 +50,10 @@ enum DataFileColumnMenuBuilder { return items } - static func rowItems(forPageRow row: Int) -> [NSMenuItem] { + static func rowItems(abovePageRow above: Int, belowPageRow below: Int) -> [NSMenuItem] { [ - rowItem(String(localized: "Insert Row Above"), #selector(DataFileSplitViewController.dataFileInsertRowAbove(_:)), row), - rowItem(String(localized: "Insert Row Below"), #selector(DataFileSplitViewController.dataFileInsertRowBelow(_:)), row) + rowItem(String(localized: "Insert Row Above"), #selector(DataFileSplitViewController.dataFileInsertRowAbove(_:)), above), + rowItem(String(localized: "Insert Row Below"), #selector(DataFileSplitViewController.dataFileInsertRowBelow(_:)), below) ] } diff --git a/apple/TablePro/Views/DataFiles/DataFileContentView.swift b/apple/TablePro/Views/DataFiles/DataFileContentView.swift index ca68516b15..fe2a2dd979 100644 --- a/apple/TablePro/Views/DataFiles/DataFileContentView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileContentView.swift @@ -9,7 +9,6 @@ import TableProTheme struct DataFileContentView: View { @ObservedObject var controller: DataFileController - let gridDelegate: DataFileGridDelegate var body: some View { VStack(spacing: 0) { @@ -17,10 +16,6 @@ struct DataFileContentView: View { DataFileFilterPanel(controller: controller) Divider() } - if controller.find.isVisible, controller.loadState == .loaded { - DataFileFindBar(controller: controller) - Divider() - } content if controller.sheets.count > 1 { Divider() @@ -66,23 +61,9 @@ struct DataFileContentView: View { } private var grid: some View { - DataGridView( - tableRowsProvider: { controller.tableRows }, - tableRowsMutator: { _ in .none }, - paginationOffsetProvider: { controller.pageOffset }, - changeManager: controller.anyChangeManager, - isEditable: controller.isEditable && !controller.isBusy, - configuration: configuration, - delegate: gridDelegate, - selectedRowIndices: $controller.selectedRowIndices, - sortState: Binding( - get: { controller.sortState }, - set: { controller.updateSort($0) } - ), - columnLayout: $controller.columnLayout, - selectionSummary: controller.selectionSummary - ) - .accessibilityIdentifier("data-grid") + DataGridView(model: controller.gridModel) + .onAppear { controller.gridModel.configuration = configuration } + .onChange(of: configuration) { controller.gridModel.configuration = $0 } } private var emptyView: some View { @@ -96,14 +77,23 @@ struct DataFileContentView: View { } private var configuration: DataGridConfiguration { + Self.gridConfiguration(for: controller) + } + + static func gridConfiguration(for controller: DataFileController) -> DataGridConfiguration { var config = DataGridConfiguration() + let isEditable = controller.isEditable && !controller.isBusy config.showRowNumbers = true - config.hiddenColumns = controller.columnLayout.hiddenColumns + config.isEditable = isEditable config.supportsValueFilter = false config.appliesRowCopyFormat = true - config.filterMenuColumnTypes = controller.columnNames.ids.map { - DataFileColumnTypes.filterType(for: controller.kind(of: $0)) + var commands: Set = [ + .filterWithColumn, .hideColumns, .showRowsAsJSON, .exportResults, .freezeColumns, .displayAs, + ] + if isEditable { + commands.formUnion([.addRow, .deleteRows, .pasteRows]) } + config.commands = commands return config } } diff --git a/apple/TablePro/Views/DataFiles/DataFileFilterPanel.swift b/apple/TablePro/Views/DataFiles/DataFileFilterPanel.swift index 959ec6b47f..382be0f527 100644 --- a/apple/TablePro/Views/DataFiles/DataFileFilterPanel.swift +++ b/apple/TablePro/Views/DataFiles/DataFileFilterPanel.swift @@ -42,8 +42,7 @@ extension DataFileController: FilterPanelActions { } func focusGrid() { - guard let tableView = gridCoordinator?.tableView else { return } - tableView.window?.makeFirstResponder(tableView) + gridModel.focus() } func addBlankFilter(columnName: String) { diff --git a/apple/TablePro/Views/DataFiles/DataFileFindBar.swift b/apple/TablePro/Views/DataFiles/DataFileFindBar.swift deleted file mode 100644 index cd105ef574..0000000000 --- a/apple/TablePro/Views/DataFiles/DataFileFindBar.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// DataFileFindBar.swift -// TablePro -// - -import SwiftUI - -struct DataFileFindBar: View { - @ObservedObject var controller: DataFileController - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - findRow - if controller.find.isReplaceVisible, controller.isEditable { - replaceRow - } - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - - private var findRow: some View { - HStack(spacing: 8) { - NativeSearchField( - text: Binding( - get: { controller.find.text }, - set: { newValue in - controller.find.text = newValue - controller.scheduleFind() - } - ), - placeholder: String(localized: "Find in file"), - controlSize: .regular, - onSubmit: { controller.findNext() }, - focusOnAppear: true, - accessibilityIdentifier: "data-file-find-field" - ) - .frame(maxWidth: 320) - - optionsMenu - - Text(counterText) - .font(.callout) - .foregroundStyle(controller.find.isPatternInvalid ? Color.red : Color.secondary) - .monospacedDigit() - - HStack(spacing: 2) { - Button(String(localized: "Previous match"), systemImage: "chevron.left") { - controller.findPrevious() - } - .help(String(localized: "Previous match")) - Button(String(localized: "Next match"), systemImage: "chevron.right") { - controller.findNext() - } - .help(String(localized: "Next match")) - } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .disabled(controller.find.matches.isEmpty) - - if let scope = controller.find.scopeColumn, let name = controller.columnNames.name(for: scope) { - Button { - controller.find.scopeColumn = nil - controller.runFind() - } label: { - Label(String(format: String(localized: "In column %@"), name), systemImage: "xmark.circle.fill") - } - .buttonStyle(.borderless) - .help(String(localized: "Search every column")) - } - - Spacer() - - if !controller.find.isReplaceVisible, controller.isEditable { - Button(String(localized: "Replace…")) { - controller.find.isReplaceVisible = true - } - .buttonStyle(.borderless) - } - - Button(String(localized: "Done")) { - controller.hideFind() - } - .buttonStyle(.borderless) - .keyboardShortcut(.cancelAction) - } - } - - private var replaceRow: some View { - HStack(spacing: 8) { - TextField( - String(localized: "Replace with"), - text: Binding(get: { controller.find.replacement }, set: { controller.find.replacement = $0 }) - ) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 320) - .accessibilityIdentifier("data-file-replace-field") - - Button(String(localized: "Replace")) { - controller.replaceCurrent() - } - .disabled(controller.find.currentIndex == nil || controller.isBusy) - - Button(String(localized: "Replace All")) { - controller.replaceAll() - controller.focusGrid() - } - .disabled(!controller.find.hasQuery || controller.find.isPatternInvalid || controller.isBusy) - .accessibilityIdentifier("data-file-replace-all") - - Spacer() - } - } - - private var optionsMenu: some View { - Menu { - Toggle(String(localized: "Match Case"), isOn: option(\.matchesCase)) - Toggle(String(localized: "Whole Words"), isOn: option(\.matchesWholeWords)) - Toggle(String(localized: "Regular Expression"), isOn: option(\.isRegularExpression)) - } label: { - Label { Text("Find Options") } icon: { Image(systemName: "slider.horizontal.3") } - } - .labelStyle(.iconOnly) - .menuStyle(.borderlessButton) - .fixedSize() - .help(String(localized: "Find Options")) - } - - private func option(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { controller.find[keyPath: keyPath] }, - set: { newValue in - controller.find[keyPath: keyPath] = newValue - controller.runFind() - } - ) - } - - private var counterText: String { - guard controller.find.hasQuery else { return "" } - if controller.find.isPatternInvalid { - return String(localized: "Invalid regular expression") - } - if controller.find.isSearching { - return String(localized: "Searching…") - } - guard let index = controller.find.currentIndex, !controller.find.matches.isEmpty else { - return String(localized: "No matches") - } - return String( - format: String(localized: "%@ of %@"), - (index + 1).formatted(), - controller.find.matches.count.formatted() - ) - } -} diff --git a/apple/TablePro/Views/DataFiles/DataFileGridAdapter.swift b/apple/TablePro/Views/DataFiles/DataFileGridAdapter.swift new file mode 100644 index 0000000000..b9cca534a7 --- /dev/null +++ b/apple/TablePro/Views/DataFiles/DataFileGridAdapter.swift @@ -0,0 +1,275 @@ +// +// DataFileGridAdapter.swift +// TablePro +// + +import AppKit +import TableProPluginKit +import TableProTabular + +// The data file grid's rows are the controller's current page; every edit, sort and column command goes +// through the controller, which owns the file and its undo. +@MainActor +final class DataFileGridAdapter { + let model: DataGridModel + weak var controller: DataFileController? + weak var owner: DataFileSplitViewController? + private let detachedChangeState = AnyChangeManager(DataFileChangeManager()) + + init(model: DataGridModel) { + self.model = model + model.dataSource = self + model.actions = self + model.datasetFinder = self + } + + private func pageRows(of rowIDs: [RowID]) -> Set { + Set(rowIDs.compactMap { model.displayIndex(of: $0) }) + } + + private func columnID(for column: DataGridColumn) -> TabularColumnID? { + guard let controller, controller.columnNames.ids.indices.contains(column.dataIndex) else { return nil } + return controller.columnNames.ids[column.dataIndex] + } + + nonisolated private static func copiedField(_ cell: PluginCellValue) -> String { + guard case .text(let text) = cell else { return "" } + return text + .replacingOccurrences(of: "\t", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + } +} + +extension DataFileGridAdapter: DataGridDataSource { + var gridRows: TableRows { controller?.tableRows ?? TableRows() } + var gridChangeState: AnyChangeManager { controller?.anyChangeManager ?? detachedChangeState } + var gridRowNumberOffset: Int { controller?.pageOffset ?? 0 } + + func gridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? { + guard let controller else { return nil } + return SelectionSummaryColumnPolicy( + rules: controller.columnNames.ids.map { DataFileColumnTypes.summaryRule(for: controller.kind(of: $0)) }, + emptyTextIsEmpty: true + ) + } +} + +extension DataFileGridAdapter: DataGridActions { + // One command is one commit: the controller writes the table once and registers one undo step. + func gridCommit(_ edits: [DataGridResolvedEdit], actionName: String) -> Bool { + guard let controller, controller.isEditable, !edits.isEmpty else { return false } + var applied = false + controller.batchCellEdits(actionName: actionName) { + for edit in edits { + guard case .existing(let key) = edit.rowID else { continue } + controller.setCell(key: key, column: edit.dataIndex, text: edit.newValue.isNull ? nil : edit.newValue.asText ?? "") + applied = true + } + } + return applied + } + + func gridDeleteRows(_ rowIDs: [RowID]) { + owner?.deleteRows(pageRows(of: rowIDs)) + } + + // A file's NULL or missing cell copies as empty, and a tab or line break in a value as a space, + // so each record pastes into a spreadsheet as one line. + func gridCopyRows(_ rowIDs: [RowID]) -> Bool { + let tableRows = model.rows + let copied = rowIDs.compactMap { tableRows.row(withID: $0) } + guard !copied.isEmpty else { return false } + let projection = VisibleColumnProjection(indices: model.columns.columns.map(\.dataIndex)) + let tsv = copied.map { row in + projection.values(Array(row.values)).map(Self.copiedField).joined(separator: "\t") + }.joined(separator: "\n") + ClipboardService.shared.writeRows( + tsv: tsv, + html: nil, + gridRows: GridRowsClipboardPayload( + columns: projection.columns(tableRows.columns), + copying: copied, + expressionColumns: copied.map { _ in [] }, + projection: projection + ) + ) + return true + } + + func gridAddRow() { + controller?.appendRow() + } + + func gridCanPasteRows() -> Bool { + guard let controller else { return false } + return controller.isEditable && ClipboardService.shared.hasText + } + + // Rows copied as CSV, JSON or Markdown carry their values, which the tab-separated parser cannot read back. + func gridPasteRows() { + guard let controller else { return } + if let copied = ClipboardService.shared.readGridRows() { + controller.pasteRows(DataFilePasteParser.rows(from: copied, into: controller.columnNames.displayNames)) + return + } + guard let raw = ClipboardService.shared.readText(), !raw.isEmpty else { return } + controller.pasteRows(DataFilePasteParser.rows(from: raw)) + } + + // The cells the command names, except that picked columns cover every visible row of the file, not only the page. + func gridSetCellsToValue(_ cells: [(rowID: RowID, column: DataGridColumn)]) -> Bool { + guard let owner, let controller else { return false } + let named = cells.compactMap { cell -> (column: TabularColumnID, key: Int)? in + guard case .existing(let key) = cell.rowID, let column = columnID(for: cell.column) else { return nil } + return (column: column, key: key) + } + let picked = controller.selectedColumnIDs() + let coversPickedColumns = !picked.isEmpty && Set(named.map(\.column)) == Set(picked) + guard let target = coversPickedColumns ? controller.cellTarget() : controller.cellTarget(cells: named) else { + return false + } + owner.presentSetCellsToValue(target) + return true + } + + func gridSortDidChange(_ sort: SortState) { + controller?.updateSort(sort) + } + + // The inspector shows the selection, so rows the menu was opened on outside it become the selection. + func gridShowRowsAsJSON(_ rowIDs: [RowID]) { + if !rowIDs.isEmpty, Set(model.selectedRowIDs) != Set(rowIDs) { + model.selectRows(rowIDs) + } + controller?.isInspectorVisible = true + } + + func gridExportResults() { + owner?.presentExport() + } + + func gridHideColumn(_ column: DataGridColumn) { + controller?.columnLayout.hiddenColumns.insert(column.name) + } + + func gridShowAllColumns() { + controller?.columnLayout.hiddenColumns.removeAll() + } + + func gridFilterWithColumn(_ column: DataGridColumn) { + guard let controller, controller.columnNames.id(forName: column.name) != nil else { return } + controller.addBlankFilter(columnName: column.name) + } + + func gridCellMenuItems(for rowID: RowID, column: DataGridColumn) -> [NSMenuItem] { + guard let controller, let id = columnID(for: column), + let row = controller.tableRows.row(withID: rowID), column.dataIndex < row.values.count else { return [] } + let item = CellFilterMenuBuilder.menuItem( + columnName: column.name, + columnType: DataFileColumnTypes.filterType(for: controller.kind(of: id)), + value: row.values[column.dataIndex] + ) { [weak controller] filter in + controller?.addFilter(filter) + } + return item.map { [$0] } ?? [] + } + + func gridColumnMenuItems(for column: DataGridColumn) -> [NSMenuItem] { + guard let controller, let id = columnID(for: column) else { return [] } + return DataFileColumnMenuBuilder.items( + for: id, + controller: controller, + selectedColumns: controller.selectedColumnIDs() + ) + } + + // Insert Row Above and Below anchor at the row right-clicked, inside a selection or not. + func gridRowMenuItems(for rowIDs: [RowID], clickedRow: RowID) -> [NSMenuItem] { + guard let controller, controller.isEditable, let row = model.displayIndex(of: clickedRow) else { return [] } + return DataFileColumnMenuBuilder.rowItems(abovePageRow: row, belowPageRow: row) + } +} + +// Find covers every row of the file: a page row's ID is `.existing(key)`, the key a match carries. +extension DataFileGridAdapter: DataGridDatasetFinding { + func gridFindAll(_ query: GridDatasetFindQuery) async throws -> [GridFindState.Match] { + guard let controller else { return [] } + return try await controller.findMatches(query) + } + + func gridDisplayPosition(of rowID: RowID) -> Int? { + guard case .existing(let key) = rowID else { return nil } + return controller?.displayPosition(ofKey: key) + } + + func gridLoadRow(_ rowID: RowID) -> Bool { + guard let controller, case .existing(let key) = rowID, controller.displayPosition(ofKey: key) != nil else { + return false + } + controller.reveal(key: key, selecting: false) + return true + } + + func gridReplace(_ match: GridFindState.Match, query: GridDatasetFindQuery, with template: String) -> Bool { + controller?.replaceMatch(match, query: query, with: template) ?? false + } + + func gridReplaceAll(_ query: GridDatasetFindQuery, with template: String) { + controller?.replaceAll(query, with: template) + } +} + +enum DataFilePasteParser { + static func rows(from text: String) -> [[String]] { + text.split(omittingEmptySubsequences: false, whereSeparator: { $0 == "\n" || $0 == "\r\n" }) + .map { line in line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) } + .filter { !($0.count == 1 && $0[0].isEmpty) } + } + + // By name when every copied column is one of the file's, once, so rows copied with columns reordered or + // hidden land where they came from. Anything else pastes in order, as its text would. + @MainActor + static func rows(from copied: GridRowsClipboardPayload, into columns: [String]) -> [[String]] { + let fileColumns = Set(columns) + guard Set(copied.columns).count == copied.columns.count, copied.columns.allSatisfy(fileColumns.contains) else { + return copied.rows.map { $0.map(text(of:)) } + } + let sources = columns.map { copied.columns.firstIndex(of: $0) } + return copied.rows.map { row in + sources.map { source in + guard let source, row.indices.contains(source) else { return "" } + return text(of: row[source]) + } + } + } + + // NULL pastes as the empty value this grid's own row copy writes for it. + @MainActor + static func text(of cell: PluginCellValue) -> String { + switch cell { + case .text(let text): return text + case .null: return "" + case .bytes(let data): return BlobFormattingService.shared.format(data, for: .copy) ?? "" + } + } +} + +enum DataFileColumnTypes { + static func filterType(for kind: TabularInferredKind) -> ColumnType { + switch kind { + case .integer: return .integer(rawType: "INTEGER") + case .decimal: return .decimal(rawType: "DECIMAL") + case .boolean: return .boolean(rawType: "BOOLEAN") + case .date: return .date(rawType: "DATE") + case .text: return .text(rawType: "TEXT") + } + } + + static func summaryRule(for kind: TabularInferredKind) -> SelectionSummaryColumnRule { + switch kind { + case .integer, .decimal: return .numeric + case .boolean, .date, .text: return .countOnly + } + } +} diff --git a/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift b/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift deleted file mode 100644 index dddbf0f380..0000000000 --- a/apple/TablePro/Views/DataFiles/DataFileGridDelegate.swift +++ /dev/null @@ -1,190 +0,0 @@ -// -// DataFileGridDelegate.swift -// TablePro -// - -import AppKit -import TableProPluginKit -import TableProTabular - -@MainActor -final class DataFileGridDelegate: DataGridViewDelegate { - weak var owner: DataFileSplitViewController? - private let controller: DataFileController - - init(controller: DataFileController) { - self.controller = controller - } - - func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { - controller.gridCoordinator = tableViewCoordinator - } - - func dataGridDidEditCell(row: Int, column: Int, newValue: String?) { - controller.setCell(pageRow: row, column: column, text: newValue) - } - - func dataGridDeleteRows(_ indices: Set) { - owner?.deleteRows(indices) - } - - func dataGridAddRow() { - controller.appendRow() - } - - func dataGridCopyRows(_ indices: Set) { - let lines = indices.sorted().compactMap { row -> String? in - guard controller.tableRows.rows.indices.contains(row) else { return nil } - return controller.tableRows.rows[row].values.map { value -> String in - guard case .text(let text) = value else { return "" } - return text - .replacingOccurrences(of: "\t", with: " ") - .replacingOccurrences(of: "\r", with: " ") - .replacingOccurrences(of: "\n", with: " ") - }.joined(separator: "\t") - } - guard !lines.isEmpty else { return } - ClipboardService.shared.writeText(lines.joined(separator: "\n")) - } - - func dataGridPasteRows() { - // Rows copied as CSV, JSON or Markdown carry their values, which the tab-separated parser cannot read back. - if let copied = ClipboardService.shared.readGridRows() { - controller.pasteRows(DataFilePasteParser.rows(from: copied, into: controller.columnNames.displayNames)) - return - } - guard let raw = ClipboardService.shared.readText(), !raw.isEmpty else { return } - let rows = DataFilePasteParser.rows(from: raw) - controller.pasteRows(rows) - } - - func dataGridCanPasteRows() -> Bool { - controller.isEditable && ClipboardService.shared.hasText - } - - func dataGridSortStateChanged(_ state: SortState) { - controller.updateSort(state) - } - - func dataGridUndo() { - controller.undoManager?.undo() - } - - func dataGridRedo() { - controller.undoManager?.redo() - } - - func dataGridShowRowAsJSON() { - controller.isInspectorVisible = true - } - - func dataGridExportResults() { - owner?.presentExport() - } - - func dataGridHideColumn(_ columnName: String) { - controller.columnLayout.hiddenColumns.insert(columnName) - } - - func dataGridShowAllColumns() { - controller.columnLayout.hiddenColumns.removeAll() - } - - func dataGridFilterColumn(_ columnName: String) { - guard controller.columnNames.id(forName: columnName) != nil else { return } - controller.addBlankFilter(columnName: columnName) - } - - func dataGridFilterMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? { - controller.gridCoordinator?.cellFilterMenuItem(forRow: displayRow, dataColumn: dataColumn) { [weak controller] filter in - controller?.addFilter(filter) - } - } - - func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] { - guard controller.columnNames.ids.indices.contains(dataColumnIndex) else { return [] } - return DataFileColumnMenuBuilder.items( - for: controller.columnNames.ids[dataColumnIndex], - controller: controller, - selectedColumns: controller.selectedColumnIDs() - ) - } - - func dataGridRowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] { - guard controller.isEditable else { return [] } - return DataFileColumnMenuBuilder.rowItems(forPageRow: displayRow) - } - - func dataGridSetCellsToValue(_ targets: [SetValueTarget]) -> Bool { - guard let owner else { return false } - if let target = controller.cellTarget(for: targets) { - owner.presentSetCellsToValue(target) - } - return true - } - - func dataGridBatchEdits(actionName: String, _ edits: () -> Void) { - controller.batchCellEdits(actionName: actionName, edits) - } - - func dataGridSummaryColumnPolicy() -> SelectionSummaryColumnPolicy? { - SelectionSummaryColumnPolicy( - rules: controller.columnNames.ids.map { DataFileColumnTypes.summaryRule(for: controller.kind(of: $0)) }, - emptyTextIsEmpty: true - ) - } -} - -enum DataFilePasteParser { - static func rows(from text: String) -> [[String]] { - text.split(omittingEmptySubsequences: false, whereSeparator: { $0 == "\n" || $0 == "\r\n" }) - .map { line in line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) } - .filter { !($0.count == 1 && $0[0].isEmpty) } - } - - // By name when every copied column is one of the file's, once, so rows copied with columns reordered or - // hidden land where they came from. Anything else pastes in order, as its text would. - @MainActor - static func rows(from copied: GridRowsClipboardPayload, into columns: [String]) -> [[String]] { - let fileColumns = Set(columns) - guard Set(copied.columns).count == copied.columns.count, copied.columns.allSatisfy(fileColumns.contains) else { - return copied.rows.map { $0.map(text(of:)) } - } - let sources = columns.map { copied.columns.firstIndex(of: $0) } - return copied.rows.map { row in - sources.map { source in - guard let source, row.indices.contains(source) else { return "" } - return text(of: row[source]) - } - } - } - - // NULL pastes as the empty value this grid's own row copy writes for it. - @MainActor - static func text(of cell: PluginCellValue) -> String { - switch cell { - case .text(let text): return text - case .null: return "" - case .bytes(let data): return BlobFormattingService.shared.format(data, for: .copy) ?? "" - } - } -} - -enum DataFileColumnTypes { - static func filterType(for kind: TabularInferredKind) -> ColumnType { - switch kind { - case .integer: return .integer(rawType: "INTEGER") - case .decimal: return .decimal(rawType: "DECIMAL") - case .boolean: return .boolean(rawType: "BOOLEAN") - case .date: return .date(rawType: "DATE") - case .text: return .text(rawType: "TEXT") - } - } - - static func summaryRule(for kind: TabularInferredKind) -> SelectionSummaryColumnRule { - switch kind { - case .integer, .decimal: return .numeric - case .boolean, .date, .text: return .countOnly - } - } -} diff --git a/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift b/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift index 872137fdd6..f7d8bc992e 100644 --- a/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift @@ -80,7 +80,7 @@ struct DataFileRowDetailsView: View { } configuredKey = controller.key(forPageRow: row) editState.configure( - selectedRowIndices: [row], + rowIDs: [controller.tableRows.rows[row].id], allRows: [values], columns: controller.tableRows.columns, columnTypes: controller.tableRows.columnTypes diff --git a/apple/TablePro/Views/DataFiles/DataFileSplitViewController+Actions.swift b/apple/TablePro/Views/DataFiles/DataFileSplitViewController+Actions.swift index a90aadc642..8385777721 100644 --- a/apple/TablePro/Views/DataFiles/DataFileSplitViewController+Actions.swift +++ b/apple/TablePro/Views/DataFiles/DataFileSplitViewController+Actions.swift @@ -37,7 +37,7 @@ extension DataFileSplitViewController { guard let self else { return } _ = self.controller.deleteRows(pageRows: pageRows) let remaining = self.controller.tableRows.rows.count - self.controller.selectedRowIndices = remaining == 0 ? [] : [min(firstRow, remaining - 1)] + self.controller.selectRows(pageRows: remaining == 0 ? [] : [min(firstRow, remaining - 1)]) } guard controller.rowsHaveData(pageRows: pageRows) else { proceed() diff --git a/apple/TablePro/Views/DataFiles/DataFileSplitViewController.swift b/apple/TablePro/Views/DataFiles/DataFileSplitViewController.swift index 1ab5459b11..5938f6871e 100644 --- a/apple/TablePro/Views/DataFiles/DataFileSplitViewController.swift +++ b/apple/TablePro/Views/DataFiles/DataFileSplitViewController.swift @@ -14,7 +14,6 @@ final class DataFileSplitViewController: NSSplitViewController { weak var dataFileDocument: DataFileDocument? let controller: DataFileController - let gridDelegate: DataFileGridDelegate private let contentItem: NSSplitViewItem private let inspectorItem: NSSplitViewItem private var cancellables: Set = [] @@ -25,12 +24,8 @@ final class DataFileSplitViewController: NSSplitViewController { init(document: DataFileDocument) { dataFileDocument = document controller = document.controller - gridDelegate = DataFileGridDelegate(controller: document.controller) - let content = NSHostingController(rootView: DataFileContentView( - controller: document.controller, - gridDelegate: gridDelegate - )) + let content = NSHostingController(rootView: DataFileContentView(controller: document.controller)) content.sizingOptions = [] contentItem = NSSplitViewItem(viewController: content) contentItem.minimumThickness = Self.contentMinThickness @@ -46,7 +41,7 @@ final class DataFileSplitViewController: NSSplitViewController { inspectorItem.isCollapsed = true super.init(nibName: nil, bundle: nil) - gridDelegate.owner = self + controller.gridAdapter.owner = self addSplitViewItem(contentItem) addSplitViewItem(inspectorItem) @@ -87,8 +82,7 @@ final class DataFileSplitViewController: NSSplitViewController { } func focusGrid() { - guard let tableView = controller.gridCoordinator?.tableView else { return } - view.window?.makeFirstResponder(tableView) + controller.gridModel.focus() } override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { diff --git a/apple/TablePro/Views/DataFiles/DataFileStatisticsView.swift b/apple/TablePro/Views/DataFiles/DataFileStatisticsView.swift index 44a85d3efc..4c7dda8e50 100644 --- a/apple/TablePro/Views/DataFiles/DataFileStatisticsView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileStatisticsView.swift @@ -215,13 +215,11 @@ extension DataFileSplitViewController { } } + // A hidden column has no header, so its popover points at the middle of the content. private func headerAnchor(for id: TabularColumnID) -> (rect: NSRect, view: NSView) { - guard let coordinator = controller.gridCoordinator, let tableView = coordinator.tableView, - let headerView = tableView.headerView, let dataIndex = controller.columnNames.index(of: id), - let tableColumnIndex = tableView.tableColumns.firstIndex(where: { coordinator.dataColumnIndex(from: $0.identifier) == dataIndex }) - else { - return (view.bounds.insetBy(dx: view.bounds.width / 2 - 1, dy: view.bounds.height / 2 - 1), view) + if let anchor = controller.headerAnchor(for: id) { + return (anchor.rect, anchor.view) } - return (headerView.headerRect(ofColumn: tableColumnIndex), headerView) + return (view.bounds.insetBy(dx: view.bounds.width / 2 - 1, dy: view.bounds.height / 2 - 1), view) } } diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridBodyChrome.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridBodyChrome.swift new file mode 100644 index 0000000000..0ec73c5095 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridBodyChrome.swift @@ -0,0 +1,111 @@ +// +// DataGridBodyChrome.swift +// TablePro +// + +import AppKit +import TableProTheme + +// gridStyleMask stays empty: AppKit's vertical grid lines keep one subview per column and made a 500-column layout pass take 518 ms (#2381). +@MainActor +enum DataGridBodyChrome { + static let separatorThickness: CGFloat = 1 + + // One separator at the leading edge of every column the rect reaches, in the space of `view`. + static func separatorRects(in rect: NSRect, of view: NSView, tableView: NSTableView) -> [NSRect] { + guard rect.width > 0, rect.height > 0 else { return [] } + let inTableView = view.convert(rect, to: tableView) + return tableView.columnIndexes(in: inTableView).compactMap { tableColumnIndex in + let columnRect = view.convert(tableView.rect(ofColumn: tableColumnIndex), from: tableView) + guard columnRect.width > 0, columnRect.minX > 0 else { return nil } + let separator = NSRect( + x: columnRect.minX - separatorThickness, + y: rect.minY, + width: separatorThickness, + height: rect.height + ) + return separator.intersects(rect) ? separator : nil + } + } + + static func drawColumnSeparators(in rect: NSRect, of view: NSView, tableView: NSTableView) { + let separators = separatorRects(in: rect, of: view, tableView: tableView) + guard !separators.isEmpty else { return } + tableView.gridColor.setFill() + separators.forEach { $0.fill() } + } + + struct RowBand: Equatable { + let row: Int + let rect: NSRect + let isTableRow: Bool + } + + // The table's rows, then bands one row apart past the last row, numbered on as NSTableView continues its alternation. + static func rowBands(in rect: NSRect, of view: NSView, tableView: NSTableView) -> [RowBand] { + guard rect.width > 0, rect.height > 0 else { return [] } + let inTableView = view.convert(rect, to: tableView) + let rows = tableView.rows(in: inTableView) + var bands = (rows.location..<(rows.location + rows.length)).map { row in + RowBand(row: row, rect: view.convert(tableView.rect(ofRow: row), from: tableView), isTableRow: true) + } + + let pitch = tableView.rowHeight + tableView.intercellSpacing.height + let rowCount = tableView.numberOfRows + let lastRowBottom = rowCount > 0 ? tableView.rect(ofRow: rowCount - 1).maxY : tableView.bounds.minY + guard pitch > 0, inTableView.maxY > lastRowBottom else { return bands } + + var index = max(0, Int(((inTableView.minY - lastRowBottom) / pitch).rounded(.down))) + while lastRowBottom + CGFloat(index) * pitch < inTableView.maxY { + let band = NSRect( + x: inTableView.minX, + y: lastRowBottom + CGFloat(index) * pitch, + width: inTableView.width, + height: pitch + ) + bands.append(RowBand(row: rowCount + index, rect: view.convert(band, from: tableView), isTableRow: false)) + index += 1 + } + return bands + } + + // AppKit reads the table's own backgroundColor for the area an elastic scroll uncovers. + static func applyThemeColors(to tableView: NSTableView, scope: ThemeScope) { + let resolved = scope.resolved + let gridColor = resolved[.gridLine] + if tableView.gridColor != gridColor { + tableView.gridColor = gridColor + } + let background = resolved[.gridBackground] + if tableView.backgroundColor != background { + tableView.backgroundColor = background + } + } + + // The theme's pair when it declares one, otherwise the system's alternating pair the slots fall back to. + static func stripeColor(forRow row: Int, of tableView: NSTableView, scope: ThemeScope) -> NSColor? { + guard tableView.usesAlternatingRowBackgroundColors else { return nil } + let resolved = scope.resolved + return row.isMultiple(of: 2) ? resolved[.gridBackground] : resolved[.gridAlternateRow] + } + + // Opaque whatever the stripe is, because a frozen pane row covers the scrolling rows beneath it. + static func fillRowBackground(_ rect: NSRect, row: Int, of tableView: NSTableView, scope: ThemeScope) { + tableView.backgroundColor.setFill() + rect.fill() + guard let stripe = stripeColor(forRow: row, of: tableView, scope: scope) else { return } + stripe.setFill() + rect.fill(using: .sourceOver) + } + + // NSTableView's own past-the-last-row drawing blends the stripe twice, measured on macOS 27. + static func drawTableBackground(in rect: NSRect, of tableView: NSTableView, scope: ThemeScope) { + tableView.backgroundColor.setFill() + rect.fill() + for band in rowBands(in: rect, of: tableView, tableView: tableView) where !band.isTableRow { + guard let stripe = stripeColor(forRow: band.row, of: tableView, scope: scope) else { continue } + stripe.setFill() + band.rect.intersection(rect).fill(using: .sourceOver) + } + } +} diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellAccessoryGlyph.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellAccessoryGlyph.swift similarity index 67% rename from apple/TablePro/Views/Results/Cells/DataGridCellAccessoryGlyph.swift rename to apple/TablePro/Views/DataGrid/Cells/DataGridCellAccessoryGlyph.swift index 100ff0737e..eb9f1003e7 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellAccessoryGlyph.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellAccessoryGlyph.swift @@ -5,10 +5,7 @@ import AppKit -/// The symbol a cell draws at its trailing edge, rasterised once per appearance. -/// -/// Shared by every cell in the grid, so the cache is static: the glyph depends on the role and the -/// appearance it is drawn into, never on which cell asked for it. +// Rasterised once per role and appearance, shared by every cell in every grid. @MainActor enum DataGridCellAccessoryGlyph { enum Role: Hashable { @@ -42,10 +39,6 @@ enum DataGridCellAccessoryGlyph { } } - /// The bare arrow spends its whole point size on the arrow itself, where the circled variant - /// spent most of it on the ring, so 14 here would draw an arrow half again as large as the - /// one it replaced. 12 keeps the ink at 11 x 9 in the 16 x 16 accessory rect, close to the - /// dropdown chevron's weight and to the 13pt cell text. var pointSize: CGFloat { switch self { case .foreignKeyNormal, .foreignKeyEmphasized: @@ -55,7 +48,6 @@ enum DataGridCellAccessoryGlyph { } } - /// An emphasized glyph sits on the row's selection, so it takes the selection's text color. func color(selectionText: NSColor) -> NSColor { switch self { case .foreignKeyNormal, .chevronNormal: @@ -71,7 +63,7 @@ enum DataGridCellAccessoryGlyph { struct Glyph { let image: CGImage let pointSize: NSSize - /// Held so the color cannot be freed and its address reused while the bitmap is cached. + // Held so the color cannot be freed and its address reused while the bitmap is cached. fileprivate let color: NSColor } @@ -84,12 +76,7 @@ enum DataGridCellAccessoryGlyph { private static var glyphs: [Key: Glyph] = [:] private static var glyphsRevision: Int? - /// Rasterizing resolves the dynamic symbol color, so a cached bitmap belongs to exactly one - /// appearance and one color. Keying on the appearance is what keeps a dark window from being - /// served the light bitmap, and `NSAppearance.currentDrawing()` only reports the drawing - /// appearance while AppKit is inside `draw(_:)`, `updateLayer` or `layout`. - /// The color is compared by identity on a hit, a pointer compare, since the palette hands every - /// cell the same object; a grid on another scope still gets its own bitmap. + // NSAppearance.currentDrawing() names the drawing appearance only inside draw, updateLayer or layout. static func image(for role: Role, color: NSColor) -> Glyph? { let revision = ThemeEngine.shared.revision if glyphsRevision != revision { @@ -117,11 +104,7 @@ enum DataGridCellAccessoryGlyph { return Glyph(image: cgImage, pointSize: image.size, color: color) } - /// A symbol stretched to fill the accessory rect stops looking like a system symbol, so the - /// glyph draws at its own point size and the rect only ever clamps it. The clamp is one factor - /// across both axes, because clamping each axis on its own would distort the glyph exactly the - /// way filling the rect did. The origin rounds to whole points so a glyph narrower than its rect - /// by an odd number of points does not land on a half point and blur at 1x. + // Drawn at its own point size and only ever shrunk, by one factor on both axes, onto whole points. static func centeredRect(pointSize: NSSize, in rect: NSRect) -> NSRect { let scale = min(1, rect.width / pointSize.width, rect.height / pointSize.height) let size = NSSize(width: pointSize.width * scale, height: pointSize.height * scale) diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellAppearance.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellAppearance.swift similarity index 69% rename from apple/TablePro/Views/Results/Cells/DataGridCellAppearance.swift rename to apple/TablePro/Views/DataGrid/Cells/DataGridCellAppearance.swift index 0c52229b40..bb2faafb71 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellAppearance.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellAppearance.swift @@ -5,33 +5,18 @@ import AppKit -/// What one data cell looks like, resolved from its content and state. -/// -/// Separated from the drawing so the decisions are testable without a view and without a graphics -/// context: which font a NULL takes, when a modified cell keeps its tint, what colour text turns on -/// a selected row. The renderer that consumes this makes no decisions of its own. +// Every decision about one cell's look, kept apart from drawing so it is testable without a view. @MainActor struct DataGridCellAppearance: Equatable { let text: String let font: NSFont let textColor: NSColor - /// Painted behind the text, for a find match, a modified value or a highlight rule. let backgroundTint: NSColor? - /// The line a pending change draws through or under the text. Unlike the tint, it survives a - /// selection, which is what makes it the cue rather than a decoration on one. let textMark: DataGridCellTextMark? let accessory: DataGridCellAccessory - /// Which symbol the accessory draws, resolved here because it follows the row's state rather - /// than anything the renderer can see. let accessoryRole: DataGridCellAccessoryGlyph.Role? let accessoryColor: NSColor? - /// The cell cursor on a selected row, which the selection fill would swallow a focus ring behind. - let drawsFocusBorder: Bool - /// The cell cursor on an unselected row. - let drawsFocusRing: Bool - let focusColor: NSColor let checkboxMark: DataGridCheckboxMark? - /// Nil leaves invisible characters unmarked. let specialCharacterColor: NSColor? static func resolve( @@ -41,7 +26,6 @@ struct DataGridCellAppearance: Equatable { palette: DataGridCellPalette, nullDisplayString: String, onEmphasizedSelection: Bool, - hasOverlay: Bool, checkboxMark: DataGridCheckboxMark? = nil, textTone: NSColor? = nil, revealsInvisibleCharacters: Bool = false @@ -64,7 +48,6 @@ struct DataGridCellAppearance: Equatable { } let findTint: NSColor? = state.isCurrentFindMatch ? palette.findMatchTint : nil - let highlightColor = state.visualState.cellHighlightColor(forColumn: state.columnIndex) let stateTint: NSColor? if state.visualState.isDeleted || state.visualState.isInserted { stateTint = nil @@ -72,11 +55,10 @@ struct DataGridCellAppearance: Equatable { || state.visualState.isStruck(columnIndex: state.columnIndex) { stateTint = palette.modifiedColumnTint } else { - stateTint = highlightColor?.washColor + stateTint = state.visualState.cellHighlightColor(forColumn: state.columnIndex)?.washColor } - // The modified or highlight tint stands down on a selected row so the selection fill is - // not painted over; a find match keeps its own highlight whatever else is true. + // The modified or highlight tint stands down on an emphasized selection; a find match keeps its highlight. let backgroundTint: NSColor? let textColor: NSColor if let findTint { @@ -90,12 +72,7 @@ struct DataGridCellAppearance: Equatable { textColor = baseColor } - let accessory = DataGridCellAccessory.visible( - for: kind, - isEditable: state.isEditable, - rawValue: content.rawValue - ) - let isCursorVisible = state.isFocused && !hasOverlay + let accessory = DataGridCellAccessory.visible(for: kind, isEditable: state.isEditable, rawValue: content.rawValue) // The editor's orange, except over a fill of the cell's own, where it takes the text's color. let specialCharacterColor: NSColor? = revealsInvisibleCharacters && content.placeholder == nil && kind != .checkbox ? (findTint != nil || onEmphasizedSelection ? textColor : .systemOrange) @@ -109,7 +86,6 @@ struct DataGridCellAppearance: Equatable { text: kind == .checkbox ? "" : DataGridCellContent.resolvedDisplayText( content.displayText, placeholder: content.placeholder, - isLargeDataset: state.isLargeDataset, nullDisplayString: nullDisplayString ), font: font, @@ -119,9 +95,6 @@ struct DataGridCellAppearance: Equatable { accessory: accessory, accessoryRole: accessoryRole, accessoryColor: accessoryRole?.color(selectionText: palette.selectionText), - drawsFocusBorder: isCursorVisible && onEmphasizedSelection, - drawsFocusRing: isCursorVisible && !onEmphasizedSelection, - focusColor: onEmphasizedSelection ? palette.selectionText : palette.focusRing, checkboxMark: kind == .checkbox ? checkboxMark : nil, specialCharacterColor: specialCharacterColor ) diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellContent.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift similarity index 77% rename from apple/TablePro/Views/Results/Cells/DataGridCellContent.swift rename to apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift index 8d493c63b8..be6494909c 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellContent.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift @@ -10,7 +10,7 @@ enum DataGridCellPlaceholder: Equatable { case null case empty case defaultMarker - /// The row has no field for the column, which a document store tells apart from NULL. + // The row has no field for the column, which a document store tells apart from NULL. case absent case unloaded(byteCount: Int) } @@ -41,22 +41,21 @@ struct DataGridCellContent { static func resolvedDisplayText( _ displayText: String, placeholder: DataGridCellPlaceholder?, - isLargeDataset: Bool, nullDisplayString: String ) -> String { switch placeholder { case .none: return displayText case .null: - return isLargeDataset ? "" : nullDisplayString + return nullDisplayString case .empty: - return isLargeDataset ? "" : String(localized: "Empty") + return String(localized: "Empty") case .defaultMarker: - return isLargeDataset ? "" : String(localized: "DEFAULT") + return String(localized: "DEFAULT") case .absent: - return isLargeDataset ? "" : absentFieldText + return absentFieldText case .unloaded(let byteCount): - return isLargeDataset ? "" : unloadedText(byteCount: byteCount) + return unloadedText(byteCount: byteCount) } } @@ -75,26 +74,20 @@ struct DataGridCellContent { struct DataGridCellState { let visualState: RowVisualState - let isFocused: Bool let isEditable: Bool - let isLargeDataset: Bool let isCurrentFindMatch: Bool let row: Int let columnIndex: Int init( visualState: RowVisualState, - isFocused: Bool, isEditable: Bool, - isLargeDataset: Bool, isCurrentFindMatch: Bool = false, row: Int, columnIndex: Int ) { self.visualState = visualState - self.isFocused = isFocused self.isEditable = isEditable - self.isLargeDataset = isLargeDataset self.isCurrentFindMatch = isCurrentFindMatch self.row = row self.columnIndex = columnIndex diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellKind.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellKind.swift similarity index 92% rename from apple/TablePro/Views/Results/Cells/DataGridCellKind.swift rename to apple/TablePro/Views/DataGrid/Cells/DataGridCellKind.swift index 3b1db36881..73b673868e 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellKind.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellKind.swift @@ -72,10 +72,7 @@ enum DataGridCellAccessory: Equatable { + reservedTrailingWidth } - var columnWidthReservation: CGFloat { - reservedTrailingWidth - } - + // The accessory's rect inside a cell whose origin is the cell's own top-left corner. func frame(in bounds: NSRect) -> NSRect { guard self != .none else { return .zero } let size = size @@ -83,19 +80,14 @@ enum DataGridCellAccessory: Equatable { guard bounds.width >= minRequiredWidth else { return .zero } return NSRect( x: bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width, - y: (bounds.height - size.height) / 2, + y: bounds.minY + (bounds.height - size.height) / 2, width: size.width, height: size.height ) } func availableTextWidth(in bounds: NSRect) -> CGFloat { - max( - 0, - bounds.width - - 2 * DataGridMetrics.cellHorizontalInset - - reservedTrailingWidth - ) + max(0, bounds.width - 2 * DataGridMetrics.cellHorizontalInset - reservedTrailingWidth) } static func reserved(for kind: DataGridCellKind, isEditable: Bool) -> DataGridCellAccessory { @@ -122,6 +114,8 @@ struct DataGridColumnPresentation: Equatable { let kind: DataGridCellKind let accessory: DataGridCellAccessory + static let plain = DataGridColumnPresentation(kind: .text, accessory: .none) + static func resolve( columnType: ColumnType?, isForeignKey: Bool, diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellPalette.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPalette.swift similarity index 82% rename from apple/TablePro/Views/Results/Cells/DataGridCellPalette.swift rename to apple/TablePro/Views/DataGrid/Cells/DataGridCellPalette.swift index 4808166b56..21d3df7aac 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellPalette.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPalette.swift @@ -11,6 +11,7 @@ struct DataGridCellPalette: Equatable { let regularFont: NSFont let italicFont: NSFont let mediumFont: NSFont + let rowNumberFont: NSFont let text: NSColor let placeholderText: NSColor let booleanTrueText: NSColor @@ -21,12 +22,12 @@ struct DataGridCellPalette: Equatable { let findMatchTint: NSColor let findMatchText: NSColor let selectionText: NSColor - let focusRing: NSColor init( regularFont: NSFont, italicFont: NSFont, mediumFont: NSFont, + rowNumberFont: NSFont = .monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular), text: NSColor, placeholderText: NSColor, booleanTrueText: NSColor, @@ -36,12 +37,12 @@ struct DataGridCellPalette: Equatable { modifiedColumnTint: NSColor, findMatchTint: NSColor, findMatchText: NSColor = DataGridCellPalette.findMatchTextColor, - selectionText: NSColor = .alternateSelectedControlTextColor, - focusRing: NSColor = .keyboardFocusIndicatorColor + selectionText: NSColor = .alternateSelectedControlTextColor ) { self.regularFont = regularFont self.italicFont = italicFont self.mediumFont = mediumFont + self.rowNumberFont = rowNumberFont self.text = text self.placeholderText = placeholderText self.booleanTrueText = booleanTrueText @@ -52,7 +53,6 @@ struct DataGridCellPalette: Equatable { self.findMatchTint = findMatchTint self.findMatchText = findMatchText self.selectionText = selectionText - self.focusRing = focusRing } static let placeholder = DataGridCellPalette( @@ -69,8 +69,7 @@ struct DataGridCellPalette: Equatable { findMatchTint: .findHighlightColor ) - /// `NSTextFinder` draws its find indicator as a light bubble in every appearance, so the text on - /// `findHighlightColor` is the light appearance's text color. + // NSTextFinder draws its find indicator as a light bubble in every appearance, so the text on it is light-appearance text. static let findMatchTextColor: NSColor = { var color = NSColor.textColor NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { @@ -86,10 +85,7 @@ struct DataGridCellPalette: Equatable { } extension ThemeScope { - /// The row-state tints are dynamic providers, which cost four times a catalog color on every - /// fill and `CTLineDraw`, so they are resolved here once in the appearance the grid draws in. - /// The catalog colors stay dynamic and resolve at draw time. Fonts are the user's typography, - /// not the theme's, so every scope draws in the engine's. + // The row-state tints are dynamic providers that cost four times a catalog color per fill, so they resolve once here. func dataGridCellPalette( in appearance: NSAppearance, fonts: DataGridFontCache = ThemeEngine.shared.dataGridFonts @@ -99,6 +95,7 @@ extension ThemeScope { regularFont: fonts.regular, italicFont: fonts.italic, mediumFont: fonts.medium, + rowNumberFont: fonts.rowNumber, text: resolved[.gridText], placeholderText: resolved[.gridNullValue], booleanTrueText: resolved[.gridBoolTrue], @@ -106,9 +103,7 @@ extension ThemeScope { rowNumberText: resolved[.gridRowNumber], deletedRowText: Self.resolvedTint(.gridDeletedText, of: resolved, in: appearance), modifiedColumnTint: Self.resolvedTint(.gridModified, of: resolved, in: appearance), - findMatchTint: .findHighlightColor, - selectionText: resolved[.gridSelectionText], - focusRing: resolved[.gridFocusBorder] + findMatchTint: .findHighlightColor ) } diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift new file mode 100644 index 0000000000..4db4c80598 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift @@ -0,0 +1,323 @@ +// +// DataGridCellPresenter.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +// Resolves what each drawn cell shows from the model, once per row draw rather than once per cell. +@MainActor +final class DataGridCellPresenter { + struct Pass { + let rows: TableRows + let formats: [ValueDisplayFormat?] + let selection: GridSelection + let findMatch: GridFindState.Match? + let isEditable: Bool + let checkboxColumns: Set + let palette: DataGridCellPalette + let scope: ThemeScope + let databaseType: DatabaseType? + let showsInvisibleCharacters: Bool + let nullDisplayString: String + } + + private weak var model: DataGridModel? + let registry: DataGridCellRegistry + let renderer = DataGridCellRenderer() + private let visualIndex = RowVisualIndex() + private var visualIndexSource: ObjectIdentifier? + private var highlightRuleSet = HighlightRuleSet.empty + private var presentations: [Int: DataGridColumnPresentation] = [:] + + init(model: DataGridModel) { + self.model = model + registry = DataGridCellRegistry(scope: model.themeScope) + } + + func adopt(_ model: DataGridModel) { + self.model = model + presentations.removeAll() + visualIndexSource = nil + highlightRuleSet = .empty + } + + func makePass(in appearance: NSAppearance) -> Pass? { + guard let model else { return nil } + registry.preparePalette(in: appearance) + let rows = model.rows + prepareRowState(rows: rows) + return Pass( + rows: rows, + formats: model.store.displayFormats, + selection: model.selection, + findMatch: model.find.currentMatch, + isEditable: model.configuration.isEditable, + checkboxColumns: model.configuration.checkboxColumns, + palette: registry.palette, + scope: model.themeScope, + databaseType: model.configuration.traits.databaseType, + showsInvisibleCharacters: registry.showsInvisibleCharacters, + nullDisplayString: registry.nullDisplayString + ) + } + + // MARK: - Row state + + func rebuildVisualIndex() { + visualIndex.rebuild(from: model?.changeState) + visualIndexSource = model?.changeState.map { ObjectIdentifier($0) } + } + + func updateVisualIndex(for rowIDs: [RowID]) { + let changeState = model?.changeState + for rowID in rowIDs { + visualIndex.updateRow(rowID, from: changeState) + } + } + + // Highlight rules and the change manager are read at draw time, so a row never paints with a stale tint. + func prepareRowState(rows: TableRows) { + syncHighlightRules(model?.dataSource?.gridHighlightRules ?? [], rows: rows) + syncVisualIndexSource() + } + + private func syncVisualIndexSource() { + let source = model?.changeState.map { ObjectIdentifier($0) } + guard source != visualIndexSource else { return } + rebuildVisualIndex() + } + + private func syncHighlightRules(_ rules: [HighlightRule], rows: TableRows) { + let key = HighlightRuleSet.Key(rules: rules, columns: rows.columns, columnTypes: rows.columnTypes) + if highlightRuleSet.key != key { + highlightRuleSet = HighlightRuleSet(rules: rules, columns: rows.columns, columnTypes: rows.columnTypes) + } + guard let display = model?.display, display.highlightRuleSetKey != key else { return } + display.highlightRuleSetKey = key + display.cache.clearHighlights() + } + + func visualState(of row: Row) -> RowVisualState { + if let state = model?.dataSource?.gridVisualState(for: row.id) { + return state + } + guard !visualIndex.isEmpty || !highlightRuleSet.isEmpty else { return .empty } + return visualIndex.visualState(for: row.id).highlighted(highlight(for: row)) + } + + private func highlight(for row: Row) -> RowHighlight { + guard !highlightRuleSet.isEmpty, let cache = model?.display.cache else { return .none } + if let cached = cache.highlight(forID: row.id) { return cached } + let resolved = highlightRuleSet.highlight(for: row.values) + cache.setHighlight(resolved, forID: row.id) + return resolved + } + + // MARK: - Column presentation + + // The data indexes whose kind or accessory changed, so their auto widths can grow to fit. + @discardableResult + func refreshColumnPresentations() -> IndexSet { + guard let model else { return [] } + let rows = model.rows + let configuration = model.configuration + var next: [Int: DataGridColumnPresentation] = [:] + next.reserveCapacity(rows.columns.count) + var changed = IndexSet() + for (dataIndex, name) in rows.columns.enumerated() { + let type = dataIndex < rows.columnTypes.count ? rows.columnTypes[dataIndex] : nil + let presentation = DataGridColumnPresentation.resolve( + columnType: type, + isForeignKey: rows.columnForeignKeys[name] != nil, + isDropdown: configuration.dropdownColumns?.contains(dataIndex) == true, + isTypePicker: configuration.typePickerColumns?.contains(dataIndex) == true, + isEnumOrSet: Self.picksFromList(type: type, enumValues: rows.columnEnumValues[name]), + isEditable: configuration.isEditable, + isCheckbox: configuration.checkboxColumns.contains(dataIndex) + ) + next[dataIndex] = presentation + if presentations[dataIndex] != presentation { + changed.insert(dataIndex) + } + } + presentations = next + return changed + } + + func presentation(forDataIndex dataIndex: Int) -> DataGridColumnPresentation { + presentations[dataIndex] ?? .plain + } + + private static func picksFromList(type: ColumnType?, enumValues: [String]?) -> Bool { + if type?.supportsElementEditing == true || type?.isVectorType == true { return true } + guard let enumValues, !enumValues.isEmpty else { return false } + return type?.isJsonType != true && type?.isBlobType != true && type?.isBooleanType != true + } + + // Read by the accessibility cell, so a list a pointer can open has a role a screen reader can find. + func presentsComboBox(dataIndex: Int) -> Bool { + guard let configuration = model?.configuration, configuration.isEditable else { return false } + return configuration.dropdownColumns?.contains(dataIndex) == true + || configuration.typePickerColumns?.contains(dataIndex) == true + } + + // MARK: - Text + + func displayText(of row: Row, dataIndex: Int, type: ColumnType?, pass: Pass) -> String { + displayText(of: row, dataIndex: dataIndex, type: type, formats: pass.formats, databaseType: pass.databaseType, columnCount: pass.rows.columns.count) + } + + func displayText( + of row: Row, + dataIndex: Int, + type: ColumnType?, + formats: [ValueDisplayFormat?], + databaseType: DatabaseType?, + columnCount: Int + ) -> String { + guard dataIndex >= 0, dataIndex < row.values.count, let cache = model?.display.cache else { return "" } + let existing = cache.box(forID: row.id) + if let existing, dataIndex < existing.values.count, let cached = existing.values[dataIndex] { + return cached + } + let rawValue = row.values[dataIndex] + let format = dataIndex < formats.count ? formats[dataIndex] : nil + let formatted = CellDisplayFormatter.format( + rawValue, + columnType: type, + displayFormat: format, + databaseType: databaseType + ) ?? rawValue.asText ?? "" + + let neededCount = max(dataIndex + 1, columnCount) + let box: RowDisplayBox + if let existing { + box = existing + if box.values.count < neededCount { + box.values.append(contentsOf: repeatElement(String?.none, count: neededCount - box.values.count)) + } + } else { + box = RowDisplayBox(ContiguousArray(repeating: nil, count: neededCount)) + } + box.values[dataIndex] = formatted + cache.setBox(box, forID: row.id) + return formatted + } + + // Formats a whole row ahead of the viewport, for the background prewarm. + func cacheText(of row: Row, rows: TableRows, formats: [ValueDisplayFormat?], databaseType: DatabaseType?) { + guard let cache = model?.display.cache, cache.box(forID: row.id) == nil else { return } + let columnCount = rows.columns.count + var values = ContiguousArray(repeating: nil, count: columnCount) + for dataIndex in 0.. DataGridCellAppearance? { + let dataIndex = column.dataIndex + guard dataIndex >= 0, dataIndex < row.values.count else { return nil } + let rawValue = row.values[dataIndex] + let text = displayText(of: row, dataIndex: dataIndex, type: column.type, pass: pass) + let isFindMatch = pass.findMatch.map { $0.rowID == row.id && $0.columnID == column.id } ?? false + + return DataGridCellAppearance.resolve( + kind: presentation(forDataIndex: dataIndex).kind, + content: DataGridCellContent( + displayText: text, + rawValue: rawValue.asText, + placeholder: DataGridCellContent.placeholder( + for: rawValue, + isAbsent: row.isAbsent(dataIndex), + unloadedByteCount: row.unloadedByteCount(dataIndex) + ) + ), + state: DataGridCellState( + visualState: visualState, + isEditable: pass.isEditable, + isCurrentFindMatch: isFindMatch, + row: displayRow, + columnIndex: dataIndex + ), + palette: pass.palette, + nullDisplayString: pass.nullDisplayString, + onEmphasizedSelection: onEmphasizedSelection, + checkboxMark: checkboxMark(of: row, column: column, checkboxColumns: pass.checkboxColumns), + textTone: model?.dataSource?.gridTextTone(for: row.id, column: column), + revealsInvisibleCharacters: pass.showsInvisibleCharacters + ) + } + + func checkboxMark(of row: Row, column: DataGridColumn, checkboxColumns: Set) -> DataGridCheckboxMark? { + guard checkboxColumns.contains(column.dataIndex), + let isOn = model?.dataSource?.gridCheckboxState(for: row.id, column: column) else { return nil } + return isOn ? .checked : .unchecked + } + + // A placeholder announces itself by name rather than as the italic marker a sighted reader sees. + func accessibilityText(of row: Row, column: DataGridColumn) -> String { + guard let model else { return "" } + if let spoken = model.dataSource?.gridAccessibilityText(for: row.id, column: column) { + return spoken + } + let dataIndex = column.dataIndex + guard dataIndex >= 0, dataIndex < row.values.count else { return "" } + let rows = model.rows + let text = displayText( + of: row, + dataIndex: dataIndex, + type: column.type, + formats: model.store.displayFormats, + databaseType: model.configuration.traits.databaseType, + columnCount: rows.columns.count + ) + let rawValue = row.values[dataIndex] + switch DataGridCellContent.placeholder( + for: rawValue, + isAbsent: row.isAbsent(dataIndex), + unloadedByteCount: row.unloadedByteCount(dataIndex) + ) { + case .null: return String(localized: "NULL") + case .empty: return String(localized: "Empty") + case .defaultMarker: return String(localized: "DEFAULT") + case .absent: return DataGridCellContent.absentFieldText + case .unloaded(let byteCount): return DataGridCellContent.unloadedSpokenText(byteCount: byteCount) + case .none: return spokenText(of: text) + } + } + + // A mark drawn in the cell is read by its name, or VoiceOver says nothing for it. + private func spokenText(of text: String) -> String { + guard registry.showsInvisibleCharacters else { return text } + let revealed = RevealedText(text, marksLineBreaks: true) + return revealed.revealsAnyCharacter ? revealed.spokenText : text + } + + func pendingChangeDescription(of row: Row, dataIndex: Int) -> String? { + DataGridCellTextMark.accessibilityDescription(state: visualState(of: row), columnIndex: dataIndex) + } + + func highlightDescription(of row: Row, dataIndex: Int) -> String? { + guard let rule = visualState(of: row).drawnHighlightRule(forColumn: dataIndex) else { return nil } + return HighlightRuleDescription.condition(of: rule, valueLimit: HighlightRuleDescription.menuValueLimit) + } +} diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridCellRegistry.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellRegistry.swift new file mode 100644 index 0000000000..777417659b --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellRegistry.swift @@ -0,0 +1,56 @@ +// +// DataGridCellRegistry.swift +// TablePro +// + +import AppKit + +@MainActor +final class DataGridCellRegistry { + private(set) var nullDisplayString: String + private(set) var showsInvisibleCharacters: Bool + private(set) var palette: DataGridCellPalette + private(set) var scope: ThemeScope + private var paletteKey: PaletteKey + + private struct PaletteKey: Equatable { + let revision: Int + let generation: Int + let appearance: NSAppearance.Name + } + + init(scope: ThemeScope = .live) { + self.scope = scope + let settings = AppSettingsManager.shared.dataGrid + nullDisplayString = settings.nullDisplay + showsInvisibleCharacters = settings.showInvisibleCharacters + let appearance = NSApp?.effectiveAppearance ?? NSAppearance.currentDrawing() + paletteKey = Self.paletteKey(for: appearance) + palette = scope.dataGridCellPalette(in: appearance) + } + + func reloadSettings(_ settings: DataGridSettings = AppSettingsManager.shared.dataGrid) { + nullDisplayString = settings.nullDisplay + showsInvisibleCharacters = settings.showInvisibleCharacters + } + + // Asked by every row as it draws: the tints resolve in the appearance the row draws in, which only the draw knows. + func preparePalette(in appearance: NSAppearance) { + let key = Self.paletteKey(for: appearance) + guard key != paletteKey else { return } + paletteKey = key + palette = scope.dataGridCellPalette(in: appearance) + } + + // A fixed scope never moves the key, so a new one rebuilds the palette here. + func adopt(_ scope: ThemeScope, in appearance: NSAppearance) { + self.scope = scope + paletteKey = Self.paletteKey(for: appearance) + palette = scope.dataGridCellPalette(in: appearance) + } + + private static func paletteKey(for appearance: NSAppearance) -> PaletteKey { + let change = ThemeEngine.shared.change + return PaletteKey(revision: change.revision, generation: change.generation, appearance: appearance.name) + } +} diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellRenderer.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellRenderer.swift similarity index 75% rename from apple/TablePro/Views/Results/Cells/DataGridCellRenderer.swift rename to apple/TablePro/Views/DataGrid/Cells/DataGridCellRenderer.swift index b68658d8b7..6928611216 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellRenderer.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellRenderer.swift @@ -7,16 +7,9 @@ import AppKit import CoreText import TableProTextEngine -/// Draws one data cell into a rect. -/// -/// The grid draws its cells rather than mounting a view for each, so the drawing has to live apart -/// from any view. Everything here is a function of the appearance it is handed; the only state is a -/// bounded cache of laid-out lines, because building a `CTLine` is the expensive part of a cell and -/// scrolling redraws the same values over and over. +// The grid draws its cells rather than mounting a view per cell; the only state here is a bounded line cache. @MainActor final class DataGridCellRenderer { - /// A laid-out line depends on nothing but the text and the two attributes it carries, so this is - /// the whole identity of a cached line. private struct LineKey: Hashable { let text: String let font: NSFont @@ -30,12 +23,9 @@ final class DataGridCellRenderer { let specialCharacters: SpecialCharacterLine? } - /// A viewport holds a few hundred cells and a scroll reuses them, so this only has to outlive a - /// few screens. Cleared wholesale rather than evicted one at a time: the cost of a miss is one - /// `CTLine`, and tracking recency would cost more than it saves. + // Cleared wholesale rather than evicted one at a time: a miss costs one CTLine. private static let lineCacheLimit = 4_096 - /// Past this many characters a cell can only ever show an ellipsis, and laying out the rest is - /// wasted on a value the reader opens the inspector to read anyway. + // Past this many characters a cell only ever shows an ellipsis. private static let maximumLaidOutCharacters = 300 private var lineCache: [LineKey: LaidOutLine] = [:] @@ -71,10 +61,18 @@ final class DataGridCellRenderer { drawCheckbox(mark, in: rect, controlView: controlView) } NSGraphicsContext.current?.restoreGraphicsState() + } - if appearance.drawsFocusBorder || appearance.drawsFocusRing { - drawCursor(in: rect, color: appearance.focusColor) - } + func drawRowNumber(_ number: Int, in rect: NSRect, font: NSFont, color: NSColor) { + let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color] + let text = String(number) as NSString + let size = text.size(withAttributes: attributes) + let inset = DataGridMetrics.cellHorizontalInset + let origin = NSPoint( + x: max(rect.minX + inset, rect.maxX - inset - size.width), + y: rect.midY - size.height / 2 + ) + text.draw(at: origin, withAttributes: attributes) } private func drawText(_ appearance: DataGridCellAppearance, in rect: NSRect) { @@ -114,10 +112,7 @@ final class DataGridCellRenderer { context.restoreGState() } - /// Built with the mark's own attribute so `CTLineDraw` draws the line from the font's metrics, - /// and so `CTLineCreateTruncatedLine` carries it onto a truncated line and its ellipsis. The - /// mark is part of the key because two cells holding the same value in the same font and colour - /// are two different drawings once one of them is struck through. + // The mark is part of the key: the same value struck through is a different drawing. private func line(for text: String, appearance: DataGridCellAppearance) -> LaidOutLine { let font = appearance.font let key = LineKey( @@ -169,21 +164,11 @@ final class DataGridCellRenderer { context.restoreGState() } - /// Drawn by the system's own checkbox cell, so it matches every other checkbox in the window in - /// each appearance and contrast setting rather than approximating one with a symbol. + // The system's own checkbox cell, so it matches every other checkbox in each appearance and contrast setting. private func drawCheckbox(_ mark: DataGridCheckboxMark, in rect: NSRect, controlView: NSView) { let frame = DataGridCheckboxMark.frame(in: rect) guard !frame.isEmpty else { return } checkboxCell.state = mark == .checked ? .on : .off checkboxCell.draw(withFrame: frame, in: controlView) } - - /// A mounted cell got its cursor from AppKit's exterior focus ring; a drawn cell has no view to - /// hang one on, so it is drawn to the same shape. - private func drawCursor(in rect: NSRect, color: NSColor) { - let path = NSBezierPath(rect: rect.insetBy(dx: 1, dy: 1)) - path.lineWidth = 2 - color.setStroke() - path.stroke() - } } diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridCellText.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellText.swift new file mode 100644 index 0000000000..72b22443c9 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellText.swift @@ -0,0 +1,77 @@ +// +// DataGridCellText.swift +// TablePro +// + +import AppKit + +extension NSFont { + func withTraits(_ traits: NSFontDescriptor.SymbolicTraits) -> NSFont { + let descriptor = fontDescriptor.withSymbolicTraits(traits) + return NSFont(descriptor: descriptor, size: pointSize) ?? self + } +} + +internal extension String { + var containsLineBreak: Bool { + let nsString = self as NSString + let length = nsString.length + guard length > 0 else { return false } + for index in 0.. String { + let nsString = self as NSString + let length = nsString.length + guard length > 0 else { return self } + + var mutable: NSMutableString? + var copiedUpTo = 0 + for index in 0..= length || nsString.character(at: index + 1) != 0x0A { + continue + } + + if mutable == nil { + mutable = NSMutableString(capacity: length) + } + if index > copiedUpTo { + mutable?.append(nsString.substring(with: NSRange(location: copiedUpTo, length: index - copiedUpTo))) + } + mutable?.append(" ") + copiedUpTo = index + 1 + } + + guard let result = mutable else { return self } + if copiedUpTo < length { + result.append(nsString.substring(with: NSRange(location: copiedUpTo, length: length - copiedUpTo))) + } + return result as String + } +} diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellTextMark.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellTextMark.swift similarity index 60% rename from apple/TablePro/Views/Results/Cells/DataGridCellTextMark.swift rename to apple/TablePro/Views/DataGrid/Cells/DataGridCellTextMark.swift index 09576271ee..5521993f3f 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellTextMark.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellTextMark.swift @@ -5,23 +5,12 @@ import AppKit -/// The line a pending change draws through or under a cell's own text, the way Pages marks tracked -/// changes. -/// -/// The tints alone could not carry this. A selected row paints over them, so a reader who selects -/// the rows they are about to save loses every mark at once, and a tint says nothing to a reader -/// who cannot see colour or is using a client that reads the grid rather than looking at it. The -/// line is drawn in the text's own colour, so it survives selection, Dark Mode, grayscale and -/// Increase Contrast, and the tints stay as the second cue. +// Drawn in the text's own color, so the mark survives selection, Dark Mode and Increase Contrast where a tint does not. enum DataGridCellTextMark: Equatable { - /// A row staged for deletion, struck through as Pages strikes deleted text. case struckThrough - /// A new row, or an edited cell, underlined as Pages underlines inserted text. case underlined - /// What a pending change makes of a cell, or nil when nothing is staged for it. - /// - /// A staged delete outranks the rest: the row is going away whatever was typed into it first. + // A staged delete outranks the rest: the row is going away whatever was typed into it first. static func resolve(state: RowVisualState, columnIndex: Int) -> DataGridCellTextMark? { if state.isDeleted { return .struckThrough } if state.isInserted { return .underlined } @@ -29,12 +18,7 @@ enum DataGridCellTextMark: Equatable { return state.isModified(columnIndex: columnIndex) ? .underlined : nil } - /// Read by `CTLineDraw` itself, which draws both lines from the font's own metrics. Measured on - /// macOS 27: a `CTLine` built from an attributed string carrying `.underlineStyle` or - /// `.strikethroughStyle` draws that line, in the run's foreground colour, and - /// `CTLineCreateTruncatedLine` carries it onto the truncated line. So the renderer never - /// computes a line's position, thickness or width, and a cell that truncates is marked to - /// exactly where its text ends. + // CTLineDraw draws both lines from the font's metrics and CTLineCreateTruncatedLine carries them onto the ellipsis. var attributes: [NSAttributedString.Key: Any] { switch self { case .struckThrough: diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridColumnWidthCalculator.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridColumnWidthCalculator.swift new file mode 100644 index 0000000000..88021d9cb7 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridColumnWidthCalculator.swift @@ -0,0 +1,126 @@ +// +// DataGridColumnWidthCalculator.swift +// TablePro +// + +import AppKit +import TableProPluginKit +import TableProTextEngine + +@MainActor +struct DataGridColumnWidthCalculator { + private static let minColumnWidth: CGFloat = 60 + private static let maxColumnWidth: CGFloat = 800 + private static let minFitToContentWidth: CGFloat = 300 + private static let fitToContentViewportFraction: CGFloat = 0.5 + private static let sampleRowCount = 30 + private static let wideResultSampleRowCount = 10 + private static let wideResultColumnCount = 50 + private static let fitToContentValueBudget = 200_000 + private static let maxMeasureChars = 50 + private static let headerPadding: CGFloat = 48 + private static let headerCharWidthRatio: CGFloat = 0.75 + + struct Input { + let column: DataGridColumn + let accessory: DataGridCellAccessory + let displayFormat: ValueDisplayFormat? + } + + private struct Budget { + let cap: CGFloat + let measuredCharLimit: Int + let sampledRows: Int + } + + let rows: TableRows + let databaseType: DatabaseType? + let nullDisplayString: String + let revealsInvisibleCharacters: Bool + let fonts: DataGridFontCache + + static func fitToContentCap(availableWidth: CGFloat) -> CGFloat { + let proportional = availableWidth * fitToContentViewportFraction + return min(max(proportional, minFitToContentWidth), maxColumnWidth) + } + + // The first paint samples, because it measures every column before the grid can draw a row. + func automaticWidth(_ input: Input) -> CGFloat { + let sampled = rows.columns.count > Self.wideResultColumnCount ? Self.wideResultSampleRowCount : Self.sampleRowCount + return measure(input, budget: Budget(cap: Self.maxColumnWidth, measuredCharLimit: Self.maxMeasureChars, sampledRows: sampled)) + } + + // An explicit fit reads the page, bounded across the whole gesture so fitting every column of a wide result stays cheap. + func fittedWidth(_ input: Input, availableWidth: CGFloat, fittedColumnCount: Int = 1) -> CGFloat { + let cap = Self.fitToContentCap(availableWidth: availableWidth) + let charWidth = fonts.monoCharWidth + let limit = charWidth > 0 ? Int((cap / charWidth).rounded(.up)) : Self.maxMeasureChars + let sampled = max(Self.sampleRowCount, Self.fitToContentValueBudget / max(1, fittedColumnCount)) + return measure(input, budget: Budget(cap: cap, measuredCharLimit: limit, sampledRows: sampled)) + } + + // Whole points: NSTableView pixel-aligns a fractional width during layout and reports it as a resize (measured). + private func measure(_ input: Input, budget: Budget) -> CGFloat { + measuredWidth(input, budget: budget).rounded(.up) + } + + private func measuredWidth(_ input: Input, budget: Budget) -> CGFloat { + let charWidth = fonts.monoCharWidth + let dataIndex = input.column.dataIndex + let headerCharCount = (input.column.name as NSString).length + var maxWidth = CGFloat(headerCharCount) * charWidth * Self.headerCharWidthRatio + Self.headerPadding + + let totalRows = rows.count + let step = max(1, totalRows / max(1, budget.sampledRows)) + + for index in stride(from: 0, to: totalRows, by: step) { + let rawValue = rows.value(at: index, column: dataIndex) + let formatted = CellDisplayFormatter.format( + rawValue, + columnType: input.column.type, + displayFormat: input.displayFormat, + databaseType: databaseType + ) ?? "" + let placeholder = DataGridCellContent.placeholder( + for: rawValue, + isAbsent: rows.isAbsent(row: index, column: dataIndex), + unloadedByteCount: rows.unloadedByteCount(row: index, column: dataIndex) + ) + let value = DataGridCellContent.resolvedDisplayText( + formatted, + placeholder: placeholder, + nullDisplayString: nullDisplayString + ) + + let charCount = min((value as NSString).length, budget.measuredCharLimit) + let markedWidth = revealsInvisibleCharacters && placeholder == nil + ? markedWidth(of: value, length: charCount) + : nil + let textWidth = markedWidth ?? CGFloat(charCount) * charWidth + maxWidth = max(maxWidth, textWidth + input.accessory.measurementPadding) + + if maxWidth >= budget.cap { + return budget.cap + } + } + + return min(max(maxWidth, Self.minColumnWidth), budget.cap) + } + + // A mark is wider than the character it stands for, so a value holding one is laid out. + private func markedWidth(of value: String, length: Int) -> CGFloat? { + let text = value as NSString + var index = 0 + while index < length { + let unit = text.character(at: index) + if unit == 0x0D || SpecialCharacter.mayBeSpecial(unit) { break } + index += 1 + } + guard index < length else { return nil } + let marked = SpecialCharacterLine( + NSAttributedString(string: text.substring(to: length), attributes: [.font: fonts.regular]), + style: SpecialCharacterStyle(marksLineBreaks: true) + ) + return CGFloat(CTLineGetTypographicBounds(marked.line, nil, nil, nil)) + } +} diff --git a/apple/TablePro/Views/Results/Cells/DataGridMetrics.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift similarity index 68% rename from apple/TablePro/Views/Results/Cells/DataGridMetrics.swift rename to apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift index 44a3c5a32c..05091ede89 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridMetrics.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift @@ -11,9 +11,11 @@ enum DataGridMetrics { static let rowNumberColumnMinWidth: CGFloat = 40 static let dataColumnMinWidth: CGFloat = 30 static let dataColumnMaxWidth: CGFloat = 1_200 - /// Room the grid scrolls past its last column. Without it the last column's divider sits on the - /// viewport's last point, which a resizable window claims for its own edge resize. + // Keeps the last column's divider off the viewport edge a resizable window claims for its own resize. static let trailingSpace: CGFloat = 40 + static let frozenDividerThickness: CGFloat = 2 + // Frozen columns that would leave the rest less than a third of the grid scroll instead. + static let maximumFrozenShare: CGFloat = 2.0 / 3.0 // The Row height setting is a floor: a grid font taller than the row would be cut at the cell edge. static func rowHeight(_ setting: DataGridRowHeight, font: NSFont) -> CGFloat { diff --git a/apple/TablePro/Views/Results/RowVisualIndex.swift b/apple/TablePro/Views/DataGrid/Cells/RowVisualIndex.swift similarity index 71% rename from apple/TablePro/Views/Results/RowVisualIndex.swift rename to apple/TablePro/Views/DataGrid/Cells/RowVisualIndex.swift index 4990b2aafc..140f135bcc 100644 --- a/apple/TablePro/Views/Results/RowVisualIndex.swift +++ b/apple/TablePro/Views/DataGrid/Cells/RowVisualIndex.swift @@ -3,7 +3,8 @@ // TablePro // -import Foundation +import AppKit +import TableProTheme @MainActor final class RowVisualIndex { @@ -19,17 +20,15 @@ final class RowVisualIndex { states.removeAll(keepingCapacity: true) } - func rebuild(from changeManager: AnyChangeManager) { + func rebuild(from changeManager: AnyChangeManager?) { states.removeAll(keepingCapacity: true) + guard let changeManager else { return } let insertedRowIDs = changeManager.insertedRowIDs guard changeManager.hasChanges || !insertedRowIDs.isEmpty else { return } for rowChange in changeManager.rowChanges { - states[rowChange.rowID] = Self.makeState( - for: rowChange, - inserted: insertedRowIDs.contains(rowChange.rowID) - ) + states[rowChange.rowID] = Self.makeState(for: rowChange, inserted: insertedRowIDs.contains(rowChange.rowID)) } for rowID in insertedRowIDs where states[rowID] == nil { @@ -37,7 +36,11 @@ final class RowVisualIndex { } } - func updateRow(_ rowID: RowID, from changeManager: AnyChangeManager) { + func updateRow(_ rowID: RowID, from changeManager: AnyChangeManager?) { + guard let changeManager else { + states.removeValue(forKey: rowID) + return + } let isInserted = changeManager.insertedRowIDs.contains(rowID) if let rowChange = changeManager.rowChanges.first(where: { $0.rowID == rowID }) { @@ -52,11 +55,7 @@ final class RowVisualIndex { } } - private static let insertedState = RowVisualState( - isDeleted: false, - isInserted: true, - modifiedColumns: [] - ) + private static let insertedState = RowVisualState(isDeleted: false, isInserted: true, modifiedColumns: []) private static func makeState(for rowChange: RowChange, inserted: Bool) -> RowVisualState { let isDeleted = rowChange.type == .delete @@ -83,3 +82,12 @@ final class RowVisualIndex { } } } + +extension RowVisualState { + // The wash a whole row carries, read by both panes so they cannot disagree. + @MainActor func tint(in scope: ThemeScope) -> NSColor? { + if isDeleted { return scope.resolved[.gridDeleted] } + if isInserted { return scope.resolved[.gridInserted] } + return highlight.rowColor?.washColor + } +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridCommandScope.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridCommandScope.swift new file mode 100644 index 0000000000..f0d312d302 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridCommandScope.swift @@ -0,0 +1,92 @@ +// +// DataGridCommandScope.swift +// TablePro +// + +import Foundation + +// What a command acts on. A context menu acts on the selection only when the click lands in it, else on the +// clicked cell or row (NSTableView clickedRow); the selection itself never moves. +struct DataGridCommandScope: Equatable { + let selection: GridSelection + // The cell a one-cell command reads: the clicked cell, else the active cell. Nil over the row numbers. + let focus: GridCoord? + // Opened over the row numbers, so Copy means the rows whatever shape the selection has. + let isRowHeader: Bool + + static func current(_ selection: GridSelection) -> DataGridCommandScope { + DataGridCommandScope(selection: selection, focus: selection.activeCell, isRowHeader: false) + } + + static func contextMenu( + selection: GridSelection, + row: Int, + displayColumn: Int?, + columnCount: Int + ) -> DataGridCommandScope { + guard let displayColumn else { + if selection.affectedRows.contains(row) { + return DataGridCommandScope(selection: selection, focus: nil, isRowHeader: true) + } + guard columnCount > 0 else { + return DataGridCommandScope(selection: .empty, focus: nil, isRowHeader: true) + } + let column = min(max(selection.activeCell?.displayColumn ?? 0, 0), columnCount - 1) + let cell = GridCoord(row: row, displayColumn: column) + let wholeRow = GridSelection( + rectangles: [GridRect(rows: row...row, columns: 0...(columnCount - 1))], + activeCell: cell, + anchor: cell + ) + return DataGridCommandScope(selection: wholeRow, focus: nil, isRowHeader: true) + } + let clicked = GridCoord(row: row, displayColumn: displayColumn) + if selection.contains(clicked) { + return DataGridCommandScope(selection: selection, focus: clicked, isRowHeader: false) + } + return DataGridCommandScope(selection: GridSelectionReducer.single(clicked), focus: clicked, isRowHeader: false) + } +} + +// A menu item's scope, held by identity: a reload under an open menu cannot retarget the command. +struct DataGridMenuContext { + let selection: GridSelectionSnapshot + let focus: GridSelectionSnapshot.Cell? + let isRowHeader: Bool + // Whether the menu acts on the grid's own selection, which the selection summary describes. + let isCurrentSelection: Bool + + @MainActor + init(_ scope: DataGridCommandScope, model: DataGridModel) { + let rows = model.rows + selection = GridSelectionSnapshot(scope.selection, order: model.order, rows: rows, columns: model.columns) + focus = scope.focus.flatMap { coord in + guard let rowID = model.rowID(at: coord.row), let column = model.columns.column(at: coord.displayColumn) else { + return nil + } + return GridSelectionSnapshot.Cell(rowID: rowID, columnID: column.id) + } + isRowHeader = scope.isRowHeader + isCurrentSelection = scope.selection == model.selection + } + + @MainActor + func scope(in model: DataGridModel) -> DataGridCommandScope { + let rows = model.rows + let resolved = selection.resolved(order: model.order, rows: rows, columns: model.columns) + .clamped(rowLimit: model.rowCount, columnLimit: model.columns.count) + let focusCoord = focus.flatMap { cell -> GridCoord? in + guard let row = model.displayIndex(of: cell.rowID), + let column = model.columns.displayPosition(of: cell.columnID) else { return nil } + return GridCoord(row: row, displayColumn: column) + } + return DataGridCommandScope(selection: resolved, focus: focusCoord, isRowHeader: isRowHeader) + } +} + +// A cell a command writes or reads, by identity; row is its display position when the command was resolved. +struct DataGridCommandCell: Equatable { + let rowID: RowID + let row: Int + let column: DataGridColumn +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridCommands+Copy.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridCommands+Copy.swift new file mode 100644 index 0000000000..9cb8f2fdb2 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridCommands+Copy.swift @@ -0,0 +1,381 @@ +// +// DataGridCommands+Copy.swift +// TablePro +// + +import AppKit +import os +import TableProPluginKit + +private let copyLogger = Logger(subsystem: "com.TablePro", category: "DataGridCopy") + +extension DataGridCommands { + var rowCopyFormat: RowCopyFormat { + configuration.appliesRowCopyFormat ? AppSettingsManager.shared.dataGrid.rowCopyFormat : .rows + } + + func canCopy(_ scope: DataGridCommandScope) -> Bool { + !scope.selection.isEmpty && !rowIDs(scope).isEmpty + } + + // Copies the cursor's cell when a click selected one row, rows in the Row Copy Format when rows were picked or + // several are selected, and a range as typed fields. + func copy(_ scope: DataGridCommandScope) { + let selection = scope.selection + guard !selection.isEmpty else { return } + if !scope.isRowHeader, selection.mode == .cursor, selection.affectedRows.count == 1, + let focus = scope.focus ?? selection.activeCell, let cell = commandCell(at: focus) { + copyCellValue(cell) + return + } + if scope.isRowHeader || isWholeRows(selection) { + copyRows(scope, as: rowCopyFormat) + return + } + if selection.uniqueCellCount == 1, let cell = cells(scope).first { + copyCellValue(cell) + return + } + copyRange(selection) + } + + // MARK: - Rows + + // The ceiling each format's converter already puts on its text, taken first so the paste payload holds the same rows. + private func capped(_ rowIDs: [RowID]) -> [RowID] { + let limit = RowOperationsManager.maxClipboardRows + guard rowIDs.count > limit else { return rowIDs } + copyLogger.warning("Clipboard copy truncated: \(rowIDs.count) rows selected, capping at \(limit)") + return Array(rowIDs.prefix(limit)) + } + + var visibleProjection: VisibleColumnProjection { + VisibleColumnProjection(indices: model.columns.columns.map(\.dataIndex)) + } + + func selectedProjection(_ scope: DataGridCommandScope) -> VisibleColumnProjection { + VisibleColumnProjection(indices: selectedColumns(scope).map(\.dataIndex)) + } + + func copyRows(_ scope: DataGridCommandScope, as format: RowCopyFormat) { + let ids = rowIDs(scope) + guard !ids.isEmpty else { return } + guard loader.ensureLoaded(ids, columns: nil, in: model, retry: { + // The scope names display positions, so it runs again only while they show the same records. + guard rowIDs(scope) == ids else { + NSSound.beep() + return + } + copyRows(scope, as: format) + }) else { return } + switch format { + case .rows: + // An owner that writes its own pasteboard type (staged SQL, structure definitions) answers first. + if model.actions?.gridCopyRows(ids) == true { return } + writeTSV(ids, includeHeaders: false) + case .rowsWithHeaders: + writeTSV(ids, includeHeaders: true) + case .json: + writeJSON(ids, projection: selectedProjection(scope)) + case .csv: + writeCSV(ids, projection: selectedProjection(scope), includeHeaders: false) + case .csvWithHeaders: + writeCSV(ids, projection: selectedProjection(scope), includeHeaders: true) + case .markdown: + writeMarkdown(ids, projection: selectedProjection(scope)) + } + } + + private func copiedRows(_ ids: [RowID]) -> [Row] { + let rows = model.rows + return capped(ids).compactMap { rows.row(withID: $0) } + } + + // Carried by every row copy, so a paste into a grid takes the rows rather than parsing the text. + private func rowsPayload(_ rows: [Row], projection: VisibleColumnProjection) -> GridRowsClipboardPayload { + let changeState = model.changeState + return GridRowsClipboardPayload( + columns: projection.columns(model.rows.columns), + copying: rows, + expressionColumns: rows.map { changeState?.expressionColumns(forRow: $0.id) ?? [] }, + projection: projection + ) + } + + private func writeTSV(_ ids: [RowID], includeHeaders: Bool) { + let copied = copiedRows(ids) + guard !copied.isEmpty else { return } + let tableRows = model.rows + let projection = visibleProjection + let columnTypes = projection.columnTypes(tableRows.columnTypes) + let columns = projection.columns(tableRows.columns) + var lines: [String] = includeHeaders ? [columns.joined(separator: "\t")] : [] + var htmlRows: [[String]] = [] + for row in copied { + let formatted = Self.formatRowValues(projection.values(Array(row.values)), columnTypes: columnTypes) + lines.append(formatted.joined(separator: "\t")) + htmlRows.append(formatted) + } + let html = includeHeaders ? HtmlTableEncoder.encode(rows: htmlRows, headers: columns) : nil + ClipboardService.shared.writeRows( + tsv: lines.joined(separator: "\n"), + html: html, + gridRows: rowsPayload(copied, projection: projection) + ) + } + + private func writeJSON(_ ids: [RowID], projection: VisibleColumnProjection) { + let copied = copiedRows(ids) + let displayIndices = Set(copied.compactMap { model.displayIndex(of: $0.id) }) + guard !displayIndices.isEmpty else { return } + let output = ResultJsonSerializer.serialize( + tableRows: model.rows, + displayIDs: model.order.filteredIDs, + selectedDisplayIndices: displayIndices, + columns: projection + ) + guard output.rowCount > 0 else { return } + ClipboardService.shared.writeText(output.json, gridRows: rowsPayload(copied, projection: projection)) + } + + private func writeCSV(_ ids: [RowID], projection: VisibleColumnProjection, includeHeaders: Bool) { + let copied = copiedRows(ids) + guard !copied.isEmpty else { return } + let tableRows = model.rows + let converter = CsvRowConverter( + columns: projection.columns(tableRows.columns), + columnTypes: projection.columnTypes(tableRows.columnTypes) + ) + ClipboardService.shared.writeCsv( + converter.generateCsv(rows: copied.map { projection.values(Array($0.values)) }, includeHeaders: includeHeaders), + gridRows: rowsPayload(copied, projection: projection) + ) + } + + private func writeMarkdown(_ ids: [RowID], projection: VisibleColumnProjection) { + let copied = copiedRows(ids) + guard !copied.isEmpty else { return } + let tableRows = model.rows + let converter = MarkdownTableConverter( + columns: projection.columns(tableRows.columns), + columnTypes: projection.columnTypes(tableRows.columnTypes) + ) + ClipboardService.shared.writeText( + converter.generateMarkdown(rows: copied.map { projection.values(Array($0.values)) }), + gridRows: rowsPayload(copied, projection: projection) + ) + } + + static func formatRowValues(_ values: [PluginCellValue], columnTypes: [ColumnType]) -> [String] { + values.enumerated().map { index, cell in + switch cell { + case .null: + return "NULL" + case .text(let value): + let columnType = columnTypes.indices.contains(index) ? columnTypes[index] : nil + return BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy) + case .bytes(let data): + return BlobFormattingService.shared.format(data, for: .copy) ?? "" + } + } + } + + // MARK: - Statements + + // The one column a cell selection covers, else the focus cell's: the clicked one in a menu, the active one elsewhere. + func inClauseColumn(_ scope: DataGridCommandScope) -> DataGridColumn? { + guard !scope.isRowHeader else { return nil } + if !isWholeRows(scope.selection) { + let positions = scope.selection.affectedColumns + if positions.count == 1, let only = positions.first { + return model.columns.column(at: only) + } + } + return scope.focus.flatMap { model.columns.column(at: $0.displayColumn) } + } + + func copyRowsAsInClause(_ scope: DataGridCommandScope) { + guard let column = inClauseColumn(scope) else { return } + let ids = rowIDs(scope) + guard !ids.isEmpty else { return } + guard loader.ensureLoaded(ids, columns: IndexSet(integer: column.dataIndex), in: model, retry: { + guard rowIDs(scope) == ids else { + NSSound.beep() + return + } + copyRowsAsInClause(scope) + }) else { return } + let tableRows = model.rows + let values = ids.compactMap { tableRows.row(withID: $0)?[column.dataIndex] } + guard !values.isEmpty else { return } + // Without an owner's driver the engine's dialect escapes: MySQL and ClickHouse read a backslash as one. + let text = model.actions?.gridSQLExporter?.inClause(values: values, column: column) + ?? InClauseConverter( + columnIndex: 0, + columnTypes: [column.type ?? .text(rawType: nil)], + escapeStringLiteral: configuration.traits.databaseType + .flatMap { try? resolveSQLDialect(for: $0) } + .map { escapeStringLiteralFromDialect($0) }, + stringLiteralPrefix: configuration.traits.stringLiteralPrefix + ).generateInClause(rows: values.map { [$0] }) + guard !text.isEmpty else { return } + ClipboardService.shared.writeText(text) + } + + // The statements leave out the columns the server owns, which only the schema names. + func canCopyAsInsert(_ scope: DataGridCommandScope) -> Bool { + guard offers(.copyAsSQL), let exporter = model.actions?.gridSQLExporter else { return false } + return exporter.canCopyAsInsert(model.rows) && !rowIDs(scope).isEmpty + } + + func canCopyAsUpdate(_ scope: DataGridCommandScope) -> Bool { + guard canCopyAsInsert(scope), let exporter = model.actions?.gridSQLExporter else { return false } + return exporter.canCopyAsUpdate(model.rows) + } + + // A copy that produced no statement leaves the clipboard as it was. + func copyRowsAsStatements(_ scope: DataGridCommandScope, update: Bool) { + guard update ? canCopyAsUpdate(scope) : canCopyAsInsert(scope), + let exporter = model.actions?.gridSQLExporter else { return } + let ids = rowIDs(scope) + guard loader.ensureLoaded(ids, columns: nil, in: model, retry: { + guard rowIDs(scope) == ids else { + NSSound.beep() + return + } + copyRowsAsStatements(scope, update: update) + }) else { return } + let columns = selectedColumns(scope) + let statements = update + ? exporter.updateStatements(for: ids, columns: columns, in: model.rows) + : exporter.insertStatements(for: ids, columns: columns, in: model.rows) + guard let statements, !statements.isEmpty else { return } + ClipboardService.shared.writeText(statements) + } + + // MARK: - Cells + + func copyCellValue(_ cell: DataGridCommandCell) { + let dataIndex = cell.column.dataIndex + guard loader.ensureLoaded([cell.rowID], columns: IndexSet(integer: dataIndex), in: model, retry: { + copyCellValue(cell) + }) else { return } + guard let row = storedRow(cell.rowID), dataIndex < row.values.count else { return } + let value = row.values[dataIndex] + if model.changeState?.isExpression(rowID: row.id, columnIndex: dataIndex) == true, let sql = value.asText { + ClipboardService.shared.writeCells(GridCellsClipboardPayload(text: sql, fields: [[sql]], expressionFields: [0: [0]])) + return + } + let text = cellCopyText(value, column: cell.column) + ClipboardService.shared.writeCells(GridCellsClipboardPayload(text: text, fields: [[value.isNull ? nil : text]])) + } + + // One cell copies as the cell reads, its display format applied; a format saved before the column changed type is skipped. + func cellCopyText(_ value: PluginCellValue, column: DataGridColumn) -> String { + let format = model.displayFormat(for: column) + let databaseType = configuration.traits.databaseType + if case .bytes(let data) = value { + return format.flatMap { format in + guard format.isApplicable(to: column.type, databaseType: databaseType) else { return nil } + return ValueDisplayFormatter.apply(data, format: format, columnType: column.type) + } + ?? BlobFormattingService.shared.format(data, for: .copy) + ?? "" + } + let text = value.asText ?? "NULL" + if let format, format != .raw, + format.isApplicable(to: column.type, databaseType: databaseType), + let formatted = ValueDisplayFormatter.apply(text, format: format, columnType: column.type) { + return formatted + } + return BlobFormattingService.shared.formatIfNeeded(text, columnType: column.type, for: .copy) + } + + // The block in the order the reader sees it, each field typed, NULL kept and staged SQL marked. + func copyRange(_ selection: GridSelection) { + guard let rect = selection.boundingRectangle else { return } + let rowCount = model.rowCount + let maxRows = RowOperationsManager.maxClipboardRows + let lastRow = min(rect.rows.upperBound, max(0, rowCount - 1), rect.rows.lowerBound + maxRows - 1) + let positions = Array(rect.columns).filter { $0 >= 0 && $0 < model.columns.count } + guard rect.rows.lowerBound <= lastRow, !positions.isEmpty else { return } + let rowRange = rect.rows.lowerBound...lastRow + let selectedRowCount = min(rect.rows.upperBound, rowCount - 1) - rect.rows.lowerBound + 1 + let ids = rowRange.map { model.rowID(at: $0) } + let copiedColumns = IndexSet(positions.compactMap { model.columns.dataIndex(atDisplayPosition: $0) }) + guard loader.ensureLoaded(ids.compactMap { $0 }, columns: copiedColumns, in: model, retry: { + // The block names display positions, so it runs again only while they show the same records. + guard rowRange.map({ model.rowID(at: $0) }) == ids else { + NSSound.beep() + return + } + copyRange(selection) + }) else { return } + + let tableRows = model.rows + let changeState = model.changeState + let isTruncated = selectedRowCount > maxRows + if isTruncated { + copyLogger.warning("Clipboard copy truncated: \(selectedRowCount) rows selected, capping at \(maxRows)") + } + var lines: [[String?]] = [] + lines.reserveCapacity(rowRange.count) + var expressionFields: [Int: Set] = [:] + for displayRow in rowRange { + guard let row = model.order.row(at: displayRow, in: tableRows) else { + lines.append(Array(repeating: "", count: positions.count)) + continue + } + let expressions = changeState?.expressionColumns(forRow: row.id) ?? [] + var fields: [String?] = [] + fields.reserveCapacity(positions.count) + for position in positions { + guard selection.contains(row: displayRow, displayColumn: position), + let column = model.columns.column(at: position), + column.dataIndex < row.values.count else { + fields.append("") + continue + } + let cell = row.values[column.dataIndex] + if expressions.contains(column.dataIndex), let sql = cell.asText { + expressionFields[lines.count, default: []].insert(fields.count) + fields.append(sql) + continue + } + fields.append(RowValueCopyFormatter.copyText(cell: cell, columnType: column.type)) + } + lines.append(fields) + } + + var text = lines.map { $0.map { $0 ?? "NULL" }.joined(separator: "\t") }.joined(separator: "\n") + if isTruncated { + text.append("\n(truncated, showing first \(maxRows) of \(selectedRowCount) rows)") + } + ClipboardService.shared.writeCells(GridCellsClipboardPayload( + text: text, + fields: lines, + expressionFields: expressionFields.isEmpty ? nil : expressionFields + )) + } + + // MARK: - Columns + + func copyColumnValues(_ column: DataGridColumn) { + let rowCount = min(model.rowCount, PluginRowLimits.emergencyMax) + guard rowCount > 0 else { return } + let tableRows = model.rows + let ids = (0.. Bool { + configuration.isEditable + && model.rowCount > 0 + && model.isColumnWritable(column) + && !configuration.primaryKeyColumns.contains(column.name) + } + + func fillTargetRowIDs() -> [RowID] { + guard configuration.isEditable else { return [] } + let rows = model.rows + return (0.. [FillColumnChoice] { + let rows = model.rows + guard rows.columns.indices.contains(column.dataIndex) else { return [.value] } + var choices: [FillColumnChoice] = [.value] + if rows.columnNullable[column.name] ?? true { + choices.append(.null) + } + if fillUUIDForm(for: column) != nil { + choices += [.uuid(.v4), .uuid(.v7)] + } + return choices + } + + // The value is read the way a typed cell is. Nil when the column holds neither the text nor NULL in its place. + func fillColumnContent(choice: FillColumnChoice, text: String, column: DataGridColumn) -> FillColumnContent? { + switch choice { + case .value: + return enteredCell(text, column: column).map { FillColumnContent.value($0.value, isExpression: $0.isExpression) } + case .null: + return .value(.null) + case .uuid(let version): + return fillUUIDForm(for: column).map { FillColumnContent.uuid(version, $0) } + } + } + + private func fillUUIDForm(for column: DataGridColumn) -> UUIDCellForm? { + setValueContext(for: column, rowIsInserted: false).flatMap { SetValueOptions.uuidForm(for: $0) } + } + + // One commit for the whole column; rows that already hold the value are left as they are. + func applyFillColumn(_ column: DataGridColumn, content: FillColumnContent) { + fill(fillTargetRowIDs(), column: column, content: content) + } + + // The retry keeps these rows: a filter changed while values load must not move the fill onto others. + private func fill(_ targets: [RowID], column: DataGridColumn, content: FillColumnContent) { + guard !targets.isEmpty else { return } + guard loader.ensureLoaded(targets, columns: IndexSet(integer: column.dataIndex), in: model, retry: { + fill(targets, column: column, content: content) + }) else { return } + let edits = targets.map { rowID in + let staged = Self.fillValue(content) + return DataGridCellEdit(rowID: rowID, columnID: column.id, value: .value(staged.value, isExpression: staged.isExpression)) + } + model.commit(edits, actionName: String(localized: "Fill Column")) + } + + private static func fillValue(_ content: FillColumnContent) -> StagedCellEntry { + switch content { + case .value(let value, let isExpression): + return (value: value, isExpression: isExpression) + case .uuid(let version, let form): + return (value: form.value(for: UUIDValueGenerator.shared.make(version)), isExpression: false) + } + } + + static func fillImpactDescription(rowCount: Int) -> String { + if rowCount == 1 { + return String(localized: "This sets 1 loaded row. Review and Save to apply.") + } + return String( + format: String(localized: "This sets %lld loaded rows. Review and Save to apply."), + Int64(rowCount) + ) + } +} + +// Value, NULL and the UUIDs are radio buttons with one superview and one action, which AppKit groups by itself. +@MainActor +final class FillColumnAccessoryView: NSView { + private static let width: CGFloat = 260 + private static let valueRowHeight: CGFloat = 24 + private static let choiceRowHeight: CGFloat = 22 + + private let valueField = NSTextField(frame: .zero) + private var radios: [(choice: FillColumnChoice, button: NSButton)] = [] + + init(choices: [FillColumnChoice]) { + let others = choices.filter { $0 != .value } + let height = Self.valueRowHeight + CGFloat(others.count) * Self.choiceRowHeight + super.init(frame: NSRect(x: 0, y: 0, width: Self.width, height: height)) + + valueField.usesSingleLineMode = true + valueField.placeholderString = String(localized: "Value") + valueField.setAccessibilityLabel(String(localized: "Value")) + addSubview(valueField) + + let valueRowY = height - Self.valueRowHeight + guard !others.isEmpty else { + valueField.frame = NSRect(x: 0, y: valueRowY, width: Self.width, height: Self.valueRowHeight) + return + } + + let valueRadio = addRadio(.value, at: valueRowY, rowHeight: Self.valueRowHeight) + valueRadio.state = .on + let fieldX = valueRadio.frame.maxX + 6 + valueField.frame = NSRect(x: fieldX, y: valueRowY, width: Self.width - fieldX, height: Self.valueRowHeight) + for (index, choice) in others.enumerated() { + addRadio(choice, at: valueRowY - CGFloat(index + 1) * Self.choiceRowHeight, rowHeight: Self.choiceRowHeight) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + var enteredText: String { valueField.stringValue } + + var choice: FillColumnChoice { + radios.first { $0.button.state == .on }?.choice ?? .value + } + + var firstResponderView: NSView { valueField } + + @discardableResult + private func addRadio(_ choice: FillColumnChoice, at rowY: CGFloat, rowHeight: CGFloat) -> NSButton { + let button = NSButton(radioButtonWithTitle: Self.title(for: choice), target: self, action: #selector(choiceChanged)) + button.sizeToFit() + button.frame.origin = NSPoint(x: 0, y: rowY + (rowHeight - button.frame.height) / 2) + addSubview(button) + radios.append((choice, button)) + return button + } + + private static func title(for choice: FillColumnChoice) -> String { + switch choice { + case .value: String(localized: "Value") + case .null: String(localized: "NULL") + case .uuid(.v4): String(localized: "New UUID") + case .uuid(.v7): String(localized: "New UUIDv7") + } + } + + @objc private func choiceChanged() { + valueField.isEnabled = choice == .value + } +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridCommands+Paste.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridCommands+Paste.swift new file mode 100644 index 0000000000..36861ef8cf --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridCommands+Paste.swift @@ -0,0 +1,198 @@ +// +// DataGridCommands+Paste.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +// The clipboard read as cells: the fields the grid's own copy carries, or the text split into lines and fields. +// Only the grid's copy can hold a NULL. +struct DataGridCellPasteBlock: Equatable { + // Splitting costs a pass over the whole string and validation asks on every Command-V, so past this the + // clipboard is not a cell block and goes to row paste. + static let maxLength = 1_000_000 + + let lines: [[String?]] + let isGridCopy: Bool + var expressionFields: [Int: Set] = [:] + + // The widest line, since an empty line holds one field and may come first. + var width: Int { lines.map(\.count).max() ?? 0 } + + var isSingleValue: Bool { lines.count == 1 && lines.first?.count == 1 } + + func isExpression(line: Int, field: Int) -> Bool { + expressionFields[line]?.contains(field) == true + } + + // Nil when the clipboard holds the grid's own row copy, which row paste reads. + @MainActor + static func fromClipboard() -> DataGridCellPasteBlock? { + let clipboard = ClipboardService.shared + guard !clipboard.hasGridRows, let text = clipboard.readText(), !text.isEmpty, + (text as NSString).length <= maxLength else { return nil } + if let copied = clipboard.readGridCells(), copied.text == text { + return DataGridCellPasteBlock(lines: copied.fields, isGridCopy: true, expressionFields: copied.expressionFields ?? [:]) + } + return DataGridCellPasteBlock(lines: pastedLines(text).map { $0.components(separatedBy: "\t") }, isGridCopy: false) + } + + // A CRLF copy folds to LF; empty lines at the end are a spreadsheet's trailing breaks, not rows. + static func pastedLines(_ text: String) -> [String] { + var lines = text.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: .newlines) + while lines.count > 1, lines.last?.isEmpty == true { + lines.removeLast() + } + return lines + } + + // Text from another app has no NULL of its own: a date, number or boolean cannot hold the letters NULL, so there + // they paste as NULL; a text column keeps them. + static func pastedText(_ field: String, columnType: ColumnType?) -> String? { + guard field == "NULL", let columnType, !columnType.hasEmptyValue, + columnType.enumValues?.contains(field) != true else { return field } + return nil + } +} + +extension DataGridCommands { + // MARK: - Planning + + // One value over a multi-cell selection fills every cell, as a spreadsheet pastes it. + func pasteFillTargets(_ scope: DataGridCommandScope, block: DataGridCellPasteBlock) -> [DataGridCommandCell]? { + guard block.isSingleValue else { return nil } + let targets = cells(scope) + return targets.count > 1 ? targets : nil + } + + // Where a block lands, from the focus cell rightwards and down in display order. A block as wide as the result, + // line for line, is whole rows and goes to row paste. + func pasteAnchor(_ scope: DataGridCommandScope, block: DataGridCellPasteBlock) -> GridCoord? { + guard !scope.isRowHeader, let anchor = scope.focus ?? scope.selection.activeCell, !block.lines.isEmpty else { + return nil + } + let dataColumnCount = model.rows.columns.count + let rowLines = block.lines.filter { $0 != [""] } + if !block.isSingleValue, dataColumnCount > 0, !rowLines.isEmpty, + rowLines.allSatisfy({ $0.count == dataColumnCount }) { + return nil + } + guard anchor.row >= 0, anchor.row < model.rowCount, + anchor.displayColumn >= 0, anchor.displayColumn < model.columns.count else { return nil } + return anchor + } + + func canPaste(_ scope: DataGridCommandScope) -> Bool { + guard configuration.isEditable else { return false } + if offers(.pasteRows), model.actions?.gridCanPasteRows() == true { return true } + guard let block = DataGridCellPasteBlock.fromClipboard() else { return false } + return pasteFillTargets(scope, block: block) != nil || pasteAnchor(scope, block: block) != nil + } + + // Menu and keyboard take this one path: fill the selection, else a block at the focus cell, else row paste. + func paste(_ scope: DataGridCommandScope) { + guard configuration.isEditable else { return } + if let block = DataGridCellPasteBlock.fromClipboard() { + if let targets = pasteFillTargets(scope, block: block) { + pasteFill(block, into: targets, scope: scope) + return + } + if let anchor = pasteAnchor(scope, block: block) { + pasteBlock(block, at: anchor, scope: scope) + return + } + } + guard offers(.pasteRows), model.actions?.gridCanPasteRows() == true else { return } + model.actions?.gridPasteRows() + } + + // MARK: - Writing + + private func pasteFill(_ block: DataGridCellPasteBlock, into targets: [DataGridCommandCell], scope: DataGridCommandScope) { + let columns = IndexSet(targets.map(\.column.dataIndex)) + // The retry keeps these targets and this block: a filter or a new clipboard while values load must not move them. + guard loader.ensureLoaded(targets.map(\.rowID), columns: columns, in: model, retry: { + pasteFill(block, into: targets, scope: scope) + }) else { return } + let field = block.lines.first?.first ?? nil + let isExpression = block.isExpression(line: 0, field: 0) + applyEntry(to: targets, actionName: String(localized: "Paste Cells")) { column in + pastedEntry(field, of: block, isExpression: isExpression, column: column) + } + } + + private func pasteBlock(_ block: DataGridCellPasteBlock, at anchor: GridCoord, scope: DataGridCommandScope) { + let maxRow = min(anchor.row + block.lines.count, model.rowCount) + let maxColumn = min(anchor.displayColumn + block.width, model.columns.count) + guard anchor.row < maxRow, anchor.displayColumn < maxColumn else { return } + + var edits: [DataGridCellEdit] = [] + var refused = false + for (lineIndex, fields) in block.lines.enumerated() { + let targetRow = anchor.row + lineIndex + guard targetRow < maxRow else { break } + guard let rowID = model.rowID(at: targetRow), !isRowDeleted(rowID) else { continue } + for (fieldIndex, field) in fields.enumerated() { + let position = anchor.displayColumn + fieldIndex + guard position < maxColumn else { break } + guard let column = model.columns.column(at: position) else { continue } + guard let entry = pastedEntry( + field, + of: block, + isExpression: block.isExpression(line: lineIndex, field: fieldIndex), + column: column + ) else { + refused = true + continue + } + edits.append(DataGridCellEdit( + rowID: rowID, + columnID: column.id, + value: .value(entry.value, isExpression: entry.isExpression) + )) + } + } + commitPasted(edits, refused: refused) + } + + // Targets are fixed by identity before any value loads, so the retry writes the cells the paste first named. + private func commitPasted(_ edits: [DataGridCellEdit], refused: Bool) { + var rowIDs: [RowID] = [] + var seen = Set() + for edit in edits where seen.insert(edit.rowID).inserted { + rowIDs.append(edit.rowID) + } + let columns = IndexSet(edits.compactMap { model.columns.column(id: $0.columnID)?.dataIndex }) + guard loader.ensureLoaded(rowIDs, columns: columns, in: model, retry: { + commitPasted(edits, refused: refused) + }) else { return } + if !edits.isEmpty { + model.commit(edits, actionName: String(localized: "Paste Cells")) + } + if refused { + NSSound.beep() + } + } + + // The grid's copy says which fields were SQL; any other field is text however it reads. Text from another app is + // read the way a typed cell is. + func pastedEntry( + _ field: String?, + of block: DataGridCellPasteBlock, + isExpression: Bool, + column: DataGridColumn + ) -> StagedCellEntry? { + let rows = model.rows + guard block.isGridCopy else { + let text = field.flatMap { + DataGridCellPasteBlock.pastedText($0, columnType: rows.editingColumnType(at: column.dataIndex)) + } + return enteredCell(text, column: column) + } + if isExpression, let field { + return (value: .text(field), isExpression: true) + } + return rows.enteredValue(field, forColumn: column.dataIndex).map { (value: $0, isExpression: false) } + } +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridCommands+SetValue.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridCommands+SetValue.swift new file mode 100644 index 0000000000..cc2d571948 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridCommands+SetValue.swift @@ -0,0 +1,240 @@ +// +// DataGridCommands+SetValue.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +extension DataGridCommands { + // Any cell's commands, so a selection over a date and a UUID column offers both kinds, in menu order. + func setValueCommands(for cells: [DataGridCommandCell]) -> [SetValueCommand] { + var resolver = DataGridSetValueResolver(commands: self) + var seen: Set = [] + var offered: [SetValueCommand] = [] + for cell in cells { + for command in resolver.commands(for: cell) ?? [] where seen.insert(command).inserted { + offered.append(command) + } + } + return offered.enumerated() + .sorted { (Self.menuRank($0.element), $0.offset) < (Self.menuRank($1.element), $1.offset) } + .map { $0.element } + } + + func isSetValueOffered(_ command: SetValueCommand, for cells: [DataGridCommandCell]) -> Bool { + var resolver = DataGridSetValueResolver(commands: self) + return cells.contains { resolver.commands(for: $0)?.contains(command) == true } + } + + // A cell that does not take the command, such as NULL on a NOT NULL column or a deleted row, is skipped. + func applySetValue(_ command: SetValueCommand, to cells: [DataGridCommandCell]) { + guard !cells.isEmpty else { return } + let columns = IndexSet(cells.map(\.column.dataIndex)) + guard loader.ensureLoaded(cells.map(\.rowID), columns: columns, in: model, retry: { + applySetValue(command, to: cells) + }) else { return } + var resolver = DataGridSetValueResolver(commands: self) + var edits: [DataGridCellEdit] = [] + for cell in cells { + guard let context = resolver.context(for: cell), + resolver.commands(for: cell)?.contains(command) == true, + let staged = Self.stagedValue(for: command, context: context) else { continue } + edits.append(DataGridCellEdit( + rowID: cell.rowID, + columnID: cell.column.id, + value: .value(staged.value, isExpression: staged.isExpression) + )) + } + guard !edits.isEmpty else { return } + model.commit(edits, actionName: Self.undoActionName(for: command)) + } + + func setValueContext(for column: DataGridColumn, rowIsInserted: Bool) -> SetValueContext? { + let rows = model.rows + let dataIndex = column.dataIndex + guard rows.columns.indices.contains(dataIndex) else { return nil } + let name = rows.columns[dataIndex] + let format = model.displayFormat(for: column) + return SetValueContext( + columnType: rows.editingColumnType(at: dataIndex), + isNullable: rows.columnNullable[name] ?? true, + serverAssignsValue: rows.serverAssignsValue(forColumn: name), + isForeignKey: rows.columnForeignKeys[name] != nil, + isWritable: model.isColumnWritable(column), + displayFormat: format, + sampleText: format == .uuid ? Self.sampleText(ofColumn: dataIndex, in: rows) : nil, + declaredType: rows.declaredType(at: dataIndex), + databaseType: configuration.traits.databaseType ?? Self.unknownDatabaseType, + rowIsInserted: rowIsInserted, + writer: writerSupport + ) + } + + // A grid with no engine named gets nothing the vocabulary keys by engine. + private static let unknownDatabaseType = DatabaseType(rawValue: "") + + // A MySQL CHAR(32) result has no length, so its values are the one sign it takes the compact UUID form. + private static func sampleText(ofColumn dataIndex: Int, in rows: TableRows) -> String? { + for row in rows.rows where dataIndex < row.values.count { + guard let text = row.values[dataIndex].asText, + !text.isEmpty, text != PluginCellValue.defaultMarkerText else { continue } + return text + } + return nil + } + + static func stagedValue(for command: SetValueCommand, context: SetValueContext) -> StagedCellEntry? { + switch command { + case .empty: + return (value: .text(""), isExpression: false) + case .null: + return (value: .null, isExpression: false) + case .defaultValue: + return (value: .text(PluginCellValue.defaultMarkerText), isExpression: false) + case .expression(let expression): + return (value: .text(expression.sql), isExpression: true) + case .uuid(let version): + guard let form = SetValueOptions.uuidForm(for: context) else { return nil } + return (value: form.value(for: UUIDValueGenerator.shared.make(version)), isExpression: false) + } + } + + static func undoActionName(for command: SetValueCommand) -> String { + guard case .uuid = command else { return String(localized: "Set Value") } + return SetValueOptions.title(for: command) + } + + // Placeholders, then the engine's functions, then UUIDs. + static func menuGroup(_ command: SetValueCommand) -> Int { + switch command { + case .empty, .null, .defaultValue: 0 + case .expression: 1 + case .uuid: 2 + } + } + + private static func menuRank(_ command: SetValueCommand) -> Int { + switch command { + case .empty: 0 + case .null: 1 + case .defaultValue: 2 + case .expression: 3 + case .uuid(.v4): 4 + case .uuid(.v7): 5 + } + } + + // MARK: - Set Cells to Value + + func isSetCellsToValueOffered(for cells: [DataGridCommandCell]) -> Bool { + cells.contains { typingCanEdit($0) } + } + + func applyEnteredText(_ text: String, to cells: [DataGridCommandCell]) { + applyEntry(to: cells, actionName: String(localized: "Set Cells to Value")) { column in + enteredCell(text, column: column) + } + } + + static func setCellsToValueImpact(cells: Int, targets: Int) -> String { + if cells < targets { + return String( + format: String(localized: "This sets %1$lld of %2$lld cells. The others cannot be edited."), + Int64(cells), + Int64(targets) + ) + } + if cells == 1 { + return String(localized: "This sets 1 cell.") + } + return String(format: String(localized: "This sets %lld cells."), Int64(cells)) + } + + // MARK: - Remove Field + + // Only an engine that tells a missing field from NULL, on a cell that still has a field to remove. + func canRemoveField(_ cell: DataGridCommandCell) -> Bool { + guard configuration.traits.supportsFieldRemoval, model.isColumnWritable(cell.column), !isRowDeleted(cell.rowID), + let row = storedRow(cell.rowID) else { return false } + return cell.column.dataIndex < row.values.count && !row.isAbsent(cell.column.dataIndex) + } + + func removeField(_ cell: DataGridCommandCell) { + guard canRemoveField(cell) else { return } + model.commit( + [DataGridCellEdit(rowID: cell.rowID, columnID: cell.column.id, value: .removeField)], + actionName: String(localized: "Remove Field") + ) + } +} + +// The records a Set Cells to Value sheet was opened over. A load that lands while the sheet is up can move or replace +// them, so the write finds the same records holding the same values, or is dropped. +@MainActor +struct DataGridCellRecords { + private let cells: [(rowID: RowID, columnID: DataGridColumnID)] + private let rows: [RowID: Row] + + init(_ cells: [DataGridCommandCell], in model: DataGridModel) { + let tableRows = model.rows + var rows: [RowID: Row] = [:] + for cell in cells { + rows[cell.rowID] = tableRows.row(withID: cell.rowID) + } + self.cells = cells.map { (rowID: $0.rowID, columnID: $0.column.id) } + self.rows = rows + } + + func current(in model: DataGridModel) -> [DataGridCommandCell]? { + let tableRows = model.rows + var resolved: [DataGridCommandCell] = [] + resolved.reserveCapacity(cells.count) + for cell in cells { + guard let row = tableRows.row(withID: cell.rowID), row == rows[cell.rowID], + let displayRow = model.displayIndex(of: cell.rowID), + let column = model.columns.column(id: cell.columnID) else { return nil } + resolved.append(DataGridCommandCell(rowID: cell.rowID, row: displayRow, column: column)) + } + return resolved + } +} + +// A context depends only on the column and whether the row is new, so a tall selection resolves a few, not one per cell. +@MainActor +struct DataGridSetValueResolver { + private struct Key: Hashable { + let columnID: DataGridColumnID + let rowIsInserted: Bool + } + + private let source: DataGridCommands + private let insertedRowIDs: Set + private let deletedRowIDs: Set + private var resolved: [Key: (context: SetValueContext, commands: [SetValueCommand])?] = [:] + + init(commands: DataGridCommands) { + source = commands + insertedRowIDs = commands.model.changeState?.insertedRowIDs ?? [] + deletedRowIDs = commands.model.changeState?.deletedRowIDs ?? [] + } + + mutating func context(for cell: DataGridCommandCell) -> SetValueContext? { + options(for: cell)?.context + } + + mutating func commands(for cell: DataGridCommandCell) -> [SetValueCommand]? { + options(for: cell)?.commands + } + + private mutating func options(for cell: DataGridCommandCell) -> (context: SetValueContext, commands: [SetValueCommand])? { + guard !deletedRowIDs.contains(cell.rowID) else { return nil } + let key = Key(columnID: cell.column.id, rowIsInserted: insertedRowIDs.contains(cell.rowID)) + if let cached = resolved[key] { return cached } + let options = source + .setValueContext(for: cell.column, rowIsInserted: key.rowIsInserted) + .map { (context: $0, commands: SetValueOptions.commands(for: $0)) } + resolved[key] = .some(options) + return options + } +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridCommands.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridCommands.swift new file mode 100644 index 0000000000..b0d3cb2177 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridCommands.swift @@ -0,0 +1,217 @@ +// +// DataGridCommands.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +typealias StagedCellEntry = (value: PluginCellValue, isExpression: Bool) + +// Copy, paste, Set Value, Fill Column and the row commands, over the model. Every multi-cell edit is one +// model.commit, so the owner records one undo step named for the command. +@MainActor +struct DataGridCommands { + let model: DataGridModel + let loader: DataGridValueLoader + + init(model: DataGridModel, loader: DataGridValueLoader = DataGridValueLoader()) { + self.model = model + self.loader = loader + } + + var configuration: DataGridConfiguration { model.configuration } + + func offers(_ command: DataGridCommand) -> Bool { + configuration.commands.contains(command) + } + + // MARK: - Targets + + // Rows the user picked or the row a click selected, as opposed to a range of cells of any width. + func isWholeRows(_ selection: GridSelection) -> Bool { + !selection.isEmpty && selection.isRows + } + + func commandCell(at coord: GridCoord) -> DataGridCommandCell? { + guard coord.row >= 0, coord.row < model.rowCount, + let rowID = model.rowID(at: coord.row), + let column = model.columns.column(at: coord.displayColumn) else { return nil } + return DataGridCommandCell(rowID: rowID, row: coord.row, column: column) + } + + func rowIDs(_ scope: DataGridCommandScope) -> [RowID] { + let rowCount = model.rowCount + let rows = scope.selection.affectedRows.filteredIndexSet { $0 >= 0 && $0 < rowCount } + return model.order.rowIDs(atDisplayIndexes: rows, in: model.rows) + } + + // The cells Set Value, Set Cells to Value and a one-value paste write. Whole rows take the focus column on each. + func cells(_ scope: DataGridCommandScope) -> [DataGridCommandCell] { + let selection = scope.selection + guard !selection.isEmpty, !scope.isRowHeader else { return [] } + let rows = model.rows + let rowCount = model.rowCount + let columns = model.columns + if isWholeRows(selection) { + guard let focus = scope.focus, let column = columns.column(at: focus.displayColumn) else { return [] } + return selection.affectedRows.compactMap { row in + guard row < rowCount, let rowID = model.order.rowID(at: row, in: rows) else { return nil } + return DataGridCommandCell(rowID: rowID, row: row, column: column) + } + } + var cells: [DataGridCommandCell] = [] + for row in selection.affectedRows where row >= 0 && row < rowCount { + guard let rowID = model.order.rowID(at: row, in: rows) else { continue } + for position in selection.columns(in: row) { + guard let column = columns.column(at: position) else { continue } + cells.append(DataGridCommandCell(rowID: rowID, row: row, column: column)) + } + } + return cells + } + + func focusCell(_ scope: DataGridCommandScope) -> DataGridCommandCell? { + guard let focus = scope.focus, + let rowID = model.rowID(at: focus.row), + let column = model.columns.column(at: focus.displayColumn) else { return nil } + return DataGridCommandCell(rowID: rowID, row: focus.row, column: column) + } + + // The scope's columns in display order. Whole rows, the row numbers and one clicked cell take every presented column. + func selectedColumns(_ scope: DataGridCommandScope) -> [DataGridColumn] { + let selection = scope.selection + guard !scope.isRowHeader, !isWholeRows(selection), selection.uniqueCellCount > 1 else { return model.columns.columns } + let picked = selection.affectedColumns.compactMap { model.columns.column(at: $0) } + return picked.isEmpty ? model.columns.columns : picked + } + + func storedRow(_ rowID: RowID) -> Row? { + model.rows.row(withID: rowID) + } + + func isRowDeleted(_ rowID: RowID) -> Bool { + model.changeState?.isRowDeleted(rowID) == true + } + + // MARK: - Rows + + func canDelete(_ scope: DataGridCommandScope) -> Bool { + offers(.deleteRows) && !scope.selection.isEmpty && !rowIDs(scope).isEmpty + } + + func deleteRows(_ scope: DataGridCommandScope) { + guard offers(.deleteRows) else { return } + let ids = rowIDs(scope) + guard !ids.isEmpty else { return } + model.actions?.gridDeleteRows(ids) + } + + func deletedRowIDs(_ scope: DataGridCommandScope) -> [RowID] { + rowIDs(scope).filter { isRowDeleted($0) } + } + + // MARK: - Entered text + + // Read the way a typed cell is: a function the engine runs there is SQL, an emptied date, number or boolean is NULL. + // Nil when the column holds neither the text nor NULL in its place. + func enteredCell(_ text: String?, column: DataGridColumn) -> StagedCellEntry? { + let rows = model.rows + if let text, + let databaseType = configuration.traits.databaseType, + let typed = CellEntry.typed( + text, + columnType: rows.editingColumnType(at: column.dataIndex), + databaseType: databaseType, + writer: writerSupport + ) { + return typed + } + return rows.enteredValue(text, forColumn: column.dataIndex).map { (value: $0, isExpression: false) } + } + + var writerSupport: CellWriterSupport { + model.changeState?.cellWriterSupport ?? configuration.traits.cellWriterSupport + } + + // One commit for every cell. The entry is read once per column; a column that refuses it keeps its cells and beeps. + @discardableResult + func applyEntry( + to cells: [DataGridCommandCell], + actionName: String, + entry: (DataGridColumn) -> StagedCellEntry? + ) -> Bool { + guard !cells.isEmpty else { return false } + var entries: [DataGridColumnID: StagedCellEntry?] = [:] + var edits: [DataGridCellEdit] = [] + var refused = false + for cell in cells where !isRowDeleted(cell.rowID) { + let resolved: StagedCellEntry? + if let cached = entries[cell.column.id] { + resolved = cached + } else { + resolved = entry(cell.column) + entries[cell.column.id] = .some(resolved) + } + guard let resolved else { + refused = true + continue + } + edits.append(DataGridCellEdit( + rowID: cell.rowID, + columnID: cell.column.id, + value: .value(resolved.value, isExpression: resolved.isExpression) + )) + } + if refused { + NSSound.beep() + } + guard !edits.isEmpty else { return false } + return model.commit(edits, actionName: actionName) + } + + // What typing could edit: a read-only or server-owned column, a deleted row, a value not loaded and a binary value stay out. + func typingCanEdit(_ cell: DataGridCommandCell) -> Bool { + guard configuration.isEditable, model.isColumnWritable(cell.column), !isRowDeleted(cell.rowID), + let row = storedRow(cell.rowID) else { return false } + let dataIndex = cell.column.dataIndex + guard dataIndex < row.values.count, row.unloadedByteCount(dataIndex) == nil else { return false } + if cell.column.type?.isBlobType == true { return false } + return row.values[dataIndex].asBytes == nil + } +} + +// Loads the values a page left on the server before a command reads them, then runs the command again once. +@MainActor +final class DataGridValueLoader { + private var isRetrying = false + + func ensureLoaded( + _ rowIDs: [RowID], + columns: IndexSet?, + in model: DataGridModel, + retry: @escaping @MainActor () -> Void + ) -> Bool { + let rows = model.rows + let pending = rowIDs.filter { rowID in + guard let row = rows.row(withID: rowID) else { return false } + return row.unloadedColumns.keys.contains { columns?.contains($0) ?? true } + } + guard !pending.isEmpty else { return true } + // A retry that still finds a value missing would load forever. + guard !isRetrying, let actions = model.actions, model.configuration.commands.contains(.loadUnloadedValues) else { + NSSound.beep() + return false + } + actions.gridLoadUnloadedValues(rowIDs: pending, columns: columns) { [weak self] loaded in + guard loaded else { + NSSound.beep() + return + } + self?.isRetrying = true + retry() + self?.isRetrying = false + } + return false + } +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+CommandResponders.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+CommandResponders.swift new file mode 100644 index 0000000000..18f493be25 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+CommandResponders.swift @@ -0,0 +1,195 @@ +// +// DataGridViewController+CommandResponders.swift +// TablePro +// + +import AppKit + +// The focused grid answers the Copy Special and Set Value commands itself, so they work in every window (interaction 8). +extension DataGridViewController: GridCopyCommandResponding { + func copyCellValue(_ sender: Any?) { + let commands = gridCommands + guard let cell = commands.focusCell(commandScope(for: sender)) else { return } + commands.copyCellValue(cell) + } + + func copyRowsAsCsv(_ sender: Any?) { + copyRows(sender, as: .csv) + } + + func copyRowsAsCsvWithHeaders(_ sender: Any?) { + copyRows(sender, as: .csvWithHeaders) + } + + func copyRowsAsMarkdown(_ sender: Any?) { + copyRows(sender, as: .markdown) + } + + func copyRowsAsInClause(_ sender: Any?) { + guard model.configuration.appliesRowCopyFormat else { return } + gridCommands.copyRowsAsInClause(commandScope(for: sender)) + } + + func copyRowsAsInsertStatements(_ sender: Any?) { + guard model.configuration.appliesRowCopyFormat else { return } + gridCommands.copyRowsAsStatements(commandScope(for: sender), update: false) + } + + func copyRowsAsUpdateStatements(_ sender: Any?) { + guard model.configuration.appliesRowCopyFormat else { return } + gridCommands.copyRowsAsStatements(commandScope(for: sender), update: true) + } + + // Structure grids list definitions, so only Copy Rows, which their owner answers, applies to them. + private func copyRows(_ sender: Any?, as format: RowCopyFormat) { + guard format == .rows || model.configuration.appliesRowCopyFormat else { return } + gridCommands.copyRows(commandScope(for: sender), as: format) + } +} + +extension DataGridViewController: GridRowCopyCommandResponding { + func copySelectedRows(_ sender: Any?) { + copyRows(sender, as: .rows) + } + + func copyRowsWithHeaders(_ sender: Any?) { + copyRows(sender, as: .rowsWithHeaders) + } + + func copyRowsAsJson(_ sender: Any?) { + copyRows(sender, as: .json) + } +} + +extension DataGridViewController: SetValueCommandResponding { + func setValueEmpty(_ sender: Any?) { + applySetValue(.empty, sender: sender) + } + + func setValueNull(_ sender: Any?) { + applySetValue(.null, sender: sender) + } + + func setValueDefault(_ sender: Any?) { + applySetValue(.defaultValue, sender: sender) + } + + func generateUUIDv4(_ sender: Any?) { + applySetValue(.uuid(.v4), sender: sender) + } + + func generateUUIDv7(_ sender: Any?) { + applySetValue(.uuid(.v7), sender: sender) + } + + // A window with its own command (a whole data file) runs it instead; the sheet otherwise names how many cells it sets. + func setCellsToValue(_ sender: Any?) { + let commands = gridCommands + let targets = commands.cells(commandScope(for: sender)) + guard !targets.isEmpty, commitCellEditing() else { return } + if model.actions?.gridSetCellsToValue(targets.map { (rowID: $0.rowID, column: $0.column) }) == true { return } + let cells = targets.filter { commands.typingCanEdit($0) } + guard !cells.isEmpty, let window = view.window else { return } + let records = DataGridCellRecords(cells, in: model) + SetCellsToValuePrompt.present( + informative: DataGridCommands.setCellsToValueImpact(cells: cells.count, targets: targets.count), + window: window + ) { [weak self] text in + guard let self, let text else { return } + guard let current = records.current(in: self.model) else { + NSSound.beep() + return + } + let commands = self.gridCommands + commands.applyEnteredText(text, to: current.filter { commands.typingCanEdit($0) }) + } + } + + private func applySetValue(_ command: SetValueCommand, sender: Any?) { + let commands = gridCommands + let cells = commands.cells(commandScope(for: sender)) + guard !cells.isEmpty, commitCellEditing() else { return } + commands.applySetValue(command, to: cells) + } + + static func setValueCommand(for action: Selector) -> SetValueCommand? { + switch action { + case #selector(SetValueCommandResponding.setValueEmpty(_:)): SetValueCommand.empty + case #selector(SetValueCommandResponding.setValueNull(_:)): SetValueCommand.null + case #selector(SetValueCommandResponding.setValueDefault(_:)): SetValueCommand.defaultValue + case #selector(SetValueCommandResponding.generateUUIDv4(_:)): SetValueCommand.uuid(.v4) + case #selector(SetValueCommandResponding.generateUUIDv7(_:)): SetValueCommand.uuid(.v7) + default: nil + } + } +} + +// The focused grid opens its own find bar, so Find reaches a query result, a Structure list or any other grid +// before the window hands the command to its editor. +extension DataGridViewController { + @objc func performFind(_ sender: Any?) { + model.showFind() + } + + @objc func performFindAndReplace(_ sender: Any?) { + model.showFind(replace: true) + } + + @objc func useSelectionForFind(_ sender: Any?) { + model.useSelectionForFind() + } + + @objc func findNext(_ sender: Any?) { + model.findNext() + } + + @objc func findPrevious(_ sender: Any?) { + model.findPrevious() + } + + func validateFindCommand(_ item: NSValidatedUserInterfaceItem) -> Bool? { + switch item.action { + case #selector(performFind(_:)), #selector(performFindAndReplace(_:)): + true + case #selector(useSelectionForFind(_:)): + model.selection.activeCell != nil + case #selector(findNext(_:)), #selector(findPrevious(_:)): + model.find.isVisible && model.hasFindTerm + default: + nil + } + } +} + +extension DataGridViewController { + // A key column takes no fill; the sheet says how many loaded rows it sets. + func presentFillColumn(for column: DataGridColumn) { + let commands = gridCommands + guard commands.canFillColumn(column), let window = view.window, commitCellEditing() else { return } + let rowCount = commands.fillTargetRowIDs().count + guard rowCount > 0 else { return } + let accessory = FillColumnAccessoryView(choices: commands.fillColumnChoices(for: column)) + let alert = NSAlert() + alert.messageText = String(format: String(localized: "Fill column \"%@\""), column.name) + alert.informativeText = DataGridCommands.fillImpactDescription(rowCount: rowCount) + alert.accessoryView = accessory + alert.addButton(withTitle: String(localized: "Fill")) + alert.addButton(withTitle: String(localized: "Cancel")) + alert.window.initialFirstResponder = accessory.firstResponderView + let columnID = column.id + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn, let self, + let current = self.model.columns.column(id: columnID) else { return } + let commands = self.gridCommands + guard let content = commands.fillColumnContent( + choice: accessory.choice, + text: accessory.enteredText, + column: current + ) else { + NSSound.beep() + return + } + commands.applyFillColumn(current, content: content) + } + } +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+HeaderMenu.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+HeaderMenu.swift new file mode 100644 index 0000000000..1b2d3456ff --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+HeaderMenu.swift @@ -0,0 +1,268 @@ +// +// DataGridViewController+HeaderMenu.swift +// TablePro +// + +import AppKit + +struct DataGridDisplayFormatChoice { + let columnID: DataGridColumnID + let format: ValueDisplayFormat +} + +extension DataGridViewController { + func buildHeaderMenu(for column: DataGridColumn) -> NSMenu? { + guard let position = model.columns.displayPosition(of: column.id) else { return nil } + let commands = gridCommands + let configuration = model.configuration + let columnID = column.id + var groups: [[NSMenuItem]] = [] + + if configuration.supportsColumnSort { + var sort = [ + menuItem(String(localized: "Sort Ascending"), #selector(sortAscendingFromMenu(_:)), columnID), + menuItem(String(localized: "Sort Descending"), #selector(sortDescendingFromMenu(_:)), columnID), + ] + if model.store.sortState.isSorting { + sort.append(menuItem(String(localized: "Don't Sort"), #selector(clearSortFromMenu(_:)), nil)) + } + groups.append(sort) + } + + var columnItems: [NSMenuItem] = [] + if model.rowCount > 0 { + columnItems.append(menuItem(String(localized: "Select Column"), #selector(selectColumnFromMenu(_:)), columnID)) + } + columnItems.append(menuItem(String(localized: "Copy Column Name"), #selector(copyColumnNameFromMenu(_:)), columnID)) + if model.rowCount > 0 { + columnItems.append(menuItem(String(localized: "Copy Column Values"), #selector(copyColumnValuesFromMenu(_:)), columnID)) + } + if commands.canFillColumn(column) { + columnItems.append(menuItem(String(localized: "Fill Column…"), #selector(fillColumnFromMenu(_:)), columnID)) + } + if commands.offers(.filterWithColumn) { + columnItems.append(menuItem(String(localized: "Filter with column"), #selector(filterWithColumnFromMenu(_:)), columnID)) + } + if offersValueFilter { + let filter = model.store.valueFilter + columnItems.append(menuItem(String(localized: "Filter Values…"), #selector(filterValuesFromMenu(_:)), columnID)) + if filter.isActive(column: column.dataIndex) { + columnItems.append(menuItem(String(localized: "Clear Value Filter"), #selector(clearValueFilterFromMenu(_:)), columnID)) + } + if filter.isActive { + columnItems.append(menuItem(String(localized: "Clear All Value Filters"), #selector(clearAllValueFiltersFromMenu(_:)), nil)) + } + } + if commands.offers(.highlightValues), let highlight = model.actions?.gridHighlightValuesMenuItem(for: column) { + columnItems.append(highlight) + } + if commands.offers(.displayAs), let displayAs = displayAsItem(for: column) { + columnItems.append(displayAs) + } + groups.append(columnItems) + + groups.append([ + menuItem(String(localized: "Size to Fit"), #selector(sizeColumnToFitFromMenu(_:)), columnID), + menuItem(String(localized: "Size All Columns to Fit"), #selector(sizeAllColumnsToFitFromMenu(_:)), nil), + ]) + + if commands.offers(.hideColumns) { + var visibility = [menuItem(String(localized: "Hide Column"), #selector(hideColumnFromMenu(_:)), columnID)] + if !model.store.columnLayout.hiddenColumns.isEmpty { + visibility.append(menuItem(String(localized: "Show All Columns"), #selector(showAllColumnsFromMenu(_:)), nil)) + } + groups.append(visibility) + } + + if commands.offers(.freezeColumns) { + groups.append(freezeItems(position: position, columnID: columnID)) + } + + groups.append(model.actions?.gridColumnMenuItems(for: column) ?? []) + return Self.menu(groups: groups) + } + + var offersValueFilter: Bool { + gridCommands.offers(.valueFilter) && model.configuration.supportsValueFilter + } + + private func displayAsItem(for column: DataGridColumn) -> NSMenuItem? { + let formats = ValueDisplayFormat.applicableFormats(for: column.type, databaseType: model.configuration.traits.databaseType) + guard formats.count > 1 else { return nil } + let current = model.displayFormat(for: column) ?? .raw + let submenu = NSMenu() + for format in formats { + let item = menuItem( + format.displayName, + #selector(setDisplayFormatFromMenu(_:)), + DataGridDisplayFormatChoice(columnID: column.id, format: format) + ) + item.state = format == current ? .on : .off + submenu.addItem(item) + } + let item = NSMenuItem(title: String(localized: "Display As"), action: nil, keyEquivalent: "") + item.submenu = submenu + return item + } + + // Refused, with the reason, when the frozen run would leave too little of the grid to scroll. + private func freezeItems(position: Int, columnID: DataGridColumnID) -> [NSMenuItem] { + var items: [NSMenuItem] = [] + let frozenCount = model.columns.frozenCount + if frozenCount != position + 1 { + let fits = canFreezeColumns(throughDisplayPosition: position) + let item = menuItem( + String(localized: "Freeze Up to This Column"), + fits ? #selector(freezeColumnsFromMenu(_:)) : nil, + columnID + ) + if !fits { + item.toolTip = String(localized: "These columns would leave too little room to scroll the rest.") + } + items.append(item) + } + if frozenCount > 0 { + items.append(menuItem(String(localized: "Unfreeze Columns"), #selector(unfreezeColumnsFromMenu(_:)), nil)) + } + return items + } + + // MARK: - Actions + + static let headerMenuActions: Set = [ + #selector(DataGridViewController.sortAscendingFromMenu(_:)), + #selector(DataGridViewController.sortDescendingFromMenu(_:)), + #selector(DataGridViewController.clearSortFromMenu(_:)), + #selector(DataGridViewController.selectColumnFromMenu(_:)), + #selector(DataGridViewController.copyColumnNameFromMenu(_:)), + #selector(DataGridViewController.copyColumnValuesFromMenu(_:)), + #selector(DataGridViewController.fillColumnFromMenu(_:)), + #selector(DataGridViewController.filterWithColumnFromMenu(_:)), + #selector(DataGridViewController.filterValuesFromMenu(_:)), + #selector(DataGridViewController.clearValueFilterFromMenu(_:)), + #selector(DataGridViewController.clearAllValueFiltersFromMenu(_:)), + #selector(DataGridViewController.setDisplayFormatFromMenu(_:)), + #selector(DataGridViewController.sizeColumnToFitFromMenu(_:)), + #selector(DataGridViewController.sizeAllColumnsToFitFromMenu(_:)), + #selector(DataGridViewController.hideColumnFromMenu(_:)), + #selector(DataGridViewController.showAllColumnsFromMenu(_:)), + #selector(DataGridViewController.freezeColumnsFromMenu(_:)), + #selector(DataGridViewController.unfreezeColumnsFromMenu(_:)), + ] + + // The column the item was built for, if the result still presents it. + private func menuColumn(_ sender: Any?) -> DataGridColumn? { + guard let id = (sender as? NSMenuItem)?.representedObject as? DataGridColumnID else { return nil } + return model.columns.column(id: id) + } + + @objc func sortAscendingFromMenu(_ sender: Any?) { + sort(menuColumn(sender), direction: .ascending) + } + + @objc func sortDescendingFromMenu(_ sender: Any?) { + sort(menuColumn(sender), direction: .descending) + } + + // Replaces the whole sort, stamped as the user's so the app default never writes over it. + private func sort(_ column: DataGridColumn?, direction: SortDirection) { + guard let column, model.configuration.supportsColumnSort else { return } + model.setSort(SortState( + columns: [SortColumn(columnIndex: column.dataIndex, direction: direction, columnName: column.name)], + source: .user + )) + } + + // The user's own empty sort, which keeps the app default from coming back on the next load. + @objc func clearSortFromMenu(_ sender: Any?) { + model.setSort(SortState(columns: [], source: .user)) + } + + // Focuses the grid too, so the Copy that usually follows acts on the column. + @objc func selectColumnFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender), + let position = model.columns.displayPosition(of: column.id), + model.rowCount > 0 else { return } + model.setSelection(GridSelection.column(position, totalRows: model.rowCount)) + mirrorSelection() + focus() + } + + @objc func copyColumnNameFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender) else { return } + ClipboardService.shared.writeText(column.name) + } + + @objc func copyColumnValuesFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender) else { return } + gridCommands.copyColumnValues(column) + } + + @objc func fillColumnFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender) else { return } + presentFillColumn(for: column) + } + + @objc func filterWithColumnFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender) else { return } + model.actions?.gridFilterWithColumn(column) + } + + @objc func filterValuesFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender) else { return } + presentValueFilter(for: column) + } + + @objc func clearValueFilterFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender) else { return } + applyValueFilter(nil, to: column) + } + + @objc func clearAllValueFiltersFromMenu(_ sender: Any?) { + clearAllValueFilters() + } + + // Only a value filter can renumber rows on a format change, so only then does the owner confirm it. + @objc func setDisplayFormatFromMenu(_ sender: Any?) { + guard let choice = (sender as? NSMenuItem)?.representedObject as? DataGridDisplayFormatChoice, + model.columns.column(id: choice.columnID) != nil else { return } + let apply: @MainActor () -> Void = { [weak self] in + guard let self, let column = self.model.columns.column(id: choice.columnID) else { return } + self.model.setDisplayFormat(choice.format, for: column) + } + guard model.store.valueFilter.isActive, let actions = model.actions else { + apply() + return + } + actions.gridConfirmDisplayOrderChange(then: apply) + } + + @objc func sizeColumnToFitFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender) else { return } + sizeColumnToFit(column) + } + + @objc func sizeAllColumnsToFitFromMenu(_ sender: Any?) { + sizeAllColumnsToFit() + } + + @objc func hideColumnFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender) else { return } + model.actions?.gridHideColumn(column) + } + + @objc func showAllColumnsFromMenu(_ sender: Any?) { + model.actions?.gridShowAllColumns() + } + + @objc func freezeColumnsFromMenu(_ sender: Any?) { + guard let column = menuColumn(sender), + let position = model.columns.displayPosition(of: column.id), + canFreezeColumns(throughDisplayPosition: position) else { return } + model.updateColumnLayout { $0.frozenColumnCount = position + 1 } + } + + @objc func unfreezeColumnsFromMenu(_ sender: Any?) { + model.updateColumnLayout { $0.frozenColumnCount = 0 } + } +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+RowDrag.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+RowDrag.swift new file mode 100644 index 0000000000..e36468b966 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+RowDrag.swift @@ -0,0 +1,34 @@ +// +// DataGridViewController+RowDrag.swift +// TablePro +// + +import AppKit + +extension DataGridViewController { + // Dropped in another app a row is a copy, as tab-separated text and an HTML table; the reorder type rides along + // only where a reorder can run. + func rowDragPasteboardItem(forRow row: Int) -> NSPasteboardItem? { + guard let rowModel = model.row(at: row) else { return nil } + let rows = model.rows + let projection = gridCommands.visibleProjection + let formatted = DataGridCommands.formatRowValues( + projection.values(Array(rowModel.values)), + columnTypes: projection.columnTypes(rows.columnTypes) + ) + let item = NSPasteboardItem() + if model.configuration.rowReorder.isEnabled { + item.setString(String(row), forType: Self.rowDragType) + } + item.setString(formatted.joined(separator: "\t"), forType: .string) + item.setString(HtmlTableEncoder.encode(rows: [formatted], headers: projection.columns(rows.columns)), forType: .html) + return item + } + + // A press on the number of a selected row lifts the rows; a press anywhere else selects. Structure reorders from any row. + func canDragRows(fromRowNumberAt row: Int) -> Bool { + if model.configuration.rowReorder.isEnabled { return true } + let selection = model.selection + return gridCommands.isWholeRows(selection) && selection.affectedRows.contains(row) + } +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+RowMenu.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+RowMenu.swift new file mode 100644 index 0000000000..5b754272d6 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+RowMenu.swift @@ -0,0 +1,320 @@ +// +// DataGridViewController+RowMenu.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +struct DataGridSetValueChoice { + let context: DataGridMenuContext + let command: SetValueCommand +} + +extension DataGridViewController { + // Unavailable items are left out; Copy and Paste stay, dimmed when they do not apply (HIG context menus). + func buildRowMenu(row: Int, displayColumn: Int?) -> NSMenu? { + guard row >= 0, row < model.rowCount, let rowID = model.rowID(at: row) else { return nil } + let commands = gridCommands + let scope = DataGridCommandScope.contextMenu( + selection: model.selection, + row: row, + displayColumn: displayColumn, + columnCount: model.columns.count + ) + let context = DataGridMenuContext(scope, model: model) + + if commands.isRowDeleted(rowID), commands.offers(.undoDelete) { + return Self.menu(groups: [[menuItem(String(localized: "Undo Delete"), #selector(undoDeleteFromMenu(_:)), context)]]) + } + + let cell = displayColumn == nil ? nil : commands.focusCell(scope) + var groups: [[NSMenuItem]] = [] + groups.append(copyItems(scope: scope, context: context, cell: cell)) + groups.append(resultItems(context: context, cell: cell)) + if let cell { + groups.append(foreignKeyItems(cell: cell, context: context)) + groups.append(valueItems(scope: scope, context: context, cell: cell)) + } + groups.append(exportItems(context: context)) + groups.append(model.actions?.gridRowMenuItems(for: commands.rowIDs(scope), clickedRow: rowID) ?? []) + groups.append(rowItems(context: context)) + return Self.menu(groups: groups) + } + + static func menu(groups: [[NSMenuItem]]) -> NSMenu? { + let filled = groups.filter { !$0.isEmpty } + guard !filled.isEmpty else { return nil } + let menu = NSMenu() + for (index, group) in filled.enumerated() { + if index > 0 { + menu.addItem(.separator()) + } + group.forEach(menu.addItem) + } + return menu + } + + func menuItem(_ title: String, _ action: Selector?, _ represented: Any?) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.target = self + item.representedObject = represented + return item + } + + // MARK: - Groups + + private func copyItems(scope: DataGridCommandScope, context: DataGridMenuContext, cell: DataGridCommandCell?) -> [NSMenuItem] { + var items = [menuItem(String(localized: "Copy"), #selector(copy(_:)), context)] + if cell != nil { + items.append(menuItem(String(localized: "Copy Cell Value"), #selector(GridCopyCommandResponding.copyCellValue(_:)), context)) + } + if model.configuration.appliesRowCopyFormat { + items.append(copyAsItem(scope: scope, context: context, includesInClause: cell != nil)) + } + if context.isCurrentSelection, let numbers = selectionSummaryTracker.currentSummary?.numbers { + for figure in [SelectionSummaryFigures.Figure.sum, .average] { + guard let text = SelectionSummaryFigures.copyText(figure, of: numbers) else { continue } + items.append(menuItem(figure.copyTitle, #selector(copySummaryFigureFromMenu(_:)), text)) + } + } + if model.configuration.isEditable { + items.append(menuItem(String(localized: "Paste"), #selector(paste(_:)), context)) + } + return items + } + + private func copyAsItem(scope: DataGridCommandScope, context: DataGridMenuContext, includesInClause: Bool) -> NSMenuItem { + let commands = gridCommands + let submenu = NSMenu() + let formats: [(String, Selector)] = [ + (String(localized: "Rows"), #selector(GridRowCopyCommandResponding.copySelectedRows(_:))), + (String(localized: "With Headers"), #selector(GridRowCopyCommandResponding.copyRowsWithHeaders(_:))), + (String(localized: "JSON"), #selector(GridRowCopyCommandResponding.copyRowsAsJson(_:))), + (String(localized: "CSV"), #selector(GridCopyCommandResponding.copyRowsAsCsv(_:))), + (String(localized: "CSV with Headers"), #selector(GridCopyCommandResponding.copyRowsAsCsvWithHeaders(_:))), + (String(localized: "Markdown"), #selector(GridCopyCommandResponding.copyRowsAsMarkdown(_:))), + ] + for (title, action) in formats { + submenu.addItem(menuItem(title, action, context)) + } + if includesInClause, commands.inClauseColumn(scope) != nil { + submenu.addItem(menuItem(String(localized: "IN Clause"), #selector(GridCopyCommandResponding.copyRowsAsInClause(_:)), context)) + } + if commands.canCopyAsInsert(scope) { + submenu.addItem(.separator()) + submenu.addItem(menuItem( + String(localized: "INSERT Statement(s)"), + #selector(GridCopyCommandResponding.copyRowsAsInsertStatements(_:)), + context + )) + if commands.canCopyAsUpdate(scope) { + submenu.addItem(menuItem( + String(localized: "UPDATE Statement(s)"), + #selector(GridCopyCommandResponding.copyRowsAsUpdateStatements(_:)), + context + )) + } + } + let item = NSMenuItem(title: String(localized: "Copy as"), action: nil, keyEquivalent: "") + item.submenu = submenu + return item + } + + private func resultItems(context: DataGridMenuContext, cell: DataGridCommandCell?) -> [NSMenuItem] { + var items: [NSMenuItem] = [] + if gridCommands.offers(.showRowsAsJSON) { + items.append(menuItem(String(localized: "Show Row as JSON"), #selector(showRowsAsJSONFromMenu(_:)), context)) + } + if let cell { + items += model.actions?.gridCellMenuItems(for: cell.rowID, column: cell.column) ?? [] + } + return items + } + + // Choosing a value is offered on an empty cell too, where it is needed most; previewing and following need a value. + private func foreignKeyItems(cell: DataGridCommandCell, context: DataGridMenuContext) -> [NSMenuItem] { + let commands = gridCommands + let rows = model.rows + guard let info = rows.columnForeignKeys[cell.column.name], let row = rows.row(withID: cell.rowID), + cell.column.dataIndex < row.values.count else { return [] } + let hasValue = row.values[cell.column.dataIndex].asText?.isEmpty == false + let canChoose = commands.typingCanEdit(cell) + && !ForeignKeyConstraintSpan.isMultiColumn(info, among: rows.columnForeignKeys) + var items: [NSMenuItem] = [] + if canChoose { + items.append(menuItem( + String(format: String(localized: "Choose %@ Row…"), info.referencedTable), + #selector(chooseForeignKeyRowFromMenu(_:)), + context + )) + } + guard hasValue, commands.offers(.navigateForeignKey) else { return items } + items.append(menuItem(String(localized: "Preview Referenced Row"), #selector(previewForeignKeyFromMenu(_:)), context)) + items.append(menuItem( + String(format: String(localized: "Open %@"), info.referencedTable), + #selector(openForeignKeyFromMenu(_:)), + context + )) + items.append(menuItem( + String(format: String(localized: "Open %@ in New Tab"), info.referencedTable), + #selector(openForeignKeyInNewTabFromMenu(_:)), + context + )) + return items + } + + private func valueItems(scope: DataGridCommandScope, context: DataGridMenuContext, cell: DataGridCommandCell) -> [NSMenuItem] { + guard model.configuration.isEditable else { return [] } + let commands = gridCommands + let targets = commands.cells(scope) + let submenu = NSMenu() + var lastGroup: Int? + for command in commands.setValueCommands(for: targets) { + let group = DataGridCommands.menuGroup(command) + if let lastGroup, lastGroup != group { + submenu.addItem(.separator()) + } + lastGroup = group + submenu.addItem(menuItem( + SetValueOptions.title(for: command), + #selector(applySetValueFromMenu(_:)), + DataGridSetValueChoice(context: context, command: command) + )) + } + if commands.isSetCellsToValueOffered(for: targets) { + if !submenu.items.isEmpty { + submenu.addItem(.separator()) + } + submenu.addItem(menuItem( + String(localized: "Set Cells to Value…"), + #selector(SetValueCommandResponding.setCellsToValue(_:)), + context + )) + } + var items: [NSMenuItem] = [] + if !submenu.items.isEmpty { + let setValue = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "") + setValue.submenu = submenu + items.append(setValue) + } + if commands.canRemoveField(cell) { + items.append(menuItem(String(localized: "Remove Field"), #selector(removeFieldFromMenu(_:)), context)) + } + return items + } + + private func exportItems(context: DataGridMenuContext) -> [NSMenuItem] { + var items: [NSMenuItem] = [] + if gridCommands.offers(.exportResults) { + items.append(menuItem(String(localized: "Export Results…"), #selector(exportResultsFromMenu(_:)), context)) + } + if gridCommands.offers(.clearResults) { + items.append(menuItem(String(localized: "Clear Results"), #selector(clearResultsFromMenu(_:)), context)) + } + return items + } + + private func rowItems(context: DataGridMenuContext) -> [NSMenuItem] { + var items: [NSMenuItem] = [] + if gridCommands.offers(.duplicateRows) { + items.append(menuItem(String(localized: "Duplicate"), #selector(duplicateRowsFromMenu(_:)), context)) + } + if gridCommands.offers(.deleteRows) { + items.append(menuItem(String(localized: "Delete"), #selector(delete(_:)), context)) + } + return items + } + + // MARK: - Actions + + private static let rowMenuActions: Set = [ + #selector(DataGridViewController.undoDeleteFromMenu(_:)), + #selector(DataGridViewController.showRowsAsJSONFromMenu(_:)), + #selector(DataGridViewController.chooseForeignKeyRowFromMenu(_:)), + #selector(DataGridViewController.previewForeignKeyFromMenu(_:)), + #selector(DataGridViewController.openForeignKeyFromMenu(_:)), + #selector(DataGridViewController.openForeignKeyInNewTabFromMenu(_:)), + #selector(DataGridViewController.applySetValueFromMenu(_:)), + #selector(DataGridViewController.removeFieldFromMenu(_:)), + #selector(DataGridViewController.exportResultsFromMenu(_:)), + #selector(DataGridViewController.clearResultsFromMenu(_:)), + #selector(DataGridViewController.duplicateRowsFromMenu(_:)), + #selector(DataGridViewController.copySummaryFigureFromMenu(_:)), + ] + + static let contextMenuActions = rowMenuActions.union(headerMenuActions) + + @objc func undoDeleteFromMenu(_ sender: Any?) { + let ids = gridCommands.deletedRowIDs(commandScope(for: sender)) + guard !ids.isEmpty else { return } + model.actions?.gridUndoDelete(ids) + } + + @objc func showRowsAsJSONFromMenu(_ sender: Any?) { + model.actions?.gridShowRowsAsJSON(gridCommands.rowIDs(commandScope(for: sender))) + } + + @objc func duplicateRowsFromMenu(_ sender: Any?) { + let ids = gridCommands.rowIDs(commandScope(for: sender)) + guard !ids.isEmpty, commitCellEditing() else { return } + model.actions?.gridDuplicateRows(ids) + } + + @objc func exportResultsFromMenu(_ sender: Any?) { + model.actions?.gridExportResults() + } + + @objc func clearResultsFromMenu(_ sender: Any?) { + model.actions?.gridClearResults() + } + + @objc func copySummaryFigureFromMenu(_ sender: Any?) { + guard let text = (sender as? NSMenuItem)?.representedObject as? String else { return } + ClipboardService.shared.writeText(text) + } + + @objc func applySetValueFromMenu(_ sender: Any?) { + guard let choice = (sender as? NSMenuItem)?.representedObject as? DataGridSetValueChoice, + commitCellEditing() else { return } + let commands = gridCommands + commands.applySetValue(choice.command, to: commands.cells(choice.context.scope(in: model))) + } + + @objc func removeFieldFromMenu(_ sender: Any?) { + let commands = gridCommands + guard let cell = commands.focusCell(commandScope(for: sender)), commitCellEditing() else { return } + commands.removeField(cell) + } + + @objc func chooseForeignKeyRowFromMenu(_ sender: Any?) { + guard let focus = commandScope(for: sender).focus else { return } + chooseForeignKeyRow(at: focus) + } + + // The preview follows the active cell, so the clicked cell becomes it first. + @objc func previewForeignKeyFromMenu(_ sender: Any?) { + guard let focus = commandScope(for: sender).focus else { return } + if model.selection.activeCell != focus || model.selection.hasMultipleCells { + model.selectCell(focus) + mirrorSelection() + } + toggleForeignKeyPreview() + } + + @objc func openForeignKeyFromMenu(_ sender: Any?) { + openForeignKey(from: sender, intent: .follow) + } + + @objc func openForeignKeyInNewTabFromMenu(_ sender: Any?) { + openForeignKey(from: sender, intent: .newTab) + } + + private func openForeignKey(from sender: Any?, intent: ReferenceOpenIntent) { + guard let cell = gridCommands.focusCell(commandScope(for: sender)) else { return } + let rows = model.rows + guard let info = rows.columnForeignKeys[cell.column.name], + let value = rows.row(withID: cell.rowID)?[cell.column.dataIndex].asText, !value.isEmpty else { return } + model.actions?.gridNavigateForeignKey(value: value, info: info, intent: intent) + } +} diff --git a/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+ValueFilter.swift b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+ValueFilter.swift new file mode 100644 index 0000000000..b4124734ad --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Commands/DataGridViewController+ValueFilter.swift @@ -0,0 +1,117 @@ +// +// DataGridViewController+ValueFilter.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +extension DataGridCommands { + // Every loaded value as the cell shows it, which is what a value filter stores and matches; NULL first. + func distinctValues(for column: DataGridColumn) -> [ColumnDistinctValue] { + let rows = model.rows + let dataIndex = column.dataIndex + guard dataIndex >= 0, dataIndex < rows.columns.count else { return [] } + let format = model.displayFormat(for: column) + let databaseType = configuration.traits.databaseType + var counts: [String: Int] = [:] + var order: [String] = [] + var nullCount = 0 + for row in rows.rows { + guard dataIndex < row.values.count else { continue } + let raw = row.values[dataIndex] + if case .null = raw { + nullCount += 1 + continue + } + let display = GridDisplayOrderResolver.displayValue( + raw, + columnType: column.type, + displayFormat: format, + databaseType: databaseType + ) + if counts[display] == nil { + order.append(display) + } + counts[display, default: 0] += 1 + } + var values = order + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + .map { ColumnDistinctValue(display: $0, isNull: false, count: counts[$0] ?? 0) } + if nullCount > 0 { + values.insert(ColumnDistinctValue(display: "", isNull: true, count: nullCount), at: 0) + } + return values + } +} + +extension DataGridViewController { + // Opened from the header menu, and from an active filter's funnel; values still on the server load first. + func presentValueFilter(for column: DataGridColumn) { + guard offersValueFilter else { return } + let rows = model.rows + let dataIndex = column.dataIndex + let unloaded = rows.rows.filter { $0.unloadedByteCount(dataIndex) != nil }.map(\.id) + let columnID = column.id + guard menuState.loader.ensureLoaded(unloaded, columns: IndexSet(integer: dataIndex), in: model, retry: { [weak self] in + guard let self, let current = self.model.columns.column(id: columnID) else { return } + self.presentValueFilter(for: current) + }) else { return } + guard let anchor = model.anchor(for: column) else { return } + + let values = gridCommands.distinctValues(for: column) + let initialFilter = model.store.valueFilter.filter(forColumn: dataIndex) + dismissValueFilterPopover() + menuState.valueFilterPopover = PopoverPresenter.show( + relativeTo: anchor.rect, + of: anchor.view, + preferredEdge: .maxY + ) { [weak self] dismiss in + ColumnValueFilterPopover( + columnName: column.name, + values: values, + loadedRowCount: rows.count, + initialFilter: initialFilter, + // A change closes the popover once applied, in followRowChangesWithPopovers; declining the alert keeps the picks. + onApply: { filter in + guard let self, let current = self.model.columns.column(id: columnID) else { + dismiss() + return + } + if !self.applyValueFilter(filter, to: current) { + dismiss() + } + }, + onCancel: dismiss + ) + } + } + + func dismissValueFilterPopover() { + guard let popover = menuState.valueFilterPopover else { return } + menuState.valueFilterPopover = nil + popover.close() + } + + // The model asks the owner to confirm before a change that would drop pending edits; no change asks nothing. + @discardableResult + func applyValueFilter(_ filter: ColumnValueFilter?, to column: DataGridColumn) -> Bool { + var state = model.store.valueFilter + if let filter { + state.set(filter, columnName: column.name, forColumn: column.dataIndex) + } else { + state.clear(column: column.dataIndex) + } + guard state != model.store.valueFilter else { return false } + model.setValueFilter(state) + return true + } + + func clearAllValueFilters() { + var state = model.store.valueFilter + guard state.isActive else { return } + state.clearAll() + model.setValueFilter(state) + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift b/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift new file mode 100644 index 0000000000..71e665d3b0 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift @@ -0,0 +1,242 @@ +// +// DataGridAccessibilityCellView.swift +// TablePro +// + +import AppKit + +// The first question an assistive client asks is the only sign one is attached: NSWorkspace reports VoiceOver alone. +// One-way and process-wide, since nothing reports a client detaching either. +@MainActor +internal enum DataGridAccessibility { + internal static let didActivateNotification = Notification.Name("com.TablePro.DataGridAccessibilityDidActivate") + + // Settable so a test can drive both shapes. + internal static var isActive = false + + internal static func markActive() { + guard !isActive else { return } + isActive = true + NotificationCenter.default.post(name: didActivateNotification, object: nil) + } +} + +// NSTableView builds its AXCell tree from cell views only (probe P6), so while a client is attached each on-screen +// cell of the main table mounts one of these. It draws nothing and takes no clicks. +@MainActor +final class DataGridAccessibilityCellView: NSTableCellView { + static let reuseIdentifier = NSUserInterfaceItemIdentifier("data-grid-cell") + + private weak var controller: DataGridViewController? + private weak var table: NSTableView? + private var slot: DataGridPane.Slot = .rowNumber + private var seededRow = 0 + + func configure(controller: DataGridViewController, table: NSTableView, row: Int, slot: DataGridPane.Slot) { + self.controller = controller + self.table = table + self.slot = slot + seededRow = row + identifier = Self.reuseIdentifier + setAccessibilityIdentifier(Self.reuseIdentifier.rawValue) + } + + // Asked of the table rather than remembered: an insert or remove moves a mounted view without asking again. + private var row: Int { + guard let table else { return seededRow } + let resolved = table.row(for: self) + return resolved >= 0 ? resolved : seededRow + } + + private var column: (model: DataGridColumn, position: Int)? { + guard case .data(let id) = slot, + let columns = controller?.model.columns, + let position = columns.displayPosition(of: id), + let column = columns.column(at: position) else { return nil } + return (column, position) + } + + private var rowModel: Row? { + controller?.model.row(at: row) + } + + // The row number the reader sees, page offset included. + private var shownRowNumber: Int { + (controller?.model.dataSource?.gridRowNumberOffset ?? 0) + row + 1 + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func accessibilityRowIndexRange() -> NSRange { + NSRange(location: row, length: 1) + } + + // A frozen column's cell is read where the frozen pane shows it, not at its home scrolled off to the left. + override func accessibilityFrame() -> NSRect { + guard let controller, let table, let window = table.window, let column, + column.position < controller.effectiveFrozenCount else { return super.accessibilityFrame() } + let offset = controller.main.scrollView.contentView.bounds.minX + let shown = table.convert(bounds, from: self).offsetBy(dx: offset, dy: 0) + return window.convertToScreen(table.convert(shown, to: nil)) + } + + override func isAccessibilityElement() -> Bool { true } + + // A cell with a list beside its text is a combo box, which is what NSComboBoxCell publishes. + override func accessibilityRole() -> NSAccessibility.Role? { + guard let column, let rowModel, let controller else { return .staticText } + if checkboxMark(row: rowModel, column: column.model) != nil { return .checkBox } + return controller.presenter.presentsComboBox(dataIndex: column.model.dataIndex) ? .comboBox : .staticText + } + + override func accessibilityValue() -> Any? { + guard let column, let rowModel else { return String(shownRowNumber) } + if let mark = checkboxMark(row: rowModel, column: column.model) { + return NSNumber(value: mark == .checked) + } + return controller?.presenter.accessibilityText(of: rowModel, column: column.model) ?? "" + } + + override func accessibilityPerformPress() -> Bool { + guard let controller, let column, let rowModel, + controller.toggleCheckbox(rowID: rowModel.id, column: column.model, displayRow: row) else { return false } + NSAccessibility.post(element: self, notification: .valueChanged) + return true + } + + // Whole sentences per combination, so a translator can order the parts; the pending change reads first. + override func accessibilityLabel() -> String? { + let rowNumber = shownRowNumber + guard let controller, let column, let rowModel else { + return String(format: String(localized: "Row %d"), rowNumber) + } + let presenter = controller.presenter + let text = presenter.accessibilityText(of: rowModel, column: column.model) + let columnNumber = column.position + 1 + let pendingChange = presenter.pendingChangeDescription(of: rowModel, dataIndex: column.model.dataIndex) + let highlight = presenter.highlightDescription(of: rowModel, dataIndex: column.model.dataIndex) + if let pendingChange, let highlight { + return String( + format: String(localized: "Row %d, column %d: %@, %@, highlighted where %@"), + rowNumber, columnNumber, text, pendingChange, highlight + ) + } + if let pendingChange { + return String(format: String(localized: "Row %d, column %d: %@, %@"), rowNumber, columnNumber, text, pendingChange) + } + if let highlight { + return String( + format: String(localized: "Row %d, column %d: %@, highlighted where %@"), + rowNumber, columnNumber, text, highlight + ) + } + return String(format: String(localized: "Row %d, column %d: %@"), rowNumber, columnNumber, text) + } + + private func checkboxMark(row: Row, column: DataGridColumn) -> DataGridCheckboxMark? { + guard let controller else { return nil } + return controller.presenter.checkboxMark(of: row, column: column, checkboxColumns: controller.model.configuration.checkboxColumns) + } +} + +extension DataGridViewController { + // Only the viewport mounts: walking the tree prepares every row, and 9,000 views behind a 1,000-row page + // starved the main thread (#2392). + func mountsAccessibilityCell(forRow row: Int) -> Bool { + let table = main.tableView + return NSLocationInRange(row, table.rows(in: table.visibleRect)) + } + + func accessibilityCellView(for tableColumn: NSTableColumn, row: Int) -> NSView { + let table = main.tableView + let cell = (table.makeView(withIdentifier: DataGridAccessibilityCellView.reuseIdentifier, owner: nil) + as? DataGridAccessibilityCellView) ?? DataGridAccessibilityCellView() + let slot: DataGridPane.Slot = main.columnID(of: tableColumn).map { .data($0) } ?? .rowNumber + cell.configure(controller: self, table: table, row: row, slot: slot) + return cell + } + + // A row prepared off screen was answered with no view and is never asked again, so revealed rows reload. + func remountAccessibilityCells(force: Bool = false) { + guard DataGridAccessibility.isActive, !isEditingCell else { return } + let table = main.tableView + let visible = table.rows(in: table.visibleRect) + guard visible.length > 0, force || !NSEqualRanges(visible, accessibilityMountedRows) else { return } + let upperBound = min(visible.location + visible.length, table.numberOfRows) + guard visible.location >= 0, upperBound > visible.location, table.numberOfColumns > 0 else { return } + accessibilityMountedRows = visible + table.reloadData( + forRowIndexes: IndexSet(integersIn: visible.location.. [Any]? { + let table = main.tableView + let selection = model.selection + guard !selection.isEmpty else { return [] } + let visible = table.rows(in: table.visibleRect) + guard visible.length > 0 else { return [] } + var cells: [Any] = [] + for rectangle in selection.rectangles { + for row in rectangle.rows where NSLocationInRange(row, visible) { + for position in rectangle.columns { + guard let index = main.tableIndex(forDisplayPosition: position, columns: model.columns), + let cell = table.view(atColumn: index, row: row, makeIfNecessary: false) as? DataGridAccessibilityCellView + else { continue } + cells.append(cell) + } + } + } + return cells + } + + func postSelectionAccessibility(from previous: GridSelection, to current: GridSelection) { + guard DataGridAccessibility.isActive else { return } + let table = main.tableView + if current.hasMultipleCells || previous.hasMultipleCells { + NSAccessibility.post( + element: table, + notification: .announcementRequested, + userInfo: [ + .announcement: Self.accessibilityAnnouncement(for: current), + .priority: NSAccessibilityPriorityLevel.medium.rawValue, + ] + ) + } + NSAccessibility.post(element: table, notification: .selectedCellsChanged) + guard let active = current.activeCell, active != previous.activeCell, + let index = main.tableIndex(forDisplayPosition: active.displayColumn, columns: model.columns), + active.row < table.numberOfRows, + let cell = table.view(atColumn: index, row: active.row, makeIfNecessary: false) as? DataGridAccessibilityCellView + else { return } + NSAccessibility.post(element: cell, notification: .focusedUIElementChanged) + } + + static func accessibilityAnnouncement(for selection: GridSelection) -> String { + guard selection.hasMultipleCells, let rect = selection.boundingRectangle else { + return String(localized: "Cell selection cleared") + } + return String( + format: String(localized: "%d cells selected, rows %d to %d, columns %d to %d"), + selection.uniqueCellCount, + rect.rows.lowerBound + 1, + rect.rows.upperBound + 1, + rect.columns.lowerBound + 1, + rect.columns.upperBound + 1 + ) + } + + // The frozen pane is hidden from accessibility, so a point over it answers with the main table's own cell. + func accessibilityElement(atFrozenPanePoint point: NSPoint) -> NSView? { + let frozenTable = frozen.tableView + let row = frozenTable.row(at: point) + let column = frozenTable.column(at: point) + guard row >= 0, column >= 0 else { return nil } + DataGridAccessibility.markActive() + let table = main.tableView + return table.view(atColumn: column, row: row, makeIfNecessary: false) + ?? table.rowView(atRow: row, makeIfNecessary: false) + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridContainerView.swift b/apple/TablePro/Views/DataGrid/DataGridContainerView.swift new file mode 100644 index 0000000000..836a407943 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridContainerView.swift @@ -0,0 +1,185 @@ +// +// DataGridContainerView.swift +// TablePro +// + +import AppKit + +// Two tables scrolled in step, per Apple's "Synchronizing Scroll Views": NSTableView has no per-column freeze. +@MainActor +final class DataGridContainerView: NSView { + let main = DataGridPane(kind: .main) + let frozen = DataGridPane(kind: .frozen) + weak var controller: DataGridViewController? + + private(set) var gridHasFocus = false + private var isSyncingScroll = false + private var firstResponderObservation: NSKeyValueObservation? + private var windowObservers: [NSObjectProtocol] = [] + private var scrollObservers: [NSObjectProtocol] = [] + + var frozenPaneWidth: CGFloat = 0 { + didSet { + guard frozenPaneWidth != oldValue else { return } + layoutFrozenPane() + } + } + + var panes: [DataGridPane] { [main, frozen] } + + init() { + super.init(frame: .zero) + addSubview(main.scrollView) + addSubview(frozen.scrollView) + frozen.scrollView.isHidden = true + frozen.scrollView.wheelTarget = main.scrollView + main.scrollView.onTile = { [weak self] in self?.layoutFrozenPane() } + observeScrolling() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isFlipped: Bool { true } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + needsLayout = true + } + + override func layout() { + super.layout() + if main.scrollView.frame != bounds { + main.scrollView.frame = bounds + } + controller?.containerDidLayout() + layoutFrozenPane() + } + + // Under legacy scrollers the frozen pane stops above the main pane's horizontal scroller, so both clips are one height. + func layoutFrozenPane() { + let width = min(frozenPaneWidth, bounds.width) + frozen.scrollView.isHidden = width <= 0 + guard width > 0 else { return } + let height = max(0, bounds.height - main.scrollView.legacyHorizontalScrollerHeight) + let frame = NSRect(x: 0, y: 0, width: width, height: height) + if frozen.scrollView.frame != frame { + frozen.scrollView.frame = frame + } + syncVertical(from: main.scrollView, to: frozen.scrollView) + } + + func pane(of tableView: NSTableView) -> DataGridPane? { + if tableView === main.tableView { return main } + if tableView === frozen.tableView { return frozen } + return nil + } + + func pane(_ kind: DataGridPane.Kind) -> DataGridPane { + kind == .main ? main : frozen + } + + // The pane under a point in this view's coordinates. + func pane(at point: NSPoint) -> DataGridPane { + if !frozen.scrollView.isHidden, frozen.scrollView.frame.contains(point) { + return frozen + } + return main + } + + // MARK: - Vertical sync + + private func observeScrolling() { + let center = NotificationCenter.default + let mainClip = main.scrollView.contentView + let frozenClip = frozen.scrollView.contentView + scrollObservers = [ + center.addObserver(forName: NSView.boundsDidChangeNotification, object: mainClip, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.syncVertical(from: self.main.scrollView, to: self.frozen.scrollView) + self.controller?.mainViewportDidMove() + } + }, + center.addObserver(forName: NSView.boundsDidChangeNotification, object: frozenClip, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.syncVertical(from: self.frozen.scrollView, to: self.main.scrollView) + } + }, + center.addObserver(forName: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.needsLayout = true } + }, + ] + } + + // clipView.scroll(to:) is right here: only y moves, so the header clip, which follows x, never drifts. + private func syncVertical(from source: NSScrollView, to target: NSScrollView) { + guard !isSyncingScroll, !target.isHidden || target === main.scrollView else { return } + let y = source.contentView.bounds.origin.y + let clip = target.contentView + guard clip.bounds.origin.y != y else { return } + isSyncingScroll = true + clip.scroll(to: NSPoint(x: clip.bounds.origin.x, y: y)) + target.reflectScrolledClipView(clip) + isSyncingScroll = false + } + + func tearDown() { + scrollObservers.forEach(NotificationCenter.default.removeObserver) + scrollObservers.removeAll() + stopObservingWindow() + } + + // MARK: - Focus + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + stopObservingWindow() + guard let window else { + refreshFocus() + return + } + for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification] { + windowObservers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refreshFocus() } + }) + } + // AppKit posts no first-responder notification but does notify KVO by hand. + firstResponderObservation = window.observe(\.firstResponder, options: [.initial, .new]) { [weak self] _, _ in + MainActor.assumeIsolated { self?.refreshFocus() } + } + controller?.containerDidMoveToWindow() + } + + private func stopObservingWindow() { + windowObservers.forEach(NotificationCenter.default.removeObserver) + windowObservers.removeAll() + firstResponderObservation = nil + } + + // The main table or a field editor inside either pane holds focus, in the key window. + func refreshFocus() { + let hasFocus: Bool + if let window, window.isKeyWindow, let responder = window.firstResponder as? NSView { + hasFocus = responder === main.tableView || responder.isDescendant(of: self) + } else { + hasFocus = false + } + guard hasFocus != gridHasFocus else { return } + gridHasFocus = hasFocus + controller?.gridFocusDidChange() + } + + // MARK: - Accessibility + + override func accessibilityHitTest(_ point: NSPoint) -> Any? { + guard let window, !frozen.scrollView.isHidden else { return super.accessibilityHitTest(point) } + let local = convert(window.convertPoint(fromScreen: point), from: nil) + guard frozen.scrollView.frame.contains(local) else { return super.accessibilityHitTest(point) } + return controller?.accessibilityElement(atFrozenPanePoint: frozen.tableView.convert(local, from: self)) + ?? super.accessibilityHitTest(point) + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridFindBar.swift b/apple/TablePro/Views/DataGrid/DataGridFindBar.swift new file mode 100644 index 0000000000..4e5f950bc2 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridFindBar.swift @@ -0,0 +1,281 @@ +import AppKit +import Combine +import SwiftUI + +struct DataGridFindBar: View { + private struct Capabilities: Equatable { + var canReplace = false + var offersSearchAllRows = false + } + + let model: DataGridModel + @ObservedObject private var find: GridFindState + @State private var capabilities = Capabilities() + @State private var pendingSearch: Task? + @State private var status: String? + @State private var focusRequest = 0 + + init(model: DataGridModel) { + self.model = model + _find = ObservedObject(wrappedValue: model.find) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { + findRow + if find.showsReplace, capabilities.canReplace { + replaceRow + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + Divider() + } + // The search field clears on the first Escape and hands the second on, which closes the bar. + .onExitCommand { model.hideFind() } + // Find while the bar is already open moves focus back to its field. + .onReceive(find.$isVisible.dropFirst()) { isVisible in + if isVisible { focusRequest += 1 } + } + // Configuration changes bump this revision; the bar observes nothing else on the model. + .onReceive(model.$editabilityRevision) { _ in + capabilities = Capabilities(canReplace: model.canReplaceInFind, offersSearchAllRows: model.offersSearchAllRows) + } + } + + // MARK: - Rows + + private var findRow: some View { + HStack(spacing: 8) { + // A SwiftUI field's @FocusState set as the bar appears never reaches the window's first responder here (CI). + NativeSearchField( + text: termBinding, + placeholder: String(localized: "Find"), + onSubmit: { step(forward: NSApp.currentEvent?.modifierFlags.contains(.shift) != true) }, + focusOnAppear: true, + focusTrigger: focusRequest, + accessibilityIdentifier: "grid-find-field" + ) + .frame(minWidth: 160, maxWidth: 320) + + optionsMenu + + ControlGroup { + Button { + step(forward: false) + } label: { + Label(String(localized: "Previous match"), systemImage: "chevron.left") + } + .help(String(localized: "Previous match")) + .accessibilityIdentifier("grid-find-previous") + + Button { + step(forward: true) + } label: { + Label(String(localized: "Next match"), systemImage: "chevron.right") + } + .help(String(localized: "Next match")) + .accessibilityIdentifier("grid-find-next") + } + .controlGroupStyle(.navigation) + .labelStyle(.iconOnly) + .fixedSize() + .disabled(find.matches.isEmpty) + + Text(counterText) + .font(.callout) + .foregroundStyle(find.problem == nil || status != nil ? Color.secondary : Color(nsColor: .systemRed)) + .monospacedDigit() + .lineLimit(1) + .accessibilityLabel(counterText) + .accessibilityIdentifier("grid-find-count") + + scopeButton + + Spacer(minLength: 8) + + if showsSearchAllRows { + Button(String(localized: "Search All Rows")) { model.searchAllRows() } + .accessibilityIdentifier("grid-find-search-all-rows") + } + + if capabilities.canReplace { + Toggle(String(localized: "Replace"), isOn: $find.showsReplace) + .toggleStyle(.checkbox) + .accessibilityIdentifier("grid-find-replace-toggle") + } + + Button(String(localized: "Done")) { model.hideFind() } + .accessibilityIdentifier("grid-find-done") + } + } + + private var replaceRow: some View { + HStack(spacing: 8) { + TextField(String(localized: "Replace with"), text: $find.replacement) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 160, maxWidth: 320) + .accessibilityLabel(String(localized: "Replace with")) + .accessibilityIdentifier("grid-replace-field") + + Button(String(localized: "Replace")) { replaceCurrent() } + .disabled(!model.canReplaceCurrentFindMatch) + .accessibilityIdentifier("grid-replace") + + Button(String(localized: "Replace All")) { replaceAll() } + .disabled(find.matches.isEmpty || find.problem != nil) + .accessibilityIdentifier("grid-replace-all") + + Spacer() + } + } + + private var optionsMenu: some View { + Menu { + Toggle(String(localized: "Match Case"), isOn: option(\.matchesCase)) + Toggle(String(localized: "Whole Words"), isOn: option(\.matchesWholeWord)) + Toggle(String(localized: "Regular Expression"), isOn: option(\.usesRegularExpression)) + Divider() + Picker(String(localized: "Search In"), selection: scopeBinding) { + Text(String(localized: "All Columns")).tag(DataGridColumnID?.none) + ForEach(model.columns.columns, id: \.id) { column in + Text(column.name).tag(DataGridColumnID?.some(column.id)) + } + } + } label: { + Label(String(localized: "Find Options"), systemImage: "slider.horizontal.3") + } + .menuStyle(.button) + .buttonStyle(.borderless) + .menuIndicator(.hidden) + .labelStyle(.iconOnly) + .fixedSize() + .help(String(localized: "Find Options")) + .accessibilityLabel(String(localized: "Find Options")) + .accessibilityIdentifier("grid-find-options") + } + + @ViewBuilder + private var scopeButton: some View { + if let scope = find.options.columnScope, let column = model.columns.column(id: scope) { + Button { + setScope(nil) + } label: { + Label(String(format: String(localized: "In column %@"), column.name), systemImage: "xmark.circle.fill") + } + .buttonStyle(.borderless) + .help(String(localized: "Search every column")) + } + } + + // MARK: - State + + private var counterText: String { + if let status { return status } + if let problem = find.problem { return problem } + guard model.hasFindTerm else { return "" } + // A search that refreshes keeps showing the count it is about to replace. + if find.matches.isEmpty { + if find.isSearching { return String(localized: "Searching…") } + if find.searchStopped { return String(localized: "Search stopped") } + } + return FindCounterText.text( + matchCount: find.matches.count, + currentIndex: find.currentIndex, + scope: model.findCoversPageOnly ? .pageOfMore : .allRowsLoaded + ) + } + + // Only when this page has no match, so the bar stays short while matches are on screen. + private var showsSearchAllRows: Bool { + capabilities.offersSearchAllRows && model.searchAllRowsKeepsTheOptions && model.hasFindTerm + && find.problem == nil && find.matches.isEmpty + } + + // Use Selection for Find searches on its own, so only typing goes through the delay. + private var termBinding: Binding { + Binding( + get: { find.term }, + set: { newValue in + guard newValue != find.term else { return } + find.term = newValue + status = nil + scheduleSearch() + } + ) + } + + private var scopeBinding: Binding { + Binding( + get: { find.options.columnScope }, + set: { setScope($0) } + ) + } + + private func option(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { find.options[keyPath: keyPath] }, + set: { newValue in + find.options[keyPath: keyPath] = newValue + searchNow() + } + ) + } + + // MARK: - Actions + + private func setScope(_ scope: DataGridColumnID?) { + find.options.columnScope = scope + searchNow() + } + + // One scan per pause in typing, not per key: a wide page is hundreds of thousands of cells. + private func scheduleSearch() { + pendingSearch?.cancel() + pendingSearch = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(120)) + guard !Task.isCancelled else { return } + pendingSearch = nil + model.findQueryDidChange() + } + } + + private func searchNow() { + pendingSearch?.cancel() + pendingSearch = nil + status = nil + model.findQueryDidChange() + } + + // A search still waiting on the delay already lands on the first match from the cursor. + private func step(forward: Bool) { + status = nil + if pendingSearch != nil { + searchNow() + if forward { return } + } + if forward { + model.findNext() + } else { + model.findPrevious() + } + } + + private func replaceCurrent() { + if pendingSearch != nil { + searchNow() + } + status = nil + model.replaceCurrentMatch() + } + + // A dataset finder reports its outcome in its own window, so nothing comes back here. + private func replaceAll() { + pendingSearch?.cancel() + pendingSearch = nil + status = nil + guard let outcome = model.replaceAllMatches() else { return } + status = GridFindEngine.replacementMessage(replacements: outcome.replacements, cells: outcome.cells) + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridHeaderView.swift b/apple/TablePro/Views/DataGrid/DataGridHeaderView.swift new file mode 100644 index 0000000000..c2812f2e9e --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridHeaderView.swift @@ -0,0 +1,33 @@ +// +// DataGridHeaderView.swift +// TablePro +// + +import AppKit + +// The stock header. It overrides only the menu, resolved from the event, and the per-step repaint drawn cells need. +@MainActor +final class DataGridHeaderView: NSTableHeaderView { + var paneKind: DataGridPane.Kind = .main + private var bodyFollowsColumnDrag = false + + private var controller: DataGridViewController? { + (tableView as? DataGridTableView)?.controller + } + + override func menu(for event: NSEvent) -> NSMenu? { + guard let tableView else { return nil } + let index = column(at: convert(event.locationInWindow, from: nil)) + guard index >= 0, index < tableView.tableColumns.count else { return nil } + return controller?.headerMenu(forTableColumn: tableView.tableColumns[index], in: paneKind) + } + + // AppKit redraws the header on every step of a resize or reorder drag but posts the column notifications only at mouse-up. + override func viewWillDraw() { + super.viewWillDraw() + let isDragging = resizedColumn >= 0 || draggedColumn >= 0 + guard isDragging || bodyFollowsColumnDrag else { return } + bodyFollowsColumnDrag = isDragging + controller?.headerGeometryDidStep(in: paneKind) + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridModel+ColumnGeometry.swift b/apple/TablePro/Views/DataGrid/DataGridModel+ColumnGeometry.swift new file mode 100644 index 0000000000..fbc39d7465 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridModel+ColumnGeometry.swift @@ -0,0 +1,27 @@ +// +// DataGridModel+ColumnGeometry.swift +// TablePro +// + +import Foundation + +extension DataGridModel { + // A resize, move or size-to-fit the user made in the grid. Widths given here are the user's, so they are recorded as + // owned; the order splices into the stored one so a hidden column keeps its slot. + func columnGeometryDidChange(widths: [String: CGFloat], order: [String]?) { + guard !widths.isEmpty || order != nil else { return } + updateColumnLayout { layout in + var captured = layout + captured.columnWidths.merge(widths) { _, new in new } + if !widths.isEmpty { + var owned = captured.columnContentWidths ?? [:] + owned.merge(widths) { _, new in new } + captured.columnContentWidths = owned + } + if let order { + captured.columnOrder = order + } + layout.applyGeometry(from: captured) + } + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridPane.swift b/apple/TablePro/Views/DataGrid/DataGridPane.swift new file mode 100644 index 0000000000..a604d916f9 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridPane.swift @@ -0,0 +1,196 @@ +// +// DataGridPane.swift +// TablePro +// + +import AppKit + +// One table of the grid: the main pane holds every column, the frozen pane the row numbers and frozen columns laid over it. +@MainActor +final class DataGridPane { + enum Kind { + case main + case frozen + } + + enum Slot: Equatable { + case rowNumber + case data(DataGridColumnID) + } + + static let rowNumberIdentifier = NSUserInterfaceItemIdentifier("DataGrid.rowNumber") + + let kind: Kind + let scrollView: DataGridScrollView + let tableView: DataGridTableView + let headerView: DataGridHeaderView + + private var columnsByID: [DataGridColumnID: NSTableColumn] = [:] + private var idByIdentifier: [NSUserInterfaceItemIdentifier: DataGridColumnID] = [:] + private var rowNumberColumn: NSTableColumn? + private var slotsByTableIndex: [Slot]? + private var tableIndexByDisplayPosition: [Int: Int]? + + init(kind: Kind) { + let table = DataGridTableView(frame: .zero) + table.paneKind = kind + Self.configure(table, kind: kind) + // Read after the style is set: the stock header's height follows the table style. + let header = DataGridHeaderView(frame: table.headerView?.frame ?? NSRect(x: 0, y: 0, width: 0, height: 28)) + header.paneKind = kind + table.headerView = header + if kind == .frozen { + table.cornerView = nil + } + let scroll = DataGridScrollView(kind: kind) + scroll.documentView = table + self.kind = kind + tableView = table + headerView = header + scrollView = scroll + } + + private static func configure(_ tableView: DataGridTableView, kind: Kind) { + tableView.style = .plain + tableView.wantsLayer = true + tableView.layerContentsRedrawPolicy = .onSetNeedsDisplay + tableView.allowsMultipleSelection = true + tableView.allowsEmptySelection = true + tableView.allowsColumnSelection = false + tableView.allowsColumnReordering = true + tableView.allowsColumnResizing = true + tableView.allowsTypeSelect = kind == .main + tableView.columnAutoresizingStyle = .noColumnAutoresizing + tableView.gridStyleMask = [] + tableView.intercellSpacing = NSSize(width: 1, height: 0) + tableView.rowSizeStyle = .custom + tableView.usesAutomaticRowHeights = false + switch kind { + case .main: + tableView.setAccessibilityIdentifier("data-grid") + tableView.setAccessibilityLabel(String(localized: "Data grid")) + tableView.setAccessibilityRole(.table) + case .frozen: + tableView.setAccessibilityIdentifier("data-grid-frozen") + tableView.setAccessibilityElement(false) + tableView.verticalMotionCanBeginDrag = false + } + } + + var isMain: Bool { kind == .main } + + // MARK: - Columns + + func columnID(of column: NSTableColumn) -> DataGridColumnID? { + idByIdentifier[column.identifier] + } + + func isRowNumber(_ column: NSTableColumn) -> Bool { + column.identifier == Self.rowNumberIdentifier + } + + func tableColumn(for id: DataGridColumnID) -> NSTableColumn? { + columnsByID[id] + } + + var hasRowNumberColumn: Bool { + tableView.tableColumns.first.map(isRowNumber) ?? false + } + + var rowNumberTableColumn: NSTableColumn? { + hasRowNumberColumn ? tableView.tableColumns.first : nil + } + + // Reuses the NSTableColumn each column already had, so a re-run of the same result does not flicker (#951). + func pooledColumn(for id: DataGridColumnID) -> NSTableColumn { + if let existing = columnsByID[id] { return existing } + let identifier = NSUserInterfaceItemIdentifier("DataGrid.column.\(id.stringValue)") + let column = NSTableColumn(identifier: identifier) + column.minWidth = DataGridMetrics.dataColumnMinWidth + column.maxWidth = DataGridMetrics.dataColumnMaxWidth + column.resizingMask = .userResizingMask + columnsByID[id] = column + idByIdentifier[identifier] = id + return column + } + + func pooledRowNumberColumn() -> NSTableColumn { + if let rowNumberColumn { return rowNumberColumn } + let column = NSTableColumn(identifier: Self.rowNumberIdentifier) + column.title = "#" + column.isEditable = false + column.resizingMask = [] + let headerCell = column.headerCell + headerCell.alignment = .right + headerCell.setAccessibilityLabel(String(localized: "Row number")) + rowNumberColumn = column + return column + } + + func forgetColumns(except kept: Set) { + for (id, column) in columnsByID where !kept.contains(id) { + columnsByID.removeValue(forKey: id) + idByIdentifier.removeValue(forKey: column.identifier) + } + } + + // MARK: - Slots + + func invalidateSlots() { + slotsByTableIndex = nil + tableIndexByDisplayPosition = nil + } + + // Read by every drawn cell, so it is built once per layout and rebuilt only when a column moves. + func slot(atTableIndex index: Int, columns: DataGridColumnSet) -> Slot? { + let slots = resolvedSlots(columns: columns) + guard index >= 0, index < slots.count else { return nil } + return slots[index] + } + + func tableIndex(forDisplayPosition position: Int, columns: DataGridColumnSet) -> Int? { + if tableIndexByDisplayPosition == nil { + rebuildSlots(columns: columns) + } + return tableIndexByDisplayPosition?[position] + } + + private func resolvedSlots(columns: DataGridColumnSet) -> [Slot] { + if let slotsByTableIndex { return slotsByTableIndex } + rebuildSlots(columns: columns) + return slotsByTableIndex ?? [] + } + + private func rebuildSlots(columns: DataGridColumnSet) { + var slots: [Slot] = [] + var positions: [Int: Int] = [:] + slots.reserveCapacity(tableView.tableColumns.count) + for (index, column) in tableView.tableColumns.enumerated() { + if isRowNumber(column) { + slots.append(.rowNumber) + } else if let id = idByIdentifier[column.identifier] { + slots.append(.data(id)) + if let position = columns.displayPosition(of: id) { + positions[position] = index + } + } else { + slots.append(.rowNumber) + } + } + slotsByTableIndex = slots + tableIndexByDisplayPosition = positions + } + + // The display positions this pane shows, in its own column order. + func displayPositions(columns: DataGridColumnSet) -> [Int] { + resolvedSlots(columns: columns).compactMap { slot in + guard case .data(let id) = slot else { return nil } + return columns.displayPosition(of: id) + } + } + + var columnsWidth: CGFloat { + guard let last = tableView.tableColumns.indices.last else { return 0 } + return tableView.rect(ofColumn: last).maxX + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridRowView.swift b/apple/TablePro/Views/DataGrid/DataGridRowView.swift new file mode 100644 index 0000000000..11175a1b10 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridRowView.swift @@ -0,0 +1,162 @@ +// +// DataGridRowView.swift +// TablePro +// + +import AppKit + +// Draws its pane's cells: NSTableView mounts a view for every column of every prepared row, which at 200 columns +// costs 45 ms a frame against 1.5 ms drawn (probe P1). +@MainActor +final class DataGridRowView: NSTableRowView { + weak var controller: DataGridViewController? + weak var gridTable: DataGridTableView? + + private let contentView = DataGridRowContentView() + + // The selection mixes AppKit's row emphasis with the grid's own focus rule, so both panes agree (probe P7, critic M4). + override var isEmphasized: Bool { + get { super.isEmphasized } + set { + let resolved = controller?.gridHasFocus ?? newValue + guard resolved != super.isEmphasized else { return } + super.isEmphasized = resolved + redrawCells() + } + } + + override var isSelected: Bool { + didSet { + guard isSelected != oldValue else { return } + redrawCells() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layerContentsRedrawPolicy = .onSetNeedsDisplay + canDrawSubviewsIntoLayer = true + contentView.rowView = self + contentView.autoresizingMask = [.width, .height] + contentView.frame = bounds + addSubview(contentView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func makeBackingLayer() -> CALayer { + let layer = super.makeBackingLayer() + layer.actions = Self.disabledLayerActions + return layer + } + + private static let disabledLayerActions: [String: any CAAction] = [ + "position": NSNull(), + "bounds": NSNull(), + "frame": NSNull(), + "contents": NSNull(), + "hidden": NSNull(), + ] + + // Asked of the table rather than remembered: insertRows and removeRows move a row view without asking for it again. + var rowIndex: Int { + gridTable?.row(for: self) ?? -1 + } + + func redrawCells() { + needsDisplay = true + contentView.needsDisplay = true + } + + func redrawCells(inTableRect rect: NSRect) { + guard let gridTable else { + redrawCells() + return + } + let local = convert(rect, from: gridTable) + let band = NSRect(x: local.minX, y: bounds.minY, width: local.width, height: bounds.height) + setNeedsDisplay(band) + contentView.setNeedsDisplay(band) + } + + // MARK: - Drawing + + override func drawBackground(in dirtyRect: NSRect) { + guard let controller, let gridTable else { + super.drawBackground(in: dirtyRect) + return + } + let row = rowIndex + let scope = controller.model.themeScope + DataGridBodyChrome.fillRowBackground(dirtyRect, row: row, of: gridTable, scope: scope) + if let tint = controller.rowTint(forRow: row) { + tint.setFill() + bounds.intersection(dirtyRect).fill(using: .sourceOver) + } + } + + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected, let controller, let gridTable else { return } + let row = rowIndex + let color: NSColor = isEmphasized ? .selectedContentBackgroundColor : .unemphasizedSelectedContentBackgroundColor + color.setFill() + for rect in controller.selectionRects(forRow: row, in: gridTable, within: convert(dirtyRect, to: gridTable)) { + let local = convert(rect, from: gridTable) + let band = NSRect(x: local.minX, y: bounds.minY, width: local.width, height: bounds.height) + band.intersection(dirtyRect).fill() + } + } + + fileprivate func drawContent(in dirtyRect: NSRect, of view: NSView) { + guard let controller, let gridTable else { return } + controller.drawCells(ofRow: rowIndex, in: dirtyRect, of: view, table: gridTable, isEmphasized: isEmphasized) + } + + // MARK: - Hit testing + + // Data cells have no views, so a press goes to the table; a mounted edit field still takes its own clicks. + override func hitTest(_ point: NSPoint) -> NSView? { + let hit = super.hitTest(point) + return hit === self || hit === contentView ? nil : hit + } + + // MARK: - Accessibility + + override func accessibilityRole() -> NSAccessibility.Role? { .row } + + // The row answers for every point no cell covers, so a client never lands on a view outside the tree. + override func accessibilityHitTest(_ point: NSPoint) -> Any? { + DataGridAccessibility.markActive() + guard let window else { return super.accessibilityHitTest(point) } + let local = convert(window.convertPoint(fromScreen: point), from: nil) + for subview in subviews where subview !== contentView && subview.frame.contains(local) { + if let hit = subview.accessibilityHitTest(point) { return hit } + } + return bounds.contains(local) ? self : nil + } +} + +// Its own view so the cells land after the row's background and selection, one view per row whatever the column count. +@MainActor +final class DataGridRowContentView: NSView { + weak var rowView: DataGridRowView? + + override var isFlipped: Bool { true } + override var allowsVibrancy: Bool { false } + + override func isAccessibilityElement() -> Bool { false } + + override func accessibilityHitTest(_ point: NSPoint) -> Any? { + rowView?.accessibilityHitTest(point) ?? super.accessibilityHitTest(point) + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func draw(_ dirtyRect: NSRect) { + rowView?.drawContent(in: dirtyRect, of: self) + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridScrollView.swift b/apple/TablePro/Views/DataGrid/DataGridScrollView.swift new file mode 100644 index 0000000000..99afcdf289 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridScrollView.swift @@ -0,0 +1,63 @@ +// +// DataGridScrollView.swift +// TablePro +// + +import AppKit + +@MainActor +final class DataGridScrollView: NSScrollView { + let paneKind: DataGridPane.Kind + var onTile: (() -> Void)? + // The frozen pane hands every wheel event to the main pane, so one momentum engine moves both. + weak var wheelTarget: NSScrollView? + + init(kind: DataGridPane.Kind) { + paneKind = kind + super.init(frame: .zero) + borderType = .noBorder + autohidesScrollers = true + contentView.wantsLayer = true + contentView.layerContentsRedrawPolicy = .onSetNeedsDisplay + contentView.postsBoundsChangedNotifications = true + switch kind { + case .main: + hasVerticalScroller = true + hasHorizontalScroller = true + case .frozen: + hasVerticalScroller = false + hasHorizontalScroller = false + horizontalScrollElasticity = .none + setAccessibilityElement(false) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func tile() { + super.tile() + onTile?() + } + + override func scrollWheel(with event: NSEvent) { + guard let wheelTarget else { + super.scrollWheel(with: event) + return + } + wheelTarget.scrollWheel(with: event) + } + + // The frozen pane repeats cells the main table already publishes, so VoiceOver sees one table. + override func accessibilityChildren() -> [Any]? { + paneKind == .frozen ? [] : super.accessibilityChildren() + } + + var legacyHorizontalScrollerHeight: CGFloat { + guard scrollerStyle == .legacy, hasHorizontalScroller, + let scroller = horizontalScroller, !scroller.isHidden else { return 0 } + return scroller.frame.height + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridTableView.swift b/apple/TablePro/Views/DataGrid/DataGridTableView.swift new file mode 100644 index 0000000000..d1edf45a3e --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridTableView.swift @@ -0,0 +1,283 @@ +// +// DataGridTableView.swift +// TablePro +// + +import AppKit + +// One class for both panes. Behavior comes from NSResponder actions the standard key bindings produce, never key codes. +@MainActor +final class DataGridTableView: NSTableView { + weak var controller: DataGridViewController? + var paneKind: DataGridPane.Kind = .main + + private static let chordModifiers: NSEvent.ModifierFlags = [.command, .shift, .option, .control] + + // The main table is the grid's one first responder; a press in the frozen pane hands focus to it. + override var acceptsFirstResponder: Bool { + paneKind == .main + } + + // MARK: - Drawing + + override func drawBackground(inClipRect clipRect: NSRect) { + let scope = controller?.model.themeScope ?? .live + DataGridBodyChrome.drawTableBackground(in: clipRect, of: self, scope: scope) + let lastRowBottom = numberOfRows > 0 ? rect(ofRow: numberOfRows - 1).maxY : bounds.minY + let belowRows = clipRect.intersection(NSRect(x: clipRect.minX, y: lastRowBottom, width: clipRect.width, height: bounds.height)) + guard !belowRows.isEmpty else { return } + DataGridBodyChrome.drawColumnSeparators(in: belowRows, of: self, tableView: self) + if paneKind == .frozen { + controller?.drawFrozenPaneEdge(in: belowRows, of: self) + } + } + + // AppKit sizes the table to its columns; widening the proposed frame keeps the header and rows over the trailing space. + override func setFrameSize(_ newSize: NSSize) { + guard paneKind == .main, let last = tableColumns.indices.last else { + super.setFrameSize(newSize) + return + } + let trailingExtent = rect(ofColumn: last).maxX + DataGridMetrics.trailingSpace + super.setFrameSize(NSSize(width: max(newSize.width, trailingExtent), height: newSize.height)) + } + + // AppKit reorders tableColumns on each pass of a header drag but posts columnDidMove only at mouse-up. + override func moveColumn(_ oldIndex: Int, toColumn newIndex: Int) { + super.moveColumn(oldIndex, toColumn: newIndex) + controller?.tableDidMoveColumn(self) + } + + // A revealed column must never park under the frozen pane, so the rect widens leftward by its width. + override func scrollToVisible(_ rect: NSRect) -> Bool { + guard paneKind == .main, let controller else { return super.scrollToVisible(rect) } + return super.scrollToVisible(controller.revealRect(for: rect, in: self)) + } + + // MARK: - Keyboard + + override func keyDown(with event: NSEvent) { + guard paneKind == .main, let controller else { + super.keyDown(with: event) + return + } + if controller.handleGridKey(event) { return } + if routesThroughKeyBindings(event) { + interpretKeyEvents([event]) + return + } + super.keyDown(with: event) + } + + // NSTableView.keyDown never calls interpretKeyEvents and leaves these keys unanswered (probe P3). + private func routesThroughKeyBindings(_ event: NSEvent) -> Bool { + guard let specialKey = event.specialKey else { return false } + let modifiers = event.modifierFlags.intersection(Self.chordModifiers) + switch specialKey { + case .tab, .backTab: + // Control-Tab leaves the table through the key view loop. + return !modifiers.contains(.control) + case .carriageReturn, .newline, .enter, .delete, .deleteForward, .leftArrow, .rightArrow: + return true + case .upArrow, .downArrow: + return modifiers.contains(.shift) && controller?.selectionIsCellRange == true + default: + return false + } + } + + override func moveLeft(_ sender: Any?) { + controller?.moveSelection(.left, extending: false, toEdge: false) + } + + override func moveRight(_ sender: Any?) { + controller?.moveSelection(.right, extending: false, toEdge: false) + } + + override func moveWordLeft(_ sender: Any?) { + controller?.moveSelection(.left, extending: false, toEdge: false) + } + + override func moveWordRight(_ sender: Any?) { + controller?.moveSelection(.right, extending: false, toEdge: false) + } + + override func moveLeftAndModifySelection(_ sender: Any?) { + controller?.moveSelection(.left, extending: true, toEdge: false) + } + + override func moveRightAndModifySelection(_ sender: Any?) { + controller?.moveSelection(.right, extending: true, toEdge: false) + } + + override func moveWordLeftAndModifySelection(_ sender: Any?) { + controller?.moveSelection(.left, extending: true, toEdge: false) + } + + override func moveWordRightAndModifySelection(_ sender: Any?) { + controller?.moveSelection(.right, extending: true, toEdge: false) + } + + override func moveUpAndModifySelection(_ sender: Any?) { + controller?.moveSelection(.up, extending: true, toEdge: false) + } + + override func moveDownAndModifySelection(_ sender: Any?) { + controller?.moveSelection(.down, extending: true, toEdge: false) + } + + override func moveToBeginningOfDocumentAndModifySelection(_ sender: Any?) { + controller?.moveSelection(.up, extending: true, toEdge: true) + } + + override func moveToEndOfDocumentAndModifySelection(_ sender: Any?) { + controller?.moveSelection(.down, extending: true, toEdge: true) + } + + override func moveToBeginningOfLine(_ sender: Any?) { + controller?.moveToColumnEdge(last: false, extending: false) + } + + override func moveToEndOfLine(_ sender: Any?) { + controller?.moveToColumnEdge(last: true, extending: false) + } + + override func moveToLeftEndOfLine(_ sender: Any?) { + controller?.moveToColumnEdge(last: false, extending: false) + } + + override func moveToRightEndOfLine(_ sender: Any?) { + controller?.moveToColumnEdge(last: true, extending: false) + } + + override func moveToBeginningOfLineAndModifySelection(_ sender: Any?) { + controller?.moveToColumnEdge(last: false, extending: true) + } + + override func moveToEndOfLineAndModifySelection(_ sender: Any?) { + controller?.moveToColumnEdge(last: true, extending: true) + } + + override func moveToLeftEndOfLineAndModifySelection(_ sender: Any?) { + controller?.moveToColumnEdge(last: false, extending: true) + } + + override func moveToRightEndOfLineAndModifySelection(_ sender: Any?) { + controller?.moveToColumnEdge(last: true, extending: true) + } + + override func insertTab(_ sender: Any?) { + guard controller?.tabToCell(forward: true) == true else { + window?.selectKeyView(following: self) + return + } + } + + override func insertBacktab(_ sender: Any?) { + guard controller?.tabToCell(forward: false) == true else { + window?.selectKeyView(preceding: self) + return + } + } + + override func insertNewline(_ sender: Any?) { + controller?.beginEditingActiveCell() + } + + override func cancelOperation(_ sender: Any?) { + guard controller?.cancelSelection() == true else { + super.cancelOperation(sender) + return + } + } + + override func deleteBackward(_ sender: Any?) { + controller?.performDeleteCommand() + } + + override func deleteForward(_ sender: Any?) { + controller?.performDeleteCommand() + } + + override func selectAll(_ sender: Any?) { + controller?.performSelectAll(sender) + } + + override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + if let action = item.action, + let enabled = FontSizeZoom.dataGrid.enablement(action, typography: AppSettingsManager.shared.typography) { + return enabled + } + if item.action == #selector(selectAll(_:)) { + return controller?.validateGridCommand(item) ?? false + } + return super.validateUserInterfaceItem(item) + } + + // MARK: - Mouse + + override func mouseDown(with event: NSEvent) { + guard let controller else { + super.mouseDown(with: event) + return + } + controller.tableMouseDown(self, with: event) + } + + // The controller hands a press back here when AppKit's own tracking is wanted, for a row reorder drag. + func performNativeMouseDown(with event: NSEvent) { + super.mouseDown(with: event) + } + + override func canDragRows(with rowIndexes: IndexSet, at mouseDownPoint: NSPoint) -> Bool { + guard paneKind == .frozen, let controller else { return false } + let column = column(at: mouseDownPoint) + let row = row(at: mouseDownPoint) + guard column >= 0, column < tableColumns.count, row >= 0, + tableColumns[column].identifier == DataGridPane.rowNumberIdentifier, + controller.canDragRows(fromRowNumberAt: row) else { return false } + return super.canDragRows(with: rowIndexes, at: mouseDownPoint) + } + + // MARK: - Accessibility + + override func accessibilityChildren() -> [Any]? { + guard paneKind == .main else { return [] } + DataGridAccessibility.markActive() + return super.accessibilityChildren() + } + + override func accessibilityCell(forColumn column: Int, row: Int) -> Any? { + guard paneKind == .main else { return nil } + DataGridAccessibility.markActive() + return view(atColumn: column, row: row, makeIfNecessary: false) as? DataGridAccessibilityCellView + ?? super.accessibilityCell(forColumn: column, row: row) + } + + override func accessibilitySelectedCells() -> [Any]? { + guard paneKind == .main else { return [] } + return controller?.accessibilitySelectedCells() ?? super.accessibilitySelectedCells() + } + + // A point inside a row but outside every column resolves to the row, not the table, so the row stays reachable there. + override func accessibilityHitTest(_ point: NSPoint) -> Any? { + DataGridAccessibility.markActive() + guard let window else { return super.accessibilityHitTest(point) } + let local = convert(window.convertPoint(fromScreen: point), from: nil) + let index = row(at: local) + guard index >= 0, let rowView = rowView(atRow: index, makeIfNecessary: false) else { + return super.accessibilityHitTest(point) + } + return rowView.accessibilityHitTest(point) ?? rowView + } +} + +extension DataGridTableView: ZoomCommandResponding { + @objc func zoomIn(_ sender: Any?) { + FontSizeZoom.dataGrid.zoom(by: 1) + } + + @objc func zoomOut(_ sender: Any?) { + FontSizeZoom.dataGrid.zoom(by: -1) + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridView.swift b/apple/TablePro/Views/DataGrid/DataGridView.swift new file mode 100644 index 0000000000..1ed325eb32 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridView.swift @@ -0,0 +1,51 @@ +// +// DataGridView.swift +// TablePro +// + +import AppKit +import SwiftUI + +struct DataGridView: View { + @ObservedObject var model: DataGridModel + + init(model: DataGridModel) { + self.model = model + } + + var body: some View { + VStack(spacing: 0) { + DataGridFindBarSlot(model: model, find: model.find) + DataGridRepresentable(model: model) + } + } +} + +// Observes the find state alone, so typing in the find field never re-renders the grid. +private struct DataGridFindBarSlot: View { + let model: DataGridModel + @ObservedObject var find: GridFindState + + var body: some View { + if find.isVisible { + DataGridFindBar(model: model) + } + } +} + +struct DataGridRepresentable: NSViewControllerRepresentable { + let model: DataGridModel + + func makeNSViewController(context: Context) -> DataGridViewController { + DataGridViewController(model: model) + } + + func updateNSViewController(_ controller: DataGridViewController, context: Context) { + controller.adopt(model) + } + + // Commits an open edit before the grid goes, which covers a result-mode switch with no tab change. + static func dismantleNSViewController(_ controller: DataGridViewController, coordinator: ()) { + controller.dismantle() + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift new file mode 100644 index 0000000000..ab5f2b8bd8 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift @@ -0,0 +1,306 @@ +// +// DataGridViewController+Changes.swift +// TablePro +// + +import AppKit + +extension DataGridViewController { + // Drawn rows repaint through needsDisplay; reloadData(forRowIndexes:columnIndexes:) only mounts and unmounts a + // cell view, because reloading an edited row ends the edit (probe P1d, P2). + func apply(_ change: DataGridChange) { + editingWillApply(change) + // AppKit adjusts and announces its own selection on an insert or removal; the model's selection is the one that counts. + isMirroringSelection = true + switch change { + case .reload: + reloadAll() + applyStagedViewport() + case .columnsChanged: + columnsDidChange() + case .rowsInserted(let indexes): + insertRows(indexes) + case .rowsRemoved(let indexes): + removeRows(indexes) + case .rowsUpdated(let rows, let columns): + updateRows(rows, columns: columns) + } + // A moved row view keeps its layer's pixels, and its number and stripe come from the index it now holds. + redrawRows(change.movedRows(rowCount: model.rowCount)) + isMirroringSelection = false + mirrorSelection() + editingDidApply(change) + selectionDidMove() + selectionSummaryTracker.dataDidChange() + } + + func configurationDidChange() { + applyTableSettings() + let changed = presenter.refreshColumnPresentations() + reconcileColumns(widening: changed) + applyRowNumberWidth() + syncSortIndicators() + redrawVisibleRows() + } + + // MARK: - Structural changes + + func reloadAll() { + presenter.rebuildVisualIndex() + let changed = presenter.refreshColumnPresentations() + reconcileColumns(widening: changed) + applyRowNumberWidth() + for pane in panes { + pane.tableView.reloadData() + } + accessibilityMountedRows = NSRange(location: 0, length: 0) + drawnSelection = model.selection + // A view-based reloadData() drops the selection without telling anyone (probe P2), so it is written back. + mirrorSelection() + updateHighlightedColumns(model.selection) + syncSortIndicators() + startBackgroundPrewarm() + } + + private func columnsDidChange() { + presenter.rebuildVisualIndex() + let changed = presenter.refreshColumnPresentations() + reconcileColumns(widening: changed) + applyRowNumberWidth() + drawnSelection = model.selection + updateHighlightedColumns(model.selection) + syncSortIndicators() + redrawVisibleRows() + remountAccessibilityCells(force: true) + } + + // Both panes take the same insert in one call, so their document heights never differ. + private func insertRows(_ indexes: IndexSet) { + let target = model.rowCount + guard !indexes.isEmpty, + panes.allSatisfy({ $0.tableView.numberOfRows + indexes.count == target }) else { + reloadAll() + return + } + presenter.rebuildVisualIndex() + for pane in panes { + pane.tableView.insertRows(at: indexes, withAnimation: []) + } + applyRowNumberWidth() + startBackgroundPrewarm() + } + + private func removeRows(_ indexes: IndexSet) { + let target = model.rowCount + guard !indexes.isEmpty, + panes.allSatisfy({ $0.tableView.numberOfRows - indexes.count == target }) else { + reloadAll() + return + } + presenter.rebuildVisualIndex() + for pane in panes { + pane.tableView.removeRows(at: indexes, withAnimation: []) + } + applyRowNumberWidth() + } + + private func updateRows(_ rows: IndexSet, columns displayColumns: IndexSet?) { + let wholeRows = refreshRowState(rows, columns: displayColumns) + for pane in panes { + let table = pane.tableView + let rects = (displayColumns ?? []).compactMap { + pane.tableIndex(forDisplayPosition: $0, columns: model.columns).map(table.rect(ofColumn:)) + } + table.enumerateAvailableRowViews { rowView, row in + guard rows.contains(row), let rowView = rowView as? DataGridRowView else { return } + if wholeRows.contains(row) { + rowView.redrawCells() + } else { + rects.forEach(rowView.redrawCells(inTableRect:)) + } + } + } + } + + // Updates the rows' pending marks and answers which drawn rows repaint whole: a row tint and the row number span + // both panes, so only a row whose tint cannot have changed keeps to the changed columns. + func refreshRowState(_ rows: IndexSet, columns displayColumns: IndexSet?) -> IndexSet { + let current = model.rows + let drawn = rowsWithViews(in: rows) + let tintsBefore = drawn.map { rowTint(forRow: $0) } + presenter.updateVisualIndex(for: rows.compactMap { model.order.rowID(at: $0, in: current) }) + // The model drops a changed row's cached highlight before this runs, so a row rule's old wash is not known. + guard displayColumns != nil, !hasRowHighlightRules else { return drawn } + var retinted = IndexSet() + for (row, before) in zip(drawn, tintsBefore) where rowTint(forRow: row) != before { + retinted.insert(row) + } + return retinted + } + + private var hasRowHighlightRules: Bool { + model.dataSource?.gridHighlightRules.contains { $0.isEnabled && $0.target == .row } ?? false + } + + private func rowsWithViews(in rows: IndexSet) -> IndexSet { + var drawn = IndexSet() + for pane in panes { + pane.tableView.enumerateAvailableRowViews { _, row in + if rows.contains(row) { + drawn.insert(row) + } + } + } + return drawn + } + + // MARK: - Geometry + + // The setting is a floor under the grid font's line height; both panes take it in one call. + func applyRowHeight() { + let height = DataGridMetrics.rowHeight( + AppSettingsManager.shared.dataGrid.rowHeight, + font: ThemeEngine.shared.dataGridFonts.regular + ) + guard panes.contains(where: { $0.tableView.rowHeight != height }) else { return } + keepingFirstVisibleRow { + for pane in panes { + pane.tableView.rowHeight = height + pane.tableView.tile() + } + } + } + + // The clip view keeps its offset in points, so a new row height would otherwise bring other rows into view. + private func keepingFirstVisibleRow(_ change: () -> Void) { + let table = main.tableView + guard let sample = viewportSample(), table.rowHeight > 0 else { + change() + return + } + let previousHeight = table.rowHeight + change() + let offset = sample.offset * table.rowHeight / previousHeight + let rowOrigin = table.rect(ofRow: sample.row).minY + let x = main.scrollView.contentView.bounds.origin.x + table.scroll(NSPoint(x: x, y: rowOrigin + offset - headerInset)) + } + + var headerInset: CGFloat { + main.scrollView.contentView.contentInsets.top + } + + func viewportSample() -> (row: Int, offset: CGFloat)? { + let table = main.tableView + guard table.numberOfRows > 0 else { return nil } + var visible = table.visibleRect + visible.origin.y += headerInset + visible.size.height = max(0, visible.height - headerInset) + let rows = table.rows(in: visible) + guard rows.length > 0, rows.location < table.numberOfRows else { return nil } + let offset = visible.minY - table.rect(ofRow: rows.location).minY + return (rows.location, max(0, offset)) + } + + // MARK: - Viewport anchor + + func saveViewportAnchor() { + guard let sample = viewportSample(), let rowID = model.rowID(at: sample.row) else { return } + model.store.viewportAnchor = GridViewportPlacement( + firstVisibleRow: rowID, + firstVisibleOffset: sample.offset, + selectedRows: [], + revealsSelection: false + ) + } + + // A placement is one-shot: staged by the owner before a reload, or saved here on the way out. + func applyStagedViewport() { + guard let placement = model.store.viewportAnchor else { return } + guard main.scrollView.contentView.bounds.height > 0 else { + needsViewportRestore = true + return + } + model.store.viewportAnchor = nil + let table = main.tableView + let x = main.scrollView.contentView.bounds.origin.x + if let rowID = placement.firstVisibleRow, + let row = model.displayIndex(of: rowID), + row < table.numberOfRows { + table.scroll(NSPoint(x: x, y: table.rect(ofRow: row).minY + placement.firstVisibleOffset - headerInset)) + } else { + table.scroll(NSPoint(x: x, y: -headerInset)) + } + guard !placement.selectedRows.isEmpty else { return } + // Selecting publishes the model, which a SwiftUI update or layout pass refuses, so it runs on the next turn. + DispatchQueue.main.async { [weak self] in + guard let self, self.model.mountedView === self else { return } + self.model.selectRows(placement.selectedRows) + guard placement.revealsSelection, let row = self.model.selection.affectedRows.first else { return } + self.reveal(row: row, displayColumn: nil) + } + } + + // MARK: - Prewarm + + // Formats ±250 rows around the viewport in 2 ms slices, paused during live scroll, so a row draw is a cache hit. + func startBackgroundPrewarm() { + prewarmResumeTask?.cancel() + prewarmResumeTask = nil + prewarmTask?.cancel() + prewarmTask = Task { @MainActor [weak self] in + await self?.runBackgroundPrewarm() + } + } + + func pausePrewarm() { + prewarmResumeTask?.cancel() + prewarmResumeTask = nil + prewarmTask?.cancel() + prewarmTask = nil + } + + func schedulePrewarmResume() { + prewarmResumeTask?.cancel() + prewarmResumeTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled, let self else { return } + self.startBackgroundPrewarm() + } + } + + private func runBackgroundPrewarm() async { + var nextIndex: Int? + let budget = Duration.milliseconds(2) + while !Task.isCancelled { + let rows = model.rows + let order = model.order + let formats = model.store.displayFormats + let databaseType = model.configuration.traits.databaseType + let visible = main.tableView.rows(in: main.tableView.visibleRect) + let window = DataGridPrewarmWindow.rows( + around: max(0, visible.location)..= deadline { break } + } + nextIndex = index + await Task.yield() + } + } + + func mainViewportDidMove() { + remountAccessibilityCells() + schedulePrewarmResume() + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Columns.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Columns.swift new file mode 100644 index 0000000000..d2374ad33b --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Columns.swift @@ -0,0 +1,388 @@ +// +// DataGridViewController+Columns.swift +// TablePro +// + +import AppKit + +extension DataGridViewController { + // Builds both panes' column lists from the model. Never culls a column with isHidden: each write costs + // O(attached columns), and seconds at 500 columns with mounted views (probe section 5). + func reconcileColumns(widening changedPresentations: IndexSet = []) { + isRebuildingColumns = true + defer { isRebuildingColumns = false } + + let columns = model.columns + let ids = Set(columns.columns.map(\.id)) + // A new result measures every column again; hiding, showing, moving or freezing keeps the widths the reader sees. + let rows = model.rows + let resultIDs = DataGridColumnSet.columnIDs(for: rows.columns) + // Widths measured over a result whose rows had not landed yet were the headings' alone. + if resultIDs != resultColumnIDs || (!rows.rows.isEmpty && !knownWidthsSawRows) { + resultColumnIDs = resultIDs + knownWidths.removeAll() + savedWidthIDs.removeAll() + } + knownWidthsSawRows = !rows.rows.isEmpty + let layout = model.store.columnLayout + let calculator = widthCalculator() + var desired: [NSTableColumn] = [] + if model.configuration.showRowNumbers { + desired.append(main.pooledRowNumberColumn()) + } + var nextSaved = Set() + for column in columns.columns { + let tableColumn = main.pooledColumn(for: column.id) + let width: CGFloat + if let saved = layout.columnWidths[column.name], keepsSavedWidth(of: column, in: layout) { + width = saved + nextSaved.insert(column.id) + } else if let known = knownWidths[column.id], !savedWidthIDs.contains(column.id) { + // Metadata that reveals an accessory arrives after the rows; an auto width only grows to fit it. + width = changedPresentations.contains(column.dataIndex) + ? max(known, calculator.automaticWidth(widthInput(for: column))) + : known + } else { + width = calculator.automaticWidth(widthInput(for: column)) + } + configure(tableColumn, as: column, width: width) + knownWidths[column.id] = tableColumn.width + desired.append(tableColumn) + } + apply(desired, to: main) + main.forgetColumns(except: ids) + savedWidthIDs = nextSaved + updateFrozenPane() + refreshHeaders() + } + + // A width saved before accessories reserved room, and never set by the user, is measured again. + private func keepsSavedWidth(of column: DataGridColumn, in layout: ColumnLayoutState) -> Bool { + if layout.columnContentWidths?[column.name] != nil { return true } + return presenter.presentation(forDataIndex: column.dataIndex).accessory == .none + } + + // The frozen pane shows the row numbers and the frozen columns that fit in two thirds of the grid. + func updateFrozenPane() { + let wasRebuilding = isRebuildingColumns + isRebuildingColumns = true + defer { isRebuildingColumns = wasRebuilding } + + sizeRowNumberColumns() + let count = resolveEffectiveFrozenCount() + effectiveFrozenCount = count + let frozenColumns = Array(model.columns.columns.prefix(count)) + var desired: [NSTableColumn] = [] + if model.configuration.showRowNumbers { + desired.append(frozen.pooledRowNumberColumn()) + } + for column in frozenColumns { + let tableColumn = frozen.pooledColumn(for: column.id) + configure(tableColumn, as: column, width: main.tableColumn(for: column.id)?.width ?? DataGridMetrics.dataColumnMinWidth) + desired.append(tableColumn) + } + apply(desired, to: frozen) + frozen.forgetColumns(except: Set(frozenColumns.map(\.id))) + applyRowNumberWidth() + container.frozenPaneWidth = desired.isEmpty ? 0 : frozen.columnsWidth + } + + func resolveEffectiveFrozenCount() -> Int { + let requested = model.columns.frozenCount + guard requested > 0 else { return 0 } + let available = container.bounds.width * DataGridMetrics.maximumFrozenShare + let spacing = main.tableView.intercellSpacing.width + var width = main.rowNumberTableColumn.map { $0.width + spacing } ?? 0 + var count = 0 + for column in model.columns.frozenColumns { + guard let tableColumn = main.tableColumn(for: column.id) else { break } + let next = width + tableColumn.width + spacing + guard next <= available else { break } + width = next + count += 1 + } + return count + } + + // Freeze Up to This Column is refused when the frozen run would leave less than a third of the grid to scroll. + func canFreezeColumns(throughDisplayPosition position: Int) -> Bool { + guard let index = main.tableIndex(forDisplayPosition: position, columns: model.columns) else { return false } + let width = main.tableView.rect(ofColumn: index).maxX + let available = container.bounds.width * DataGridMetrics.maximumFrozenShare + return available > 0 && width <= available + } + + private func apply(_ desired: [NSTableColumn], to pane: DataGridPane) { + let table = pane.tableView + let wanted = Set(desired.map(\.identifier)) + for column in table.tableColumns where !wanted.contains(column.identifier) { + table.removeTableColumn(column) + } + let attached = Set(table.tableColumns.map(\.identifier)) + for column in desired where !attached.contains(column.identifier) { + table.addTableColumn(column) + } + + NSAnimationContext.beginGrouping() + NSAnimationContext.current.duration = 0 + NSAnimationContext.current.allowsImplicitAnimation = false + defer { NSAnimationContext.endGrouping() } + + var indexByIdentifier: [NSUserInterfaceItemIdentifier: Int] = [:] + indexByIdentifier.reserveCapacity(table.tableColumns.count) + for (index, column) in table.tableColumns.enumerated() { + indexByIdentifier[column.identifier] = index + } + for (target, column) in desired.enumerated() { + guard let current = indexByIdentifier[column.identifier], current != target else { continue } + table.moveColumn(current, toColumn: target) + Self.shiftIndexes(&indexByIdentifier, from: current, to: target) + } + pane.invalidateSlots() + } + + private static func shiftIndexes(_ map: inout [NSUserInterfaceItemIdentifier: Int], from source: Int, to destination: Int) { + let lower = min(source, destination) + let upper = max(source, destination) + let delta = source < destination ? -1 : 1 + for (key, value) in map where value >= lower && value <= upper { + map[key] = value == source ? destination : value + delta + } + } + + // Raising minWidth past width moves the width without updating the table's geometry, so the width goes in first. + private func configure(_ tableColumn: NSTableColumn, as column: DataGridColumn, width: CGFloat) { + let configuration = model.configuration + let minWidth = configuration.columnMinimumWidths[column.name] ?? DataGridMetrics.dataColumnMinWidth + let resolvedWidth = max(width, minWidth) + if tableColumn.minWidth > minWidth { + tableColumn.minWidth = minWidth + } + if tableColumn.width != resolvedWidth { + tableColumn.width = resolvedWidth + } + if tableColumn.minWidth != minWidth { + tableColumn.minWidth = minWidth + } + if tableColumn.isEditable != configuration.isEditable { + tableColumn.isEditable = configuration.isEditable + } + guard configuration.supportsColumnSort else { + if tableColumn.sortDescriptorPrototype != nil { + tableColumn.sortDescriptorPrototype = nil + } + return + } + let ascending = firstClickSortDirection == .ascending + let key = column.id.stringValue + if tableColumn.sortDescriptorPrototype?.key != key || tableColumn.sortDescriptorPrototype?.ascending != ascending { + tableColumn.sortDescriptorPrototype = NSSortDescriptor(key: key, ascending: ascending) + } + } + + // The column is pinned to one width, so its interval opens before the width moves through it; AppKit + // otherwise keeps the old geometry and posts no resize (measured on macOS 27). + func applyRowNumberWidth() { + guard sizeRowNumberColumns() else { return } + container.frozenPaneWidth = frozen.tableView.tableColumns.isEmpty ? 0 : frozen.columnsWidth + redrawVisibleRows() + } + + @discardableResult + private func sizeRowNumberColumns() -> Bool { + let maxNumber = max((model.dataSource?.gridRowNumberOffset ?? 0) + model.rowCount, 1) + let font = ThemeEngine.shared.dataGridFonts.rowNumber + let textWidth = (String(maxNumber) as NSString).size(withAttributes: [.font: font]).width + let measured = ceil(textWidth) + 2 * DataGridMetrics.cellHorizontalInset + DataGridMetrics.rowNumberHeaderPadding + let width = max(DataGridMetrics.rowNumberColumnMinWidth, measured) + var changed = false + for pane in panes { + guard let column = pane.rowNumberTableColumn, + column.width != width || column.minWidth != width || column.maxWidth != width else { continue } + column.minWidth = min(column.minWidth, width) + column.maxWidth = max(column.maxWidth, width) + column.width = width + column.minWidth = width + column.maxWidth = width + changed = true + } + return changed + } + + var firstClickSortDirection: SortDirection { + guard model.configuration.appliesRowSortPreferences else { return .ascending } + return AppSettingsManager.shared.dataGrid.defaultSortDirection + } + + // MARK: - Width measurement + + func widthCalculator() -> DataGridColumnWidthCalculator { + DataGridColumnWidthCalculator( + rows: model.rows, + databaseType: model.configuration.traits.databaseType, + nullDisplayString: presenter.registry.nullDisplayString, + revealsInvisibleCharacters: presenter.registry.showsInvisibleCharacters, + fonts: ThemeEngine.shared.dataGridFonts + ) + } + + func widthInput(for column: DataGridColumn) -> DataGridColumnWidthCalculator.Input { + DataGridColumnWidthCalculator.Input( + column: column, + accessory: presenter.presentation(forDataIndex: column.dataIndex).accessory, + displayFormat: model.displayFormat(for: column) + ) + } + + private var visibleGridWidth: CGFloat { + main.scrollView.contentView.bounds.width + } + + func fittedWidth(for column: DataGridColumn, fittedColumnCount: Int = 1) -> CGFloat { + widthCalculator().fittedWidth(widthInput(for: column), availableWidth: visibleGridWidth, fittedColumnCount: fittedColumnCount) + } + + // Size to Fit, from the loaded display strings; written to the model as a width the user chose. + func sizeColumnToFit(_ column: DataGridColumn) { + setWidths([column.id: fittedWidth(for: column)]) + } + + func sizeAllColumnsToFit() { + let columns = model.columns.columns + let calculator = widthCalculator() + var widths: [DataGridColumnID: CGFloat] = [:] + for column in columns { + widths[column.id] = calculator.fittedWidth( + widthInput(for: column), + availableWidth: visibleGridWidth, + fittedColumnCount: columns.count + ) + } + setWidths(widths) + } + + private func setWidths(_ widths: [DataGridColumnID: CGFloat]) { + isRebuildingColumns = true + var named: [String: CGFloat] = [:] + for (id, width) in widths { + for pane in panes { + pane.tableColumn(for: id)?.width = width + } + if let column = main.tableColumn(for: id) { + named[id.name] = column.width + savedWidthIDs.insert(id) + knownWidths[id] = column.width + } + } + isRebuildingColumns = false + geometryDidChange() + model.columnGeometryDidChange(widths: named, order: nil) + } + + // MARK: - Geometry from the user + + func columnDidResize(_ tableColumn: NSTableColumn, in pane: DataGridPane) { + guard !isRebuildingColumns, let id = pane.columnID(of: tableColumn) else { return } + if pane.kind == .frozen, let mainColumn = main.tableColumn(for: id), mainColumn.width != tableColumn.width { + isRebuildingColumns = true + mainColumn.width = tableColumn.width + isRebuildingColumns = false + } + savedWidthIDs.insert(id) + knownWidths[id] = tableColumn.width + if resolveEffectiveFrozenCount() != effectiveFrozenCount { + updateFrozenPane() + } + geometryDidChange() + model.columnGeometryDidChange(widths: [id.name: tableColumn.width], order: nil) + } + + func columnDidMove(from oldIndex: Int, to newIndex: Int, in pane: DataGridPane) { + guard !isRebuildingColumns else { return } + if pane.kind == .frozen { + isRebuildingColumns = true + main.tableView.moveColumn(oldIndex, toColumn: newIndex) + isRebuildingColumns = false + } + let order = main.tableView.tableColumns.compactMap { main.columnID(of: $0)?.name } + panes.forEach { $0.invalidateSlots() } + geometryDidChange() + model.columnGeometryDidChange(widths: [:], order: order) + } + + // The body has no cell views for AppKit to relay out, so every width, order or freeze step repaints it (#2449). + func geometryDidChange() { + if !frozen.tableView.tableColumns.isEmpty { + container.frozenPaneWidth = frozen.columnsWidth + } + for pane in panes { + pane.tableView.setNeedsDisplay(pane.tableView.visibleRect) + } + redrawVisibleRows() + updateReorderToolTip() + } + + func tableDidMoveColumn(_ table: DataGridTableView) { + container.pane(of: table)?.invalidateSlots() + guard !isRebuildingColumns else { return } + redrawVisibleRows() + } + + // A frozen column resized in its own header carries the main pane's copy with it on every step. + func headerGeometryDidStep(in kind: DataGridPane.Kind) { + if kind == .frozen { + isRebuildingColumns = true + for tableColumn in frozen.tableView.tableColumns { + guard let id = frozen.columnID(of: tableColumn), + let mainColumn = main.tableColumn(for: id), + mainColumn.width != tableColumn.width else { continue } + mainColumn.width = tableColumn.width + } + isRebuildingColumns = false + } + geometryDidChange() + } + + func shouldReorderColumn(_ index: Int, to newIndex: Int, in pane: DataGridPane) -> Bool { + let table = pane.tableView + guard table.tableColumns.indices.contains(index), !pane.isRowNumber(table.tableColumns[index]) else { return false } + let leading = pane.hasRowNumberColumn ? 1 : 0 + switch pane.kind { + case .main: + // Columns under the frozen pane move in the frozen header, never in this one. + let firstMovable = leading + effectiveFrozenCount + guard index >= firstMovable else { return false } + return newIndex < 0 || newIndex >= firstMovable + case .frozen: + return newIndex < 0 || newIndex >= leading + } + } + + // MARK: - Frozen pane edge + + // A 1 pt separator after the row numbers alone, a 2 pt divider once columns are frozen. + func drawFrozenPaneEdge(in rect: NSRect, of view: NSView) { + let table = frozen.tableView + guard !table.tableColumns.isEmpty else { return } + let thickness = effectiveFrozenCount > 0 ? DataGridMetrics.frozenDividerThickness : DataGridBodyChrome.separatorThickness + let edge = view.convert(NSRect(x: frozen.columnsWidth - thickness, y: 0, width: thickness, height: 1), from: table) + let divider = NSRect(x: edge.minX, y: rect.minY, width: thickness, height: rect.height) + guard divider.intersects(rect) else { return } + table.gridColor.setFill() + divider.fill() + } + + // MARK: - Reveal + + // Widened leftward by the frozen pane, so a revealed column never parks beneath it; a frozen column is always shown. + func revealRect(for rect: NSRect, in table: DataGridTableView) -> NSRect { + let frozenWidth = frozen.scrollView.isHidden ? 0 : container.frozenPaneWidth + guard frozenWidth > 0 else { return rect } + let visible = table.visibleRect + if rect.maxX <= frozenWidth { + return NSRect(x: visible.minX, y: rect.minY, width: min(rect.width, visible.width), height: rect.height) + } + guard rect.width < visible.width - frozenWidth else { return rect } + return NSRect(x: rect.minX - frozenWidth, y: rect.minY, width: rect.width + frozenWidth, height: rect.height) + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Drawing.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Drawing.swift new file mode 100644 index 0000000000..c9cfeb609e --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Drawing.swift @@ -0,0 +1,157 @@ +// +// DataGridViewController+Drawing.swift +// TablePro +// + +import AppKit + +extension DataGridViewController { + // The full row in a whole-row selection, else the selected columns' span: NSTableView selects rows, not rectangles. + func selectionRects(forRow row: Int, in table: DataGridTableView, within tableRect: NSRect) -> [NSRect] { + guard row >= 0, let pane = container.pane(of: table) else { return [] } + let columns = model.columns + let selected = model.selection.columns(in: row) + guard !selected.isEmpty, !columns.isEmpty else { return [] } + if selected.contains(integersIn: 0..= 0, let pane = container.pane(of: table), + let pass = presenter.makePass(in: NSAppearance.currentDrawing()), + let rowModel = model.order.row(at: row, in: pass.rows) else { return } + let columns = model.columns + let visualState = presenter.visualState(of: rowModel) + let selected = pass.selection.columns(in: row) + let line = DrawnRow( + row: row, + model: rowModel, + visualState: visualState, + selected: selected, + isWholeRow: !columns.isEmpty && selected.contains(integersIn: 0.. Int? { + guard let dragged = table.headerView?.draggedColumn, dragged >= 0, dragged < table.numberOfColumns else { return nil } + return dragged + } + + // An inner stroke, 2 pt while the grid has focus and 1 pt otherwise: a focus ring from a row view loses its top + // and bottom (critic B M5). On an emphasized fill it takes the selected text color, which stays visible there. + private func drawActiveCellBorder(of view: NSView, table: DataGridTableView, pane: DataGridPane, line: DrawnRow) { + guard let active = line.pass.selection.activeCell, active.row == line.row, + editedCell != active, + let index = pane.tableIndex(forDisplayPosition: active.displayColumn, columns: model.columns) else { return } + let columnRect = view.convert(table.rect(ofColumn: index), from: table) + let lineWidth: CGFloat = gridHasFocus ? 2 : 1 + let cellRect = NSRect( + x: columnRect.minX, + y: 0, + width: max(0, columnRect.width - table.intercellSpacing.width), + height: view.bounds.height + ) + let onFill = line.isEmphasized && line.selected.contains(active.displayColumn) + let color = onFill ? line.pass.palette.selectionText : NSColor.keyboardFocusIndicatorColor + let path = NSBezierPath(rect: cellRect.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)) + path.lineWidth = lineWidth + color.setStroke() + path.stroke() + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Editing.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Editing.swift new file mode 100644 index 0000000000..67a2942f6b --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Editing.swift @@ -0,0 +1,155 @@ +// +// DataGridViewController+Editing.swift +// TablePro +// + +import AppKit + +@MainActor +final class DataGridEditingState { + var session: DataGridEditSession? + var suspendedField: DataGridSuspendedFieldEdit? + var deferredCommit: (session: DataGridEditSession, text: String)? + var isApplyingChange = false + var isEndingField = false + + lazy var editingCellView = DataGridEditingCellView(frame: .zero) + let fieldDelegate = DataGridFieldEditorDelegate() + + var textPopover: DataGridTextEditorPopover? + + var typedPopover: NSPopover? + var typedPopoverRow: DataGridHeldRow? + var typedPopoverPane: DataGridPane.Kind = .main + var typedPopoverEdits = false + let typedPopoverObserver = DataGridPopoverCloseObserver() + + weak var poppedOutEditor: JSONViewerWindowController? + var poppedOutRow: DataGridHeldRow? + + var foreignKeyPreview: NSPopover? + var foreignKeyPreviewModel: FKPreviewModel? + var foreignKeyPreviewColumn: DataGridColumnID? + var foreignKeyPreviewPane: DataGridPane.Kind = .main +} + +extension DataGridViewController { + var isEditingCell: Bool { + let state = editingState + if state.session != nil || state.suspendedField != nil { return true } + return state.typedPopoverEdits && state.typedPopover != nil + } + + var editedCell: GridCoord? { + guard let session = editingState.session, session.editor == .field else { return nil } + return session.cell + } + + func editingCellView(in pane: DataGridPane, tableColumn: NSTableColumn, row: Int) -> NSView? { + guard let session = editingState.session, + session.editor == .field, + session.pane == pane.kind, + session.cell.row == row, + pane.columnID(of: tableColumn) == session.target.columnID else { return nil } + return editingState.editingCellView + } + + @discardableResult + func beginEditingCell(row: Int, displayColumn: Int) -> Bool { + let coord = GridCoord(row: row, displayColumn: displayColumn) + guard let cell = editCell(at: coord) else { return false } + if model.selection.activeCell != coord || model.selection.hasMultipleCells { + model.selectCell(coord) + mirrorSelection() + } + reveal(row: row, displayColumn: displayColumn) + let target = cell.target + guard loadsUnloadedValue(of: cell, then: { $0.beginTextEditing(for: target) }) else { return false } + return beginTextEditing(cell) + } + + func beginEditingActiveCell() { + guard let active = model.selection.activeCell else { return } + reveal(row: active.row, displayColumn: active.displayColumn) + openEditor(at: active) + } + + func commitCellEditing() -> Bool { + var committed = true + if let session = editingState.session { + switch session.editor { + case .field: + committed = finishFieldEditing(commit: true) + case .textPopover: + committed = finishTextPopover(.commit(nil)) + } + } + flushDeferredCommit() + closeCellPopovers() + return committed + } + + func discardCellEditing() { + editingState.deferredCommit = nil + if let session = editingState.session { + switch session.editor { + case .field: + finishFieldEditing(commit: false) + case .textPopover: + finishTextPopover(.cancel) + } + } + closeCellPopovers() + } + + func handleCellDoubleClick(at cell: GridCoord) { + openEditor(at: cell) + } + + func handleClickOnActiveCell(_ cell: GridCoord) { + guard let clicked = editCell(at: cell), textEditableValue(of: clicked) != nil else { return } + openEditor(at: cell) + } + + func handleForeignKeyArrowClick(at cell: GridCoord, opensNewTab: Bool) { + followForeignKey(at: cell, opensNewTab: opensNewTab) + } + + func handleChevronClick(at cell: GridCoord) { + openChevronEditor(at: cell) + } + + func toggleForeignKeyPreview() { + if editingState.foreignKeyPreview?.isShown == true { + closeForeignKeyPreview() + return + } + guard let active = model.selection.activeCell else { return } + previewForeignKey(at: active) + } + + func editingSelectionDidChange() { + followActiveCellWithForeignKeyPreview() + } + + func editingWillApply(_ change: DataGridChange) { + editingState.isApplyingChange = true + guard change.isStructural else { return } + suspendFieldEditing(unmounting: change != .reload) + } + + func editingDidApply(_ change: DataGridChange) { + editingState.isApplyingChange = false + flushDeferredCommit() + guard change.isStructural else { return } + resumeFieldEditing() + followRowChangesWithPopovers() + } +} + +extension DataGridChange { + var isStructural: Bool { + if case .rowsUpdated = self { return false } + return true + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift new file mode 100644 index 0000000000..74d65806b3 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Header.swift @@ -0,0 +1,267 @@ +// +// DataGridViewController+Header.swift +// TablePro +// + +import AppKit + +extension DataGridViewController { + // The sort entries the current result carries a column for, resolved by name: a stale index would put the + // indicator on whichever column moved into the slot. + func presentedSortEntries() -> [(column: DataGridColumn, direction: SortDirection)] { + guard model.configuration.supportsColumnSort else { return [] } + let names = model.rows.columns + let columns = model.columns + return model.store.sortState.columns.compactMap { sortColumn -> (column: DataGridColumn, direction: SortDirection)? in + guard let name = SortColumnResolver.columnName(for: sortColumn, displayColumns: names) else { return nil } + let dataIndex: Int + if sortColumn.columnIndex >= 0, sortColumn.columnIndex < names.count, names[sortColumn.columnIndex] == name { + dataIndex = sortColumn.columnIndex + } else if let found = names.firstIndex(of: name) { + dataIndex = found + } else { + return nil + } + guard let position = columns.displayPosition(ofDataIndex: dataIndex), + let column = columns.column(at: position) else { return nil } + return (column: column, direction: sortColumn.direction) + } + } + + // The model's sort is the source; tableView.sortDescriptors follows it under a guard, since assigning inside the + // callback re-enters it once (probe P5b). + func sortDidChange() { + syncSortIndicators() + } + + func summaryRulesDidChange() { + selectionSummaryTracker.rulesDidChange() + } + + func syncSortIndicators() { + let descriptors = presentedSortEntries().map { + NSSortDescriptor(key: $0.column.id.stringValue, ascending: $0.direction == .ascending) + } + isApplyingSortDescriptors = true + for pane in panes { + let current = pane.tableView.sortDescriptors + let same = current.count == descriptors.count + && zip(current, descriptors).allSatisfy { $0.key == $1.key && $0.ascending == $1.ascending } + if !same { + pane.tableView.sortDescriptors = descriptors + } + } + isApplyingSortDescriptors = false + refreshHeaders() + } + + func headerPressed(_ tableColumn: NSTableColumn, in pane: DataGridPane) { + pendingHeaderPress = (pane.kind, tableColumn.identifier) + } + + // AppKit already put the clicked key first; a plain click keeps only it, Option-click keeps AppKit's appended key. + func sortDescriptorsDidChange(in pane: DataGridPane) { + guard !isApplyingSortDescriptors, model.configuration.supportsColumnSort else { return } + let column = clickedSortColumn(in: pane) + pendingHeaderPress = nil + guard let column else { + syncSortIndicators() + return + } + let addsKey = NSApp.currentEvent?.modifierFlags.contains(.option) == true + let current = SortColumnResolver.reindexed(model.store.sortState, displayColumns: model.rows.columns) + let transition = HeaderSortCycle.nextTransition( + state: current, + clickedColumn: column.dataIndex, + isMultiSort: addsKey, + firstClickDirection: firstClickSortDirection + ) + model.setSort(SortColumnResolver.stamped(transition.newState, displayColumns: model.rows.columns)) + syncSortIndicators() + } + + private func clickedSortColumn(in pane: DataGridPane) -> DataGridColumn? { + if let press = pendingHeaderPress, + let tableColumn = container.pane(press.kind).tableView.tableColumns.first(where: { $0.identifier == press.identifier }), + let id = container.pane(press.kind).columnID(of: tableColumn) { + return model.columns.column(id: id) + } + guard let key = pane.tableView.sortDescriptors.first?.key else { return nil } + return model.columns.columns.first { $0.id.stringValue == key } + } + + // Command-click selects the whole column; Shift and Command reach no sort callback at all (critic B M1). + func headerClicked(_ tableColumn: NSTableColumn, in pane: DataGridPane) { + guard let event = NSApp.currentEvent, event.modifierFlags.contains(.command), + let id = pane.columnID(of: tableColumn), + let position = model.columns.displayPosition(of: id) else { return } + var modifiers: GridPointerModifiers = .command + if event.modifierFlags.contains(.shift) { + modifiers.insert(.shift) + } + let next = GridSelectionReducer.columnHeaderCommandClick( + model.selection, + column: position, + modifiers: modifiers, + in: selectionContext + ) + model.setSelection(next) + mirrorSelection() + focus() + } + + // Where a popover about a column points: its heading, in the pane that shows it, scrolled into view first. + func anchor(forColumn displayColumn: Int) -> (view: NSView, rect: NSRect)? { + let pane = displayColumn < effectiveFrozenCount ? frozen : main + guard let index = pane.tableIndex(forDisplayPosition: displayColumn, columns: model.columns) else { return nil } + if pane.isMain { + revealColumn(displayColumn) + } + return (pane.headerView, pane.headerView.headerRect(ofColumn: index)) + } + + // MARK: - Header + + // Titles, the value-filter funnel, tooltips and sort accessibility for every column of both panes. + func refreshHeaders() { + let columns = model.columns + let filter = model.store.valueFilter + let sortEntries = presentedSortEntries() + let showsComments = AppSettingsManager.shared.general.showObjectComments + var sortIndex: [DataGridColumnID: (direction: SortDirection, priority: Int)] = [:] + for (priority, entry) in sortEntries.enumerated() { + sortIndex[entry.column.id] = (entry.direction, priority + 1) + } + for pane in panes { + for tableColumn in pane.tableView.tableColumns { + guard let id = pane.columnID(of: tableColumn), let column = columns.column(id: id) else { continue } + let sort = sortIndex[id] + applyHeaderTitle( + to: tableColumn, + column: column, + isFiltered: model.configuration.supportsValueFilter && filter.isActive(column: column.dataIndex) + ) + let tooltip = headerToolTip( + for: column, + showsComment: showsComments, + sort: sort, + sortKeyCount: sortEntries.count + ) + if tableColumn.headerToolTip != tooltip { + tableColumn.headerToolTip = tooltip + } + let cell = tableColumn.headerCell + let label = headerAccessibilityLabel(for: column, showsComment: showsComments) + if cell.accessibilityLabel() != label { + cell.setAccessibilityLabel(label) + } + // sortDescriptors reaches no accessibility client: the header cell's direction stays unknown (#2381 era). + cell.setAccessibilitySortDirection(Self.accessibilitySortDirection(sort?.direction)) + } + pane.headerView.needsDisplay = true + } + } + + private func applyHeaderTitle(to tableColumn: NSTableColumn, column: DataGridColumn, isFiltered: Bool) { + let cell = tableColumn.headerCell + guard isFiltered else { + if cell.stringValue != column.name { + tableColumn.title = column.name + } + return + } + let font = cell.font ?? .systemFont(ofSize: NSFont.smallSystemFontSize) + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byTruncatingTail + paragraph.alignment = cell.alignment + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.headerTextColor, + .paragraphStyle: paragraph, + ] + let title = NSMutableAttributedString(string: column.name + " ", attributes: attributes) + if let funnel = Self.filterFunnel(for: font) { + let attachment = NSAttributedString(attachment: funnel) + let marked = NSMutableAttributedString(attributedString: attachment) + marked.addAttributes(attributes, range: NSRange(location: 0, length: marked.length)) + title.append(marked) + } + cell.attributedStringValue = title + } + + // Drawn in the header's own text color, so the funnel never turns white on an inactive selection (F1). + private static func filterFunnel(for font: NSFont) -> NSTextAttachment? { + let configuration = NSImage.SymbolConfiguration(pointSize: font.pointSize, weight: .regular) + .applying(.init(hierarchicalColor: .headerTextColor)) + guard let image = NSImage( + systemSymbolName: "line.3.horizontal.decrease.circle.fill", + accessibilityDescription: String(localized: "Filtered") + )?.withSymbolConfiguration(configuration) else { return nil } + let attachment = NSTextAttachment() + attachment.image = image + let size = image.size + attachment.bounds = NSRect(x: 0, y: (font.capHeight - size.height) / 2, width: size.width, height: size.height) + return attachment + } + + private func headerToolTip( + for column: DataGridColumn, + showsComment: Bool, + sort: (direction: SortDirection, priority: Int)?, + sortKeyCount: Int + ) -> String { + var tooltip: String + if let typeName = column.type?.rawType ?? column.type?.displayName { + tooltip = column.name + " (" + typeName + ")" + } else { + tooltip = column.name + } + if showsComment, let comment = Self.displayableComment(column.comment) { + tooltip += "\n" + comment + } + if let sort, sortKeyCount > 1 { + tooltip += "\n" + String( + format: String(localized: "Sort key %d of %d, %@"), + sort.priority, + sortKeyCount, + sort.direction.displayName + ) + } + return tooltip + } + + private func headerAccessibilityLabel(for column: DataGridColumn, showsComment: Bool) -> String { + let label = String(format: String(localized: "Column: %@"), column.name) + guard showsComment, let comment = Self.displayableComment(column.comment) else { return label } + return label + ", " + comment + } + + private static func displayableComment(_ comment: String?) -> String? { + guard let trimmed = comment?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } + return trimmed + } + + private static func accessibilitySortDirection(_ direction: SortDirection?) -> NSAccessibilitySortDirection { + switch direction { + case .ascending: return .ascending + case .descending: return .descending + case nil: return .unknown + } + } + + // A whole column picked by its heading highlights that heading in whichever pane shows it. + func updateHighlightedColumns(_ selection: GridSelection) { + let picked = selection.activeCell.flatMap { selection.columns.contains($0.displayColumn) ? $0.displayColumn : nil } + ?? selection.columns.first + let id = picked.flatMap { model.columns.column(at: $0)?.id } + for pane in panes { + let column = id.flatMap { pane.tableColumn(for: $0) } + let attached = column.flatMap { candidate in + pane.tableView.tableColumns.contains { $0 === candidate } ? candidate : nil + } + if pane.tableView.highlightedTableColumn !== attached { + pane.tableView.highlightedTableColumn = attached + } + } + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Menus.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Menus.swift new file mode 100644 index 0000000000..022f1e79bd --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Menus.swift @@ -0,0 +1,135 @@ +// +// DataGridViewController+Menus.swift +// TablePro +// + +import AppKit + +@MainActor +final class DataGridMenuState { + let loader = DataGridValueLoader() + var valueFilterPopover: NSPopover? +} + +extension DataGridViewController { + var gridCommands: DataGridCommands { + DataGridCommands(model: model, loader: menuState.loader) + } + + // A context menu item carries the scope it was built over; the menu bar acts on the selection. + func commandScope(for sender: Any?) -> DataGridCommandScope { + if let item = sender as? NSMenuItem { + if let context = item.representedObject as? DataGridMenuContext { + return context.scope(in: model) + } + if let choice = item.representedObject as? DataGridSetValueChoice { + return choice.context.scope(in: model) + } + } + return .current(model.selection) + } + + // Context menu for a row; displayColumn is nil over the row numbers. Built from clickedRow and clickedColumn. + func rowMenu(forRow row: Int, displayColumn: Int?, in pane: DataGridPane.Kind) -> NSMenu? { + buildRowMenu(row: row, displayColumn: displayColumn) + } + + func headerMenu(for column: DataGridColumn) -> NSMenu? { + buildHeaderMenu(for: column) + } + + func emptySpaceMenu() -> NSMenu? { + model.actions?.gridEmptySpaceMenu() + } + + // Delete and Forward Delete mark every row the selection touches; a grid that deletes nothing beeps. + func performDeleteCommand() { + let scope = DataGridCommandScope.current(model.selection) + guard gridCommands.canDelete(scope) else { + NSSound.beep() + return + } + guard commitCellEditing() else { return } + gridCommands.deleteRows(scope) + } + + // A double-click below the last row. + func handleDoubleClickBelowRows() { + guard model.configuration.isEditable, gridCommands.offers(.addRow), commitCellEditing() else { return } + model.actions?.gridAddRow() + } + + func performSelectAll(_ sender: Any?) { + let context = selectionContext + guard !context.isEmpty else { return } + model.setSelection(GridSelectionReducer.selectAll(model.selection, in: context)) + mirrorSelection() + } + + // MARK: - Standard edit actions + + @objc func copy(_ sender: Any?) { + gridCommands.copy(commandScope(for: sender)) + } + + // The context menu's Paste and Command-V take this one path (interaction 4). + @objc func paste(_ sender: Any?) { + let scope = commandScope(for: sender) + guard commitCellEditing() else { return } + gridCommands.paste(scope) + } + + @objc func delete(_ sender: Any?) { + let scope = commandScope(for: sender) + guard gridCommands.canDelete(scope), commitCellEditing() else { return } + gridCommands.deleteRows(scope) + } + + // MARK: - Validation + + // Validation for the grid's own commands; nil leaves an item to its responder's default. + func validateGridCommand(_ item: NSValidatedUserInterfaceItem) -> Bool? { + guard let action = item.action else { return nil } + let commands = gridCommands + let scope = commandScope(for: item) + let appliesRowFormat = model.configuration.appliesRowCopyFormat + switch action { + case #selector(copy(_:)), #selector(GridRowCopyCommandResponding.copySelectedRows(_:)): + return commands.canCopy(scope) + case #selector(paste(_:)): + return commands.canPaste(scope) + case #selector(delete(_:)): + return commands.canDelete(scope) + case #selector(NSTableView.selectAll(_:)): + return !selectionContext.isEmpty + case #selector(GridCopyCommandResponding.copyCellValue(_:)): + return commands.focusCell(scope) != nil + case #selector(GridRowCopyCommandResponding.copyRowsWithHeaders(_:)), + #selector(GridRowCopyCommandResponding.copyRowsAsJson(_:)), + #selector(GridCopyCommandResponding.copyRowsAsCsv(_:)), + #selector(GridCopyCommandResponding.copyRowsAsCsvWithHeaders(_:)), + #selector(GridCopyCommandResponding.copyRowsAsMarkdown(_:)): + return appliesRowFormat && commands.canCopy(scope) + case #selector(GridCopyCommandResponding.copyRowsAsInClause(_:)): + return appliesRowFormat && commands.canCopy(scope) && commands.inClauseColumn(scope) != nil + case #selector(GridCopyCommandResponding.copyRowsAsInsertStatements(_:)): + return appliesRowFormat && commands.canCopyAsInsert(scope) + case #selector(GridCopyCommandResponding.copyRowsAsUpdateStatements(_:)): + return appliesRowFormat && commands.canCopyAsUpdate(scope) + case #selector(SetValueCommandResponding.setCellsToValue(_:)): + return commands.isSetCellsToValueOffered(for: commands.cells(scope)) + default: + if let command = Self.setValueCommand(for: action) { + return commands.isSetValueOffered(command, for: commands.cells(scope)) + } + // The menus build these items only where they apply. + return Self.contextMenuActions.contains(action) ? true : nil + } + } +} + +extension DataGridViewController: NSMenuItemValidation { + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + validateEditingCommand(menuItem) ?? validateFindCommand(menuItem) ?? validateGridCommand(menuItem) ?? true + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift new file mode 100644 index 0000000000..7728930d6a --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift @@ -0,0 +1,393 @@ +// +// DataGridViewController+Selection.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +extension DataGridViewController { + var selectionContext: GridSelectionContext { + GridSelectionContext(rowCount: model.rowCount, columnCount: model.columns.count) + } + + // Every rectangle spans all columns: AppKit's own row movement and type-select then drive it. + var selectionIsCellRange: Bool { + let selection = model.selection + return !selection.isEmpty && selection.mode == .cells + } + + // MARK: - Model to tables + + func selectionDidChange() { + let previous = drawnSelection + let current = model.selection + drawnSelection = current + mirrorSelection() + repaintSelection(from: previous, to: current) + updateHighlightedColumns(current) + postSelectionAccessibility(from: previous, to: current) + selectionSummaryTracker.selectionDidChange() + editingSelectionDidChange() + } + + // A change that kept the selection on its records moved it; nothing to announce, only to repaint. + func selectionDidMove() { + let previous = drawnSelection + let current = model.selection + guard previous != current else { return } + drawnSelection = current + repaintSelection(from: previous, to: current) + updateHighlightedColumns(current) + selectionSummaryTracker.selectionDidChange() + } + + // Both tables hold the rows the selection spans; deferred while a structural change has not reached them yet. + func mirrorSelection() { + let rowCount = model.rowCount + guard panes.allSatisfy({ $0.tableView.numberOfRows == rowCount }) else { return } + let rows = model.selection.affectedRows.filteredIndexSet { $0 >= 0 && $0 < rowCount } + let wasMirroring = isMirroringSelection + isMirroringSelection = true + for pane in panes where pane.tableView.selectedRowIndexes != rows { + pane.tableView.selectRowIndexes(rows, byExtendingSelection: false) + } + isMirroringSelection = wasMirroring + } + + private func repaintSelection(from previous: GridSelection, to current: GridSelection) { + var dirty = previous.affectedRows.union(current.affectedRows) + for cell in [previous.activeCell, current.activeCell].compactMap({ $0 }) { + dirty.insert(cell.row) + } + redrawRows(dirty) + } + + // A native change in the main table (Up, Down, type-select) goes to the model first, then mirrors. + func tableSelectionDidChange(in pane: DataGridPane) { + guard !isMirroringSelection else { return } + let next = GridSelectionReducer.rowsSelectedNatively( + pane.tableView.selectedRowIndexes, + previous: model.selection, + in: selectionContext + ) + model.setSelection(next) + mirrorSelection() + } + + // MARK: - Keyboard + + func moveSelection(_ direction: GridMoveDirection, extending: Bool, toEdge: Bool) { + applyKeyboardSelection(GridSelectionReducer.move( + model.selection, + direction, + extending: extending, + toEdge: toEdge, + in: selectionContext + )) + } + + func moveToColumnEdge(last: Bool, extending: Bool) { + applyKeyboardSelection(GridSelectionReducer.moveToColumnEdge( + model.selection, + last: last, + extending: extending, + in: selectionContext + )) + } + + // False leaves the grid through the key view loop: with no cell to start from, Tab has nowhere to go inside. + func tabToCell(forward: Bool) -> Bool { + let current = model.selection + guard current.activeCell != nil || current.anchor != nil || !current.isEmpty else { return false } + applyKeyboardSelection(GridSelectionReducer.tab(current, forward: forward, in: selectionContext)) + return true + } + + // Escape drops a range to its active cell, then the cell or rows to nothing; false passes the key on. + func cancelSelection() -> Bool { + let current = model.selection + guard !current.isEmpty else { return false } + let next = GridSelectionReducer.cancel(current, in: selectionContext) + model.setSelection(next) + if next.isEmpty { + let wasMirroring = isMirroringSelection + isMirroringSelection = true + panes.forEach { $0.tableView.deselectAll(nil) } + isMirroringSelection = wasMirroring + } + mirrorSelection() + return true + } + + func widenSelectionToRows() { + applyKeyboardSelection(GridSelectionReducer.widenToRows(model.selection, in: selectionContext)) + } + + private func applyKeyboardSelection(_ next: GridSelection) { + model.setSelection(next) + mirrorSelection() + revealActiveCell() + } + + // Shift+Space widens to rows; Space toggles the checkbox column where one exists, else previews the reference. + func handleGridKey(_ event: NSEvent) -> Bool { + let modifiers = event.modifierFlags.intersection([.command, .shift, .option, .control]) + let isSpace = KeyCode(rawValue: event.keyCode) == .space + if isSpace, modifiers == .shift { + widenSelectionToRows() + return true + } + if isSpace, modifiers.isEmpty, !model.configuration.checkboxColumns.isEmpty, toggleCheckboxesForSelection() { + return true + } + if let shortcut = AppSettingsManager.shared.keyboard.shortcut(for: .previewFKReference), + !shortcut.isCleared, + shortcut.matches(event) { + toggleForeignKeyPreview() + return true + } + return false + } + + // MARK: - Checkboxes + + // Sets every selected checkbox the same way, the way a list of mail messages marks them: on unless all are on. + func toggleCheckboxesForSelection() -> Bool { + guard let dataIndex = model.configuration.checkboxColumns.min(), + let column = model.columns.columns.first(where: { $0.dataIndex == dataIndex }), + let dataSource = model.dataSource else { return false } + let states = model.selectedRowIDs.compactMap { rowID in + dataSource.gridCheckboxState(for: rowID, column: column).map { (rowID: rowID, isOn: $0) } + } + guard !states.isEmpty else { return false } + let everyRowChecked = states.allSatisfy { $0.isOn } + model.actions?.gridSetCheckbox(!everyRowChecked, rowIDs: states.map { $0.rowID }, column: column) + redrawRows(model.selection.affectedRows) + return true + } + + @discardableResult + func toggleCheckbox(rowID: RowID, column: DataGridColumn, displayRow: Int) -> Bool { + guard let isOn = model.dataSource?.gridCheckboxState(for: rowID, column: column) else { return false } + model.actions?.gridSetCheckbox(!isOn, rowIDs: [rowID], column: column) + redrawRows(IndexSet(integer: displayRow)) + return true + } + + // MARK: - Reveal + + func reveal(row: Int, displayColumn: Int?) { + let table = main.tableView + guard row >= 0, row < table.numberOfRows else { return } + table.scrollRowToVisible(row) + if let displayColumn { + revealColumn(displayColumn) + } + } + + // Horizontal only; a frozen column is on screen at every offset. + func revealColumn(_ displayColumn: Int) { + let table = main.tableView + guard displayColumn >= effectiveFrozenCount, + let index = main.tableIndex(forDisplayPosition: displayColumn, columns: model.columns) else { return } + let columnRect = table.rect(ofColumn: index) + let visible = table.visibleRect + _ = table.scrollToVisible(NSRect(x: columnRect.minX, y: visible.midY, width: columnRect.width, height: 1)) + } + + func revealActiveCell() { + guard let active = model.selection.activeCell else { return } + reveal(row: active.row, displayColumn: active.displayColumn) + } + + // MARK: - Pointer + + private func pointerModifiers(_ event: NSEvent) -> GridPointerModifiers { + var modifiers: GridPointerModifiers = [] + if event.modifierFlags.contains(.shift) { modifiers.insert(.shift) } + if event.modifierFlags.contains(.command) { modifiers.insert(.command) } + return modifiers + } + + // A press in either pane resolves through the container and the column set to one global cell. + func tableMouseDown(_ table: DataGridTableView, with event: NSEvent) { + guard let pane = container.pane(of: table) else { + table.performNativeMouseDown(with: event) + return + } + view.window?.makeFirstResponder(main.tableView) + let point = table.convert(event.locationInWindow, from: nil) + let row = table.row(at: point) + let tableColumn = table.column(at: point) + let modifiers = pointerModifiers(event) + + if event.clickCount == 2, row < 0 { + handleDoubleClickBelowRows() + return + } + guard row >= 0 else { + if event.clickCount == 1, modifiers.isEmpty { + model.clearSelection() + mirrorSelection() + } + return + } + guard tableColumn >= 0, let slot = pane.slot(atTableIndex: tableColumn, columns: model.columns) else { + // Past the last column the press lands on the row, which AppKit selects and tracks as in any table. + if modifiers.isEmpty { + model.setSelection(GridSelectionReducer.rowsSelectedNatively( + IndexSet(integer: row), + previous: model.selection, + in: selectionContext + )) + mirrorSelection() + } + table.performNativeMouseDown(with: event) + return + } + + switch slot { + case .rowNumber: + // AppKit tracks a press on the frozen row number itself, so a sideways drag can lift the rows it picks. + if pane.kind == .frozen, canDragRows(fromRowNumberAt: row), !modifiers.contains(.command), !modifiers.contains(.shift) { + model.setSelection(GridSelectionReducer.rowNumberDragPress( + model.selection, + row: row, + keepsPickedRows: !model.configuration.rowReorder.isEnabled, + in: selectionContext + )) + mirrorSelection() + table.performNativeMouseDown(with: event) + return + } + guard event.clickCount == 1 else { return } + let (selection, drag) = GridSelectionReducer.rowHeaderPress(model.selection, row: row, modifiers: modifiers, in: selectionContext) + model.setSelection(selection) + mirrorSelection() + trackDrag(drag, from: GridCoord(row: row, displayColumn: drag.origin.displayColumn)) + case .data(let id): + guard let position = model.columns.displayPosition(of: id) else { return } + let cell = GridCoord(row: row, displayColumn: position) + if handleAccessoryPress(at: point, in: table, row: row, tableColumn: tableColumn, cell: cell, event: event) { + return + } + if event.clickCount >= 2 { + handleCellDoubleClick(at: cell) + return + } + let current = model.selection + // The cursor's cell on the one row a click selected, or a lone selected cell: a second click edits it. + let pressesActiveCell = current.activeCell == cell && !current.picksRows + && current.affectedRows == IndexSet(integer: cell.row) + && (!current.hasMultipleCells || current.mode == .cursor) + let (selection, drag) = GridSelectionReducer.press(model.selection, at: cell, modifiers: modifiers, in: selectionContext) + model.setSelection(selection) + mirrorSelection() + let moved = trackDrag(drag, from: cell) + if !moved, modifiers.isEmpty, pressesActiveCell { + handleClickOnActiveCell(cell) + } + } + } + + // Runs until mouse-up, autoscrolling the main pane; the point decides which pane names the cell. + @discardableResult + private func trackDrag(_ drag: GridSelectionDrag, from start: GridCoord) -> Bool { + guard let window = view.window else { return false } + var moved = false + while let event = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { + if event.type == .leftMouseUp { break } + _ = main.tableView.autoscroll(with: event) + guard let cell = dragCell(at: event.locationInWindow, rowsOnly: drag.mode == .rows) else { continue } + if drag.mode == .rows ? cell.row != start.row : cell != start { + moved = true + } + model.setSelection(GridSelectionReducer.drag(drag, to: cell, in: selectionContext)) + mirrorSelection() + } + return moved + } + + private func dragCell(at windowPoint: NSPoint, rowsOnly: Bool) -> GridCoord? { + let rowCount = model.rowCount + let columns = model.columns + guard rowCount > 0, !columns.isEmpty else { return nil } + let pane = container.pane(at: container.convert(windowPoint, from: nil)) + let table = pane.tableView + let point = table.convert(windowPoint, from: nil) + guard let row = GridSelectionReducer.dragIndex( + hit: table.row(at: point), + coordinate: point.y, + first: 0, + last: rowCount - 1, + lastMaxEdge: table.rect(ofRow: rowCount - 1).maxY + ) else { return nil } + guard !rowsOnly else { return GridCoord(row: row, displayColumn: 0) } + + let hit = table.column(at: point) + if hit >= 0, case .data(let id)? = pane.slot(atTableIndex: hit, columns: columns), + let position = columns.displayPosition(of: id) { + return GridCoord(row: row, displayColumn: position) + } + let positions = pane.displayPositions(columns: columns) + if hit >= 0 || positions.isEmpty { + return GridCoord(row: row, displayColumn: positions.first ?? firstColumnRightOfFrozenPane()) + } + let last = positions.last ?? 0 + return GridCoord(row: row, displayColumn: point.x >= pane.columnsWidth ? last : positions.first ?? 0) + } + + // Over the row numbers alone, a drag reveals what the pane covers and names the column at its edge. + private func firstColumnRightOfFrozenPane() -> Int { + let table = main.tableView + let clip = main.scrollView.contentView.bounds + let edge = container.frozenPaneWidth + if clip.minX > 0 { + table.scroll(NSPoint(x: max(0, clip.minX - edge), y: clip.minY)) + } + let x = main.scrollView.contentView.bounds.minX + edge + let hit = table.column(at: NSPoint(x: x, y: clip.minY)) + guard hit >= 0, case .data(let id)? = main.slot(atTableIndex: hit, columns: model.columns) else { return 0 } + return model.columns.displayPosition(of: id) ?? 0 + } + + // FK arrow (Command opens a new tab), chevron and checkbox are hit-tested here and consume the press. + private func handleAccessoryPress( + at point: NSPoint, + in table: DataGridTableView, + row: Int, + tableColumn: Int, + cell: GridCoord, + event: NSEvent + ) -> Bool { + guard let rowModel = model.row(at: row), let column = model.columns.column(at: cell.displayColumn) else { return false } + let columnRect = table.rect(ofColumn: tableColumn) + let rowRect = table.rect(ofRow: row) + let cellRect = NSRect(x: columnRect.minX, y: rowRect.minY, width: columnRect.width, height: rowRect.height) + if model.configuration.checkboxColumns.contains(column.dataIndex) { + guard DataGridCheckboxMark.frame(in: cellRect).contains(point) else { return false } + return toggleCheckbox(rowID: rowModel.id, column: column, displayRow: row) + } + let presentation = presenter.presentation(forDataIndex: column.dataIndex) + guard presentation.accessory != .none, column.dataIndex < rowModel.values.count else { return false } + let accessory = DataGridCellAccessory.visible( + for: presentation.kind, + isEditable: model.configuration.isEditable, + rawValue: rowModel.values[column.dataIndex].asText + ) + let frame = accessory.frame(in: cellRect) + guard !frame.isEmpty, frame.contains(point) else { return false } + switch accessory { + case .foreignKey: + handleForeignKeyArrowClick(at: cell, opensNewTab: event.modifierFlags.contains(.command)) + return true + case .chevron: + guard !presenter.visualState(of: rowModel).isDeleted else { return false } + handleChevronClick(at: cell) + return true + case .none: + return false + } + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+TableDelegate.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+TableDelegate.swift new file mode 100644 index 0000000000..8ad4588ea3 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+TableDelegate.swift @@ -0,0 +1,183 @@ +// +// DataGridViewController+TableDelegate.swift +// TablePro +// + +import AppKit + +extension DataGridViewController: NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { + model.rowCount + } + + // MARK: - Views + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + guard let table = tableView as? DataGridTableView else { return nil } + let rowView = (tableView.makeView(withIdentifier: Self.rowViewIdentifier, owner: nil) as? DataGridRowView) ?? DataGridRowView() + rowView.identifier = Self.rowViewIdentifier + rowView.controller = self + rowView.gridTable = table + rowView.redrawCells() + return rowView + } + + func tableView(_ tableView: NSTableView, didAdd rowView: NSTableRowView, forRow row: Int) { + rowView.isEmphasized = gridHasFocus + } + + // Nil for every drawn cell: only the edit cell and, while an assistive client is attached, the accessibility cells mount. + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard let tableColumn, let pane = container.pane(of: tableView) else { return nil } + if let editing = editingCellView(in: pane, tableColumn: tableColumn, row: row) { + return editing + } + guard pane.isMain, DataGridAccessibility.isActive, mountsAccessibilityCell(forRow: row) else { return nil } + return accessibilityCellView(for: tableColumn, row: row) + } + + static let rowViewIdentifier = NSUserInterfaceItemIdentifier("DataGridRowView") + + // Answers from the active column only, so type-select matches what the reader is looking at. + func tableView(_ tableView: NSTableView, typeSelectStringFor tableColumn: NSTableColumn?, row: Int) -> String? { + guard tableView === main.tableView, + let tableColumn, + let id = main.columnID(of: tableColumn), + let column = model.columns.column(id: id), + let position = model.columns.displayPosition(of: id), + position == (model.selection.activeCell?.displayColumn ?? 0), + let rowModel = model.row(at: row) else { return nil } + return presenter.displayText( + of: rowModel, + dataIndex: column.dataIndex, + type: column.type, + formats: model.store.displayFormats, + databaseType: model.configuration.traits.databaseType, + columnCount: model.rows.columns.count + ) + } + + // MARK: - Selection + + func tableViewSelectionDidChange(_ notification: Notification) { + guard let table = notification.object as? NSTableView, let pane = container.pane(of: table) else { return } + tableSelectionDidChange(in: pane) + } + + // MARK: - Header + + func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { + guard let pane = container.pane(of: tableView) else { return } + sortDescriptorsDidChange(in: pane) + } + + func tableView(_ tableView: NSTableView, mouseDownInHeaderOf tableColumn: NSTableColumn) { + guard let pane = container.pane(of: tableView) else { return } + headerPressed(tableColumn, in: pane) + } + + func tableView(_ tableView: NSTableView, didClick tableColumn: NSTableColumn) { + guard let pane = container.pane(of: tableView) else { return } + headerClicked(tableColumn, in: pane) + } + + func tableViewColumnDidResize(_ notification: Notification) { + guard let table = notification.object as? NSTableView, + let pane = container.pane(of: table), + let column = notification.userInfo?["NSTableColumn"] as? NSTableColumn else { return } + columnDidResize(column, in: pane) + } + + func tableViewColumnDidMove(_ notification: Notification) { + guard let table = notification.object as? NSTableView, + let pane = container.pane(of: table), + let oldIndex = notification.userInfo?["NSOldColumn"] as? Int, + let newIndex = notification.userInfo?["NSNewColumn"] as? Int else { return } + columnDidMove(from: oldIndex, to: newIndex, in: pane) + } + + func tableView(_ tableView: NSTableView, shouldReorderColumn columnIndex: Int, toColumn newColumnIndex: Int) -> Bool { + guard let pane = container.pane(of: tableView) else { return false } + return shouldReorderColumn(columnIndex, to: newColumnIndex, in: pane) + } + + // The divider double-click, measured from the loaded display strings. + func tableView(_ tableView: NSTableView, sizeToFitWidthOfColumn column: Int) -> CGFloat { + guard let pane = container.pane(of: tableView), column >= 0, column < tableView.tableColumns.count else { return 0 } + let tableColumn = tableView.tableColumns[column] + guard let id = pane.columnID(of: tableColumn), let dataColumn = model.columns.column(id: id) else { + return tableColumn.width + } + return fittedWidth(for: dataColumn) + } + + // MARK: - Row reorder + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { + guard tableView === frozen.tableView, canDragRows(fromRowNumberAt: row) else { return nil } + return rowDragPasteboardItem(forRow: row) + } + + // Every gap opens in one pane only, so the drop marks the row itself (.regular feedback, critic A M7). + func tableView( + _ tableView: NSTableView, + validateDrop info: any NSDraggingInfo, + proposedRow row: Int, + proposedDropOperation dropOperation: NSTableView.DropOperation + ) -> NSDragOperation { + guard model.configuration.rowReorder.isEnabled, + info.draggingSource as? NSTableView === frozen.tableView, + info.draggingPasteboard.availableType(from: [Self.rowDragType]) != nil else { return [] } + if dropOperation != .above { + tableView.setDropRow(row, dropOperation: .above) + } + return .move + } + + func tableView( + _ tableView: NSTableView, + acceptDrop info: any NSDraggingInfo, + row: Int, + dropOperation: NSTableView.DropOperation + ) -> Bool { + guard model.configuration.rowReorder.isEnabled, + let item = info.draggingPasteboard.pasteboardItems?.first, + let fromRow = item.string(forType: Self.rowDragType).flatMap({ Int($0) }), + fromRow != row, fromRow != row - 1, + let rowID = model.rowID(at: fromRow), + let actions = model.actions else { return false } + actions.gridMoveRow(from: rowID, toDisplayIndex: row) + return true + } +} + +extension DataGridViewController: NSMenuDelegate { + // Built from clickedRow and clickedColumn, so AppKit draws the clicked-row ring and the selection does not move. + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + guard let pane = panes.first(where: { $0.tableView.menu === menu }) else { return } + let table = pane.tableView + let row = table.clickedRow + let built: NSMenu? + if row >= 0 { + let column = table.clickedColumn + var position: Int? + if column >= 0, case .data(let id)? = pane.slot(atTableIndex: column, columns: model.columns) { + position = model.columns.displayPosition(of: id) + } + built = rowMenu(forRow: row, displayColumn: position, in: pane.kind) + } else { + built = emptySpaceMenu() + } + guard let built else { return } + let items = built.items + built.removeAllItems() + items.forEach(menu.addItem) + } + + func headerMenu(forTableColumn tableColumn: NSTableColumn, in kind: DataGridPane.Kind) -> NSMenu? { + let pane = container.pane(kind) + guard let id = pane.columnID(of: tableColumn), let column = model.columns.column(id: id) else { return nil } + return headerMenu(for: column) + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController.swift b/apple/TablePro/Views/DataGrid/DataGridViewController.swift new file mode 100644 index 0000000000..3776585b3e --- /dev/null +++ b/apple/TablePro/Views/DataGrid/DataGridViewController.swift @@ -0,0 +1,382 @@ +// +// DataGridViewController.swift +// TablePro +// + +import AppKit +import Combine + +// An NSViewController so commitEditing() and discardEditing() are NSEditor's own (probe P1h). +@MainActor +final class DataGridViewController: NSViewController, DataGridMountedView { + private(set) var model: DataGridModel + let presenter: DataGridCellPresenter + let container = DataGridContainerView() + let editingState = DataGridEditingState() + let menuState = DataGridMenuState() + + private(set) lazy var selectionSummaryTracker = SelectionSummaryTracker( + inputProvider: { [weak self] in self?.selectionSummaryInput() } + ) + + var isMirroringSelection = false + var isApplyingSortDescriptors = false + var isRebuildingColumns = false + var pendingHeaderPress: (kind: DataGridPane.Kind, identifier: NSUserInterfaceItemIdentifier)? + var drawnSelection: GridSelection = .empty + var effectiveFrozenCount = 0 + var resultColumnIDs: [DataGridColumnID] = [] + var knownWidths: [DataGridColumnID: CGFloat] = [:] + var knownWidthsSawRows = false + var savedWidthIDs: Set = [] + var accessibilityMountedRows = NSRange(location: 0, length: 0) + var needsViewportRestore = false + var pendingFocus = false + var lastDataGridSettings = AppSettingsManager.shared.dataGrid + var prewarmTask: Task? + var prewarmResumeTask: Task? + var reorderToolTipOwner: NSString? + private var observers: [NSObjectProtocol] = [] + private var cancellables: Set = [] + private var themeCancellable: AnyCancellable? + private var findCancellable: AnyCancellable? + private var drawnFindMatch: GridFindState.Match? + + init(model: DataGridModel) { + self.model = model + presenter = DataGridCellPresenter(model: model) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + var main: DataGridPane { container.main } + var frozen: DataGridPane { container.frozen } + var panes: [DataGridPane] { container.panes } + var gridHasFocus: Bool { container.gridHasFocus } + + // MARK: - Lifecycle + + override func loadView() { + view = container + } + + override func viewDidLoad() { + super.viewDidLoad() + container.controller = self + for pane in panes { + pane.tableView.controller = self + pane.tableView.delegate = self + pane.tableView.dataSource = self + let menu = NSMenu() + menu.delegate = self + pane.tableView.menu = menu + pane.tableView.setDraggingSourceOperationMask(.move, forLocal: true) + pane.tableView.setDraggingSourceOperationMask(.copy, forLocal: false) + } + observeEnvironment() + mount() + } + + private func mount() { + model.attach(self) + presenter.registry.adopt(model.themeScope, in: view.effectiveAppearance) + observeTheme() + observeFind() + applyTableSettings() + reloadAll() + needsViewportRestore = model.store.viewportAnchor != nil + // Attaching publishes the summary state, which SwiftUI refuses inside the update that made this view. + DispatchQueue.main.async { [weak self] in + guard let self, self.model.mountedView === self else { return } + self.selectionSummaryTracker.attach(self.model.selectionSummary) + } + } + + // A representable kept across a model swap moves the grid to the new model instead of rebuilding. + func adopt(_ newModel: DataGridModel) { + guard newModel !== model else { return } + _ = commitCellEditing() + saveViewportAnchor() + model.detach(self) + model = newModel + presenter.adopt(newModel) + drawnSelection = .empty + resultColumnIDs = [] + knownWidths = [:] + knownWidthsSawRows = false + savedWidthIDs = [] + mount() + } + + // A new mount of the same model can arrive before this one goes, so only the current mount writes the anchor. + func dismantle() { + _ = commitCellEditing() + dismissValueFilterPopover() + if model.mountedView === self { + saveViewportAnchor() + } + model.detach(self) + prewarmTask?.cancel() + prewarmTask = nil + prewarmResumeTask?.cancel() + prewarmResumeTask = nil + observers.forEach(NotificationCenter.default.removeObserver) + observers.removeAll() + cancellables.removeAll() + themeCancellable = nil + findCancellable = nil + selectionSummaryTracker.cancel() + selectionSummaryTracker.attach(nil) + container.tearDown() + } + + func containerDidMoveToWindow() { + guard view.window != nil else { return } + if pendingFocus { + pendingFocus = false + focus() + } + container.refreshFocus() + } + + func containerDidLayout() { + if resolveEffectiveFrozenCount() != effectiveFrozenCount { + updateFrozenPane() + redrawVisibleRows() + } + updateReorderToolTip() + if needsViewportRestore, main.scrollView.contentView.bounds.height > 0 { + needsViewportRestore = false + applyStagedViewport() + } + } + + // MARK: - DataGridMountedView + + var isEditing: Bool { isEditingCell } + + @discardableResult + func beginEditing(row: Int, displayColumn: Int) -> Bool { + beginEditingCell(row: row, displayColumn: displayColumn) + } + + override func commitEditing() -> Bool { + commitCellEditing() + } + + override func discardEditing() { + discardCellEditing() + } + + @discardableResult + func focus() -> Bool { + guard let window = view.window else { + pendingFocus = true + return false + } + return window.makeFirstResponder(main.tableView) + } + + func gridFocusDidChange() { + let hasFocus = gridHasFocus + for pane in panes { + pane.tableView.enumerateAvailableRowViews { rowView, _ in + rowView.isEmphasized = hasFocus + (rowView as? DataGridRowView)?.redrawCells() + } + } + } + + // MARK: - Environment + + private func observeEnvironment() { + AppEvents.shared.dataGridSettingsChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.dataGridSettingsDidChange() } + .store(in: &cancellables) + AppEvents.shared.systemTimeZoneChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.reformatText() } + .store(in: &cancellables) + + let center = NotificationCenter.default + // Deferred off the accessibility query that raised the flag: rebuilding rows inside it re-enters AppKit's walk. + observers.append(center.addObserver(forName: DataGridAccessibility.didActivateNotification, object: nil, queue: nil) { [weak self] _ in + Task { @MainActor [weak self] in self?.remountAccessibilityCells() } + }) + let scrollView = main.scrollView + observers.append(center.addObserver(forName: NSScrollView.willStartLiveScrollNotification, object: scrollView, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.pausePrewarm() } + }) + observers.append(center.addObserver(forName: NSScrollView.didEndLiveScrollNotification, object: scrollView, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.schedulePrewarmResume() } + }) + } + + // objectWillChange fires before the value moves, so the current match is read on the next turn. + private func observeFind() { + drawnFindMatch = model.find.currentMatch + findCancellable = model.find.objectWillChange.sink { [weak self] _ in + DispatchQueue.main.async { [weak self] in self?.findStateDidChange() } + } + } + + private func findStateDidChange() { + let match = model.find.currentMatch + guard match != drawnFindMatch else { return } + let rows = IndexSet([drawnFindMatch, match].compactMap { $0.flatMap { model.displayIndex(of: $0.rowID) } }) + drawnFindMatch = match + redrawRows(rows) + } + + private func observeTheme() { + themeCancellable = model.themeScope.changes + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.themeDidChange() } + } + + // Also fires for Accent, Highlight, Increase Contrast and the grid font, none of which moves a value: repaint, never reload. + func themeDidChange() { + presenter.registry.preparePalette(in: view.effectiveAppearance) + for pane in panes { + DataGridBodyChrome.applyThemeColors(to: pane.tableView, scope: model.themeScope) + pane.headerView.needsDisplay = true + } + applyRowHeight() + applyRowNumberWidth() + refreshHeaders() + redrawVisibleRows() + } + + private func dataGridSettingsDidChange() { + let settings = AppSettingsManager.shared.dataGrid + let previous = lastDataGridSettings + lastDataGridSettings = settings + presenter.registry.reloadSettings(settings) + applyRowHeight() + if previous.showAlternateRows != settings.showAlternateRows { + panes.forEach { $0.tableView.usesAlternatingRowBackgroundColors = settings.showAlternateRows } + panes.forEach { $0.tableView.needsDisplay = true } + redrawVisibleRows() + } + if previous.defaultSortDirection != settings.defaultSortDirection { + reconcileColumns() + } + if previous.dateFormat != settings.dateFormat + || previous.nullDisplay != settings.nullDisplay + || previous.enableSmartValueDetection != settings.enableSmartValueDetection { + reformatText() + } else if previous.showInvisibleCharacters != settings.showInvisibleCharacters { + redrawVisibleRows() + } + } + + // Rebuilds every formatted string: the date format, NULL text or smart detection moved, or the Mac's time zone did. + func reformatText() { + model.display.cache.removeAll() + redrawVisibleRows() + startBackgroundPrewarm() + } + + func applyTableSettings() { + if presenter.registry.scope !== model.themeScope { + presenter.registry.adopt(model.themeScope, in: view.effectiveAppearance) + observeTheme() + } + let configuration = model.configuration + let settings = AppSettingsManager.shared.dataGrid + for pane in panes { + let table = pane.tableView + DataGridBodyChrome.applyThemeColors(to: table, scope: model.themeScope) + table.usesAlternatingRowBackgroundColors = settings.showAlternateRows + table.toolTip = configuration.isEditable ? nil : configuration.editRefusalMessage + applyRowReorder(configuration.rowReorder, to: table) + } + main.tableView.setAccessibilityLabel(configuration.accessibilityLabel ?? String(localized: "Data grid")) + applyRowHeight() + } + + private func applyRowReorder(_ reorder: DataGridRowReorder, to table: DataGridTableView) { + let hasType = table.registeredDraggedTypes.contains(Self.rowDragType) + if reorder.isEnabled, !hasType { + table.registerForDraggedTypes([Self.rowDragType]) + table.draggingDestinationFeedbackStyle = .regular + } else if !reorder.isEnabled, hasType { + let remaining = table.registeredDraggedTypes.filter { $0 != Self.rowDragType } + table.unregisterDraggedTypes() + if !remaining.isEmpty { + table.registerForDraggedTypes(remaining) + } + } + } + + static let rowDragType = NSPasteboard.PasteboardType("com.TablePro.rowDrag") + + // The row number is the handle a reorder drag starts from, so it carries the reason a drag is withheld. + func updateReorderToolTip() { + let table = frozen.tableView + table.removeAllToolTips() + reorderToolTipOwner = nil + guard let reason = model.configuration.rowReorder.unavailableReason, frozen.hasRowNumberColumn else { return } + let owner = reason as NSString + reorderToolTipOwner = owner + _ = table.addToolTip(table.rect(ofColumn: 0), owner: owner, userData: nil) + } + + // MARK: - Rows + + func redrawVisibleRows() { + for pane in panes { + pane.tableView.enumerateAvailableRowViews { rowView, _ in + (rowView as? DataGridRowView)?.redrawCells() + } + } + } + + // Only rows AppKit holds a view for can need a repaint, so a select-all over a large result costs the prepared rows. + func redrawRows(_ rows: IndexSet) { + guard !rows.isEmpty else { return } + for pane in panes { + pane.tableView.enumerateAvailableRowViews { rowView, row in + guard rows.contains(row) else { return } + (rowView as? DataGridRowView)?.redrawCells() + } + } + } + + func rowTint(forRow row: Int) -> NSColor? { + let rows = model.rows + guard let rowModel = model.order.row(at: row, in: rows) else { return nil } + presenter.prepareRowState(rows: rows) + return presenter.visualState(of: rowModel).tint(in: model.themeScope) + } + + // MARK: - Selection summary + + // Only a range of two or more cells has a summary; a clicked or picked row spans its cells without choosing them. + private func selectionSummaryInput() -> SelectionSummaryInput? { + let selection = model.selection + guard selection.mode == .cells, selection.hasMultipleCells else { return nil } + let rows = model.rows + let changeState = model.changeState + let policy = model.dataSource?.gridSummaryColumnPolicy() ?? .derived( + columnTypes: rows.columnTypes, + displayFormats: model.store.displayFormats, + columnCount: rows.columns.count + ) + return SelectionSummaryInput( + selection: selection, + tableRows: rows, + displayIDs: model.order.filteredIDs, + dataColumnsByDisplayPosition: model.columns.columns.map(\.dataIndex), + policy: policy, + deletedRowIDs: changeState?.deletedRowIDs ?? [], + insertedRowIDs: changeState?.insertedRowIDs ?? [], + modifiedCells: changeState?.modifiedCells ?? [:] + ) + } +} diff --git a/apple/TablePro/Views/Results/CellEditorMovement.swift b/apple/TablePro/Views/DataGrid/Editing/CellEditorMovement.swift similarity index 54% rename from apple/TablePro/Views/Results/CellEditorMovement.swift rename to apple/TablePro/Views/DataGrid/Editing/CellEditorMovement.swift index 65965bacd9..a35473ddda 100644 --- a/apple/TablePro/Views/Results/CellEditorMovement.swift +++ b/apple/TablePro/Views/DataGrid/Editing/CellEditorMovement.swift @@ -5,28 +5,15 @@ import Foundation -/// Which way an inline cell editor was left. -/// -/// The cases are AppKit's own vocabulary for leaving one field for the next: `NSTextMovement` -/// declares `up` and `down` beside `tab` and `backtab` as "movement codes for movement between -/// fields". The overlay editor is a standalone text view rather than a field editor, so it reads -/// the four selectors itself and reports them here. -enum CellEditorMovement { +// NSTableView never moves editing between view-based cells itself (probe P1c), so the grid does, in these steps. +enum CellEditorMovement: Equatable { case tab case backtab case up case down } -/// Whether Up or Down in an inline cell editor moves the caret or leaves the cell. -/// -/// A cell value can hold line breaks, so the arrow keys belong to the value's own lines first and -/// the editor is left only from the line at that end. A value on one line has no line to move to -/// in either direction, so both arrows leave it, including straight after the editor opens with -/// the whole value selected. -/// -/// A line break is the only thing that starts a line here, because the overlay never wraps text -/// (`CellOverlayBase.applyCellTextLayout`, pinned by `CellOverlayTextLayoutTests`). +// Up and Down belong to a value's own lines first and leave the cell only from the line at that end (#2569). struct CellEditorArrowExit { let canExitUp: Bool let canExitDown: Bool diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridEditSession.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridEditSession.swift new file mode 100644 index 0000000000..4f550b2aab --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridEditSession.swift @@ -0,0 +1,87 @@ +// +// DataGridEditSession.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +// Captured when an editor opens, so a reload, a sort or a tab switch never redirects its commit (H2). +struct DataGridEditTarget: Equatable { + let rowID: RowID + let columnID: DataGridColumnID +} + +struct DataGridEditSession { + enum Editor: Equatable { + case field + case textPopover + } + + let target: DataGridEditTarget + let editor: Editor + // Committing it unchanged is no edit, even over a NULL that opened as empty text. + let originalText: String + // A replaced result reuses RowIDs for other records; the values tell them apart. + let originalRow: ContiguousArray + var pane: DataGridPane.Kind + // Read by every drawn cell, so it is refreshed per change rather than resolved per draw. + var cell: GridCoord +} + +struct DataGridHeldRow { + let target: DataGridEditTarget + var values: ContiguousArray +} + +struct DataGridEditCell { + let coord: GridCoord + let row: Row + let column: DataGridColumn + + var target: DataGridEditTarget { + DataGridEditTarget(rowID: row.id, columnID: column.id) + } + + var value: PluginCellValue { + row[column.dataIndex] + } + + var isUnloaded: Bool { + row.unloadedByteCount(column.dataIndex) != nil + } +} + +// A reload ends AppKit's editing (probe P1d), so the field pauses across one. +struct DataGridSuspendedFieldEdit { + let session: DataGridEditSession + let text: String + let selection: NSRange +} + +// A menu item's target is weak, so the item keeps its action alive as its represented object. +@MainActor +final class DataGridMenuAction: NSObject { + private let perform: @MainActor () -> Void + + init(_ perform: @escaping @MainActor () -> Void) { + self.perform = perform + } + + @objc func invoke(_ sender: Any?) { + perform() + } + + static func item( + _ title: String, + state: NSControl.StateValue = .off, + perform: @escaping @MainActor () -> Void + ) -> NSMenuItem { + let action = DataGridMenuAction(perform) + let item = NSMenuItem(title: title, action: #selector(invoke(_:)), keyEquivalent: "") + item.target = action + item.representedObject = action + item.state = state + return item + } +} diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridEditingCellView.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridEditingCellView.swift new file mode 100644 index 0000000000..21762e8b70 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridEditingCellView.swift @@ -0,0 +1,196 @@ +// +// DataGridEditingCellView.swift +// TablePro +// + +import AppKit +import TableProTheme + +// The one cell view the grid mounts, for the edited cell; every other cell is drawn (probe P1a, P1b). +@MainActor +final class DataGridEditingCellView: NSTableCellView { + static let reuseIdentifier = NSUserInterfaceItemIdentifier("data-grid-edit-cell") + + private static let borderWidth: CGFloat = 2 + + let field = DataGridEditingTextField(frame: .zero) + private var scope: ThemeScope = .live + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + identifier = Self.reuseIdentifier + // Its own layer: the row view draws its subviews into one layer, which would flatten the caret. + wantsLayer = true + field.autoresizingMask = [] + addSubview(field) + textField = field + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func prepare(text: String, font: NSFont, scope: ThemeScope, columnName: String, delegate: any NSTextFieldDelegate) { + self.scope = scope + let palette = scope.palette + field.adopt(scope) + field.font = font + field.textColor = palette.color(.editorText, otherwise: .textColor) + field.backgroundColor = palette.color(.editorBackground, otherwise: .textBackgroundColor) + field.stringValue = text + field.delegate = delegate + // A text field's accessibility element is its cell; a label set on the control never reaches VoiceOver (measured). + field.cell?.setAccessibilityLabel(columnName) + needsLayout = true + needsDisplay = true + } + + // Centred like the drawn text, which a field the cell's height would draw above. + func layoutField() { + let inset = Self.borderWidth + let lineHeight = field.font.map { ($0.ascender - $0.descender + $0.leading).rounded(.up) } ?? bounds.height + let height = min(lineHeight + 2, max(0, bounds.height - 2 * inset)) + let frame = NSRect( + x: inset, + y: ((bounds.height - height) / 2).rounded(), + width: max(0, bounds.width - 2 * inset), + height: height + ) + if field.frame != frame { + field.frame = frame + } + } + + override func layout() { + super.layout() + layoutField() + } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + layoutField() + } + + override var isFlipped: Bool { true } + + // A selected row's emphasized style would turn the text white on the field's own white background. + override var backgroundStyle: NSView.BackgroundStyle { + get { .normal } + set { super.backgroundStyle = newValue == .emphasized ? .normal : newValue } + } + + // The row skips the active-cell border under the edited cell, so the editor draws it. + override func draw(_ dirtyRect: NSRect) { + scope.palette.color(.editorBackground, otherwise: .textBackgroundColor).setFill() + bounds.fill() + let path = NSBezierPath(rect: bounds.insetBy(dx: Self.borderWidth / 2, dy: Self.borderWidth / 2)) + path.lineWidth = Self.borderWidth + NSColor.keyboardFocusIndicatorColor.setStroke() + path.stroke() + } +} + +// Its own field editor: a caret color written to the window's shared one stays on the next field edited (measured). +@MainActor +final class DataGridEditingTextField: NSTextField { + private(set) lazy var ownFieldEditor: NSTextView = { + let editor = DataGridFieldEditor() + editor.isFieldEditor = true + editor.allowsUndo = true + TextValueEditor.disableSubstitutions(in: editor) + return editor + }() + + // Held, or its theme subscription stops at the first change. + private var fieldEditorStyle: ThemeTextViewStyle? + private var invisibleCharacters: InvisibleCharacterTextDisplay? + + override class var cellClass: AnyClass? { + get { DataGridEditingTextFieldCell.self } + set { super.cellClass = newValue } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isEditable = true + isSelectable = true + isBordered = false + isBezeled = false + drawsBackground = true + focusRingType = .none + usesSingleLineMode = true + lineBreakMode = .byClipping + maximumNumberOfLines = 1 + allowsEditingTextAttributes = false + importsGraphics = false + cell?.wraps = false + cell?.isScrollable = true + cell?.setAccessibilityIdentifier("data-grid-edit-field") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func adopt(_ scope: ThemeScope) { + guard fieldEditorStyle?.scope !== scope else { return } + fieldEditorStyle?.detach() + fieldEditorStyle = ThemeTextViewStyle(textView: ownFieldEditor, scope: scope) + } + + // AppKit resets the editor's colors from the cell each time editing starts. + fileprivate func fieldEditorDidSetUp() { + fieldEditorStyle?.apply() + let marksInvisibles = AppSettingsManager.shared.dataGrid.showInvisibleCharacters + if let invisibleCharacters { + invisibleCharacters.isEnabled = marksInvisibles + } else { + invisibleCharacters = InvisibleCharacterTextDisplay.install(on: ownFieldEditor, isEnabled: marksInvisibles) + } + } +} + +// Holds no state of its own: AppKit copies a cell bit for bit, so everything lives on the field. +private final class DataGridEditingTextFieldCell: NSTextFieldCell { + override func fieldEditor(for controlView: NSView) -> NSTextView? { + (controlView as? DataGridEditingTextField)?.ownFieldEditor + } + + override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText { + let configured = super.setUpFieldEditorAttributes(textObj) + (controlView as? DataGridEditingTextField)?.fieldEditorDidSetUp() + return configured + } + + override func endEditing(_ textObj: NSText) { + (textObj as? DataGridFieldEditor)?.endUndoSession() + super.endEditing(textObj) + } +} + +// The window's undo manager would keep typing actions into text AppKit empties at the end, and Undo would raise. +private final class DataGridFieldEditor: NSTextView { + private let sessionUndoManager = UndoManager() + + override var undoManager: UndoManager? { + sessionUndoManager + } + + func endUndoSession() { + sessionUndoManager.removeAllActions() + } + + // Paste, drop and Services read here; single-line mode would drop the text's line breaks. + override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool { + let range = rangeForUserTextChange + if range.location != NSNotFound, + let text = pboard.string(forType: .string), text.contains(where: \.isNewline), + let fieldDelegate = (delegate as? NSTextField)?.delegate as? DataGridFieldEditorDelegate, + fieldDelegate.fieldEditorInsertsLines(text, replacing: range) { + return true + } + return super.readSelection(from: pboard, type: type) + } +} diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridTextEditorPopover.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridTextEditorPopover.swift new file mode 100644 index 0000000000..e5a0c52077 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridTextEditorPopover.swift @@ -0,0 +1,213 @@ +// +// DataGridTextEditorPopover.swift +// TablePro +// + +import AppKit +import TableProTheme + +// The field editor cannot grow past its row, so a multi-line or long value edits here; read-only, it is the viewer. +@MainActor +final class DataGridTextEditorPopover: NSObject, NSPopoverDelegate, NSTextViewDelegate { + enum Ending: Equatable { + case commit(CellEditorMovement?) + case cancel + } + + // Wrapping lays a paragraph out whole first: 206 ms at 256 KB and 816 ms at 1 MB, 7 ms unwrapped (#2381). + static let wrapLimit = 65_536 + + private static let minimumWidth: CGFloat = 320 + private static let maximumWidth: CGFloat = 560 + private static let minimumHeight: CGFloat = 80 + private static let maximumHeight: CGFloat = 360 + + let popover = NSPopover() + let textView: DataGridPopoverTextView + let isEditable: Bool + var onEnd: ((Ending, String) -> Void)? + + private var hasEnded = false + // Held, or their subscriptions stop at the first change. + private var style: ThemeTextViewStyle? + private var invisibleCharacters: InvisibleCharacterTextDisplay? + + init(text: String, isEditable: Bool, cellWidth: CGFloat, scope: ThemeScope) { + self.isEditable = isEditable + let font = ThemeEngine.shared.dataGridFonts.regular + let size = Self.contentSize(for: text, cellWidth: cellWidth, font: font) + let scrollView = NSScrollView(frame: NSRect(origin: .zero, size: size)) + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.drawsBackground = true + scrollView.backgroundColor = scope.palette.color(.editorBackground, otherwise: .textBackgroundColor) + scrollView.autoresizingMask = [.width, .height] + + let textView = DataGridPopoverTextView(usingTextLayoutManager: true) + textView.frame = NSRect(origin: .zero, size: scrollView.contentSize) + textView.isEditable = isEditable + textView.isSelectable = true + textView.isRichText = false + textView.allowsUndo = true + textView.importsGraphics = false + TextValueEditor.disableSubstitutions(in: textView) + textView.font = font + textView.textColor = .textColor + textView.backgroundColor = .textBackgroundColor + textView.textContainerInset = NSSize(width: 4, height: 6) + Self.applyLayout(to: textView, for: text, width: scrollView.contentSize.width) + scrollView.hasHorizontalScroller = !Self.wraps(text) + self.textView = textView + super.init() + + textView.editorPopover = self + style = ThemeTextViewStyle(textView: textView, scope: scope) + invisibleCharacters = InvisibleCharacterTextDisplay.install( + on: textView, + isEnabled: AppSettingsManager.shared.dataGrid.showInvisibleCharacters + ) + textView.string = text + textView.delegate = self + textView.setAccessibilityIdentifier(isEditable ? "grid-text-editor" : "grid-text-viewer") + scrollView.documentView = textView + + let controller = NSViewController() + controller.view = scrollView + popover.contentViewController = controller + popover.contentSize = size + popover.behavior = .transient + popover.animates = false + popover.delegate = self + } + + static func wraps(_ text: String) -> Bool { + (text as NSString).length <= wrapLimit + } + + // maxSize stays unbounded, or a long line is clipped at the popover's edge instead of scrolling. + static func applyLayout(to textView: NSTextView, for text: String, width: CGFloat) { + let unbounded = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.minSize = .zero + textView.maxSize = unbounded + textView.isVerticallyResizable = true + let wrapping = wraps(text) + textView.isHorizontallyResizable = !wrapping + textView.autoresizingMask = wrapping ? [.width] : [] + textView.textContainer?.widthTracksTextView = wrapping + textView.textContainer?.containerSize = wrapping + ? NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) + : unbounded + } + + static func contentSize(for text: String, cellWidth: CGFloat, font: NSFont) -> NSSize { + let width = min(max(cellWidth, minimumWidth), maximumWidth) + let lineHeight = (font.ascender - font.descender + font.leading).rounded(.up) + var lines = 1 + let scalars = text.unicodeScalars + var previous: Unicode.Scalar? + for scalar in scalars where lines < 40 { + if scalar == "\r" || (scalar == "\n" && previous != "\r") { + lines += 1 + } + previous = scalar + } + let characterWidth = max(1, ("0" as NSString).size(withAttributes: [.font: font]).width) + let charactersPerLine = max(1, Int((width - 16) / characterWidth)) + let wrappedLines = wraps(text) ? (text.utf16.count / charactersPerLine) : 0 + let height = CGFloat(lines + wrappedLines) * lineHeight + 16 + return NSSize(width: width, height: min(max(height, minimumHeight), maximumHeight)) + } + + func show(relativeTo rect: NSRect, of view: NSView, selection: NSRange?) { + // A window off screen has no place on screen to point the popover at. + if view.window?.isVisible == true { + popover.show(relativeTo: rect, of: view, preferredEdge: .maxY) + textView.window?.makeFirstResponder(textView) + } + let length = (textView.string as NSString).length + if let selection { + let location = min(max(selection.location, 0), length) + textView.setSelectedRange(NSRange(location: location, length: min(max(selection.length, 0), length - location))) + } else if isEditable { + textView.selectAll(nil) + } + textView.scrollRangeToVisible(textView.selectedRange()) + } + + var isShown: Bool { popover.isShown && !hasEnded } + + func end(_ ending: Ending) { + guard !hasEnded else { return } + hasEnded = true + let text = textView.string + style?.detach() + style = nil + invisibleCharacters = nil + popover.close() + onEnd?(ending, text) + onEnd = nil + } + + // A click outside or an app switch closes it; what was typed is kept, as when focus leaves a field. + func popoverDidClose(_ notification: Notification) { + end(isEditable ? .commit(nil) : .cancel) + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)): + end(isEditable ? .commit(nil) : .cancel) + return true + case #selector(NSResponder.cancelOperation(_:)): + end(.cancel) + return true + case #selector(NSResponder.insertTab(_:)): + return leave(.tab, from: textView) + case #selector(NSResponder.insertBacktab(_:)): + return leave(.backtab, from: textView) + case #selector(NSResponder.moveUp(_:)): + return leaveVertically(.up, from: textView) + case #selector(NSResponder.moveDown(_:)): + return leaveVertically(.down, from: textView) + default: + return false + } + } + + // An arrow during an IME composition moves through the composition. + private func leaveVertically(_ movement: CellEditorMovement, from textView: NSTextView) -> Bool { + guard isEditable, !textView.hasMarkedText() else { return false } + let exit = CellEditorArrowExit(text: textView.string as NSString, selection: textView.selectedRange()) + guard movement == .up ? exit.canExitUp : exit.canExitDown else { return false } + end(.commit(movement)) + return true + } + + // A literal tab is not what Tab was pressed for, so a composition swallows it. + private func leave(_ movement: CellEditorMovement, from textView: NSTextView) -> Bool { + guard !textView.hasMarkedText() else { return true } + end(isEditable ? .commit(movement) : .cancel) + return true + } +} + +// Typing undo stays here, apart from the window's history of committed edits. +@MainActor +final class DataGridPopoverTextView: NSTextView { + private let storedUndoManager = UndoManager() + + weak var editorPopover: DataGridTextEditorPopover? + + override var undoManager: UndoManager? { storedUndoManager } + + // Cmd+S saves what is typed: commit first, then let Save run. + override func performKeyEquivalent(with event: NSEvent) -> Bool { + let modifiers = event.modifierFlags.intersection([.command, .shift, .option, .control]) + if modifiers == .command, event.charactersIgnoringModifiers == "s", let editorPopover, editorPopover.isEditable { + editorPopover.end(.commit(nil)) + return false + } + return super.performKeyEquivalent(with: event) + } +} diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+ConnectionScope.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+ConnectionScope.swift new file mode 100644 index 0000000000..bdbf6df778 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+ConnectionScope.swift @@ -0,0 +1,35 @@ +// +// DataGridViewController+ConnectionScope.swift +// TablePro +// + +import AppKit + +// The editors that read the database learn the connection from the configuration the owner sets. +extension DataGridViewController { + var foreignKeyScope: DatabaseScope? { + model.configuration.traits.connectionScope + } + + var editingDatabaseType: DatabaseType? { + model.configuration.traits.databaseType + } + + var userDefinedTypeScope: DatabaseScope? { + let traits = model.configuration.traits + return traits.looksUpUserDefinedTypes ? traits.connectionScope : nil + } + + // MySQL doubles a backslash as well as a quote, which the shared helper does not. + func stringLiteralEscaper() -> (String) -> String { + guard let connectionId = model.configuration.traits.connectionScope?.connectionId, + let driver = DatabaseManager.shared.driver(for: connectionId) else { + return { SQLEscaping.escapeStringLiteral($0) } + } + return { driver.escapeStringLiteral($0) } + } + + func canSearchForeignKeys(_ databaseType: DatabaseType) -> Bool { + PluginManager.shared.sqlDialect(for: databaseType) != nil + } +} diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+EditCells.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+EditCells.swift new file mode 100644 index 0000000000..9af494d523 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+EditCells.swift @@ -0,0 +1,216 @@ +// +// DataGridViewController+EditCells.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +extension DataGridViewController { + // MARK: - Cells + + func editCell(at coord: GridCoord) -> DataGridEditCell? { + guard let row = model.row(at: coord.row), let column = model.columns.column(at: coord.displayColumn) else { return nil } + return DataGridEditCell(coord: coord, row: row, column: column) + } + + func editCell(for target: DataGridEditTarget) -> DataGridEditCell? { + guard let row = model.displayIndex(of: target.rowID), + let column = model.columns.displayPosition(of: target.columnID) else { return nil } + return editCell(at: GridCoord(row: row, displayColumn: column)) + } + + // A replaced result reuses RowIDs for other records. + func editCell(for held: DataGridHeldRow) -> DataGridEditCell? { + guard let cell = editCell(for: held.target), cell.row.values == held.values else { return nil } + return cell + } + + func heldRow(_ cell: DataGridEditCell) -> DataGridHeldRow { + DataGridHeldRow(target: cell.target, values: cell.row.values) + } + + func isRowDeleted(_ rowID: RowID) -> Bool { + model.changeState?.isRowDeleted(rowID) == true + } + + func textEditableValue(of cell: DataGridEditCell) -> String? { + guard model.configuration.isEditable, + !isRowDeleted(cell.row.id), + model.isColumnWritable(cell.column), + !cell.isUnloaded, + cell.column.type?.isBlobType != true, + cell.value.asBytes == nil else { return nil } + return cell.value.asText ?? "" + } + + func cellContext(for cell: DataGridEditCell) -> CellContext { + let name = cell.column.name + let rows = model.rows + let value = cell.value + // A column the owner locked is not read-only content: it refuses with a beep instead (#2726). + let serverOwned = model.configuration.traits.immutableColumns.contains(name) + || model.changeState?.generatedColumns.contains(name) == true + return CellContext( + columnType: cell.column.type, + value: value.asText, + isTableEditable: model.configuration.isEditable, + isRowDeleted: isRowDeleted(cell.row.id), + isImmutableColumn: serverOwned, + isBinaryValue: value.asBytes != nil, + isForeignKey: rows.columnForeignKeys[name] != nil, + displayFormatOverride: model.displayFormat(for: cell.column), + detectedContent: CellValueContentDetector.detect(value) + ) + } + + // A grid that refuses a keystroke in silence reads as broken (#2726). + func refuseEdit(of column: DataGridColumn) { + let configuration = model.configuration + if configuration.lockedColumns.contains(column.name) || (!configuration.isEditable && configuration.editRefusalMessage != nil) { + NSSound.beep() + } + } + + // MARK: - Geometry + + // A frozen column's home in the main table lies under the frozen pane. + func editingPane(forDisplayColumn displayColumn: Int) -> DataGridPane { + if !frozen.scrollView.isHidden, frozen.tableIndex(forDisplayPosition: displayColumn, columns: model.columns) != nil { + return frozen + } + return main + } + + func cellFrame(of coord: GridCoord) -> (pane: DataGridPane, tableIndex: Int, rect: NSRect)? { + let pane = editingPane(forDisplayColumn: coord.displayColumn) + let table = pane.tableView + guard coord.row >= 0, coord.row < table.numberOfRows, + let index = pane.tableIndex(forDisplayPosition: coord.displayColumn, columns: model.columns), + index < table.numberOfColumns else { return nil } + return (pane, index, table.frameOfCell(atColumn: index, row: coord.row)) + } + + // MARK: - Commit + + @discardableResult + static func commitEnteredText(_ text: String?, to target: DataGridEditTarget, in model: DataGridModel?) -> Bool { + guard let model else { return false } + if refusesEntry(text, for: target, in: model) { + NSSound.beep() + return false + } + model.commit( + [DataGridCellEdit(rowID: target.rowID, columnID: target.columnID, value: .enteredText(text))], + actionName: String(localized: "Edit Cell") + ) + return true + } + + @discardableResult + static func commitValue(_ value: PluginCellValue, to target: DataGridEditTarget, in model: DataGridModel?) -> Bool { + guard let model else { return false } + return model.commit( + [DataGridCellEdit(rowID: target.rowID, columnID: target.columnID, value: .value(value, isExpression: false))], + actionName: String(localized: "Edit Cell") + ) + } + + private static func refusesEntry(_ text: String?, for target: DataGridEditTarget, in model: DataGridModel) -> Bool { + let rows = model.rows + let ids = DataGridColumnSet.columnIDs(for: rows.columns) + guard let dataIndex = ids.firstIndex(of: target.columnID) else { return false } + let traits = model.configuration.traits + if let text, let databaseType = traits.databaseType, + CellEntry.typed( + text, + columnType: rows.editingColumnType(at: dataIndex), + databaseType: databaseType, + writer: traits.cellWriterSupport + ) != nil { + return false + } + return rows.enteredValue(text, forColumn: dataIndex) == nil + } + + // MARK: - Unloaded values + + // A value left on the server reads NULL until loaded (#3758); the retry finds the record by identity. + func loadsUnloadedValue( + of cell: DataGridEditCell, + then retry: @escaping @MainActor (DataGridViewController) -> Void + ) -> Bool { + guard cell.isUnloaded else { return true } + guard let actions = model.actions else { + NSSound.beep() + return false + } + let target = cell.target + actions.gridLoadUnloadedValues(rowIDs: [target.rowID], columns: IndexSet(integer: cell.column.dataIndex)) { [weak self] loaded in + guard let self else { return } + guard loaded, let current = self.editCell(for: target), !current.isUnloaded else { + NSSound.beep() + return + } + retry(self) + } + return false + } + + // MARK: - Opening + + func openEditor(for target: DataGridEditTarget) { + guard let cell = editCell(for: target) else { return } + openEditor(at: cell.coord) + } + + func openEditor(at coord: GridCoord) { + guard let cell = editCell(at: coord) else { return } + let target = cell.target + guard loadsUnloadedValue(of: cell, then: { $0.openEditor(for: target) }) else { return } + let mode = CellInteractionResolver().resolve(cellContext(for: cell)) + switch mode { + case .blocked: + return + case .viewText(let value): + showTextPopover(for: cell, text: value, isEditable: false) + case .viewJson: + showJSONViewer(for: cell) + case .viewBlob: + showBlobPopover(for: cell, isEditable: false) + case .viewPhpSerialized: + showPhpViewer(for: cell) + case .viewBinaryValue(let format): + showBinaryValueViewer(for: cell, format: format) + case .viewVector, .editVector: + showVectorPopover(for: cell, isEditable: mode == .editVector && textEditableValue(of: cell) != nil) + case .viewSvg, .editSvg: + showSvgPopover(for: cell, isEditable: mode == .editSvg && textEditableValue(of: cell) != nil) + case .editInline, .editText: + beginTextEditing(cell) + case .editForeignKey: + showForeignKeyPicker(for: cell) + case .editJson: + showJSONEditor(for: cell) + case .editBlob: + showBlobPopover(for: cell, isEditable: true) + } + } + + @discardableResult + func beginTextEditing(_ cell: DataGridEditCell) -> Bool { + guard let original = textEditableValue(of: cell) else { + refuseEdit(of: cell.column) + return false + } + if CellInteractionResolver.editsInPopover(original) { + return beginTextPopoverEditing(cell, text: original, original: original, selection: nil) + } + return beginFieldEditing(cell, text: original, original: original, selection: nil, reveals: true) + } + + func beginTextEditing(for target: DataGridEditTarget) { + guard let cell = editCell(for: target) else { return } + beginTextEditing(cell) + } +} diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+EditorPopovers.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+EditorPopovers.swift new file mode 100644 index 0000000000..7c4b14ce2c --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+EditorPopovers.swift @@ -0,0 +1,138 @@ +// +// DataGridViewController+EditorPopovers.swift +// TablePro +// + +import AppKit +import SwiftUI + +extension DataGridViewController { + // An .applicationDefined popover the grid forgot would strand on screen. + @discardableResult + func presentTypedPopover( + for cell: DataGridEditCell, + edits: Bool, + contentSize: NSSize? = nil, + behavior: NSPopover.Behavior = .semitransient, + @ViewBuilder content: (_ dismiss: @escaping () -> Void) -> Content + ) -> NSPopover? { + endOpenTextEditor() + closeTypedPopover() + reveal(row: cell.coord.row, displayColumn: cell.coord.displayColumn) + guard let frame = cellFrame(of: cell.coord) else { return nil } + let popover = PopoverPresenter.make(contentSize: contentSize, behavior: behavior, content: content) + let state = editingState + state.typedPopover = popover + state.typedPopoverRow = heldRow(cell) + state.typedPopoverPane = frame.pane.kind + state.typedPopoverEdits = edits + state.typedPopoverObserver.onClose = { [weak self] closed in + self?.typedPopoverDidClose(closed) + } + popover.delegate = state.typedPopoverObserver + if frame.pane.tableView.window?.isVisible == true { + popover.show(relativeTo: frame.rect, of: frame.pane.tableView, preferredEdge: .maxY) + } + return popover + } + + func closeTypedPopover() { + guard let popover = editingState.typedPopover else { return } + popover.close() + typedPopoverDidClose(popover) + } + + private func typedPopoverDidClose(_ popover: NSPopover) { + let state = editingState + guard state.typedPopover === popover else { return } + let edits = state.typedPopoverEdits + state.typedPopover = nil + state.typedPopoverRow = nil + state.typedPopoverEdits = false + if edits { + model.editingDidEnd() + } + } + + func closePoppedOutEditor() { + let state = editingState + state.poppedOutRow = nil + guard let window = state.poppedOutEditor else { return } + state.poppedOutEditor = nil + window.close() + } + + func closeCellPopovers() { + if editingState.session == nil, let viewer = editingState.textPopover { + viewer.onEnd = nil + viewer.end(.cancel) + editingState.textPopover = nil + } + closeTypedPopover() + closePoppedOutEditor() + closeForeignKeyPreview() + } + + func refreshHeldRows() { + let state = editingState + let rows = model.rows + func refreshed(_ held: DataGridHeldRow?) -> DataGridHeldRow? { + guard let held, let index = rows.index(of: held.target.rowID) else { return held } + return DataGridHeldRow(target: held.target, values: rows.rows[index].values) + } + state.typedPopoverRow = refreshed(state.typedPopoverRow) + state.poppedOutRow = refreshed(state.poppedOutRow) + } + + // The detached window needs no cell to point at, so it stays while its record exists. + func followRowChangesWithPopovers() { + let state = editingState + if let session = state.session, session.editor == .textPopover, let popover = state.textPopover { + let held = DataGridHeldRow(target: session.target, values: session.originalRow) + if let cell = editCell(for: held), let frame = cellFrame(of: cell.coord), frame.pane.kind == session.pane { + state.session?.cell = cell.coord + // Moving a popover that is not on screen raises "window must exist" in NSPopover. + if popover.popover.isShown { + popover.popover.positioningRect = frame.rect + } + } else { + finishTextPopover(.cancel) + } + } else if let viewer = state.textPopover, state.session == nil { + viewer.onEnd = nil + viewer.end(.cancel) + state.textPopover = nil + } + if let held = state.typedPopoverRow, let popover = state.typedPopover { + if let cell = editCell(for: held), let frame = cellFrame(of: cell.coord), frame.pane.kind == state.typedPopoverPane { + if popover.isShown { + popover.positioningRect = frame.rect + } + } else { + closeTypedPopover() + } + } + if let held = state.poppedOutRow { + let rows = model.rows + let holds = rows.index(of: held.target.rowID).map { rows.rows[$0].values == held.values } ?? false + if !holds { + closePoppedOutEditor() + } + } + // The value filter lists the values it opened over, which a reload, insert or removal makes stale. + dismissValueFilterPopover() + followActiveCellWithForeignKeyPreview() + } +} + +// A popover that closes itself (Escape, a click outside, its own Done) reaches the grid here. +@MainActor +final class DataGridPopoverCloseObserver: NSObject, NSPopoverDelegate { + var onClose: (@MainActor (NSPopover) -> Void)? + + // Names the popover that closed: a replaced one finishes animating out after the next is shown. + func popoverDidClose(_ notification: Notification) { + guard let popover = notification.object as? NSPopover else { return } + onClose?(popover) + } +} diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+FieldEditing.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+FieldEditing.swift new file mode 100644 index 0000000000..b5363f8f42 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+FieldEditing.swift @@ -0,0 +1,403 @@ +// +// DataGridViewController+FieldEditing.swift +// TablePro +// + +import AppKit + +// Probe P1: set the session, reload the one cell so viewFor mounts the field, editColumn; reload it again to unmount. +extension DataGridViewController { + // MARK: - Field + + @discardableResult + func beginFieldEditing( + _ cell: DataGridEditCell, + text: String, + original: String, + selection: NSRange?, + reveals: Bool + ) -> Bool { + endOpenTextEditor() + closeTypedPopover() + unmountEditingCell() + if reveals { + reveal(row: cell.coord.row, displayColumn: cell.coord.displayColumn) + } + guard let frame = cellFrame(of: cell.coord) else { return false } + let table = frame.pane.tableView + let state = editingState + state.session = DataGridEditSession( + target: cell.target, + editor: .field, + originalText: original, + originalRow: cell.row.values, + pane: frame.pane.kind, + cell: cell.coord + ) + let cellView = state.editingCellView + cellView.prepare( + text: text, + font: ThemeEngine.shared.dataGridFonts.regular, + scope: model.themeScope, + columnName: cell.column.name, + delegate: state.fieldDelegate + ) + state.fieldDelegate.controller = self + table.reloadData(forRowIndexes: IndexSet(integer: cell.coord.row), columnIndexes: IndexSet(integer: frame.tableIndex)) + guard table.view(atColumn: frame.tableIndex, row: cell.coord.row, makeIfNecessary: true) === cellView else { + state.session = nil + return false + } + cellView.layoutField() + table.editColumn(frame.tableIndex, row: cell.coord.row, with: nil, select: true) + // The frozen table refuses first responder, which editColumn may ask of it; the field itself never does. + if cellView.field.currentEditor() == nil { + view.window?.makeFirstResponder(cellView.field) + } + guard let editor = cellView.field.currentEditor() as? NSTextView else { + state.session = nil + unmountEditingCell() + return false + } + if let selection { + let length = (editor.string as NSString).length + let location = min(max(selection.location, 0), length) + editor.setSelectedRange(NSRange(location: location, length: min(max(selection.length, 0), length - location))) + } + redrawRows(IndexSet(integer: cell.coord.row)) + return true + } + + // abortEditing sends no end notification, so the commit is made here. + @discardableResult + func finishFieldEditing(commit: Bool, then movement: CellEditorMovement? = nil) -> Bool { + guard let session = editingState.session, session.editor == .field else { return true } + let text = currentFieldText() + endFieldSession() + unmountEditingCell() + restoreGridFocus() + let committed = commit ? commitText(text, for: session) : true + model.editingDidEnd() + if let movement, committed { + continueEditing(from: session.target, movement) + } + return committed + } + + func currentFieldText() -> String { + let field = editingState.editingCellView.field + return (field.currentEditor() as? NSTextView)?.string ?? field.stringValue + } + + private func currentFieldSelection() -> NSRange { + let field = editingState.editingCellView.field + if let editor = field.currentEditor() as? NSTextView { + return editor.selectedRange() + } + return NSRange(location: (field.stringValue as NSString).length, length: 0) + } + + private func endFieldSession() { + let state = editingState + guard let session = state.session, session.editor == .field else { return } + state.session = nil + state.isEndingField = true + state.editingCellView.field.abortEditing() + state.isEndingField = false + redrawRows(IndexSet(integer: session.cell.row)) + } + + // An insert or a column move can carry the field elsewhere without asking. + func unmountEditingCell() { + let cellView = editingState.editingCellView + guard let table = cellView.enclosingTableView else { return } + let row = table.row(for: cellView) + let column = table.column(for: cellView) + guard row >= 0, column >= 0, row < table.numberOfRows, column < table.numberOfColumns else { + cellView.removeFromSuperview() + return + } + table.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: column)) + } + + // After an abort the window holds first responder; the grid keeps the keyboard it had. + private func restoreGridFocus() { + guard let window = view.window, window.firstResponder === window || window.firstResponder == nil else { return } + window.makeFirstResponder(main.tableView) + } + + private func commitText(_ text: String, for session: DataGridEditSession) -> Bool { + guard text != session.originalText else { return true } + return Self.commitEnteredText(text, to: session.target, in: model) + } + + // MARK: - Field editor callbacks + + func fieldEditor(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { + guard editingState.session?.editor == .field else { return false } + switch selector { + case #selector(NSResponder.insertNewline(_:)): + finishFieldEditing(commit: true) + return true + case #selector(NSResponder.cancelOperation(_:)): + finishFieldEditing(commit: false) + return true + case #selector(NSResponder.insertTab(_:)): + guard !textView.hasMarkedText() else { return true } + finishFieldEditing(commit: true, then: .tab) + return true + case #selector(NSResponder.insertBacktab(_:)): + guard !textView.hasMarkedText() else { return true } + finishFieldEditing(commit: true, then: .backtab) + return true + case #selector(NSResponder.moveUp(_:)), #selector(NSResponder.moveDown(_:)): + let movement: CellEditorMovement = selector == #selector(NSResponder.moveUp(_:)) ? .up : .down + guard !textView.hasMarkedText() else { return false } + let exit = CellEditorArrowExit(text: textView.string as NSString, selection: textView.selectedRange()) + guard movement == .up ? exit.canExitUp : exit.canExitDown else { return false } + finishFieldEditing(commit: true, then: movement) + return true + case #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)): + moveFieldEditToPopover(inserting: "\n", replacing: textView.selectedRange()) + return true + default: + return false + } + } + + // NSTextMovement.other: focus moved away or the cell went; the text is kept. + func fieldDidEndEditing() { + let state = editingState + guard !state.isEndingField, let session = state.session, session.editor == .field else { return } + let text = state.editingCellView.field.stringValue + endFieldSession() + // The end can arrive inside a reload or a first-responder change, which a reload of the cell must not re-enter. + DispatchQueue.main.async { [weak self] in + guard let self, self.editingState.session?.editor != .field else { return } + self.unmountEditingCell() + } + if state.isApplyingChange { + state.deferredCommit = (session, text) + } else { + _ = commitText(text, for: session) + model.editingDidEnd() + } + } + + func flushDeferredCommit() { + guard let deferred = editingState.deferredCommit else { return } + editingState.deferredCommit = nil + _ = commitText(deferred.text, for: deferred.session) + model.editingDidEnd() + } + + // Pasted or dropped text with a line break goes to the popover editor, which keeps the break. + func fieldEditorInsertsLines(_ text: String, replacing range: NSRange) -> Bool { + guard editingState.session?.editor == .field else { return false } + moveFieldEditToPopover(inserting: text, replacing: range) + return true + } + + private func moveFieldEditToPopover(inserting insertion: String, replacing selection: NSRange) { + guard let session = editingState.session, session.editor == .field else { return } + let text = currentFieldText() as NSString + let location = min(max(selection.location, 0), text.length) + let replaced = NSRange(location: location, length: min(max(selection.length, 0), text.length - location)) + let combined = text.replacingCharacters(in: replaced, with: insertion) + endFieldSession() + unmountEditingCell() + guard let cell = editCell(for: session.target) else { + model.editingDidEnd() + return + } + let caret = NSRange(location: location + (insertion as NSString).length, length: 0) + if !beginTextPopoverEditing(cell, text: combined, original: session.originalText, selection: caret) { + model.editingDidEnd() + } + } + + // MARK: - Structural changes + + func suspendFieldEditing(unmounting: Bool) { + let state = editingState + guard let session = state.session, session.editor == .field else { return } + let text = currentFieldText() + let selection = currentFieldSelection() + endFieldSession() + if unmounting { + unmountEditingCell() + } + state.suspendedField = DataGridSuspendedFieldEdit(session: session, text: text, selection: selection) + } + + // Hidden by a filter, the record keeps what was typed; gone, or replaced at its RowID, it loses it. + func resumeFieldEditing() { + let state = editingState + guard let suspended = state.suspendedField else { return } + state.suspendedField = nil + let session = suspended.session + let held = DataGridHeldRow(target: session.target, values: session.originalRow) + if let cell = editCell(for: held), textEditableValue(of: cell) != nil, + beginFieldEditing(cell, text: suspended.text, original: session.originalText, selection: suspended.selection, reveals: false) { + return + } + restoreGridFocus() + let rows = model.rows + if let index = rows.index(of: session.target.rowID), rows.rows[index].values == session.originalRow { + _ = commitText(suspended.text, for: session) + } else if suspended.text != session.originalText { + NSSound.beep() + } + model.editingDidEnd() + } + + // MARK: - Moving on + + // Up and Down hold the column and stop at the ends rather than wrap. + func continueEditing(from target: DataGridEditTarget, _ movement: CellEditorMovement) { + guard let current = editCell(for: target)?.coord else { return } + let context = selectionContext + let next: GridCoord + switch movement { + case .tab, .backtab: + let moved = GridSelectionReducer.tab(GridSelectionReducer.single(current), forward: movement == .tab, in: context) + guard let active = moved.activeCell else { return } + next = active + case .up: + next = GridCoord(row: current.row - 1, displayColumn: current.displayColumn) + case .down: + next = GridCoord(row: current.row + 1, displayColumn: current.displayColumn) + } + guard next != current, next.row >= 0, next.row < context.rowCount else { return } + model.selectCell(next) + mirrorSelection() + revealActiveCell() + guard let cell = editCell(at: next), textEditableValue(of: cell) != nil else { return } + let nextTarget = cell.target + guard loadsUnloadedValue(of: cell, then: { $0.beginTextEditing(for: nextTarget) }) else { return } + beginTextEditing(cell) + } + + // MARK: - Text popover + + @discardableResult + func beginTextPopoverEditing(_ cell: DataGridEditCell, text: String, original: String, selection: NSRange?) -> Bool { + showTextPopover(for: cell, text: text, isEditable: true, original: original, selection: selection) + } + + @discardableResult + func showTextPopover( + for cell: DataGridEditCell, + text: String, + isEditable: Bool, + original: String? = nil, + selection: NSRange? = nil + ) -> Bool { + endOpenTextEditor() + closeTypedPopover() + reveal(row: cell.coord.row, displayColumn: cell.coord.displayColumn) + guard let frame = cellFrame(of: cell.coord) else { return false } + let popover = DataGridTextEditorPopover( + text: text, + isEditable: isEditable, + cellWidth: frame.rect.width, + scope: model.themeScope + ) + let state = editingState + if isEditable { + state.session = DataGridEditSession( + target: cell.target, + editor: .textPopover, + originalText: original ?? text, + originalRow: cell.row.values, + pane: frame.pane.kind, + cell: cell.coord + ) + } + state.textPopover = popover + popover.onEnd = { [weak self, weak popover] ending, text in + guard let self, let popover else { return } + self.textPopoverDidEnd(popover, ending: ending, text: text) + } + popover.show(relativeTo: frame.rect, of: frame.pane.tableView, selection: selection) + return true + } + + @discardableResult + func finishTextPopover(_ ending: DataGridTextEditorPopover.Ending) -> Bool { + guard let popover = editingState.textPopover else { return true } + var committed = true + let session = editingState.session + popover.onEnd = nil + popover.end(ending) + editingState.textPopover = nil + guard let session, session.editor == .textPopover else { return true } + editingState.session = nil + if case .commit = ending { + committed = commitText(popover.textView.string, for: session) + } + model.editingDidEnd() + return committed + } + + private func textPopoverDidEnd(_ popover: DataGridTextEditorPopover, ending: DataGridTextEditorPopover.Ending, text: String) { + let state = editingState + guard state.textPopover === popover else { return } + state.textPopover = nil + guard let session = state.session, session.editor == .textPopover else { return } + state.session = nil + var committed = true + if case .commit = ending { + committed = commitText(text, for: session) + } + model.editingDidEnd() + if case .commit(let movement?) = ending, committed { + continueEditing(from: session.target, movement) + } + } + + func endOpenTextEditor() { + guard let session = editingState.session else { + if let viewer = editingState.textPopover { + viewer.onEnd = nil + viewer.end(.cancel) + editingState.textPopover = nil + } + return + } + switch session.editor { + case .field: + finishFieldEditing(commit: true) + case .textPopover: + finishTextPopover(.commit(nil)) + } + } +} + +private extension NSView { + var enclosingTableView: NSTableView? { + var ancestor = superview + while let current = ancestor { + if let table = current as? NSTableView { return table } + ancestor = current.superview + } + return nil + } +} + +@MainActor +final class DataGridFieldEditorDelegate: NSObject, NSTextFieldDelegate { + weak var controller: DataGridViewController? + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + controller?.fieldEditor(textView, doCommandBy: commandSelector) ?? false + } + + func fieldEditorInsertsLines(_ text: String, replacing range: NSRange) -> Bool { + controller?.fieldEditorInsertsLines(text, replacing: range) ?? false + } + + func controlTextDidEndEditing(_ obj: Notification) { + controller?.fieldDidEndEditing() + } +} diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+ForeignKeys.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+ForeignKeys.swift new file mode 100644 index 0000000000..7bf6731a9b --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+ForeignKeys.swift @@ -0,0 +1,151 @@ +// +// DataGridViewController+ForeignKeys.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +extension DataGridViewController { + func foreignKeyInfo(of cell: DataGridEditCell) -> ForeignKeyInfo? { + model.rows.columnForeignKeys[cell.column.name] + } + + // MARK: - Follow + + func followForeignKey(at coord: GridCoord, opensNewTab: Bool) { + guard let cell = editCell(at: coord), let info = foreignKeyInfo(of: cell), + let value = cell.value.asText, !value.isEmpty else { return } + model.actions?.gridNavigateForeignKey(value: value, info: info, intent: opensNewTab ? .newTab : .follow) + } + + // MARK: - Picker + + // The picker writes one column, so a column of a composite key keeps the text editor. + func canChooseForeignKeyRow(for cell: DataGridEditCell) -> Bool { + guard let info = foreignKeyInfo(of: cell), textEditableValue(of: cell) != nil else { return false } + return !ForeignKeyConstraintSpan.isMultiColumn(info, among: model.rows.columnForeignKeys) + } + + // Choose Row…: the picker, whatever editor the cell's content would otherwise open. + func chooseForeignKeyRow(at coord: GridCoord) { + guard let cell = editCell(at: coord) else { return } + let target = cell.target + guard loadsUnloadedValue(of: cell, then: { controller in + guard let cell = controller.editCell(for: target) else { return } + controller.showForeignKeyPicker(for: cell) + }) else { return } + showForeignKeyPicker(for: cell) + } + + // With no dialect to search in or no connection, the cell keeps the text editor. + func showForeignKeyPicker(for cell: DataGridEditCell) { + guard canChooseForeignKeyRow(for: cell), + let info = foreignKeyInfo(of: cell), + let databaseType = editingDatabaseType, + canSearchForeignKeys(databaseType), + let scope = foreignKeyScope else { + beginTextEditing(cell) + return + } + let commit = enteredTextCommit(for: cell) + let current = cell.value.asText + let isNullable = model.rows.columnNullable[cell.column.name] ?? true + presentTypedPopover(for: cell, edits: true) { dismiss in + ForeignKeyPickerView( + scope: scope, + databaseType: databaseType, + fkInfo: info, + currentValue: current, + isNullable: isNullable, + onCommit: { commit($0) }, + onDismiss: dismiss + ) + } + } + + // MARK: - Preview + + func canPreviewForeignKey(at coord: GridCoord) -> Bool { + guard let cell = editCell(at: coord), foreignKeyInfo(of: cell) != nil, + cell.value.asText?.isEmpty == false else { return false } + return editingDatabaseType != nil && foreignKeyScope != nil + } + + func previewForeignKey(at coord: GridCoord) { + guard canPreviewForeignKey(at: coord), + let cell = editCell(at: coord), + let info = foreignKeyInfo(of: cell), + let databaseType = editingDatabaseType, + let scope = foreignKeyScope else { return } + closeForeignKeyPreview() + reveal(row: coord.row, displayColumn: coord.displayColumn) + guard let frame = cellFrame(of: coord), frame.pane.tableView.window?.isVisible == true else { return } + let previewModel = FKPreviewModel(cellValue: cell.value.asText, fkInfo: info) + let popover = PopoverPresenter.show( + relativeTo: frame.rect, + of: frame.pane.tableView, + contentSize: NSSize(width: 380, height: 400) + ) { [weak self] dismiss in + ForeignKeyPreviewView( + model: previewModel, + scope: scope, + databaseType: databaseType, + onNavigate: { [previewModel] in + dismiss() + guard let value = previewModel.cellValue else { return } + self?.model.actions?.gridNavigateForeignKey(value: value, info: previewModel.fkInfo, intent: .follow) + }, + onDismiss: dismiss + ) + } + let state = editingState + state.foreignKeyPreview = popover + state.foreignKeyPreviewModel = previewModel + state.foreignKeyPreviewColumn = cell.column.id + state.foreignKeyPreviewPane = frame.pane.kind + } + + func closeForeignKeyPreview() { + let state = editingState + let popover = state.foreignKeyPreview + state.foreignKeyPreview = nil + state.foreignKeyPreviewModel = nil + state.foreignKeyPreviewColumn = nil + popover?.close() + } + + func followActiveCellWithForeignKeyPreview() { + let state = editingState + guard let popover = state.foreignKeyPreview else { return } + guard popover.isShown, + let previewModel = state.foreignKeyPreviewModel, + let active = model.selection.activeCell, + let cell = editCell(at: active), + cell.column.id == state.foreignKeyPreviewColumn, + let info = foreignKeyInfo(of: cell), + let frame = cellFrame(of: active), + frame.pane.kind == state.foreignKeyPreviewPane else { + closeForeignKeyPreview() + return + } + previewModel.cellValue = cell.value.asText + previewModel.fkInfo = info + popover.positioningRect = frame.rect + } + + // MARK: - Menu + + @objc func previewFKReference(_ sender: Any?) { + toggleForeignKeyPreview() + } + + // validateMenuItem asks this first; nil leaves the item to the other commands. + func validateEditingCommand(_ item: NSValidatedUserInterfaceItem) -> Bool? { + guard item.action == #selector(previewFKReference(_:)) else { return nil } + if editingState.foreignKeyPreview?.isShown == true { return true } + guard let active = model.selection.activeCell else { return false } + return canPreviewForeignKey(at: active) + } +} diff --git a/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+TypedEditors.swift b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+TypedEditors.swift new file mode 100644 index 0000000000..a0a4227f07 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/Editing/DataGridViewController+TypedEditors.swift @@ -0,0 +1,432 @@ +// +// DataGridViewController+TypedEditors.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +extension DataGridViewController { + // MARK: - Chevron + + func openChevronEditor(for target: DataGridEditTarget) { + guard let cell = editCell(for: target) else { return } + openChevronEditor(at: cell.coord) + } + + func openChevronEditor(at coord: GridCoord) { + guard model.configuration.isEditable, let cell = editCell(at: coord), !isRowDeleted(cell.row.id) else { return } + guard model.isColumnWritable(cell.column) else { + refuseEdit(of: cell.column) + return + } + let target = cell.target + guard loadsUnloadedValue(of: cell, then: { $0.openChevronEditor(for: target) }) else { return } + + let configuration = model.configuration + let dataIndex = cell.column.dataIndex + if configuration.dropdownColumns?.contains(dataIndex) == true { + showDropdownMenu(for: cell) + return + } + if configuration.typePickerColumns?.contains(dataIndex) == true { + showTypePicker(for: cell) + return + } + guard let columnType = cell.column.type else { return } + let allowedValues = model.rows.columnEnumValues[cell.column.name] ?? [] + if columnType.isBooleanType { + showDropdownMenu(for: cell) + } else if columnType.supportsElementEditing { + showArrayEditor(for: cell) + } else if columnType.isVectorType { + showVectorPopover(for: cell, isEditable: textEditableValue(of: cell) != nil) + } else if !allowedValues.isEmpty { + if columnType.isSetType { + showSetMenu(for: cell, allowedValues: allowedValues) + } else { + showEnumMenu(for: cell, allowedValues: allowedValues) + } + } else if columnType.isJsonType { + showJSONEditor(for: cell) + } else if columnType.isBlobType { + showBlobPopover(for: cell, isEditable: true) + } else if columnType.isDateType { + if opensDatePicker(cell.value.asText) { + showDateTimePicker(for: cell, columnType: columnType) + } else { + beginTextEditing(cell) + } + } else if columnType.isEnumOrSetType { + beginTextEditing(cell) + } + } + + // MARK: - Commit closures + + // Never a display position: an open editor outlives a filter or a sort (interaction 5). + func enteredTextCommit(for cell: DataGridEditCell) -> (String?) -> Void { + let target = cell.target + return { [weak self, weak model = self.model] text in + Self.commitEnteredText(text, to: target, in: model) + self?.refreshHeldRows() + } + } + + func valueCommit(for cell: DataGridEditCell) -> (PluginCellValue) -> Void { + let target = cell.target + return { [weak self, weak model = self.model] value in + Self.commitValue(value, to: target, in: model) + self?.refreshHeldRows() + } + } + + // MARK: - Menus + + func showDropdownMenu(for cell: DataGridEditCell) { + reveal(row: cell.coord.row, displayColumn: cell.coord.displayColumn) + guard let frame = cellFrame(of: cell.coord) else { return } + let configuration = model.configuration + let dataIndex = cell.column.dataIndex + let current = cell.value.asText + // A list that depends on the row cannot live in a dictionary keyed by column. + let custom = model.dataSource?.gridMenuOptions(for: cell.row.id, column: cell.column) + ?? configuration.customDropdownOptions?[dataIndex] + let options: [GridMenuOption] + if let custom { + options = custom + } else if configuration.dropdownColumns?.contains(dataIndex) == true { + return + } else { + options = GridMenuOption.values(configuration.traits.usesTrueFalseBooleans ? ["true", "false"] : ["1", "0"]) + } + + let commit = enteredTextCommit(for: cell) + let target = cell.target + let menu = NSMenu() + for option in options { + switch option { + case .sectionHeader(let title): + menu.addItem(NSMenuItem.sectionHeaderCompat(title: title)) + case .value(let title, let sql): + menu.addItem(DataGridMenuAction.item(title, state: sql == current ? .on : .off) { commit(sql) }) + case .clear(let title): + menu.addItem(DataGridMenuAction.item(title, state: current == nil ? .on : .off) { commit(nil) }) + case .custom(let title): + menu.addItem(DataGridMenuAction.item(title) { [weak self] in + guard let self, let cell = self.editCell(for: target) else { return } + self.showCustomValuePopover(for: cell) + }) + } + } + let isNullable = model.rows.columnNullable[cell.column.name] ?? true + if isNullable, custom == nil { + menu.addItem(.separator()) + menu.addItem(DataGridMenuAction.item(String(localized: "Set NULL"), state: current == nil ? .on : .off) { commit(nil) }) + } + menu.popUp(positioning: nil, at: NSPoint(x: frame.rect.minX, y: frame.rect.maxY), in: frame.pane.tableView) + } + + func showEnumMenu(for cell: DataGridEditCell, allowedValues: [String]) { + reveal(row: cell.coord.row, displayColumn: cell.coord.displayColumn) + guard let frame = cellFrame(of: cell.coord) else { return } + let rows = model.rows + let name = cell.column.name + // A string default arrives as the SQL after DEFAULT, quoted; the picker matches unquoted tokens. + let storedDefault = rows.columnDefaults[name] ?? nil + let defaultValue = storedDefault.flatMap(SQLStringLiteral.unquoted) ?? storedDefault + let commit = enteredTextCommit(for: cell) + EnumMenuPicker.presentEnum( + relativeTo: frame.rect, + in: frame.pane.tableView, + allowedValues: allowedValues, + currentValue: cell.value.asText, + isNullable: rows.columnNullable[name] ?? true, + defaultValue: defaultValue + ) { commit($0) } + } + + func showSetMenu(for cell: DataGridEditCell, allowedValues: [String]) { + reveal(row: cell.coord.row, displayColumn: cell.coord.displayColumn) + guard let frame = cellFrame(of: cell.coord) else { return } + let commit = enteredTextCommit(for: cell) + EnumMenuPicker.presentSet( + relativeTo: frame.rect, + in: frame.pane.tableView, + allowedValues: allowedValues, + currentCsv: cell.value.asText + ) { commit($0) } + } + + // MARK: - Popovers + + func showCustomValuePopover(for cell: DataGridEditCell) { + let commit = enteredTextCommit(for: cell) + let escape = stringLiteralEscaper() + let prefix = SQLStringLiteralPrefix.forDatabaseType(editingDatabaseType) + let current = cell.value.asText ?? "" + presentTypedPopover(for: cell, edits: true) { dismiss in + CustomValueContentView( + initialValue: current, + escapeStringLiteral: escape, + stringLiteralPrefix: prefix, + onCommit: { commit($0) }, + onDismiss: dismiss + ) + } + } + + func showTypePicker(for cell: DataGridEditCell) { + let commit = enteredTextCommit(for: cell) + let databaseType = editingDatabaseType ?? .mysql + let scope = userDefinedTypeScope + let current = cell.value.asText ?? "" + presentTypedPopover(for: cell, edits: true) { dismiss in + UserDefinedTypeAwarePicker(scope: scope) { userDefinedTypes in + TypePickerContentView( + databaseType: databaseType, + currentValue: current, + userDefinedTypes: userDefinedTypes, + onCommit: { commit($0) }, + onDismiss: dismiss + ) + } + } + } + + // A date control has no unreadable state: a value the parser rejects keeps the text editor. + func opensDatePicker(_ value: String?) -> Bool { + guard let value, !value.trimmingCharacters(in: .whitespaces).isEmpty else { return true } + return DatabaseDateParser.parse(value) != nil + } + + func showDateTimePicker(for cell: DataGridEditCell, columnType: ColumnType) { + let commit = enteredTextCommit(for: cell) + let parsed = DatabaseDateParser.parse(cell.value.asText) + // A fraction near one second rounds up, which would open past the stored value. + let initialDate = parsed?.wholeSecond ?? Date() + let timeZone = parsed?.timeZone ?? DateEditingService.defaultTimeZone + let components = DateEditingService.components(for: columnType) + presentTypedPopover(for: cell, edits: true) { dismiss in + DateTimePickerContentView( + initialDate: initialDate, + components: components, + timeZone: timeZone, + carriesItsOwnZone: parsed?.carriesItsOwnZone ?? false, + onCommit: { picked in + guard picked != initialDate else { return } + let text = parsed.map { DateEditingService.string(from: picked, like: $0, offered: components) } + ?? DateEditingService.defaultString(from: picked, columnType: columnType) + commit(text) + }, + onDismiss: dismiss + ) + } + } + + func showArrayEditor(for cell: DataGridEditCell) { + guard let columnType = cell.column.type else { return } + let delimiter = PostgresArrayDelimiter.forColumn(columnType) + let value = cell.value + let literal: String? + if value.isNull { + literal = nil + } else { + let stored = value.asText ?? "" + guard PostgresArrayLiteralCodec.parse(stored, delimiter: delimiter) != nil else { + beginTextEditing(cell) + return + } + literal = stored + } + let rows = model.rows + let name = cell.column.name + let allowedValues = rows.columnEnumValues[name] ?? [] + let isNullable = rows.columnNullable[name] ?? true + let elementEditor = columnType.arrayElementEditor ?? .scalar + let commit = enteredTextCommit(for: cell) + presentTypedPopover(for: cell, edits: true, behavior: .applicationDefined) { dismiss in + ArrayValueEditorView( + literal: literal, + allowedValues: allowedValues, + isNullable: isNullable, + delimiter: delimiter, + elementEditor: elementEditor, + onCommit: { commit($0) }, + onDismiss: dismiss + ) + } + } + + func showVectorPopover(for cell: DataGridEditCell, isEditable: Bool) { + guard let literal = cell.value.asText, VectorLiteral.isVectorText(literal) else { + if isEditable { beginTextEditing(cell) } + return + } + let commit = enteredTextCommit(for: cell) + let expectedDimensions = cell.column.type?.vectorDimensions + presentTypedPopover( + for: cell, + edits: isEditable, + behavior: isEditable ? .applicationDefined : .semitransient + ) { dismiss in + VectorValueView( + literal: literal, + expectedDimensions: expectedDimensions, + isEditable: isEditable, + onCommit: { commit($0) }, + onDismiss: dismiss + ) + } + } + + func showSvgPopover(for cell: DataGridEditCell, isEditable: Bool) { + let commit = enteredTextCommit(for: cell) + let value = cell.value.asText ?? "" + let name = cell.column.name + presentTypedPopover(for: cell, edits: isEditable) { dismiss in + SvgViewerContentView( + initialValue: value, + isEditable: isEditable, + onDismiss: dismiss, + onCommit: isEditable ? { commit($0) } : nil, + onPopOut: { text in + dismiss() + CellImageWindowController.open(data: Data(text.utf8), format: .svg, sourceKind: .markup, columnName: name) + } + ) + } + } + + func showJSONEditor(for cell: DataGridEditCell) { + let commit = enteredTextCommit(for: cell) + let current = cell.value.asText + let name = cell.column.name + let held = heldRow(cell) + presentTypedPopover(for: cell, edits: true, contentSize: NSSize(width: 560, height: 420)) { [weak self] dismiss in + JSONEditorContentView( + initialValue: current, + columnName: name, + onCommit: { commit($0) }, + onDismiss: dismiss, + onPopOut: { text in + dismiss() + self?.popOutJSONEditor(text: text, baseline: current, columnName: name, held: held) + } + ) + } + } + + // Save compares against the stored value: the popover's text can hold an edit nothing committed yet. + private func popOutJSONEditor(text: String, baseline: String?, columnName: String, held: DataGridHeldRow) { + closePoppedOutEditor() + let target = held.target + let window = JSONViewerWindowController.open( + text: text, + baseline: baseline, + columnName: columnName, + isEditable: true, + onCommit: { [weak self, weak model = self.model] value in + Self.commitEnteredText(value, to: target, in: model) + self?.refreshHeldRows() + } + ) + editingState.poppedOutEditor = window + editingState.poppedOutRow = held + } + + func showJSONViewer(for cell: DataGridEditCell) { + let current = cell.value.asText + let name = cell.column.name + presentTypedPopover(for: cell, edits: false, contentSize: NSSize(width: 560, height: 360)) { dismiss in + JSONViewerContentView( + initialValue: current, + columnName: name, + onDismiss: dismiss, + onPopOut: { text in + dismiss() + JSONViewerWindowController.open(text: text, columnName: name, isEditable: false, onCommit: nil) + } + ) + } + } + + func showPhpViewer(for cell: DataGridEditCell) { + let current = cell.value.asText + let name = cell.column.name + presentTypedPopover(for: cell, edits: false, contentSize: NSSize(width: 560, height: 360)) { dismiss in + PhpViewerContentView( + initialValue: current, + columnName: name, + onDismiss: dismiss, + onPopOut: { text in + dismiss() + PhpViewerWindowController.open(text: text, columnName: name) + } + ) + } + } + + func showBinaryValueViewer(for cell: DataGridEditCell, format: BinaryValueFormat) { + let data: Data + switch cell.value { + case .null: return + case .bytes(let bytes): data = bytes + case .text(let text): data = text.storedBytes + } + let name = cell.column.name + presentTypedPopover(for: cell, edits: false, contentSize: NSSize(width: 560, height: 360)) { dismiss in + BinaryValueViewerView( + data: data, + format: format, + onPopOut: { + dismiss() + BinaryValueViewerWindowController.open(data: data, format: format, columnName: name) + } + ) + .frame(width: 560) + .frame(minHeight: 200, maxHeight: 480) + } + } + + func showBlobPopover(for cell: DataGridEditCell, isEditable: Bool) { + let commit = enteredTextCommit(for: cell) + let commitBytes = valueCommit(for: cell) + let current = blobText(of: cell.value) + let image = model.displayFormat(for: cell.column) == .raw ? nil : blobImage(of: cell.value) + let name = cell.column.name + presentTypedPopover(for: cell, edits: isEditable) { dismiss in + BlobPopoverContentView( + initialValue: current, + image: image, + columnName: name, + isEditable: isEditable, + onCommit: isEditable ? { commit($0) } : nil, + onCommitBytes: isEditable ? { commitBytes(.bytes($0)) } : nil, + onDismiss: dismiss + ) + } + } + + private func blobImage(of value: PluginCellValue) -> CellImageValue? { + switch value { + case .null: + return nil + case .bytes(let bytes): + guard let format = CellImageSniffer.format(of: bytes) else { return nil } + return CellImageValue(data: bytes, format: format) + case .text(let text): + guard let format = CellImageSniffer.format(ofText: text) else { return nil } + return CellImageValue(data: text.storedBytes, format: format) + } + } + + private func blobText(of value: PluginCellValue) -> String? { + switch value { + case .null: return nil + case .text(let text): return text + case .bytes(let data): return String(data: data, encoding: .isoLatin1) + } + } +} diff --git a/apple/TablePro/Views/DataGrid/HeaderSortCycle.swift b/apple/TablePro/Views/DataGrid/HeaderSortCycle.swift new file mode 100644 index 0000000000..ac546e62d1 --- /dev/null +++ b/apple/TablePro/Views/DataGrid/HeaderSortCycle.swift @@ -0,0 +1,46 @@ +// +// HeaderSortCycle.swift +// TablePro +// + +import Foundation + +struct HeaderSortTransition: Equatable { + let newState: SortState +} + +// HIG sorting: a click sorts by the column, a click on the sorted column flips it, Option-click adds or flips a +// secondary key. Don't Sort lives in the header menu. Every result is stamped .user so the app default never overwrites it. +enum HeaderSortCycle { + static func nextTransition( + state: SortState, + clickedColumn: Int, + isMultiSort: Bool, + firstClickDirection: SortDirection + ) -> HeaderSortTransition { + var next = isMultiSort + ? addingKey(state: state, clickedColumn: clickedColumn, firstClickDirection: firstClickDirection) + : replacingSort(state: state, clickedColumn: clickedColumn, firstClickDirection: firstClickDirection) + next.source = .user + return HeaderSortTransition(newState: next) + } + + private static func addingKey(state: SortState, clickedColumn: Int, firstClickDirection: SortDirection) -> SortState { + var next = state + guard let index = state.columns.firstIndex(where: { $0.columnIndex == clickedColumn }) else { + next.columns.append(SortColumn(columnIndex: clickedColumn, direction: firstClickDirection)) + return next + } + next.columns[index].direction = state.columns[index].direction.opposite + return next + } + + private static func replacingSort(state: SortState, clickedColumn: Int, firstClickDirection: SortDirection) -> SortState { + guard let primary = state.columns.first, primary.columnIndex == clickedColumn else { + return SortState(columns: [SortColumn(columnIndex: clickedColumn, direction: firstClickDirection)]) + } + var flipped = primary + flipped.direction = primary.direction.opposite + return SortState(columns: [flipped]) + } +} diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift b/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift index b6a07c6475..8fdd29deeb 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift @@ -54,13 +54,7 @@ enum ERDiagramNodeRenderer { ) canvas.clip(to: body) { - if let headerFill = palette.headerFill { - canvas.fill(headerRect, color: headerFill) - } - // A cluster's hue groups related tables, so it still washes a themed header. - if palette.headerFill == nil || clusterColor != nil { - canvas.fill(headerRect, color: palette.headerWash(cluster: clusterColor)) - } + canvas.fill(headerRect, color: palette.headerWash(cluster: clusterColor)) } canvas.stroke( @@ -76,7 +70,7 @@ enum ERDiagramNodeRenderer { canvas.drawText( displayName, font: .monospacedSystemFont(ofSize: headerPointSize * scale, weight: .semibold), - color: palette.headerText, + color: palette.text, at: CGPoint(x: rect.minX + headerTextXOffset, y: rect.minY + headerHeight / 2), anchor: .leading ) diff --git a/apple/TablePro/Views/ERDiagram/ERDiagramPalette.swift b/apple/TablePro/Views/ERDiagram/ERDiagramPalette.swift index 4f716cb659..5e1639dab8 100644 --- a/apple/TablePro/Views/ERDiagram/ERDiagramPalette.swift +++ b/apple/TablePro/Views/ERDiagram/ERDiagramPalette.swift @@ -1,20 +1,16 @@ import AppKit import TableProTheme -/// Key, link and cluster hues carry meaning, so they stay system colors under a theme. +// Key, link and cluster hues carry meaning, so they stay system colors under a theme. @MainActor internal struct ERDiagramPalette { internal let appearance: NSAppearance internal let backdrop: CGColor internal let nodeFill: CGColor - /// Nil keeps the accent wash a table header has always had, also while the theme draws the - /// header in the body's color, as the derived header does. - internal let headerFill: CGColor? internal let rule: CGColor internal let selectionRule: CGColor internal let edge: CGColor internal let text: NSColor - internal let headerText: NSColor internal let secondaryText: NSColor internal let insertedRow: CGColor internal let modifiedRow: CGColor @@ -39,13 +35,10 @@ internal struct ERDiagramPalette { backdrop = Self.bake(color(.windowBackdrop, otherwise: .controlBackgroundColor), in: appearance) nodeFill = Self.bake(color(.gridBackground, otherwise: .controlBackgroundColor), in: appearance) - headerFill = Self.headerFill(of: theme, in: appearance) rule = Self.bake(color(.gridLine, otherwise: .tertiaryLabelColor), in: appearance) - // The grid outlines its own selection with the focus border, not the row fill. - selectionRule = Self.bake(color(.gridFocusBorder, otherwise: .controlAccentColor), in: appearance) + selectionRule = Self.bake(.controlAccentColor, in: appearance) edge = Self.wash(.secondaryLabelColor, alpha: Self.edgeAlpha, in: appearance) text = color(.gridText, otherwise: .labelColor) - headerText = color(.gridHeaderText, otherwise: .labelColor) secondaryText = .secondaryLabelColor insertedRow = Self.bake(color(.gridInserted, otherwise: NativeThemeColors.insertedRow), in: appearance) modifiedRow = Self.bake(color(.gridModified, otherwise: NativeThemeColors.modifiedRow), in: appearance) @@ -66,13 +59,6 @@ internal struct ERDiagramPalette { return clusterWashes[cluster] ?? Self.wash(cluster, alpha: Self.clusterWashAlpha, in: appearance) } - private static func headerFill(of theme: ThemeDefinition, in appearance: NSAppearance) -> CGColor? { - let highContrast = appearance.isHighContrast - guard let header = theme.value(for: .gridHeaderBackground, highContrast: highContrast).themedColor, - header != theme.value(for: .gridBackground, highContrast: highContrast).themedColor else { return nil } - return bake(header.nsColor, in: appearance) - } - private static func bake(_ color: NSColor, in appearance: NSAppearance) -> CGColor { var result: CGColor? appearance.performAsCurrentDrawingAppearance { @@ -81,7 +67,7 @@ internal struct ERDiagramPalette { return result ?? color.cgColor } - /// The alpha goes on the resolved color, so the system color itself is never frozen. + // The alpha goes on the resolved color, so the system color itself is never frozen. private static func wash(_ color: NSColor, alpha: CGFloat, in appearance: NSAppearance) -> CGColor { let resolved = bake(color, in: appearance) return resolved.copy(alpha: alpha) ?? resolved diff --git a/apple/TablePro/Views/Main/Child/DataGridModelReader.swift b/apple/TablePro/Views/Main/Child/DataGridModelReader.swift new file mode 100644 index 0000000000..9190709fe8 --- /dev/null +++ b/apple/TablePro/Views/Main/Child/DataGridModelReader.swift @@ -0,0 +1,40 @@ +// +// DataGridModelReader.swift +// TablePro +// + +import SwiftUI + +// Re-renders its content when the model's selection or display order moves, for views outside the grid. +struct DataGridModelReader: View { + @ObservedObject var model: DataGridModel + @ViewBuilder let content: (DataGridModel) -> Content + + var body: some View { + content(model) + } +} + +// Runs `onChange` for every selection change of the model, and once when it starts watching another one. +struct GridSelectionWatcher: View { + @ObservedObject var model: DataGridModel + let onAttach: () -> Void + let onChange: (_ rowsChanged: Bool) -> Void + @State private var selectedRowIDs: [RowID] = [] + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .onAppear { + selectedRowIDs = model.selectedRowIDs + onAttach() + } + .onChange(of: model.selectionRevision) { _ in + let rowIDs = model.selectedRowIDs + let rowsChanged = rowIDs != selectedRowIDs + selectedRowIDs = rowIDs + onChange(rowsChanged) + } + } +} diff --git a/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift b/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift new file mode 100644 index 0000000000..fc724a2854 --- /dev/null +++ b/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift @@ -0,0 +1,316 @@ +// +// DataTabGridAdapter.swift +// TablePro +// + +import AppKit +import Combine +import TableProPluginKit + +@MainActor +final class DataTabGridAdapter: DataGridDataSource, DataGridActions { + private static let detachedChangeState = AnyChangeManager(DataChangeManager()) + + let tabId: UUID + private weak var coordinator: MainContentCoordinator? + private weak var model: DataGridModel? + private let sqlExporter: DataTabSQLExporter + var pendingColumnJump: ColumnJumpRequest? + + init(tabId: UUID, coordinator: MainContentCoordinator) { + self.tabId = tabId + self.coordinator = coordinator + self.sqlExporter = DataTabSQLExporter(tabId: tabId, coordinator: coordinator) + } + + // A refresh deferred while a cell was open runs once the edit ends, committed, cancelled or unchanged, on the + // next turn, as after a commit, so the editor finishes tearing down first. + func attach(_ model: DataGridModel) { + self.model = model + model.onEditingEnded = { [weak coordinator] in + Task { @MainActor [weak coordinator] in + coordinator?.resumeDeferredTableRefresh() + } + } + } + + private var tab: QueryTab? { + coordinator?.tabManager.tabs.first { $0.id == tabId } + } + + // The change manager and the window's row commands belong to the selected tab only. + private var ownsWindowState: Bool { + coordinator?.changeManagerTabId == tabId + } + + // MARK: - DataGridDataSource + + var gridRows: TableRows { + coordinator?.tabSessionRegistry.existingTableRows(for: tabId) ?? TableRows() + } + + var gridChangeState: AnyChangeManager { + coordinator?.gridChangeState ?? Self.detachedChangeState + } + + var gridRowNumberOffset: Int { + tab?.pagination.currentOffset ?? 0 + } + + var gridHasUnloadedRows: Bool { + guard let tab else { return false } + return tab.pagination.hasUnloadedRows(loadedRowCount: gridRows.rows.count) + } + + var gridHighlightRules: [HighlightRule] { + guard let coordinator, let tab else { return [] } + return coordinator.highlightRules(for: tab) + } + + // MARK: - Edits + + // A commit against a tab the shared change manager no longer holds would land in another table (H2). + func gridCommit(_ edits: [DataGridResolvedEdit], actionName: String) -> Bool { + guard let coordinator, ownsWindowState else { return false } + let committed = coordinator.rowEditingCoordinator.commitGridEdits(edits, actionName: actionName, tabId: tabId) + if committed { + coordinator.gridCellsEdited.send() + Task { @MainActor [weak coordinator] in + coordinator?.resumeDeferredTableRefresh() + } + } + return committed + } + + func gridDeleteRows(_ rowIDs: [RowID]) { + guard ownsWindowState else { return } + coordinator?.deleteRows(rowIDs) + } + + // The window's own copy, whose paste payload carries staged SQL, as Copy Rows always was here. + func gridCopyRows(_ rowIDs: [RowID]) -> Bool { + guard let coordinator, ownsWindowState, !rowIDs.isEmpty else { return false } + coordinator.copyRowsToClipboard(rowIDs) + return true + } + + func gridUndoDelete(_ rowIDs: [RowID]) { + guard ownsWindowState else { return } + coordinator?.undoRowDeletions(rowIDs) + } + + func gridAddRow() { + guard ownsWindowState else { return } + coordinator?.addNewRow() + } + + func gridDuplicateRows(_ rowIDs: [RowID]) { + guard ownsWindowState, let first = rowIDs.first else { return } + coordinator?.duplicateRow(first) + } + + func gridCanPasteRows() -> Bool { + ownsWindowState && (coordinator?.commandActions?.canPasteRows ?? false) + } + + func gridPasteRows() { + guard ownsWindowState else { return } + coordinator?.pasteRows() + } + + func gridLoadUnloadedValues( + rowIDs: [RowID], + columns: IndexSet?, + completion: @escaping @MainActor (Bool) -> Void + ) { + guard let coordinator else { + completion(false) + return + } + let tabId = tabId + Task { @MainActor in + completion(await coordinator.loadUnloadedValues(tabId: tabId, rowIDs: rowIDs, columns: columns)) + } + } + + // MARK: - Display order + + // The gate sort, pagination and the WHERE filter take; it answers at once when nothing would be lost. + func gridConfirmDisplayOrderChange(then apply: @escaping @MainActor () -> Void) { + guard let coordinator else { + apply() + return + } + coordinator.confirmDiscardRestoringRowsIfNeeded(action: .displayOrder) { confirmed in + guard confirmed else { return } + apply() + } + } + + // MARK: - Columns + + func gridFilterWithColumn(_ column: DataGridColumn) { + guard ownsWindowState else { return } + coordinator?.addFilterForColumn(column.name) + } + + func gridHideColumn(_ column: DataGridColumn) { + guard ownsWindowState else { return } + coordinator?.hideColumn(column.name) + } + + func gridShowAllColumns() { + guard ownsWindowState else { return } + coordinator?.showAllColumns() + } + + // MARK: - Navigation and results + + func gridNavigateForeignKey(value: String, info: ForeignKeyInfo, intent: ReferenceOpenIntent) { + coordinator?.navigateToFKReference(value: value, fkInfo: info, intent: intent) + } + + // The inspector shows the selection, so rows the menu was opened on outside it become the selection. + func gridShowRowsAsJSON(_ rowIDs: [RowID]) { + guard ownsWindowState else { return } + if let model, !rowIDs.isEmpty, Set(model.selectedRowIDs) != Set(rowIDs) { + model.selectRows(rowIDs) + } + coordinator?.showRowAsJSON() + } + + func gridExportResults() { + AppCommands.shared.exportQueryResults.send(()) + } + + func gridClearResults() { + guard ownsWindowState else { return } + coordinator?.clearActiveQueryResults() + } + + func gridSearchAllRows(term: String) { + coordinator?.findCoordinator.searchAllRows(term: term, tabID: tabId) + } + + var gridSQLExporter: (any DataGridSQLExporting)? { + sqlExporter + } + + // MARK: - Menus + + func gridRowMenuItems(for rowIDs: [RowID], clickedRow: RowID) -> [NSMenuItem] { + guard let coordinator, ownsWindowState, coordinator.canInsertDocument else { return [] } + var items: [NSMenuItem] = [] + // Read while the menu is built, so a reload under the open menu cannot retarget the edit. + if rowIDs.count == 1, let rowID = rowIDs.first, let locator = coordinator.documentLocator(for: rowID) { + items.append(ClosureMenuTarget.item(title: String(localized: "Edit Document…")) { [weak coordinator] in + coordinator?.presentEditDocument(locator: locator) + }) + } + items.append(ClosureMenuTarget.item(title: String(localized: "Insert Document…")) { [weak coordinator] in + coordinator?.presentInsertDocument() + }) + return items + } + + func gridCellMenuItems(for rowID: RowID, column: DataGridColumn) -> [NSMenuItem] { + guard let coordinator, ownsWindowState else { return [] } + let rows = gridRows + guard let row = rows.row(withID: rowID), column.dataIndex < row.values.count else { return [] } + var items: [NSMenuItem] = [] + if let filterItem = filterMenuItem(row: row, column: column, in: rows, coordinator: coordinator) { + items.append(filterItem) + } + if let highlightItem = highlightMenuItem(row: row, column: column, in: rows, coordinator: coordinator) { + items.append(highlightItem) + } + return items + } + + // Only a value the server holds: a filter built from an inserted row or an edited cell finds nothing. + private func filterMenuItem( + row: Row, + column: DataGridColumn, + in rows: TableRows, + coordinator: MainContentCoordinator + ) -> NSMenuItem? { + guard coordinator.canFilterRows, + !row.id.isInserted, + !coordinator.changeManager.isCellModified(rowID: row.id, columnIndex: column.dataIndex) else { return nil } + let tabId = tabId + return CellFilterMenuBuilder.menuItem( + columnName: column.name, + columnType: column.type, + value: row.values[column.dataIndex], + family: SQLTypeFamily.of(coordinator.connection.type) + ) { [weak coordinator] filter in + coordinator?.applyCellFilter(filter, forTab: tabId) + } + } + + private func highlightMenuItem( + row: Row, + column: DataGridColumn, + in rows: TableRows, + coordinator: MainContentCoordinator + ) -> NSMenuItem? { + guard let tab else { return nil } + let tabId = tabId + let context = HighlightMenuBuilder.CellContext( + columnName: column.name, + columnOccurrence: HighlightRuleSet.occurrence(ofColumnAt: column.dataIndex, in: rows.columns), + columnType: column.type, + value: row.values[column.dataIndex], + existingRules: coordinator.highlightRules(for: tab) + ) + let actions = HighlightMenuBuilder.Actions( + apply: { [weak coordinator] rule in + coordinator?.applyQuickHighlight(rule, forTab: tabId) + }, + remove: { [weak coordinator] rule in + coordinator?.removeHighlightRules(sharingConditionWith: rule, forTab: tabId) + }, + showRules: { [weak coordinator] in + coordinator?.presentHighlightRules() + } + ) + return HighlightMenuBuilder.menuItem(for: context, actions: actions) + } + + func gridEmptySpaceMenu() -> NSMenu? { + guard let coordinator, ownsWindowState else { return nil } + var items: [NSMenuItem] = [] + if coordinator.canAddRow { + items.append(ClosureMenuTarget.item(title: String(localized: "Add Row")) { [weak coordinator] in + coordinator?.addNewRow() + }) + } + if coordinator.canInsertDocument { + items.append(ClosureMenuTarget.item(title: String(localized: "Insert Document…")) { [weak coordinator] in + coordinator?.presentInsertDocument() + }) + } + guard !items.isEmpty else { return nil } + let menu = NSMenu() + items.forEach(menu.addItem) + return menu + } + + func gridHighlightValuesMenuItem(for column: DataGridColumn) -> NSMenuItem? { + guard let coordinator, ownsWindowState else { return nil } + let occurrence = HighlightRuleSet.occurrence(ofColumnAt: column.dataIndex, in: gridRows.columns) + let name = column.name + return ClosureMenuTarget.item(title: String(localized: "Highlight Values…")) { [weak coordinator] in + coordinator?.presentHighlightRules(addingRuleForColumn: name, occurrence: occurrence) + } + } +} + +// A jump to a column the result does not present yet, landed once the column arrives. +struct ColumnJumpRequest: Equatable { + let name: String + let dataIndex: Int? + let tableKey: ColumnLayoutTableKey? + // A table tab fetches a column to show it, so its jump waits for that result rather than an interim update. + let awaitsResultReplacement: Bool +} diff --git a/apple/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/apple/TablePro/Views/Main/Child/DataTabGridDelegate.swift deleted file mode 100644 index 7d23be8eb3..0000000000 --- a/apple/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ /dev/null @@ -1,245 +0,0 @@ -// -// DataTabGridDelegate.swift -// TablePro -// -// DataGridViewDelegate for the data tab in MainEditorContentView. -// Bridges delegate calls to MainContentCoordinator and view-level callbacks. -// - -import AppKit -import Combine - -@MainActor -final class DataTabGridDelegate: DataGridViewDelegate { - weak var coordinator: MainContentCoordinator? - - var selectionState: GridSelectionState? - - var onCellEdit: ((Int, Int, String?) -> Void)? - var onSortStateChanged: ((SortState) -> Void)? - var onAddRow: (() -> Void)? - var onFilterColumn: ((String) -> Void)? - - // MARK: - DataGridViewDelegate - - func dataGridDidEditCell(row: Int, column: Int, newValue: String?) { - onCellEdit?(row, column, newValue) - } - - func dataGridSortStateChanged(_ state: SortState) { - onSortStateChanged?(state) - } - - /// The same gate sort, pagination and the WHERE filter go through. `confirmDiscardChangesIfNeeded` - /// answers immediately when there is nothing to lose, so an unedited result never sees an alert. - func dataGridConfirmDisplayOrderChange(then apply: @escaping () -> Void) { - guard let coordinator else { - apply() - return - } - coordinator.confirmDiscardRestoringRowsIfNeeded(action: .displayOrder) { confirmed in - guard confirmed else { return } - apply() - } - } - - func dataGridDisplayOrderChanged() { - coordinator?.gridDisplayRevision &+= 1 - } - - func dataGridDisplayFormatChanged() { - coordinator?.gridDisplayRevision &+= 1 - } - - func dataGridAddRow() { - onAddRow?() - } - - func dataGridFilterColumn(_ columnName: String) { - onFilterColumn?(columnName) - } - - func dataGridDeleteRows(_ indices: Set) { - coordinator?.deleteSelectedRows(indices: indices) - } - - func dataGridCopyRows(_ indices: Set) { - coordinator?.copySelectedRowsToClipboard(indices: indices) - } - - func dataGridPasteRows() { - coordinator?.pasteRows() - } - - func dataGridCanPasteRows() -> Bool { - coordinator?.commandActions?.canPasteRows ?? false - } - - func dataGridDuplicateRow() { - guard let selectionState, let firstIndex = selectionState.indices.first else { return } - coordinator?.duplicateSelectedRow(index: firstIndex) - } - - func dataGridExportResults() { - AppCommands.shared.exportQueryResults.send(()) - } - - func dataGridClearResults() { - coordinator?.clearActiveQueryResults() - } - - func dataGridCanClearResults() -> Bool { - coordinator?.canClearActiveQueryResults ?? false - } - - func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo, intent: ReferenceOpenIntent) { - coordinator?.navigateToFKReference(value: value, fkInfo: fkInfo, intent: intent) - } - - /// The panel reads the selection, not a row this is told about. - /// - /// `KeyHandlingTableView.menu(for:)` retargets the selection to a row clicked outside it, so - /// the usual single-row case shows the row the reader asked about. A click inside a multi-row - /// selection keeps that selection on purpose, and the panel then shows its first row, which is - /// the row the Details tab shows too. - func dataGridShowRowAsJSON() { - coordinator?.showRowAsJSON() - } - - func dataGridHideColumn(_ columnName: String) { - coordinator?.hideColumn(columnName) - } - - func dataGridShowAllColumns() { - coordinator?.showAllColumns() - } - - func dataGridEmptySpaceMenu() -> NSMenu? { - var items: [NSMenuItem] = [] - if let onAddRow, coordinator?.canAddRow == true { - items.append(Self.menuItem(String(localized: "Add Row"), action: onAddRow)) - } - if let coordinator, coordinator.canInsertDocument { - items.append(Self.menuItem(String(localized: "Insert Document…")) { [weak coordinator] in - coordinator?.presentInsertDocument() - }) - } - guard !items.isEmpty else { return nil } - let menu = NSMenu() - items.forEach(menu.addItem) - return menu - } - - /// The row's locator is read while the menu is built and carried by the item, so a reload that - /// lands while the menu is open cannot turn the click into an edit of another document. - func dataGridDocumentMenuItems(forRow displayRow: Int) -> [NSMenuItem] { - guard let coordinator, coordinator.canInsertDocument else { return [] } - var items: [NSMenuItem] = [] - if let locator = coordinator.documentLocator(forDisplayRow: displayRow) { - items.append(Self.menuItem(String(localized: "Edit Document…")) { [weak coordinator] in - coordinator?.presentEditDocument(locator: locator) - }) - } - items.append(Self.menuItem(String(localized: "Insert Document…")) { [weak coordinator] in - coordinator?.presentInsertDocument() - }) - return items - } - - private static func menuItem(_ title: String, action: @escaping () -> Void) -> NSMenuItem { - let target = StructureMenuTarget(action: action) - let item = NSMenuItem(title: title, action: #selector(StructureMenuTarget.runAction), keyEquivalent: "") - item.target = target - item.representedObject = target - return item - } - - func dataGridHighlightMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? { - guard let coordinator, - let grid = tableViewCoordinator, - let tab = coordinator.tabManager.selectedTab, - let row = grid.displayRow(at: displayRow) else { return nil } - let tableRows = grid.tableRowsProvider() - let columns = tableRows.columns - guard columns.indices.contains(dataColumn), dataColumn < row.values.count else { return nil } - - let tabId = tab.id - let context = HighlightMenuBuilder.CellContext( - columnName: columns[dataColumn], - columnOccurrence: HighlightRuleSet.occurrence(ofColumnAt: dataColumn, in: columns), - columnType: dataColumn < tableRows.columnTypes.count ? tableRows.columnTypes[dataColumn] : nil, - value: row.values[dataColumn], - existingRules: coordinator.highlightRules(for: tab) - ) - let actions = HighlightMenuBuilder.Actions( - apply: { [weak coordinator] rule in - coordinator?.applyQuickHighlight(rule, forTab: tabId) - }, - remove: { [weak coordinator] rule in - coordinator?.removeHighlightRules(sharingConditionWith: rule, forTab: tabId) - }, - showRules: { [weak coordinator] in - coordinator?.presentHighlightRules() - } - ) - return HighlightMenuBuilder.menuItem(for: context, actions: actions) - } - - /// Offered only for a value the server holds: a row the reader inserted, or a cell they edited, - /// is not on the server yet, so a filter built from it cannot find the row it came from. - func dataGridFilterMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? { - guard let coordinator, coordinator.canFilterRows, - let grid = tableViewCoordinator, - let tabId = coordinator.tabManager.selectedTab?.id else { return nil } - return grid.cellFilterMenuItem(forRow: displayRow, dataColumn: dataColumn) { [weak coordinator] filter in - coordinator?.applyCellFilter(filter, forTab: tabId) - } - } - - func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem? { - guard coordinator != nil, let grid = tableViewCoordinator else { return nil } - let columns = grid.tableRowsProvider().columns - guard columns.indices.contains(dataColumnIndex) else { return nil } - let columnName = columns[dataColumnIndex] - let occurrence = HighlightRuleSet.occurrence(ofColumnAt: dataColumnIndex, in: columns) - return ClosureMenuTarget.item(title: String(localized: "Highlight Values…")) { [weak coordinator] in - coordinator?.presentHighlightRules(addingRuleForColumn: columnName, occurrence: occurrence) - } - } - - weak var tableViewCoordinator: TableViewCoordinator? - - func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { - self.tableViewCoordinator = tableViewCoordinator - } - - func dataGridDidInsertRows(at indices: IndexSet) { - tableViewCoordinator?.applyInsertedRows(indices) - } - - func dataGridDidRemoveRows(at indices: IndexSet) { - tableViewCoordinator?.applyRemovedRows(indices) - } - - func dataGridDidReplaceAllRows() { - tableViewCoordinator?.applyFullReplace() - } - - func dataGridDidCloseCellOverlay() { - coordinator?.resumeDeferredTableRefresh() - } - - func dataGridLoadUnloadedValues( - rowIDs: [RowID], - columns: IndexSet?, - completion: @escaping @MainActor (Bool) -> Void - ) { - guard let coordinator, let tabId = coordinator.tabManager.selectedTab?.id else { - completion(false) - return - } - Task { @MainActor in - completion(await coordinator.loadUnloadedValues(tabId: tabId, rowIDs: rowIDs, columns: columns)) - } - } -} diff --git a/apple/TablePro/Views/Main/Child/DataTabSQLExporter.swift b/apple/TablePro/Views/Main/Child/DataTabSQLExporter.swift new file mode 100644 index 0000000000..87addaed2a --- /dev/null +++ b/apple/TablePro/Views/Main/Child/DataTabSQLExporter.swift @@ -0,0 +1,170 @@ +// +// DataTabSQLExporter.swift +// TablePro +// + +import Foundation +import os +import TableProAutocomplete +import TableProPluginKit + +@MainActor +final class DataTabSQLExporter: DataGridSQLExporting { + private static let logger = Logger(subsystem: "com.TablePro", category: "DataTabSQLExporter") + + private let tabId: UUID + private weak var coordinator: MainContentCoordinator? + + init(tabId: UUID, coordinator: MainContentCoordinator?) { + self.tabId = tabId + self.coordinator = coordinator + } + + private struct Target { + let tableName: String + let schemaName: String? + let primaryKeyColumns: [String] + let databaseType: DatabaseType + let connectionId: UUID + let changeManager: DataChangeManager + let driver: (any DatabaseDriver)? + } + + private var target: Target? { + guard let coordinator, + let tab = coordinator.tabManager.tabs.first(where: { $0.id == tabId }), + let tableName = tab.tableContext.tableName else { return nil } + return Target( + tableName: tableName, + schemaName: tab.tableContext.schemaName, + primaryKeyColumns: tab.tableContext.primaryKeyColumns, + databaseType: coordinator.connection.type, + connectionId: coordinator.connectionId, + changeManager: coordinator.changeManager, + driver: coordinator.services.databaseManager.driver(for: coordinator.connectionId) + ) + } + + // The statements leave out the columns the server owns, which only the schema names. + func canCopyAsInsert(_ rows: TableRows) -> Bool { + guard let target, target.databaseType != .mongodb, target.databaseType != .redis else { return false } + return rows.hasAuthoritativeSchema + } + + // A table without a key is matched on every column, so one no match can compare leaves nothing to find the row by. + func canCopyAsUpdate(_ rows: TableRows) -> Bool { + guard canCopyAsInsert(rows), let target else { return false } + return !target.primaryKeyColumns.isEmpty || rows.rowMatchPolicy.excludedColumns.isEmpty + } + + func insertStatements(for rowIDs: [RowID], columns: [DataGridColumn], in rows: TableRows) -> String? { + guard let target else { return nil } + let projection = VisibleColumnProjection(indices: columns.map(\.dataIndex)) + do { + let converter = try SQLRowToStatementConverter( + tableName: target.tableName, + schemaName: target.schemaName, + columns: projection.columns(rows.columns), + primaryKeyColumns: target.primaryKeyColumns, + databaseType: target.databaseType, + unwritableColumns: unwritableColumns(in: rows, target: target), + quoteIdentifier: target.driver?.quoteIdentifier, + escapeStringLiteral: target.driver?.escapeStringLiteral, + identifierSpelling: identifierSpelling(for: target) + ) + let sources = rowIDs.compactMap { rows.row(withID: $0) }.map { row in + SQLRowToStatementConverter.SourceRow( + values: projection.values(Array(row.values)), + defaultedColumns: projection.positions(of: stagedDefaults(of: row, target: target)), + expressionColumns: projection.positions(of: target.changeManager.expressionColumns(forRow: row.id)) + ) + } + return nonEmpty(converter.generateInserts(rows: sources)) + } catch { + Self.logger.error("Copy as INSERT failed: \(error.publicLogShape, privacy: .public)") + return nil + } + } + + // SET covers the columns asked for; the row is found by all of its key, or every stored column, hidden ones included. + func updateStatements(for rowIDs: [RowID], columns: [DataGridColumn], in rows: TableRows) -> String? { + guard let target else { return nil } + do { + let converter = try SQLRowToStatementConverter( + tableName: target.tableName, + schemaName: target.schemaName, + columns: rows.columns, + primaryKeyColumns: target.primaryKeyColumns, + databaseType: target.databaseType, + unwritableColumns: unwritableColumns(in: rows, target: target), + rowMatchPolicy: rows.rowMatchPolicy, + settableColumns: columns.map(\.name), + quoteIdentifier: target.driver?.quoteIdentifier, + escapeStringLiteral: target.driver?.escapeStringLiteral, + identifierSpelling: identifierSpelling(for: target), + rowUpdateStatement: target.driver?.rowUpdateStatement, + matchesEveryColumn: target.driver?.primaryKeyNamesOneRow == false + ) + let sources = rowIDs.compactMap { rows.row(withID: $0) }.map { sourceRow($0, target: target) } + return nonEmpty(converter.generateUpdates(rows: sources)) + } catch { + Self.logger.error("Copy as UPDATE failed: \(error.publicLogShape, privacy: .public)") + return nil + } + } + + // Any result, a join included, needs its engine's escaping: MySQL reads a backslash as one. + func inClause(values: [PluginCellValue], column: DataGridColumn) -> String? { + guard !values.isEmpty, let coordinator else { return nil } + let databaseType = coordinator.connection.type + let driver = coordinator.services.databaseManager.driver(for: coordinator.connectionId) + let converter = InClauseConverter( + columnIndex: 0, + columnTypes: [column.type ?? .text(rawType: nil)], + escapeStringLiteral: driver?.escapeStringLiteral, + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType) + ) + return converter.generateInClause(rows: values.map { [$0] }) + } + + private func identifierSpelling(for target: Target) -> SQLIdentifierSpelling? { + guard let rules = target.databaseType.identifierRules else { return nil } + return SQLIdentifierSpelling(rules: rules, policy: SQLIdentifierCase.stored(for: target.connectionId)) + } + + private func unwritableColumns(in rows: TableRows, target: Target) -> Set { + let immutable = Set(PluginManager.shared.immutableColumns(for: target.databaseType)) + return Set(rows.columns.filter { name in + rows.generatedColumns.contains(name) || immutable.contains(name) || !target.changeManager.isColumnWritable(name) + }) + } + + private func sourceRow(_ row: Row, target: Target) -> SQLRowToStatementConverter.SourceRow { + var stored = Array(row.values) + let pendingUpdate = target.changeManager.rowChanges.first { $0.rowID == row.id && $0.type == .update } + for cellChange in pendingUpdate?.cellChanges ?? [] where stored.indices.contains(cellChange.columnIndex) { + stored[cellChange.columnIndex] = cellChange.oldValue + } + return SQLRowToStatementConverter.SourceRow( + values: Array(row.values), + storedValues: stored, + isPendingInsert: target.changeManager.insertedRowIDs.contains(row.id), + defaultedColumns: stagedDefaults(of: row, target: target), + expressionColumns: target.changeManager.expressionColumns(forRow: row.id) + ) + } + + // Every marker in a row not saved yet, and in a saved row the cells a pending edit set to Default. + private func stagedDefaults(of row: Row, target: Target) -> Set { + let marker = PluginCellValue.text("__DEFAULT__") + if target.changeManager.insertedRowIDs.contains(row.id) { + return Set(row.values.indices.filter { row.values[$0] == marker }) + } + let pendingUpdate = target.changeManager.rowChanges.first { $0.rowID == row.id && $0.type == .update } + return Set(pendingUpdate?.cellChanges.filter { $0.newValue == marker }.map(\.columnIndex) ?? []) + } + + private func nonEmpty(_ statements: String) -> String? { + statements.isEmpty ? nil : statements + } +} diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index 370ecef4c2..366fd67835 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -11,17 +11,13 @@ import SwiftUI import TableProEditorKit import TableProPluginKit -/// Identity for the visibility-scoped lazy-load `.task(id:)` modifier on -/// `MainEditorContentView`. Changes to either field cancel the previous -/// task and start a new one — exactly the rapid-switch coalescing semantic -/// we want for Cmd+Number tab navigation. +// A change to either field cancels the old task and starts a new one, which coalesces rapid Cmd+Number switches. private struct TabLoadKey: Hashable { let tabId: UUID? let loadEpoch: Int } -/// Picking another database reruns the tab only while it shows rows. An error, a plan or a write's -/// count is not a view of the data the new database holds, and rerunning it was an unasked run. +// Only a tab showing rows reruns: an error, a plan or a write's count is not a view of the new database's data. @MainActor internal enum ContainerChangeRerunPolicy { internal static func reruns(showing activeResult: ResultSet?) -> Bool { @@ -36,9 +32,7 @@ struct MainEditorContentView: View { @ObservedObject private var schemaService = SchemaService.shared @ObservedObject private var licenseManager = LicenseManager.shared @ObservedObject private var settingsManager = AppSettingsManager.shared - /// A query tab nests its own editor/results split, whose two minimums are required constraints. - /// The drawer's own minimum has to clear their sum, or dragging the drawer down asks AppKit to - /// satisfy a height the content it contains cannot reach. + // Must clear the nested editor/results split's required minimums, or dragging the drawer asks AppKit for an unreachable height. static let tabContentMinimumHeight = CollapsibleSplitController.combinedMinimumThickness // MARK: - Dependencies @@ -46,26 +40,15 @@ struct MainEditorContentView: View { @ObservedObject var tabManager: QueryTabManager @ObservedObject var coordinator: MainContentCoordinator - /// The drawer state is a per-connection singleton behind a factory, so it is handed in - /// rather than resolved in `body`, where nothing would observe it. + // Handed in, not resolved in `body` where nothing would observe it: the drawer state is a per-connection singleton. @ObservedObject var historyState: HistoryPanelState @ObservedObject var changeManager: DataChangeManager let connection: DatabaseConnection let windowId: UUID let connectionId: UUID - // MARK: - Selection State - - @ObservedObject var selectionState: GridSelectionState - // MARK: - Callbacks - let onCellEdit: (Int, Int, String?) -> Void - let onSortStateChanged: (SortState) -> Void - let onAddRow: () -> Void - let onSelectionChange: (Set) -> Void - let onFilterColumn: (String) -> Void - let onFirstPage: () -> Void let onPreviousPage: () -> Void let onNextPage: () -> Void @@ -74,38 +57,17 @@ struct MainEditorContentView: View { let onShowAll: () -> Void let onGoToPage: (Int) -> Void - @State private var cachedChangeManager: AnyChangeManager? @State private var queryPlanViewStates = QueryPlanViewStateStore() @State private var serverDashboardViewModels: [UUID: ServerDashboardViewModel] = [:] @State private var usersRolesViewModels: [UUID: UsersRolesViewModel] = [:] @State private var queryInsightsViewModels: [UUID: QueryInsightsViewModel] = [:] @State private var tableListingModels = TableListingModelStore() - @State private var dataTabDelegate = DataTabGridDelegate() @ObservedObject private var treeService = DatabaseTreeMetadataService.shared - /// A table's highlight rules live in this store, not on the tab, and `body` reads them for both - /// the grid and the status bar popover. Without observing it here a rule added to a table tab was - /// written and never shown: Add Rule did nothing visible, while a query result's rules, kept on - /// the tab, updated as expected. + // Table highlight rules live here, not on the tab; unobserved, Add Rule on a table tab changed nothing on screen. @ObservedObject private var highlightRuleStorage = HighlightRuleStorage.shared - // Native macOS window tabs — no LRU tracking needed (single tab per window) - - // MARK: - Environment - - - /// Returns the cached AnyChangeManager, creating it on first access. - private var currentChangeManager: AnyChangeManager { - if let existing = cachedChangeManager { - return existing - } - // Fallback before onAppear initializes cachedChangeManager. - // Safe: onAppear fires before any user interaction needs it. - return AnyChangeManager(changeManager) - } - - /// The tip that tells a first-time reader where past queries are. It is answered once history - /// has been on screen, which is now the trailing pane rather than a drawer under the editor. + // The tip is answered once history has been on screen, now the trailing pane rather than a drawer under the editor. private var showsHistoryTip: Bool { let historyState = HistoryPanelState.forConnection(connectionId) return !historyState.isVisible && !historyState.isCapturePaused @@ -206,12 +168,6 @@ struct MainEditorContentView: View { } .onAppear { updateHasQueryText() - cachedChangeManager = AnyChangeManager(changeManager) - wireDataTabDelegateStableRefs() - coordinator.dataTabDelegate = dataTabDelegate - } - .onDisappear { - cachedChangeManager = nil } .task(id: TabLoadKey( tabId: tabManager.selectedTabId, @@ -219,18 +175,6 @@ struct MainEditorContentView: View { )) { coordinator.lazyLoadCurrentTabIfNeeded() } - .onChange(of: selectionState.indices) { newIndices in - onSelectionChange(newIndices) - } - } - - private func wireDataTabDelegateStableRefs() { - dataTabDelegate.coordinator = coordinator - dataTabDelegate.selectionState = selectionState - dataTabDelegate.onCellEdit = onCellEdit - dataTabDelegate.onSortStateChanged = onSortStateChanged - dataTabDelegate.onAddRow = onAddRow - dataTabDelegate.onFilterColumn = onFilterColumn } // MARK: - Tab Content @@ -463,8 +407,6 @@ struct MainEditorContentView: View { PluginManager.shared.containerSwitchTarget(for: connection.type) } - /// The schemas on an engine whose entries are schemas: a query tab is bound to one, and this is - /// how it is pointed at another. private func containerDatabases(for tab: QueryTab) -> [DatabaseMetadata] { switch containerSwitchTarget { case .database: @@ -489,8 +431,7 @@ struct MainEditorContentView: View { PluginManager.shared.containerEntityName(for: connection.type) } - /// Read from the tab's own scope, the same value completion resolves against, so the control - /// and the suggestions can never describe different databases. + // Read from the tab's own scope, which completion also resolves against, so the two never name different databases. private func containerName(for tab: QueryTab) -> String { if containerSwitchTarget == .schema { return coordinator.scope(for: tab)?.schema ?? tab.tableContext.schemaName ?? "" @@ -500,15 +441,13 @@ struct MainEditorContentView: View { return bound.isEmpty ? coordinator.browseDatabaseName : bound } - /// Only shown beside a database, never instead of one: on an engine whose container IS the - /// schema the picker is already naming it. + // Only beside a database: where the container is the schema, the picker already names it. private func containerSchemaName(for tab: QueryTab) -> String? { guard containerSwitchTarget == .database else { return nil } return coordinator.scope(for: tab)?.schema } - /// The tab owns the new database, or schema, for the rest of its life, so it moves to that - /// entry and the window goes with it, the way selecting a tab of another entry does. + // The tab owns the new container for life, so it moves to that entry and the window follows, like selecting its tab. private func changeContainer(for tab: QueryTab, to name: String) { let tabId = tab.id guard tabManager.containerName(of: tab) != name, @@ -519,7 +458,7 @@ struct MainEditorContentView: View { SchemaProviderRegistry.shared.reclaimUnheldProviders(for: connectionId) guard tabManager.selectedTabId == tabId, ContainerChangeRerunPolicy.reruns(showing: tabManager.selectedTab?.display.activeResultSet) else { return } - /// The statement at the caret may not be the one that produced the rows, so a write asks first. + // The statement at the caret may not be the one that produced the rows, so a write asks first. coordinator.runQuery(viewport: .firstRow, extraCapabilities: .confirmsWrites) } @@ -527,9 +466,7 @@ struct MainEditorContentView: View { coordinator.tabManager.mutate(tabId: tabId) { FileTabBaseline.dismissDiskChange(in: &$0.content) } } - /// Both facts the toolbar's query items validate against, written together. They used to be one - /// fact, because the only thing that read it was a button inside this view which already knew - /// it was on a query tab. The toolbar does not: it belongs to the window and outlives every tab. + // Two facts, not one: the window's toolbar outlives every tab, so unlike the old in-view button it cannot assume a query tab. private func updateHasQueryText() { coordinator.syncQueryToolbarStateForSelectedTab() } @@ -542,17 +479,12 @@ struct MainEditorContentView: View { tabManager.tabs.first(where: { $0.id == tabId })?.content.query ?? fallbackQuery }, set: { newValue in - // Find this tab by ID, not by selectedTabIndex. During tab switch, - // flushTextUpdate() fires on the OLD tab's EditorCoordinator when - // selectedTabIndex already points to the NEW tab — writing to - // selectedTabIndex would overwrite the new tab's query. + // By ID: mid-switch, flushTextUpdate() fires for the old tab while selectedTabIndex already points to the new one. guard tabManager.mutate(tabId: tabId, { $0.content.query = newValue }) else { return } coordinator.scheduleDraftSave() - // Typing into a scratch tab dirties it too: the text lives nowhere but this tab. - // The dot belongs to this tab's own window, not whichever window happens to be - // key, because a background window tab's editor stays mounted and can fire here. + // Scratch tabs dirty too. Only this tab's window: a background window tab's editor stays mounted and can fire here. guard tabId == tabManager.selectedTabId, let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), let window = coordinator.contentWindow else { return } @@ -622,8 +554,7 @@ struct MainEditorContentView: View { coordinator.scope(for: tab) } - /// A Create Table tab holds nothing but unsaved work, so its draft is cached here rather than - /// left in the view, which is destroyed the moment the tab is deselected. + // The draft is cached here because the view is destroyed as soon as the tab is deselected. @ViewBuilder private func createTableContent(tab: QueryTab) -> some View { Group { @@ -632,7 +563,6 @@ struct MainEditorContentView: View { connection: connection, scope: structureScope(for: tab), coordinator: coordinator, - selectionState: selectionState, draft: draft ) } else { @@ -643,17 +573,7 @@ struct MainEditorContentView: View { .id(tab.id) } - /// The structure editor is rebuilt whenever the tab is deselected or switched to Data, so its - /// staged ALTERs live in a session cached here by tab, the same way the Users & Roles, ER - /// diagram and dashboard view models do. Creating it in `onAppear` rather than inline keeps the - /// write out of the view-update pass. - /// - /// The identity is the tab, exactly as it is for every other builder that caches a view model - /// under `tab.id`. It used to be `"..
"`, which two tabs on one table - /// share, so switching between them updated the view in place instead of re-creating it: its - /// `@State` went on answering for whichever tab mounted first while `session` resolved to the - /// other. The `session.identity == identity` branch stays, because flipping it is what forces a - /// real remount when a tab is retargeted to a different table. + // Session cached by tab.id (a table-name key let two tabs share @State); the identity check forces a remount on retarget. @ViewBuilder private func structureContent(tab: QueryTab, tableName: String) -> some View { let scope = structureScope(for: tab) @@ -668,7 +588,6 @@ struct MainEditorContentView: View { schemaName: scope?.schema, toolbarState: coordinator.toolbarState, coordinator: coordinator, - selectionState: selectionState, session: session ) } else { @@ -691,9 +610,6 @@ struct MainEditorContentView: View { .frame(maxHeight: .infinity) } - /// Renders `QueryResultPresentation`. Every branch this used to take lived here as a switch - /// over the view mode whose arms each repeated the result chrome, wrapped around a nested - /// `if/else` chain. The decision is a pure value now, so this is a rendering and nothing else. @ViewBuilder private func resultsSection(tab: QueryTab) -> some View { let rows = resolvedTableRows(for: tab) @@ -708,31 +624,17 @@ struct MainEditorContentView: View { rowFilterChrome(tab: tab, rows: rows) } - if presentation.showsFindBar { - FindBarView( - coordinator: coordinator, - findState: tab.findState, - rowsRevision: tab.loadEpoch - &+ tab.pagination.currentPage - &+ tab.paginationVersion - &+ rows.rows.count, - onSearchAllRows: { coordinator.findCoordinator.escalateToAllRows() } - ) - /// Per tab, like the grid below it. The field text lives in the view's own - /// `@State`, seeded once from `onAppear`, and the grid's find tint lives on a - /// coordinator that `.id(tabId)` rebuilds from nothing. With find open on both tabs - /// this view kept its identity across a switch, so neither was re-seeded: the field - /// showed the other tab's term next to this tab's match count, and the grid came - /// back untinted. (#2667) - .id(tab.id) - Divider() - } - resultContent(presentation.content, tab: tab, rows: rows) .gridStandInBackground(isActive: presentation.content.standsInForGrid) if presentation.showsStatusBar { - statusBar(tab: tab) + if let model = coordinator.activeGridModel { + DataGridModelReader(model: model) { model in + statusBar(tab: tab, selectedRowCount: model.selection.affectedRows.count) + } + } else { + statusBar(tab: tab, selectedRowCount: 0) + } } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -770,40 +672,41 @@ struct MainEditorContentView: View { } case .map: if let resultSet = tab.display.activeResultSet { - ResultMapView( - configuration: mapConfigurationBinding(for: tab), - columns: tab.display.spatialColumns, + DataGridModelReader(model: coordinator.gridModel(forTab: tab.id)) { model in + ResultMapView( + configuration: mapConfigurationBinding(for: tab), + columns: tab.display.spatialColumns, + tableRows: rows, + displayIDs: model.displayedRowIDs, + selectedRowIDs: Set(model.selectedRowIDs), + tabId: tab.id, + resultSetId: resultSet.id, + dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, + displayRevision: coordinator.gridDisplayRevision, + onSelectRow: { rowID in + if let rowID { + model.selectRows([rowID]) + } else { + model.clearSelection() + } + } + ) + } + .id(tab.id) + } + case .json: + DataGridModelReader(model: coordinator.gridModel(forTab: tab.id)) { model in + ResultsJsonView( tableRows: rows, - displayIDs: coordinator.displayIDs(forTab: tab.id), - selectedRowIndices: selectionState.indices, - tabId: tab.id, - resultSetId: resultSet.id, + selectedRowIDs: Set(model.selectedRowIDs), + displayIDs: model.displayedRowIDs, + deletedRowIDs: changeManager.deletedRowIDs, + valueFilter: tab.valueFilter, dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, displayRevision: coordinator.gridDisplayRevision, - onSelectRow: { displayIndex in - let selected: Set = displayIndex.map { [$0] } ?? [] - selectionState.indices = selected - /// The shared channel alone does not survive the trip to Data mode: the - /// grid remounts and restores the tab's own stored selection over it, - /// which a map click never wrote. Storing it here is the same half that - /// #2667 added for a mode switch, and the cell rectangle is cleared - /// because a shape names a row and no columns. - coordinator.storeGridSelection(rows: selected, cells: .empty, forTab: tab.id) - } + columnLayout: tab.columnLayout ) - .id(tab.id) } - case .json: - ResultsJsonView( - tableRows: rows, - selectedRowIndices: selectionState.indices, - displayIDs: coordinator.displayIDs(forTab: tab.id), - deletedRowIDs: changeManager.deletedRowIDs, - valueFilter: tab.valueFilter, - dataRevision: coordinator.tabSessionRegistry.session(for: tab.id)?.dataRevision ?? 0, - displayRevision: coordinator.gridDisplayRevision, - columnLayout: tab.columnLayout - ) .id(tab.id) case .grid: dataGridView(tab: tab) @@ -833,8 +736,7 @@ struct MainEditorContentView: View { ) } - /// Gathers what the resolver needs. The one place tab state is read for this decision, so the - /// conditions cannot drift apart the way they did while each arm tested its own combination. + // The one place tab state is read for this decision, so the conditions cannot drift apart again. private func resultPresentation(for tab: QueryTab, rows: TableRows) -> QueryResultPresentation { let activeResultSet = tab.display.activeResultSet var inputs = QueryResultInputs() @@ -860,12 +762,10 @@ struct MainEditorContentView: View { inputs.executionStatusMessage = tab.execution.statusMessage inputs.hasAppliedFilters = tab.filterState.hasAppliedFilters inputs.isFilterPanelVisible = tab.filterState.isVisible - inputs.isFindBarVisible = tab.findState.isVisible return QueryResultPresentation(inputs: inputs) } - /// Shared by every mode whose `showsRowFilters` is true. Filtering rebuilds the query and - /// re-runs it, so a mode that renders this panel shows filtered rows without knowing about it. + // Filtering rebuilds and reruns the query, so a mode showing this panel gets filtered rows without knowing. @ViewBuilder private func rowFilterChrome(tab: QueryTab, rows: TableRows) -> some View { if tab.filterState.isVisible && tab.tabType == .table @@ -887,8 +787,7 @@ struct MainEditorContentView: View { } } - /// Identified by its result set, so one plan's selection, zoom and scroll never carry onto the - /// next plan shown in the same place. + // Identified by its result set so one plan's selection, zoom and scroll never carry onto the next. private func queryPlanResultView(for resultSet: ResultSet, in tab: QueryTab) -> some View { QueryPlanResultView( rawText: resultSet.explainRawText ?? "", @@ -905,9 +804,7 @@ struct MainEditorContentView: View { .id(resultSet.id) } - /// Whether the grid is the thing on screen, which is what decides if there is anything to jump - /// a column into. Asked of the resolver so it cannot disagree with what was actually rendered, - /// which is what a second hand-written copy of the condition used to do. + // Asked of the resolver so it cannot disagree with what was rendered, as a hand-written copy once did. private func showsGrid(tab: QueryTab, rows: TableRows) -> Bool { resultPresentation(for: tab, rows: rows).content == .grid } @@ -924,93 +821,45 @@ struct MainEditorContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + // Configuration is applied outside the update pass: the model publishes when it changes. @ViewBuilder private func dataGridView(tab: QueryTab) -> some View { - let refusal = coordinator.activeResultEditRefusal - let isEditable = coordinator.canEditActiveResult - - let tabId = tab.id - DataGridView( - tableRowsProvider: { [coordinator] in - coordinator.tabSessionRegistry.existingTableRows(for: tabId) ?? TableRows() - }, - tableRowsMutator: { [coordinator] mutate in - coordinator.mutateActiveTableRows(for: tabId) { rows in mutate(&rows) } - }, - paginationOffsetProvider: { [coordinator] in - coordinator.tabManager.tabs.first(where: { $0.id == tabId })?.pagination.currentOffset ?? 0 - }, - changeManager: currentChangeManager, - isEditable: isEditable, - configuration: DataGridConfiguration( - connectionId: connection.id, - databaseType: connection.type, - tableName: tab.tableContext.tableName, - databaseName: tab.tableContext.databaseName, - schemaName: tab.tableContext.schemaName, - primaryKeyColumns: changeManager.primaryKeyColumns, - tabType: tab.tabType, - showRowNumbers: settingsManager.dataGrid.showRowNumbers, - hiddenColumns: tab.columnLayout.hiddenColumns, - appliesRowSortPreferences: true, - appliesRowCopyFormat: true, - supportsColumnSort: tab.tabType != .table - || PluginManager.shared.supportsColumnSort(for: connection.type), - editRefusalMessage: refusal?.message - ), - displayFormats: coordinator.displayFormats(for: tab), - highlightRules: coordinator.highlightRules(for: tab), - delegate: dataTabDelegate, - selectedRowIndices: Binding( - get: { selectionState.indices }, - set: { selectionState.indices = $0 } - ), - sortState: sortStateBinding(for: tab), - columnLayout: columnLayoutBinding(for: tab), - valueFilter: valueFilterBinding(for: tab), - displayOrderProvider: { [coordinator] in - coordinator.displayIDs(forTab: tabId) - }, - displayState: coordinator.displayState(for: tab), - restoredRowSelection: tab.selectedRowIndices, - restoredCellSelection: tab.cellSelection, - onSelectionTeardown: { [coordinator] rows, cells in - coordinator.storeGridSelectionOnTeardown(rows: rows, cells: cells, forTab: tabId) - }, - viewportPlacementProvider: { [coordinator] in - coordinator.takeViewportPlacement(forTab: tabId) - }, - selectionSummary: coordinator.selectionSummary - ) - .id(tabId) - .frame(maxHeight: .infinity, alignment: .top) + let model = displayedGridModel(for: tab) + let configuration = coordinator.gridConfiguration(for: tab) + let highlightRules = coordinator.highlightRules(for: tab) + DataGridView(model: model) + .id(tab.id) + .frame(maxHeight: .infinity, alignment: .top) + .onAppear { + model.configuration = configuration + focusGridIfRequested(model) + } + .onChange(of: configuration) { model.configuration = $0 } + .onChange(of: highlightRules) { _ in + model.display.cache.clearHighlights() + model.repaintAllRows() + } } - private func resolvedTableRows(for tab: QueryTab) -> TableRows { - coordinator.tabSessionRegistry.existingTableRows(for: tab.id) ?? TableRows() + private func displayedGridModel(for tab: QueryTab) -> DataGridModel { + let model = coordinator.gridModel(forTab: tab.id) + coordinator.prepareGridModelForDisplay(forTab: tab.id) + return model } - private func valueFilterBinding(for tab: QueryTab) -> Binding { - let tabId = tab.id - return Binding( - get: { tab.valueFilter }, - set: { coordinator.setValueFilter($0, forTab: tabId) } - ) + // An explicit open asks for the keyboard; the grid is in its window once this turn ends. + private func focusGridIfRequested(_ model: DataGridModel) { + guard coordinator.consumePendingGridFocus() else { return } + Task { @MainActor in + model.focus() + } } - private func sortStateBinding(for tab: QueryTab) -> Binding { - Binding( - get: { tab.sortState }, - set: { newValue in - if let index = tabManager.selectedTabIndex { - tabManager.mutate(at: index) { $0.sortState = newValue } - } - } - ) + private func resolvedTableRows(for tab: QueryTab) -> TableRows { + coordinator.tabSessionRegistry.existingTableRows(for: tab.id) ?? TableRows() } - /// The chart's choices belong to the tab, not to the result set: a page turn, a sort or a - /// re-execute builds a new `ResultSet`, and the axes have to outlive it. + // Owned by the tab: a page turn, sort or rerun builds a new `ResultSet`, and the axes must outlive it. private func chartConfigurationBinding(for tab: QueryTab) -> Binding { Binding( get: { tab.chartConfiguration }, @@ -1022,8 +871,7 @@ struct MainEditorContentView: View { ) } - /// The map's choices belong to the tab for the same reason the chart's do: a page turn, a sort - /// or a re-execute builds a new `ResultSet`, and the chosen column has to outlive it. + // Owned by the tab like the chart's: a page turn, sort or rerun builds a new `ResultSet`, and the column must outlive it. private func mapConfigurationBinding(for tab: QueryTab) -> Binding { let tabId = tab.id return Binding( @@ -1034,23 +882,9 @@ struct MainEditorContentView: View { ) } - private func columnLayoutBinding(for tab: QueryTab) -> Binding { - let tabId = tab.id - return Binding( - get: { tab.columnLayout }, - set: { newValue in - coordinator.isUpdatingColumnLayout = true - coordinator.applyColumnGeometry(from: newValue, toTabId: tabId) - Task { @MainActor in - coordinator.isUpdatingColumnLayout = false - } - } - ) - } - // MARK: - Status Bar - private func statusBar(tab: QueryTab) -> some View { + private func statusBar(tab: QueryTab, selectedRowCount: Int) -> some View { let resolvedRows = resolvedTableRows(for: tab) let structureFooter = coordinator.structureSessions[tab.id]?.footer ?? StructureFooterCapability() let isExecuting = coordinator.tabExecution.isBusy(tab.id) @@ -1069,7 +903,7 @@ struct MainEditorContentView: View { model: ResultStatusModel( snapshot: snapshot, viewMode: tab.display.resultsViewMode, - selectedRowCount: selectionState.indices.count + selectedRowCount: selectedRowCount ), snapshot: snapshot, filterState: tab.filterState, @@ -1134,12 +968,7 @@ struct MainEditorContentView: View { ) } - /// Empty unless the resolver says the chooser is on screen, so the bar and the pane agree on - /// whether there is a choice to make. - /// - /// Asked with only the three fields that decide it rather than a full `resultPresentation`, - /// which would pull the tab's whole row buffer out of the session registry to answer a question - /// that does not depend on a single row. + // Only the three deciding fields, not a full `resultPresentation`, which would pull the whole row buffer for a row-free answer. private func resultSetMenuModel(for tab: QueryTab) -> ResultSetMenuModel { var selectorInputs = QueryResultInputs() selectorInputs.tabType = tab.tabType @@ -1172,6 +1001,7 @@ struct MainEditorContentView: View { set: { newValue in Task { @MainActor in if let index = tabManager.selectedTabIndex { + coordinator.commitActiveGridEdit() tabManager.mutate(at: index) { $0.display.resultsViewMode = newValue } } } @@ -1275,8 +1105,7 @@ extension MainEditorContentView { } fileprivate extension MainEditorContentView { - /// The editor is kept per tab on the coordinator, keyed by the tab's id rather than the type's - /// name, so a rename that retitles the tab keeps its queue. + // Keyed by tab id, not type name, so a rename that retitles the tab keeps its queue. @ViewBuilder func userTypeContent(tab: QueryTab, objectRef: DatabaseObjectRef) -> some View { Group { diff --git a/apple/TablePro/Views/Main/Child/QueryTabGridStateStore.swift b/apple/TablePro/Views/Main/Child/QueryTabGridStateStore.swift new file mode 100644 index 0000000000..7bedc57c19 --- /dev/null +++ b/apple/TablePro/Views/Main/Child/QueryTabGridStateStore.swift @@ -0,0 +1,212 @@ +// +// QueryTabGridStateStore.swift +// TablePro +// + +import Foundation + +@MainActor +final class QueryTabGridStateStore: DataGridStateStore { + private static let layoutPersistDelay: Duration = .milliseconds(500) + + let tabId: UUID + private weak var coordinator: MainContentCoordinator? + private let persister: FileColumnLayoutPersister + + // Kept here rather than on the tab on every click: a tab write re-renders every view of the window. + var selectionSnapshot: GridSelectionSnapshot + + private var ephemeralFormats: (key: EphemeralFormatKey, formats: [ValueDisplayFormat?])? + private var pendingLayout: (layout: ColumnLayoutState, key: ColumnLayoutTableKey)? + private var layoutPersistTask: Task? + + init( + tabId: UUID, + coordinator: MainContentCoordinator, + persister: FileColumnLayoutPersister = .shared + ) { + self.tabId = tabId + self.coordinator = coordinator + self.persister = persister + self.selectionSnapshot = coordinator.tabManager.tabs.first { $0.id == tabId }?.selectionSnapshot ?? .empty + } + + private var tab: QueryTab? { + coordinator?.tabManager.tabs.first { $0.id == tabId } + } + + private var tableRows: TableRows { + coordinator?.tabSessionRegistry.existingTableRows(for: tabId) ?? TableRows() + } + + var layoutScope: DataGridLayoutScope { + guard let tab, tab.tabType == .table, let key = coordinator?.columnLayoutTableKey(for: tab) else { + return .ephemeral + } + return .table(key) + } + + // MARK: - Sort and value filter + + // The tab takes the sort only once the reload it asks for is confirmed, so the write is a request. + var sortState: SortState { + get { tab?.sortState ?? SortState() } + set { coordinator?.requestSort(newValue, forTab: tabId) } + } + + var valueFilter: GridValueFilterState { + get { tab?.valueFilter ?? GridValueFilterState() } + set { + guard let coordinator, tab?.valueFilter != newValue else { return } + coordinator.setValueFilter(newValue, forTab: tabId) + coordinator.gridDisplayRevision &+= 1 + } + } + + // MARK: - Column layout + + var columnLayout: ColumnLayoutState { + get { + guard let tab else { return ColumnLayoutState() } + var layout = tab.columnLayout + switch layoutScope { + case .table(let key): + if let stored = pendingLayout?.layout ?? persister.load(for: key) { + layout.columnWidths = stored.columnWidths + layout.columnContentWidths = stored.columnContentWidths + layout.columnOrder = stored.columnOrder + layout.frozenColumnCount = stored.frozenColumnCount + } + case .ephemeral: + if let order = layout.columnOrder, !Self.canRestoreColumnOrder(order, columns: tableRows.columns) { + layout.columnOrder = nil + } + } + return layout + } + set { + guard let coordinator, let current = tab?.columnLayout else { return } + var layout = newValue + // The getter hid an order saved for other columns; it stays for when those columns come back. + if case .ephemeral = layoutScope, layout.columnOrder == nil, let kept = current.columnOrder, + !Self.canRestoreColumnOrder(kept, columns: tableRows.columns) { + layout.columnOrder = kept + } + if current != layout { + coordinator.isUpdatingColumnLayout = true + coordinator.tabManager.mutate(tabId: tabId) { $0.columnLayout = layout } + Task { @MainActor [weak coordinator] in + coordinator?.isUpdatingColumnLayout = false + } + } + guard case .table(let key) = layoutScope else { return } + if newValue.hasNoGeometry { + discardPendingLayout() + persister.clearGeometry(for: key) + return + } + schedulePersist(newValue, key: key) + } + } + + // A saved order names columns, which identify one only while the names are unique and match the result's (#1565). + private static func canRestoreColumnOrder(_ order: [String], columns: [String]) -> Bool { + let names = Set(columns) + return names.count == columns.count && Set(order) == names + } + + private func schedulePersist(_ layout: ColumnLayoutState, key: ColumnLayoutTableKey) { + layoutPersistTask?.cancel() + pendingLayout = (layout, key) + layoutPersistTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: Self.layoutPersistDelay) + guard !Task.isCancelled else { return } + self?.flushPendingLayout() + } + } + + func flushPendingLayout() { + layoutPersistTask?.cancel() + layoutPersistTask = nil + guard let pending = pendingLayout else { return } + pendingLayout = nil + persister.save(pending.layout, for: pending.key) + } + + // For a table that is gone: a flush would write the layout back over the clear. + func discardPendingLayout() { + layoutPersistTask?.cancel() + layoutPersistTask = nil + pendingLayout = nil + } + + // MARK: - Display formats + + var displayFormats: [ValueDisplayFormat?] { + get { + guard let coordinator, let tab else { return [] } + if let ephemeralFormats, ephemeralFormats.key == EphemeralFormatKey(tab: tab, columns: tableRows.columns) { + return ephemeralFormats.formats + } + return coordinator.displayFormats(for: tab) + } + set { + guard let coordinator, let tab else { return } + let columns = tableRows.columns + guard case .table = layoutScope, let scope = tab.tableContext.scope(connectionId: coordinator.connectionId) else { + ephemeralFormats = (EphemeralFormatKey(tab: tab, columns: columns), newValue) + coordinator.gridDisplayRevision &+= 1 + return + } + let current = coordinator.displayFormats(for: tab) + let storageKeys = ValueDisplayFormatColumnKey.storageKeys(for: columns) + for (index, storageKey) in storageKeys.enumerated() { + let format = index < newValue.count ? newValue[index] : nil + let previous = index < current.count ? current[index] : nil + guard format != previous else { continue } + ValueDisplayFormatService.shared.setOverride(format, columnKey: storageKey, scope: scope) + } + coordinator.gridDisplayRevision &+= 1 + } + } + + // MARK: - Viewport + + var viewportAnchor: GridViewportPlacement? { + get { + guard let session = coordinator?.tabSessionRegistry.session(for: tabId), + let stage = session.viewportStage, + stage.bufferEpoch == session.bufferEpoch else { return nil } + return stage.placement + } + set { + guard let registry = coordinator?.tabSessionRegistry else { return } + guard let newValue else { + _ = registry.takeViewportPlacement(for: tabId) + return + } + registry.stageViewportPlacement(newValue, for: tabId) + } + } +} + +private struct EphemeralFormatKey: Equatable { + let resultSetId: UUID? + let schemaVersion: Int + let columns: [String] + + init(tab: QueryTab, columns: [String]) { + resultSetId = tab.display.activeResultSetId + schemaVersion = tab.schemaVersion + self.columns = columns + } +} + +private extension ColumnLayoutState { + var hasNoGeometry: Bool { + columnWidths.isEmpty + && columnContentWidths?.isEmpty != false + && columnOrder == nil + && frozenColumnCount == 0 + } +} diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCommandActions+Focus.swift b/apple/TablePro/Views/Main/Extensions/MainContentCommandActions+Focus.swift index b27cae6072..ae4db04a18 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCommandActions+Focus.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCommandActions+Focus.swift @@ -6,21 +6,16 @@ import AppKit internal extension MainContentCommandActions { - /// Focus Results goes through the grid's own coordinator rather than naming a view, because the - /// grid already owns the call and answers whether it took the keyboard. - /// - /// A collapsed result pane keeps its grid attached to the window, so focusing without expanding - /// first sends the keyboard to a table clipped to nothing. + // A collapsed result pane keeps its grid in the window, so focusing it unexpanded hands the keyboard to nothing. @discardableResult func focusActiveGrid() -> Bool { expandResultsIfCollapsed() - return coordinator?.dataTabDelegate?.tableViewCoordinator?.focusGrid() ?? false + return coordinator?.focusActiveGrid() ?? false } - /// A grid that is not in a window cannot take the keyboard, which is the state a query tab with - /// no result, and every result view other than the data grid, leaves behind. + // Only a mounted grid can take the keyboard; a query tab with no result and every other result view have none. var canFocusActiveGrid: Bool { - coordinator?.dataTabDelegate?.tableViewCoordinator?.tableView?.window != nil + coordinator?.selectedTabGridModel?.isMounted == true } private func expandResultsIfCollapsed() { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ChangeGuard.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ChangeGuard.swift index b1260ffbc3..b730595993 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ChangeGuard.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ChangeGuard.swift @@ -2,21 +2,16 @@ // MainContentCoordinator+ChangeGuard.swift // TablePro // -// Guard against data-destructive operations when unsaved changes exist. -// Provides a reusable confirmation gate for sort, pagination, and filter operations. -// import AppKit import Foundation extension MainContentCoordinator { - /// Check for unsaved changes and prompt user to confirm discarding them. - /// Returns true if the caller is safe to proceed (no changes, or user chose to discard). func confirmDiscardChangesIfNeeded( action: DiscardAction, completion: @escaping (Bool) -> Void ) { - dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + commitActiveGridEdit() guard changeManager.hasChanges else { completion(true) return @@ -36,17 +31,12 @@ extension MainContentCoordinator { } } - /// The same gate, for an operation that does NOT re-query. - /// - /// An edit is written into the tab's loaded rows as well as being recorded, so every caller of - /// the plain gate above gets away with clearing only the records: sort, pagination, the WHERE - /// filter and refresh all re-run the query and replace the buffer. A per-column value filter - /// narrows the rows already loaded, so the edited values would stay on screen with nothing - /// tracking them, and the next edit would capture an unsaved value as its baseline. (#2667) + // Not the plain gate: a value filter keeps the loaded rows, so edited values would stay on screen untracked (#2667). func confirmDiscardRestoringRowsIfNeeded( action: DiscardAction, completion: @escaping (Bool) -> Void ) { + commitActiveGridEdit() guard changeManager.hasChanges else { completion(true) return diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnJump.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnJump.swift index de06db3f7c..4158c4095f 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnJump.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnJump.swift @@ -6,41 +6,34 @@ import AppKit import Foundation -/// Where a Jump to Column panel was opened from. A menu command can switch the tab under the -/// panel before Return, and a commit that resolved the tab at that moment would jump to the same -/// index in an unrelated result, so the commit is checked against the origin instead. +// Where a Jump to Column panel was opened. A menu command can switch the tab under the panel before +// Return, so the commit is checked against the origin rather than resolved at that moment. struct ColumnJumpOrigin { let tabId: UUID - weak var grid: TableViewCoordinator? + weak var model: DataGridModel? } extension MainContentCoordinator { static let columnJumpPanelIdentity = "column-jump" - /// The one column list the Columns popover and Jump to Column both read, so hiding and - /// jumping never disagree about what the result holds. + // The one column list the Columns popover and Jump to Column both read. func columnCatalog(for tab: QueryTab, resultRows: TableRows) -> [GridColumnEntry] { - let mountedGrid = tab.id == tabManager.selectedTabId ? dataTabDelegate?.tableViewCoordinator : nil - return GridColumnCatalog.entries( + GridColumnCatalog.entries( resultColumns: resultRows.columns, columnTypes: resultRows.columnTypes, hiddenColumns: tab.columnLayout.hiddenColumns, - displayOrder: mountedGrid?.visibleColumnDataIndices(), + displayOrder: existingGridModel(forTab: tab.id)?.columns.columns.map(\.dataIndex), pickerColumns: columnsForVisibilityPicker(for: tab, resultColumns: resultRows.columns) ) } - /// Whether the selected tab has a data grid on screen to jump in. A query result with no rows - /// shows an empty-result view instead of a grid, and a collapsed results pane hides it, so the - /// grid's own attachment is the fact, not the view mode. + // A query with no rows shows an empty-result view and a collapsed pane hides the grid, so the mount decides. var hasMountedDataGrid: Bool { - guard let tab = tabManager.selectedTab, !tab.display.isResultsCollapsed, - let grid = dataTabDelegate?.tableViewCoordinator else { return false } - return grid.tableView?.window != nil + guard let tab = tabManager.selectedTab, !tab.display.isResultsCollapsed else { return false } + return existingGridModel(forTab: tab.id)?.isMounted == true } - /// Invoking the command while its own panel is up closes it, the way Open Quickly toggles. - /// An Open Quickly panel is replaced rather than closed: the reader asked for columns. + // The command closes its own panel, the way Open Quickly toggles; an Open Quickly panel is replaced. func showColumnJump(seededWith query: String = "") { guard let quickSwitcherPanel else { return } guard !quickSwitcherPanel.isPresenting(Self.columnJumpPanelIdentity) else { @@ -48,16 +41,16 @@ extension MainContentCoordinator { return } guard hasMountedDataGrid, let tab = tabManager.selectedTab, - let grid = dataTabDelegate?.tableViewCoordinator else { return } + let model = existingGridModel(forTab: tab.id) else { return } let resultRows = tabSessionRegistry.existingTableRows(for: tab.id) ?? TableRows() let entries = columnCatalog(for: tab, resultRows: resultRows) guard !entries.isEmpty else { return } - let origin = ColumnJumpOrigin(tabId: tab.id, grid: grid) + let origin = ColumnJumpOrigin(tabId: tab.id, model: model) let panelView = ColumnJumpPanelView( entries: entries, initialQuery: query, - cursorColumnIndex: grid.focusedDataColumnIndex, + cursorColumnIndex: model.activeCell?.column.dataIndex, onCommit: { [weak self] entry in self?.quickSwitcherPanel?.dismiss() self?.jumpToColumn(entry, from: origin) @@ -66,30 +59,72 @@ extension MainContentCoordinator { quickSwitcherPanel.present(panelView, over: contentWindow, identity: Self.columnJumpPanelIdentity) } - /// A hidden column has to be shown before it can be reached, and on a table tab showing it - /// means fetching it, so the jump is parked on the grid and lands once the column is presented. - /// - /// The entry was listed against the result the panel opened over. A result-set switch or a - /// re-run in the same tab keeps the tab and the grid and replaces the columns, so an index is - /// trusted only while the column at it still carries the entry's name. + // A hidden column is shown first, which on a table tab means fetching it, so the jump waits for it. + // An index is trusted only while the column at it still carries the entry's name. func jumpToColumn(_ entry: GridColumnEntry, from origin: ColumnJumpOrigin) { guard hasMountedDataGrid, let tab = tabManager.selectedTab, tab.id == origin.tabId, - let grid = origin.grid, - dataTabDelegate?.tableViewCoordinator === grid else { return } + let model = origin.model, + existingGridModel(forTab: tab.id) === model else { return } if entry.isHidden { guard tab.columnLayout.hiddenColumns.contains(entry.name) else { return } - grid.pendingColumnJump = PendingColumnJump( + dataGridEntries[tab.id]?.adapter.pendingColumnJump = ColumnJumpRequest( name: entry.name, dataIndex: entry.dataIndex, - tableKey: grid.columnLayoutKey, + tableKey: columnLayoutTableKey(for: tab), awaitsResultReplacement: tab.tabType == .table ) showColumn(entry.name) return } + let columns = model.rows.columns guard let dataIndex = entry.dataIndex, - grid.identitySchema.columnName(for: dataIndex) == entry.name else { return } - grid.jumpToColumn(dataIndex: dataIndex) + columns.indices.contains(dataIndex), + columns[dataIndex] == entry.name else { return } + jumpToColumn(dataIndex: dataIndex, in: model, tabId: tab.id) + } + + // Puts the active cell in the column on the active row, or the first visible row, and scrolls it into view. + @discardableResult + private func jumpToColumn(dataIndex: Int, in model: DataGridModel, tabId: UUID) -> Bool { + guard let displayColumn = model.columns.displayPosition(ofDataIndex: dataIndex), + let column = model.columns.column(at: displayColumn) else { return false } + dataGridEntries[tabId]?.adapter.pendingColumnJump = nil + model.commitEditing() + let row = model.selection.activeCell?.row + ?? model.selection.affectedRows.first + ?? model.viewportSample()?.row + ?? 0 + if let rowID = model.rowID(at: row) { + model.selectCell(GridCoord(row: row, displayColumn: displayColumn)) + model.reveal(rowID: rowID, columnID: column.id) + } else { + model.revealColumn(column.id) + } + model.focus() + return true + } + + // A request waiting for its refetch lands only on a new result, and one whose column that result + // does not carry is dropped rather than left armed. + func consumePendingColumnJump(forTab tabId: UUID, contentReplaced: Bool) { + guard let entry = dataGridEntries[tabId], + let pending = entry.adapter.pendingColumnJump, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + guard pending.tableKey == columnLayoutTableKey(for: tab) else { + entry.adapter.pendingColumnJump = nil + return + } + guard contentReplaced || !pending.awaitsResultReplacement else { return } + let columns = entry.model.rows.columns + let dataIndex = pending.dataIndex.flatMap { index in + columns.indices.contains(index) && columns[index] == pending.name ? index : nil + } ?? columns.firstIndex(of: pending.name) + if let dataIndex, jumpToColumn(dataIndex: dataIndex, in: entry.model, tabId: tabId) { + return + } + if contentReplaced { + entry.adapter.pendingColumnJump = nil + } } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index da830b3246..543e5a7ecc 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -42,16 +42,7 @@ extension MainContentCoordinator { changeColumnScope { $0 = Set(columns) } } - /// Every route that changes which columns are fetched, behind the same gate the WHERE filter - /// uses on the very same `rebuildTableQuery` call. - /// - /// Hiding or showing a column re-runs the table's query with a different column list, so the - /// rows are replaced and any unsaved edit goes with them. Sort, pagination and the WHERE filter - /// all confirm before doing that; this was the one reload that did it without asking. (#2667) - /// - /// The set is mutated inside the approved work, so declining leaves the column list alone as - /// well as the edits. Only the first change in a run prompts: confirming clears the changes, so - /// every later toggle finds nothing to lose and passes straight through. + // Confirms like sort, pagination and the WHERE filter: re-running with a new column list drops unsaved edits (#2667). private func changeColumnScope(_ mutate: @escaping (inout Set) -> Void) { confirmDiscardRestoringRowsIfNeeded(action: .columnVisibility) { [weak self] confirmed in guard confirmed, let self else { return } @@ -77,11 +68,6 @@ extension MainContentCoordinator { mutateSelectedTabHiddenColumns(persist: false) { $0 = restored } } - func applyColumnGeometry(from geometry: ColumnLayoutState, toTabId tabId: UUID) { - guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - tabManager.mutate(at: index) { $0.columnLayout.applyGeometry(from: geometry) } - } - func clearColumnLayoutForSelectedTable() { guard let target = selectedColumnLayoutClearTarget() else { return } clearColumnLayout(target) @@ -94,9 +80,11 @@ extension MainContentCoordinator { } func clearColumnLayout(_ target: ColumnLayoutClearTarget) { - if tabManager.selectedTabId == target.tabId, - dataTabDelegate?.tableViewCoordinator?.columnLayoutKey == target.tableKey { - dataTabDelegate?.tableViewCoordinator?.resetColumnGeometry() + if let entry = dataGridEntries[target.tabId], + let tab = tabManager.tabs.first(where: { $0.id == target.tabId }), + columnLayoutTableKey(for: tab) == target.tableKey { + entry.store.discardPendingLayout() + entry.model.updateColumnLayout { $0.resetGeometry() } } FileColumnLayoutPersister.shared.clearGeometry(for: target.tableKey) guard let index = tabManager.tabs.firstIndex(where: { $0.id == target.tabId }), @@ -106,9 +94,7 @@ extension MainContentCoordinator { } } - /// Reset clears the hidden set along with the widths, so it re-queries too and takes the same - /// confirmation. The width half is not undone by declining, because nothing about a width can - /// invalidate an edit; only the refetch can. + // Clearing the hidden set re-queries, so reset confirms too; declining keeps the width reset, since widths cannot void edits. func resetColumns() { confirmDiscardRestoringRowsIfNeeded(action: .columnVisibility) { [weak self] confirmed in guard confirmed, let self else { return } @@ -118,8 +104,12 @@ extension MainContentCoordinator { private func applyColumnReset() { guard let index = tabManager.selectedTabIndex else { return } - dataTabDelegate?.tableViewCoordinator?.resetColumnGeometry() let tab = tabManager.tabs[index] + // Through the model, which rebuilds its columns: a query tab's requery reloads nothing, so it alone shows them again. + if let entry = dataGridEntries[tab.id] { + entry.store.discardPendingLayout() + entry.model.updateColumnLayout { $0 = ColumnLayoutState() } + } if let key = columnLayoutTableKey(for: tab) { FileColumnLayoutPersister.shared.clear(for: key) } @@ -128,7 +118,7 @@ extension MainContentCoordinator { } // A temporary table's layout is not kept: the key would be the permanent table's of the same name. - private func columnLayoutTableKey(for tab: QueryTab) -> ColumnLayoutTableKey? { + func columnLayoutTableKey(for tab: QueryTab) -> ColumnLayoutTableKey? { guard !tab.tableContext.isTemporary, let tableName = tab.tableContext.tableName, !tableName.isEmpty else { return nil } @@ -156,19 +146,15 @@ extension MainContentCoordinator { guard let index = tabManager.selectedTabIndex else { return } var hidden = tabManager.tabs[index].columnLayout.hiddenColumns mutate(&hidden) - let presentedRunChanged = hidden != tabManager.tabs[index].columnLayout.hiddenColumns - tabManager.mutate(at: index) { tab in - tab.columnLayout.hiddenColumns = hidden - /// A stored cell rectangle is a set of display positions in the presented run, and - /// hiding a column renumbers that run. Clamping cannot catch it: a position that is - /// still in range now names a different column, so the rectangle would come back - /// pointing at data the reader never selected. A mounted grid drops its own selection - /// on the same event; this is that rule for a tab whose grid is not mounted. (#2667) - guard presentedRunChanged, !tab.cellSelection.isEmpty else { return } - tab.cellSelection = .empty + guard hidden != tabManager.tabs[index].columnLayout.hiddenColumns else { return } + // Through the model first, which rebuilds its columns and keeps the selection by column identity. + existingGridModel(forTab: tabManager.tabs[index].id)?.updateColumnLayout { $0.hiddenColumns = hidden } + if tabManager.tabs[index].columnLayout.hiddenColumns != hidden { + tabManager.mutate(at: index) { $0.columnLayout.hiddenColumns = hidden } } if persist { persistTabHiddenColumns(tabManager.tabs[index]) } + consumePendingColumnJump(forTab: tabManager.tabs[index].id, contentReplaced: false) } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index 92aa925092..9289878f49 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -14,6 +14,7 @@ extension MainContentCoordinator { pendingTruncates: inout Set, pendingDeletes: inout Set ) { + commitActiveGridEdit() rowEditingCoordinator.handleDiscard( pendingTruncates: &pendingTruncates, pendingDeletes: &pendingDeletes diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DocumentEditing.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DocumentEditing.swift index 0a1ec6c5ed..323e2b9eb8 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DocumentEditing.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+DocumentEditing.swift @@ -6,13 +6,7 @@ import Foundation extension MainContentCoordinator { - /// Whether the selected tab is a collection whose engine writes whole documents. - /// - /// One definition for every place that offers Insert Document or Edit Document: the Edit menu, - /// the row menu and the empty-space menu. Only a table tab, because the write ends by reloading - /// the tab and a query tab's result is not reloaded that way, so the change would not appear. A - /// tab with staged grid edits refuses it, because a reload over staged edits asks to discard them - /// in a sheet behind this one. + // Table tabs only: the write ends by reloading the tab. Staged grid edits refuse it, since that reload would ask to discard them. var documentEditingAvailable: Bool { guard canEditActiveResult, PluginManager.shared.supportsDocumentEditing(for: connection.type), @@ -34,26 +28,17 @@ extension MainContentCoordinator { activeSheet = .documentEditor(DocumentEditorRequest(table: table, scope: scope)) } - /// What the driver finds the document on a grid row by, read through the display order so a - /// sort or a value filter cannot hand back another row's document. Nil for a row the driver - /// gave none, which is a row Edit Document is not offered on. - func documentLocator(forDisplayRow displayRow: Int) -> String? { + // What the driver finds the row's document by; nil for a row it gave none, where Edit Document is not offered. + func documentLocator(for rowID: RowID) -> String? { guard documentEditingAvailable, let tab = tabManager.selectedTab else { return nil } - let tableRows = tabSessionRegistry.tableRows(for: tab.id) - guard let row = DisplayRowMapping.row( - forDisplay: displayRow, - displayIDs: activeGridDisplayIDs, - in: tableRows - ) else { return nil } - return tableRows.rowLocator(for: row.id) + return tabSessionRegistry.tableRows(for: tab.id).rowLocator(for: rowID) } - func canEditDocument(atDisplayRow displayRow: Int) -> Bool { - documentLocator(forDisplayRow: displayRow) != nil + func canEditDocument(rowID: RowID) -> Bool { + documentLocator(for: rowID) != nil } - /// Opens the editor on the document a locator names. The locator is resolved by the caller at - /// the moment the command was chosen, so a reload in between cannot move it to another row. + // The caller resolves the locator when the command is chosen, so a reload in between can't move it to another row. func presentEditDocument(locator: String) { guard documentEditingAvailable, let table = tabManager.selectedTab?.tableContext.tableName, diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ExecuteAll.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ExecuteAll.swift index 3611d1e297..520eea9ae1 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ExecuteAll.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ExecuteAll.swift @@ -7,6 +7,7 @@ import Foundation extension MainContentCoordinator { func runAllStatements(extraCapabilities: CallerCapabilities = []) { + commitActiveGridEdit() queryExecutionCoordinator.runAllStatements(extraCapabilities: extraCapabilities) } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index f3029f2d6a..ebc823b1b9 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -14,9 +14,7 @@ private let fkNavigationLogger = Logger(subsystem: "com.TablePro", category: "FK extension MainContentCoordinator { // MARK: - Foreign Key Navigation - /// The JSON inspector's own route to the same navigation. It holds a `JSONForeignKeyRef`, - /// which is a `ForeignKeyInfo` without the identity and the referential actions, because a node - /// tree cannot hold a type whose `==` is a fresh `UUID`. + // The JSON inspector's route: a node tree can't hold ForeignKeyInfo, whose == compares a fresh UUID. func navigateToFKReference(reference: JSONForeignKeyRef, value: String) { navigateToFKReference( value: value, @@ -31,14 +29,7 @@ extension MainContentCoordinator { ) } - /// Navigate to the referenced table filtered by the FK value. - /// - /// The choice is `ReferenceNavigationPlanner`'s, so it can be read and tested in one place. It - /// never re-points the selected tab at a different table. A tab already on the referenced table - /// is re-filtered, which is a move within the table the reader is in and which Back undoes. - /// Retargeting used to be guarded by a predicate of its own, which knew about unsaved edits but - /// not about applied filters, a sort or pinned results, so the arrow in a cell took over a tab - /// the sidebar would have left alone and the reader lost the rows they were reading. + // Never retargets the selected tab: the old guard ignored filters, sort and pins, so a cell arrow lost the reader's rows. func navigateToFKReference(value: String, fkInfo: ForeignKeyInfo, intent: ReferenceOpenIntent) { let referencedTable = fkInfo.referencedTable let referencedColumn = fkInfo.referencedColumn @@ -56,11 +47,7 @@ extension MainContentCoordinator { return } - /// The referenced table's own database and schema, not the raw catalog value. An engine - /// with no schema layer names the referenced database in the slot its catalog calls a - /// schema, and carrying that through gave a table reached by a key a different identity - /// from the same table opened from the sidebar: its own filters, its own column layout, no - /// tab to reuse, and a rename that never found it. + // Resolved scope, not the raw catalog value: schema-less engines put the database there, giving the table a second identity. let target = ForeignKeyTargetScope.resolve( origin: sourceScope, referencedDatabase: fkInfo.referencedDatabase, @@ -85,17 +72,13 @@ extension MainContentCoordinator { for: ReferenceNavigationContext( intent: intent, selectedTabShowsTarget: showsTarget, - /// The discard alert clears staged cell edits and nothing else, so re-querying - /// under a staged structure edit would promise something this path cannot keep. - /// Back and Forward stand down on the same tab for the same reason. + // The discard alert clears only staged cell edits, so a staged structure edit rules out re-querying (Back/Forward too). selectedTabAcceptsRefilter: selectedTab.map { !hasStagedStructureEdits(in: $0) } ?? false, anotherTabShowsReference: existing != nil ) ) - /// Both jumps that leave the tab keep it. The reader navigated away from it rather than - /// clicking past it, so the next sidebar click must not retarget it; `openTableTab` promotes - /// before it hands off for the same reason. Re-filtering stays on the tab, so it does not. + // Jumps that leave the tab promote it, so the next sidebar click can't retarget it; re-filtering stays on the tab. switch plan { case .refilterSelectedTab: refilterSelectedTab(with: filter, showsReferenceAlready: selectedTab.map { @@ -151,9 +134,7 @@ extension MainContentCoordinator { isVisible: true, filterLogicMode: .and ) - /// This payload is only built once no open tab could take the jump, so it must land in a - /// tab of its own. Reusing one would write the FK filter over filters the user applied - /// there, and leave the grid on rows the new filter never ran against. + // Built only when no open tab could take the jump; reusing one would write the FK filter over the user's own filters. return EditorTabPayload( connectionId: connection.id, tabType: .table, @@ -166,31 +147,6 @@ extension MainContentCoordinator { ) } - /// Toggle FK preview for the currently focused cell in the data grid. - /// Called from the menu command system (Settings > Keyboard rebindable). - /// `focusedColumn` is a position in `tableView.tableColumns`, which carries the row-number - /// column and a hidden spacer before the data, and which the reader can reorder. Subtracting a - /// fixed offset from it named a different column than the one they were on, or none at all, so - /// this resolves it by column identity the way the key-equivalent path already does. - func toggleFKPreviewForFocusedCell() { - guard let tableView = NSApp.keyWindow?.firstResponder as? KeyHandlingTableView, - let coordinator = tableView.coordinator, - tableView.selectedRow >= 0, - tableView.presentsDataColumn(at: tableView.focusedColumn), - let columnIndex = DataGridView.dataColumnIndex( - for: tableView.focusedColumn, - in: tableView, - schema: coordinator.identitySchema - ) - else { return } - coordinator.toggleForeignKeyPreview( - tableView: tableView, - row: tableView.selectedRow, - column: tableView.focusedColumn, - columnIndex: columnIndex - ) - } - private func matchesFKTarget(_ tab: QueryTab, table: String, database: String, schema: String?) -> Bool { tab.tabType == .table && tab.tableContext.tableName == table @@ -204,21 +160,14 @@ extension MainContentCoordinator { && lhs.value == rhs.value } - /// Whether this tab is already showing exactly this reference and nothing else. One definition, - /// because the reuse search and the history both have to agree on what "already here" means. - /// - /// Asked of what the rows were fetched with, never of `appliedFilters`, which resolves from the - /// panel's editable draft. Typing `id = 42` into a tab showing `id = 7` and not pressing Apply - /// made that tab answer yes, and revealing it runs no query, so the reader landed on the rows - /// for 7 while the app reported it had found the reference. + // Asks what the rows were fetched with, not appliedFilters, which reads the unapplied draft and claimed a false match. private func showsOnlyFKPredicate(_ tab: QueryTab, filter: TableFilter) -> Bool { let applied = tab.filterState.executedFilters guard applied.count == 1 else { return false } return isSameFKPredicate(applied[0], filter) } - /// A tab already showing exactly this reference. Matching the filter too keeps a click on a - /// different row from re-filtering a tab the user opened for another one. + // Matching the filter too keeps a click on a different row from re-filtering a tab opened for another one. private func openFKTargetTab( table: String, database: String, @@ -234,9 +183,7 @@ extension MainContentCoordinator { return (self, match.id) } - /// Hosted coordinators, not `allActiveCoordinators()`, which is a registry of every - /// coordinator SwiftUI has built and can hold one whose window is gone. A reveal has to - /// land somewhere the reader can see. + // Hosted coordinators, not allActiveCoordinators(), which can hold one whose window is gone. for sibling in hostedTabRouting.coordinators(connectionId) where sibling !== self { guard let match = sibling.tabManager.tabs.first(where: matches) else { continue } return (sibling, match.id) @@ -244,16 +191,9 @@ extension MainContentCoordinator { return nil } - /// Re-points the tab the reader is already on at another row of the same table. - /// - /// Everything happens inside one discard guard. The pair this replaced ran `applyFilters`, - /// which defers behind the alert, beside `setFKFilter`, which does not, so refusing the alert - /// left the panel showing an applied filter the grid was never re-queried for and pushed a - /// history entry for a jump that never happened, dropping the forward stack with it. + // One discard guard: applyFilters deferred behind the alert but setFKFilter didn't, so a refusal left a stale filter. private func refilterSelectedTab(with filter: TableFilter, showsReferenceAlready: Bool) { - /// Re-filtering the tab in place is a jump like any other, so it goes on the history. - /// Clicking the reference the tab is already showing is not, and recording it would stack - /// identical entries a reader has to press Back through. + // Not recorded when the tab already shows this reference, or Back would step through identical entries. let departing = showsReferenceAlready ? nil : captureNavigationEntry() confirmDiscardChangesIfNeeded(action: .filter) { [weak self] confirmed in guard let self, confirmed else { return } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridDisplay.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridDisplay.swift index 7ae13eb2c3..4e453a18d1 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridDisplay.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridDisplay.swift @@ -10,232 +10,37 @@ import Foundation import TableProPluginKit import TableProUtilities -/// A resolved display order plus the inputs it was resolved from. -/// -/// `rowSetRevision` is ticked by `TabSessionRegistry` whenever rows arrive, leave or are replaced, -/// and never for a cell edit. An edit that takes a row out of the filter's match therefore leaves it -/// where it is until one of the three inputs here moves, which is what the grid shows, and every -/// reader that maps a display position through this order lands on the row the grid has at that -/// position. The display formats are one of those inputs because the filter matches formatted text, -/// so changing a column's format re-resolves the order the way changing the filter does. -struct DisplayOrderCacheEntry { - let rowSetRevision: Int - let valueFilter: GridValueFilterState - let displayFormats: [ValueDisplayFormat?] - let displayIDs: [RowID]? - - func matches( - rowSetRevision: Int, - valueFilter: GridValueFilterState, - displayFormats: [ValueDisplayFormat?] - ) -> Bool { - self.rowSetRevision == rowSetRevision - && self.valueFilter == valueFilter - && self.displayFormats == displayFormats - } -} - -/// A tab's formatted-cell text, kept across the grid remounts a tab switch causes, plus the inputs -/// that decided it. -struct DisplayStateCacheEntry { - let identity: DataGridDisplayIdentity - let state: DataGridDisplayState - var lastUsed: Int -} - extension MainContentCoordinator { - /// The formatted text and viewport anchor for a tab's result, kept here because the grid that - /// derives them is destroyed and rebuilt on every tab switch. - /// - /// A stale entry is replaced rather than repaired: the cache is keyed by row id, row ids are - /// positional, and a new page reuses the ids of the one before it, so anything that can change - /// what a position holds has to be part of the identity. It keys on `bufferEpoch` rather than - /// `dataRevision` for exactly that reason: an in-place edit keeps every row id, and the grid - /// already drops the one row it touched, so replacing the whole entry there would re-format the - /// page after every keystroke that commits. (#2424) - func displayState(for tab: QueryTab) -> DataGridDisplayState { - let settings = AppSettingsManager.shared.dataGrid - let tableRows = tabSessionRegistry.existingTableRows(for: tab.id) - let identity = DataGridDisplayIdentity( - bufferEpoch: tabSessionRegistry.session(for: tab.id)?.bufferEpoch ?? 0, - resultSetId: tab.display.activeResultSetId, - columns: tableRows?.columns ?? [], - columnTypes: tableRows?.columnTypes ?? [], - displayFormats: displayFormats(for: tab), - dateFormat: settings.dateFormat, - nullDisplay: settings.nullDisplay, - smartValueDetection: settings.enableSmartValueDetection, - systemTimeZoneGeneration: DateFormattingService.shared.systemTimeZoneGeneration - ) - - displayStateClock &+= 1 - if let cached = displayStateCache[tab.id], cached.identity == identity { - displayStateCache[tab.id]?.lastUsed = displayStateClock - return cached.state - } - let state = DataGridDisplayState() - displayStateCache[tab.id] = DisplayStateCacheEntry( - identity: identity, - state: state, - lastUsed: displayStateClock - ) - pruneDisplayStateCache() - return state - } - - /// Each entry holds a whole result's formatted text, budgeted at 64 MB by `RowDisplayCache`, so - /// the tabs the user is actually moving between keep theirs and the rest give it back. The - /// budget is the one inactive row data already uses, so the two do not disagree about how many - /// background tabs are worth keeping. - private func pruneDisplayStateCache() { - let budget = MemoryPressureAdvisor.budgetForInactiveTabs() + 1 - guard displayStateCache.count > budget else { return } - let ordered = displayStateCache.sorted { $0.value.lastUsed > $1.value.lastUsed } - for entry in ordered.dropFirst(budget) { - displayStateCache.removeValue(forKey: entry.key) - } - } - - /// Keeps a grid's selection on its tab, so returning to the tab does not start with nothing - /// selected. - /// - /// `handleTabChange` has always restored `selectedRowIndices` on the way in, but nothing ever - /// wrote it: outside a paste, its only writers set it to empty, so every switch back replayed an - /// empty set over the reader's selection. This is the missing half, and it is driven by the - /// grid's own teardown rather than by the tab switch, because the grid is also destroyed when - /// the tab changes result mode, which is not a tab switch at all. (#2667) - /// The check is made before `mutate`, not inside it. An `inout` access to an array element runs - /// the array's setter whether or not the block writes anything, so guarding inside would still - /// invalidate every observer of `tabs` on the way out of a grid that had nothing selected. - func storeGridSelection(rows: Set, cells: GridSelection, forTab tabId: UUID) { - guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), - tab.selectedRowIndices != rows || tab.cellSelection != cells else { return } - tabManager.mutate(tabId: tabId) { tab in - tab.selectedRowIndices = rows - tab.cellSelection = cells - } - } - - /// The grid's own teardown, which is authoritative only while its tab is still the selected one. - /// - /// A result-mode switch destroys the grid without changing tabs, and there the table view still - /// holds what the reader selected. A tab switch does not: `handleTabChange` repoints the shared - /// `GridSelectionState` at the incoming tab while the outgoing grid is still mounted and bound to - /// it, so `syncSelection` clears the outgoing table view on the way out and the teardown reads an - /// empty selection (measured). That case is captured in `handleTabChange` instead, before the - /// repoint. (#2667) - func storeGridSelectionOnTeardown(rows: Set, cells: GridSelection, forTab tabId: UUID) { - guard tabManager.selectedTabId == tabId else { return } - storeGridSelection(rows: rows, cells: cells, forTab: tabId) - } - - /// What the mounted data grid has selected right now. - /// - /// Only ever one grid is mounted, so this needs no tab argument; callers decide which tab the - /// answer belongs to. `handleTabChange` reads it for the tab it is leaving, which is still the - /// mounted one at the point it runs. - func mountedGridSelection() -> (rows: Set, cells: GridSelection)? { - guard let grid = dataTabDelegate?.tableViewCoordinator, - let tableView = grid.tableView else { return nil } - return (Set(tableView.selectedRowIndexes), grid.selectionController.selection) - } - - /// The rows the grid is displaying, in display order, or nil when that is the storage order. - /// - /// Resolved from the tab rather than from the mounted grid. SwiftUI destroys an - /// `NSViewRepresentable`'s coordinator the moment the representable leaves the view tree, so - /// reading the order off `TableViewCoordinator` returned nil in JSON, Structure and Chart mode - /// and silently degraded every caller to storage order. (#2251) + // The rows the grid shows, in display order, or nil when that is the storage order. Read from the + // tab's model, which resolves the order with no grid mounted (#2251). func displayIDs(forTab tabId: UUID) -> [RowID]? { - guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return nil } - guard tab.valueFilter.isActive else { return nil } - - let session = tabSessionRegistry.session(for: tabId) - let rowSetRevision = session?.rowSetRevision ?? 0 - let formats = displayFormats(for: tab) - - if let cached = displayOrderCache[tabId], - cached.matches(rowSetRevision: rowSetRevision, valueFilter: tab.valueFilter, displayFormats: formats) { - return cached.displayIDs - } - - let resolved = GridDisplayOrderResolver.resolve( - tableRows: session?.tableRows ?? TableRows(), - valueFilter: tab.valueFilter, - displayFormats: formats, - databaseType: connection.type - ) - displayOrderCache[tabId] = DisplayOrderCacheEntry( - rowSetRevision: rowSetRevision, - valueFilter: tab.valueFilter, - displayFormats: formats, - displayIDs: resolved - ) - return resolved - } - - /// Resolves the order now, against the rows as they stand, and stores it for every later reader. - /// - /// The order is a function of the values the rows held when the filter or the row set last - /// changed, not of the values they hold when someone asks. Resolving it lazily makes the answer - /// depend on whether anything happened to read it before the edit: the first read after an edit - /// that takes a row out of the filter's match drops that row, while the grid goes on showing it, - /// and every display position from there on names a different row than the grid has. - @discardableResult - func refreshDisplayOrder(forTab tabId: UUID) -> [RowID]? { - displayOrderCache.removeValue(forKey: tabId) - return displayIDs(forTab: tabId) + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), tab.valueFilter.isActive else { return nil } + return gridModel(forTab: tabId).displayedRowIDs } func setValueFilter(_ valueFilter: GridValueFilterState, forTab tabId: UUID) { guard tabManager.tabs.first(where: { $0.id == tabId })?.valueFilter != valueFilter else { return } tabManager.mutate(tabId: tabId) { $0.valueFilter = valueFilter } - refreshDisplayOrder(forTab: tabId) } - /// Re-reading the same source can move or drop columns, so the filter follows its columns by name. - /// The mounted grid's mirror takes the result before the full replace, or its own prune of the old - /// copy would write that back over the tab. + // Re-reading the same source can move or drop columns, so the filter follows its columns by name. func carryValueFilter(onto columns: [String], forTab tabId: UUID) { guard let current = tabManager.tabs.first(where: { $0.id == tabId })?.valueFilter, current.isActive else { return } - /// The format cache keys on the result, which is replaced only after these rows are installed, - /// so it still holds the outgoing columns' formats by position. + // Keyed on the result, which is replaced only after these rows are installed. displayFormatsCache.removeValue(forKey: tabId) let carried = current.remapped(toColumns: columns) - if carried != current { - displayOrderCache.removeValue(forKey: tabId) - tabManager.mutate(tabId: tabId) { $0.valueFilter = carried } - } - if tabManager.selectedTabId == tabId { - dataTabDelegate?.tableViewCoordinator?.adoptValueFilter(carried) - } + guard carried != current else { return } + tabManager.mutate(tabId: tabId) { $0.valueFilter = carried } } - /// Drops a value filter whose rows have been replaced wholesale. - /// - /// The filter stores the displayed strings the user picked out of the loaded rows, so it means - /// nothing once those rows are gone. Left in place it narrows an unrelated result to nothing, - /// and in JSON mode there is not even a header indicator to explain why. - /// - /// A mounted grid holds its own mirror of the filter and is told about the clear here rather - /// than at the next view update, because `setActiveTableRows` drives the grid's full reload on - /// the very next line. Left to the view update, that reload would resolve the new rows through - /// the old filter, and pruning the old filter against the new columns would write it back onto - /// the tab that was just cleared. + // The filter holds displayed strings picked out of rows that are gone, so on new rows it narrows to nothing. func clearValueFilter(forTab tabId: UUID) { - displayOrderCache.removeValue(forKey: tabId) - if tabManager.selectedTabId == tabId { - dataTabDelegate?.tableViewCoordinator?.adoptValueFilter(GridValueFilterState()) - } guard tabManager.tabs.first(where: { $0.id == tabId })?.valueFilter.isActive == true else { return } tabManager.mutate(tabId: tabId) { $0.valueFilter.clearAll() } } - /// The per-column display formats for a tab's result, cached on the inputs that decide them. - /// - /// Lives here rather than in the view because the display order is matched on formatted values, - /// and that has to resolve with no view mounted. + // Resolved here because the display order matches formatted values with no grid mounted. func displayFormats(for tab: QueryTab) -> [ValueDisplayFormat?] { let settings = AppSettingsManager.shared.dataGrid let service = ValueDisplayFormatService.shared diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift index ba06eb60ad..10bca66439 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridFocus.swift @@ -6,8 +6,9 @@ import Foundation internal extension MainContentCoordinator { - func focusActiveGrid() { - dataTabDelegate?.tableViewCoordinator?.focusGrid() + @discardableResult + func focusActiveGrid() -> Bool { + selectedTabGridModel?.focus() ?? false } func consumePendingGridFocus() -> Bool { @@ -16,10 +17,8 @@ internal extension MainContentCoordinator { return true } - /// Focus the active grid now if it is already attached, otherwise defer to the grid - /// as it appears. Use for explicit-open gestures where the target grid may or may not - /// be rebuilt (e.g. promoting a preview tab). + // For an explicit open: focus a grid already on screen, or let the grid take it as it mounts. func requestGridFocus() { - pendingGridFocusOnOpen = !(dataTabDelegate?.tableViewCoordinator?.focusGrid() ?? false) + pendingGridFocusOnOpen = !focusActiveGrid() } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift new file mode 100644 index 0000000000..268fc780cf --- /dev/null +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift @@ -0,0 +1,254 @@ +// +// MainContentCoordinator+GridModels.swift +// TablePro +// + +import Combine +import Foundation +import TableProUtilities + +// The Structure and Create Table owners answer which of their grids is on screen. +@MainActor +protocol ActiveGridModelProviding: AnyObject { + var activeGridModel: DataGridModel? { get } +} + +struct DataTabGridEntry { + let model: DataGridModel + let adapter: DataTabGridAdapter + let store: QueryTabGridStateStore + var lastUsed: Int + var displayInputs: DataTabDisplayInputs? +} + +// What decided a model's formatted text, which nothing invalidates while the grid is unmounted. +struct DataTabDisplayInputs: Equatable { + let dateFormat: DateFormatOption + let nullDisplay: String + let smartValueDetection: Bool + let systemTimeZoneGeneration: Int + let overridesVersion: Int +} + +extension MainContentCoordinator { + // MARK: - Models + + func gridModel(forTab tabId: UUID) -> DataGridModel { + displayStateClock &+= 1 + if let model = dataGridEntries[tabId]?.model { + dataGridEntries[tabId]?.lastUsed = displayStateClock + return model + } + let store = QueryTabGridStateStore(tabId: tabId, coordinator: self) + let configuration = tabManager.tabs.first { $0.id == tabId }.map(gridConfiguration(for:)) ?? DataGridConfiguration() + let model = DataGridModel(store: store, configuration: configuration, selectionSummary: selectionSummary) + let adapter = DataTabGridAdapter(tabId: tabId, coordinator: self) + adapter.attach(model) + model.dataSource = adapter + model.actions = adapter + dataGridEntries[tabId] = DataTabGridEntry( + model: model, + adapter: adapter, + store: store, + lastUsed: displayStateClock, + displayInputs: currentDisplayInputs + ) + model.reloadRows() + return model + } + + func existingGridModel(forTab tabId: UUID) -> DataGridModel? { + dataGridEntries[tabId]?.model + } + + var selectedTabGridModel: DataGridModel? { + tabManager.selectedTabId.flatMap(existingGridModel(forTab:)) + } + + // The one selection source: the data tab's model in Data, JSON and Map, the Structure or Create Table grid otherwise. + var activeGridModel: DataGridModel? { + guard let tab = tabManager.selectedTab else { return nil } + switch GridSelectionOwner.resolve(tabType: tab.tabType, resultsViewMode: tab.display.resultsViewMode) { + case .dataGrid: + return gridModel(forTab: tab.id) + case .schemaGrid: + return activeGridModelProvider(for: tab)?.activeGridModel + case .none: + return nil + } + } + + var activeDataGridModel: DataGridModel? { + guard let tab = tabManager.selectedTab, + GridSelectionOwner.resolve(tabType: tab.tabType, resultsViewMode: tab.display.resultsViewMode) == .dataGrid + else { return nil } + return gridModel(forTab: tab.id) + } + + func activeGridModelProvider(for tab: QueryTab) -> (any ActiveGridModelProviding)? { + let owner: AnyObject? = tab.tabType == .createTable ? createTableDrafts[tab.id] : structureSessions[tab.id] + return owner as? any ActiveGridModelProviding + } + + func releaseGridModel(forTab tabId: UUID, persistingLayout: Bool = true) { + guard let entry = dataGridEntries.removeValue(forKey: tabId) else { return } + if persistingLayout { + entry.store.flushPendingLayout() + } else { + entry.store.discardPendingLayout() + } + entry.model.discardEditing() + } + + func releaseAllGridModels() { + for tabId in Array(dataGridEntries.keys) { + releaseGridModel(forTab: tabId) + } + } + + func flushColumnLayoutPersistence() { + for entry in dataGridEntries.values { + entry.store.flushPendingLayout() + } + } + + // MARK: - Row changes + + // Every replacement of a tab's rows reaches its model, mounted or not. + func reloadGridModel(forTab tabId: UUID) { + guard let model = existingGridModel(forTab: tabId) else { return } + model.reloadRows() + consumePendingColumnJump(forTab: tabId, contentReplaced: true) + } + + func applyGridDelta(_ delta: Delta, forTab tabId: UUID) { + guard let model = existingGridModel(forTab: tabId), delta != .none else { return } + model.apply(delta) + if delta == .columnsReplaced || delta == .fullReplace { + consumePendingColumnJump(forTab: tabId, contentReplaced: delta == .fullReplace) + } + } + + // MARK: - Editing + + // The tab whose edits the shared change manager holds. A switch repoints it after it starts. + var changeManagerTabId: UUID? { + tabSwitchOutgoingTabId ?? tabManager.selectedTabId + } + + @discardableResult + func commitActiveGridEdit() -> Bool { + guard let tab = tabManager.selectedTab else { return true } + var committed = existingGridModel(forTab: tab.id)?.commitEditing() ?? true + if let schemaModel = activeGridModelProvider(for: tab)?.activeGridModel { + committed = schemaModel.commitEditing() && committed + } + return committed + } + + var isEditingActiveGrid: Bool { + guard let tab = tabManager.selectedTab else { return false } + return existingGridModel(forTab: tab.id)?.isEditing == true + || activeGridModelProvider(for: tab)?.activeGridModel?.isEditing == true + } + + func observeChangeManagerForGridRepaint() -> AnyCancellable { + changeManager.$reloadVersion + .removeDuplicates() + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.selectedTabGridModel?.repaintAllRows() } + } + + // MARK: - Sort + + func requestSort(_ sort: SortState, forTab tabId: UUID) { + guard tabManager.selectedTabId == tabId else { return } + handleSortStateChanged(sort) + } + + // MARK: - Configuration + + func gridConfiguration(for tab: QueryTab) -> DataGridConfiguration { + let isSelected = tab.id == tabManager.selectedTabId + let isEditable = isSelected && canEditActiveResult + var configuration = DataGridConfiguration() + configuration.primaryKeyColumns = tab.tableContext.primaryKeyColumns + configuration.showRowNumbers = AppSettingsManager.shared.dataGrid.showRowNumbers + configuration.appliesRowSortPreferences = true + configuration.appliesRowCopyFormat = true + configuration.supportsColumnSort = tab.tabType != .table || supportsColumnSort + configuration.isEditable = isEditable + configuration.editRefusalMessage = isSelected ? activeResultEditRefusal?.message : nil + configuration.traits = DataGridEngineTraits( + databaseType: connection.type, + immutableColumns: Set(services.pluginManager.immutableColumns(for: connection.type)), + usesTrueFalseBooleans: services.pluginManager.usesTrueFalseBooleans(for: connection.type), + stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(connection.type), + cellWriterSupport: changeManager.cellWriterSupport, + supportsFieldRemoval: changeManager.supportsFieldRemoval, + connectionScope: scope(for: tab) + ) + configuration.commands = gridCommands(for: tab, isSelected: isSelected, isEditable: isEditable) + return configuration + } + + private func gridCommands(for tab: QueryTab, isSelected: Bool, isEditable: Bool) -> Set { + var commands: Set = [ + .hideColumns, .showRowsAsJSON, .exportResults, .navigateForeignKey, + .highlightValues, .displayAs, .freezeColumns, .valueFilter + ] + if isEditable { + commands.formUnion([.addRow, .deleteRows, .undoDelete]) + // A duplicate copies values the result set alone cannot vouch for until the schema arrives. + if tabSessionRegistry.existingTableRows(for: tab.id)?.hasAuthoritativeSchema == true { + commands.insert(.duplicateRows) + } + if tab.tabType == .table { commands.insert(.pasteRows) } + } + if isSelected, canFilterRows { commands.insert(.filterWithColumn) } + if tab.tableContext.tableName != nil { commands.insert(.copyAsSQL) } + if tab.tabType == .table, !tab.tableContext.primaryKeyColumns.isEmpty { commands.insert(.loadUnloadedValues) } + if findCoordinator.offersSearchAllRows(forTab: tab.id) { commands.insert(.searchAllRows) } + if isSelected, canClearActiveQueryResults { commands.insert(.clearResults) } + return commands + } + + // MARK: - Display caches + + var currentDisplayInputs: DataTabDisplayInputs { + let settings = AppSettingsManager.shared.dataGrid + return DataTabDisplayInputs( + dateFormat: settings.dateFormat, + nullDisplay: settings.nullDisplay, + smartValueDetection: settings.enableSmartValueDetection, + systemTimeZoneGeneration: DateFormattingService.shared.systemTimeZoneGeneration, + overridesVersion: ValueDisplayFormatService.shared.overridesVersion + ) + } + + // Formatted text kept by an unmounted model goes stale when the settings it was formatted with move. + func prepareGridModelForDisplay(forTab tabId: UUID) { + let inputs = currentDisplayInputs + guard let entry = dataGridEntries[tabId], entry.displayInputs != inputs else { return } + entry.model.display.cache.removeAll() + dataGridEntries[tabId]?.displayInputs = inputs + } + + // Each model holds a whole result's formatted text, so only the tabs in use keep theirs. + func pruneGridDisplayCaches() { + let budget = MemoryPressureAdvisor.budgetForInactiveTabs() + 1 + guard dataGridEntries.count > budget else { return } + let ordered = dataGridEntries.sorted { $0.value.lastUsed > $1.value.lastUsed } + for (tabId, entry) in ordered.dropFirst(budget) where !entry.model.isMounted { + entry.model.display.cache.removeAll() + dataGridEntries[tabId]?.displayInputs = nil + } + } +} + +extension DataGridModel { + var displayedRowIDs: [RowID]? { + order.filteredIDs + } +} diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridViewport.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridViewport.swift index 4b09e7f7ca..fffc3eb546 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridViewport.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridViewport.swift @@ -6,10 +6,6 @@ import Foundation extension MainContentCoordinator { - func takeViewportPlacement(forTab tabId: UUID) -> GridViewportPlacement? { - tabSessionRegistry.takeViewportPlacement(for: tabId) - } - func restoredRowAnchor(forTab tabId: UUID) -> [String: String]? { tabManager.tabs.first(where: { $0.id == tabId })?.restoredRowAnchor } @@ -19,14 +15,13 @@ extension MainContentCoordinator { tabManager.mutate(tabId: tabId) { $0.restoredRowAnchor = nil } } - /// A query tab's grid stays where AppKit leaves it after a run; only a re-read of its own result - /// asks to keep the place and the selection. + // A query tab's grid stays where AppKit leaves it after a run; only a re-read of its own result keeps place and selection. func isGridMounted(forTab tabId: UUID, acceptsQueryTab: Bool = false) -> Bool { guard tabManager.selectedTabId == tabId, let tab = tabManager.selectedTab, tab.tabType == .table || (acceptsQueryTab && tab.tabType == .query), tab.display.resultsViewMode == .data else { return false } - return dataTabDelegate?.tableViewCoordinator != nil + return existingGridModel(forTab: tabId)?.isMounted == true } func viewportKeyColumns(forTab tabId: UUID) -> [String] { @@ -42,15 +37,16 @@ extension MainContentCoordinator { guard intent == .keepPlace || keepsSelection, let tableRows = tabSessionRegistry.existingTableRows(for: tabId), !tableRows.rows.isEmpty else { return .top } - let sample = intent == .keepPlace ? dataTabDelegate?.tableViewCoordinator?.viewportSample() : nil + let model = existingGridModel(forTab: tabId) + let sample = intent == .keepPlace ? viewportSample(of: model) : nil guard sample != nil || keepsSelection else { return .top } let snapshot = GridViewportResolver.snapshot( of: tableRows, - displayIDs: displayIDs(forTab: tabId), - firstVisibleDisplayRow: sample?.firstVisibleRow ?? 0, + displayIDs: model?.displayedRowIDs, + firstVisibleDisplayRow: sample?.row ?? 0, firstVisibleOffset: sample?.offset ?? 0, - selectedDisplayRows: keepsSelection ? Array(selectionState.indices) : [], + selectedDisplayRows: keepsSelection ? Array(model?.selection.affectedRows ?? IndexSet()) : [], keyColumns: keyColumns, isCellModified: { [changeManager] rowID, column in changeManager.isCellModified(rowID: rowID, columnIndex: column) @@ -58,4 +54,14 @@ extension MainContentCoordinator { ) return sample == nil ? snapshot.selectionOnly : snapshot } + + // The mounted grid's first visible row, else the anchor the grid saved when it was last unmounted. + private func viewportSample(of model: DataGridModel?) -> (row: Int, offset: CGFloat)? { + guard let model else { return nil } + if let live = model.viewportSample() { return live } + guard let anchor = model.store.viewportAnchor, + let rowID = anchor.firstVisibleRow, + let row = model.displayIndex(of: rowID) else { return nil } + return (row, anchor.firstVisibleOffset) + } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift index d4cb4e7845..bd00b048c8 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+NavigationHistory.swift @@ -13,13 +13,7 @@ private let navigationHistoryLogger = Logger(subsystem: "com.TablePro", category extension MainContentCoordinator { // MARK: - Availability - /// Back is refused once the tab is showing something other than a table, because stepping back - /// would have nothing to put on the forward stack in its place. - /// - /// Unsaved cell edits do NOT refuse it. Going back does replace the tab's content and would - /// take them with it, but that is what the discard alert is for, and `step(back:)` routes - /// through the same one refresh, sort, pagination and filter already use. Refusing instead left - /// Back dim over a destination that still existed, with nothing saying why. + // Unsaved cell edits do not refuse Back: `step(back:)` goes through the discard alert; refusing left Back dim with no reason. var canNavigateBack: Bool { guard let tabId = navigableTabId else { return false } return navigationHistories[tabId]?.canGoBack ?? false @@ -37,12 +31,7 @@ extension MainContentCoordinator { return tab.id } - /// The half of `selectedTabHoldsProtectedContent` navigation cannot offer to discard. - /// - /// Staged structure edits are the only one that survives the `tabType == .table` requirement - /// alongside cell edits: `holdsQueryWork` is false off a query tab and the `.createTable` arm - /// cannot be reached. They stay a refusal because the discard alert clears `changeManager` and - /// nothing else, so offering to discard them would be a promise this path cannot keep. + // Staged structure edits block: the discard alert clears only `changeManager`, so offering to discard them is a broken promise. private var selectedTabBlocksNavigation: Bool { guard let tab = tabManager.selectedTab else { return true } return hasStagedStructureEdits(in: tab) @@ -50,11 +39,7 @@ extension MainContentCoordinator { // MARK: - Recording - /// What the selected tab is showing right now, or nil when it is not showing a table. - /// - /// Capturing and recording are two calls rather than one because a retarget can fail. Recording - /// what a tab was showing when it never left would push a duplicate onto Back and, worse, drop - /// the whole forward stack for a navigation that did not happen. + // Capture and commit are separate because a retarget can fail; recording a non-move dupes Back and drops the forward stack. func captureNavigationEntry() -> TabNavigationEntry? { guard let tab = tabManager.selectedTab, tab.tabType == .table, @@ -78,31 +63,21 @@ extension MainContentCoordinator { ) } - /// Records a location the selected tab has now left, once the retarget that left it succeeded. - /// - /// Every navigation that retargets a tab in place calls this, because those are the only ones - /// that overwrite a view the reader has no other route back to. A jump that opens its own tab - /// records nothing: the tab it came from is still open, and the new tab starts with an empty - /// history, the way Command-clicking a link does. + // Only in-place retargets record; a jump that opens its own tab leaves the source open and starts with an empty history. func commitNavigationEntry(_ entry: TabNavigationEntry?) { guard let entry, let tabId = tabManager.selectedTabId else { return } navigationHistories[tabId, default: TabNavigationHistory()].record(entry) } - /// The primary-key values of the row the reader is on, so returning lands on that row rather - /// than at the top of the page. - /// - /// The selected index is a display position, so it is resolved to a row through the grid's own - /// mapping before anything is read off it: a per-column value filter makes display order and - /// storage order diverge, and indexing the buffer directly would read a different row (#1837). + // The key of the row the reader is on, so returning lands on that row rather than at the top (#1837). private func capturePrimaryKeyAnchor(for tab: QueryTab) -> [String: String]? { let keyColumns = tab.tableContext.primaryKeyColumns guard !keyColumns.isEmpty, - let gridCoordinator = dataTabDelegate?.tableViewCoordinator, - let displayIndex = selectionState.indices.min() else { return nil } + let model = existingGridModel(forTab: tab.id), + let displayIndex = model.selection.affectedRows.first, + let row = model.row(at: displayIndex) else { return nil } - let tableRows = tabSessionRegistry.tableRows(for: tab.id) - guard let row = gridCoordinator.displayRow(at: displayIndex, in: tableRows) else { return nil } + let tableRows = model.rows return NavigationRowAnchor.build( keyColumns: keyColumns, @@ -124,13 +99,7 @@ extension MainContentCoordinator { step(back: false) } - /// Asks before it discards, through the alert the other reload paths use. With nothing staged - /// the guard completes synchronously with `true`, so an ordinary step never defers a turn. - /// - /// The departing entry is captured *before* the guard on purpose. Discarding clears the change - /// records but leaves the edited values in `TableRows`, so a capture afterwards cannot tell a - /// staged key from a saved one and would anchor the forward stack on a value the user just - /// threw away. + // Captures before the discard guard: discarding keeps edited values in `TableRows`, so a later capture anchors on dropped keys. private func step(back: Bool) { let departing = captureNavigationEntry() confirmDiscardChangesIfNeeded(action: .navigation) { [weak self] confirmed in @@ -139,11 +108,7 @@ extension MainContentCoordinator { } } - /// Moves one entry and puts it back on the tab, or leaves the history exactly as it was. - /// - /// The stacks are mutated on a copy and only written back once the restore has actually landed. - /// Moving them first would strand the reader if the retarget failed: the entry they asked for - /// would be gone and Forward would point at the view they are still looking at. + // Stacks change on a copy, written back only once the restore lands, so a failed retarget leaves the history untouched. private func commitStep(back: Bool, from departing: TabNavigationEntry?) { guard let tabId = navigableTabId, var history = navigationHistories[tabId], @@ -160,12 +125,7 @@ extension MainContentCoordinator { navigationHistoryLogger.debug("navigated to \(entry.tableName, privacy: .public)") } - /// Puts a recorded location back on the selected tab. - /// - /// The sequence is `reuseActiveTab`'s, so a restore and an ordinary retarget reach the grid the - /// same way. The difference is the state written between the retarget and the load: the - /// recorded filters instead of the table's saved defaults, and the recorded sort and page - /// through the pending fields the first load already knows how to consume. + // Follows `reuseActiveTab`'s sequence, but loads the recorded filters, sort and page instead of the table's saved defaults. private func restore(_ entry: TabNavigationEntry, in tabId: UUID) -> Bool { do { try tabManager.replaceTabContent( @@ -187,9 +147,7 @@ extension MainContentCoordinator { discardRowsForRetarget(resultsViewMode: entry.resultsViewMode) guard let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.id == tabId else { return false } - /// The page is always carried, not only when it is past the first. `replaceTabContent` - /// resets pagination to the app's default page size, so a view recorded on page one of a - /// 500-row page would otherwise come back in pages of a different size. + // Always carry the page: `replaceTabContent` resets to the default page size, so a recorded page would come back resized. tabManager.mutate(at: tabIndex) { tab in tab.filterState = entry.filterState tab.pendingRestoredSort = entry.sortColumns.isEmpty ? nil : entry.sortColumns diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index d66e15145f..244e8e2069 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -72,7 +72,7 @@ extension MainContentCoordinator { tab.tabType == .table, tab.display.resultsViewMode != .structure else { return } - dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + commitActiveGridEdit() guard changeManager.hasChanges || hasPendingTableOps else { reloadTableTab(at: tabIndex) return @@ -92,10 +92,7 @@ extension MainContentCoordinator { func reloadTableTab(at tabIndex: Int) { stopExecution(for: tabManager.tabs[tabIndex].id) - /// A refresh asks for the table as it is now, so the exact count the user requested earlier - /// describes a table that may have moved on. Retiring it here is what lets the automatic - /// count re-derive a total, which it otherwise refuses to do rather than downgrade an exact - /// count to an estimate. + // A refresh retires the earlier exact count, else the automatic count refuses to re-derive rather than downgrade it to an estimate. tabManager.mutate(at: tabIndex) { $0.pagination.retireDerivedRowCount() } rebuildTableQuery(at: tabIndex) runQuery(viewport: .keepPlace) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 2f1adc30d5..d5359844b6 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -7,19 +7,13 @@ import Foundation import TableProPluginKit extension MainContentCoordinator { - /// Whether the selected tab can take a new row. - /// - /// One definition, because the answer drives two controls: the toolbar item that inserts the row - /// and the **Add Row** item on the data grid's empty-space menu. + // One definition for both the toolbar item and the data grid's empty-space Add Row item. var canAddRow: Bool { guard canEditActiveResult, let tab = tabManager.selectedTab else { return false } guard tab.tableContext.tableName != nil else { return false } - /// Only the data grid takes a row. `addNewRow()` resolves its target through - /// `GridSelectionOwner`, which answers `.none` in Chart mode and `.schemaGrid` in Structure - /// mode, so without this the command is either inert or adds a column under a row's name. + // `GridSelectionOwner` answers `.none` in Chart and `.schemaGrid` in Structure, so elsewhere Add Row is inert or adds a column. guard tab.display.resultsViewMode == .data else { return false } - /// A new row is pre-filled from the schema's account of which columns the server fills in, - /// so the command waits for that account rather than staging NULL into an identity column. + // A new row pre-fills server-filled columns from the schema, so wait for it rather than stage NULL into an identity column. return tabSessionRegistry.tableRows(for: tab.id).hasAuthoritativeSchema } @@ -27,12 +21,16 @@ extension MainContentCoordinator { rowEditingCoordinator.addNewRow() } - func deleteSelectedRows(indices: Set) { - rowEditingCoordinator.deleteSelectedRows(indices: indices) + func deleteRows(_ rowIDs: [RowID]) { + rowEditingCoordinator.deleteRows(rowIDs) } - func duplicateSelectedRow(index: Int) { - rowEditingCoordinator.duplicateSelectedRow(index: index) + func undoRowDeletions(_ rowIDs: [RowID]) { + rowEditingCoordinator.undoRowDeletions(rowIDs) + } + + func duplicateRow(_ rowID: RowID) { + rowEditingCoordinator.duplicateRow(rowID) } func handleUndoResult(_ result: UndoResult) { @@ -71,27 +69,15 @@ extension MainContentCoordinator { rowEditingCoordinator.stageInspectorFieldRemoval(columnIndex: columnIndex, rowIDs: rowIDs) } - func copySelectedRowsToClipboard(indices: Set) { - rowEditingCoordinator.copySelectedRowsToClipboard(indices: indices) - } - - func copySelectedRowsWithHeaders(indices: Set) { - rowEditingCoordinator.copySelectedRowsWithHeaders(indices: indices) + func copyRowsToClipboard(_ rowIDs: [RowID], includeHeaders: Bool = false) { + rowEditingCoordinator.copyRowsToClipboard(rowIDs, includeHeaders: includeHeaders) } - func copySelectedRowsAsJson(indices: Set) { - rowEditingCoordinator.copySelectedRowsAsJson(indices: indices) + func copyRowsAsJson(_ rowIDs: [RowID]) { + rowEditingCoordinator.copyRowsAsJson(rowIDs) } func pasteRows() { rowEditingCoordinator.pasteRows() } - - func updateCellInTab(rowIndex: Int, columnIndex: Int, value: String?) { - rowEditingCoordinator.updateCellInTab( - rowIndex: rowIndex, - columnIndex: columnIndex, - value: PluginCellValue.fromOptional(value) - ) - } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index d78df27930..38e1b2bfe9 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -28,32 +28,23 @@ extension MainContentCoordinator { return try changeManager.generateSQL(for: changes) } - // Each row keeps which fields it has none of, so a removal is written as one and a value typed into a missing - // field is compared against a field that is not there. + // Each row keeps its missing fields, so a removal writes as one and a typed value compares against an absent field. private func sidebarEditChanges(editedFields: [InspectorFieldEdit]) -> [RowChange] { guard let tab = tabManager.selectedTab, - !selectionState.indices.isEmpty, tab.tableContext.tableName != nil, - GridSelectionOwner.resolve( - tabType: tab.tabType, - resultsViewMode: tab.display.resultsViewMode - ) == .dataGrid + let model = activeDataGridModel, + !model.selection.isEmpty else { return [] } guard !editedFields.isEmpty else { return [] } - let tableRows = tabSessionRegistry.tableRows(for: tab.id) + let tableRows = model.rows let writes = editedFields.compactMap { sidebarWrite(for: $0, in: tableRows) } guard !writes.isEmpty else { return [] } - let displayIDs = activeGridDisplayIDs - return selectionState.indices.sorted().compactMap { rowIndex -> RowChange? in - guard let resolvedRow = DisplayRowMapping.row( - forDisplay: rowIndex, - displayIDs: displayIDs, - in: tableRows - ) else { return nil } + return model.selection.affectedRows.compactMap { displayRow -> RowChange? in + guard let resolvedRow = model.row(at: displayRow) else { return nil } let originalRow = Array(resolvedRow.values) return RowChange( rowID: resolvedRow.id, @@ -79,9 +70,7 @@ extension MainContentCoordinator { } } - /// Typed text is read for its column, as the grid reads a cell: a function name on a date column is the - /// function, and text a NOT NULL column can hold neither as itself nor as NULL writes nothing. An explicit - /// NULL stays NULL, and SQL picked from the menu is already the vocabulary's spelling. + // Typed text is read for its column as the grid reads a cell (a date function stays a function); explicit NULL stays NULL. private func sidebarWrite( for field: InspectorFieldEdit, in tableRows: TableRows diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift index c09b02ff6c..49fd8a17f4 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift @@ -6,24 +6,12 @@ import Foundation extension MainContentCoordinator { - /// The path that means "the user closed these tabs themselves", which, with moving the last tab - /// to another connection, is the consent `clearForUserClosedAllTabs` requires. Every automatic - /// path leaves the saved state alone, because an empty tab list can equally mean a session was - /// lost or the app is quitting, and treating that as a delete throws away tabs nobody closed. - /// - /// Closing tabs never closes the window: the window hosts every open connection now, so the - /// connection is simply left on its empty state. - /// - /// Consent to close is not consent to lose work. Callers that can reach a tab holding unsaved - /// work ask first, through `MainContentCommandActions.closeTabAwaiting(id:)`. + // User-initiated, the consent `clearForUserClosedAllTabs` needs; automatic paths never clear saved tabs. func closeTabsByUser(ids: [UUID]) { - dataTabDelegate?.tableViewCoordinator?.flushPendingColumnLayoutPersistence() + flushColumnLayoutPersistence() for id in ids { guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { continue } - /// The closing tab's own save point. A tab switch saves the tab it leaves, but a close - /// removes the tab before the selection moves, so nothing else writes the rows the - /// reader left in the filter bar. An applied filter was written when it was applied; - /// rows that were never applied have only this. + // A close removes the tab before the selection moves, so this is the only save of filter rows never applied. saveLastFilters(of: tab) RecentlyClosedTabStore.shared.push(tab: tab, connection: connection) releaseResources(of: tab) @@ -34,18 +22,16 @@ extension MainContentCoordinator { persistence.clearForUserClosedAllTabs() } - /// Not a close: the tab is not going away, so nothing goes to Reopen Closed Tab. The source view is - /// usually unparented by now, so its structure-change save will not fire and this save is the only one. + // Not a close, so nothing goes to Reopen Closed Tab; the unparented source view will not save, so this is the only save. internal func takeTabForMove(id: UUID) -> QueryTab? { guard canMoveTabToConnection(id), let tab = tabManager.tabs.first(where: { $0.id == id }) else { return nil } let moved = enrichedForPersistence(tab) - /// The live caret is the moved tab's. Closing selects a neighbour without its editor mounting, - /// so the save below would otherwise write that caret into the neighbour. + // The live caret is the moved tab's: closing selects a neighbour without mounting its editor, so the save would write it there. if tabManager.selectedTabId == id { cursorPositions = [] } - dataTabDelegate?.tableViewCoordinator?.flushPendingColumnLayoutPersistence() + flushColumnLayoutPersistence() releaseResources(of: tab) releaseExecution(of: tab) tabSessionRegistry.unregister(id: id) @@ -53,29 +39,18 @@ extension MainContentCoordinator { cleanupTabCaches(openTabIds: Set(tabManager.tabs.map(\.id))) services.schemaProviderRegistry.reclaimUnheldProviders(for: connectionId) persistence.saveAggregated() - /// Moving the last tab out is the user emptying this connection, the same consent closing it - /// gives. Left on disk, the tab would come back here on the next restore as a duplicate. + // Moving the last tab out empties the connection like closing it; left on disk, the tab would restore here as a duplicate. if Self.aggregatedTabs(for: connectionId).isEmpty { persistence.clearForUserClosedAllTabs() } return moved } - /// The object these tabs show is gone, which is not the user closing them. Nothing goes to Reopen - /// Closed Tab, which could only reopen onto an error, and the saved tab set is never cleared, - /// because an emptied tab list is not consent to forget it. + // Not a user close: nothing goes to Reopen Closed Tab (it would reopen onto an error) and the saved tab set is kept. func closeTabsForRemovedObjects(ids: [UUID]) { - /// Discarded rather than flushed, which is what the user-close path does. The object is - /// gone and the adoption has already cleared its saved settings, so a write from the - /// teardown would put the layout back after the clear. - /// - /// Only when the closed tabs include the selected one, because that is the only tab with a - /// mounted grid: a pending width belongs to it, and dropping an unrelated table would - /// otherwise throw away a resize the reader had just made somewhere else. - if let selectedId = tabManager.selectedTabId, ids.contains(selectedId) { - dataTabDelegate?.tableViewCoordinator?.discardPendingColumnLayoutPersistence() - } for id in ids { + // Discarded, not flushed: the object is gone and its saved layout was cleared, so a write would put it back. + releaseGridModel(forTab: id, persistingLayout: false) guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { continue } releaseResources(of: tab) releaseExecution(of: tab) @@ -84,13 +59,12 @@ extension MainContentCoordinator { } } - /// Drops the per-tab caches of tabs that are no longer open. Every one of these is keyed by - /// tab id, so a stale entry does not merely waste memory: the next tab to be handed that id - /// would read another tab's state. + // All keyed by tab id, so a stale entry would hand another tab's state to the next tab given that id. func cleanupTabCaches(openTabIds: Set) { prune(&displayFormatsCache, keeping: openTabIds) - prune(&displayOrderCache, keeping: openTabIds) - prune(&displayStateCache, keeping: openTabIds) + for tabId in Array(dataGridEntries.keys) where !openTabIds.contains(tabId) { + releaseGridModel(forTab: tabId) + } prune(&tableMetadataCache, keeping: openTabIds) prune(&createTableDrafts, keeping: openTabIds) prune(&userTypeEditors, keeping: openTabIds) @@ -103,21 +77,14 @@ extension MainContentCoordinator { } } - /// Rewrites the dictionary only when something has actually gone, because assigning a filtered - /// copy back is what a plain `filter` would cost on every tab change. + // Rewrites only when something went, so a tab change does not pay for a filtered copy every time. private func prune(_ cache: inout [UUID: Value], keeping openTabIds: Set) { guard cache.keys.contains(where: { !openTabIds.contains($0) }) else { return } cache = cache.filter { openTabIds.contains($0.key) } } - /// A retarget keeps the tab's id and throws away everything the id used to mean, so the - /// per-tab caches keyed on that id now describe a table the tab no longer shows. Left behind, - /// the structure session goes on reporting staged ALTERs, which raises an unsaved-changes prompt - /// naming the previous table and offers to apply its statements from a tab that cannot show - /// them. `selectedTabHoldsProtectedContent` is what stops a tab holding real work being - /// retargeted at all; this is what keeps the caches honest once one without work has been. + // A retarget keeps the id, so stale caches (staged ALTERs) would prompt to apply the previous table's changes. func releaseRetargetedTabState(for tabId: UUID) { - displayStateCache.removeValue(forKey: tabId) tableMetadataCache.removeValue(forKey: tabId) tabSessionRegistry.forgetFreshness(for: tabId) structureSessions.removeValue(forKey: tabId)?.releaseViewWiring() @@ -130,15 +97,7 @@ extension MainContentCoordinator { toolbarState.hasTypeChanges = false } - /// A closed tab has to take its coordinator-side state with it, and this is the only place that - /// can: `handleTabChange` snapshots a tab by looking it up in `tabManager.tabs`, and by the time - /// it runs the tab is already gone. So it must happen before `tabManager.closeTab(id:)`, which - /// is also what makes the selected-tab test meaningful, since that call reassigns the selection. - /// - /// Left behind, the change manager keeps the closed tab's edits and the table name they were - /// written against. Nothing clears it, so the connection goes on reporting unsaved work with no - /// tabs to show for it, and the next Save resolves its scope through `browseScope` and runs - /// those statements against whatever database the sidebar has since moved to. + // Before `closeTab(id:)`, which hides the tab from `handleTabChange`; stale edits would later save to the wrong database. private func releaseResources(of tab: QueryTab) { tabsWithStagedPrincipals.remove(tab.id) structureSessions.removeValue(forKey: tab.id)?.releaseViewWiring() @@ -146,7 +105,7 @@ extension MainContentCoordinator { userTypeEditors.removeValue(forKey: tab.id) erDiagramModels.removeValue(forKey: tab.id) navigationHistories.removeValue(forKey: tab.id) - displayStateCache.removeValue(forKey: tab.id) + releaseGridModel(forTab: tab.id) tableMetadataCache.removeValue(forKey: tab.id) guard isSelectedTab(tab) else { return } changeManager.clearChangesAndUndoHistory() @@ -156,15 +115,7 @@ extension MainContentCoordinator { toolbarState.hasTypeChanges = false } - /// Ends what the tab was running, on the same terms a retarget does. - /// - /// A closed tab used to keep its claim in the registry, so the window went on reporting the - /// work as running until the query it could no longer show came back. Reopen Closed Tab hands - /// the restored tab the id it had before, which is enough for the orphan to be mistaken for the - /// reopened tab's own load and for that load to be refused as a duplicate. - /// - /// The query handle is this tab's own, so it goes down with the tab. No ownership check is - /// needed any more: a handle keyed by tab cannot belong to another one. + // A closed tab kept its registry claim, so a reopened tab with the same id had its load refused as a duplicate. internal func releaseExecution(of tab: QueryTab) { reportEndedExecutions(tabExecution.invalidate(tab.id, reason: .abandoned).map { [$0] } ?? []) cancelTableLoad(for: tab.id) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabStatePersistence.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabStatePersistence.swift index ff047a7377..87e404a2e9 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabStatePersistence.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabStatePersistence.swift @@ -5,15 +5,14 @@ import Foundation -/// The view-layer half of `SessionTabStatePersisting`. One coordinator is enough to ask, because -/// `saveAggregatedSync()` collects the tabs of every window on the connection, not just its own. +// One coordinator is enough to ask: saveAggregatedSync() collects the tabs of every window on the connection. @MainActor internal final class SessionTabStatePersister: SessionTabStatePersisting { internal func persistTabState(for connectionId: UUID) { let coordinators = MainContentCoordinator.allActiveCoordinators() .filter { $0.connectionId == connectionId } for coordinator in coordinators { - coordinator.dataTabDelegate?.tableViewCoordinator?.flushPendingColumnLayoutPersistence() + coordinator.flushColumnLayoutPersistence() } coordinators.first?.persistence.saveAggregatedSync() } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 06fb49c1e6..d89a25e631 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -26,7 +26,13 @@ extension MainContentCoordinator { "[switch] handleTabChange start from=\(oldTabId?.uuidString ?? "nil", privacy: .public) to=\(newTabId?.uuidString ?? "nil", privacy: .public) connId=\(self.connectionId, privacy: .public) tabsCount=\(self.tabManager.tabs.count)" ) isHandlingTabSwitch = true + // The change manager holds the outgoing tab's edits until the restore below, so an open cell editor commits there (H2). + tabSwitchOutgoingTabId = oldTabId + if let oldTabId { + commitGridEdit(ofTab: oldTabId) + } defer { + tabSwitchOutgoingTabId = nil isHandlingTabSwitch = false Self.lifecycleLogger.debug( "[switch] handleTabChange done to=\(newTabId?.uuidString ?? "nil", privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" @@ -37,32 +43,12 @@ extension MainContentCoordinator { if let oldId = oldTabId, let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) { - /// The second half of the condition is the fix. Gated on `hasChanges` alone, an undo was - /// never recorded: the tab kept the snapshot a previous switch had saved, so switching - /// back restored the edit the reader had just taken back and the tab went on reporting - /// unsaved work. Writing the empty snapshot is what clears it. - /// - /// Still conditional, because `mutate` takes the element `inout` and so runs the array's - /// setter whether or not the block writes: an unconditional write would fire `tabs`' - /// `didSet` over every open tab on every switch, for nothing. - /// - /// Safe to take the snapshot from the change manager here because nothing has repointed - /// it yet: every other `configureForTable` and `restoreState` caller is either the - /// incoming block below or guarded to the selected tab, and this runs synchronously - /// from the selection change with no suspension in between. + // Also checks the tab's snapshot: on `hasChanges` alone an undo kept a stale snapshot that restored the undone edit on return. if changeManager.hasChanges || tabManager.tabs[oldIndex].pendingChanges.hasChanges { let savedState = changeManager.saveState() tabManager.mutate(at: oldIndex) { $0.pendingChanges = savedState } } - // One editor serves every query tab, so `cursorPositions` describes the outgoing tab - // only until the switch completes. Persistence writes the live caret for the selected - // tab alone, so a caret not captured here is gone once the editor has consumed the - // tab's restored value. - // - // Only a tab whose own restored caret is already consumed can be written, because the - // query editor subtree has no `.id(tab.id)`: selecting a restored tab reuses the same - // editor without re-installing it, so `cursorPositions` can still describe the tab - // before it. Overwriting there would destroy the very caret this is meant to keep. + // One editor serves every query tab with no `.id(tab.id)`, so write only once this tab's restored caret has been consumed. let outgoing = tabManager.tabs[oldIndex] if outgoing.tabType == .query, outgoing.restoredCursorOffset == nil, @@ -73,23 +59,16 @@ extension MainContentCoordinator { $0.restoredCursorLength = range.length } } - /// Before the restore below repoints `selectionState` at the incoming tab. The outgoing - /// grid is still mounted and still bound to that shared channel, so the repoint clears - /// its table view and its own teardown then has nothing left to report. (#2667) - if let live = mountedGridSelection() { - storeGridSelection(rows: live.rows, cells: live.cells, forTab: oldId) - } filterCoordinator.saveLastFilters(of: tabManager.tabs[oldIndex]) } let saveMs = Int(Date().timeIntervalSince(saveStart) * 1_000) - // Defer to the next run-loop tick so the synchronous switch path - // stays cheap; the sort + budget calculation is non-trivial on - // connections with many open tabs. + // Deferred a tick: the sort and budget calculation is costly with many open tabs. if tabManager.tabs.count > 2 { let activeIds: Set = Set([oldTabId, newTabId].compactMap { $0 }) Task { @MainActor [weak self] in self?.evictInactiveTabs(excluding: activeIds) + self?.pruneGridDisplayCaches() } } @@ -99,14 +78,11 @@ extension MainContentCoordinator { let newTab = tabManager.tabs[newIndex] let newRows = tabSessionRegistry.tableRows(for: newId) - selectionState.indices = newTab.selectedDisplayRows toolbarState.isTableTab = newTab.tabType == .table toolbarState.isResultsCollapsed = newTab.display.isResultsCollapsed syncQueryToolbarState(for: newTab) - /// Consumed once restored. The change manager is the selected tab's edits from here, and - /// a copy left on the tab went stale with the first undo: it went on reporting edits the - /// reader had taken back, and a reload put off for them never ran. + // Consumed once restored: a copy left on the tab went stale with the first undo and kept reporting undone edits. let pendingState = newTab.pendingChanges if pendingState.hasChanges { changeManager.restoreState( @@ -119,6 +95,7 @@ extension MainContentCoordinator { columnTypeNames: newRows.columnTypeNames ) tabManager.mutate(at: newIndex) { $0.pendingChanges = TabChangeSnapshot() } + tabSwitchOutgoingTabId = nil } else { changeManager.configureForTable( tableName: newTab.tableContext.tableName ?? "", @@ -131,6 +108,7 @@ extension MainContentCoordinator { columnTypeNames: newRows.columnTypeNames, triggerReload: false ) + tabSwitchOutgoingTabId = nil } let restoreMs = Int(Date().timeIntervalSince(restoreStart) * 1_000) @@ -138,11 +116,7 @@ extension MainContentCoordinator { "[switch] handleTabChange phases: saveOutgoing=\(saveMs)ms restoreIncoming=\(restoreMs)ms" ) - // No `reloadVersion` bump here. It is the change manager's throw-away-and-fetch-again - // signal and it is shared by every tab in the window, so bumping it on a switch told - // the incoming grid its rows had changed and made it re-format the whole result. The - // reload a switch does need is already forced by the freshly mounted grid's zero row - // count, and a real content change still arrives through `configureForTable`. (#2424) + // No `reloadVersion` bump: it is window-wide, and bumping it on a switch re-formatted the incoming grid's whole result (#2424). let needsReload = needsRowReload(newId) lazyLoadCurrentTabIfNeeded() TabSwitchTracer.shared.selectionApplied( @@ -152,10 +126,7 @@ extension MainContentCoordinator { isShowing: { [weak manager = tabManager] in manager?.isShowing(newId) ?? false } ) } else { - /// An entry with no tab of its own. The outgoing tab's edits were saved onto it above, - /// and the live copy goes: the change manager is the selected tab's, and with none - /// selected a Save would write those rows against the database now on screen. - selectionState.indices = [] + // No tab selected: drop the live copy, or a Save would write the outgoing tab's rows against the database now on screen. changeManager.configureForTable( tableName: "", columns: [], @@ -171,6 +142,12 @@ extension MainContentCoordinator { } } + private func commitGridEdit(ofTab tabId: UUID) { + existingGridModel(forTab: tabId)?.commitEditing() + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + activeGridModelProvider(for: tab)?.activeGridModel?.commitEditing() + } + // For a tab that comes on screen without a selection change, before its pane restarts any load. func arrivingRows(for tabId: UUID) -> TabSwitchRows { guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return .resident } @@ -189,12 +166,7 @@ extension MainContentCoordinator { TableLoadTracer.shared.activeToken(for: tabId) != nil } - /// What the toolbar's Run, Explain, Format and Favorite items validate against. - /// - /// Execution state is deliberately not among these: `TabExecutionRegistry` is the single answer - /// to "is something running", and its own documentation records that a hand-kept mirror of that - /// is what let the titlebar report a query which had already ended (#2342). The toolbar reads - /// it live through `isSelectedTabExecuting`. + // No execution state here: a hand-kept mirror let the titlebar show an ended query (#2342), so the toolbar reads the registry. func syncQueryToolbarState(for tab: QueryTab) { toolbarState.isQueryTab = tab.tabType == .query toolbarState.hasQueryText = tab.tabType == .query && tab.hasQueryText @@ -209,20 +181,12 @@ extension MainContentCoordinator { syncQueryToolbarState(for: tab) } - /// Whether the tab the toolbar is pointed at has a query in flight, asked of the registry each - /// time rather than stored. var isSelectedTabExecuting: Bool { guard let tabId = tabManager.selectedTabId else { return false } return tabExecution.isExecuting(tabId) } - /// Whether dropping this tab's rows is safe, which is exactly whether `canAutoLoadTableTab` - /// will bring them back. The two answers have to agree: a tab evicted without a route back to - /// its rows shows an empty grid until the user refreshes it by hand. - /// - /// Table tabs qualify because their rows follow from their generated query. Query tabs do not, - /// a pinned result shares the buffer it would lose, and a tab with an execution, a load task or - /// a page fetch in flight would have the result land on a buffer that moved out from under it. + // Must agree with `canAutoLoadTableTab`: a tab evicted with no route back to its rows shows an empty grid until refreshed. func canEvictReloadableTableRows(_ tab: QueryTab) -> Bool { guard tab.id != tabManager.selectedTabId, tab.tabType == .table, @@ -254,7 +218,7 @@ extension MainContentCoordinator { tab.loadEpoch &+= 1 } tabSessionRegistry.evict(for: tabId) - displayStateCache.removeValue(forKey: tabId) + reloadGridModel(forTab: tabId) return true } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index fcec52a80b..869d72a70b 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -18,9 +18,7 @@ extension MainContentCoordinator { _ mutate: (inout TableRows) -> Delta ) -> Delta { let delta = tabSessionRegistry.updateTableRows(for: tabId, mutate) - if delta.changesRowSet { - refreshDisplayOrder(forTab: tabId) - } + applyGridDelta(delta, forTab: tabId) return delta } @@ -43,12 +41,13 @@ extension MainContentCoordinator { } else { resetViewStateForNewSource(tabId: tabId) } + // Staged before the model reloads: the mounted grid applies it, selection included, on that reload. if gridIsMounted { let placement = GridViewportResolver.placement( for: intent, from: snapshot, in: tableRows, - displayIDs: displayIDs(forTab: tabId), + displayIDs: resolvedDisplayIDs(of: tableRows, forTab: tabId), keyColumns: keyColumns ) tabSessionRegistry.stageViewportPlacement(placement, for: tabId) @@ -56,16 +55,21 @@ extension MainContentCoordinator { if !tableRows.rows.isEmpty { clearRestoredRowAnchor(forTab: tabId) } - notifyFullReplaceIfActive(tabId: tabId) + notifyFullReplace(tabId: tabId) + } + + // The order the incoming rows will show in, before the model has reloaded onto them. + private func resolvedDisplayIDs(of tableRows: TableRows, forTab tabId: UUID) -> [RowID]? { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), tab.valueFilter.isActive else { return nil } + return GridDisplayOrderResolver.resolve( + tableRows: tableRows, + valueFilter: tab.valueFilter, + displayFormats: dataGridEntries[tabId]?.store.displayFormats ?? displayFormats(for: tab), + databaseType: connection.type + ) } - /// The one place a whole `TableRows` is installed into the shared buffer. - /// - /// Reconciling the view mode belongs here rather than at each caller, because a result set - /// switch and a re-seed replace the rows just as completely as a fresh execution does: a tab - /// left on Map that switches to a result with no geometry column is stranded exactly the same - /// way. An incremental edit goes through `mutateActiveTableRows` instead and never changes - /// which modes the result can offer. + // Mode reconciliation lives here: a result switch or re-seed strands a Map tab like a fresh run does. Edits never change modes. private func installTableRows(_ tableRows: TableRows, for tabId: UUID) { tabSessionRegistry.setTableRows(tableRows, for: tabId) let spatialColumns = SpatialColumn.columns(in: tableRows) @@ -73,12 +77,7 @@ extension MainContentCoordinator { reconcileResultsViewMode(against: tableRows, spatialColumns: spatialColumns, for: tabId) } - /// Keeps the tab on a mode it can still offer. - /// - /// A mode that leaves `availableModes` takes its own switcher segment and every View menu item - /// with it, and those items carry no key equivalent, so nothing is left to press: the tab is - /// stranded until another statement happens to restore the mode. A succeeding non-SELECT - /// installs an empty `TableRows` and does exactly that. + // A mode leaving `availableModes` takes its segment and View menu items (no key equivalent), stranding the tab, as a non-SELECT does. private func reconcileResultsViewMode( against tableRows: TableRows, spatialColumns: [SpatialColumn], @@ -101,10 +100,7 @@ extension MainContentCoordinator { tabManager.mutate(tabId: tabId) { $0.display.resultsViewMode = reconciled } } - /// Switching result sets replaces what the grid shows and what an edit would be written to, so - /// it takes the same unsaved-changes gate as sorting, paging and filtering. Without the gate a - /// pending edit set outlives the switch and `handleColumnsChange` refuses to rebuild the change - /// manager while it exists, which leaves the grid writing to the outgoing result's table. + // Same unsaved-changes gate as sorting: `handleColumnsChange` won't rebuild with pending edits, so the grid would write to the old table. func switchActiveResultSet(to resultSetId: UUID?, in tabId: UUID) { guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }), tabManager.tabs[tabIdx].display.activeResultSetId != resultSetId else { return } @@ -116,16 +112,7 @@ extension MainContentCoordinator { } } - /// Takes the reader to the statement a result came from, when they moved to a different result. - /// - /// This is the reverse of the run: a statement produces a result, so selecting the result says which statement to - /// look at. It is only ever a response to the reader picking a result, which is what the HIG allows; nothing here - /// runs on its own. Re-selecting the result already showing does nothing, because the reader is comparing pinned - /// results rather than asking to be taken anywhere, and scrolling the editor under them would be an answer to a - /// question they did not ask. - /// - /// The caret moves without taking first responder, so focus stays wherever the reader put it and the - /// caret-statement band follows for free. + // Only on the reader picking another result (HIG). Moves the caret without taking first responder, so focus stays put. private func revealStatement(behind resultSetId: UUID?, in tabId: UUID) { guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }), tabManager.tabs[tabIdx].tabType == .query, @@ -136,7 +123,7 @@ extension MainContentCoordinator { requestStatementJump(anchor, in: tabId) } - /// The switch itself, for callers that have already decided the outgoing result is going away. + // No discard gate: for callers that already decided the outgoing result is going away. func applyResultSetSwitch(to resultSetId: UUID?, in tabId: UUID) { guard let tabIdx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } flushBufferToActiveResult(tabId: tabId, pinnedOnly: false) @@ -146,12 +133,10 @@ extension MainContentCoordinator { resetViewStateForNewSource(tabId: tabId) syncLoadMoreState(from: incoming, at: tabIdx) adoptOrigin(of: incoming, at: tabIdx) - notifyFullReplaceIfActive(tabId: tabId) + notifyFullReplace(tabId: tabId) } - /// The shared row buffer holds whatever the active result is showing, and it is the only copy - /// that page loads, Fetch All and cell edits write to. Handing it back before it is replaced is - /// what keeps a result the user can return to from serving rows it never produced. + // The shared buffer is the only copy page loads, Fetch All and edits write to, so hand it back before replacing it. func flushBufferToActiveResult(tabId: UUID, pinnedOnly: Bool) { guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }), let active = tabManager.tabs[idx].display.activeResultSet, @@ -160,28 +145,16 @@ extension MainContentCoordinator { active.tableRows = tabSessionRegistry.tableRows(for: tabId) } - /// Re-points the shared buffer at whatever result just became active. - /// - /// A plan or an error result carries no rows, and leaving the previous result's rows in the - /// buffer means the next flush hands them to it, after which it renders a data grid instead of - /// its plan or its error. - /// - /// It replaces the rows as wholly as a result switch does, so it takes the same full-replace - /// path. The grid skips its update while a cell editor or viewer is open, and only the full - /// replace closes them, so a failed Run All over an open viewer otherwise kept the previous - /// result's headings and selection under the error. + // A plan or error result has no rows: left in the buffer, the previous rows get flushed into it and it renders as a grid. func seedBufferFromActiveResult(tabId: UUID) { guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } let rows = tabManager.tabs[idx].display.activeResultSet?.tableRows ?? TableRows() installTableRows(rows, for: tabId) resetViewStateForNewSource(tabId: tabId) - notifyFullReplaceIfActive(tabId: tabId) + notifyFullReplace(tabId: tabId) } - /// A tab's table context describes its newest execution, so moving to another result has to - /// move the context onto that result's own table. The change manager is rebuilt here rather - /// than through the `schemaVersion` observer, because that observer refuses to run while - /// unsaved edits exist and would leave the manager pointed at the outgoing table (#2243). + // Rebuilds the change manager here, not via the `schemaVersion` observer, which refuses to run with unsaved edits (#2243). private func adoptOrigin(of resultSet: ResultSet, at tabIdx: Int) { guard tabManager.tabs[tabIdx].tabType == .query else { return } let origin = resultSet.origin @@ -209,8 +182,7 @@ extension MainContentCoordinator { ) } - /// A value filter stores displayed strings picked out of the outgoing source, so on an unrelated - /// result it narrows to nothing. A query tab's grid sort goes too: the new rows carry no ORDER BY. + // A value filter holds strings from the outgoing source, so it narrows an unrelated result to nothing; new rows carry no ORDER BY. private func resetViewStateForNewSource(tabId: UUID) { clearValueFilter(forTab: tabId) tabManager.mutate(tabId: tabId) { tab in @@ -220,31 +192,17 @@ extension MainContentCoordinator { resetSelection(tabId: tabId) } - /// Row selection is a set of display positions into the rows being replaced, so it means nothing - /// after them. A re-read of the same source puts it back by key through the viewport placement. + // Row ids are positional per page. A re-read of the same source restores the selection by key via the viewport placement. private func resetSelection(tabId: UUID) { - tabManager.mutate(tabId: tabId) { tab in - guard !tab.selectedRowIndices.isEmpty || !tab.cellSelection.isEmpty else { return } - tab.selectedRowIndices = [] - tab.cellSelection = .empty - } - guard let idx = tabManager.selectedTabIndex, - idx < tabManager.tabs.count, - tabManager.tabs[idx].id == tabId else { return } - /// The cell selection is what the published row selection is derived from, so leaving it - /// behind here would let the next publish reinstate rows the new result does not have. - dataTabDelegate?.tableViewCoordinator?.selectionController.clear() - dataTabDelegate?.tableViewCoordinator?.clearRowSelection() - if !selectionState.indices.isEmpty { - selectionState.indices = [] + if tabManager.tabs.first(where: { $0.id == tabId })?.selectionSnapshot.isEmpty == false { + tabManager.mutate(tabId: tabId) { $0.selectionSnapshot = .empty } } + guard let entry = dataGridEntries[tabId] else { return } + entry.store.selectionSnapshot = .empty + entry.model.clearSelection() } - /// Orders a result that has no query of its own to send again, over the rows the grid already holds. - /// - /// A SQL Server batch can return a result no single statement stands behind, or one read by a statement that - /// needs a variable the batch declared. Re-running with `ORDER BY` would mean re-sending the whole script, writes - /// and all, so the rows are ordered here instead. + // A SQL Server batch result may have no single statement behind it, and re-running with ORDER BY would resend writes, so sort here. func sortHeldRows(by state: SortState, tabId: UUID) { confirmDiscardChangesIfNeeded(action: .sort) { [weak self] confirmed in guard let self, confirmed else { return } @@ -276,13 +234,10 @@ extension MainContentCoordinator { } } - private func notifyFullReplaceIfActive(tabId: UUID) { - guard let idx = tabManager.selectedTabIndex, - idx < tabManager.tabs.count, - tabManager.tabs[idx].id == tabId else { return } - + private func notifyFullReplace(tabId: UUID) { + let isSelected = tabManager.selectedTabId == tabId let tracer = TableLoadTracer.shared - let token = tracer.applyingToken + let token = isSelected ? tracer.applyingToken : nil if let token { tracer.stage( .gridReloadBegin, @@ -290,7 +245,7 @@ extension MainContentCoordinator { detail: "rows=\(tabSessionRegistry.tableRows(for: tabId).rows.count)" ) } - dataTabDelegate?.tableViewCoordinator?.applyFullReplace() + reloadGridModel(forTab: tabId) if let token { tracer.stage(.gridReloadEnd, token: token) } } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift index 700136ca79..74b9acd5d0 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift @@ -9,13 +9,7 @@ import Combine import Foundation extension MainContentCoordinator { - /// Marks every addressed table tab stale, reloads the selected one now when the plan allows, and - /// has the structure of each fetched again. - /// - /// Nothing here asks a question. The change was made somewhere else, maybe in another window, so - /// a tab holding edits or an open cell overlay keeps them, and its rows, until they are gone, and - /// a structure holding staged edits keeps those. The tab that made the change is left out of the - /// rows plan only: it reloads its own rows, and its structure is fetched like any other. + // Asks nothing: the change came from elsewhere, so tabs with edits or overlays keep them; the source tab reloads its own rows. func refreshTableTabs( for change: TableFreshness.Change, excluding originTabId: UUID? = nil, @@ -44,10 +38,7 @@ extension MainContentCoordinator { refreshStructure(ofTabs: addressed) } - /// Runs the reload the selected tab put off while its cell overlay or its edits were in the way, - /// once they are gone. The plan is asked again rather than remembered, so whatever the user - /// started meanwhile, a new overlay, an edit or a load of the tab's own, still stands in the way. - /// A grid torn down with its window closes its overlay too, and that starts nothing. + // The plan is asked again, not remembered, so anything the user started meanwhile still blocks the reload. func resumeDeferredTableRefresh() { guard !isTearingDown, let selectedState = selectedTabRefreshState, @@ -60,10 +51,7 @@ extension MainContentCoordinator { ) } - /// Edits go without a Discard too: the last one undone, a cell typed back to what it held, a - /// deleted row restored. Each leaves the change manager clean, which is when a reload put off for - /// them can run. Heard a turn later, because the manager publishes before it stores the value, and - /// a switch or a save that cleaned it has started the tab's own load by then. + // Heard a turn later: the manager publishes before it stores, and by then a cleaning switch or save has started the load. func resumeWhenEditsClear() -> AnyCancellable { changeManager.$hasChanges .removeDuplicates() @@ -73,8 +61,7 @@ extension MainContentCoordinator { .sink { [weak self] _ in self?.resumeDeferredTableRefresh() } } - /// A change that names no single table reaches every table tab in its scope, and can have - /// changed the columns of a table no tab shows, so every column list cached for scoping goes. + // A scope-wide change can alter columns of tables no tab shows, so every cached column list goes. func applyDataRefresh(_ request: DataRefreshRequest) { guard request.connectionId == connectionId else { return } schemaColumns.removeAll() @@ -83,9 +70,7 @@ extension MainContentCoordinator { } } - /// Structure the user is not editing is fetched again: now for the one on screen, on its next - /// mount for the rest. A structure holding staged edits keeps them and the old baseline, and is - /// fetched once they are applied, undone or discarded. + // A structure with staged edits keeps them and the old baseline until they are applied, undone or discarded. func refreshStructure(ofTabs tabs: [QueryTab]) { let selectedId = tabManager.selectedTabId for tab in tabs { @@ -99,15 +84,11 @@ extension MainContentCoordinator { } } - /// The named table's column list, cached for scoping a tab opened on it later. func forgetSchemaColumns(of change: DatabaseObjectChange) { schemaColumns.remove(schemaColumnsKey(change.name, scope: change.scope)) } - /// The columns cached for building a table's column-scoped query describe the definition before - /// the change, and a reload builds its SQL from them before it fetches anything: kept, they would - /// name a dropped column in the select list and leave out an added one. The reload's own - /// definition fetch stores the new set. + // A reload builds its SQL from these cached columns before fetching, so stale ones would select a dropped column. private func forgetSchemaColumns(ofTabs tabs: [QueryTab]) { for tab in tabs { guard let tableName = tab.tableContext.tableName else { continue } @@ -140,7 +121,7 @@ extension MainContentCoordinator { guard let tab = tabManager.selectedTab else { return nil } let holdsEdits = changeManager.hasChanges || tab.pendingChanges.hasChanges - || dataTabDelegate?.tableViewCoordinator?.hasOpenCellOverlay == true + || isEditingActiveGrid return TableRowsRefreshPlan.SelectedTabState(id: tab.id, holdsEdits: holdsEdits, load: selectedTabLoad(tab.id)) } @@ -154,7 +135,7 @@ extension MainContentCoordinator { return tabExecution.isBusy(tabId) ? .extending : .idle } - /// Written only when there is a count to retire, because every write fires `tabs`' `didSet`. + // Written only when set, because every write fires tabs' didSet. private func retireDerivedRowCountIfSet(forTab tabId: UUID) { guard let pagination = tabManager.tabs.first(where: { $0.id == tabId })?.pagination, pagination.totalRowCount != nil || pagination.isApproximateRowCount else { return } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+UnloadedValues.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+UnloadedValues.swift index 48c1b46fe2..f3bd273ecc 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+UnloadedValues.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+UnloadedValues.swift @@ -43,23 +43,24 @@ extension MainContentCoordinator { // False while the rows' unloaded values load; `retry` runs once every one has arrived. func unloadedValuesAreLoaded( - displayRows: Set, + rowIDs: [RowID], columns: IndexSet? = nil, retry: @escaping @MainActor () -> Void ) -> Bool { guard let tab = tabManager.selectedTab else { return true } - let positions = displayRows.sorted() - let shownIDs = shownRowIDs(at: positions, tabId: tab.id) let rows = tabSessionRegistry.tableRows(for: tab.id) - let ids = shownIDs.compactMap { $0 } - .filter { id in rows.row(withID: id)?.unloadedColumns.keys.contains { columns?.contains($0) ?? true } == true } + let ids = rowIDs.filter { id in + rows.row(withID: id)?.unloadedColumns.keys.contains { columns?.contains($0) ?? true } == true + } guard !ids.isEmpty else { return true } let tabId = tab.id + // Row ids are positional per page, so a new buffer makes the same ids name other rows. + let epoch = tabSessionRegistry.session(for: tabId)?.bufferEpoch Task { @MainActor [weak self] in guard let self else { return } let loaded = await self.loadUnloadedValues(tabId: tabId, rowIDs: ids, columns: columns) guard loaded, self.tabManager.selectedTabId == tabId, - self.shownRowIDs(at: positions, tabId: tabId) == shownIDs else { + self.tabSessionRegistry.session(for: tabId)?.bufferEpoch == epoch else { NSSound.beep() return } @@ -68,19 +69,9 @@ extension MainContentCoordinator { return false } - private func shownRowIDs(at positions: [Int], tabId: UUID) -> [RowID?] { - let rows = tabSessionRegistry.tableRows(for: tabId) - let displayIDs = activeGridDisplayIDs - return positions.map { DisplayRowMapping.row(forDisplay: $0, displayIDs: displayIDs, in: rows)?.id } - } - - func loadInspectorRowValues(tabId: UUID, displayRow: Int) async { + func loadInspectorRowValues(tabId: UUID, rowID: RowID) async { guard tabManager.selectedTabId == tabId, - let row = DisplayRowMapping.row( - forDisplay: displayRow, - displayIDs: activeGridDisplayIDs, - in: tabSessionRegistry.tableRows(for: tabId) - ), + let row = tabSessionRegistry.tableRows(for: tabId).row(withID: rowID), row.hasUnloadedValues else { return } await loadUnloadedValues(tabId: tabId, rowIDs: [row.id]) } @@ -148,7 +139,6 @@ extension MainContentCoordinator { mutateActiveTableRows(for: tabId) { $0.fillUnloaded(read.values) } if tabManager.selectedTabId == tabId { changeManager.adoptLoadedValues(read.values) - repaintFilledCells(read.values, tabId: tabId) } else { tabManager.mutate(tabId: tabId) { $0.pendingChanges.adoptLoadedValues(read.values) } } @@ -211,23 +201,6 @@ extension MainContentCoordinator { return read } - // The fill reports storage positions, and the grid reads a delta's rows as display positions. - private func repaintFilledCells(_ values: [RowID: [Int: PluginCellValue]], tabId: UUID) { - let rows = tabSessionRegistry.tableRows(for: tabId) - let displayIDs = activeGridDisplayIDs - var positions: Set = [] - for (id, cells) in values { - guard let displayRow = DisplayRowMapping.displayIndex(forRowID: id, displayIDs: displayIDs, in: rows) else { - continue - } - for column in cells.keys { - positions.insert(CellPosition(row: displayRow, column: column)) - } - } - guard !positions.isEmpty else { return } - dataTabDelegate?.tableViewCoordinator?.applyDelta(.cellsChanged(positions)) - } - func rowsWithUnloadedValues() -> Set { guard let tabId = tabManager.selectedTab?.id else { return [] } return Set(tabSessionRegistry.tableRows(for: tabId).rows.lazy.filter(\.hasUnloadedValues).map(\.id)) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 85e5c78726..d19feeaa42 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -2,11 +2,7 @@ // MainContentCoordinator+WindowLifecycle.swift // TablePro // -// Window-lifecycle handlers invoked by TabWindowController's NSWindowDelegate -// methods. windowDidBecomeKey is intentionally lightweight (focus state, -// sidebar sync, and a file check that stats off the main actor) per Apple's -// documentation; visibility-scoped lazy-load lives in MainEditorContentView's -// `.task(id:)` modifier. +// windowDidBecomeKey stays light per Apple's docs; visibility-scoped lazy load lives in MainEditorContentView's .task(id:). // import AppKit @@ -17,13 +13,7 @@ import TableProPluginKit extension MainContentCoordinator { // MARK: - Window Delegate Dispatch - /// Called from `TabWindowController.windowDidBecomeKey(_:)`. - /// Updates focus state, refreshes file-based schema if stale, starts a check of - /// each file-backed tab against its file on disk, and syncs the sidebar selection - /// to the active tab. The one query-related action here is - /// consuming a deferred restore load: a restored background tab loads its data - /// the first time its window becomes key. All other lazy-load is owned by - /// `MainEditorContentView`'s `.task(id:)` modifier. + // The one query action here: a restored background tab loads its data the first time its window becomes key. func handleWindowDidBecomeKey() { let t0 = Date() Self.lifecycleLogger.debug( @@ -45,9 +35,6 @@ extension MainContentCoordinator { ) } - /// Called from `TabWindowController.windowDidResignKey(_:)`. - /// Schedules a 5s-delayed eviction of row data in inactive tabs; a fresh - /// `windowDidBecomeKey` cancels the eviction before it fires. func handleWindowDidResignKey() { Self.lifecycleLogger.debug( "[switch] coordinator.handleWindowDidResignKey connId=\(self.connectionId, privacy: .public)" @@ -65,20 +52,15 @@ extension MainContentCoordinator { } } - /// Called from `TabWindowController.windowWillClose(_:)`. - /// Synchronous teardown — no grace period, no delayed Task. Writes tab - /// state to disk, releases SwiftUI-scoped right-panel state, then - /// disconnects the session if this was the last window for the connection. + // Synchronous on purpose: no grace period, no delayed Task. func handleWindowWillClose() { let t0 = Date() Self.lifecycleLogger.info( "[close] coordinator.handleWindowWillClose connId=\(self.connectionId, privacy: .public) tabs=\(self.tabManager.tabs.count)" ) - /// Never clears: a window closing says nothing about whether the user wants these tabs - /// kept, and every connection in the window reaches here. Discarding saved state is - /// `closeTabsByUser`'s job alone. - dataTabDelegate?.tableViewCoordinator?.flushPendingColumnLayoutPersistence() + // Never clears: closing a window says nothing about keeping its tabs; only closeTabsByUser discards saved state. + flushColumnLayoutPersistence() if !MainContentCoordinator.isAppTerminating, !isTearingDown { persistence.saveAggregatedSync() } @@ -95,8 +77,6 @@ extension MainContentCoordinator { ) } - /// Announce the active tab title to VoiceOver when the window becomes key, - /// so assistive-technology users get the same context the window title gives. private func announceActiveTabToVoiceOver() { guard let title = tabManager.selectedTab?.title, !title.isEmpty else { return } NSAccessibility.post( @@ -129,13 +109,7 @@ extension MainContentCoordinator { // MARK: - Sidebar Sync - /// Mark the object the selected tab is showing in this window's object tree, or nothing when - /// that tab belongs to a container the tree is not listing. Reads tables fresh from the - /// DatabaseManager because the schema load is async and may complete after focus changes. - /// - /// The mark is a function of four inputs, so every one of them calls this: the selected tab, - /// the browsed container, the object list, and the window becoming key. Recomputing on only - /// some of them is what left a stale mark on a row in another database (#2217). + // Every input the mark depends on (tab, container, objects, key window) calls this; skipping one left a stale mark (#2217). func syncSidebarObjectSelection() { let selectedTab = tabManager.selectedTab let selection = SidebarObjectSelection.resolve( @@ -151,13 +125,7 @@ extension MainContentCoordinator { // MARK: - Lazy Load - /// Lowers the flag `discardRowsForRetarget` raised, for a load that is not going to run. - /// - /// The flag is raised synchronously with the row buffer being emptied, so the bar never reads a - /// cleared tab as a settled empty result. Every path that then declines to start the load has to - /// lower it again, or the bar keeps a spinner and a fully disabled pagination cluster for a tab - /// that is not loading anything, until the next successful load or `Cmd+.`. A load that is - /// already in flight is not a decline, and must leave the flag alone. + // Every path that declines the load must lower the flag, or the bar keeps a spinner; an in-flight load leaves it alone. func declineTableLoad(for tabId: UUID) { tabManager.mutate(tabId: tabId) { $0.pagination.isLoading = false } } @@ -183,8 +151,7 @@ extension MainContentCoordinator { connectionId: connectionId ) - /// A trace the in-flight load still owns stays open, because that load is the one that will - /// close it. Only a trace this call minted is closed here. + // The in-flight load closes the trace it owns; only a trace this call minted is closed here. func noteLoadAlreadyInFlight() { guard carriedToken == nil else { return } tracer.anomaly(.loadAlreadyInFlight, token: traceToken) @@ -198,12 +165,7 @@ extension MainContentCoordinator { clearAbandonedExecutingFlagIfNeeded(for: tab) - /// The task slot above stops answering the moment the load hands off to an execution: - /// `executeQueryInternal` supersedes, and `supersedeExecution` clears the very slot held by - /// the task it is running inside. Every later trigger for the same navigation then found an - /// empty slot and scheduled a second identical load, whose predecessor took the successor's - /// claim down with it on the way out (#2342). The registry owns the other half of the same - /// question, so both halves are asked, and neither alone is enough. + // The task slot empties once the load hands off to execution, so the registry is asked too, or a duplicate load ran (#2342). guard !tabExecution.isBusy(tab.id) else { noteLoadAlreadyInFlight() return @@ -241,9 +203,7 @@ extension MainContentCoordinator { tableLoadTasks[tabId] = nil } - /// Becoming key ends the deferral whichever tab is selected. A link or an agent can select - /// another tab before the window comes forward, and a deferral left standing refused every - /// later load of that tab, including the table the user next opened into it. + // Ends the deferral whatever tab is selected: a link or agent can switch tabs first, and a leftover deferral blocked loads. func consumeDeferredRestoreLoadIfNeeded() { guard isKeyWindow, let deferredId = deferredRestoreLoadTabId else { return } deferredRestoreLoadTabId = nil diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index b9571b453a..86bd465bca 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -12,9 +12,7 @@ import TableProPluginKit extension MainContentView { // MARK: - Inspector Selection - /// Which grid owns the current selection. `GridSelectionState` is shared by the data - /// grid, the structure grid, and the new-table grid, so its indices only address the - /// data tab's rows when the data grid is the one on screen. + // Whose rows the active grid model's selection indexes: the data tab's, a schema grid's, or nobody's. var gridSelectionOwner: GridSelectionOwner { GridSelectionOwner.resolve( tabType: coordinator.tabManager.selectedTab?.tabType, @@ -22,27 +20,27 @@ extension MainContentView { ) } + private var dataGridModel: DataGridModel? { + gridSelectionOwner == .dataGrid ? coordinator.activeGridModel : nil + } + var selectedInspectorRow: InspectorRow? { guard gridSelectionOwner == .schemaGrid, - let displayRow = coordinator.selectionState.indices.min() else { return nil } + let displayRow = coordinator.activeGridModel?.selection.affectedRows.first else { return nil } return coordinator.inspectorRowSource?.inspectorRow(atDisplayRow: displayRow) } // MARK: - Inspector Subject - /// Whether anything is selected for the inspector to show. var hasInspectableRow: Bool { switch gridSelectionOwner { case .schemaGrid: return selectedInspectorRow != nil - case .dataGrid: return !coordinator.selectionState.indices.isEmpty + case .dataGrid: return dataGridModel?.selection.isEmpty == false case .none: return false } } - /// What the inspector names above its fields. - /// - /// A schema grid's selection is a column definition, not a row of a result: it has no position - /// and no identity, so it gets its own case rather than being rendered as "Row 0 of 0". + // A schema grid's selection is a column definition with no position, so it is not named "Row 0 of 0". var inspectorSubject: InspectorSubject { switch gridSelectionOwner { case .schemaGrid: @@ -61,34 +59,24 @@ extension MainContentView { } return dataRowSubject(table: table) ?? .tableOnly(table: table) case .none: - /// A chart, a dashboard or an ER diagram owns no grid, so there is no row to name. The - /// table still is the subject when the tab has one, which is what the table statistics - /// are shown under. + // A chart, a dashboard or a diagram owns no grid; the table is still the subject of its statistics. guard let table = qualifiedTableName else { return .empty } return .tableOnly(table: table) } } private func dataRowSubject(table: String) -> InspectorSubject? { - let indices = coordinator.selectionState.indices - guard !indices.isEmpty else { return nil } - if indices.count > 1 { - return .multipleRows(table: table, count: indices.count) + guard let model = dataGridModel else { return nil } + let rows = model.selection.affectedRows + guard let first = rows.first else { return nil } + if rows.count > 1 { + return .multipleRows(table: table, count: rows.count) } - guard let tab = coordinator.tabManager.selectedTab, let first = indices.min() else { - return .tableRow(table: table, position: nil) - } - let total = coordinator.activeGridDisplayIDs?.count - ?? coordinator.tabSessionRegistry.tableRows(for: tab.id).count + let total = model.rowCount guard total > 0 else { return .tableRow(table: table, position: nil) } - return .tableRow( - table: table, - position: InspectorSubject.RowPosition(index: first + 1, total: total) - ) + return .tableRow(table: table, position: InspectorSubject.RowPosition(index: first + 1, total: total)) } - /// Schema-qualified where the engine has schemas, so two tables of the same name in different - /// schemas do not read identically in the header. private var qualifiedTableName: String? { guard let table = currentTab?.tableContext.tableName else { return nil } guard let schema = currentTab?.tableContext.schemaName, !schema.isEmpty else { return table } @@ -97,23 +85,15 @@ extension MainContentView { // MARK: - Selected Row for the JSON Tab - /// The same selection the details tab reads, carried as raw cell values. - /// - /// The JSON tab decides from the column's own type whether a value prints quoted, which the - /// formatted strings the details tab takes cannot answer. Only the data grid supplies it: the - /// schema grid's rows are label and value pairs with no types and no foreign keys. + // Raw cell values, so the JSON tab can quote by the column's own type. Only the data grid has types. var jsonRowSnapshotForSidebar: JSONRowSnapshot? { - guard gridSelectionOwner == .dataGrid, + guard let model = dataGridModel, let tab = coordinator.tabManager.selectedTab, - let firstDisplayIndex = coordinator.selectionState.indices.min(), - let scope = coordinator.scope(for: tab) else { return nil } - let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) - guard !tableRows.columns.isEmpty, - let row = DisplayRowMapping.row( - forDisplay: firstDisplayIndex, - displayIDs: coordinator.activeGridDisplayIDs, - in: tableRows - ) else { return nil } + let firstDisplayIndex = model.selection.affectedRows.first, + let scope = coordinator.scope(for: tab), + let row = model.row(at: firstDisplayIndex) else { return nil } + let tableRows = model.rows + guard !tableRows.columns.isEmpty else { return nil } return JSONRowSnapshot( rowIdentity: "\(tab.id.uuidString)\u{001F}\(row.id)", @@ -128,36 +108,29 @@ extension MainContentView { // MARK: - Sidebar Edit State - /// Determine if sidebar should be in editable mode var isSidebarEditable: Bool { if gridSelectionOwner == .schemaGrid { return selectedInspectorRow?.isEditable ?? false } - guard gridSelectionOwner == .dataGrid, + guard let model = dataGridModel, let tab = coordinator.tabManager.selectedTab, tab.tabType == .table || tab.tableContext.tableName != nil, coordinator.canEditActiveResult, - !coordinator.selectionState.indices.isEmpty else { + !model.selection.isEmpty else { return false } return true } var isSelectedRowDeleted: Bool { - guard gridSelectionOwner == .dataGrid, - let tabId = coordinator.tabManager.selectedTab?.id, - let firstIndex = coordinator.selectionState.indices.min(), - let row = DisplayRowMapping.row( - forDisplay: firstIndex, - displayIDs: coordinator.activeGridDisplayIDs, - in: coordinator.tabSessionRegistry.tableRows(for: tabId) - ) else { return false } - return coordinator.changeManager.isRowDeleted(row.id) + guard let model = dataGridModel, + let first = model.selection.affectedRows.first, + let rowID = model.rowID(at: first) else { return false } + return coordinator.changeManager.isRowDeleted(rowID) } // MARK: - Sort State Binding - /// Binding for the current tab's sort state var sortStateBinding: Binding { Binding( get: { @@ -176,12 +149,12 @@ extension MainContentView { // MARK: - Results View Mode Binding - /// Binding for resultsViewMode state var resultsViewModeBinding: Binding { Binding( get: { coordinator.tabManager.selectedTab?.display.resultsViewMode ?? .data }, set: { newValue in if let index = coordinator.tabManager.selectedTabIndex { + coordinator.commitActiveGridEdit() coordinator.tabManager.mutate(at: index) { $0.display.resultsViewMode = newValue } } } @@ -190,7 +163,6 @@ extension MainContentView { // MARK: - Current Tab Accessor - /// Current selected tab for convenience var currentTab: QueryTab? { coordinator.tabManager.selectedTab } @@ -219,15 +191,14 @@ struct InspectorTrigger: Equatable { } enum InspectorValueDisplayFormatResolver { - /// The window keeps one grid coordinator, so it can still be pointed at the previously - /// selected tab. Its formats are only meaningful while it holds this tab's columns. + // The model's formats include a Display As the grid applied without a table to save it under. @MainActor static func activeFormats( - from grid: TableViewCoordinator?, + from model: DataGridModel?, matching columns: [String] ) -> [ValueDisplayFormat?]? { - guard let grid, grid.tableRowsProvider().columns == columns else { return nil } - return grid.columnDisplayFormats + guard let model, model.rows.columns == columns else { return nil } + return model.store.displayFormats } static func resolve( @@ -249,8 +220,7 @@ enum InspectorValueDisplayFormatResolver { } } -/// Lightweight equatable value combining all pending-change sources -/// for consolidated toolbar badge onChange observation. +// One value so a single `onChange` drives the toolbar badge across every pending-change source. struct PendingChangeTrigger: Equatable { let hasDataChanges: Bool let pendingTruncates: Set @@ -261,9 +231,7 @@ struct PendingChangeTrigger: Equatable { var selectedTabId: UUID? let isFileDirty: Bool let hasCreateTablePending: Bool - /// The fifth source. Without it staging a principal edit changes nothing this value can see, so - /// `updateToolbarPendingState()` is never re-run and the commit control stays dim however many - /// changes the Users & Roles tab holds. + // Without it a staged principal edit never re-runs `updateToolbarPendingState()`, so the commit control stays dim. let hasPrincipalChanges: Bool let hasTypeChanges: Bool } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index ce0aebb503..e7c1f83bff 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -2,9 +2,6 @@ // MainContentView+EventHandlers.swift // TablePro // -// Extension containing event handler methods for MainContentView. -// Extracted to reduce main view complexity. -// import os import SwiftUI @@ -29,9 +26,7 @@ extension MainContentView { updateWindowTitleAndFileState() let t2 = Date() - /// A tab selection change in a background window is usually a side effect rather than - /// navigation, a close refocusing its neighbour above all, and that window re-marks its own - /// object tree the moment it becomes key. + // In a background window this is usually a side effect (a close refocusing a neighbour); it re-marks once it is key. if coordinator.isKeyWindow { coordinator.syncSidebarObjectSelection() } @@ -72,7 +67,7 @@ extension MainContentView { } func handleColumnsChange(newColumns: [String]?) { - // Skip during tab switch — handleTabChange already configures the change manager + // Skip during tab switch: handleTabChange already configures the change manager guard !coordinator.isHandlingTabSwitch else { return } if let newColumns = newColumns { @@ -140,7 +135,7 @@ extension MainContentView { case .skip: return case .reuseActiveTab: - coordinator.selectionState.indices = [] + coordinator.selectedTabGridModel?.clearSelection() coordinator.openTableTab(table, schema: ref.qualifyingSchema) case .openNewTab: coordinator.openTableTab(table, schema: ref.qualifyingSchema) @@ -150,9 +145,7 @@ extension MainContentView { // MARK: - Sidebar Edit Handling func updateSidebarEditState() { - /// A selection change, a refresh or an undo rebuilds the fields under whatever was being - /// typed, so the run it was building ends here rather than folding the next keystroke into - /// a step that belongs to another row. + // A selection change, refresh or undo rebuilds the fields, so the typed run ends here, not in another row's step. coordinator.endInspectorEditRun() switch gridSelectionOwner { case .schemaGrid: @@ -165,19 +158,15 @@ extension MainContentView { break } - let selectedIndices = coordinator.selectionState.indices guard let tab = coordinator.tabManager.selectedTab, - !selectedIndices.isEmpty + let model = coordinator.activeGridModel, + !model.selection.isEmpty else { clearSidebarEditState() return } - let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) - - let displayIDs = coordinator.activeGridDisplayIDs - let selectedRows = selectedIndices.sorted().compactMap { - DisplayRowMapping.row(forDisplay: $0, displayIDs: displayIDs, in: tableRows) - } + let tableRows = model.rows + let selectedRows = model.selection.affectedRows.compactMap { model.row(at: $0) } let allRows = selectedRows.map { Array($0.values) } let columnTypes = tableRows.columnTypes.indices.compactMap { tableRows.editingColumnType(at: $0) } @@ -204,7 +193,6 @@ extension MainContentView { } } trailingPaneState.inspector.editState.configure( - selectedRowIndices: selectedIndices, rowIDs: selectedRows.map(\.id), allRows: stringRows, absentCells: selectedRows.map(\.absentColumns), @@ -266,8 +254,7 @@ extension MainContentView { capturedCoordinator.stageInspectorFieldRemoval(columnIndex: columnIndex, rowIDs: rowIDs) } trailingPaneState.inspector.editState.onDetachedFieldChanged = { columnIndex, newValue, rowIDs, isExpression in - /// A value window commits on every keystroke exactly as the field it detached from - /// does, so its typing folds into one undo step the same way. + // A value window commits per keystroke like its field, so its typing folds into one undo step the same way. capturedCoordinator.rowEditingCoordinator.stageInspectorFieldEdit( columnIndex: columnIndex, value: newValue, @@ -278,13 +265,7 @@ extension MainContentView { } } - /// The per-column display formats the grid is applying, in column order. - /// - /// The inspector had no idea these existed: its values came from a raw pass that decoded bytes - /// with `isoLatin1` and passed text straight through, while the dead tuple path beside it - /// computed formatted values nothing displayed. Only the editor choice is taken from the - /// format; the value stays raw, because a save writes what the editor holds and a formatted - /// timestamp written back into an integer column would corrupt it. + // Only the editor comes from the format; the value stays raw, since a formatted timestamp saved back would corrupt it. private func inspectorDisplayFormats( for tab: QueryTab, columns: [String], @@ -294,7 +275,7 @@ extension MainContentView { let scope = tab.tableContext.scope(connectionId: coordinator.connection.id) let storageKeys = ValueDisplayFormatColumnKey.storageKeys(for: columns) let activeFormats = InspectorValueDisplayFormatResolver.activeFormats( - from: coordinator.dataTabDelegate?.tableViewCoordinator, + from: coordinator.existingGridModel(forTab: tab.id), matching: columns ) let storedFormats = activeFormats == nil @@ -308,10 +289,7 @@ extension MainContentView { columnType: index < types.count ? types[index] : nil, databaseType: coordinator.connection.type ) - /// `.raw` is what the resolver returns for "nobody chose a format", and it is also what - /// `FieldEditorResolver` reads as "skip JSON, PHP and image detection". Forwarding it - /// would take the content-driven editors away from every column that has no override, - /// which is nearly all of them, so absence stays nil. + // FieldEditorResolver also reads .raw as skip JSON, PHP and image detection, so no override stays nil. return resolved == .raw ? nil : resolved } } @@ -330,11 +308,9 @@ extension MainContentView { trailingPaneState.inspector.editState.onDetachedFieldChanged = nil } - /// Populate the inspector from the grid that owns a schema selection, and send every - /// edit back to it so a change lands in its change manager exactly as an inline - /// grid edit does, with one undo step and one pending change. + // Edits go back to the owning grid so they land in its change manager with one undo step, like an inline edit. private func updateSchemaSidebarEditState() { - guard let displayRow = coordinator.selectionState.indices.min(), + guard let displayRow = coordinator.activeGridModel?.selection.affectedRows.first, let row = selectedInspectorRow else { clearSidebarEditState() diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 73c5f50ffc..4885f746c6 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -12,14 +12,7 @@ import TableProPluginKit extension MainContentView { // MARK: - Helper Methods - /// What the Table Info load waits for, so it runs again once each of them moves. - /// - /// The load waits for the tab's rows. Without `hasRows`, a table tab that had just opened ran the - /// load before they arrived and never again, so the panel showed no numbers until the inspector - /// left the screen and came back. Whether rows arrived, not when: a page, a sort or a filter - /// runs the query again without changing the size or the row count, and a read per page would - /// count up to 100,001 rows on SQLite and run `collStats` on MongoDB each time. The whole - /// identity, not the name, because `tenant_a.orders` and `tenant_b.orders` are two tables. + // Keys on whether rows arrived, not when: a read per page would count up to 100,001 SQLite rows or run MongoDB `collStats`. struct TableInfoTaskKey: Hashable { let tabId: UUID let table: TableTabIdentity? @@ -54,9 +47,7 @@ extension MainContentView { toolbarState.syncFromSession(for: connection) } - /// The banner appears without the user doing anything and macOS has no live region for it, so VoiceOver - /// hears it only through an announcement. Every window on the session shows the banner, and announcements - /// are app-wide, so only the key window speaks. + // macOS has no live region for the banner, so it is announced; announcements are app-wide, so only the key window speaks. func announceConnectionWarning(_ warning: String?) { guard let warning, coordinator.isKeyWindow else { return } AccessibilityAnnouncement.post( @@ -110,8 +101,7 @@ extension MainContentView { updateAssistantContext() } - /// Built only once the assistant exists. The grid summary is for the chat, and a window whose - /// assistant was never revealed has nothing to tell. + // The grid summary is for the chat, so a window whose assistant was never revealed builds nothing. func updateAssistantContext() { guard trailingPaneState.assistant.isActivated else { return } let snapshot = coordinator.assistantEditorSnapshot @@ -122,15 +112,12 @@ extension MainContentView { ) } - /// Nil on a tab that has no table of its own. Read from the entry cached for this tab: a single - /// latest-wins slot showed a query tab, a dashboard, or a table of the same name in another - /// database the numbers of whichever table had loaded last. + // Per-tab cache: a single latest-wins slot showed whichever table loaded last, even on a query tab or another database. var tableMetadataForCurrentTab: TableMetadata? { currentTab.flatMap { coordinator.currentTableMetadata(for: $0) } } - /// The scope a structure row's type picker looks types up in: the tab's own database and - /// schema, never the sidebar's, because a tab bound to another database edits that one. + // The tab's own database and schema, never the sidebar's: a tab bound to another database edits that one. private var structureTypeScope: DatabaseScope? { guard let tab = currentTab, tab.tabType == .table || tab.tabType == .createTable else { return nil } let database = tab.tableContext.databaseName ?? coordinator.browseDatabaseName @@ -197,36 +184,58 @@ extension MainContentView { // The inspector shows one row's values, so it loads that row's unloaded values while it is on screen. internal struct InspectorValueLoadModifier: ViewModifier { @ObservedObject var inspector: RowInspectorState - @ObservedObject var selection: GridSelectionState + let model: DataGridModel? let tabId: UUID? - let selectsDataRows: Bool let contentVersion: Int - let load: (UUID, Int) async -> Void + let load: (UUID, RowID) async -> Void func body(content: Content) -> some View { - content.task(id: key) { - guard let key else { return } - await load(key.tabId, key.displayRow) + content.background { + if let model, let tabId { + InspectorValueLoader( + model: model, + isOnScreen: inspector.isOnScreen, + tabId: tabId, + contentVersion: contentVersion, + load: load + ) + } } } +} + +// Observes the grid model here, so a selection change redraws this and not the window's content view. +private struct InspectorValueLoader: View { + @ObservedObject var model: DataGridModel + let isOnScreen: Bool + let tabId: UUID + let contentVersion: Int + let load: (UUID, RowID) async -> Void + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .task(id: key) { + guard let key else { return } + await load(key.tabId, key.rowID) + } + } private var key: Key? { - guard inspector.isOnScreen, selectsDataRows, let tabId, selection.indices.count == 1, - let displayRow = selection.indices.first else { return nil } - return Key(tabId: tabId, displayRow: displayRow, contentVersion: contentVersion) + guard isOnScreen, model.selection.affectedRows.count == 1, + let rowID = model.selectedRowIDs.first else { return nil } + return Key(tabId: tabId, rowID: rowID, contentVersion: contentVersion) } struct Key: Hashable { let tabId: UUID - let displayRow: Int + let rowID: RowID let contentVersion: Int } } -/// Runs the Table Info load where the inspector's on-screen state is observed. -/// -/// `MainContentView` does not observe the inspector, whose context changes on every selection, so a -/// key built in its body never saw `isOnScreen` move. Observed here, only this modifier redraws. +// `MainContentView` does not observe the inspector, so a key built in its body never saw `isOnScreen` move. internal struct TableInfoLoadModifier: ViewModifier { @ObservedObject var inspector: RowInspectorState let tab: QueryTab? diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 9b7d3f2f60..6d1c4b77d1 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -40,7 +40,7 @@ extension MainContentView { if payload == nil || payload?.intent == .restoreOrDefault { await coordinator.restoreSavedTabs() } - /// Before the schema load, which can take seconds, so a move waiting on the restore lands now. + // Before the schema load, which can take seconds, so a move waiting on the restore lands now. coordinator.settleTabRestore() _ = await schemaLoad } @@ -74,10 +74,7 @@ extension MainContentView { // MARK: - Command Actions Setup - /// One resolution of what is staged, so the commit control, its verb and Preview SQL's gate - /// cannot disagree. The arm this replaces never read `hasPrincipalChanges`, so a Users & Roles - /// tab with staged principals left both the toolbar's commit button and Cmd+S dim over work - /// `saveChanges()` already knew how to apply. + // One resolution of what is staged, so commit, its verb and Preview SQL agree; staged principals once left Cmd+S dim. func updateToolbarPendingState() { let kind = PendingChangeKind.resolve( tabType: tabManager.selectedTab?.tabType, @@ -90,18 +87,11 @@ extension MainContentView { ) toolbarState.pendingChange = kind toolbarState.hasPendingChanges = kind != nil - /// Preview SQL asks a narrower question than the commit control: a dirty query file and - /// staged principals both raise the commit and neither has grid SQL to show. + // Narrower than the commit: a dirty query file and staged principals raise the commit but have no grid SQL to show. toolbarState.hasDataPendingChanges = kind == .data || kind == .structure } - /// Update window title, proxy icon, and dirty dot based on the selected tab. - /// - /// This tree is the browse content, so it names the window as the browse content: `.content` - /// and `.browse` are what it is, not guesses. Whether it is the tree on screen is the window's - /// question, and its bindings drop a name or a file written from behind an agent conversation. - /// The edited dot is still written directly, because the unsaved work it reports is still in - /// the window while the conversation is drawn over it. + // The edited dot is written directly: the unsaved work it reports stays in the window under an agent conversation. func updateWindowTitleAndFileState() { let selectedTab = tabManager.selectedTab let resolved = WindowTitleResolver.resolveWindow( @@ -123,7 +113,7 @@ extension MainContentView { viewWindow?.isDocumentEdited = selectedTab.map(coordinator.showsUnsavedIndicator) ?? false } - /// Configure the hosting NSWindow — called by WindowAccessor when the window is available. + // Called by WindowAccessor once the window exists. func configureWindow(_ window: NSWindow) { let start = Date() MainContentView.lifecycleLogger.info( @@ -159,14 +149,11 @@ extension MainContentView { } func setupCommandActions() { - /// Called from `onAppear`, which a connection switch fires again. Building a second set of - /// actions left the hidden trailing pane holding the first, so every refresh and connect - /// broadcast was handled twice, and both trailing panes were rebuilt on every switch. + // `onAppear` refires on a connection switch; a second set of actions handled every broadcast twice and rebuilt both panes. guard commandActions == nil else { return } let actions = MainContentCommandActions( coordinator: coordinator, connection: connection, - selectionState: coordinator.selectionState, selectedTables: Binding( get: { coordinator.windowSidebarState.selectedTables }, set: { coordinator.windowSidebarState.selectTables($0) } diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift b/apple/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift index 1204f69f88..daf9947399 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift @@ -9,8 +9,7 @@ import Foundation extension MainContentCommandActions { enum BatchCloseKind: Equatable { case all - /// Anchored explicitly, because a contextual menu acts on the tab under the pointer and - /// that is not always the selected one. + // Explicit: a contextual menu acts on the tab under the pointer, which is not always the selected one. case others(anchor: UUID) case otherDatabases } @@ -50,8 +49,7 @@ extension MainContentCommandActions { return !tabsToClose(kind: .otherDatabases).isEmpty } - /// The entry this window shows, named the way this engine names containers. Comparing a - /// schema-switching engine's tabs against a database name classified nothing. + // Named the engine's way: a schema-switching engine's tabs compared against a database name classified nothing. var shownContainerName: String { coordinator?.tabManager.visibleContainer ?? coordinator?.browseCursorCoordinator.browsedContainer ?? "" } @@ -63,11 +61,7 @@ extension MainContentCommandActions { ) } - /// Tabs live in one window now, so a batch close is a list edit rather than a walk over - /// sibling windows. Closing every tab is still a window close, which already owns the save - /// prompt and the recovery capture, so that case is handed straight to it. - /// Closing every tab leaves the connection on its empty state rather than closing the window, - /// because the window is no longer this connection's window: it hosts all of them. + // A list edit, not a window close: the window hosts every connection, so closing all tabs leaves this one empty. private func runBatchClose(kind: BatchCloseKind) async { guard let coordinator else { return } let victims = tabsToClose(kind: kind) @@ -78,25 +72,16 @@ extension MainContentCommandActions { coordinator.closeTabsByUser(ids: victims.map(\.id).filter { closable.contains($0) && unpinned.contains($0) }) } - /// What a close should do about the work it is going to destroy. internal enum UnsavedWorkOutcome: Equatable { case cancel - /// The ids that may now be closed. Save leaves out any victim it could not actually save. + // Save leaves out any victim it could not actually save. case close(Set) } - /// A partial close leaves the window open, so it cannot lean on the window's own prompt. - /// The question is asked once for the batch rather than once per tab, which is also what keeps - /// the sheets from queueing: `NSWindow.beginSheet` queues a second sheet behind the first rather - /// than presenting it, so N prompts would be answered one at a time with no way to see why. - /// - /// Save goes on to close. It used to save and then return false, which left the batch standing - /// after a successful save and made Save mean "cancel" on this path while it meant "close" on - /// the window path. And it used to run `saveSelectedTabWork()` whatever the victims were, so on - /// Close Other Tabs and Close Tabs for Other Databases, where the selected tab is never a - /// victim, Save wrote a bystander's changes and the close destroyed the victims' own. + // One prompt for the batch: NSWindow.beginSheet queues a second sheet behind the first instead of presenting it. internal func resolveUnsavedWork(in victims: [QueryTab]) async -> UnsavedWorkOutcome { let everything = Set(victims.map(\.id)) + coordinator?.commitActiveGridEdit() guard hasUnsavedWork(among: victims) else { return .close(everything) } switch await confirmSaveChanges(unsavedWorkMessage(for: victims), closeAnchorWindow) { @@ -109,9 +94,7 @@ extension MainContentCommandActions { } } - /// Save cannot reach a tab that is not on screen for grid edits, staged principals or a table - /// draft, so the alert says what will happen to those rather than promising a save it cannot - /// make: they stay open, and everything else closes. + // Save cannot reach off-screen grid edits, staged principals or table drafts; those tabs stay open and the alert says so. private func unsavedWorkMessage(for victims: [QueryTab]) -> String { guard let coordinator, victims.contains(where: { coordinator.savability(of: $0) == .mountedOnly }) @@ -126,13 +109,7 @@ extension MainContentCommandActions { ) } - /// Saves each victim through the path that can reach it, and answers with the ones that are now - /// safe to close. - /// - /// A victim it cannot save keeps its tab: closing it would be the data loss the prompt exists to - /// prevent. That includes a file whose copy on disk changed underneath it, because the conflict - /// sheet is one window-level slot with no queue, and a second victim's conflict would overwrite - /// the first while both tabs closed behind it. + // A victim it cannot save keeps its tab, a file conflict too: the conflict sheet is one window-level slot with no queue. private func saveVictims(_ victims: [QueryTab]) async -> Set { guard let coordinator else { return Set(victims.map(\.id)) } guard await applyStagedStructureEdits(in: victims) else { return [] } @@ -161,14 +138,7 @@ extension MainContentCommandActions { return closable } - /// The pane on screen, saved through the one path that reaches all of it. - /// - /// Not every kind of unsaved work belongs to a tab: a staged TRUNCATE and an edit in the row - /// inspector belong to the connection, `savability` deliberately leaves them out, and - /// `saveSelectedTabWork` is the only thing that applies them. Skipping it because the selected - /// tab's own work looked clean is how Save came to close a connection with a staged TRUNCATE - /// still staged. An empty victim list is the whole connection, which is what the window-close - /// and disconnect paths ask about, and it goes through here too. + // Always runs: a staged TRUNCATE and inspector edits belong to the connection, and only saveSelectedTabWork applies them. private func saveWorkOnScreen(among victims: [QueryTab]) async -> Bool { guard let coordinator else { return true } let selected = coordinator.tabManager.selectedTab @@ -177,18 +147,7 @@ extension MainContentCommandActions { return await saveSelectedTabWork() } - /// What the batch is about to destroy, not what the connection happens to hold. - /// - /// A close that names its victims used to be judged by `hasUnsavedWorkInConnection`, which walks - /// every tab of the connection: closing one database's tabs while another database had a dirty - /// one asked the user to save work the command was not going to touch, and Save then ran - /// `saveSelectedTabWork()` on whatever tab was selected, which could be that other database's. - /// An empty victim list still means the whole connection, which is what the window-close and - /// disconnect paths ask about. - /// - /// The connection-wide flags ride with the selected tab. The row inspector's edits and the - /// pending truncates and drops belong to the pane on screen, so they count exactly when that - /// pane's tab is one of the victims. + // Victims only (empty means the whole connection); connection-wide flags count only when the on-screen tab is a victim. func hasUnsavedWork(among victims: [QueryTab]) -> Bool { guard let coordinator, !victims.isEmpty else { return hasUnsavedWorkInConnection } if victims.contains(where: { coordinator.hasUnsavedWork(in: $0) }) { return true } @@ -196,10 +155,7 @@ extension MainContentCommandActions { return coordinator.hasSidebarEdits || coordinator.hasPendingDestructiveTableOps } - /// Close All and Close Other Tabs act on the strip, which lists one entry: a close never takes - /// tabs the user cannot see. Close Tabs for Other Databases is the one that reaches behind the - /// other entries, and it takes every tab there, an empty one too, since each would keep its - /// entry listed. None of the three takes a pinned tab. + // Close All and Close Others act on the visible entry; Other Databases reaches every other entry, empty tabs too. private func tabsToClose(kind: BatchCloseKind) -> [QueryTab] { guard let coordinator else { return [] } let manager = coordinator.tabManager @@ -211,9 +167,7 @@ extension MainContentCommandActions { return manager.visibleTabs.filter { $0.id != anchor && !$0.isPinned } case .otherDatabases: let current = shownContainerName - /// A tab that cannot name its container is not in another one, it is in every one, and - /// `containerName` returning nil compared against a non-optional name made every such - /// tab foreign. + // A tab with no container name is in every container, not another one; nil compared to a name made it foreign. return manager.tabs.filter { tab in guard !tab.isPinned, let name = manager.containerName(of: tab) else { return false } return name != current diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+Documents.swift b/apple/TablePro/Views/Main/MainContentCommandActions+Documents.swift index 63343c5163..f97b9b60c1 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions+Documents.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions+Documents.swift @@ -15,13 +15,13 @@ extension MainContentCommandActions { } func editDocument() { - guard let displayRow = singleSelectedDataGridRow, - let locator = coordinator?.documentLocator(forDisplayRow: displayRow) else { return } + guard let rowID = singleSelectedDataGridRowID, + let locator = coordinator?.documentLocator(for: rowID) else { return } coordinator?.presentEditDocument(locator: locator) } var canEditDocument: Bool { - guard let displayRow = singleSelectedDataGridRow else { return false } - return coordinator?.canEditDocument(atDisplayRow: displayRow) ?? false + guard let rowID = singleSelectedDataGridRowID else { return false } + return coordinator?.canEditDocument(rowID: rowID) ?? false } } diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+PanelVisibility.swift b/apple/TablePro/Views/Main/MainContentCommandActions+PanelVisibility.swift index 44086fee18..52fdee2c75 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions+PanelVisibility.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions+PanelVisibility.swift @@ -6,8 +6,7 @@ import Foundation import TableProPluginKit -/// Read-side of the panel toggles. Each one reads the same state its `toggle` writes, -/// so the menu title describes what the command will actually do. +// Each reads the state its toggle writes, so the menu title matches what the command does. extension MainContentCommandActions { var isFilterBarVisible: Bool { guard let coordinator, let index = coordinator.tabManager.selectedTabIndex else { return false } @@ -25,19 +24,13 @@ extension MainContentCommandActions { } } -/// The result's view mode, which the results header switches and the View menu mirrors. -/// -/// The switcher used to be the only route to JSON and Chart mode, so those views were reachable by -/// mouse alone. A menu command is also what the HIG asks of any control that is not in the menu bar. +// The switcher was the only route to JSON and Chart; the HIG wants a menu command for any control not in the menu bar. extension MainContentCommandActions { var resultsViewMode: ResultsViewMode? { coordinator?.tabManager.selectedTab?.display.resultsViewMode } - /// Recomputed here rather than read off the status bar's snapshot, because the View menu has - /// to validate while no status bar is on screen. Both call sites take the same inputs, the - /// tab's own geometry columns included, so the menu and the switcher can never disagree about - /// whether Map is offered. + // Recomputed, not read from the status bar: the View menu validates while no status bar is on screen. var availableResultsViewModes: [ResultsViewMode] { guard let coordinator, let tab = coordinator.tabManager.selectedTab else { return [] } let tableRows = coordinator.tabSessionRegistry.existingTableRows(for: tab.id) @@ -53,6 +46,7 @@ extension MainContentCommandActions { func setResultsViewMode(_ mode: ResultsViewMode) { guard let coordinator, let tabId = coordinator.tabManager.selectedTab?.id else { return } guard availableResultsViewModes.contains(mode) else { return } + coordinator.commitActiveGridEdit() coordinator.tabManager.mutate(tabId: tabId) { $0.display.resultsViewMode = mode } } } diff --git a/apple/TablePro/Views/Main/MainContentCommandActions.swift b/apple/TablePro/Views/Main/MainContentCommandActions.swift index 2692c0e247..a58475ef42 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions.swift @@ -2,10 +2,6 @@ // MainContentCommandActions.swift // TablePro // -// Provides command actions for MainContentView, reached through MainContentCoordinator. -// Menu commands and toolbar buttons call methods directly instead of posting notifications. -// Retains NotificationCenter subscribers only for legitimate multi-listener broadcasts. -// import AppKit import Combine @@ -17,7 +13,7 @@ import TableProSQLGrammar import TableProUtilities import UniformTypeIdentifiers -/// Provides command actions for MainContentView, reached through `MainContentCoordinator.commandActions`. +// Menu and toolbar commands call methods directly; NotificationCenter stays only for multi-listener broadcasts. @MainActor final class MainContentCommandActions: ObservableObject { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MainContentCommandActions") @@ -37,14 +33,12 @@ final class MainContentCommandActions: ObservableObject { // MARK: - Bindings - private let selectionState: GridSelectionState private let selectedTables: Binding> private let pendingTruncates: Binding> private let pendingDeletes: Binding> private let tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]> private let trailingPaneState: TrailingPaneState - /// The window this instance belongs to — used for key-window guards. weak var window: NSWindow? { didSet { guard window !== oldValue else { return } @@ -54,24 +48,20 @@ final class MainContentCommandActions: ObservableObject { // MARK: - State - /// Whether a text input holds first responder in this instance's window. - /// Stored rather than computed so Observation wakes the menu when focus - /// crosses that boundary; `NSWindow.firstResponder` publishes no change. + // Stored, not computed: NSWindow.firstResponder publishes no change, so Observation could not wake the menu. @Published var focusOwnsTextInput = false let textInputFocusObserver = OSAllocatedUnfairLock<(any NSObjectProtocol)?>(uncheckedState: nil) var isTextInputFocusCheckScheduled = false - /// Asks whether to save a tab being closed. The alert by default, a scripted answer in a test. + // A var so a test can script the answer instead of showing the alert. var confirmSaveChanges: (String, NSWindow?) async -> AlertHelper.SaveConfirmationResult = { message, window in await AlertHelper.confirmSaveChanges(message: message, window: window) } - /// Task handles for async notification observers; cancelled on deinit. private var notificationTasks: [Task] = [] - /// Combine subscriptions for typed AppEvents publishers. private var eventCancellables: Set = [] // MARK: - Initialization @@ -79,7 +69,6 @@ final class MainContentCommandActions: ObservableObject { init( coordinator: MainContentCoordinator, connection: DatabaseConnection, - selectionState: GridSelectionState, selectedTables: Binding>, pendingTruncates: Binding>, pendingDeletes: Binding>, @@ -88,7 +77,6 @@ final class MainContentCommandActions: ObservableObject { ) { self.coordinator = coordinator self.connection = connection - self.selectionState = selectionState self.selectedTables = selectedTables self.pendingTruncates = pendingTruncates self.pendingDeletes = pendingDeletes @@ -109,8 +97,6 @@ final class MainContentCommandActions: ObservableObject { // MARK: - Async Notification Helper - /// Creates a Task that iterates an async notification sequence and calls the handler. - /// The task is stored for cancellation on deinit. private func observe( _ name: Notification.Name, handler: @escaping @MainActor (Notification) -> Void @@ -124,16 +110,13 @@ final class MainContentCommandActions: ObservableObject { notificationTasks.append(task) } - /// The window being key is no longer enough: every connection it hosts shares that window, so - /// a broadcast gated on it alone ran once per connection and opened a file in all of them. - /// Only the connection on screen answers. + // Key window alone is not enough: every connection shares it, so a broadcast opened a file in all of them. private func isVisibleInKeyWindow() -> Bool { guard let window = self.window, window.isKeyWindow else { return false } guard let host = window.contentViewController as? MainSplitViewController else { return true } return host.workspaces.selected?.sessionState?.coordinator === coordinator } - /// Like `observe(_:handler:)` but only runs the handler when this instance's window is key. private func observeKeyWindowOnly( _ name: Notification.Name, handler: @escaping @MainActor (Notification) -> Void @@ -144,7 +127,6 @@ final class MainContentCommandActions: ObservableObject { } } - /// Subscribes to an `AppCommands` publisher and only runs the handler when this instance's window is key. private func observeKeyWindowOnly( _ publisher: PassthroughSubject, handler: @escaping @MainActor (Payload) -> Void @@ -156,11 +138,6 @@ final class MainContentCommandActions: ObservableObject { // MARK: - Save Action - /// Writes the inspector's pending edits. - /// - /// This used to be stored back on the panel's state as an `onSave` closure that only this type - /// ever set and only this type ever called; no view read it, despite a comment saying the panel - /// did. Calling the coordinator directly is the same work with one fewer hop. private func saveInspectorEdits() { let editState = trailingPaneState.inspector.editState Task { [weak self] in @@ -191,13 +168,10 @@ final class MainContentCommandActions: ObservableObject { observeKeyWindowOnly(AppCommands.shared.exportQueryResults) { [weak self] _ in self?.exportQueryResults() } } - // MARK: - Row Operations (Group A — Called Directly) + // MARK: - Row Operations (Group A: Called Directly) func addNewRow() { - // The structure tab routes through StructureGridDelegate, which inserts - // a column / index / FK row depending on the active Structure sub-tab. - // The data tab routes through MainContentCoordinator.addNewRow which - // calls RowEditingCoordinator.addNewRow (data-only). + // Structure inserts a column, index or FK row by sub-tab; the data path adds data rows only. switch selectionOwner { case .schemaGrid: coordinator?.structureActions?.addRow?() case .dataGrid: coordinator?.addNewRow() @@ -205,17 +179,13 @@ final class MainContentCommandActions: ObservableObject { } } - private func resolvedRowSelection() -> Set { - coordinator?.dataTabDelegate?.tableViewCoordinator?.currentRowSelection() ?? selectionState.indices + // The rows the data grid's model has selected, in display order; empty when another grid owns the selection. + private var selectedDataRowIDs: [RowID] { + guard dataGridOwnsSelection else { return [] } + return coordinator?.activeGridModel?.selectedRowIDs ?? [] } - /// `selectionState` is shared with the structure and new-table grids, and nothing clears it when - /// the result mode changes, so its indices only mean something once this says whose grid they - /// came from. Every row command routes through it rather than re-deriving the answer. - /// - /// A Create Table tab publishes into the same channel but has no structure handler behind it, - /// so claiming ownership there would make its commands silently inert and would shadow the - /// table-deletion fallback the sidebar still needs. Ownership counts only where someone can act. + // A Create Table tab has no structure handler; claiming its selection would make commands inert and shadow sidebar delete. private var selectionOwner: GridSelectionOwner { let owner = GridSelectionOwner.resolve( tabType: coordinator?.tabManager.selectedTab?.tabType, @@ -227,57 +197,49 @@ final class MainContentCommandActions: ObservableObject { private var dataGridOwnsSelection: Bool { selectionOwner == .dataGrid } - /// The display position of the one data-grid row a single-row command acts on, or nil when the - /// data grid does not own the selection or it holds other than one row. - var singleSelectedDataGridRow: Int? { - guard dataGridOwnsSelection else { return nil } - let indices = resolvedRowSelection() - guard indices.count == 1 else { return nil } - return indices.first + // The one data-grid row a single-row command acts on, or nil when the selection holds other than one row. + var singleSelectedDataGridRowID: RowID? { + let rowIDs = selectedDataRowIDs + guard rowIDs.count == 1 else { return nil } + return rowIDs.first } - func deleteSelectedRows(rowIndices: Set? = nil) { - let fromDataGrid = rowIndices != nil - + func deleteSelectedRows() { if selectionOwner == .schemaGrid { coordinator?.structureActions?.removeRow?() return } - let indices = dataGridOwnsSelection ? (rowIndices ?? resolvedRowSelection()) : [] - if !indices.isEmpty { - coordinator?.deleteSelectedRows(indices: indices) - } else if !fromDataGrid, !selectedTables.wrappedValue.isEmpty { - /// Through the sidebar's own path rather than a second copy of it. Staging the queue - /// here directly skipped the confirmation `batchToggleDelete` raises, so Delete from - /// the menu bar queued a drop with no dialog while the sidebar's Delete asked first. + let rowIDs = selectedDataRowIDs + if !rowIDs.isEmpty { + coordinator?.deleteRows(rowIDs) + } else if !selectedTables.wrappedValue.isEmpty { + // Through the sidebar's own path, which asks before it queues a drop. coordinator?.sidebarViewModel?.batchToggleDelete(refs: Array(selectedTables.wrappedValue)) } } func duplicateRow() { - guard dataGridOwnsSelection else { return } - let indices = selectionState.indices - guard let selectedIndex = indices.first, indices.count == 1 else { return } - coordinator?.duplicateSelectedRow(index: selectedIndex) + guard let rowID = singleSelectedDataGridRowID else { return } + coordinator?.duplicateRow(rowID) } func copySelectedRows() { switch selectionOwner { case .schemaGrid: coordinator?.structureActions?.copyRows?() - case .dataGrid: coordinator?.copySelectedRowsToClipboard(indices: resolvedRowSelection()) + case .dataGrid: coordinator?.copyRowsToClipboard(selectedDataRowIDs) case .none: break } } func copySelectedRowsWithHeaders() { guard dataGridOwnsSelection else { return } - coordinator?.copySelectedRowsWithHeaders(indices: resolvedRowSelection()) + coordinator?.copyRowsToClipboard(selectedDataRowIDs, includeHeaders: true) } func copySelectedRowsAsJson() { guard dataGridOwnsSelection else { return } - coordinator?.copySelectedRowsAsJson(indices: resolvedRowSelection()) + coordinator?.copyRowsAsJson(selectedDataRowIDs) } func pasteRows() { @@ -290,14 +252,10 @@ final class MainContentCommandActions: ObservableObject { // MARK: - Per-Window State (replaces AppState.shared for menu enablement) - /// Answered by the window that owns this instance, because only its `ConnectionWindowPhase` - /// can tell a window that is dialing or has failed from one that is connected. This object - /// existing proves nothing: it is kept alive across a lost session so a reconnect can restore - /// the user's tabs. + // Only the window's ConnectionWindowPhase knows; this object outlives a lost session so a reconnect can restore tabs. var isConnected: Bool { coordinator?.splitViewController?.isConnected ?? false } var isQueryExecuting: Bool { coordinator?.isSelectedTabBusy ?? false } - /// Separate from `isQueryExecuting` because `Cmd+.` has to dim while a batch commits, which is - /// running work nothing can interrupt. + // Not isQueryExecuting: Cmd+. has to dim while a batch commits, which nothing can interrupt. var isQueryStoppable: Bool { coordinator?.isSelectedTabStoppable ?? false } var safeModeLevel: SafeModeLevel { coordinator?.toolbarState.safeModeLevel ?? connection.safeModeLevel } @@ -316,8 +274,7 @@ final class MainContentCommandActions: ObservableObject { var connectionId: UUID { connection.id } - /// Whether Close has a tab to act on. With none, it ends the connection instead, and the menu - /// has to say so rather than offering to close a tab that is not there. + // With no tab, Close ends the connection, and the menu has to say so. var hasOpenTab: Bool { coordinator?.tabManager.selectedTab != nil } var browseDatabaseName: String { coordinator?.browseDatabaseName ?? "" } @@ -328,8 +285,6 @@ final class MainContentCommandActions: ObservableObject { PluginManager.shared.supportsContainerSwitching(for: connection.type) } - /// An engine with no database dimension has nothing to favorite, and neither has a window whose - /// browse database is still empty. var canFavoriteActiveDatabase: Bool { PluginManager.shared.containerSwitchTarget(for: connection.type) == .database && !browseDatabaseName.isEmpty @@ -360,10 +315,7 @@ final class MainContentCommandActions: ObservableObject { ) } - /// Picks between the two spellings a container command has. Each one is a whole localized - /// string rather than a noun dropped into a format, because System Settings binds an App - /// Shortcut to a menu item's exact literal title, and because the driver's own entity name - /// would make the set open-ended. Both callers live in other files, so this is not private. + // Whole localized titles, not a format: System Settings binds an App Shortcut to a menu item's exact title. func containerSwitchTitle(schema: String, database: String) -> String { switch PluginManager.shared.containerSwitchTarget(for: currentDatabaseType) { case .schema: return schema @@ -382,9 +334,7 @@ final class MainContentCommandActions: ObservableObject { PluginManager.shared.supportsDatabaseTree(for: connection.type) } - /// Whether the driver published any session context to switch. Only Snowflake does today, and - /// it pays two round trips for the list, so this reads what `loadSessionContexts` already - /// fetched rather than asking again. + // Reads what loadSessionContexts fetched: Snowflake (the only one today) pays two round trips for the list. var hasSessionContexts: Bool { !(coordinator?.sessionContexts.isEmpty ?? true) } @@ -393,15 +343,13 @@ final class MainContentCommandActions: ObservableObject { PluginManager.shared.supportsSchemaSwitching(for: connection.type) } - /// Filtering the database list only means anything on a connection whose sidebar can show one, - /// which is the same rule the tree layout itself is gated on. + // Same rule the tree layout is gated on. var canFilterDatabases: Bool { PluginManager.shared.supportsDatabaseTree(for: connection.type) && sidebarLayout == .tree } - /// Asks the same question the sidebar banner does, so Show All Databases is never offered for a - /// filter that shows nothing on screen: one naming only system databases while they are hidden. + // Same check as the sidebar banner: no Show All Databases for a filter naming only hidden system databases. var hasDatabaseFilter: Bool { DatabaseTreeVisibility.isFiltering( selected: SharedSidebarState.forConnection(connection.id).databaseFilterSelected, @@ -410,8 +358,7 @@ final class MainContentCommandActions: ObservableObject { ) } - /// Every layout of an engine that lists tables per schema, because search, Open Quickly and the - /// pickers follow the filter even where the sidebar shows one schema. + // Not gated on layout: search, Open Quickly and the pickers follow the filter even where the sidebar shows one schema. var canFilterSchemas: Bool { DatabaseTreeMetadataService.listsTablesPerSchema(PluginManager.shared.databaseGroupingStrategy(for: connection.type)) } @@ -445,8 +392,7 @@ final class MainContentCommandActions: ObservableObject { } - /// Find and the filter panel act on the result grid, so they need a table tab that is showing - /// one. Chart mode is not, and neither is Structure, whose own grid has its own commands. + // Chart mode has no result grid, and Structure's grid has its own commands. var canUseTableResultCommands: Bool { guard coordinator?.toolbarState.isTableTab == true, let viewMode = coordinator?.tabManager.selectedTab?.display.resultsViewMode @@ -456,23 +402,18 @@ final class MainContentCommandActions: ObservableObject { return viewMode.showsRowFilters } + // A query result's grid finds as a table's does; only a mounted grid has a bar to show. var canUseGridFindCommands: Bool { - guard coordinator?.toolbarState.isTableTab == true, - let viewMode = coordinator?.tabManager.selectedTab?.display.resultsViewMode - else { - return false - } - return viewMode.showsFindBar + guard let coordinator, let tab = coordinator.tabManager.selectedTab, + tab.tabType == .table || tab.tabType == .query, + tab.display.resultsViewMode.showsFindBar else { return false } + return coordinator.hasMountedDataGrid } var hasActiveGridFind: Bool { - guard canUseGridFindCommands, - let findState = coordinator?.tabManager.selectedTab?.findState else { return false } - return findState.isVisible && !findState.matches.isEmpty + canUseGridFindCommands && coordinator?.findCoordinator.hasActiveFind == true } - /// Jump to Column reads the mounted data grid, so it needs the grid on screen and a result that - /// names columns. A query's result counts as much as a table's: a wide result is a wide result. var canJumpToColumn: Bool { guard dataGridOwnsSelection, let coordinator, @@ -483,9 +424,7 @@ final class MainContentCommandActions: ObservableObject { return !coordinator.columnsForVisibilityPicker(for: tab, resultColumns: resultColumns).isEmpty } - /// What `pasteRows()` will actually do, so the Edit menu's Paste item is enabled only when it - /// leads somewhere. AppKit gives a disabled item its key equivalent all the same, so an item - /// enabled over a handler that returns at its first guard swallows Command+V in silence. + // Mirrors pasteRows(): an item enabled over a handler that bails at its first guard swallows Command+V silently. var canPasteRows: Bool { guard !safeModeLevel.blocksAllWrites, let tab = coordinator?.tabManager.selectedTab else { return false @@ -501,9 +440,7 @@ final class MainContentCommandActions: ObservableObject { } } - /// The two facts Save As and Export Results actually turn on. Their menu items used to be - /// validated on `isConnected` alone, so both stayed lit in states where the handler returns at - /// its first guard and the click does nothing at all. + // Save As and Export Results gate on this, not isConnected, which left them lit over a handler that did nothing. var isQueryTab: Bool { coordinator?.tabManager.selectedTab?.tabType == .query } @@ -514,14 +451,12 @@ final class MainContentCommandActions: ObservableObject { } var hasRowSelection: Bool { - selectionOwner != .none && !resolvedRowSelection().isEmpty + selectionOwner != .none && coordinator?.activeGridModel?.selection.isEmpty == false } - /// Copy with headers and copy as JSON read the data grid's columns, so they are only meaningful - /// when the data grid owns the indices. The structure grid has its own plain copy and nothing - /// else; handing it these would read a structure row's position into the result rows. + // Structure grid indices are not result rows; copy with headers or as JSON would read the wrong rows. var hasDataGridRowSelection: Bool { - dataGridOwnsSelection && !resolvedRowSelection().isEmpty + !selectedDataRowIDs.isEmpty } var hasTableSelection: Bool { @@ -536,16 +471,14 @@ final class MainContentCommandActions: ObservableObject { coordinator?.openJoinBuilder(seededWith: selectedTables.wrappedValue) } - /// A selection can be perfectly valid and still hold nothing truncatable, so the menu bar asks - /// this rather than `hasTableSelection`, which is what let it stage a `TRUNCATE` on a view. + // Not hasTableSelection, which let the menu bar stage a TRUNCATE on a view. var canTruncateSelectedTables: Bool { TableOperationEligibility.canTruncate( selectedTables.wrappedValue, context: tableOperationEligibility ) } - /// The same question the sidebar's own Delete item asks, so the two agree. Without it the menu - /// bar offered Delete on an engine with no statement for it and the sidebar did not. + // Same check as the sidebar's Delete item, which the menu bar used to skip on engines with no statement for it. var canDropSelectedTables: Bool { TableOperationEligibility.canDrop(selectedTables.wrappedValue, context: tableOperationEligibility) } @@ -560,18 +493,13 @@ final class MainContentCommandActions: ObservableObject { ) } - /// The one selected object with the database and schema it lives in, or nil when the selection - /// is empty or spans several. A command that acts on the object takes this rather than the bare - /// `TableInfo`, which names no database, so it reaches the object the user selected even while - /// the browser points at another database or schema. + // Use over TableInfo, which names no database, so commands reach the selection while the browser points elsewhere. var selectedObjectRef: DatabaseTreeTableRef? { let selection = selectedTables.wrappedValue guard selection.count == 1 else { return nil } return selection.first } - /// The one selected object, or nil when the selection is empty or spans several. - /// Commands that open a single object need this rather than `hasTableSelection`. var selectedObject: TableInfo? { selectedObjectRef?.table } @@ -580,30 +508,27 @@ final class MainContentCommandActions: ObservableObject { coordinator?.tabManager.selectedTab?.hasQueryText ?? false } - /// Whether there are pending data changes that the SQL preview can show. - /// Mirrors the toolbar Preview SQL button's enabled condition so the - /// menu shortcut (Cmd+Shift+P) doesn't open an empty preview popover. + // Mirrors the toolbar Preview SQL button so Cmd+Shift+P never opens an empty preview popover. var hasDataPendingChanges: Bool { coordinator?.toolbarState.hasDataPendingChanges ?? false } - /// Any pending changes (data edits OR file edits). Mirrors the toolbar - /// Save Changes button's enabled condition. + // Mirrors the toolbar Save Changes button's enabled condition. var hasPendingChanges: Bool { coordinator?.toolbarState.hasPendingChanges ?? false } + var hasOpenGridEdit: Bool { + coordinator?.isEditingActiveGrid == true + } + var hasStructureChanges: Bool { coordinator?.toolbarState.hasStructureChanges ?? false } // MARK: - Unsaved Changes Check - /// Scoped to the whole window, not the selected tab: closing a window closes every tab in it, - /// so a tab the user is not looking at must still get its prompt. - /// Every connection the window hosts, because closing the window closes all of them. Asking - /// only about the one on screen let a background connection's unsaved edits go without a - /// prompt, which is silent data loss rather than a missing confirmation. + // Every connection in the window: closing it closes them all, and skipping a background one is silent data loss. internal var hasUnsavedWorkInWindow: Bool { guard let host = window?.contentViewController as? MainSplitViewController else { return coordinator?.hasAnyUnsavedWork() ?? false @@ -613,8 +538,7 @@ final class MainContentCommandActions: ObservableObject { } } - /// This connection only. Closing its tabs says nothing about what another connection in the - /// same window has pending, so prompting about that would ask the wrong question. + // This connection only: closing its tabs leaves another connection's pending work alone. internal var hasUnsavedWorkInConnection: Bool { coordinator?.hasAnyUnsavedWork() ?? false } @@ -637,7 +561,7 @@ final class MainContentCommandActions: ObservableObject { return actions.redoMenuTitle() } - // MARK: - Editor Query Loading (Group A — Called Directly) + // MARK: - Editor Query Loading (Group A: Called Directly) func loadQueryIntoEditor(_ query: String) { coordinator?.loadQueryIntoEditor(query) @@ -651,10 +575,8 @@ final class MainContentCommandActions: ObservableObject { coordinator?.applyAISuggestion(afterSQL, replacing: beforeSQL, source: source) } - // MARK: - Tab Operations (Group A — Called Directly) + // MARK: - Tab Operations (Group A: Called Directly) - /// A new tab joins the connection's own tab list. It used to open another window whenever - /// the list was not empty, which is why two tables meant two windows. func newTab(initialQuery: String? = nil) { guard let coordinator else { return } coordinator.tabManager.addTab( @@ -664,25 +586,19 @@ final class MainContentCommandActions: ObservableObject { ) } - /// Closing the last tab leaves the connection open on its empty state, the same state it is - /// in right after connecting. The window hosts every open connection now, so closing it here - /// would take the other connections' tabs and their unsaved edits with it. + // The last tab leaves the connection on its empty state; closing the window would take other connections' tabs too. func closeTab(id: UUID) { Task { await closeTabAwaiting(id: id) } } - /// A tab holding work only a save can recover asks before it goes, which is what the window - /// close and the batch closes already do and what the HIG requires of an app that does not - /// autosave: "present a save dialog when people choose to close the document, quit your app, - /// log out, or restart". - /// - /// Save proceeds with the close, per `NSDocument.canCloseDocumentWithDelegate`: "shouldClose - /// will be YES if ... the user chose to discard modifications, or chose to save and the saving - /// was successful". `saveSelectedTabWork` returns false whenever the work is still unsaved - /// after the attempt, and the tab stays open. + // Asks before closing unsaved work (HIG); like NSDocument, the tab stays open when the save leaves work unsaved. func closeTabAwaiting(id: UUID) async { guard let coordinator, let tab = coordinator.tabManager.tabs.first(where: { $0.id == id }) else { return } + // An open cell editor is unsaved work too, so it lands before the question is asked. + if coordinator.tabManager.selectedTabId == id { + coordinator.commitActiveGridEdit() + } guard coordinator.hasUnsavedWork(in: tab) else { coordinator.closeTabsByUser(ids: [id]) return @@ -708,9 +624,7 @@ final class MainContentCommandActions: ObservableObject { } } - /// The selection goes back only while the closing tab still holds it. A save can wait on the - /// server with the strip still live, and a tab the user picked in the meantime is a newer choice - /// than the one this close set aside. + // A save can wait on the server; a tab the user picked meanwhile is a newer choice than the one this close set aside. private func closeRevealedTab(_ id: UUID, returningTo previousSelection: UUID?) { guard let coordinator else { return } let stillShowsClosingTab = coordinator.tabManager.selectedTabId == id @@ -719,19 +633,13 @@ final class MainContentCommandActions: ObservableObject { restoreSelection(previousSelection) } - /// Shown, then asked. The save and discard machinery reads the selected tab, so the tab being - /// closed has to be the selected one before the question is put; naming work the user cannot - /// see would also ask them to decide about something they have no way to look at first. + // Save and discard read the selected tab, and the user should see the work the alert asks about. private func revealTab(_ id: UUID) { guard let coordinator, coordinator.tabManager.selectedTabId != id else { return } coordinator.tabManager.selectedTabId = id } - /// Every answer puts the selection back where the user had it, unless they have since picked - /// another tab, because it only moved so the alert had somewhere honest to point. After a close that is the tab they were working in, not - /// the neighbour of the one that went: closing a tab in the background leaves the one in front - /// alone whether or not it had anything to save. Closing the tab in front lands on its - /// neighbour as before, since the tab it would restore is gone. + // The selection moved only for the alert; a closed front tab lands on its neighbour since the tab to restore is gone. private func restoreSelection(_ id: UUID?) { guard let coordinator, let id, @@ -740,9 +648,6 @@ final class MainContentCommandActions: ObservableObject { coordinator.tabManager.selectedTabId = id } - /// Cmd+W closes the tab in front. Pressed again with no tabs left it closes the entry, the way - /// the connections strip closes it, and the connection once that was its only entry. The window - /// itself goes only once that was the last connection open in it. func closeTab() { guard let coordinator else { Task { await closeWindowAwaiting() } @@ -762,12 +667,11 @@ final class MainContentCommandActions: ObservableObject { Task { await ConnectionCloseAction.close(connectionId: connectionId) } } - /// The single close primitive. `asBatchSurvivor` is `nil` for a lone close gesture, which lets - /// the window decide for itself whether it can go away; a batch passes `true` for the one - /// window it keeps blank and `false` for every window it tears down. + // asBatchSurvivor: nil for a lone close (the window decides), true for the window a batch keeps blank, false otherwise. @discardableResult func closeWindowAwaiting(asBatchSurvivor: Bool? = nil) async -> WindowCloseOutcome { let seq = MainContentCoordinator.nextSwitchSeq() + coordinator?.commitActiveGridEdit() Self.logger.info("[close] closeWindowAwaiting seq=\(seq) hasUnsavedWork=\(self.hasUnsavedWorkInWindow)") guard hasUnsavedWorkInWindow else { @@ -796,8 +700,7 @@ final class MainContentCommandActions: ObservableObject { coordinator?.contentWindow ?? window ?? NSApp.keyWindow } - /// A background tabbed window is occluded by the selected tab, so its confirmation sheet would - /// animate onto a surface the user cannot see. Bring it forward first. + // A background tabbed window is occluded, so its confirmation sheet would animate where the user cannot see it. private func selectInTabGroup() { guard let target = coordinator?.contentWindow ?? window, let tabGroup = target.tabGroup, @@ -808,9 +711,7 @@ final class MainContentCommandActions: ObservableObject { } } - /// Every close gesture funnels here, so this is the one place that can guarantee a closing - /// tab's content outlives the window. It runs before the branch dispatch below because two of - /// the three branches tear the window down without another chance to capture anything. + // Called before the close dispatch: two of its three branches tear the window down with no later chance to capture. private func captureClosingTabsForRecovery() { guard let coordinator else { return } for tab in coordinator.tabsForRecoveryCapture() { @@ -844,8 +745,7 @@ final class MainContentCommandActions: ObservableObject { Self.logger.info("[close] finish done ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } - /// Empties the window instead of closing it, which is how the last tab of a group lands on the - /// no-tabs state with its connection still live. + // Empties instead of closing, so the last tab lands on the no-tabs state with its connection still live. private func clearTabsInPlace() { guard let coordinator else { return } for tab in coordinator.tabManager.tabs { @@ -856,15 +756,7 @@ final class MainContentCommandActions: ObservableObject { coordinator.toolbarState.isTableTab = false } - /// The save half of a close, shared by the tab close, the window close and the batch close so - /// the three cannot drift on what Save means. Returns whether the caller may go on to close. - /// - /// False comes back whenever the work is still staged after the attempt, because the caller - /// goes on to close and closing destroys it. User and role changes can only be applied after - /// the SQL is reviewed, so Save opens the review sheet and stands the close down; a schema - /// change that Safe Mode refused, that the user cancelled at the gate's confirmation, or that - /// the server rejected stands it down for the same reason, and so does a file that changed on - /// disk, whose conflict sheet is now up, or a Save As the user cancelled. + // False while work is still staged after the attempt (review sheet, refused or cancelled DDL, file conflict, Save As cancel). func saveSelectedTabWork() async -> Bool { guard let coordinator = coordinator else { return true } @@ -873,10 +765,7 @@ final class MainContentCommandActions: ObservableObject { return false } - /// Asked of the tab's session rather than of the view on screen. `hasUnsavedWork` reads the - /// session too, so the prompt that offered Save can be raised by a tab showing its Data - /// view, or by a background tab in a batch close. Keying this on `resultsViewMode` meant - /// those answers ran nothing, reported success, and closed over the staged ALTERs. + // Keyed on the session, not resultsViewMode, which let a Data-view or background tab close over its staged ALTERs. if let tabId = coordinator.tabManager.selectedTabId, let session = coordinator.structureSessions[tabId], session.changeManager.hasChanges { @@ -923,11 +812,7 @@ final class MainContentCommandActions: ObservableObject { return true } - /// Save on a prompt raised for the whole window or the whole batch has to reach every tab it - /// asked about, not just the selected one. `hasUnsavedWorkInWindow` and - /// `hasUnsavedWorkInConnection` both walk every tab's session, so a background tab's staged - /// ALTERs are exactly what the user has been asked about. The selected tab is skipped here - /// because `saveSelectedTabWork` takes it, along with its data-grid edits. + // Reaches every tab the window or batch prompt asked about; the selected tab is left to saveSelectedTabWork. func applyStagedStructureEdits(in tabs: [QueryTab]) async -> Bool { guard let coordinator else { return true } let selectedId = coordinator.tabManager.selectedTabId @@ -958,7 +843,7 @@ final class MainContentCommandActions: ObservableObject { coordinator?.sidebarViewModel?.batchToggleTruncate() } - // MARK: - Filter Operations (Group A — Called Directly) + // MARK: - Filter Operations (Group A: Called Directly) func toggleFilterPanel() { guard canUseTableResultCommands, let coordinator else { return } @@ -974,26 +859,27 @@ final class MainContentCommandActions: ObservableObject { coordinator.presentHighlightRules() } - func showFindBar() { - guard canUseGridFindCommands, let coordinator else { return } - coordinator.findCoordinator.show() + func showFindBar(replace: Bool = false) { + guard canUseGridFindCommands else { return } + coordinator?.findCoordinator.showFind(replace: replace) + } + + func useGridSelectionForFind() { + guard canUseGridFindCommands else { return } + coordinator?.findCoordinator.useSelectionForFind() } func stepFindForward() { - coordinator?.findCoordinator.stepForward() + coordinator?.findCoordinator.findNext() } func stepFindBackward() { - coordinator?.findCoordinator.stepBackward() + coordinator?.findCoordinator.findPrevious() } - // MARK: - Data Operations (Group A — Called Directly) + // MARK: - Data Operations (Group A: Called Directly) - /// Cmd+S on a tab showing its structure. Which sub-tab is on screen makes no difference: DDL, - /// Parts and Triggers are read-only views of the same table, and refusing to save from them - /// used to make Cmd+S silently inert. The results mode still gates this, because Cmd+S saves - /// what you are looking at; the close prompt asks a different question and reaches the session - /// whatever the tab is showing. + // Any Structure sub-tab saves: DDL, Parts and Triggers are read-only views, and refusing left Cmd+S silently inert. private func applyStagedStructureChanges() { guard let coordinator, let tabId = coordinator.tabManager.selectedTabId, @@ -1007,6 +893,7 @@ final class MainContentCommandActions: ObservableObject { } func saveChanges() { + coordinator?.commitActiveGridEdit() if isUsersRolesTab { coordinator?.usersRolesActions?.reviewAndApply() return @@ -1024,9 +911,7 @@ final class MainContentCommandActions: ObservableObject { } else if coordinator?.changeManager.hasChanges == true || !pendingTruncates.wrappedValue.isEmpty || !pendingDeletes.wrappedValue.isEmpty { - // Handle data grid changes (prioritize over sidebar edits since - // data grid edits are synced to sidebar editState, and the data grid - // path uses the correct plugin driver for statement generation) + // Data grid first: its edits sync to the sidebar editState, and its path uses the plugin driver for statements. var truncates = pendingTruncates.wrappedValue var deletes = pendingDeletes.wrappedValue var options = tableOperationOptions.wrappedValue @@ -1102,10 +987,6 @@ final class MainContentCommandActions: ObservableObject { coordinator?.runAIQueryAction(action, target: .selectionOrStatementAtCursor) } - func previewFKReference() { - coordinator?.toggleFKPreviewForFocusedCell() - } - func showRowAsJSON() { coordinator?.showRowAsJSON() } @@ -1138,9 +1019,7 @@ final class MainContentCommandActions: ObservableObject { coordinator?.activeSheet = .backupDatabase(databases: []) } - /// Asked of the connection, not only its type. libSQL reaches either a local file or a Turso - /// URL and only the file can be handed to `sqlite3`, so a remote one offered a Backup Dump that - /// wrote a 52-byte file and reported success. + // Per connection: a remote libSQL (Turso) URL cannot go to sqlite3, and its Backup Dump wrote a 52-byte file. var supportsBackup: Bool { NativeDumpRegistry.supports( connection, @@ -1178,8 +1057,6 @@ final class MainContentCommandActions: ObservableObject { coordinator?.activeSheet = .restoreDatabase(fileURL: url) } - /// The engine's own archive, not PostgreSQL's. Every engine the registry supports is offered - /// Restore Dump, and the panel used to tell all of them to pick a `pg_dump` custom archive. private var restoreSourceContentTypes: [UTType] { let extensions = NativeDumpRegistry.formats(for: connection.type) .map(\.fileExtension) @@ -1189,8 +1066,7 @@ final class MainContentCommandActions: ObservableObject { return types + [.folder, .data] } - /// DuckDB restores either one `.duckdb` file or a folder of Parquet, so the panel has to accept - /// a folder as well. + // DuckDB restores a .duckdb file or a folder of Parquet. private var restoreAcceptsDirectory: Bool { NativeDumpRegistry.formats(for: connection.type).contains { $0.producesDirectory } } @@ -1242,12 +1118,6 @@ final class MainContentCommandActions: ObservableObject { EditorEventRouter.shared.performFormatSQLForKeyWindow() } - /// Emptying the editor and discarding the results are two commands, not one. - /// - /// They used to be a single trash button whose tooltip and accessibility label both said - /// "Clear Query" while it also cleared the results, the execution record and collapsed the - /// results pane. Neither half had a menu-bar command, so neither could be undone, reached by - /// keyboard, or announced for what it was. func clearQuery() { guard let coordinator, let (tab, tabIndex) = coordinator.tabManager.selectedTabAndIndex, @@ -1255,10 +1125,7 @@ final class MainContentCommandActions: ObservableObject { coordinator.tabManager.mutate(at: tabIndex) { $0.content.query = "" } coordinator.toolbarState.hasQueryText = false coordinator.scheduleDraftSave() - /// The editor's own text binding recomputes this on every keystroke, and emptying the tab - /// from a command does not go through that binding. Without it a scratch tab keeps the - /// dirty dot it no longer deserves, and a file-backed tab that this command just emptied - /// is not marked modified until some other window event happens to recompute it. + // Emptying from a command bypasses the editor binding that recomputes the dirty indicator on each keystroke. coordinator.refreshUnsavedIndicator() } @@ -1299,18 +1166,12 @@ final class MainContentCommandActions: ObservableObject { EditorEventRouter.shared.moveCursorToStatementForKeyWindow(.next) } - /// Runs the statement the caret is in, then puts the caret on the next one. - /// - /// The caret moves first so the reader can see which statement is queued next while this one runs, and so a held - /// key steps through the script rather than running the same statement repeatedly. The editor resolves and runs - /// both halves itself, through the same callback the gutter control uses, so the statement can only ever reach the - /// connection whose editor it came from. That callback ends at `runStatement`, which refuses while the tab is - /// executing, so a held key cannot queue a second run. + // Through the gutter's callback, so it only reaches its own connection; the caret moves first so a held key steps through. func runStatementAndAdvance() { EditorEventRouter.shared.runStatementAtCursorAndAdvanceForKeyWindow() } - // MARK: - UI Operations (Group A — Called Directly) + // MARK: - UI Operations (Group A: Called Directly) func toggleHistoryPanel() { guard let connectionId = coordinator?.connectionId else { return } @@ -1468,12 +1329,9 @@ final class MainContentCommandActions: ObservableObject { private func handleDatabaseDidConnect() { Task { [weak coordinator] in guard let coordinator, !coordinator.isTearingDown else { return } - /// A reconnect hands the session a new driver, and the query builder would otherwise keep - /// building against the one that was just disconnected. + // A reconnect brings a new driver; the query builder would otherwise keep the disconnected one. coordinator.setupPluginDriver() - /// The first connect is also announced here, while the coordinator's own initial load runs - /// for it on the same driver. Refreshing on top sent every catalog query twice, so the - /// refresh waits for that load and runs only if it did not leave the catalog loaded. + // The first connect lands here during the initial load; refreshing on top sent every catalog query twice. if let inFlight = coordinator.schemaLoadTask, SchemaLoadPolicy.inFlightLoadCoversConnect( loadDriver: coordinator.schemaLoadDriver, @@ -1536,12 +1394,16 @@ final class MainContentCommandActions: ObservableObject { } extension MainContentCommandActions { - // Copy with the grid not focused: the grid's format when one is mounted, else Copy Rows. + // Copy with the grid not focused takes the selected rows in the row copy format; a focused grid answers `copy:` itself. func copySelectedRowsInCopyFormat() { - guard dataGridOwnsSelection, let grid = coordinator?.dataTabDelegate?.tableViewCoordinator else { + guard dataGridOwnsSelection, let model = coordinator?.activeGridModel else { copySelectedRows() return } - grid.copyRowsInCopyFormat(at: resolvedRowSelection()) + let commands = DataGridCommands(model: model) + commands.copyRows( + DataGridCommandScope(selection: model.selection, focus: nil, isRowHeader: true), + as: commands.rowCopyFormat + ) } } diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index 8949548fcb..c293e1242e 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -14,7 +14,6 @@ import TableProEditorKit import TableProPluginKit import TableProSQLGrammar -/// Discard action types for unified alert handling enum DiscardAction { case refresh case sort @@ -46,27 +45,21 @@ struct DisplayFormatsCacheEntry { } } -/// Represents which sheet is currently active in MainContentView. -/// Uses a single `.sheet(item:)` modifier instead of multiple `.sheet(isPresented:)`. +// Drives one `.sheet(item:)` in MainContentView instead of several `.sheet(isPresented:)`. enum ActiveSheet: Identifiable { case sqlPreview case exportDialog case importDialog(formatId: String) case rowImport(formatId: String) - /// Several files, or a folder, each imported as rows into its own table. The files are in `importBatch`. + // The files are in `importBatch`, each imported into its own table. case rowImportBatch case exportQueryResults - /// The tables the user right-clicked travel with the request, because the object browser may be - /// pointed somewhere else by the time the sheet appears. + // Carries the right-clicked tables: the object browser may point elsewhere by the time the sheet appears. case transferTables(tables: Set, schema: String?) - /// The databases the user right-clicked travel with the request, so the sheet opens on them - /// rather than on wherever the object browser happens to point. Empty means the browse database. + // Carries the right-clicked databases, not the object browser's current one. Empty means the browse database. case backupDatabase(databases: Set) case restoreDatabase(fileURL: URL) - /// The object's own database and schema travel with the request. A maintenance statement names - /// its table and nothing else, so acting on wherever the object browser happens to point - /// maintains the same-named table in another database whenever the two have drifted apart. - /// This is the rule the sidebar's other destructive commands already keep by carrying their ref. + // Carries the object's own scope: the statement names only the table, so the browser's scope could hit a same-named one. case maintenance( operation: PluginMaintenanceOperation, tableName: String, @@ -74,28 +67,19 @@ enum ActiveSheet: Identifiable { schema: String? ) case createDatabase - /// The database the new schema goes in travels with the request, for the reason `.maintenance` - /// carries its scope: the sheet creates the schema in the database the user right-clicked, not - /// in wherever the object browser points by the time Create is pressed. + // The right-clicked database, not wherever the object browser points by the time Create is pressed. case createSchema(database: String?) case editSchema(DatabaseContainerRef) - /// The object's own database and schema travel in the target, for the reason `.maintenance` - /// carries them: the comment is written to the object the user right-clicked, not to a - /// same-named one wherever the browser points by the time Save is pressed. + // Carries the object's scope so the comment lands on the right-clicked object, not a same-named one the browser points at. case editObjectComment(DatabaseObjectTarget) case setSequenceNextValue(DatabaseObjectTarget) case editSequence(DatabaseObjectTarget) - /// Copying carries the whole launch request, because the source database, the source schema - /// and the objects the user right-clicked are all part of what the sheet opens onto, and the - /// object browser may be pointed somewhere else by the time the sheet appears. + // Carries the source database, schema and objects: the object browser may point elsewhere by the time the sheet appears. case copyObjects(ObjectCopyLaunchRequest) case rewind case tableRebuildReview - /// The collection and its database travel with the request, for the reason `.maintenance` - /// carries its scope: the document is written where it was read, not wherever the object - /// browser points by the time Save is pressed. + // Carries the collection and database so the document is saved where it was read, not where the browser points on Save. case documentEditor(DocumentEditorRequest) - /// The database and schema the Types section was right-clicked in travel with the request. case createUserType(database: String?, schema: String?) case searchDatabase(DatabaseSearchSession) case joinBuilder(JoinBuilderLaunchRequest) @@ -136,13 +120,12 @@ enum ActiveSheet: Identifiable { } } -/// Coordinator managing MainContentView business logic @MainActor final class MainContentCoordinator: ObservableObject { nonisolated static let logger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator") nonisolated static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") - /// Monotonic counter for correlating rapid tab-switch/close log entries. + // Correlates rapid tab-switch and tab-close log entries. static var switchSeq: Int = 0 static func nextSwitchSeq() -> Int { switchSeq += 1 @@ -156,17 +139,11 @@ final class MainContentCoordinator: ObservableObject { var connectionId: UUID { connection.id } var sqlDialect: SqlDialect { SqlDialect.from(databaseTypeId: connection.type.rawValue) } - /// How this connection's scripts are split for execution, with what the driver read from its own session. var lexicalGrammar: SQLLexicalGrammar { SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connectionId) } var statementModel: QueryStatementModel { QueryStatementModel.forDatabaseType(connection.type) } - /// Whether `:name` in this connection's queries is a bind parameter, which it is only in SQL. - /// - /// Every other language already gives the colon a meaning: an object key in a MongoDB script, a field - /// query in an Elasticsearch URL (`?q=name:lamp`), a key separator in Redis (`GET user:name`), a record - /// id in SurrealQL (`person:tobie`). Read as a parameter, it opens the panel, holds the run until a value - /// is typed, and then sends a placeholder the engine's driver never binds. + // Only SQL: `:name` means something else in MongoDB, Elasticsearch (`?q=name:lamp`), Redis (`GET user:name`) and SurrealQL. var bindsNamedParameters: Bool { services.pluginManager.editorLanguage(for: connection.type) == .sql } var browseDatabaseName: String { services.databaseManager.browseDatabaseName(for: connection) @@ -175,8 +152,7 @@ final class MainContentCoordinator: ObservableObject { tabManager.openingDatabase(browsing: browseDatabaseName) } var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } - /// The level the toolbar shows, and the floor under it with Agent mode's included, which is what - /// the Safe Mode list offers from and what the toolbar's tooltip explains. + // The floor includes Agent mode's; the Safe Mode list offers levels from it and the toolbar tooltip explains it. var safeModeStatus: SafeModeStatus { SafeModeStatus(level: safeModeLevel, floor: AgentModeSafeModeFloor.effectiveFloor(for: connection)) } @@ -184,7 +160,6 @@ final class MainContentCoordinator: ObservableObject { services.databaseManager.chooseSafeModeLevel(level, for: connectionId) toolbarState.safeModeLevel = services.databaseManager.session(for: connectionId)?.safeModeLevel ?? level } - let selectionState = GridSelectionState() let selectionSummary = SelectionSummaryState() let tabManager: QueryTabManager let changeManager: DataChangeManager @@ -209,107 +184,72 @@ final class MainContentCoordinator: ObservableObject { private(set) var rowEditingCoordinator: RowEditingCoordinator! private(set) lazy var browseCursorCoordinator = BrowseCursorCoordinator(parent: self) - /// Stable identifier for this coordinator's window (set by MainContentView on appear) + // Set by MainContentView on appear. @Published var windowId: UUID? - /// Setting this presents the favorite-edit dialog sheet from `MainEditorContentView`. @Published var favoriteDialogQuery: FavoriteDialogQuery? - /// Direct reference to sidebar viewmodel, eliminates global notification broadcasts weak var sidebarViewModel: SidebarViewModel? - /// Direct reference to structure view actions — eliminates notification broadcasts weak var structureActions: StructureViewActionHandler? - /// Direct reference to create-table view actions so the Save Changes menu - /// (Cmd+S) routes to table creation. Set by `CreateTableView` on appear. + // Lets Save Changes (Cmd+S) route to table creation. Set by `CreateTableView` on appear. weak var createTableActions: CreateTableActionHandler? weak var usersRolesActions: UsersRolesActionHandler? - /// Staged structure edits and table drafts, per tab. They belong here rather than in the view - /// for two reasons: the view is destroyed on every tab switch and on every switch between Data - /// and Structure, and the close gate has to be able to see the staged work of a tab the user is - /// not currently looking at. + // Not in the view: it dies on every tab and Data/Structure switch, and the close gate must see unseen tabs' staged work. @Published var structureSessions: [UUID: StructureEditingSession] = [:] @Published var createTableDrafts: [UUID: CreateTableDraft] = [:] - /// A type tab's queued changes, kept here for the same two reasons. + // Kept here for the same reasons as `structureSessions`. @Published var userTypeEditors: [UUID: UserTypeEditorModel] = [:] // Held here for the same reason: the diagram stages schema edits that Save and the close gate reach. @Published var erDiagramModels: [UUID: ERDiagramViewModel] = [:] - /// Tabs holding staged principal changes. `usersRolesActions` is nilled the moment the tab is - /// deselected, but the view model behind it is cached per tab id and keeps the staged work, so - /// without this record a background Users & Roles tab reports itself clean and closes silently. + // The cached view model keeps staged work after `usersRolesActions` is nilled; without this a background tab closes silently. internal var tabsWithStagedPrincipals: Set = [] - /// Tabs whose close confirmation is already on screen. `saveCompletionContinuation` is a single - /// slot, so a second gesture arriving before the first sheet resolves would overwrite the - /// continuation the first one is suspended on and leave that task waiting forever. + // `saveCompletionContinuation` is one slot: a second close gesture would overwrite it and strand the first task forever. internal var tabClosesInFlight: Set = [] - /// The grid that owns the current selection when it is not the data grid, so the - /// inspector reads the selected row from it instead of the data tab's rows. - /// Set by `TableStructureView` and `CreateTableView` on appear. + // The non-data grid that owns the selection, read by the inspector. Set by `TableStructureView` and `CreateTableView`. weak var inspectorRowSource: (any InspectorRowSource)? - /// Bumped whenever a published schema row changes, so the inspector re-reads it. + // Bumped when a published schema row changes, so the inspector re-reads it. @Published var inspectorRowSourceRevision: Int = 0 - /// Direct reference to AI chat viewmodel — eliminates notification broadcasts - /// The assistant's view model, and only if something has already brought one into existence. - /// Reading this never builds one: an editor command that wants to talk to the assistant reveals - /// it first, and revealing is what activates it. + // Never builds the view model: a command reveals the assistant first, and revealing is what activates it. var aiViewModel: AIChatViewModel? { trailingPaneState?.assistant.viewModelIfActivated } weak var trailingPaneState: TrailingPaneState? - /// Direct reference to the data tab grid delegate — enables row mutation operations to - /// Observable mirror of the grid's display revision, so views outside the grid re-render when - /// the value filter or the displayed order changes. The grid's own state lives on a plain - /// AppKit object reached through observation-ignored hops, so it cannot invalidate a view. + // Moves when a value filter or a display format changes, so views outside the grid re-read the order. @Published var gridDisplayRevision: Int = 0 - /// Sent when an inspector edit rewrites the selected row's values, so the inspector's JSON - /// rendering re-reads the row. Apart from `gridDisplayRevision`, which drives a full rebuild of - /// the field list and takes first responder out of whatever is being typed into. - /// - /// An event rather than a published counter. The counter it replaces was not published, so the - /// `onChange` that read it never fired and the JSON rendering went stale. Publishing it would - /// have redrawn every view that observes this coordinator on each keystroke a detached value - /// window commits, and only one of them wants to know. + // Refreshes the inspector JSON without `gridDisplayRevision`'s full rebuild; a subject so observers don't redraw per keystroke. let inspectorRowContentChanged = PassthroughSubject() - /// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate. - weak var dataTabDelegate: DataTabGridDelegate? + // One grid model per data tab, outliving every mount of its grid. + var dataGridEntries: [UUID: DataTabGridEntry] = [:] + // Set while a tab switch still has the outgoing tab's edits in the change manager. + var tabSwitchOutgoingTabId: UUID? + private(set) lazy var gridChangeState = AnyChangeManager(changeManager) + let gridCellsEdited = PassthroughSubject() - var activeGridDisplayIDs: [RowID]? { - guard let tabId = tabManager.selectedTab?.id else { return nil } - return displayIDs(forTab: tabId) - } - - /// One-shot intent set when the user explicitly opens a table (Return/double-click), - /// consumed by the grid as it appears to move focus into it. Never set on mere selection. + // One-shot: set on an explicit open (Return, double-click), consumed by the grid to take focus. Never set on selection. var pendingGridFocusOnOpen = false - /// Proxy for toggling the inspector NSSplitViewItem from coordinator code weak var trailingPaneProxy: TrailingPaneProxy? - /// Direct reference to split view controller for sidebar toggle weak var splitViewController: MainSplitViewController? - /// Direct reference to this coordinator's content window, used for presenting alerts. - /// Avoids NSApp.keyWindow which may return a sheet window, causing stuck dialogs. + // For alerts instead of NSApp.keyWindow, which may return a sheet window and leave dialogs stuck. weak var contentWindow: NSWindow? - /// Back-reference to this coordinator's command actions, enabling window → coordinator → actions - /// lookup. The app runs the AppKit lifecycle with no SwiftUI `Scene`, so a focused value has - /// nothing to resolve against; this reference reaches every caller, AppKit and SwiftUI alike. + // No SwiftUI `Scene` in the AppKit lifecycle, so a focused value can't resolve; this reaches AppKit and SwiftUI callers. weak var commandActions: MainContentCommandActions? - /// Presents the quick switcher as a floating panel anchored over this coordinator's window. - /// The window owns it, because the panel anchors on the window and every connection the window - /// hosts would otherwise bring one of its own to the same point. + // Owned by the window: the panel anchors on it, and each connection the window hosts would otherwise bring its own. var quickSwitcherPanel: QuickSwitcherPanelController? { splitViewController?.quickSwitcherPanel } @@ -320,10 +260,7 @@ final class MainContentCoordinator: ObservableObject { @Published var activeSheet: ActiveSheet? { didSet { releaseImportFileUnlessImporting() } } - /// Owns the connection and database switcher surfaces. The commands present through this - /// rather than flipping a flag a toolbar-hosted view has to observe, because that view is - /// absent whenever its item is clipped into the overflow menu or removed by the user. It - /// belongs to the window for the same reason the panel it drives does. + // Commands present through this, not a flag: the toolbar view is gone when its item is in overflow or removed. var switcherPresenter: ToolbarSwitcherPresenter? { splitViewController?.switcherPresenter } @@ -339,14 +276,9 @@ final class MainContentCoordinator: ObservableObject { var deferredRestoreLoadTabId: UUID? var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] - var displayOrderCache: [UUID: DisplayOrderCacheEntry] = [:] - var displayStateCache: [UUID: DisplayStateCacheEntry] = [:] - /// Published because the inspector draws from it: a load that lands, a comment refetch and a - /// rename each reach the panel because the view rebuilds its snapshot when this changes. + // Published because the inspector rebuilds its snapshot from it (load, comment refetch, rename). @Published var tableMetadataCache: [UUID: TableMetadataCacheEntry] = [:] - /// Moves on every Table Info invalidation, so a read in flight across one cannot mark the cache - /// current: its numbers may predate the change. One counter for the window rather than one per - /// tab, because an invalidation can arrive before a tab's first read has made an entry to mark. + // Bumped per Table Info invalidation so an in-flight read can't mark the cache current; per window, as it can precede a tab entry. var tableMetadataGeneration = 0 var displayStateClock = 0 @@ -365,68 +297,47 @@ final class MainContentCoordinator: ObservableObject { var importFormatLookup = ImportFormatLookup.live - /// Routing failures report through here so a test can observe the message instead of raising a - /// real alert. `AlertHelper.present` runs application-modal when no window qualifies, and a - /// unit test host has no window, so calling it directly parks the main thread in a modal loop - /// that nothing can dismiss and no test time limit can interrupt. + // Injectable: `AlertHelper.present` goes app-modal with no window, and a unit test host has none, hanging the main thread. var presentError: (String, String, NSWindow?) -> Void = { title, message, window in AlertHelper.showErrorSheet(title: title, message: message, window: window) } // MARK: - Internal State - /// Per-tab execution ownership. Replaces a per-window generation counter, a stored per-tab - /// `isExecuting` bool and two task handles that a tab retarget participated in none of. - /// - /// Deliberately observed rather than ``: busy state is derived from - /// membership here, so the views that used to read the stored flag have to be able to see it - /// change. It is a value type, so every claim, settle and invalidate is a write to this - /// property and invalidates its readers. + // Published on purpose: busy state is derived from membership here, and every claim, settle and invalidate is a write. @Published internal var tabExecution = TabExecutionRegistry() - /// One in-flight query task per tab, beside the claim registry that owns each tab's result. - /// Cancellation is keyed by tab for the same reason ownership is: the window hosts several tabs - /// on one connection, and a handle shared between them made every start path a Stop for whoever - /// held it. + // Keyed by tab: the window hosts several tabs on one connection, and a shared handle made every start a Stop for its holder. internal var queryTasks = TabQueryTasks() internal var rowCountTasks: [UUID: (token: UUID, task: Task)] = [:] - /// Which user-requested exact count currently owns each tab's counting indicator. + // Tab id to the user-requested exact count that owns its counting indicator. internal var exactCountOwners: [UUID: UUID] = [:] internal var tableLoadTasks: [UUID: (token: UUID, task: Task)] = [:] - /// Each tab's browse history, keyed by tab id the way the other per-tab caches here are. - /// - /// Not a field on `QueryTab`: that struct is the persisted shape of a tab, and an entry - /// describes rows that may be gone by the next launch. Keeping it out of the struct also keeps - /// it out of the hand-written `Equatable`, so a push never re-publishes the tab list. + // Not on `QueryTab`: that is the persisted shape, and staying out of its `Equatable` keeps a push from re-publishing tabs. internal var navigationHistories: [UUID: TabNavigationHistory] = [:] internal var redisDatabaseSwitchTask: Task? private var periodicSaveTask: Task? private var draftSaveTask: Task? private var terminationObserver: NSObjectProtocol? internal var postConnectCancellable: AnyCancellable? - /// Owned here rather than by the view that asks for it: a view's `task` is cancelled whenever a - /// connection switch takes its pane out of the window, and a cancelled first load left the - /// object list without anything to load it again. + // Owned here, not by the view: a connection switch cancels the view's `task`, and a cancelled first load never reran. internal var schemaLoadTask: Task? - /// The driver `schemaLoadTask` reads through. A reconnect installs a new one, and a load still - /// running on the old driver says nothing about the catalog the new one sees. + // A reconnect installs a new driver, and a load on the old one says nothing about the new catalog. internal weak var schemaLoadDriver: (any DatabaseDriver)? - /// The coordinator's, not the view's: a session that drops and comes back swaps the content - /// view out and in again, and restoring the saved tabs a second time over the live ones replaced - /// whatever changed since the last save. + // On the coordinator: a dropped session swaps the view, and restoring twice overwrote the live tabs' changes. internal var hasRestoredTabs = false - /// Set once this coordinator's restore has run, or was not due. A moved tab waits for it, - /// so it is checked against, merged into and saved with the saved set. + // Set once restore ran or was not due. A moved tab waits for it so it merges into and saves with the saved set. internal var hasSettledTabRestore = false internal var movesAwaitingRestore: [PendingTabMove] = [] - /// How many times this connection's content has appeared, a connection switch back included. + // Counts every appearance, a switch back to this connection included. internal private(set) var activationCount = 0 private var externalFileModCancellable: AnyCancellable? internal lazy var sourceFileDiskChangeMonitor = SourceFileDiskChangeMonitor(tabManager: tabManager) private var schemaSwitchCancellable: AnyCancellable? private var clearedEditsCancellable: AnyCancellable? + private var gridRepaintCancellable: AnyCancellable? private var browseContainerCancellable: AnyCancellable? @Published var fileConflictRequest: FileConflictRequest? @@ -440,75 +351,59 @@ final class MainContentCoordinator: ObservableObject { } internal private(set) var fileWatcher: DatabaseFileWatcher? - /// Set during handleTabChange to suppress redundant column-change reconfiguration + // Set during handleTabChange to suppress redundant column-change reconfiguration. internal var isHandlingTabSwitch = false var isUpdatingColumnLayout = false - /// Guards against re-entrant confirm dialogs (e.g. nested run loop during runModal) + // Guards re-entrant confirm dialogs (nested run loop during runModal). internal var isShowingConfirmAlert = false - /// Guards against duplicate safe mode confirmation prompts internal var isShowingSafeModePrompt = false - /// What restoring the last save would do, once it has been planned against the live rows. @Published internal var rewindPlan: RewindPlan? - /// The rebuild a column drag asked for, held while the user reads it. + // The rebuild a column drag asked for, held while the user reviews it. @Published internal var tableRebuildRequest: TableRebuildReviewRequest? - /// Continuation for callers that need to await the result of a fire-and-forget save - /// (e.g. save-then-close). Set before calling `saveChanges`, resumed by `executeCommitStatements`. + // For save-then-close callers. Set before `saveChanges`, resumed by `executeCommitStatements`. internal var saveCompletionContinuation: CheckedContinuation? // MARK: - Window Lifecycle (driven by TabWindowController NSWindowDelegate) - /// Whether this coordinator's window is the key (focused) window. - /// Updated by TabWindowController delegate methods; consumed by - /// event handlers (e.g. sidebar table-selection navigation filter). + // Updated by TabWindowController; read by event handlers such as the sidebar table-selection filter. var isKeyWindow = false - /// Eviction task scheduled in `handleWindowDidResignKey` (fires 5s later). + // Scheduled in `handleWindowDidResignKey`, fires 5s later. var evictionTask: Task? var refreshCoalesceTask: Task? var refreshPendingTrailing = false - /// True once the coordinator's view has appeared (onAppear fired). - /// Coordinators that SwiftUI creates during body re-evaluation but never - /// adopts into @State are silently discarded — no teardown warning needed. + // SwiftUI creates and discards coordinators during body re-evaluation; those never activate and need no teardown warning. private let _didActivate = OSAllocatedUnfairLock(initialState: false) - /// Tracks whether teardown() was called; used by deinit to log missed teardowns + // Read by deinit to log missed teardowns. private let _didTeardown = OSAllocatedUnfairLock(initialState: false) - /// Tracks whether teardown has been scheduled (but not yet executed) - /// so deinit doesn't warn if SwiftUI deallocates before the delayed Task fires + // So deinit doesn't warn if SwiftUI deallocates before the delayed teardown Task fires. private let _teardownScheduled = OSAllocatedUnfairLock(initialState: false) - /// Whether teardown is scheduled or already completed — used by views to skip - /// persistence during window close teardown + // Views skip persistence during window close teardown. var isTearingDown: Bool { _teardownScheduled.withLock { $0 } || _didTeardown.withLock { $0 } } - /// Set when NSApplication is terminating — suppresses deinit warning since - /// SwiftUI does not call onDisappear during app termination + // Suppresses the deinit warning: SwiftUI does not call onDisappear during app termination. nonisolated private static let _isAppTerminating = OSAllocatedUnfairLock(initialState: false) nonisolated static var isAppTerminating: Bool { get { _isAppTerminating.withLock { $0 } } set { _isAppTerminating.withLock { $0 = newValue } } } - /// Stable instance identity. Used to key the registry so a recycled - /// `ObjectIdentifier` from a freshly-allocated coordinator can never - /// remove a different instance's entry from a delayed cleanup Task. + // Registry key: a recycled `ObjectIdentifier` could let a delayed cleanup Task remove another instance's entry. let instanceId = UUID() - /// Registry of active coordinators for aggregated quit-time persistence. - /// Keyed by `instanceId` (UUID) — never by `ObjectIdentifier`, which can - /// be recycled across allocations. + // For quit-time persistence. Keyed by `instanceId`, never `ObjectIdentifier`, which can be recycled. static var activeCoordinators: [UUID: MainContentCoordinator] = [:] - /// Register this coordinator so quit-time persistence can aggregate tabs. - /// Idempotent — repeated registration is a no-op. func registerEagerly() { Self.activeCoordinators[instanceId] = self } @@ -525,14 +420,7 @@ final class MainContentCoordinator: ObservableObject { _didActivate.withLock { $0 } } - /// Every tab the connection has open, across every window hosting it. Tearing a tab off into - /// its own window splits one connection's tabs over two coordinators, and the saved set is the - /// union: saving from one of them alone writes a partial list over the full one, which is how - /// tabs that were never closed get erased. - /// - /// Deduped by tab id, because `activeCoordinators` also holds the throwaway coordinators - /// SwiftUI builds and discards while re-evaluating a body, and those report the same tabs as - /// the real one until they deallocate. + // Union across windows: saving one window's tabs erased torn-off ones. Deduped, since SwiftUI's throwaway coordinators repeat tabs. static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { var seen = Set() return activeCoordinators.values @@ -543,8 +431,6 @@ final class MainContentCoordinator: ObservableObject { .filter { seen.insert($0.id).inserted } } - /// Resolve transient view state that only the live coordinator knows about - /// (sort column names, editor cursor offset) onto the tab before it is serialized. func enrichedForPersistence(_ tab: QueryTab) -> QueryTab { var enriched = tab if enriched.sortState.isSorting { @@ -558,19 +444,15 @@ final class MainContentCoordinator: ObservableObject { return named } } - // Only when there is a live caret to write. `cursorPositions` starts empty and is fed by - // the editor's own change events, so a tab whose editor never became first responder has - // none, and writing that absence over the tab's saved caret discards it. + // Only with a live caret: a tab whose editor never took focus has no `cursorPositions`, and writing that erased its saved caret. if tab.tabType == .query, tab.id == tabManager.selectedTabId, let range = cursorPositions.first?.range { enriched.restoredCursorOffset = range.location enriched.restoredCursorLength = range.length } - // The grid's own teardown keeps the selection on the tab, but a tab moved to another window - // is snapshotted while its grid is still mounted, so that capture has not run yet. - if tab.id == tabManager.selectedTabId, let live = mountedGridSelection() { - enriched.selectedRowIndices = live.rows - enriched.cellSelection = live.cells + // The model keeps the selection; a tab moved to another window carries it in the tab. + if let store = dataGridEntries[tab.id]?.store { + enriched.selectionSnapshot = store.selectionSnapshot } return enriched } @@ -580,8 +462,6 @@ final class MainContentCoordinator: ObservableObject { return buffer.columns.isEmpty ? effectiveResultColumns(for: tab) : buffer.columns } - /// Map persisted sort columns (keyed by name) back to indices into the live column set. - /// Columns that no longer exist are dropped, so a renamed or removed column degrades gracefully. static func resolveRestoredSortColumns( _ persisted: [PersistedSortColumn], in columns: [String] @@ -592,11 +472,7 @@ final class MainContentCoordinator: ObservableObject { } } - /// The selection a query tab was left with, as a range to hand straight to the editor. - /// - /// The editor applies this once through its own controller rather than through the cursor - /// binding, so this is a plain read. The value stays on the tab until the editor reports it - /// consumed, because `body` runs many times before the text view exists. + // A plain read: the value stays on the tab until the editor reports it consumed, since `body` runs before the text view exists. func restoredCursorRange(for tabId: UUID) -> NSRange? { guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), tabManager.tabs[index].tabType == .query, @@ -607,10 +483,7 @@ final class MainContentCoordinator: ObservableObject { return NSRange(location: clamped, length: max(0, selectionLength)) } - /// The regions collapsed in a query tab. - /// - /// Folds live on the tab rather than on the window, so switching tabs cannot carry one tab's collapsed regions - /// onto another and nothing has to be cleared when a tab appears. + // Folds live on the tab, not the window, so a tab switch can't carry one tab's folds onto another. func foldRanges(for tabId: UUID) -> [Range]? { guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), tabManager.tabs[index].tabType == .query else { return nil } @@ -626,18 +499,13 @@ final class MainContentCoordinator: ObservableObject { } } - /// The statement a selected result asked the editor to go to, if one is waiting. func pendingStatementJump(for tabId: UUID) -> StatementAnchor? { guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), tabManager.tabs[index].tabType == .query else { return nil } return tabManager.tabs[index].pendingStatementJump } - /// Asks the tab's editor to take the reader to `anchor`. - /// - /// The anchor is stored rather than applied, because the editor owns the text this has to be resolved against and - /// may not be mounted yet. It is cleared as soon as an editor consumes it, so selecting the same result again - /// asks again rather than being swallowed as an unchanged value. + // Stored, not applied: the editor may not be mounted yet. Cleared on consume, so selecting the same result asks again. func requestStatementJump(_ anchor: StatementAnchor, in tabId: UUID) { guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), tabManager.tabs[index].tabType == .query else { return } @@ -663,9 +531,7 @@ final class MainContentCoordinator: ObservableObject { private static let periodicSaveInterval: Duration = .seconds(30) private static let draftSaveDebounce: Duration = .seconds(1) - /// Persist shortly after the user stops typing. Editing query text changes no tab - /// identity, so it drives none of the structural saves, and without this a scratch - /// query lives only in memory until the 30 second timer or the quit-time flush. + // Typing changes no tab identity, so without this a scratch query lives only in memory until the 30s timer or quit flush. func scheduleDraftSave() { guard !Self.isAppTerminating, !isTearingDown else { return } draftSaveTask?.cancel() @@ -689,14 +555,12 @@ final class MainContentCoordinator: ObservableObject { } } - /// Get selected tab ID from any coordinator for a given connectionId. static func aggregatedSelectedTabId(for connectionId: UUID) -> UUID? { activeCoordinators.values .first { $0.connectionId == connectionId && $0.tabManager.selectedTabId != nil }? .tabManager.selectedTabId } - /// Check if this coordinator is the first registered for its connection. private func isFirstCoordinatorForConnection() -> Bool { Self.activeCoordinators.values .first { $0.connectionId == self.connectionId } === self @@ -712,11 +576,7 @@ final class MainContentCoordinator: ObservableObject { } }() - /// Frees the row data of every background tab that can fetch it again, called when this - /// coordinator's window stops being key. The selected tab keeps its rows so returning to the - /// window costs no refresh, and `canEvictReloadableTableRows` decides the rest: a query tab, a - /// tab holding a pinned result, a failed tab and a tab with work in flight all stay resident, - /// because none of them would come back on their own. + // Called on resign key. The selected tab keeps its rows; `canEvictReloadableTableRows` keeps tabs that can't reload themselves. func evictInactiveRowData() { for tab in tabManager.tabs { evictReloadableTableRows(for: tab.id) @@ -767,15 +627,13 @@ final class MainContentCoordinator: ObservableObject { self?.browseCursorCoordinator.browseContainerDidChange() } tabManager.onTabRetargeted = { [weak self] tabId in - self?.dataTabDelegate?.tableViewCoordinator?.flushPendingColumnLayoutPersistence() + self?.existingGridModel(forTab: tabId)?.discardEditing() + self?.flushColumnLayoutPersistence() self?.supersedeExecution(for: tabId) self?.releaseRetargetedTabState(for: tabId) } - // Synchronous save at quit time. NotificationCenter with queue: .main - // delivers the closure on the main thread, satisfying assumeIsolated's - // precondition. The write completes before the process exits — unlike - // Task-based saves that need a run loop. + // Synchronous: queue .main satisfies assumeIsolated, and the write finishes before exit, unlike Task-based saves. terminationObserver = NotificationCenter.default.addObserver( forName: NSApplication.willTerminateNotification, object: nil, @@ -783,12 +641,8 @@ final class MainContentCoordinator: ObservableObject { ) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } - self.dataTabDelegate?.tableViewCoordinator?.flushPendingColumnLayoutPersistence() - // Only the first coordinator for this connection saves, - // aggregating tabs from all windows to fix last-write-wins bug. - // Skip isTearingDown check: during Cmd+Q, onDisappear fires - // markTeardownScheduled() before willTerminate, and we still - // need to save here. + self.flushColumnLayoutPersistence() + // First coordinator saves every window's tabs (last-write-wins bug). No isTearingDown check: Cmd+Q marks it before willTerminate. let allTabs = Self.aggregatedTabs(for: self.connectionId) let selectedId = Self.aggregatedSelectedTabId(for: self.connectionId) self.persistence.saveNowSync(tabs: allTabs, selectedTabId: selectedId) @@ -822,6 +676,7 @@ final class MainContentCoordinator: ObservableObject { self.paginationCoordinator = PaginationCoordinator(parent: self) self.rowEditingCoordinator = RowEditingCoordinator(parent: self) clearedEditsCancellable = resumeWhenEditsClear() + gridRepaintCancellable = observeChangeManagerForGridRepaint() Self.lifecycleLogger.info( "[open] MainContentCoordinator.init done connId=\(connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1_000))" @@ -840,15 +695,12 @@ final class MainContentCoordinator: ObservableObject { current = true return prior } - /// This runs on every appearance, and a connection switch is one: it takes this - /// connection's panes out of the window and puts them back. Only the first one starts what - /// lives as long as the coordinator. + // Runs on every appearance, a connection switch included; only the first starts coordinator-lifetime work. if !wasAlreadyActive { services.schemaProviderRegistry.setLiveScopeProvider(CoordinatorLiveScopeProvider.shared) services.schemaProviderRegistry.retain(for: connection.id) registerForPersistence() - /// Recovery records which connections have a live coordinator, which only changes here - /// and in `teardown()`, so a switch has nothing new to write. + // Live coordinators change only here and in `teardown()`, so a switch has nothing new to write. SessionRecoveryTracker.sync() startPeriodicSave() startFileWatcherIfNeeded() @@ -860,7 +712,6 @@ final class MainContentCoordinator: ObservableObject { ) } - /// Start watching the database file for external changes (SQLite, DuckDB). private func startFileWatcherIfNeeded() { guard fileWatcher == nil, services.pluginManager.connectionMode(for: connection.type) == .fileBased else { return } @@ -877,19 +728,14 @@ final class MainContentCoordinator: ObservableObject { fileWatcher = watcher } - /// Reveals the assistant, building its view model if this is the first time anything asked for - /// one. Activation happens here rather than at window open, which is what keeps a window that - /// never opens the assistant from reading the whole conversation history off disk. + // Activates lazily so a window that never opens the assistant never reads the conversation history off disk. func showAssistant() { - /// The gate comes first. Activating builds the view model, whose init reads the stored - /// conversations, and the pane would then refuse to open it anyway. + // Gate first: activating reads the stored conversations, and the pane would refuse to open anyway. guard AppSettingsManager.shared.ai.enabled else { return } trailingPaneState?.assistant.activate(connection: connection) trailingPaneProxy?.showAssistant() } - /// Reveals the inspector on its JSON rendering. The view mode is part of the inspector's own - /// state, so "show the row as JSON" is two facts: which surface, and which rendering. func showRowAsJSON() { trailingPaneState?.inspector.viewMode = .json trailingPaneProxy?.showInspector() @@ -916,8 +762,7 @@ final class MainContentCoordinator: ObservableObject { _teardownScheduled.withLock { $0 = false } } - /// Requests the connection-scoped refresh, which every window of this connection - /// shares, then applies the window-local follow-up. + // The refresh is connection-scoped and shared by every window; the follow-up is window-local. func refreshTables(currentDatabaseOnly: Bool = false) async { schemaColumns.removeAll() await services.schemaRefreshService.refresh( @@ -954,9 +799,7 @@ final class MainContentCoordinator: ObservableObject { } } - /// Opens the viewer rather than fetching here. Inspecting an object should not put its source - /// into an editable query buffer, where the next Cmd+Return runs it, and the viewer refetches - /// on its own so a restored tab shows the current definition instead of a stale one. + // Opens the viewer, not a query buffer: Cmd+Return would run the source, and the viewer refetches the current definition. func showObjectSource(_ objectRef: DatabaseObjectRef) { let resolved = objectRef.resolvingDatabase(browseDatabaseName) let payload = EditorTabPayload( @@ -984,12 +827,7 @@ final class MainContentCoordinator: ObservableObject { WindowManager.shared.openTab(payload: payload) } - /// Drop sidebar state for tables that no longer exist. The selection lives in this - /// window's sidebar, so it is pruned per window. - /// Explicit cleanup, called when the connection or the window that hosts it goes away, never - /// from a view's `onDisappear`: a workspace switch unparents a connection's panes, which is a - /// disappearance the connection is expected to come back from. Releases the schema provider - /// synchronously on MainActor so we don't depend on deinit + Task scheduling. + // Never from `onDisappear`: a workspace switch unparents panes the connection comes back to. Releases synchronously, not via deinit. func teardown() { let start = Date() Self.lifecycleLogger.info( @@ -1014,10 +852,7 @@ final class MainContentCoordinator: ObservableObject { fileWatcher?.stopWatching(connectionId: connectionId) fileWatcher = nil cancelAllQueryTasks() - /// A cancelled task is not a finished one. `Task.cancel()` is cooperative, so the driver - /// call may still be running and will throw on the way out; without this the resulting - /// error reaches the ordinary failure path and reports a query that "failed" when what - /// actually happened is that the window closed. + // `Task.cancel()` is cooperative: the driver call may still throw and report a failure when the window just closed. reportEndedExecutions(tabExecution.invalidateAll(reason: .sessionEnded)) refreshCoalesceTask?.cancel() refreshCoalesceTask = nil @@ -1033,20 +868,16 @@ final class MainContentCoordinator: ObservableObject { databaseSearchSession?.stop() databaseSearchSession = nil - dataTabDelegate?.tableViewCoordinator?.releaseData() + releaseAllGridModels() tabSessionRegistry.removeAll() - /// The delegate a session owns holds closures the mounted view installed, and those capture - /// the view, which holds this coordinator. Dropping the dictionary is not enough to break - /// that, so the wiring is released explicitly here as well as at every per-tab drop site. + // The session delegate holds view closures that capture this coordinator, so the wiring is released explicitly. for session in structureSessions.values { session.releaseViewWiring() } structureSessions.removeAll() createTableDrafts.removeAll() userTypeEditors.removeAll() erDiagramModels.removeAll() displayFormatsCache.removeAll() - displayOrderCache.removeAll() - displayStateCache.removeAll() tableMetadataCache.removeAll() schemaColumns.removeAll() columnScopeRequeryTask?.cancel() @@ -1055,8 +886,7 @@ final class MainContentCoordinator: ObservableObject { tabManager.tabs.removeAll() tabManager.selectedTabId = nil - // Release change manager state — pluginDriver holds a strong reference - // to the entire database driver which prevents deallocation + // pluginDriver strongly holds the whole database driver, which blocks deallocation. changeManager.clearChanges() changeManager.pluginDriver = nil @@ -1074,10 +904,7 @@ final class MainContentCoordinator: ObservableObject { let connectionId = connection.id let alreadyHandled = _didTeardown.withLock { $0 } || _teardownScheduled.withLock { $0 } - // Never-activated coordinators are throwaway instances created by SwiftUI - // during body re-evaluation — @State only keeps the first, rest are discarded. - // Retain is paired with `markActivated`, so a never-activated coordinator - // never retained the schema provider and must not release it here. + // Never-activated coordinators are SwiftUI throwaways that never retained the schema provider, so must not release it. guard _didActivate.withLock({ $0 }) else { let id = instanceId Task { @MainActor in @@ -1103,7 +930,7 @@ final class MainContentCoordinator: ObservableObject { // MARK: - Initialization Actions - /// Synchronous toolbar setup — no I/O, safe to call inline + // No I/O, safe to call inline. func initializeToolbar() { toolbarState.update(from: connection) @@ -1112,15 +939,13 @@ final class MainContentCoordinator: ObservableObject { } } - /// Load schema if not already loaded by another window for this connection. - /// A second window on a connection, or this view mounting again, finds the schema loaded and - /// leaves it alone instead of reloading the autocomplete every window shares. + // A second window or a remount finds the schema loaded and leaves the shared autocomplete alone. func loadSchemaIfNeeded() async { loadSchemaOnActivation() await schemaLoadTask?.value } - /// Initialize view with connection info and load schema (legacy — used by first window) + // Legacy, used by the first window. func initializeView() async { initializeToolbar() await loadSchemaIfNeeded() @@ -1137,16 +962,14 @@ final class MainContentCoordinator: ObservableObject { traceExecutionBlocked(tabId: tab.id, site: "runQuery") return } + commitActiveGridEdit() if tab.tabType == .table { executeTableTabQueryDirectly(trigger: trigger, viewport: viewport) return } - /// The offset says where the SQL sits in the tab's query, so each result can point back at the statement that - /// produced it. The selection and the statement at the caret both hand over untrimmed text with an exact - /// offset and let the scanner do the trimming, which keeps the two from having to agree about how much - /// whitespace was dropped. + // The offset lets each result point back at its statement. Both paths pass untrimmed text and let the scanner trim. let target = selectionOrStatementAtCursor(in: tab.content.query) executeResolvedSQL( target.sql, tabIndex: index, bypassRowLimit: bypassRowLimit, @@ -1154,13 +977,7 @@ final class MainContentCoordinator: ObservableObject { ) } - /// Runs one statement, named by its own text rather than by where the caret happens to be. - /// - /// The gutter's run control draws itself from the same scan this executes through, so the control runs the - /// statement it sits beside even when the caret is somewhere else entirely. It hands over the SQL rather than a - /// range on purpose: the editor's text and the tab's binding are two strings that can differ for a moment, and a - /// range resolved against the wrong one truncates silently. Past that it is the ordinary path, so parameters, safe - /// mode and the execution gate all apply exactly as they do to any other run. + // Takes SQL, not a range: the editor text and the tab binding can briefly differ, and a range on the wrong one truncates silently. @discardableResult func runStatement(_ sql: String, sourceOffset: Int? = nil) -> Bool { guard let (tab, index) = tabManager.selectedTabAndIndex, tab.tabType == .query else { return false } @@ -1168,18 +985,12 @@ final class MainContentCoordinator: ObservableObject { traceExecutionBlocked(tabId: tab.id, site: "runStatement") return false } + commitActiveGridEdit() return executeResolvedSQL(sql, tabIndex: index, bypassRowLimit: false, sourceOffset: sourceOffset) } - /// Everything both run paths do once the SQL to run has been decided. - /// - /// Shared so that a statement run from the gutter and a statement run from the caret cannot drift apart on - /// parameter handling, which is the half of this that is easy to forget. - /// - /// Returns whether the SQL was actually dispatched. It is not when the statement carries parameters whose panel - /// has yet to be filled in: that opens the panel and runs nothing, and a caller that advances the caret on the - /// strength of a run would then be pointing at the wrong statement when the reader presses again. + // Returns false when a parameter panel opens instead, so callers don't advance the caret on a run that didn't happen. @discardableResult private func executeResolvedSQL( _ sql: String, @@ -1225,11 +1036,7 @@ final class MainContentCoordinator: ObservableObject { return true } - /// Execute table tab query directly. - /// Table tab queries are always app-generated SELECTs, so they skip dangerous-query - /// checks but still respect safe mode levels that apply to all queries. - /// Returns the confirmation task when Safe Mode asks before the load runs. - /// A table tab changes source only through `replaceTabContent`, so every run here re-reads its own. + // App-built SELECTs skip dangerous-query checks but keep safe mode. Returns the confirmation task when Safe Mode asks first. @discardableResult func executeTableTabQueryDirectly( trigger: TableLoadTrigger = .userInitiated, @@ -1268,10 +1075,7 @@ final class MainContentCoordinator: ObservableObject { ) ) isShowingSafeModePrompt = false - /// The sheet holds this window, not a link or an agent selecting another table under - /// it, and the answer is about the statement it showed. Run on whatever tab was - /// selected by then, it put this table's rows under another table's name. The text - /// alone does not name the statement: a Redis database switch keeps the same SCAN. + // Only on the tab and table the sheet showed: elsewhere it put these rows under another name; SQL alone misses a Redis db switch. let asked = tabManager.tabs.first(where: { $0.id == tabId }) guard tabManager.selectedTabId == tabId, let asked, @@ -1388,7 +1192,7 @@ final class MainContentCoordinator: ObservableObject { let needsDefinition = tabSessionRegistry.needsDefinition(tabId) let needsMetadataFetch = tableName.map { isEditable && !isMetadataCached(tabId: tabId, tableName: $0) } ?? false - /// Captured now, while the result this decision was made against is still the active one. + // Captured now, while the result this decision was made against is still the active one. let cachedMetadata: ParsedSchemaMetadata? = needsMetadataFetch ? nil : ParsedSchemaMetadata.cached( rows: tabSessionRegistry.tableRows(for: tabId), primaryKeyColumns: tabManager.tabs[index].tableContext.primaryKeyColumns @@ -1452,20 +1256,14 @@ final class MainContentCoordinator: ObservableObject { let inlineMeta = needsMetadataFetch ? QueryExecutor.inlineMetadata(from: fetchResult.resultColumnMeta, columns: fetchResult.columns) : nil - /// After a definition change the rows and the definition commit together, so the grid - /// never holds the new columns under the old keys, defaults or generated columns. + // Rows and definition commit together after a definition change, so new columns never sit under old keys or defaults. let definition = needsDefinition ? try? await schemaTask?.value : nil let applied = await MainActor.run { [weak self] () -> Bool in guard let self else { return false } traceFetchCompleted(traceToken, began: fetchBeganAt, ended: fetchEndedAt, result: fetchResult) - // Every write below belongs to whoever owns the tab now. A superseded result - // that cleared the spinner or nilled the task handle would be reporting on a - // query that is still running, so the gate comes before all of them. Settling - // is that gate: it answers and releases in one step, and it comes before the - // cancellation check rather than sharing a guard with it, because a short - // circuit there would leave the tab claimed forever and refusing later runs. + // Settle first: it gates every write below, and sharing a guard with the cancel check would leave the tab claimed forever. guard tabExecution.settle(claim) else { traceStaleResultDropped(traceToken) return false @@ -1519,21 +1317,14 @@ final class MainContentCoordinator: ObservableObject { ) return true } - /// Phase 2 writes keys, defaults and a row count onto whatever the tab holds when it - /// lands. A result that lost the tab to a retarget would describe its own table on - /// the next one, so only the execution that applied its rows may start it. + // Phase 2 writes onto whatever the tab holds, so only the execution that applied its rows may start it. guard applied else { schemaTask?.cancel() return } if isEditable, let tableName { - /// Committing to phase 2 is what makes the total pending, so it is recorded here - /// rather than inside `resolveRowCount`. On a first open the metadata arm reaches - /// that function only after a Task hop and the schema round trip, and phase 1 has - /// already cleared `isLoading` synchronously, so the tab spends the whole fetch - /// looking settled with no total: the readout states a row count it is about to - /// replace, and `Count Exactly` is offered against a total nobody has yet. + // Marked pending here: phase 1 clears `isLoading` first, so without it the tab looks settled and offers Count Exactly early. tabManager.mutate(tabId: tabId) { $0.pagination.isCountPending = true } launchPhase2( tableName: tableName, @@ -1571,9 +1362,7 @@ final class MainContentCoordinator: ObservableObject { let editorLanguage = services.pluginManager.editorLanguage(for: connection.type) let pluginBuildsBrowse = (services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter)? .queryBuildingPluginDriver != nil - /// An engine whose plugin writes its own browse, or whose language is not SQL, gets no table from a query's - /// text, and the name a query tab carries came from an earlier result or an older build, so it can name - /// another collection. A pipeline's `_id` can also be a group key that names an unrelated document. + // Plugin-built browse or non-SQL: no table from query text; a tab's stored name or a pipeline `_id` can name another collection. let usesNoSQLBrowsing = editorLanguage != .sql || pluginBuildsBrowse if tab.tabType != .table, usesNoSQLBrowsing { return (nil, false) diff --git a/apple/TablePro/Views/Main/MainContentView.swift b/apple/TablePro/Views/Main/MainContentView.swift index 25b61e6911..fdc7b3f76a 100644 --- a/apple/TablePro/Views/Main/MainContentView.swift +++ b/apple/TablePro/Views/Main/MainContentView.swift @@ -2,30 +2,19 @@ // MainContentView.swift // TablePro // -// Main content view combining query editor and results table. -// Refactored to use coordinator pattern for business logic separation. -// -// Extensions: -// - MainContentView+Bindings.swift — computed bindings and trigger types -// - MainContentView+EventHandlers.swift — tab/table selection, sidebar edit handling -// - MainContentView+Setup.swift — initialization, command actions, database switching -// - MainContentView+Helpers.swift — helper methods, inspector context -// - MainContentView+Modifiers.swift — toolbar tint, focused command actions, preview -// import Combine import os import SwiftUI import TableProPluginKit -/// Main content view - thin presentation layer struct MainContentView: View { nonisolated static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") // MARK: - Properties let connection: DatabaseConnection - /// Payload identifying what this window-tab should display (nil = default query tab) + // nil means the default query tab. let payload: EditorTabPayload? // Shared state from parent @@ -56,9 +45,7 @@ struct MainContentView: View { @State var queryResultsSummaryCache: (tabId: UUID, version: Int, summary: String?)? @State var inspectorUpdateTask: Task? @State var inspectorContextRefreshTask: Task? - /// Stable identifier for this window in WindowLifecycleMonitor @State var windowId = UUID() - /// Reference to this view's NSWindow for filtering notifications @State var viewWindow: NSWindow? // MARK: - Environment @@ -138,8 +125,7 @@ struct MainContentView: View { // MARK: - Sheet Content - /// Connection with the active database from the current session, - /// so export/import dialogs see the database the user actually switched to. + // Export and import dialogs need the database the user switched to, not the saved one. var connectionWithCurrentDatabase: DatabaseConnection { var conn = connection if let currentDB = DatabaseManager.shared.session(for: connection.id)?.browseDatabase { @@ -148,9 +134,7 @@ struct MainContentView: View { return conn } - /// Exporting a container names the database that container lives in, which is not always the - /// one being browsed. The dialog scopes every list and the export itself to this connection's - /// database, so naming it here is what makes exporting another database show that database. + // Exporting another database's container has to name that database; the dialog scopes everything to this one. var exportConnection: DatabaseConnection { var conn = connectionWithCurrentDatabase if let scoped = coordinator.exportPreselection?.scopedDatabase, !scoped.isEmpty { @@ -159,11 +143,7 @@ struct MainContentView: View { return conn } - /// Returns the appropriate sheet view for the given `ActiveSheet` case. - /// Uses a dismissal binding that sets `coordinator.activeSheet = nil` when the - /// child view sets `isPresented = false`. - /// The transfer sheet is built here rather than inline, because `sheetContent(for:)` is one - /// switch over every sheet the window can present and is already at the function length limit. + // Its own function: sheetContent(for:) switches over every sheet and is already at the function length limit. @ViewBuilder func transferSheet(tables: Set, schema: String?, dismiss: Binding) -> some View { TableTransferSheet( @@ -183,11 +163,7 @@ struct MainContentView: View { coordinator.activeSheet = nil coordinator.exportPreselection = nil coordinator.exportFormatId = nil - /// Cleared on every dismissal, Cancel included. The request holds the closure - /// that runs the rebuild, and that closure holds the structure view, which - /// holds this coordinator; leaving it set after a cancel keeps the cycle alive - /// for the window's life and offers a stale plan to whatever opens the sheet - /// next. + // Cleared on every dismissal: the request's closure holds this coordinator, so a kept one leaks and offers a stale plan. coordinator.tableRebuildRequest = nil } } @@ -330,9 +306,6 @@ struct MainContentView: View { } } - /// Trigger for toolbar pending-changes badge — combines all four sources that - /// contribute to `hasPendingChanges`. Replaces four separate handlers that each - /// called `updateToolbarPendingState()`. private var pendingChangeTrigger: PendingChangeTrigger { PendingChangeTrigger( hasDataChanges: changeManager.hasChanges, @@ -348,7 +321,7 @@ struct MainContentView: View { ) } - /// Split into two halves to help the Swift type checker with the long modifier chain. + // Split in two to help the Swift type checker with the long modifier chain. private var bodyContent: some View { bodyContentCore .background { @@ -365,40 +338,41 @@ struct MainContentView: View { }) .modifier(InspectorValueLoadModifier( inspector: trailingPaneState.inspector, - selection: coordinator.selectionState, + model: gridSelectionOwner == .dataGrid ? coordinator.activeGridModel : nil, tabId: currentTab?.id, - selectsDataRows: gridSelectionOwner == .dataGrid, contentVersion: currentTab?.schemaVersion ?? 0 - ) { [coordinator] tabId, displayRow in - await coordinator.loadInspectorRowValues(tabId: tabId, displayRow: displayRow) + ) { [coordinator] tabId, rowID in + await coordinator.loadInspectorRowValues(tabId: tabId, rowID: rowID) }) - /// Keyed on the connection alone. The only driver that answers `fetchSessionContexts` - /// is Snowflake, which pays two round trips for it, so this must not reload per query. - /// It lives with the connection's content rather than with the toolbar control it used - /// to feed, because the Database menu is what offers these now and a menu has no view - /// to hang a `task` on. + .background { + if let model = coordinator.activeGridModel { + GridSelectionWatcher( + model: model, + onAttach: { scheduleInspectorUpdate() }, + onChange: { rowsChanged in handleGridSelectionChange(rowsChanged: rowsChanged) } + ) + .id(ObjectIdentifier(model)) + } + } + .onReceive(coordinator.gridCellsEdited) { _ in + scheduleInspectorUpdate() + } + // Keyed on the connection alone: Snowflake, the only driver that answers, pays two round trips for it. .task(id: coordinator.toolbarState.connectionState) { await coordinator.loadSessionContexts() } .onChange(of: inspectorTrigger) { _ in scheduleInspectorUpdate() } - /// The numbers land after the rows that moved the trigger above, and a comment refetch - /// moves nothing else, so the snapshot follows the cache on its own. Only the snapshot: - /// the edit state does not depend on them, and rebuilding it would end a typed undo run. + // Only the snapshot: rebuilding the edit state would end a typed undo run. .onChange(of: tableMetadataForCurrentTab) { _ in updateInspectorContext() } - /// The JSON rendering draws the snapshot the context carries, and an edit made in the - /// fields rendering changes the row under it without moving anything `InspectorTrigger` - /// watches. Rebuilding on the switch is enough: the two renderings are never on screen - /// together, so the stale snapshot is only ever reached by switching to it. + // An edit in the fields rendering moves nothing InspectorTrigger watches; the two renderings are never on screen together. .onValueChange(of: \.viewMode, in: trailingPaneState.inspector) { _, _ in updateInspectorContext() } - /// A value window detached from a field goes on writing while the JSON rendering is the - /// one on screen, and it moves nothing the trigger above watches. Debounced, because it - /// commits per keystroke and rebuilding the JSON tree cancels the reader's fetches. + // Debounced: a detached value window commits per keystroke, and rebuilding the JSON tree cancels the reader's fetches. .onReceive(coordinator.inspectorRowContentChanged) { _ in scheduleInspectorContextRefresh() } @@ -478,16 +452,23 @@ struct MainContentView: View { } handleTableSelectionChange(from: oldTables, to: newTables) } - /// A background reload of the same container must not take a selection out from under - /// the user. Every other input re-asserts unconditionally: a container switch above all, - /// because a selection made in the container being left is not one in the container - /// arriving. + // A background reload of the same container must not take the selection out from under the user. .onChange(of: tables) { _ in guard coordinator.windowSidebarState.acceptsObjectMarkRefresh else { return } coordinator.syncSidebarObjectSelection() } } + // Any grid's selection counts, a query result's as much as a table's: the setting is "on row select", not on cursor moves. + private func handleGridSelectionChange(rowsChanged: Bool) { + if rowsChanged, + coordinator.activeGridModel?.selection.isEmpty == false, + AppSettingsManager.shared.dataGrid.autoShowInspector { + coordinator.trailingPaneProxy?.revealInspectorForSelection() + } + scheduleInspectorUpdate() + } + // MARK: - Main Content private var mainContentView: some View { @@ -520,31 +501,6 @@ struct MainContentView: View { connection: connection, windowId: windowId, connectionId: connection.id, - selectionState: coordinator.selectionState, - onCellEdit: { rowIndex, colIndex, value in - coordinator.updateCellInTab( - rowIndex: rowIndex, columnIndex: colIndex, value: value) - scheduleInspectorUpdate() - }, - onSortStateChanged: { newState in - coordinator.handleSortStateChanged(newState) - }, - onAddRow: { - coordinator.addNewRow() - }, - onSelectionChange: { newIndices in - /// Any grid selection counts, not just a table tab's. The setting is called - /// "Auto-show inspector on row select" and both docs pages describe it that way, - /// but it was gated on `tabType == .table`, so picking a row in a query result did - /// nothing and the setting read as intermittently broken rather than scoped. - if !newIndices.isEmpty, AppSettingsManager.shared.dataGrid.autoShowInspector { - coordinator.trailingPaneProxy?.revealInspectorForSelection() - } - scheduleInspectorUpdate() - }, - onFilterColumn: { columnName in - coordinator.addFilterForColumn(columnName) - }, onFirstPage: { coordinator.goToFirstPage() }, diff --git a/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift b/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift index 1b9e5e5ffa..bf48e11048 100644 --- a/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift +++ b/apple/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift @@ -1,11 +1,3 @@ -// -// QueryPlanDiagramView.swift -// TablePro -// -// EXPLAIN plan diagram: boxes and arrows on an AppKit-magnified canvas, so pan and zoom -// behave the way every other document surface on macOS does. -// - import SwiftUI import TableProTheme @@ -13,11 +5,10 @@ struct QueryPlanDiagramView: View { @Binding var selectedNodeId: UUID? @Environment(\.colorScheme) private var colorScheme - /// Owned by the plan's `QueryPlanViewState`, because this view goes away on every mode switch. + // Owned by the plan's `QueryPlanViewState`: this view goes away on every mode switch. let viewport: DiagramViewportController - /// Derived from the plan on every update, so a second EXPLAIN in the same tab redraws - /// instead of keeping the layout the first one produced. + // Derived on every update, so a second EXPLAIN in the same tab redraws. private let layout: QueryPlanDiagramLayout init(plan: QueryPlan, selectedNodeId: Binding, viewport: DiagramViewportController) { @@ -76,9 +67,7 @@ struct QueryPlanDiagramView: View { } } -/// A non-interactive copy at natural scale, so an export never captures the current zoom, scroll -/// offset or selection. `ImageRenderer` draws in the light scheme whatever the window shows, so the -/// canvas's own scheme is passed in. +// `ImageRenderer` draws in the light scheme whatever the window shows, so the canvas's scheme is passed in. struct QueryPlanDiagramExportCanvas: View { let layout: QueryPlanDiagramLayout let colorScheme: ColorScheme @@ -110,8 +99,7 @@ struct QueryPlanDiagramExportCanvas: View { // MARK: - Drawing -/// What the canvas view hosts: every step and arrow, drawn at the layout's own coordinates. It takes -/// no input and publishes nothing to accessibility, both of which `QueryPlanDiagramCanvasView` owns. +// Input and accessibility belong to `QueryPlanDiagramCanvasView`. struct QueryPlanDiagramDrawing: View { let layout: QueryPlanDiagramLayout let selectedNodeId: UUID? @@ -135,10 +123,7 @@ struct QueryPlanDiagramDrawing: View { // MARK: - Arrow Layer -/// Shapes rather than a `Canvas`. A `Canvas` inside a magnifying `NSScrollView` stops painting past -/// `contentSize * magnification + 128` document points once magnification reaches 0.5, so a plan -/// zoomed out lost its arrows while its nodes, being ordinary views, stayed (#2692). The export -/// canvas is never magnified and draws the same arrows from the same geometry. +// Shapes, not a `Canvas`: a `Canvas` in a magnified `NSScrollView` stops painting at 0.5 magnification (#2692). struct QueryPlanArrowsView: View { let arrows: [QueryPlanDiagramLayout.Arrow] let size: CGSize @@ -161,9 +146,7 @@ struct QueryPlanArrowsView: View { return path } - /// One shape for every curve and one for every head, rather than one view per arrow. A shape - /// view is sized to its own path, so a per-arrow view would be laid out at its bounding box and - /// centred, which moves the arrow off the node it points at. + // A per-arrow shape view is laid out at its bounding box and centred, off the node it points at. private struct Combined: Shape { let paths: [Path] @@ -188,8 +171,6 @@ struct QueryPlanArrowsView: View { struct QueryPlanDiagramNodeView: View { @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor - @Environment(\.colorSchemeContrast) private var contrast - @ObservedObject private var themeEngine = ThemeEngine.shared let node: QueryPlanNode let isSelected: Bool @@ -264,9 +245,7 @@ struct QueryPlanDiagramNodeView: View { node.severity?.tint(differentiateWithoutColor: differentiateWithoutColor) ?? .secondary } - /// The grid outlines its own selection with the focus border, so a selected step does too. - private var stroke: AnyShapeStyle { - guard isSelected else { return AnyShapeStyle(tint) } - return ThemeShapeStyle.style(.gridFocusBorder, native: Color.accentColor, engine: themeEngine, contrast: contrast) + private var stroke: Color { + isSelected ? .accentColor : tint } } diff --git a/apple/TablePro/Views/Results/CellInteractionResolver.swift b/apple/TablePro/Views/Results/CellInteractionResolver.swift index dcba244658..aa64b5e819 100644 --- a/apple/TablePro/Views/Results/CellInteractionResolver.swift +++ b/apple/TablePro/Views/Results/CellInteractionResolver.swift @@ -14,10 +14,7 @@ internal struct CellContext: Equatable { let isBinaryValue: Bool let isForeignKey: Bool let displayFormatOverride: ValueDisplayFormat? - - /// What the value itself turned out to be, resolved once by the caller that holds the typed - /// cell. It reaches the resolver rather than being sniffed here so the row inspector, which - /// resolves through `CellValueContentDetector` too, cannot reach a different answer. + // Resolved by the caller that holds the typed cell, so the row inspector and the grid reach one answer. let detectedContent: CellValueContent init( @@ -44,7 +41,7 @@ internal struct CellContext: Equatable { } internal enum CellInteractionMode: Equatable { - case viewInline(value: String) + case viewText(value: String) case viewJson case viewBlob case viewPhpSerialized @@ -54,7 +51,7 @@ internal enum CellInteractionMode: Equatable { case viewVector case editInline(value: String) - case editOverlay(value: String) + case editText(value: String) case editForeignKey case editJson case editBlob @@ -65,6 +62,12 @@ internal enum CellInteractionMode: Equatable { } internal struct CellInteractionResolver { + static let inlineLengthLimit = 256 + + static func editsInPopover(_ text: String) -> Bool { + (text as NSString).length > inlineLengthLimit || text.containsLineBreak + } + func resolve(_ context: CellContext) -> CellInteractionMode { guard !context.isRowDeleted else { return .blocked } @@ -101,17 +104,14 @@ internal struct CellInteractionResolver { } } - /// A writable foreign key column picks from the rows it points at rather than taking a typed - /// key on trust. Resolved here rather than ahead of the blob and structured-format branches, so - /// a foreign key that is also a blob, JSON or PHP-serialized value keeps the editor its content - /// needs, and a read-only cell keeps every viewer it has. + // Last, so a foreign key holding a blob or JSON keeps the editor its content needs. private func plainText(for context: CellContext, isReadOnly: Bool) -> CellInteractionMode { if isReadOnly { - return .viewInline(value: context.value ?? "NULL") + return .viewText(value: context.value ?? "NULL") } if context.isForeignKey { return .editForeignKey } let value = context.value ?? "" - if value.containsLineBreak { return .editOverlay(value: value) } + if Self.editsInPopover(value) { return .editText(value: value) } return .editInline(value: value) } diff --git a/apple/TablePro/Views/Results/CellOverlayBase.swift b/apple/TablePro/Views/Results/CellOverlayBase.swift deleted file mode 100644 index 4eadc03e16..0000000000 --- a/apple/TablePro/Views/Results/CellOverlayBase.swift +++ /dev/null @@ -1,342 +0,0 @@ -// -// CellOverlayBase.swift -// TablePro -// - -import AppKit -import Combine -import TableProTheme - -enum CellOverlayDismissReason { - case userAction - case scroll - case columnGeometry - case appResign - case windowResignKey - case outsideClick -} - -@MainActor -class CellOverlayBase: NSObject { - private var container: CellOverlayContainerView? - private weak var hostTableView: NSTableView? - private var scrollObserver: NSObjectProtocol? - private var columnGeometryObservers: [any NSObjectProtocol] = [] - private var appResignObserver: NSObjectProtocol? - private var windowResignKeyObserver: NSObjectProtocol? - private var outsideClickMonitor: Any? - private var isFollowingGrid = false - /// Held for the life of the open overlay: its subscription is what repaints the text on a theme - /// change. - private var textStyle: ThemeTextViewStyle? - private var invisibleCharacters: InvisibleCharacterTextDisplay? - var onRemove: (() -> Void)? - - private(set) var row: Int = -1 - private(set) var column: Int = -1 - private(set) var columnIndex: Int = -1 - - var isActive: Bool { container != nil } - var containerView: NSView? { container } - var tableView: NSTableView? { hostTableView } - - func install( - in tableView: NSTableView, - row: Int, - column: Int, - columnIndex: Int, - container: CellOverlayContainerView - ) { - guard let scrollView = tableView.enclosingScrollView else { return } - self.hostTableView = tableView - self.row = row - self.column = column - self.columnIndex = columnIndex - Self.mount(container, over: tableView, in: scrollView, column: column) - self.container = container - setOverlayCell(CellPosition(row: row, column: columnIndex), in: tableView) - selectionOverlay(in: tableView)?.needsDisplay = true - installDismissObservers() - } - - private static func mount( - _ container: CellOverlayContainerView, - over tableView: NSTableView, - in scrollView: NSScrollView, - column: Int - ) { - container.frame = scrollView.convert(container.frame, from: tableView) - scrollView.addSubview(container, positioned: .above, relativeTo: layer(for: column, of: tableView, in: scrollView)) - } - - // Above AppKit's floating container only for a frozen cell, which the band would otherwise cover. - private static func layer(for column: Int, of tableView: NSTableView, in scrollView: NSScrollView) -> NSView { - guard let coordinator = (tableView as? KeyHandlingTableView)?.coordinator, - coordinator.freezingBand()?.pins(column) == true, - let container = coordinator.rowGutter?.superview, - container.superview === scrollView else { return scrollView.contentView } - return container - } - - // Where the cell is on screen, which for a frozen column is the pinned band, not its home. - static func displayedCellFrame(in tableView: NSTableView, column: Int, row: Int) -> NSRect { - let frame = tableView.frameOfCell(atColumn: column, row: row) - return (tableView as? KeyHandlingTableView)?.coordinator?.freezingBand()?.displayed(frame, ofColumn: column) ?? frame - } - - /// The cell under the overlay draws no text of its own behind it. A drawn cell has no view to - /// carry that, so the coordinator holds it and repaints the cell either side of the change. - private func setOverlayCell(_ position: CellPosition?, in tableView: NSTableView) { - (tableView as? KeyHandlingTableView)?.coordinator?.overlayCell = position - } - - private func selectionOverlay(in tableView: NSTableView) -> GridSelectionOverlay? { - (tableView as? KeyHandlingTableView)?.selectionOverlay - } - - func handleDismiss(reason: CellOverlayDismissReason) { - removeOverlay() - } - - func removeOverlay() { - guard let activeContainer = container else { return } - removeDismissObservers() - if let hostTableView { - setOverlayCell(nil, in: hostTableView) - selectionOverlay(in: hostTableView)?.needsDisplay = true - } - activeContainer.removeFromSuperview() - container = nil - textStyle = nil - invisibleCharacters = nil - if let hostTableView { - hostTableView.window?.makeFirstResponder(hostTableView) - } - onRemove?() - } - - // A zoom resizes the value and moves its cell, so the overlay follows instead of ending the edit as a - // column resize or a scroll would. - func followGrid(through change: () -> Void) { - isFollowingGrid = true - defer { isFollowingGrid = false } - change() - guard let container, let hostTableView, let scrollView = hostTableView.enclosingScrollView, - let textView = (container.subviews.first as? NSScrollView)?.documentView as? NSTextView else { return } - hostTableView.scrollRowToVisible(row) - textView.font = ThemeEngine.shared.valueFont - let cellFrame = Self.displayedCellFrame(in: hostTableView, column: column, row: row) - container.frame = scrollView.convert(Self.overlayFrame(for: cellFrame, value: textView.string), from: hostTableView) - } - - static func overlayFrame(for cellFrame: NSRect, value: String) -> NSRect { - let lineHeight = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 - // A CR on its own breaks the line in the text view as LF does; CRLF is one break. - var newlineCount = 0 - var previous: Unicode.Scalar? - for scalar in value.unicodeScalars { - if scalar == "\r" || (scalar == "\n" && previous != "\r") { - newlineCount += 1 - } - previous = scalar - } - let lineCount = CGFloat(newlineCount + 1) - let contentHeight = max(lineCount * lineHeight + 8, cellFrame.height) - let height = min(max(contentHeight, cellFrame.height), 120) - return NSRect(x: cellFrame.origin.x, y: cellFrame.origin.y, width: cellFrame.width, height: height) - } - - /// The grid's own scope, so an overlay over the theme editor's preview wears the draft. - static func themeScope(of tableView: NSTableView) -> ThemeScope { - (tableView as? KeyHandlingTableView)?.themeScope ?? .live - } - - static func makeContainer(frame: NSRect, scope: ThemeScope) -> CellOverlayContainerView { - let container = CellOverlayContainerView(frame: frame) - container.wantsLayer = true - container.layer?.borderWidth = 2 - container.layer?.cornerRadius = 2 - container.layer?.masksToBounds = true - container.scope = scope - container.applyLayerColors() - return container - } - - /// A value is content, so the box takes the editor's colors, caret and selection included, the - /// way the inspector and the pop-out editor show the same value. - func applyTheme(to textView: NSTextView, scope: ThemeScope) { - textStyle = ThemeTextViewStyle(textView: textView, scope: scope) - } - - // Before the text is set, so the value is laid out once. - func markInvisibleCharacters(in textView: NSTextView) { - invisibleCharacters = InvisibleCharacterTextDisplay.install( - on: textView, - isEnabled: AppSettingsManager.shared.dataGrid.showInvisibleCharacters - ) - } - - /// Lays a text view out the way an inline cell overlay needs. - /// - /// A cell holds one value, so the overlay behaves like a field editor and scrolls a long line - /// rather than wrapping it. Wrapping made TextKit 2 lay the whole value out before the overlay - /// could appear: measured at 206ms for a 256KB value and 816ms for 1MB, against 7ms unwrapped, - /// and the wrapped result was thousands of visual lines in a box 120pt tall (#2381). - /// - /// `maxSize` is raised with the container because a text view grows only as far as `maxSize`, - /// which `init(frame:)` leaves at the frame: without it the long line is clipped at the cell's - /// width instead of scrolled, measured as a 140pt document against 344,166pt with it raised. - static func applyCellTextLayout(to textView: NSTextView) { - let unbounded = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = true - textView.maxSize = unbounded - textView.textContainer?.widthTracksTextView = false - textView.textContainer?.containerSize = unbounded - } - - static func makeScrollView(in container: NSView, scope: ThemeScope) -> NSScrollView { - let scrollView = NSScrollView(frame: container.bounds) - scrollView.autoresizingMask = [.width, .height] - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.borderType = .noBorder - scrollView.drawsBackground = true - scrollView.backgroundColor = scope.palette.color(.editorBackground, otherwise: .textBackgroundColor) - return scrollView - } - - private func installDismissObservers() { - guard let hostTableView else { return } - - if let clipView = hostTableView.enclosingScrollView?.contentView { - scrollObserver = NotificationCenter.default.addObserver( - forName: NSView.boundsDidChangeNotification, - object: clipView, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - guard self?.isFollowingGrid == false else { return } - self?.handleDismiss(reason: .scroll) - } - } - } - - columnGeometryObservers = [ - NSTableView.columnDidResizeNotification, - NSTableView.columnDidMoveNotification, - ].map { name in - NotificationCenter.default.addObserver(forName: name, object: hostTableView, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { - guard self?.isFollowingGrid == false else { return } - self?.handleDismiss(reason: .columnGeometry) - } - } - } - - appResignObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.handleDismiss(reason: .appResign) - } - } - - if let overlayWindow = hostTableView.window { - windowResignKeyObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didResignKeyNotification, - object: overlayWindow, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.handleDismiss(reason: .windowResignKey) - } - } - } - - outsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in - MainActor.assumeIsolated { - self?.handleOutsideClick(event: event) - } - return event - } - } - - private func removeDismissObservers() { - if let observer = scrollObserver { - NotificationCenter.default.removeObserver(observer) - scrollObserver = nil - } - columnGeometryObservers.forEach(NotificationCenter.default.removeObserver) - columnGeometryObservers = [] - if let observer = appResignObserver { - NotificationCenter.default.removeObserver(observer) - appResignObserver = nil - } - if let observer = windowResignKeyObserver { - NotificationCenter.default.removeObserver(observer) - windowResignKeyObserver = nil - } - if let monitor = outsideClickMonitor { - NSEvent.removeMonitor(monitor) - outsideClickMonitor = nil - } - } - - private func handleOutsideClick(event: NSEvent) { - guard let containerView = container, - let containerWindow = containerView.window, - event.window === containerWindow else { return } - let frameInWindow = containerView.convert(containerView.bounds, to: nil) - if !frameInWindow.contains(event.locationInWindow) { - handleDismiss(reason: .outsideClick) - } - } -} - -final class CellOverlayContainerView: NSView { - override var isFlipped: Bool { true } - - var scope: ThemeScope = .live { - didSet { - guard scope !== oldValue else { return } - themeCancellable = nil - subscribeIfInWindow() - applyLayerColors() - } - } - - private var themeCancellable: AnyCancellable? - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - subscribeIfInWindow() - } - - private func subscribeIfInWindow() { - guard window != nil, themeCancellable == nil else { return } - themeCancellable = scope.changes - .receive(on: RunLoop.main) - .sink { [weak self] _ in self?.applyLayerColors() } - } - - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - applyLayerColors() - } - - /// A `CGColor` is a resolved colour and a layer never resolves it again, so the two layer - /// colours are reapplied whenever the appearance or the theme changes under an open overlay. - func applyLayerColors() { - let appearance = effectiveAppearance - let resolved = scope.resolved - layer?.borderColor = resolved.cgColor(.gridFocusBorder, in: appearance) - layer?.backgroundColor = resolved.cgColor(.editorBackground, otherwise: .textBackgroundColor, in: appearance) - } -} diff --git a/apple/TablePro/Views/Results/CellOverlayEditor.swift b/apple/TablePro/Views/Results/CellOverlayEditor.swift deleted file mode 100644 index 12725bdd1c..0000000000 --- a/apple/TablePro/Views/Results/CellOverlayEditor.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// CellOverlayEditor.swift -// TablePro -// - -import AppKit - -@MainActor -final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate { - private var editorTextView: OverlayTextView? - private var initialValue: String = "" - - var onCommit: ((_ row: Int, _ columnIndex: Int, _ newValue: String) -> Void)? - var onMovement: ((_ row: Int, _ column: Int, _ movement: CellEditorMovement) -> Void)? - - func show( - in tableView: NSTableView, - row: Int, - column: Int, - columnIndex: Int, - value: String - ) { - dismiss(commit: true) - - let cellFrame = Self.displayedCellFrame(in: tableView, column: column, row: row) - guard !cellFrame.isEmpty else { return } - guard let window = tableView.window else { return } - - let frame = Self.overlayFrame(for: cellFrame, value: value) - let scope = Self.themeScope(of: tableView) - let containerView = Self.makeContainer(frame: frame, scope: scope) - let scrollView = Self.makeScrollView(in: containerView, scope: scope) - - let textView = OverlayTextView(frame: scrollView.bounds) - textView.overlayEditor = self - textView.isEditable = true - textView.isRichText = false - textView.allowsUndo = true - textView.font = ThemeEngine.shared.valueFont - textView.textColor = .labelColor - textView.backgroundColor = .textBackgroundColor - applyTheme(to: textView, scope: scope) - textView.focusRingType = .none - Self.applyCellTextLayout(to: textView) - markInvisibleCharacters(in: textView) - textView.delegate = self - textView.string = value - textView.selectAll(nil) - - scrollView.documentView = textView - containerView.addSubview(scrollView) - - initialValue = value - editorTextView = textView - - install(in: tableView, row: row, column: column, columnIndex: columnIndex, container: containerView) - window.makeFirstResponder(textView) - } - - override func handleDismiss(reason: CellOverlayDismissReason) { - dismiss(commit: reason != .columnGeometry) - } - - func dismiss(commit: Bool) { - guard let activeTextView = editorTextView else { return } - let newValue = activeTextView.string - let originalValue = initialValue - let dismissRow = row - let dismissColumnIndex = columnIndex - - editorTextView = nil - initialValue = "" - removeOverlay() - - if commit, newValue != originalValue { - onCommit?(dismissRow, dismissColumnIndex, newValue) - } - } - - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - if commandSelector == #selector(NSResponder.insertNewline(_:)) { - if NSApp.currentEvent?.modifierFlags.contains(.option) == true { - textView.insertNewlineIgnoringFieldEditor(nil) - return true - } - dismiss(commit: true) - return true - } - - if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - dismiss(commit: false) - return true - } - - if commandSelector == #selector(NSResponder.insertTab(_:)) { - return leave(with: .tab, from: textView) - } - - if commandSelector == #selector(NSResponder.insertBacktab(_:)) { - return leave(with: .backtab, from: textView) - } - - if commandSelector == #selector(NSResponder.moveUp(_:)) { - return leaveVertically(.up, from: textView) - } - - if commandSelector == #selector(NSResponder.moveDown(_:)) { - return leaveVertically(.down, from: textView) - } - - return false - } - - /// Only the plain arrows are read. Shift, Option and Command each map to a selector of their - /// own, so extending a selection or jumping to the end of the value keeps its native meaning. - /// - /// An unhandled arrow moves the caret inside marked text, which is what it is for, so a - /// composition takes it back rather than having it swallowed. - private func leaveVertically(_ movement: CellEditorMovement, from textView: NSTextView) -> Bool { - guard !textView.hasMarkedText() else { return false } - let exit = CellEditorArrowExit( - text: textView.string as NSString, - selection: textView.selectedRange() - ) - let leaves = movement == .up ? exit.canExitUp : exit.canExitDown - guard leaves else { return false } - return leave(with: movement, from: textView) - } - - /// A composition in progress owns the keystroke. Until the input method commits it the text - /// view holds provisional text, and leaving the cell would save that half-composed value and - /// carry the editor off it. The key is swallowed rather than passed back, because a literal - /// tab in a cell is not what Tab was pressed for. - private func leave(with movement: CellEditorMovement, from textView: NSTextView) -> Bool { - guard !textView.hasMarkedText() else { return true } - let dismissRow = row, dismissColumn = column - dismiss(commit: true) - onMovement?(dismissRow, dismissColumn, movement) - return true - } -} - -private final class OverlayTextView: NSTextView { - private let storedUndoManager = UndoManager() - - weak var overlayEditor: CellOverlayEditor? - - private static let menuKeyEquivalents: Set = ["s"] - - override var undoManager: UndoManager? { storedUndoManager } - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if event.modifierFlags.contains(.command), - let chars = event.charactersIgnoringModifiers, - Self.menuKeyEquivalents.contains(chars) { - overlayEditor?.dismiss(commit: true) - return false - } - return super.performKeyEquivalent(with: event) - } -} diff --git a/apple/TablePro/Views/Results/CellOverlayViewer.swift b/apple/TablePro/Views/Results/CellOverlayViewer.swift deleted file mode 100644 index 6559c43d52..0000000000 --- a/apple/TablePro/Views/Results/CellOverlayViewer.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// CellOverlayViewer.swift -// TablePro -// - -import AppKit - -@MainActor -final class CellOverlayViewer: CellOverlayBase, NSTextViewDelegate { - func show( - in tableView: NSTableView, - row: Int, - column: Int, - columnIndex: Int, - value: String - ) { - removeOverlay() - - let cellFrame = Self.displayedCellFrame(in: tableView, column: column, row: row) - guard !cellFrame.isEmpty else { return } - guard let window = tableView.window else { return } - - let frame = Self.overlayFrame(for: cellFrame, value: value) - let scope = Self.themeScope(of: tableView) - let containerView = Self.makeContainer(frame: frame, scope: scope) - let scrollView = Self.makeScrollView(in: containerView, scope: scope) - - let textView = NSTextView(frame: scrollView.bounds) - textView.isEditable = false - textView.isSelectable = true - textView.isRichText = false - textView.font = ThemeEngine.shared.valueFont - textView.textColor = .labelColor - textView.backgroundColor = .textBackgroundColor - applyTheme(to: textView, scope: scope) - Self.applyCellTextLayout(to: textView) - markInvisibleCharacters(in: textView) - textView.delegate = self - textView.string = value - textView.selectAll(nil) - - scrollView.documentView = textView - containerView.addSubview(scrollView) - - install(in: tableView, row: row, column: column, columnIndex: columnIndex, container: containerView) - window.makeFirstResponder(textView) - } - - func dismiss() { - removeOverlay() - } - - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - switch commandSelector { - case #selector(NSResponder.insertNewline(_:)), - #selector(NSResponder.cancelOperation(_:)), - #selector(NSResponder.insertTab(_:)), - #selector(NSResponder.insertBacktab(_:)): - removeOverlay() - return true - default: - return false - } - } -} diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift b/apple/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift deleted file mode 100644 index f2c2b1634c..0000000000 --- a/apple/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// DataGridCellAccessibilityView.swift -// TablePro -// - -import AppKit - -/// Whether anything is reading the data grid through accessibility. -/// -/// Accessibility is pull-based, and the first question anyone asks the grid is the app's only signal -/// that a client is attached: `NSWorkspace.isVoiceOverEnabled` covers VoiceOver alone, and nothing -/// at all reports Switch Control, Voice Control, an inspector or XCUITest. So the grid starts in its -/// fast shape and changes shape the first time it is asked. -/// -/// One-way and process-wide: a client that attaches once may detach, but nothing reports that -/// either, and dropping back would be a second reload for no gain. -@MainActor -internal enum DataGridAccessibility { - internal static let didActivateNotification = Notification.Name("com.TablePro.DataGridAccessibilityDidActivate") - - /// Written through `markActive()` in the app. Settable so a test can drive both shapes. - internal static var isActive = false - - internal static func markActive() { - guard !isActive else { return } - isActive = true - NotificationCenter.default.post(name: didActivateNotification, object: nil) - } -} - -/// A data cell's stand-in for an assistive client, mounted only while one is attached. -/// -/// `NSTableView` builds its `AXCell` tree from cell views and from nothing else. An -/// `NSAccessibilityElement` published by the row never reaches that tree, whichever attribute -/// carries the text, whether the attribute is set or overridden, and however the element is -/// parented: the tree shows AppKit's own placeholder in its place, which is why drawn cells read as -/// a full grid of correctly placed, permanently blank values. One view per cell is the cost `#2381` -/// removed, so it is charged only to the sessions that need it. -/// -/// The view draws nothing and takes no clicks. Its row still paints every cell and still handles -/// every click, so this carries the text and no pixels. -@MainActor -internal final class DataGridCellAccessibilityView: NSView { - internal static let reuseIdentifier = NSUserInterfaceItemIdentifier("DataGridCellAccessibilityView") - - private weak var coordinator: TableViewCoordinator? - private var seededRow = 0 - private var dataColumn = 0 - - /// The row this cell is showing now, asked of the table rather than remembered, for the reason - /// `DataGridRowView.rowIndex` is: an incremental insert or remove moves a mounted cell view to - /// its new slot without asking for it again, so a stored index outlives the row it named and the - /// cell then speaks a different row's value under the old row's number. - private var row: Int { - guard let tableView = coordinator?.tableView else { return seededRow } - let resolved = tableView.row(for: self) - return resolved >= 0 ? resolved : seededRow - } - - internal func configure(coordinator: TableViewCoordinator, row: Int, dataColumn: Int) { - self.coordinator = coordinator - seededRow = row - self.dataColumn = dataColumn - setAccessibilityColumnIndexRange(NSRange(location: dataColumn, length: 1)) - } - - /// Answered live for the same reason, rather than stamped in `configure`, so a client reading the - /// tree after a row moved is told where the cell is rather than where it was built. - override internal func accessibilityRowIndexRange() -> NSRange { - NSRange(location: row, length: 1) - } - - override internal func hitTest(_ point: NSPoint) -> NSView? { nil } - - // A frozen column's cell is read where the pinned band shows it, not at its home off screen. - override internal func accessibilityFrame() -> NSRect { - guard let tableView = coordinator?.tableView, - let window = tableView.window, - let band = coordinator?.freezingBand() else { return super.accessibilityFrame() } - let column = tableView.column(for: self) - guard band.pins(column) else { return super.accessibilityFrame() } - let displayed = band.displayed(tableView.convert(bounds, from: self), ofColumn: column) - return window.convertToScreen(tableView.convert(displayed, to: nil)) - } - - override internal func isAccessibilityElement() -> Bool { true } - - /// Static text inside the `AXCell` that `NSTableView` wraps every cell view in, which is the - /// shape the row-number column already publishes and the one VoiceOver expects of a table. - /// - /// A cell that carries a chevron is a combo box, because it takes typed text and offers a list - /// beside it, and that is what `NSComboBoxCell` publishes (measured: AXComboBox, with AXShowMenu - /// and AXConfirm). Reporting it as static text would leave the menu unreachable to a client that - /// looks for one, which is every client but a sighted pointer. - override internal func accessibilityRole() -> NSAccessibility.Role? { - if coordinator?.checkboxMark(row: row, columnIndex: dataColumn) != nil { return .checkBox } - return coordinator?.presentsComboBoxCell(columnIndex: dataColumn) == true ? .comboBox : .staticText - } - - override internal func accessibilityValue() -> Any? { - if let mark = coordinator?.checkboxMark(row: row, columnIndex: dataColumn) { - return NSNumber(value: mark == .checked) - } - return text - } - - override internal func accessibilityPerformPress() -> Bool { - guard coordinator?.toggleCheckbox(row: row, columnIndex: dataColumn) == true else { return false } - NSAccessibility.post(element: self, notification: .valueChanged) - return true - } - - /// Every combination is its own format string rather than pieces joined at run time, so a - /// translator sees a whole sentence and can order its parts. The pending change comes before the - /// highlight rule, because it is the one the reader is about to save. - override internal func accessibilityLabel() -> String? { - let pendingChange = coordinator?.pendingChangeDescription(row: row, columnIndex: dataColumn) - let highlight = coordinator?.highlightDescription(row: row, columnIndex: dataColumn) - - if let pendingChange, let highlight { - return String( - format: String(localized: "Row %d, column %d: %@, %@, highlighted where %@"), - row + 1, dataColumn + 1, text, pendingChange, highlight - ) - } - if let pendingChange { - return String( - format: String(localized: "Row %d, column %d: %@, %@"), - row + 1, dataColumn + 1, text, pendingChange - ) - } - if let highlight { - return String( - format: String(localized: "Row %d, column %d: %@, highlighted where %@"), - row + 1, dataColumn + 1, text, highlight - ) - } - return String(format: String(localized: "Row %d, column %d: %@"), row + 1, dataColumn + 1, text) - } - - /// Read through rather than stored, so an edit, an undo or a display-format change is spoken - /// without anything having to remember to stand this view down. - private var text: String { - coordinator?.accessibilityText(row: row, columnIndex: dataColumn) ?? "" - } -} diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift b/apple/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift deleted file mode 100644 index 7507ab145f..0000000000 --- a/apple/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// DataGridCellAccessoryDelegate.swift -// TablePro -// - -import Foundation - -@MainActor -protocol DataGridCellAccessoryDelegate: AnyObject { - func dataGridCellDidClickFKArrow(row: Int, columnIndex: Int, intent: ReferenceOpenIntent) - func dataGridCellDidClickChevron(row: Int, columnIndex: Int) - func dataGridCellDidDoubleClick(row: Int, columnIndex: Int) -} diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/apple/TablePro/Views/Results/Cells/DataGridCellRegistry.swift deleted file mode 100644 index 03950b6e61..0000000000 --- a/apple/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// DataGridCellRegistry.swift -// TablePro -// - -import AppKit -import Combine -import Foundation - -@MainActor -final class DataGridCellRegistry { - weak var accessoryDelegate: DataGridCellAccessoryDelegate? - - private(set) var nullDisplayString: String - private(set) var showsInvisibleCharacters: Bool - private(set) var palette: DataGridCellPalette - private(set) var scope: ThemeScope - private var paletteKey: PaletteKey - private var settingsCancellable: AnyCancellable? - - private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCellView") - - private struct PaletteKey: Equatable { - let revision: Int - let generation: Int - let appearance: NSAppearance.Name - } - - init(scope: ThemeScope = .live) { - self.scope = scope - nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay - showsInvisibleCharacters = AppSettingsManager.shared.dataGrid.showInvisibleCharacters - let appearance = NSApp?.effectiveAppearance ?? NSAppearance.currentDrawing() - paletteKey = Self.paletteKey(for: appearance) - palette = scope.dataGridCellPalette(in: appearance) - settingsCancellable = AppEvents.shared.dataGridSettingsChanged - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay - self?.showsInvisibleCharacters = AppSettingsManager.shared.dataGrid.showInvisibleCharacters - } - } - - /// Asked by every row as it draws: the tints resolve in the appearance the row draws in, which - /// only the draw knows, and the key makes the call a comparison until the theme or it moves. - func preparePalette(in appearance: NSAppearance) { - let key = Self.paletteKey(for: appearance) - guard key != paletteKey else { return } - paletteKey = key - palette = scope.dataGridCellPalette(in: appearance) - } - - /// A fixed scope never moves the key, so a new one rebuilds the palette here. - func adopt(_ scope: ThemeScope, in appearance: NSAppearance) { - self.scope = scope - paletteKey = Self.paletteKey(for: appearance) - palette = scope.dataGridCellPalette(in: appearance) - } - - /// The engine's counters for every scope: a system color change reaches every scope through it. - private static func paletteKey(for appearance: NSAppearance) -> PaletteKey { - let change = ThemeEngine.shared.change - return PaletteKey(revision: change.revision, generation: change.generation, appearance: appearance.name) - } - - func makeRowNumberCell( - in tableView: NSTableView, - row: Int, - pageOffset: Int, - cachedRowCount: Int, - visualState: RowVisualState - ) -> NSView { - let cellView: NSTableCellView - let cell: NSTextField - - if let reused = tableView.makeView(withIdentifier: rowNumberCellIdentifier, owner: nil) as? NSTableCellView, - let textField = reused.textField { - cellView = reused - cell = textField - cell.font = ThemeEngine.shared.dataGridFonts.rowNumber - } else { - cellView = DataGridRowNumberCellView() - cellView.identifier = rowNumberCellIdentifier - - cell = NSTextField(labelWithString: "") - cell.alignment = .right - cell.font = ThemeEngine.shared.dataGridFonts.rowNumber - cell.tag = DataGridFontVariant.rowNumber - cell.translatesAutoresizingMaskIntoConstraints = false - - cellView.textField = cell - cellView.addSubview(cell) - cell.alphaValue = 0 - - NSLayoutConstraint.activate([ - cell.leadingAnchor.constraint( - equalTo: cellView.leadingAnchor, - constant: DataGridMetrics.cellHorizontalInset - ), - cell.trailingAnchor.constraint( - equalTo: cellView.trailingAnchor, - constant: -DataGridMetrics.cellHorizontalInset - ), - cell.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), - ]) - } - - cell.textColor = rowNumberColor(for: visualState) - guard row >= 0 && row < cachedRowCount else { - cell.stringValue = "" - return cellView - } - - let displayNumber = row + pageOffset + 1 - cell.stringValue = "\(displayNumber)" - cellView.setAccessibilityLabel(String(format: String(localized: "Row %d"), displayNumber)) - cellView.setAccessibilityRowIndexRange(NSRange(location: row, length: 1)) - - return cellView - } - - func rowNumberColor(for visualState: RowVisualState) -> NSColor { - palette.rowNumberColor(for: visualState, onEmphasizedSelection: false) - } -} - -/// The row-number cell, mounted under the pinned row gutter, which paints every visible number. -/// -/// Its number would show beside the gutter while a sideways bounce slides the column out from under -/// it, so the field draws at zero alpha. Zero alpha keeps the field in the accessibility tree, where -/// `isHidden` would drop it. It is also the only view a row drag can build its image from, so the -/// drag image is taken with the field shown. -final class DataGridRowNumberCellView: NSTableCellView { - override var draggingImageComponents: [NSDraggingImageComponent] { - guard let textField else { return super.draggingImageComponents } - let alpha = textField.alphaValue - textField.alphaValue = 1 - defer { textField.alphaValue = alpha } - return super.draggingImageComponents - } -} diff --git a/apple/TablePro/Views/Results/Comparison/ResultComparisonGrid.swift b/apple/TablePro/Views/Results/Comparison/ResultComparisonGrid.swift index 4ebfeee44d..8804aabd50 100644 --- a/apple/TablePro/Views/Results/Comparison/ResultComparisonGrid.swift +++ b/apple/TablePro/Views/Results/Comparison/ResultComparisonGrid.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import SwiftUI import TableProPluginKit @@ -12,7 +13,7 @@ internal struct ResultComparisonGridLine: Equatable { } @MainActor -internal final class ResultComparisonGridModel: DataGridViewDelegate { +internal final class ResultComparisonGridModel: ObservableObject { internal static let leadingColumnCount = 2 internal struct LoadKey: Hashable { @@ -20,14 +21,24 @@ internal final class ResultComparisonGridModel: DataGridViewDelegate { let filter: ResultComparisonFilter } + internal let grid = HeldRowsGridAdapter(configuration: ResultComparisonGridModel.configuration) private(set) var lines: [ResultComparisonGridLine] = [] - private(set) var tableRows = TableRows() - private var states: [RowVisualState] = [] private var loadedKey: LoadKey? - // A grid row is a display position; the grid's own value filter can reorder it. - private weak var coordinator: TableViewCoordinator? + internal var model: DataGridModel { grid.model } + internal var tableRows: TableRows { grid.heldRows } + private static var configuration: DataGridConfiguration { + var configuration = DataGridConfiguration() + configuration.showRowNumbers = false + configuration.supportsValueFilter = false + configuration.supportsColumnSort = false + configuration.appliesRowCopyFormat = true + configuration.commands = [.freezeColumns, .displayAs] + return configuration + } + + @discardableResult internal func ensureLoaded( key: LoadKey, comparison: ResultComparison, @@ -36,10 +47,23 @@ internal final class ResultComparisonGridModel: DataGridViewDelegate { pairing: ResultColumnPairing ) -> TableRows { guard loadedKey != key else { return tableRows } + // A row is a line of this load, so a selection made against another load names nothing here. + if loadedKey != nil { + grid.model.clearSelection() + } loadedKey = key lines = Self.lines(for: comparison.entries.filter { key.filter.matches($0.kind) }) - states = lines.map(Self.visualState(for:)) - tableRows = Self.tableRows(for: lines, left: left, right: right, pairing: pairing) + let rows = Self.tableRows(for: lines, left: left, right: right, pairing: pairing) + var states: [RowID: RowVisualState] = [:] + for (row, line) in zip(rows.rows, lines) { + states[row.id] = Self.visualState(for: line) + } + grid.visualStates = states + // Display formats are kept by data index, so another pairing's columns start with raw values. + if rows.columns != tableRows.columns || rows.columnTypes != tableRows.columnTypes { + grid.model.store.displayFormats = [] + } + grid.setRows(rows) return tableRows } @@ -137,26 +161,6 @@ internal final class ResultComparisonGridModel: DataGridViewDelegate { ) -> ColumnType { CompareRowGridModel.displayType(source: left.columnType(at: pair.left), target: right.columnType(at: pair.right)) } - - private func lineIndex(forDisplayRow row: Int) -> Int? { - let index = coordinator?.tableRowsIndex(forDisplayRow: row) ?? row - return lines.indices.contains(index) ? index : nil - } - - // MARK: - DataGridViewDelegate - - internal func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { - coordinator = tableViewCoordinator - } - - internal func dataGridVisualState(forRow row: Int) -> RowVisualState? { - guard let index = lineIndex(forDisplayRow: row), states.indices.contains(index) else { return nil } - return states[index] - } - - internal func dataGridCopyRows(_ indices: Set) { - coordinator?.copyRowsAsTSV(at: indices, includeHeaders: false) - } } internal struct ResultComparisonGrid: View { @@ -166,38 +170,19 @@ internal struct ResultComparisonGrid: View { internal let pairing: ResultColumnPairing internal let filter: ResultComparisonFilter - @State private var model = ResultComparisonGridModel() - @State private var changeManager = AnyChangeManager(DataChangeManager()) - @State private var selectedRows: Set = [] - @State private var columnLayout = ColumnLayoutState() + @StateObject private var model = ResultComparisonGridModel() + private var key: ResultComparisonGridModel.LoadKey { + ResultComparisonGridModel.LoadKey(comparisonId: comparison.id, filter: filter) + } + + // task(id:) runs with this body's values; onChange(of:) would run with the previous body's (measured). internal var body: some View { - let key = ResultComparisonGridModel.LoadKey(comparisonId: comparison.id, filter: filter) - let model = model - let comparison = comparison - let left = left - let right = right - let pairing = pairing - var configuration = DataGridConfiguration() - configuration.showRowNumbers = false - configuration.supportsColumnCommands = false - configuration.supportsValueFilter = false - configuration.supportsColumnSort = false - return DataGridView( - tableRowsProvider: { - model.ensureLoaded(key: key, comparison: comparison, left: left, right: right, pairing: pairing) - }, - changeManager: changeManager, - isEditable: false, - configuration: configuration, - delegate: model, - selectedRowIndices: $selectedRows, - sortState: .constant(SortState()), - columnLayout: $columnLayout, - contentRevision: key.hashValue - ) - .onChange(of: key) { _ in - selectedRows = [] - } + DataGridView(model: model.model) + .task(id: key) { load() } + } + + private func load() { + model.ensureLoaded(key: key, comparison: comparison, left: left, right: right, pairing: pairing) } } diff --git a/apple/TablePro/Views/Results/DataGridBodyChrome.swift b/apple/TablePro/Views/Results/DataGridBodyChrome.swift deleted file mode 100644 index d8545cc094..0000000000 --- a/apple/TablePro/Views/Results/DataGridBodyChrome.swift +++ /dev/null @@ -1,225 +0,0 @@ -// -// DataGridBodyChrome.swift -// TablePro -// - -import AppKit -import TableProTheme - -/// The single owner of the data grid body's column separators and of the table's background, as -/// `SortableHeaderChrome` is for the header. -/// -/// `NSTableView` draws vertical grid lines by keeping one separator view per column as its own -/// subview, and it re-sorts that whole subview list on every layout pass. That is O(columns) views -/// and O(columns squared) work per pass: measured at 518ms for a single pass on a 500-column result, -/// against 0.03ms with the mask cleared. Adding or removing any subview of the table view, which is -/// exactly what opening the inline cell editor does, forces such a pass. So the grid clears -/// `gridStyleMask` and draws the separators itself (#2381). -/// -/// Where they are drawn follows AppKit's own split rather than inventing one. A row view covers -/// whatever the table view draws underneath it, which is why AppKit puts its horizontal separator -/// inside `NSTableRowView.drawSeparator(in:)` and can only put a vertical one in a view above the -/// rows. So a row draws the separators crossing it, and the table view draws only the area below the -/// last row, where nothing covers it. -@MainActor -enum DataGridBodyChrome { - static let separatorThickness: CGFloat = 1 - - /// Where the separators fall: one standing at the leading edge of every presented column the - /// rect reaches. - /// - /// The leading edge, not the trailing one, is where AppKit put it, and taking the boundary from - /// the column rather than from a fixed step keeps the row-number column's edge and leaves the - /// pool's spacers without one. - /// - /// Pure, and separate from the drawing, so a test can measure the geometry against - /// `rect(ofColumn:)` without a graphics context. - /// - /// - Parameters: - /// - rect: the area being drawn, in the coordinate space of `view`. - /// - view: the view drawing, which supplies the space the column rects are converted into. - static func separatorRects( - in rect: NSRect, - of view: NSView, - tableView: NSTableView, - presentsColumn: (Int) -> Bool - ) -> [NSRect] { - guard rect.width > 0, rect.height > 0 else { return [] } - let inTableView = view.convert(rect, to: tableView) - return tableView.columnIndexes(in: inTableView).compactMap { tableColumnIndex in - guard presentsColumn(tableColumnIndex) else { return nil } - let columnRect = view.convert(tableView.rect(ofColumn: tableColumnIndex), from: tableView) - guard columnRect.width > 0 else { return nil } - let separator = NSRect( - x: columnRect.minX - separatorThickness, - y: rect.minY, - width: separatorThickness, - height: rect.height - ) - return separator.intersects(rect) ? separator : nil - } - } - - static func drawColumnSeparators( - in rect: NSRect, - of view: NSView, - tableView: NSTableView, - presentsColumn: (Int) -> Bool - ) { - let separators = separatorRects(in: rect, of: view, tableView: tableView, presentsColumn: presentsColumn) - guard !separators.isEmpty else { return } - tableView.gridColor.setFill() - separators.forEach { $0.fill() } - } - - // MARK: - Row backgrounds - - /// One row's band in the space of the view asking: a row of the table, or one of the bands past - /// the last row that the alternation continues through. - struct RowBand: Equatable { - let row: Int - let rect: NSRect - let isTableRow: Bool - } - - /// Every band the rect reaches: the table's rows, then bands one row apart past the last row, - /// numbered on from it, which is where `NSTableView` continues the alternation. - /// - /// - Parameters: - /// - rect: the area being drawn, in the coordinate space of `view`. - /// - view: the view drawing, which supplies the space the bands are converted into. - static func rowBands(in rect: NSRect, of view: NSView, tableView: NSTableView) -> [RowBand] { - guard rect.width > 0, rect.height > 0 else { return [] } - let inTableView = view.convert(rect, to: tableView) - let rows = tableView.rows(in: inTableView) - var bands = (rows.location..<(rows.location + rows.length)).map { row in - RowBand(row: row, rect: view.convert(tableView.rect(ofRow: row), from: tableView), isTableRow: true) - } - - let pitch = tableView.rowHeight + tableView.intercellSpacing.height - let rowCount = tableView.numberOfRows - let lastRowBottom = rowCount > 0 ? tableView.rect(ofRow: rowCount - 1).maxY : tableView.bounds.minY - guard pitch > 0, inTableView.maxY > lastRowBottom else { return bands } - - var index = max(0, Int(((inTableView.minY - lastRowBottom) / pitch).rounded(.down))) - while lastRowBottom + CGFloat(index) * pitch < inTableView.maxY { - let band = NSRect( - x: inTableView.minX, - y: lastRowBottom + CGFloat(index) * pitch, - width: inTableView.width, - height: pitch - ) - bands.append(RowBand(row: rowCount + index, rect: view.convert(band, from: tableView), isTableRow: false)) - index += 1 - } - return bands - } - - /// Set on the table rather than read at each draw: AppKit reads the table's own - /// `backgroundColor` for the area an elastic scroll uncovers, and the separators draw in its - /// `gridColor`. A theme that declares neither leaves both at the table's own defaults, - /// `controlBackgroundColor` and `separatorColor`. - static func applyThemeColors(to tableView: NSTableView, scope: ThemeScope) { - let resolved = scope.resolved - let gridColor = resolved[.gridLine] - if tableView.gridColor != gridColor { - tableView.gridColor = gridColor - } - let background = resolved[.gridBackground] - if tableView.backgroundColor != background { - tableView.backgroundColor = background - } - } - - /// The alternate stripe a row carries, or nil when the table does not alternate, where a row - /// shows the table's own background. - /// - /// The theme's pair when it declares one, otherwise `NSColor.alternatingContentBackgroundColors`, - /// which is the pair `NSTableView` itself hands its row views. - static func stripeColor(forRow row: Int, of tableView: NSTableView, scope: ThemeScope) -> NSColor? { - guard tableView.usesAlternatingRowBackgroundColors else { return nil } - let resolved = scope.resolved - return row.isMultiple(of: 2) ? resolved[.gridBackground] : resolved[.gridAlternateRow] - } - - // MARK: - Selection - - static func selectionSlot(emphasized: Bool) -> ThemeSlot { - emphasized ? .gridSelection : .gridInactiveSelection - } - - /// What a selected row shows, for the surfaces that paint it themselves: the pinned row gutter - /// and a selected column's heading. Without a theme override it is the color `NSTableRowView` - /// fills with, so they match the rows AppKit draws. - static func selectionColor(emphasized: Bool, scope: ThemeScope) -> NSColor { - themedSelectionColor(emphasized: emphasized, scope: scope) - ?? scope.resolved[selectionSlot(emphasized: emphasized)] - } - - static func isSelectionThemed(emphasized: Bool, scope: ThemeScope) -> Bool { - scope.resolved.isOverridden(selectionSlot(emphasized: emphasized)) - } - - /// Nil unless the theme declares its own selection; a row then leaves the selection to - /// `NSTableRowView`, which follows Accent and focus. - /// Opaque: the row, the gutter and a heading sit on three different bases, so a translucent - /// selection is laid over the grid background once and all three paint that. - static func themedSelectionColor(emphasized: Bool, scope: ThemeScope) -> NSColor? { - let resolved = scope.resolved - let slot = selectionSlot(emphasized: emphasized) - guard resolved.isOverridden(slot) else { return nil } - guard let selection = resolved.definition.value(for: slot).themedColor, !selection.isOpaque else { - return resolved[slot] - } - if let background = resolved.definition.value(for: .gridBackground).themedColor { - return selection.composited(over: background).nsColor - } - let nativeBackground = resolved[.gridBackground] - return NSColor(name: nil) { appearance in - var base = ThemeColor.white - appearance.performAsCurrentDrawingAppearance { - base = ThemeColor(nativeBackground) - } - return selection.composited(over: base.opaque).nsColor - } - } - - /// What a row view lays down before its tint and selection: its stripe, or the table's own - /// background when the table does not alternate. - /// - /// Painted by `DataGridRowView` itself rather than by `NSTableRowView`, which paints the system - /// stripes whatever the theme declares. - static func rowBackgroundColor(forRow row: Int, of tableView: NSTableView, scope: ThemeScope) -> NSColor { - stripeColor(forRow: row, of: tableView, scope: scope) ?? tableView.backgroundColor - } - - /// Lays `background` down and blends `layers` over it bottom to top, which is the colour a row - /// view's fills end up showing over the table beneath them. Opaque whatever the layers are, - /// because the background replaces the pixels rather than blending over them. - static func fill(_ rect: NSRect, with layers: [NSColor], over background: NSColor) { - background.setFill() - rect.fill() - for layer in layers { - layer.setFill() - rect.fill(using: .sourceOver) - } - } - - /// The table's background: its own colour, and past the last row the alternate stripes blended - /// over it once. - /// - /// Drawn here rather than by `NSTableView.drawBackground(inClipRect:)`, which blends the stripe in - /// twice past the last row. Measured on macOS 27 in dark mode, its empty rows read 52 against the - /// rows' 40, so the empty area striped brighter than the rows above it, and nothing that paints - /// the stripe once could match it, the pinned row gutter included. Under the rows only the - /// table's colour goes down, because a row view blends its own stripe over exactly that. - static func drawTableBackground(in rect: NSRect, of tableView: NSTableView, scope: ThemeScope) { - tableView.backgroundColor.setFill() - rect.fill() - for band in rowBands(in: rect, of: tableView, tableView: tableView) where !band.isTableRow { - guard let stripe = stripeColor(forRow: band.row, of: tableView, scope: scope) else { continue } - stripe.setFill() - band.rect.intersection(rect).fill(using: .sourceOver) - } - } -} diff --git a/apple/TablePro/Views/Results/DataGridCellFactory.swift b/apple/TablePro/Views/Results/DataGridCellFactory.swift deleted file mode 100644 index 0e2be0be4b..0000000000 --- a/apple/TablePro/Views/Results/DataGridCellFactory.swift +++ /dev/null @@ -1,262 +0,0 @@ -// -// DataGridCellFactory.swift -// TablePro -// - -import AppKit -import Foundation -import TableProPluginKit -import TableProTextEngine - -@MainActor -final class DataGridCellFactory { - private static let minColumnWidth: CGFloat = 60 - private static let maxColumnWidth: CGFloat = 800 - private static let minFitToContentWidth: CGFloat = 300 - private static let fitToContentViewportFraction: CGFloat = 0.5 - private static let sampleRowCount = 30 - private static let wideResultSampleRowCount = 10 - private static let wideResultColumnCount = 50 - private static let fitToContentValueBudget = 200_000 - private static let maxMeasureChars = 50 - private static let headerPadding: CGFloat = 48 - private static let headerCharWidthRatio: CGFloat = 0.75 - - private struct ColumnWidthBudget { - let cap: CGFloat - let measuredCharLimit: Int - let sampledRows: Int - } - - static func fitToContentCap(availableWidth: CGFloat) -> CGFloat { - let proportional = availableWidth * fitToContentViewportFraction - return min(max(proportional, minFitToContentWidth), maxColumnWidth) - } - - func calculateOptimalColumnWidth( - for columnName: String, - columnIndex: Int, - tableRows: TableRows, - accessory: DataGridCellAccessory = .none, - displayFormat: ValueDisplayFormat? = nil, - databaseType: DatabaseType? = nil, - isLargeDataset: Bool = false, - nullDisplayString: String? = nil, - revealsInvisibleCharacters: Bool = false - ) -> CGFloat { - measureColumnWidth( - for: columnName, - columnIndex: columnIndex, - tableRows: tableRows, - accessory: accessory, - displayFormat: displayFormat, - databaseType: databaseType, - isLargeDataset: isLargeDataset, - nullDisplayString: nullDisplayString, - revealsInvisibleCharacters: revealsInvisibleCharacters, - budget: ColumnWidthBudget( - cap: Self.maxColumnWidth, - measuredCharLimit: Self.maxMeasureChars, - sampledRows: Self.automaticSampleRowCount(columnCount: tableRows.columns.count) - ) - ) - } - - func calculateFitToContentWidth( - for columnName: String, - columnIndex: Int, - tableRows: TableRows, - availableWidth: CGFloat, - accessory: DataGridCellAccessory = .none, - displayFormat: ValueDisplayFormat? = nil, - databaseType: DatabaseType? = nil, - isLargeDataset: Bool = false, - nullDisplayString: String? = nil, - revealsInvisibleCharacters: Bool = false, - fittedColumnCount: Int = 1 - ) -> CGFloat { - let cap = Self.fitToContentCap(availableWidth: availableWidth) - let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth - let measuredCharLimit = charWidth > 0 ? Int((cap / charWidth).rounded(.up)) : Self.maxMeasureChars - - return measureColumnWidth( - for: columnName, - columnIndex: columnIndex, - tableRows: tableRows, - accessory: accessory, - displayFormat: displayFormat, - databaseType: databaseType, - isLargeDataset: isLargeDataset, - nullDisplayString: nullDisplayString, - revealsInvisibleCharacters: revealsInvisibleCharacters, - budget: ColumnWidthBudget( - cap: cap, - measuredCharLimit: measuredCharLimit, - sampledRows: Self.fitSampleRowCount(fittedColumnCount: fittedColumnCount) - ) - ) - } - - /// The first paint samples, because it measures every column of the result before the grid can - /// draw a single row. - private static func automaticSampleRowCount(columnCount: Int) -> Int { - columnCount > wideResultColumnCount ? wideResultSampleRowCount : sampleRowCount - } - - /// An explicit fit reads the page rather than a sample, because a sample that stepped over the - /// longest value is the reason the user asked twice. The budget is on the whole gesture, not on - /// one column, so fitting a single column covers any page a user can configure while fitting - /// every column of a wide result stays a bounded amount of formatting on the main thread. - private static func fitSampleRowCount(fittedColumnCount: Int) -> Int { - max(sampleRowCount, fitToContentValueBudget / max(1, fittedColumnCount)) - } - - private func measureColumnWidth( - for columnName: String, - columnIndex: Int, - tableRows: TableRows, - accessory: DataGridCellAccessory, - displayFormat: ValueDisplayFormat?, - databaseType: DatabaseType?, - isLargeDataset: Bool, - nullDisplayString: String?, - revealsInvisibleCharacters: Bool, - budget: ColumnWidthBudget - ) -> CGFloat { - let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth - let headerCharCount = (columnName as NSString).length - var maxWidth = CGFloat(headerCharCount) * charWidth * Self.headerCharWidthRatio + Self.headerPadding - - let totalRows = tableRows.count - let step = max(1, totalRows / max(1, budget.sampledRows)) - - let columnType = columnIndex < tableRows.columnTypes.count - ? tableRows.columnTypes[columnIndex] - : nil - let resolvedNullDisplayString = nullDisplayString - ?? AppSettingsManager.shared.dataGrid.nullDisplay - - for i in stride(from: 0, to: totalRows, by: step) { - let rawValue = tableRows.value(at: i, column: columnIndex) - let formattedValue = CellDisplayFormatter.format( - rawValue, - columnType: columnType, - displayFormat: displayFormat, - databaseType: databaseType - ) ?? "" - let placeholder = DataGridCellContent.placeholder( - for: rawValue, - isAbsent: tableRows.isAbsent(row: i, column: columnIndex), - unloadedByteCount: tableRows.unloadedByteCount(row: i, column: columnIndex) - ) - let value = DataGridCellContent.resolvedDisplayText( - formattedValue, - placeholder: placeholder, - isLargeDataset: isLargeDataset, - nullDisplayString: resolvedNullDisplayString - ) - - let charCount = min((value as NSString).length, budget.measuredCharLimit) - let markedWidth = revealsInvisibleCharacters && placeholder == nil - ? Self.markedWidth(of: value, length: charCount) - : nil - let textWidth = markedWidth ?? CGFloat(charCount) * charWidth - maxWidth = max(maxWidth, textWidth + accessory.measurementPadding) - - if maxWidth >= budget.cap { - return budget.cap - } - } - - return min(max(maxWidth, Self.minColumnWidth), budget.cap) - } - - // A mark is wider than the character it stands for, so a value holding one is laid out. - private static func markedWidth(of value: String, length: Int) -> CGFloat? { - let text = value as NSString - var index = 0 - while index < length { - let unit = text.character(at: index) - if unit == 0x0D || SpecialCharacter.mayBeSpecial(unit) { break } - index += 1 - } - guard index < length else { return nil } - let font = ThemeEngine.shared.dataGridFonts.regular - let marked = SpecialCharacterLine( - NSAttributedString(string: text.substring(to: length), attributes: [.font: font]), - style: SpecialCharacterStyle(marksLineBreaks: true) - ) - return CGFloat(CTLineGetTypographicBounds(marked.line, nil, nil, nil)) - } -} - -extension NSFont { - func withTraits(_ traits: NSFontDescriptor.SymbolicTraits) -> NSFont { - let descriptor = fontDescriptor.withSymbolicTraits(traits) - return NSFont(descriptor: descriptor, size: pointSize) ?? self - } -} - -internal extension String { - var containsLineBreak: Bool { - let nsString = self as NSString - let length = nsString.length - guard length > 0 else { return false } - for i in 0.. String { - let nsString = self as NSString - let length = nsString.length - guard length > 0 else { return self } - - var mutable: NSMutableString? - var copiedUpTo = 0 - for i in 0..= length || nsString.character(at: i + 1) != 0x0A { - continue - } - - if mutable == nil { - mutable = NSMutableString(capacity: length) - } - if i > copiedUpTo { - mutable?.append(nsString.substring(with: NSRange(location: copiedUpTo, length: i - copiedUpTo))) - } - mutable?.append(" ") - copiedUpTo = i + 1 - } - - guard let result = mutable else { return self } - if copiedUpTo < length { - result.append(nsString.substring(with: NSRange(location: copiedUpTo, length: length - copiedUpTo))) - } - return result as String - } -} diff --git a/apple/TablePro/Views/Results/DataGridColumnPool.swift b/apple/TablePro/Views/Results/DataGridColumnPool.swift deleted file mode 100644 index 1167853cf8..0000000000 --- a/apple/TablePro/Views/Results/DataGridColumnPool.swift +++ /dev/null @@ -1,352 +0,0 @@ -// -// DataGridColumnPool.swift -// TablePro -// - -import AppKit - -@MainActor -final class DataGridColumnPool { - private var pooledColumns: [NSTableColumn] = [] - private weak var attachedTableView: NSTableView? - - /// Columns the user hid, kept apart from the ones the window unmounts so a window slide can - /// never bring a hidden column back. - private var userHiddenIdentifiers: Set = [] - /// Slots the current result actually uses. The pool only grows, so the surplus slots past the - /// result's column count stay attached and hidden, and windowing must never mount one. - private var activeIdentifiers: Set = [] - - var totalSlots: Int { pooledColumns.count } - - func attach(to tableView: NSTableView) { - attachedTableView = tableView - } - - /// Whether the result presents this column at all, which is a different question from whether - /// the window currently has it mounted. - /// - /// `isHidden` answers both at once, and everything that asks "which columns is the user - /// looking at" (copy, find, cell navigation, size-all) means this one. Reading `isHidden` - /// there silently narrows those to the columns near the viewport. - func presentsColumn(_ column: NSTableColumn) -> Bool { - activeIdentifiers.contains(column.identifier) && !userHiddenIdentifiers.contains(column.identifier) - } - - var hasUserHiddenColumns: Bool { !userHiddenIdentifiers.isEmpty } - - /// Whether this position in `tableColumns` holds one of the columns the result presents. - /// - /// The row-number column is an attached column too, and the pool keeps the surplus slots of a - /// previously wider result attached and hidden, so no fixed position answers this. - func presentsColumn(atTableColumnIndex index: Int, in tableView: NSTableView) -> Bool { - guard index >= 0, index < tableView.tableColumns.count else { return false } - return presentsColumn(tableView.tableColumns[index]) - } - - func firstPresentedColumnIndex(in tableView: NSTableView) -> Int? { - tableView.tableColumns.firstIndex { presentsColumn($0) } - } - - func lastPresentedColumnIndex(in tableView: NSTableView) -> Int? { - tableView.tableColumns.lastIndex { presentsColumn($0) } - } - - func nextPresentedColumnIndex(after index: Int, in tableView: NSTableView) -> Int? { - let start = max(0, index + 1) - guard start < tableView.tableColumns.count else { return nil } - return tableView.tableColumns[start...].firstIndex { presentsColumn($0) } - } - - func previousPresentedColumnIndex(before index: Int, in tableView: NSTableView) -> Int? { - let end = min(max(0, index), tableView.tableColumns.count) - guard end > 0 else { return nil } - return tableView.tableColumns[.., - firstClickSortDirection: SortDirection, - supportsColumnSort: Bool = true, - supportsValueFilter: Bool = true, - minimumWidths: [String: CGFloat] = [:], - widthCalculator: (String, Int) -> CGFloat - ) -> Bool { - attach(to: tableView) - let visibleCount = schema.columnNames.count - activeIdentifiers = Set(schema.identifiers) - - growBackingPoolIfNeeded(to: visibleCount) - - let willRestoreWidths = !(savedLayout?.columnWidths.isEmpty ?? true) - let hiddenFromLayout = savedLayout?.hiddenColumns ?? [] - var comments: [NSUserInterfaceItemIdentifier: String] = [:] - var showsComments = false - var visibilityChanged = false - - for slot in 0..