diff --git a/CHANGELOG.md b/CHANGELOG.md index 533ba8869..e314d8aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Value picker on a foreign key cell, listing rows from the referenced table with a label beside the key. (#2511) - Breakdown of a query's time into server, first row and transfer, behind the toolbar's duration readout. (#2503) - Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516) +- Jump to Column in the grid, a fuzzy search over the result's columns with their type and position. (#2495) ### Changed diff --git a/TablePro/Core/Menu/EditMenuBuilder.swift b/TablePro/Core/Menu/EditMenuBuilder.swift index a814c8e86..bea09cd5f 100644 --- a/TablePro/Core/Menu/EditMenuBuilder.swift +++ b/TablePro/Core/Menu/EditMenuBuilder.swift @@ -133,6 +133,13 @@ enum EditMenuBuilder { action: #selector(MainSplitViewController.findPrevious(_:)), shortcut: .findPrevious, keyboard: keyboard + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Jump to Column…"), + action: #selector(MainSplitViewController.jumpToColumn(_:)), + shortcut: .jumpToColumn, + keyboard: keyboard ) ]) } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift index 8fe6f1408..761209f0b 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+EditMenuActions.swift @@ -87,6 +87,10 @@ extension MainSplitViewController { commandActions?.stepFindBackward() } + @objc func jumpToColumn(_ sender: Any?) { + commandActions?.showColumnJump() + } + @objc func addRow(_ sender: Any?) { commandActions?.addNewRow() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 613d8a85b..cd9e26993 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -19,6 +19,8 @@ struct MenuValidationContext: Equatable { 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 /// 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. @@ -176,6 +178,8 @@ extension MainSplitViewController: NSMenuItemValidation { return context.hasEditorForFind || (context.isConnected && context.canUseGridFindCommands) case #selector(findNext(_:)), #selector(findPrevious(_:)): return context.hasEditorForFind || context.hasActiveGridFind + case #selector(jumpToColumn(_:)): + return context.isConnected && context.canJumpToColumn case #selector(undo(_:)): return context.canUndo case #selector(redo(_:)): @@ -259,6 +263,7 @@ extension MainSplitViewController: NSMenuItemValidation { isReadOnly: actions.isReadOnly, canUseTableResultCommands: actions.canUseTableResultCommands, canUseGridFindCommands: actions.canUseGridFindCommands, + canJumpToColumn: actions.canJumpToColumn, isQueryTab: actions.isQueryTab, hasResultRows: actions.hasResultRows, isCurrentTabEditable: actions.isCurrentTabEditable, diff --git a/TablePro/Models/UI/GridColumnEntry.swift b/TablePro/Models/UI/GridColumnEntry.swift new file mode 100644 index 000000000..d70a3a8c3 --- /dev/null +++ b/TablePro/Models/UI/GridColumnEntry.swift @@ -0,0 +1,99 @@ +// +// GridColumnEntry.swift +// TablePro +// + +import Foundation + +/// One column as the Columns popover and Jump to Column both list it. +/// +/// `dataIndex` is nil for a column the user hid on a table tab, because a hidden column is not +/// fetched and so has no place in the result; it is still listed so it can be shown again. +/// `position` is the column's 1-based place among the columns the grid presents, in the order the +/// grid presents them, so a reordered column reports where the reader sees it rather than where the +/// result put it. +struct GridColumnEntry: Identifiable, Hashable, Sendable { + let id: String + let name: String + let dataIndex: Int? + let typeName: String? + let position: Int? + let isHidden: Bool + + init(name: String, dataIndex: Int?, typeName: String?, position: Int?, isHidden: Bool) { + self.id = dataIndex.map { "column-\($0)" } ?? "hidden-\(name)" + self.name = name + self.dataIndex = dataIndex + self.typeName = typeName + self.position = position + self.isHidden = isHidden + } +} + +enum GridColumnCatalog { + /// The catalog keeps one entry per physical column, and a join routinely carries two columns + /// with one name. Visibility is kept by name, so anything that counts or toggles visibility + /// reads this projection: the first entry of each name, in catalog order. + static func uniqueByName(_ entries: [GridColumnEntry]) -> [GridColumnEntry] { + var seen = Set() + return entries.filter { seen.insert($0.name).inserted } + } + + /// - Parameters: + /// - displayOrder: data indices of the presented columns in the order the grid shows them, + /// or nil when no grid is mounted, in which case the result's own order stands in. + /// - pickerColumns: every column the Columns popover offers, which on a table tab includes + /// the schema's columns the result left out because they are hidden. + static func entries( + resultColumns: [String], + columnTypes: [ColumnType], + hiddenColumns: Set, + displayOrder: [Int]?, + pickerColumns: [String] + ) -> [GridColumnEntry] { + let presented = displayOrder + ?? resultColumns.indices.filter { !hiddenColumns.contains(resultColumns[$0]) } + var positionByDataIndex: [Int: Int] = [:] + positionByDataIndex.reserveCapacity(presented.count) + for (offset, dataIndex) in presented.enumerated() { + positionByDataIndex[dataIndex] = offset + 1 + } + + var dataIndicesByName: [String: [Int]] = [:] + for (dataIndex, name) in resultColumns.enumerated() { + dataIndicesByName[name, default: []].append(dataIndex) + } + + func resultEntry(_ dataIndex: Int) -> GridColumnEntry { + let name = resultColumns[dataIndex] + let type = dataIndex < columnTypes.count ? columnTypes[dataIndex] : nil + let isHidden = hiddenColumns.contains(name) + return GridColumnEntry( + name: name, + dataIndex: dataIndex, + typeName: type.map { $0.rawType ?? $0.displayName }, + position: isHidden ? nil : positionByDataIndex[dataIndex], + isHidden: isHidden + ) + } + + var entries: [GridColumnEntry] = [] + entries.reserveCapacity(max(pickerColumns.count, resultColumns.count)) + var listedNames = Set() + for name in pickerColumns { + guard listedNames.insert(name).inserted else { continue } + guard let dataIndices = dataIndicesByName.removeValue(forKey: name) else { + entries.append(GridColumnEntry(name: name, dataIndex: nil, typeName: nil, position: nil, isHidden: true)) + continue + } + for dataIndex in dataIndices { + entries.append(resultEntry(dataIndex)) + } + } + let unlisted = dataIndicesByName.values.flatMap { $0 }.sorted() + for dataIndex in unlisted { + entries.append(resultEntry(dataIndex)) + } + return entries + } +} diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index d84ca8c92..52c25a44c 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -113,6 +113,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case refresh case export case importData + case jumpToColumn // Navigation case navigateBack @@ -155,7 +156,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow, .truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage, - .nextPage, .firstPage, .lastPage, .refresh, .export, .importData: + .nextPage, .firstPage, .lastPage, .refresh, .export, .importData, .jumpToColumn: return .dataGrid case .navigateBack, .navigateForward, .newTab, .closeTab, .closeOtherTabs, .closeTabsForOtherDatabases, .closeAllTabs, @@ -182,7 +183,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { return .editor case .previousPage, .nextPage, .firstPage, .lastPage, .addRow, .duplicateRow, .delete, .truncateTable, .previewFKReference, .saveAsFavorite, - .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters: + .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters, .jumpToColumn: return .dataGrid default: return .global @@ -234,6 +235,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .findPrevious: return String(localized: "Find Previous") case .export: return String(localized: "Export") case .importData: return String(localized: "Import") + case .jumpToColumn: return String(localized: "Jump to Column") case .quickSwitcher: return String(localized: "Open Quickly") case .previousPage: return String(localized: "Previous Page") case .nextPage: return String(localized: "Next Page") @@ -561,6 +563,7 @@ struct KeyboardSettings: Codable, Equatable { .firstPage: .special(.upArrow, command: true, option: true), .lastPage: .special(.downArrow, command: true, option: true), .refresh: .character("r", command: true), + .jumpToColumn: .character("j", command: true, shift: true), // Navigation /// Not the Safari chord. Command+[ and Command+] are Previous/Next Page and are claimed diff --git a/TablePro/ViewModels/ColumnJumpViewModel.swift b/TablePro/ViewModels/ColumnJumpViewModel.swift new file mode 100644 index 000000000..ff36a31a3 --- /dev/null +++ b/TablePro/ViewModels/ColumnJumpViewModel.swift @@ -0,0 +1,106 @@ +// +// ColumnJumpViewModel.swift +// TablePro +// + +import Foundation +import Observation + +@MainActor @Observable +final class ColumnJumpViewModel { + struct Match: Identifiable, Equatable { + let entry: GridColumnEntry + let matchedIndices: [Int] + + var id: String { entry.id } + } + + let entries: [GridColumnEntry] + private(set) var matches: [Match] = [] + var selectedId: String? + var searchText: String { + didSet { refilter() } + } + + @ObservationIgnored private var rankedQuery: String + + /// - Parameter cursorColumnIndex: the data index under the grid's cell cursor, which the empty + /// list opens on so Return with nothing typed goes nowhere the reader is not already looking. + init(entries: [GridColumnEntry], initialQuery: String = "", cursorColumnIndex: Int? = nil) { + self.entries = entries + let query = initialQuery.trimmingCharacters(in: .whitespaces) + self.searchText = initialQuery + self.rankedQuery = query + let ranked = Self.rank(entries, query: query) + self.matches = ranked + let cursorMatch = cursorColumnIndex.flatMap { index in + ranked.first { $0.entry.dataIndex == index && !$0.entry.isHidden } + } + self.selectedId = (cursorMatch ?? ranked.first)?.id + } + + var presentedColumnCount: Int { + entries.filter { !$0.isHidden }.count + } + + var selectedEntry: GridColumnEntry? { + matches.first { $0.id == selectedId }?.entry + } + + func moveSelection(by offset: Int) { + guard !matches.isEmpty else { return } + let current = selectedId.flatMap { id in matches.firstIndex { $0.id == id } } ?? 0 + let next = min(max(current + offset, 0), matches.count - 1) + selectedId = matches[next].id + } + + func listHeight(rowHeight: CGFloat, maxVisibleRows: Int) -> CGFloat { + CGFloat(min(max(matches.count, 1), maxVisibleRows)) * rowHeight + } + + /// A query that ranks the same rows keeps the selection; a new query moves it to the best + /// match, because the row the reader had was chosen against a list that no longer exists. + private func refilter() { + let query = searchText.trimmingCharacters(in: .whitespaces) + guard query != rankedQuery else { return } + rankedQuery = query + matches = Self.rank(entries, query: query) + selectedId = matches.first?.id + } + + private static func rank(_ entries: [GridColumnEntry], query: String) -> [Match] { + let ordered = entries.enumerated().map { (entry: $0.element, catalogOrder: $0.offset) } + guard !query.isEmpty else { + return ordered + .sorted { precedes($0, $1) } + .map { Match(entry: $0.entry, matchedIndices: []) } + } + return ordered + .compactMap { candidate -> (match: Match, score: Int, candidate: (entry: GridColumnEntry, catalogOrder: Int))? in + guard let fuzzy = FuzzyMatcher.match(query: query, candidate: candidate.entry.name) else { return nil } + return (Match(entry: candidate.entry, matchedIndices: fuzzy.matchedIndices), fuzzy.score, candidate) + } + .sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + return precedes(lhs.candidate, rhs.candidate) + } + .map { $0.match } + } + + /// Presented columns first, in the order the grid shows them; hidden ones after, in catalog order. + private static func precedes( + _ lhs: (entry: GridColumnEntry, catalogOrder: Int), + _ rhs: (entry: GridColumnEntry, catalogOrder: Int) + ) -> Bool { + switch (lhs.entry.position, rhs.entry.position) { + case let (left?, right?): + return left < right + case (.some, nil): + return true + case (nil, .some): + return false + case (nil, nil): + return lhs.catalogOrder < rhs.catalogOrder + } + } +} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 8c7b9014a..ddd85f4d7 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -763,10 +763,7 @@ struct MainEditorContentView: View { Divider() } - if tab.tabType == .query && !resolvedRows.columns.isEmpty - && resolvedRows.rows.isEmpty && tab.execution.lastExecutedAt != nil - && !coordinator.tabExecution.isExecuting(tab.id) && !tab.filterState.hasAppliedFilters - { + if showsEmptyResultView(tab: tab, rows: resolvedRows) { emptyResultView(executionTime: tab.display.activeResultSet?.executionTime ?? tab.execution.executionTime) } else { dataGridView(tab: tab) @@ -841,6 +838,14 @@ struct MainEditorContentView: View { ) } + /// A query that came back with columns and no rows shows this instead of a grid, so anything + /// that offers a jump into the grid reads the same condition. + private func showsEmptyResultView(tab: QueryTab, rows: TableRows) -> Bool { + tab.tabType == .query && !rows.columns.isEmpty + && rows.rows.isEmpty && tab.execution.lastExecutedAt != nil + && !coordinator.tabExecution.isExecuting(tab.id) && !tab.filterState.hasAppliedFilters + } + private func emptyResultView(executionTime: TimeInterval?) -> some View { let description: String? = executionTime.map { String(format: "%.3fs", $0) } return ContentUnavailableView { @@ -973,11 +978,15 @@ struct MainEditorContentView: View { filterState: tab.filterState, columnState: StatusBarColumnState( hidden: tab.columnLayout.hiddenColumns, - all: coordinator.columnsForVisibilityPicker(for: tab, resultColumns: resolvedRows.columns), + columns: coordinator.columnCatalog(for: tab, resultRows: resolvedRows), onToggle: { coordinator.toggleColumnVisibility($0) }, onShowAll: { coordinator.showAllColumns() }, onHideAll: { coordinator.hideAllColumns($0) }, - onReset: { coordinator.resetColumns() } + onReset: { coordinator.resetColumns() }, + onJumpToColumn: tab.display.resultsViewMode == .data && !tab.display.isResultsCollapsed + && !showsEmptyResultView(tab: tab, rows: resolvedRows) + ? { coordinator.showColumnJump(seededWith: $0) } + : nil ), paginationCallbacks: PaginationCallbacks( onFirst: onFirstPage, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnJump.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnJump.swift new file mode 100644 index 000000000..de06db3f7 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnJump.swift @@ -0,0 +1,95 @@ +// +// MainContentCoordinator+ColumnJump.swift +// TablePro +// + +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. +struct ColumnJumpOrigin { + let tabId: UUID + weak var grid: TableViewCoordinator? +} + +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. + func columnCatalog(for tab: QueryTab, resultRows: TableRows) -> [GridColumnEntry] { + let mountedGrid = tab.id == tabManager.selectedTabId ? dataTabDelegate?.tableViewCoordinator : nil + return GridColumnCatalog.entries( + resultColumns: resultRows.columns, + columnTypes: resultRows.columnTypes, + hiddenColumns: tab.columnLayout.hiddenColumns, + displayOrder: mountedGrid?.visibleColumnDataIndices(), + 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. + var hasMountedDataGrid: Bool { + guard let tab = tabManager.selectedTab, !tab.display.isResultsCollapsed, + let grid = dataTabDelegate?.tableViewCoordinator else { return false } + return grid.tableView?.window != nil + } + + /// 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. + func showColumnJump(seededWith query: String = "") { + guard let quickSwitcherPanel else { return } + guard !quickSwitcherPanel.isPresenting(Self.columnJumpPanelIdentity) else { + quickSwitcherPanel.dismiss() + return + } + guard hasMountedDataGrid, let tab = tabManager.selectedTab, + let grid = dataTabDelegate?.tableViewCoordinator 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 panelView = ColumnJumpPanelView( + entries: entries, + initialQuery: query, + cursorColumnIndex: grid.focusedDataColumnIndex, + onCommit: { [weak self] entry in + self?.quickSwitcherPanel?.dismiss() + self?.jumpToColumn(entry, from: origin) + } + ) + 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. + 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 } + if entry.isHidden { + guard tab.columnLayout.hiddenColumns.contains(entry.name) else { return } + grid.pendingColumnJump = PendingColumnJump( + name: entry.name, + dataIndex: entry.dataIndex, + tableKey: grid.columnLayoutKey, + awaitsResultReplacement: tab.tabType == .table + ) + showColumn(entry.name) + return + } + guard let dataIndex = entry.dataIndex, + grid.identitySchema.columnName(for: dataIndex) == entry.name else { return } + grid.jumpToColumn(dataIndex: dataIndex) + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index c2ff19db6..0166c8281 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -9,9 +9,13 @@ import AppKit import Foundation extension MainContentCoordinator { + static let openQuicklyPanelIdentity = "open-quickly" + + /// Toggles only its own panel: a Jump to Column panel on the same controller is replaced, + /// because the reader asked for objects, the same way Jump to Column replaces this one. func showQuickSwitcher() { guard let quickSwitcherPanel else { return } - guard !quickSwitcherPanel.isPresented else { + guard !quickSwitcherPanel.isPresenting(Self.openQuicklyPanelIdentity) else { quickSwitcherPanel.dismiss() return } @@ -39,7 +43,7 @@ extension MainContentCoordinator { onSelect: { [weak self] item, intent in self?.handleQuickSwitcherSelection(item, intent: intent) }, onDismiss: { [weak self] in self?.quickSwitcherPanel?.dismiss() } ) - quickSwitcherPanel.present(panelView, over: contentWindow) + quickSwitcherPanel.present(panelView, over: contentWindow, identity: Self.openQuicklyPanelIdentity) } func handleQuickSwitcherSelection(_ item: QuickSwitcherItem, intent: QuickSwitcherCommitIntent = .open) { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index a0c4afd72..c40097b21 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -439,6 +439,18 @@ final class MainContentCommandActions { return findState.isVisible && !findState.matches.isEmpty } + /// 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, + coordinator.hasMountedDataGrid, + let tab = coordinator.tabManager.selectedTab, + tab.display.resultsViewMode == .data else { return false } + let resultColumns = coordinator.tabSessionRegistry.existingTableRows(for: tab.id)?.columns ?? [] + 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. @@ -1284,6 +1296,11 @@ final class MainContentCommandActions { coordinator?.showQuickSwitcher() } + func showColumnJump() { + guard canJumpToColumn else { return } + coordinator?.showColumnJump() + } + /// The window presents this one. It is a window command wherever it is invoked from, and /// keeping a copy of the presentation here would give one window two owners for one popover. func openConnectionSwitcher() { diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index 4471b810f..b64262d75 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -80,11 +80,20 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { private var panel: QuickSwitcherPanel? private var anchor: Anchor? + private var presentedIdentity: String? var isPresented: Bool { panel != nil } - func present(_ content: some View, over parentWindow: NSWindow?) { + /// Whether the panel on screen is the one a caller named. One controller serves Open Quickly + /// and Jump to Column, so a command that toggles its own panel has to ask which one is up + /// rather than whether any is, or Command Shift J would close an Open Quickly it never opened. + func isPresenting(_ identity: String) -> Bool { + panel != nil && presentedIdentity == identity + } + + func present(_ content: some View, over parentWindow: NSWindow?, identity: String? = nil) { dismiss() + presentedIdentity = identity let sizeReportingContent = content.onGeometryChange(for: CGSize.self) { proxy in proxy.size @@ -119,6 +128,7 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { panel?.contentViewController = nil panel = nil anchor = nil + presentedIdentity = nil } func windowDidResize(_ notification: Notification) { diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 315032f5a..4c93eb6fa 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -371,15 +371,15 @@ struct QuickSwitcherPanelContent: View { /// on screen saying so, that first press reads as a key that did nothing. private var footer: some View { HStack(spacing: 14) { - keyHint("\u{21A9}", String(localized: "Open")) - keyHint("\u{2318}\u{21A9}", String(localized: "New Tab")) + QuickSwitcherKeyHint(symbol: "\u{21A9}", label: String(localized: "Open")) + QuickSwitcherKeyHint(symbol: "\u{2318}\u{21A9}", label: String(localized: "New Tab")) Spacer(minLength: 0) - keyHint("\u{2318}1\u{2013}5", String(localized: "Scope")) - keyHint( - "\u{238B}", - escapeDismissesPanel ? String(localized: "Close") : String(localized: "Clear") + QuickSwitcherKeyHint(symbol: "\u{2318}1\u{2013}5", label: String(localized: "Scope")) + QuickSwitcherKeyHint( + symbol: "\u{238B}", + label: escapeDismissesPanel ? String(localized: "Close") : String(localized: "Clear") ) } .padding(.horizontal, 16) @@ -397,23 +397,6 @@ struct QuickSwitcherPanelContent: View { : String(localized: "Escape clears the search text") } - private func keyHint(_ symbol: String, _ label: String) -> some View { - HStack(spacing: 4) { - Text(symbol) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .frame(minWidth: 20, minHeight: 17) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color(nsColor: .quaternarySystemFill)) - ) - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - } - } - // MARK: - Menu @ViewBuilder @@ -486,15 +469,7 @@ struct QuickSwitcherPanelContent: View { } private func highlightedName(for item: QuickSwitcherItem) -> AttributedString { - var attributed = AttributedString(item.name) - guard !item.matchedIndices.isEmpty else { return attributed } - let characterIndices = Array(attributed.characters.indices) - for index in item.matchedIndices where index < characterIndices.count { - let start = characterIndices[index] - let end = attributed.characters.index(after: start) - attributed[start.. AttributedString { + var attributed = AttributedString(name) + guard !matchedIndices.isEmpty else { return attributed } + let characterIndices = Array(attributed.characters.indices) + for index in matchedIndices where index < characterIndices.count { + let start = characterIndices[index] + let end = attributed.characters.index(after: start) + attributed[start.. Void var onMoveDown: () -> Void var onSubmit: () -> Void + var accessibilityIdentifier = "quick-switcher-search-field" func makeNSView(context: Context) -> QuickSwitcherTextField { let field = QuickSwitcherTextField() @@ -28,7 +29,7 @@ internal struct QuickSwitcherSearchField: NSViewRepresentable { field.cell?.wraps = false field.delegate = context.coordinator field.setContentHuggingPriority(.defaultLow, for: .horizontal) - field.setAccessibilityIdentifier("quick-switcher-search-field") + field.setAccessibilityIdentifier(accessibilityIdentifier) return field } diff --git a/TablePro/Views/Results/ColumnJumpPanelView.swift b/TablePro/Views/Results/ColumnJumpPanelView.swift new file mode 100644 index 000000000..5208d0c19 --- /dev/null +++ b/TablePro/Views/Results/ColumnJumpPanelView.swift @@ -0,0 +1,330 @@ +// +// ColumnJumpPanelView.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// Jump to Column: the grid's answer to Open Quickly, over the columns of the result on screen. +/// +/// It lives in the same floating panel as Open Quickly and draws with the same chrome, so the two +/// read as one family of chooser: a search field that keeps focus, a ranked list under it, and a +/// footer that says what Return and Escape will do. +struct ColumnJumpPanelView: View { + @State private var viewModel: ColumnJumpViewModel + private let onCommit: (GridColumnEntry) -> Void + + init( + entries: [GridColumnEntry], + initialQuery: String = "", + cursorColumnIndex: Int? = nil, + onCommit: @escaping (GridColumnEntry) -> Void + ) { + _viewModel = State(wrappedValue: ColumnJumpViewModel( + entries: entries, + initialQuery: initialQuery, + cursorColumnIndex: cursorColumnIndex + )) + self.onCommit = onCommit + } + + var body: some View { + ColumnJumpPanelContent(viewModel: viewModel, onCommit: onCommit) + } +} + +struct ColumnJumpPanelContent: View { + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + @Bindable var viewModel: ColumnJumpViewModel + let onCommit: (GridColumnEntry) -> Void + + @State private var keyMonitor: Any? + + var body: some View { + QuickSwitcherGlassGroup { + VStack(spacing: 0) { + inputRow + + Divider().opacity(dividerOpacity) + + results + + Divider().opacity(dividerOpacity) + + footer + } + .frame(width: QuickSwitcherMetrics.width) + .quickSwitcherSurface(cornerRadius: QuickSwitcherMetrics.cornerRadius) + } + .onAppear { installKeyMonitor() } + .onDisappear { removeKeyMonitor() } + } + + // MARK: - State + + /// The field editor owns the first Escape and branches on the raw string, so a query of + /// nothing but spaces still has something to clear. See `QuickSwitcherPanelContent`. + private var escapeDismissesPanel: Bool { + viewModel.searchText.isEmpty + } + + private var dividerOpacity: Double { + colorSchemeContrast == .increased ? 1 : 0.6 + } + + // MARK: - Input + + private var inputRow: some View { + HStack(spacing: 10) { + Image(systemName: "rectangle.split.3x1") + .font(.title2) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + + QuickSwitcherSearchField( + text: $viewModel.searchText, + placeholder: String(localized: "Jump to column…"), + onMoveUp: { viewModel.moveSelection(by: -1) }, + onMoveDown: { viewModel.moveSelection(by: 1) }, + onSubmit: { commitSelection() }, + accessibilityIdentifier: "column-jump-search-field" + ) + } + .padding(.horizontal, 18) + .frame(height: QuickSwitcherMetrics.inputRowHeight) + } + + // MARK: - Results + + @ViewBuilder + private var results: some View { + if viewModel.matches.isEmpty { + noMatchesRow + } else { + resultsList + } + } + + private var resultsList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(viewModel.matches) { match in + columnRow(match) + } + } + .padding(.vertical, QuickSwitcherMetrics.listVerticalPadding) + } + .frame(height: listHeight) + .onAppear { + if let id = viewModel.selectedId { + proxy.scrollTo(id) + } + } + .onChange(of: viewModel.selectedId) { _, newValue in + if let id = newValue { + proxy.scrollTo(id) + } + } + } + } + + private var listHeight: CGFloat { + viewModel.listHeight( + rowHeight: QuickSwitcherMetrics.rowHeight, + maxVisibleRows: QuickSwitcherMetrics.maxVisibleRows + ) + QuickSwitcherMetrics.listVerticalPadding * 2 + } + + private var noMatchesRow: some View { + Text(String(format: String(localized: "No columns match “%@”"), viewModel.searchText)) + .font(.body) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .frame(height: QuickSwitcherMetrics.rowHeight + QuickSwitcherMetrics.listVerticalPadding * 2) + } + + private func columnRow(_ match: ColumnJumpViewModel.Match) -> some View { + let entry = match.entry + let isSelected = match.id == viewModel.selectedId + let secondaryColor = isSelected ? Color.emphasizedSelectionLabel.opacity(0.85) : Color.secondary + + return HStack(spacing: 12) { + iconView(isSelected: isSelected) + + Text(QuickSwitcherRowChrome.highlightedName(entry.name, matchedIndices: match.matchedIndices)) + .font(.body) + .foregroundStyle(isSelected ? Color.emphasizedSelectionLabel : Color.primary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 8) + + if let typeName = entry.typeName { + Text(typeName) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(secondaryColor) + .lineLimit(1) + } + + if entry.isHidden { + Text(String(localized: "Hidden")) + .font(.caption2.weight(.medium)) + .foregroundStyle(secondaryColor) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) + } else if let position = entry.position { + Text(positionLabel(position)) + .font(.callout) + .foregroundStyle(secondaryColor) + .monospacedDigit() + } + + if isSelected { + Text(commitHint(for: entry)) + .font(.caption) + .foregroundStyle(secondaryColor) + } + } + .padding(.horizontal, 18) + .frame(height: QuickSwitcherMetrics.rowHeight) + .background { + if isSelected { + RoundedRectangle(cornerRadius: QuickSwitcherMetrics.rowCornerRadius, style: .continuous) + .fill(Color(nsColor: .selectedContentBackgroundColor)) + .padding(.horizontal, QuickSwitcherMetrics.rowInset) + } + } + .contentShape(Rectangle()) + .onTapGesture { + viewModel.selectedId = match.id + guard NSApp.currentEvent?.clickCount == 2 else { return } + onCommit(entry) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(entry.name)) + .accessibilityValue(Text(accessibilityValue(for: entry))) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + .accessibilityAction { onCommit(entry) } + .id(match.id) + } + + private func iconView(isSelected: Bool) -> some View { + Image(systemName: "rectangle.split.3x1") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(isSelected ? Color.emphasizedSelectionLabel : Color.secondary) + .frame(width: QuickSwitcherMetrics.iconContainerSize, height: QuickSwitcherMetrics.iconContainerSize) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + isSelected + ? Color.emphasizedSelectionLabel.opacity(0.2) + : Color(nsColor: .quaternarySystemFill) + ) + ) + .accessibilityHidden(true) + } + + private func positionLabel(_ position: Int) -> String { + String(format: String(localized: "%d of %d"), position, viewModel.presentedColumnCount) + } + + private func commitHint(for entry: GridColumnEntry) -> String { + entry.isHidden ? String(localized: "Show and Jump") : String(localized: "Jump") + } + + private func accessibilityValue(for entry: GridColumnEntry) -> String { + var parts: [String] = [] + if let typeName = entry.typeName { + parts.append(typeName) + } + if entry.isHidden { + parts.append(String(localized: "Hidden")) + } else if let position = entry.position { + parts.append(positionLabel(position)) + } + return parts.joined(separator: ", ") + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 14) { + QuickSwitcherKeyHint(symbol: "\u{21A9}", label: String(localized: "Jump")) + + Spacer(minLength: 0) + + QuickSwitcherKeyHint( + symbol: "\u{238B}", + label: escapeDismissesPanel ? String(localized: "Close") : String(localized: "Clear") + ) + } + .padding(.horizontal, 16) + .frame(height: QuickSwitcherMetrics.footerHeight) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(escapeHintLabel)) + } + + private var escapeHintLabel: String { + escapeDismissesPanel + ? String(localized: "Escape closes Jump to Column") + : String(localized: "Escape clears the search text") + } + + // MARK: - Keyboard + + private func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard event.window is QuickSwitcherPanel else { return event } + let command = QuickSwitcherKeyCommand.resolve( + characters: event.charactersIgnoringModifiers ?? "", + modifiers: event.modifierFlags, + scopeCount: 0 + ) + guard let command else { return event } + + switch command { + case let .moveSelection(offset): + viewModel.moveSelection(by: offset) + case .selectScope: + return event + case .commit: + commitSelection() + } + return nil + } + } + + private func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + } + keyMonitor = nil + } + + private func commitSelection() { + guard let entry = viewModel.selectedEntry else { return } + onCommit(entry) + } +} + +#Preview("Wide result") { + let entries = (1...40).map { index in + GridColumnEntry( + name: "column_\(index)", + dataIndex: index - 1, + typeName: index.isMultiple(of: 3) ? "INTEGER" : "VARCHAR(255)", + position: index, + isHidden: false + ) + } + [GridColumnEntry(name: "notes", dataIndex: nil, typeName: nil, position: nil, isHidden: true)] + let viewModel = ColumnJumpViewModel(entries: entries, initialQuery: "col") + return ColumnJumpPanelContent(viewModel: viewModel) { _ in } + .padding(40) + .background(Color.gray.opacity(0.4)) +} diff --git a/TablePro/Views/Results/ColumnVisibilityPopover.swift b/TablePro/Views/Results/ColumnVisibilityPopover.swift index b8c59346b..38a9c0a8a 100644 --- a/TablePro/Views/Results/ColumnVisibilityPopover.swift +++ b/TablePro/Views/Results/ColumnVisibilityPopover.swift @@ -6,20 +6,25 @@ import SwiftUI struct ColumnVisibilityPopover: View { - let columns: [String] + let columns: [GridColumnEntry] let hiddenColumns: Set let onToggleColumn: (String) -> Void let onShowAll: () -> Void let onHideAll: ([String]) -> Void let onReset: () -> Void + let onJumpToColumn: ((String) -> Void)? @State private var searchText = "" - private var filteredColumns: [String] { + private var filteredColumns: [GridColumnEntry] { if searchText.isEmpty { return columns } - return columns.filter { $0.localizedCaseInsensitiveContains(searchText) } + return columns.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + private var columnNames: [String] { + columns.map(\.name) } var body: some View { @@ -39,11 +44,21 @@ struct ColumnVisibilityPopover: View { footer } - .frame(width: 260) + .frame(width: 300) } private var footer: some View { HStack { + if let onJumpToColumn { + Button("Jump to Column…") { onJumpToColumn(searchText) } + .buttonStyle(.link) + .controlSize(.small) + .help(AppSettingsManager.shared.keyboard.shortcutHint( + String(localized: "Scroll to a column and put the cell cursor in it"), + for: .jumpToColumn + )) + .accessibilityIdentifier("column-visibility-jump") + } Spacer() Button("Reset Columns") { onReset() } .buttonStyle(.link) @@ -75,7 +90,7 @@ struct ColumnVisibilityPopover: View { .controlSize(.small) .disabled(hiddenColumns.isEmpty) - Button("Hide All") { onHideAll(columns) } + Button("Hide All") { onHideAll(columnNames) } .buttonStyle(.link) .controlSize(.small) .disabled(hiddenColumns.count == columns.count) @@ -85,14 +100,19 @@ struct ColumnVisibilityPopover: View { } private var searchField: some View { - NativeSearchField(text: $searchText, placeholder: String(localized: "Search columns…"), controlSize: .small) - .padding(.horizontal, 12) - .padding(.vertical, 6) + NativeSearchField( + text: $searchText, + placeholder: String(localized: "Search columns…"), + controlSize: .small, + accessibilityIdentifier: "column-visibility-search" + ) + .padding(.horizontal, 12) + .padding(.vertical, 6) } private var columnList: some View { List { - ForEach(filteredColumns, id: \.self) { column in + ForEach(filteredColumns) { column in columnRow(column) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 1, leading: 12, bottom: 1, trailing: 12)) @@ -103,14 +123,27 @@ struct ColumnVisibilityPopover: View { .frame(minHeight: 120, maxHeight: 320) } - private func columnRow(_ column: String) -> some View { + private func columnRow(_ column: GridColumnEntry) -> some View { Toggle(isOn: Binding( - get: { !hiddenColumns.contains(column) }, - set: { _ in onToggleColumn(column) } + get: { !hiddenColumns.contains(column.name) }, + set: { _ in onToggleColumn(column.name) } )) { - Text(column) - .lineLimit(1) - .truncationMode(.tail) + HStack(spacing: 8) { + Text(column.name) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 0) + + if let typeName = column.typeName { + Text(typeName) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(-1) + } + } } .toggleStyle(.checkbox) } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index bc2f4099f..b561fe1ca 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -75,6 +75,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private(set) var displayState = DataGridDisplayState() var displayCache: RowDisplayCache { displayState.cache } private var pendingScrollAnchorRow: Int? + var pendingColumnJump: PendingColumnJump? weak var delegate: (any DataGridViewDelegate)? var rowReorder: DataGridRowReorder = .disabled weak var activeFKPreviewPopover: NSPopover? @@ -658,6 +659,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData prewarmResumeTask = nil detachScrollObservers() selectionController.clear() + pendingColumnJump = nil overlayEditor?.dismiss(commit: false) overlayViewer?.dismiss() settingsCancellable?.cancel() diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 17d8bba72..e241537d3 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -211,6 +211,7 @@ struct DataGridView: NSViewRepresentable { columnComments: columnComments ) + var contentReplaced = false if snapshot != coordinator.lastUpdateSnapshot { // Read from the retained state rather than from `lastUpdateSnapshot`, which is nil on a // freshly mounted coordinator and would therefore report every remount as a content @@ -220,6 +221,7 @@ struct DataGridView: NSViewRepresentable { contentRevision: contentRevision ) let contentChanged = coordinator.displayState.contentIdentity != contentIdentity + contentReplaced = contentChanged coordinator.displayState.contentIdentity = contentIdentity applyStructuralUpdate( tableView: tableView, @@ -238,6 +240,7 @@ struct DataGridView: NSViewRepresentable { syncSortState(tableView: tableView, coordinator: coordinator) syncSelection(tableView: tableView, coordinator: coordinator) + coordinator.schedulePendingColumnJump(contentReplaced: contentReplaced) } private func applyStructuralUpdate( diff --git a/TablePro/Views/Results/Extensions/DataGridView+ColumnJump.swift b/TablePro/Views/Results/Extensions/DataGridView+ColumnJump.swift new file mode 100644 index 000000000..3d7840707 --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+ColumnJump.swift @@ -0,0 +1,96 @@ +// +// DataGridView+ColumnJump.swift +// TablePro +// + +import AppKit + +/// A jump the grid could not perform yet because the column was hidden. +/// +/// It carries the data index when the entry had one, because a name alone resolves to the last +/// of two same-named columns; a column a table tab never fetched has no index until it lands, so +/// the name is the fallback. `tableKey` is the table the request was made against, so a preview +/// tab retargeted to another table with a column of the same name does not jump there. A table +/// tab refetches to show a column, so its jump waits for that result to be installed rather than +/// landing on the interim update that unhides an already-fetched key or sort column, which the +/// refetch would then undo; a query tab shows a column without fetching, so it does not wait. +struct PendingColumnJump: Equatable { + let name: String + let dataIndex: Int? + let tableKey: ColumnLayoutTableKey? + let awaitsResultReplacement: Bool +} + +extension TableViewCoordinator { + /// The data index under the cell cursor, or nil while the cursor sits on chrome or nowhere. + var focusedDataColumnIndex: Int? { + guard let tableView = tableView as? KeyHandlingTableView, + presentsColumn(atTableColumnIndex: tableView.focusedColumn) else { return nil } + return dataColumnIndex(from: tableView.tableColumns[tableView.focusedColumn].identifier) + } + + /// Scrolls a presented column into view and puts the cell cursor in it, on the selected row or + /// else the first row in the viewport. It goes through `focusCell`, the one way a keystroke + /// moves the cursor, so the selection, the repaint and the accessibility notice all follow. + /// A jump that lands supersedes any jump still parked, so an older request cannot take the + /// cursor back when its column finally arrives. + @discardableResult + func jumpToColumn(dataIndex: Int) -> Bool { + guard let tableView = tableView as? KeyHandlingTableView, + let tableColumnIndex = tableColumnIndex(for: dataIndex), + presentsColumn(atTableColumnIndex: tableColumnIndex) else { return false } + pendingColumnJump = nil + overlayEditor?.dismiss(commit: true) + dismissFKPreviewOnColumnChange() + let row = jumpRow(in: tableView) + if row >= 0 { + tableView.focusCell(row: row, column: tableColumnIndex) + } else { + scrollColumnToVisible(tableColumnIndex: tableColumnIndex) + } + _ = focusGrid() + return true + } + + /// Runs the parked jump once the grid's update pass has put the column on screen. Deferred off + /// the pass itself, because the jump selects a row and that write reaches SwiftUI state. + func schedulePendingColumnJump(contentReplaced: Bool) { + guard pendingColumnJump != nil else { return } + Task { @MainActor [weak self] in + self?.consumePendingColumnJump(contentReplaced: contentReplaced) + } + } + + /// - Parameter contentReplaced: whether this update installed a new result. A request that + /// waits for its refetch lands only on one, and a request whose column a new result no + /// longer carries is dropped rather than left armed for a result that may never come. + func consumePendingColumnJump(contentReplaced: Bool) { + guard let pending = pendingColumnJump else { return } + guard pending.tableKey == columnLayoutKey else { + pendingColumnJump = nil + return + } + guard contentReplaced || !pending.awaitsResultReplacement else { return } + if let dataIndex = resolvedDataIndex(for: pending), jumpToColumn(dataIndex: dataIndex) { + return + } + if contentReplaced { + pendingColumnJump = nil + } + } + + private func resolvedDataIndex(for pending: PendingColumnJump) -> Int? { + if let dataIndex = pending.dataIndex, identitySchema.columnName(for: dataIndex) == pending.name { + return dataIndex + } + return identitySchema.dataIndex(forColumnName: pending.name) + } + + private func jumpRow(in tableView: NSTableView) -> Int { + if tableView.selectedRow >= 0 { + return tableView.selectedRow + } + guard tableView.numberOfRows > 0 else { return -1 } + return max(0, tableView.rows(in: tableView.visibleRect).location) + } +} diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index 05e9db487..197f410aa 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -188,19 +188,25 @@ struct ResultStatusBar: View { .controlSize(.small) /// Present but inert until the result names its columns, so a reload dims the button rather /// than removing it and shifting everything beside it. - .disabled(columnState.all.isEmpty) + .disabled(columnState.columns.isEmpty) .help(String(localized: "Choose which columns the grid shows")) .accessibilityLabel(String(localized: "Columns")) .accessibilityValue(columnsAccessibilityValue) .accessibilityIdentifier("result-status-columns") .popover(isPresented: $showColumnPopover, arrowEdge: .top) { ColumnVisibilityPopover( - columns: columnState.all, + columns: columnState.visibilityColumns, hiddenColumns: columnState.hidden, onToggleColumn: columnState.onToggle, onShowAll: columnState.onShowAll, onHideAll: columnState.onHideAll, - onReset: columnState.onReset + onReset: columnState.onReset, + onJumpToColumn: columnState.onJumpToColumn.map { jump in + { query in + showColumnPopover = false + jump(query) + } + } ) } } @@ -232,8 +238,8 @@ struct ResultStatusBar: View { /// different control name depending on how many columns happened to be hidden. private var columnsAccessibilityValue: String { guard hasHiddenColumns else { return String(localized: "All columns visible") } - let visible = columnState.all.count - columnState.hidden.count - return String(format: String(localized: "%d of %d columns visible"), visible, columnState.all.count) + let total = columnState.visibilityColumns.count + return String(format: String(localized: "%d of %d columns visible"), total - columnState.hidden.count, total) } private var filtersAccessibilityValue: String { diff --git a/TablePro/Views/Results/ResultStatusInputs.swift b/TablePro/Views/Results/ResultStatusInputs.swift index 2a1d90f38..5473a9d50 100644 --- a/TablePro/Views/Results/ResultStatusInputs.swift +++ b/TablePro/Views/Results/ResultStatusInputs.swift @@ -18,9 +18,16 @@ struct PaginationCallbacks { struct StatusBarColumnState { let hidden: Set - let all: [String] + let columns: [GridColumnEntry] let onToggle: (String) -> Void let onShowAll: () -> Void let onHideAll: ([String]) -> Void let onReset: () -> Void + /// Nil where no grid is mounted to jump in, which hides the popover's Jump to Column button. + let onJumpToColumn: ((String) -> Void)? + + /// What the visibility controls count and toggle: one row per name, because hiding is by name. + var visibilityColumns: [GridColumnEntry] { + GridColumnCatalog.uniqueByName(columns) + } } diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 17b51860c..0a4be99b5 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -237,6 +237,18 @@ struct MainMenuShortcutCoverageTests { #expect(item?.keyEquivalent == "f") #expect(item?.keyEquivalentModifierMask == [.command, .shift]) } + + @Test("Jump to Column… sits in the Edit menu's Find submenu on Cmd+Shift+J") + func jumpToColumnLivesUnderFind() { + let edit = buildMenu().items.first { $0.title == String(localized: "Edit") }?.submenu + let find = edit?.items.first { $0.title == String(localized: "Find") }?.submenu + let item = find?.items.first { $0.identifier == MenuItemFactory.identifier(for: .jumpToColumn) } + + #expect(item?.title == String(localized: "Jump to Column…")) + #expect(item?.action == #selector(MainSplitViewController.jumpToColumn(_:))) + #expect(item?.keyEquivalent == "j") + #expect(item?.keyEquivalentModifierMask == [.command, .shift]) + } } @Suite("Main menu validation") diff --git a/TableProTests/Core/Services/Infrastructure/JumpToColumnMenuValidationTests.swift b/TableProTests/Core/Services/Infrastructure/JumpToColumnMenuValidationTests.swift new file mode 100644 index 000000000..9dcc059f9 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/JumpToColumnMenuValidationTests.swift @@ -0,0 +1,30 @@ +// +// JumpToColumnMenuValidationTests.swift +// TableProTests +// + +import AppKit +import Testing + +@testable import TablePro + +@Suite("Jump to Column menu validation") +@MainActor +struct JumpToColumnMenuValidationTests { + private let selector = #selector(MainSplitViewController.jumpToColumn(_:)) + + @Test("The item lights only over a connected window whose grid has columns to list") + func enabledOnlyWithAConnectedGrid() { + var context = MenuValidationContext() + #expect(!MainSplitViewController.isEnabled(selector, context: context)) + + context.isConnected = true + #expect(!MainSplitViewController.isEnabled(selector, context: context)) + + context.canJumpToColumn = true + #expect(MainSplitViewController.isEnabled(selector, context: context)) + + context.isConnected = false + #expect(!MainSplitViewController.isEnabled(selector, context: context)) + } +} diff --git a/TableProTests/Models/GridColumnCatalogTests.swift b/TableProTests/Models/GridColumnCatalogTests.swift new file mode 100644 index 000000000..503e236a8 --- /dev/null +++ b/TableProTests/Models/GridColumnCatalogTests.swift @@ -0,0 +1,129 @@ +// +// GridColumnCatalogTests.swift +// TableProTests +// + +import Testing + +@testable import TablePro + +@Suite("Grid column catalog") +struct GridColumnCatalogTests { + private let columns = ["id", "name", "created_at"] + private let types: [ColumnType] = [ + .integer(rawType: "INTEGER"), + .text(rawType: "VARCHAR(255)"), + .timestamp(rawType: nil) + ] + + @Test("Positions follow the grid's display order, not the result's") + func positionsFollowDisplayOrder() { + let entries = GridColumnCatalog.entries( + resultColumns: columns, + columnTypes: types, + hiddenColumns: [], + displayOrder: [2, 0, 1], + pickerColumns: columns + ) + + #expect(entries.map(\.name) == columns) + #expect(entries.map(\.position) == [2, 3, 1]) + #expect(entries.map(\.typeName) == ["INTEGER", "VARCHAR(255)", "Timestamp"]) + #expect(entries.map(\.dataIndex) == [0, 1, 2]) + #expect(entries.allSatisfy { !$0.isHidden }) + } + + @Test("A hidden result column keeps its data index and loses its position") + func hiddenResultColumn() { + let entries = GridColumnCatalog.entries( + resultColumns: columns, + columnTypes: types, + hiddenColumns: ["name"], + displayOrder: [0, 2], + pickerColumns: columns + ) + + let name = entries[1] + #expect(name.isHidden) + #expect(name.dataIndex == 1) + #expect(name.position == nil) + #expect(name.typeName == "VARCHAR(255)") + #expect(entries[2].position == 2) + } + + @Test("A schema column the result left out is listed as hidden with no data index") + func schemaOnlyHiddenColumn() { + let entries = GridColumnCatalog.entries( + resultColumns: ["id"], + columnTypes: [types[0]], + hiddenColumns: ["notes"], + displayOrder: [0], + pickerColumns: ["id", "notes"] + ) + + #expect(entries.map(\.name) == ["id", "notes"]) + let notes = entries[1] + #expect(notes.isHidden) + #expect(notes.dataIndex == nil) + #expect(notes.typeName == nil) + #expect(notes.position == nil) + #expect(notes.id == "hidden-notes") + } + + @Test("Without a mounted grid the result's order stands in for positions") + func resultOrderWithoutAGrid() { + let entries = GridColumnCatalog.entries( + resultColumns: columns, + columnTypes: types, + hiddenColumns: ["name"], + displayOrder: nil, + pickerColumns: columns + ) + + #expect(entries.map(\.position) == [1, nil, 2]) + } + + @Test("Duplicate names get one entry per data index") + func duplicateNames() { + let entries = GridColumnCatalog.entries( + resultColumns: ["id", "id"], + columnTypes: [types[0], types[0]], + hiddenColumns: [], + displayOrder: nil, + pickerColumns: ["id"] + ) + + #expect(entries.map(\.id) == ["column-0", "column-1"]) + #expect(entries.map(\.position) == [1, 2]) + } + + @Test("The visibility projection keeps one entry per name, the first in catalog order") + func uniqueByName() { + let entries = GridColumnCatalog.entries( + resultColumns: ["id", "name", "id"], + columnTypes: [types[0], types[1], types[2]], + hiddenColumns: ["id"], + displayOrder: nil, + pickerColumns: ["id", "name"] + ) + + let unique = GridColumnCatalog.uniqueByName(entries) + #expect(unique.map(\.name) == ["id", "name"]) + #expect(unique.first?.typeName == "INTEGER") + #expect(unique.first?.isHidden == true) + } + + @Test("Entries keep the picker's order and never drop a result column") + func pickerOrder() { + let entries = GridColumnCatalog.entries( + resultColumns: ["a", "z", "extra"], + columnTypes: [], + hiddenColumns: [], + displayOrder: nil, + pickerColumns: ["z", "a"] + ) + + #expect(entries.map(\.name) == ["z", "a", "extra"]) + #expect(entries.map(\.typeName) == [nil, nil, nil]) + } +} diff --git a/TableProTests/Models/KeyboardShortcutTests.swift b/TableProTests/Models/KeyboardShortcutTests.swift index 38816ac67..d3deaedf8 100644 --- a/TableProTests/Models/KeyboardShortcutTests.swift +++ b/TableProTests/Models/KeyboardShortcutTests.swift @@ -45,6 +45,14 @@ struct ShortcutActionDefaultsTests { #expect(KeyboardSettings.defaultShortcuts[.findNext] == .character("g", command: true)) #expect(KeyboardSettings.defaultShortcuts[.findPrevious] == .character("g", command: true, shift: true)) } + + @Test("Jump to Column default is Cmd+Shift+J and belongs to the data grid") + func jumpToColumnDefault() { + #expect(KeyboardSettings.defaultShortcuts[.jumpToColumn] == .character("j", command: true, shift: true)) + #expect(ShortcutAction.jumpToColumn.context == .dataGrid) + #expect(ShortcutAction.jumpToColumn.category == .dataGrid) + #expect(ShortcutAction.reservedConflict(for: .character("j", command: true, shift: true), context: .dataGrid) == nil) + } } @Suite("Default shortcut hygiene") diff --git a/TableProTests/ViewModels/ColumnJumpViewModelTests.swift b/TableProTests/ViewModels/ColumnJumpViewModelTests.swift new file mode 100644 index 000000000..57f9d49c1 --- /dev/null +++ b/TableProTests/ViewModels/ColumnJumpViewModelTests.swift @@ -0,0 +1,109 @@ +// +// ColumnJumpViewModelTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@MainActor +struct ColumnJumpViewModelTests { + private func entry(_ name: String, dataIndex: Int, position: Int?, hidden: Bool = false) -> GridColumnEntry { + GridColumnEntry(name: name, dataIndex: dataIndex, typeName: "TEXT", position: position, isHidden: hidden) + } + + @Test("An empty query lists presented columns by position and hidden ones after") + func emptyQueryOrder() { + let viewModel = ColumnJumpViewModel(entries: [ + entry("third", dataIndex: 0, position: 3), + entry("first", dataIndex: 1, position: 1), + entry("gone", dataIndex: 2, position: nil, hidden: true), + entry("second", dataIndex: 3, position: 2) + ]) + + #expect(viewModel.matches.map(\.entry.name) == ["first", "second", "third", "gone"]) + #expect(viewModel.matches.allSatisfy { $0.matchedIndices.isEmpty }) + #expect(viewModel.selectedId == "column-1") + } + + @Test("A query ranks fuzzy matches and reports the characters it hit") + func fuzzyRanking() { + let viewModel = ColumnJumpViewModel( + entries: [ + entry("customer_id", dataIndex: 0, position: 1), + entry("created_at", dataIndex: 1, position: 2), + entry("id", dataIndex: 2, position: 3) + ], + initialQuery: "crat" + ) + + #expect(viewModel.matches.map(\.entry.name) == ["created_at"]) + #expect( + viewModel.matches.first?.matchedIndices == [0, 1, 8, 9], + "the matcher prefers the `_at` boundary over the consecutive `at`, and the row must bold what it chose" + ) + #expect(viewModel.selectedEntry?.name == "created_at") + } + + @Test("The list opens on the column under the cell cursor") + func cursorPreselection() { + let viewModel = ColumnJumpViewModel( + entries: [ + entry("id", dataIndex: 0, position: 1), + entry("name", dataIndex: 1, position: 2), + entry("email", dataIndex: 2, position: 3) + ], + cursorColumnIndex: 2 + ) + + #expect(viewModel.selectedId == "column-2") + } + + @Test("A new query moves the selection to its best match, a repeated one keeps it") + func selectionFollowsTheQuery() { + let viewModel = ColumnJumpViewModel(entries: [ + entry("created_at", dataIndex: 0, position: 1), + entry("credit", dataIndex: 1, position: 2), + entry("crumbs", dataIndex: 2, position: 3) + ]) + + viewModel.searchText = "cr" + viewModel.moveSelection(by: 1) + let moved = viewModel.selectedId + #expect(moved != viewModel.matches.first?.id) + + viewModel.searchText = "cr " + #expect(viewModel.selectedId == moved, "trailing whitespace ranks the same rows, so the selection stays") + + viewModel.searchText = "cre" + #expect(viewModel.matches.map(\.entry.name) == ["credit", "created_at"]) + #expect(viewModel.selectedId == viewModel.matches.first?.id) + } + + @Test("Moving the selection clamps to the list") + func moveSelectionClamps() { + let viewModel = ColumnJumpViewModel(entries: [ + entry("a", dataIndex: 0, position: 1), + entry("b", dataIndex: 1, position: 2) + ]) + + viewModel.moveSelection(by: -5) + #expect(viewModel.selectedId == "column-0") + viewModel.moveSelection(by: 50) + #expect(viewModel.selectedId == "column-1") + } + + @Test("The list height budgets one row for no matches and caps at the visible maximum") + func listHeight() { + let entries = (0..<20).map { entry("c\($0)", dataIndex: $0, position: $0 + 1) } + let viewModel = ColumnJumpViewModel(entries: entries) + + #expect(viewModel.listHeight(rowHeight: 10, maxVisibleRows: 9) == 90) + viewModel.searchText = "zzz" + #expect(viewModel.matches.isEmpty) + #expect(viewModel.listHeight(rowHeight: 10, maxVisibleRows: 9) == 10) + #expect(viewModel.presentedColumnCount == 20) + } +} diff --git a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index 52f66439d..de3bbf5a8 100644 --- a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -41,11 +41,15 @@ struct ResultStatusBarLayoutTests { filterState: TabFilterState(), columnState: StatusBarColumnState( hidden: [], - all: ["id", "name"], + columns: [ + GridColumnEntry(name: "id", dataIndex: 0, typeName: "INTEGER", position: 1, isHidden: false), + GridColumnEntry(name: "name", dataIndex: 1, typeName: "TEXT", position: 2, isHidden: false) + ], onToggle: { _ in }, onShowAll: {}, onHideAll: { _ in }, - onReset: {} + onReset: {}, + onJumpToColumn: nil ), paginationCallbacks: PaginationCallbacks( onFirst: {}, diff --git a/TableProTests/Views/QuickSwitcherPanelControllerTests.swift b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift index edc9a050b..eb3f93da2 100644 --- a/TableProTests/Views/QuickSwitcherPanelControllerTests.swift +++ b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift @@ -77,6 +77,24 @@ struct QuickSwitcherPanelControllerTests { #expect(controller.isPresented == false) } + /// One controller serves Open Quickly and Jump to Column, so a command that toggles its own + /// panel asks which one is up rather than whether any is. + @Test("a presented identity answers only for itself and clears on close") + func presentedIdentityIsTracked() { + let controller = QuickSwitcherPanelController() + controller.present(Text(verbatim: "columns"), over: nil, identity: "column-jump") + #expect(controller.isPresenting("column-jump")) + #expect(controller.isPresenting("open-quickly") == false) + + controller.present(Text(verbatim: "objects"), over: nil) + #expect(controller.isPresented) + #expect(controller.isPresenting("column-jump") == false) + + controller.present(Text(verbatim: "columns"), over: nil, identity: "column-jump") + controller.dismiss() + #expect(controller.isPresenting("column-jump") == false) + } + @Test("panel can become key but not main") func panelKeyAndMainBehavior() { let panel = QuickSwitcherPanel( diff --git a/TableProTests/Views/Results/TableViewCoordinatorColumnJumpTests.swift b/TableProTests/Views/Results/TableViewCoordinatorColumnJumpTests.swift new file mode 100644 index 000000000..6b798d22e --- /dev/null +++ b/TableProTests/Views/Results/TableViewCoordinatorColumnJumpTests.swift @@ -0,0 +1,231 @@ +// +// TableViewCoordinatorColumnJumpTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class ColumnJumpLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@Suite("Jump to Column in the grid") +@MainActor +struct TableViewCoordinatorColumnJumpTests { + private func makeCoordinator( + columns: [String] = ["id", "name", "email"], + hiddenColumns: Set = [], + rowCount: Int = 2 + ) -> TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: ColumnJumpLayoutPersister() + ) + let rows = (0..) { + guard let tableView = coordinator.tableView else { return } + coordinator.columnPool.reconcile( + tableView: tableView, + schema: coordinator.identitySchema, + columnTypes: [], + savedLayout: nil, + isEditable: true, + hiddenColumnNames: hiddenColumns, + widthCalculator: { _, _ in 100 } + ) + coordinator.invalidateColumnIndexCache() + } + + @Test("A jump puts the cell cursor in the column on the first row when nothing is selected") + func jumpSeedsTheCursor() throws { + let coordinator = makeCoordinator() + let tableView = try #require(coordinator.tableView as? KeyHandlingTableView) + + #expect(coordinator.jumpToColumn(dataIndex: 2)) + + #expect(tableView.selectedRow == 0) + #expect(tableView.focusedRow == 0) + #expect(tableView.focusedColumn == coordinator.tableColumnIndex(for: 2)) + #expect(coordinator.focusedDataColumnIndex == 2) + } + + @Test("A jump keeps the selected row") + func jumpKeepsTheSelectedRow() throws { + let coordinator = makeCoordinator() + let tableView = try #require(coordinator.tableView as? KeyHandlingTableView) + tableView.selectRowIndexes(IndexSet(integer: 1), byExtendingSelection: false) + + #expect(coordinator.jumpToColumn(dataIndex: 1)) + + #expect(tableView.selectedRow == 1) + #expect(tableView.focusedRow == 1) + #expect(coordinator.focusedDataColumnIndex == 1) + } + + @Test("A hidden column is refused, and the cursor is left where it was") + func hiddenColumnIsRefused() throws { + let coordinator = makeCoordinator(hiddenColumns: ["name"]) + let tableView = try #require(coordinator.tableView as? KeyHandlingTableView) + + #expect(coordinator.jumpToColumn(dataIndex: 1) == false) + + #expect(tableView.selectedRow == -1) + #expect(coordinator.focusedDataColumnIndex == nil) + } + + private func parked( + _ name: String, + dataIndex: Int? = nil, + tableKey: ColumnLayoutTableKey? = nil, + awaitsResultReplacement: Bool = false + ) -> PendingColumnJump { + PendingColumnJump( + name: name, + dataIndex: dataIndex, + tableKey: tableKey, + awaitsResultReplacement: awaitsResultReplacement + ) + } + + @Test("A parked jump waits for the column to be presented, then lands once") + func pendingJumpLandsWhenPresented() throws { + let coordinator = makeCoordinator(hiddenColumns: ["name"]) + let pending = parked("name") + coordinator.pendingColumnJump = pending + + coordinator.consumePendingColumnJump(contentReplaced: false) + #expect(coordinator.pendingColumnJump == pending) + #expect(coordinator.focusedDataColumnIndex == nil) + + reconcile(coordinator, hiddenColumns: []) + coordinator.consumePendingColumnJump(contentReplaced: false) + + #expect(coordinator.pendingColumnJump == nil) + #expect(coordinator.focusedDataColumnIndex == 1) + } + + /// A name resolves to the last of two same-named columns, so a parked jump keeps the index the + /// reader chose and lands on that one. + @Test("A parked jump on a duplicate name lands on the chosen column") + func pendingJumpKeepsTheChosenDuplicate() throws { + let coordinator = makeCoordinator(columns: ["id", "name", "name"], hiddenColumns: ["name"]) + coordinator.pendingColumnJump = parked("name", dataIndex: 1) + + reconcile(coordinator, hiddenColumns: []) + coordinator.consumePendingColumnJump(contentReplaced: false) + + #expect(coordinator.pendingColumnJump == nil) + #expect(coordinator.focusedDataColumnIndex == 1) + } + + /// A table tab refetches to show a column, and a key or sort column is fetched while hidden, + /// so the interim update that unhides it must not land the jump the refetch would then undo. + @Test("A table tab's parked jump waits for the refetched result") + func pendingJumpOnATableTabWaitsForTheResult() throws { + let coordinator = makeCoordinator(hiddenColumns: ["name"]) + let pending = parked("name", dataIndex: 1, awaitsResultReplacement: true) + coordinator.pendingColumnJump = pending + reconcile(coordinator, hiddenColumns: []) + + coordinator.consumePendingColumnJump(contentReplaced: false) + #expect(coordinator.pendingColumnJump == pending) + #expect(coordinator.focusedDataColumnIndex == nil) + + coordinator.consumePendingColumnJump(contentReplaced: true) + #expect(coordinator.pendingColumnJump == nil) + #expect(coordinator.focusedDataColumnIndex == 1) + } + + @Test("A new result that lacks the column drops the parked jump instead of arming it forever") + func replacementWithoutTheColumnDropsThePendingJump() throws { + let coordinator = makeCoordinator(hiddenColumns: ["name"]) + coordinator.pendingColumnJump = parked("elsewhere") + + coordinator.consumePendingColumnJump(contentReplaced: false) + #expect(coordinator.pendingColumnJump != nil) + + coordinator.consumePendingColumnJump(contentReplaced: true) + #expect(coordinator.pendingColumnJump == nil) + } + + @Test("A jump that lands supersedes a jump still parked") + func directJumpSupersedesThePendingJump() throws { + let coordinator = makeCoordinator(hiddenColumns: ["name"]) + coordinator.pendingColumnJump = parked("name", dataIndex: 1) + + #expect(coordinator.jumpToColumn(dataIndex: 2)) + + #expect(coordinator.pendingColumnJump == nil) + #expect(coordinator.focusedDataColumnIndex == 2) + } + + @Test("A parked jump made against another table is dropped") + func pendingJumpForAnotherTableIsDropped() throws { + let coordinator = makeCoordinator(hiddenColumns: ["name"]) + let otherTable = ColumnLayoutTableKey( + connectionId: UUID(), + databaseName: "db", + schemaName: nil, + tableName: "other" + ) + coordinator.pendingColumnJump = parked("name", dataIndex: 1, tableKey: otherTable) + reconcile(coordinator, hiddenColumns: []) + + coordinator.consumePendingColumnJump(contentReplaced: false) + + #expect(coordinator.pendingColumnJump == nil) + #expect(coordinator.focusedDataColumnIndex == nil) + } + + @Test("A jump with no rows still scrolls to the column without seeding a cursor") + func jumpWithoutRows() throws { + let coordinator = makeCoordinator(rowCount: 0) + let tableView = try #require(coordinator.tableView as? KeyHandlingTableView) + + #expect(coordinator.jumpToColumn(dataIndex: 2)) + + #expect(tableView.selectedRow == -1) + #expect(coordinator.focusedDataColumnIndex == nil) + } + + @Test("Releasing the grid drops a parked jump") + func releaseDropsPendingJump() { + let coordinator = makeCoordinator(hiddenColumns: ["name"]) + coordinator.pendingColumnJump = parked("name", dataIndex: 1) + + coordinator.releaseData() + + #expect(coordinator.pendingColumnJump == nil) + } +} diff --git a/TableProUITests/ColumnJumpUITests.swift b/TableProUITests/ColumnJumpUITests.swift new file mode 100644 index 000000000..c9bddbd33 --- /dev/null +++ b/TableProUITests/ColumnJumpUITests.swift @@ -0,0 +1,96 @@ +// +// ColumnJumpUITests.swift +// TableProUITests +// + +import XCTest + +final class ColumnJumpUITests: UITestCase { + private static let columnCount = 60 + + /// One launch for every route into the panel and for the jump itself. The routes are + /// independent, so `continueAfterFailure` stays on: a broken menu item must not hide whether the + /// popover still reaches the panel. + func testJumpToColumnReachesAColumnPastTheViewport() throws { + continueAfterFailure = true + let app = try launchWithSampleDatabase() + let window = app.windows.firstMatch + let grid = runWideQuery(in: app) + let lastColumnHeader = grid.buttons["Column: col_60"] + XCTAssertTrue(lastColumnHeader.waitToExist(timeout: 10), "The result must expose its last column's header") + XCTAssertFalse(lastColumnHeader.isHittable, "Sixty columns must push the last one past the viewport") + + app.typeKey("j", modifierFlags: [.command, .shift]) + let panel = switcherPanel(in: app) + let searchField = panel.textFields["column-jump-search-field"] + XCTAssertTrue(searchField.waitToExist(timeout: 15), "Command Shift J must open Jump to Column") + + searchField.typeText("col60") + let row = panel.buttons["col_60"] + XCTAssertTrue(row.waitToExist(timeout: 10), "A fuzzy match must list the column") + XCTAssertTrue( + ((row.value as? String) ?? "").contains("60 of 60"), + "The row must carry the column's position, got \(String(describing: row.value))" + ) + + app.typeKey(.return, modifierFlags: []) + XCTAssertTrue(searchField.waitForNonExistence(timeout: 5), "Return must close the panel") + XCTAssertTrue( + waitForPredicate(timeout: 10) { lastColumnHeader.isHittable }, + "The jump must scroll the column into view" + ) + + /// The menu route, then an Escape on an empty field, which closes. + let menuItem = app.menuBars.menuItems["Jump to Column…"] + XCTAssertTrue(menuItem.waitToExist(timeout: 5)) + menuItem.click() + XCTAssertTrue(searchField.waitToExist(timeout: 15), "The Edit menu item must open the panel") + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(searchField.waitForNonExistence(timeout: 5), "Escape on an empty field must close the panel") + + /// The popover route carries the popover's search text into the panel. + let columnsButton = window.buttons["result-status-columns"] + XCTAssertTrue(waitUntilHittable(columnsButton, timeout: 10)) + columnsButton.click() + let popoverSearch = app.searchFields["column-visibility-search"].firstMatch + XCTAssertTrue(popoverSearch.waitToExist(timeout: 10), "The columns popover must offer its search field") + popoverSearch.click() + app.typeText("col_1") + /// `.any`, because a link-styled SwiftUI button is published as a link, not a button. + let jumpButton = app.descendants(matching: .any).matching(identifier: "column-visibility-jump").firstMatch + XCTAssertTrue(waitUntilHittable(jumpButton, timeout: 10), "The popover must offer Jump to Column") + jumpButton.click() + XCTAssertTrue(searchField.waitToExist(timeout: 15), "The popover button must open the panel") + XCTAssertTrue( + waitForPredicate(timeout: 5) { (searchField.value as? String ?? "") == "col_1" }, + "The popover's search text must seed the panel" + ) + app.typeKey(.escape, modifierFlags: []) + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(searchField.waitForNonExistence(timeout: 5)) + } + + // MARK: - Helpers + + private func runWideQuery(in app: XCUIApplication) -> XCUIElement { + app.typeKey("t", modifierFlags: .command) + let editor = editorTextView(in: app) + XCTAssertTrue(editor.waitToExist(timeout: 10)) + editor.click() + let columns = (1...Self.columnCount) + .map { String(format: "%d AS col_%02d", $0, $0) } + .joined(separator: ", ") + app.typeText("SELECT \(columns) FROM Track LIMIT 3;") + app.typeKey(.return, modifierFlags: .command) + + let grid = app.windows.firstMatch.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 20), "The query must produce a result grid") + return grid + } + + /// `.any` because AppKit gives a floating panel the `AXDialog` subrole, which XCUITest reports as + /// Dialog rather than Window. See `OpenQuicklyCommandUITests`. + private func switcherPanel(in app: XCUIApplication) -> XCUIElement { + app.children(matching: .any).matching(identifier: "quick-switcher-panel").firstMatch + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 858f7e170..08c636c1d 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -46,6 +46,15 @@ Hide columns from the columns button in the status bar or from the header menu. Widths, order, and hidden columns are remembered per table, scoped to the connection, database, and schema; **Reset Columns** in the popover puts them back. Right-click a header and choose **Display As** to read a column's stored values in a different form; [Cell and Row Viewers](/features/json-viewer) lists the formats. +### Jump to a column + +`Cmd+Shift+J`, or **Edit > Find > Jump to Column**, lists every column of the result with its type and its place in the grid. Type part of a name; matching is fuzzy, so `crat` finds `created_at`. `Return` scrolls the selected column into view and puts the cell cursor in it, on the selected row. A hidden column is listed too, marked **Hidden**, and jumping to it shows it first. **Jump to Column** at the foot of the columns popover opens the same panel with the popover's search carried over. + + + A floating panel over the data grid listing columns with their types and positions + A floating panel over the data grid listing columns with their types and positions + + ## Foreign keys A foreign key cell carries an arrow on its right edge. Click it to open the referenced table filtered to the matching row, or right-click for **Preview Referenced Row**, which shows that row in a popover. `Cmd`-click always opens a new tab; otherwise the reference takes over the current tab unless that tab holds a query or unsaved edits. diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index e826f0298..0a6868d0c 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -88,6 +88,7 @@ Every row except Find, Find Next and Find Previous is built into the editor and |--------|----------| | Move between cells | Arrow keys | | Next / previous cell | `Tab` / `Shift+Tab` | +| Jump to column | `Cmd+Shift+J` | | Scroll to first / last row | `Home` / `End` | | Scroll page up / down | `Page Up` / `Page Down` | | Extend selection by one cell | `Shift+Arrow` | diff --git a/docs/images/column-jump-dark.png b/docs/images/column-jump-dark.png new file mode 100644 index 000000000..1fb7ad2c6 Binary files /dev/null and b/docs/images/column-jump-dark.png differ diff --git a/docs/images/column-jump.png b/docs/images/column-jump.png new file mode 100644 index 000000000..a9851de1b Binary files /dev/null and b/docs/images/column-jump.png differ diff --git a/docs/scripts/check-docs-against-source.py b/docs/scripts/check-docs-against-source.py index 6aeacf442..38afec838 100755 --- a/docs/scripts/check-docs-against-source.py +++ b/docs/scripts/check-docs-against-source.py @@ -42,6 +42,7 @@ "Toggle history": "toggleHistory", "Toggle results": "toggleResults", "Find": "find", + "Jump to column": "jumpToColumn", } RUNTIME_LEAVES = {