From 871cbf8805b0a290a6a24de8e1e876cfbbaf18f6 Mon Sep 17 00:00:00 2001 From: shouwang0527 Date: Thu, 28 May 2026 13:10:48 +0800 Subject: [PATCH 01/13] feat(datagrid): add column and cell-range selection Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 + TablePro/Views/Results/CellSelection.swift | 60 ++++++++++ .../Results/Cells/DataGridCellContent.swift | 1 + .../Results/Cells/DataGridCellView.swift | 10 ++ .../Results/DataGridView+RowActions.swift | 60 ++++++++++ .../Extensions/DataGridView+Columns.swift | 6 + .../Extensions/DataGridView+Selection.swift | 5 + .../Views/Results/KeyHandlingTableView.swift | 108 ++++++++++++++++-- .../Views/Results/SortableHeaderView.swift | 11 +- TablePro/Views/Results/TableSelection.swift | 15 ++- 10 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 TablePro/Views/Results/CellSelection.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ced712dc7..92da0647e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Column and cell-range selection in the data grid: Cmd+click a column header to select the entire column, Shift+click to select a vertical range, Cmd+click individual cells for non-contiguous selection (#1446) + ## [0.43.3] - 2026-05-22 ### Fixed diff --git a/TablePro/Views/Results/CellSelection.swift b/TablePro/Views/Results/CellSelection.swift new file mode 100644 index 000000000..9594f6892 --- /dev/null +++ b/TablePro/Views/Results/CellSelection.swift @@ -0,0 +1,60 @@ +import Foundation + +enum CellSelection: Equatable { + case none + case column(Int) + case range(column: Int, rows: ClosedRange) + case cells(Set) + + func contains(row: Int, column: Int) -> Bool { + switch self { + case .none: + return false + case .column(let col): + return column == col + case .range(let col, let rows): + return column == col && rows.contains(row) + case .cells(let positions): + return positions.contains(CellPosition(row: row, column: column)) + } + } + + var affectedColumns: IndexSet { + switch self { + case .none: + return IndexSet() + case .column(let col): + return IndexSet(integer: col) + case .range(let col, _): + return IndexSet(integer: col) + case .cells(let positions): + return IndexSet(positions.map(\.column)) + } + } + + var affectedRows: IndexSet { + switch self { + case .none: + return IndexSet() + case .column: + return IndexSet() + case .range(_, let rows): + return IndexSet(integersIn: rows) + case .cells(let positions): + return IndexSet(positions.map(\.row)) + } + } + + var isEmpty: Bool { + switch self { + case .none: + return true + case .column: + return false + case .range(_, let rows): + return rows.isEmpty + case .cells(let positions): + return positions.isEmpty + } + } +} diff --git a/TablePro/Views/Results/Cells/DataGridCellContent.swift b/TablePro/Views/Results/Cells/DataGridCellContent.swift index f8350c2ca..b0215edc2 100644 --- a/TablePro/Views/Results/Cells/DataGridCellContent.swift +++ b/TablePro/Views/Results/Cells/DataGridCellContent.swift @@ -20,6 +20,7 @@ struct DataGridCellContent { struct DataGridCellState { let visualState: RowVisualState let isFocused: Bool + let isInCellSelection: Bool let isEditable: Bool let isLargeDataset: Bool let row: Int diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index a336e4c78..576bc2ac1 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -29,6 +29,7 @@ final class DataGridCellView: NSView { private var visualState: RowVisualState = .empty private var isFocusedCell: Bool = false + private var isInCellSelection: Bool = false private var onEmphasizedSelection: Bool = false private var cachedLine: CTLine? @@ -150,6 +151,10 @@ final class DataGridCellView: NSView { updateFocusPresentation() needsRedraw = true } + if isInCellSelection != state.isInCellSelection { + isInCellSelection = state.isInCellSelection + needsRedraw = true + } setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1)) setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1)) @@ -202,6 +207,11 @@ final class DataGridCellView: NSView { bounds.fill() } + if isInCellSelection && !onEmphasizedSelection { + NSColor.controlAccentColor.withAlphaComponent(0.12).setFill() + bounds.fill() + } + let accessoryRect = computeAccessoryRect() accessoryHitRect = accessoryRect diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index d9028e737..beb63a716 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -308,4 +308,64 @@ extension TableViewCoordinator { delegate.dataGridMoveRow(from: fromRow, to: row) return true } + + func selectColumn(_ dataColumnIndex: Int) { + guard let keyTableView = tableView as? KeyHandlingTableView else { return } + keyTableView.selection.cellSelection = .column(dataColumnIndex) + keyTableView.cellSelectionAnchor = nil + keyTableView.deselectAll(nil) + } + + func copyCellSelection(_ cellSelection: CellSelection) { + switch cellSelection { + case .none: + return + case .column(let columnIndex): + copyColumnValues(columnIndex: columnIndex) + case .range(let columnIndex, let rows): + copyCellRange(columnIndex: columnIndex, rows: rows) + case .cells(let positions): + copyCellPositions(positions) + } + } + + private func copyCellRange(columnIndex: Int, rows: ClosedRange) { + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + + let columnType = tableRows.columnTypes.indices.contains(columnIndex) + ? tableRows.columnTypes[columnIndex] + : nil + + var lines: [String] = [] + lines.reserveCapacity(rows.count) + + for rowIndex in rows { + guard let row = displayRow(at: rowIndex), row.values.indices.contains(columnIndex) else { continue } + let text = RowValueCopyFormatter.copyText(cell: row.values[columnIndex], columnType: columnType) ?? "NULL" + lines.append(text) + } + + ClipboardService.shared.writeText(lines.joined(separator: "\n")) + } + + private func copyCellPositions(_ positions: Set) { + let tableRows = tableRowsProvider() + let sorted = positions.sorted { $0.row != $1.row ? $0.row < $1.row : $0.column < $1.column } + + var lines: [String] = [] + lines.reserveCapacity(sorted.count) + + for pos in sorted { + guard pos.column >= 0, pos.column < tableRows.columns.count else { continue } + guard let row = displayRow(at: pos.row), row.values.indices.contains(pos.column) else { continue } + let columnType = tableRows.columnTypes.indices.contains(pos.column) + ? tableRows.columnTypes[pos.column] + : nil + let text = RowValueCopyFormatter.copyText(cell: row.values[pos.column], columnType: columnType) ?? "NULL" + lines.append(text) + } + + ClipboardService.shared.writeText(lines.joined(separator: "\n")) + } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 89b877133..a60381256 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -65,6 +65,11 @@ extension TableViewCoordinator { return true }() + let isInCellSelection: Bool = { + guard let keyTableView = tableView as? KeyHandlingTableView else { return false } + return keyTableView.selection.cellSelection.contains(row: row, column: columnIndex) + }() + let isDropdown = dropdownColumns?.contains(columnIndex) == true let isTypePicker = typePickerColumns?.contains(columnIndex) == true let isEnumOrSet = enumOrSetColumns.contains(columnIndex) @@ -87,6 +92,7 @@ extension TableViewCoordinator { let cellState = DataGridCellState( visualState: state, isFocused: isFocused, + isInCellSelection: isInCellSelection, isEditable: isEditable, isLargeDataset: isLargeDataset, row: row, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index 7ab81ee9c..316ec9db2 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -38,6 +38,11 @@ extension TableViewCoordinator { guard let keyTableView = tableView as? KeyHandlingTableView else { return } + if !newSelection.isEmpty && !keyTableView.selection.cellSelection.isEmpty { + keyTableView.selection.cellSelection = .none + keyTableView.cellSelectionAnchor = nil + } + let newFocus = resolvedFocus( previous: previousSelection, current: newSelection, diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 54822f472..88130fef6 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -4,6 +4,7 @@ final class KeyHandlingTableView: NSTableView { weak var coordinator: TableViewCoordinator? private var isRaisingOverlay = false + var cellSelectionAnchor: CellPosition? override var acceptsFirstResponder: Bool { true @@ -30,6 +31,14 @@ final class KeyHandlingTableView: NSTableView { var selection = TableSelection() { didSet { + if oldValue.cellSelection != selection.cellSelection { + if case .column = oldValue.cellSelection { + reloadVisibleColumnCells(oldValue.cellSelection.affectedColumns) + } + if case .column = selection.cellSelection { + reloadVisibleColumnCells(selection.cellSelection.affectedColumns) + } + } guard let (rows, columns) = selection.reloadIndexes(from: oldValue) else { return } scheduleFocusReload(rows: rows, columns: columns) } @@ -62,6 +71,19 @@ final class KeyHandlingTableView: NSTableView { reloadData(forRowIndexes: validRows, columnIndexes: validColumns) } + private func reloadVisibleColumnCells(_ columns: IndexSet) { + guard !columns.isEmpty, numberOfRows > 0 else { return } + let visibleRows = rows(in: visibleRect) + guard visibleRows.length > 0 else { return } + let rowRange = visibleRows.location..<(visibleRows.location + visibleRows.length) + let tableColumnIndexes = IndexSet(columns.compactMap { dataCol in + guard let schema = coordinator?.identitySchema else { return nil } + return DataGridView.tableColumnIndex(for: dataCol, in: self, schema: schema) + }) + guard !tableColumnIndexes.isEmpty else { return } + reloadData(forRowIndexes: IndexSet(integersIn: rowRange), columnIndexes: tableColumnIndexes) + } + var focusedRow: Int { get { selection.focusedRow } set { selection.focusedRow = newValue } @@ -84,21 +106,43 @@ final class KeyHandlingTableView: NSTableView { return } - let alreadyFocusedHere = clickedRow >= 0 - && clickedColumn >= 0 - && clickedRow == focusedRow - && clickedColumn == focusedColumn - - super.mouseDown(with: event) - guard clickedRow >= 0, clickedColumn >= 0, clickedColumn < numberOfColumns else { + super.mouseDown(with: event) return } let column = tableColumns[clickedColumn] - if column.identifier == ColumnIdentitySchema.rowNumberIdentifier { + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let isDataColumn = column.identifier != ColumnIdentitySchema.rowNumberIdentifier + + if isDataColumn, let schema = coordinator?.identitySchema, + let dataColIndex = DataGridView.dataColumnIndex(for: clickedColumn, in: self, schema: schema) { + if modifiers.contains(.command) && !modifiers.contains(.shift) { + handleCmdClickCell(row: clickedRow, dataColumn: dataColIndex) + return + } + if modifiers.contains(.shift) && !modifiers.contains(.command) { + if handleShiftClickCell(row: clickedRow, dataColumn: dataColIndex) { + return + } + } + } + + if !selection.cellSelection.isEmpty { + selection.cellSelection = .none + cellSelectionAnchor = nil + } + + let alreadyFocusedHere = clickedRow >= 0 + && clickedColumn >= 0 + && clickedRow == focusedRow + && clickedColumn == focusedColumn + + super.mouseDown(with: event) + + if !isDataColumn { focusedRow = -1 focusedColumn = -1 return @@ -116,6 +160,43 @@ final class KeyHandlingTableView: NSTableView { } } + private func handleCmdClickCell(row: Int, dataColumn: Int) { + var positions: Set + switch selection.cellSelection { + case .cells(let existing): + positions = existing + case .column, .range: + positions = Set() + case .none: + positions = Set() + } + + let pos = CellPosition(row: row, column: dataColumn) + if positions.contains(pos) { + positions.remove(pos) + } else { + positions.insert(pos) + } + + selection.cellSelection = positions.isEmpty ? .none : .cells(positions) + cellSelectionAnchor = positions.isEmpty ? nil : pos + deselectAll(nil) + } + + private func handleShiftClickCell(row: Int, dataColumn: Int) -> Bool { + guard let anchor = cellSelectionAnchor, anchor.column == dataColumn else { + cellSelectionAnchor = CellPosition(row: row, column: dataColumn) + selection.cellSelection = .cells(Set([CellPosition(row: row, column: dataColumn)])) + deselectAll(nil) + return true + } + let low = min(anchor.row, row) + let high = max(anchor.row, row) + selection.cellSelection = .range(column: dataColumn, rows: low...high) + deselectAll(nil) + return true + } + @objc func delete(_ sender: Any?) { guard coordinator?.isEditable == true else { return } guard !selectedRowIndexes.isEmpty else { return } @@ -123,7 +204,9 @@ final class KeyHandlingTableView: NSTableView { } @objc func copy(_ sender: Any?) { - if let cell = focusedDataCell() { + if !selection.cellSelection.isEmpty { + coordinator?.copyCellSelection(selection.cellSelection) + } else if let cell = focusedDataCell() { coordinator?.copyCellValue(at: cell.row, columnIndex: cell.columnIndex) } else { coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) @@ -162,13 +245,13 @@ final class KeyHandlingTableView: NSTableView { case #selector(delete(_:)), #selector(deleteBackward(_:)): return coordinator?.isEditable == true && !selectedRowIndexes.isEmpty case #selector(copy(_:)), #selector(copyRowsAsTSV(_:)): - return !selectedRowIndexes.isEmpty + return !selection.cellSelection.isEmpty || !selectedRowIndexes.isEmpty case #selector(paste(_:)): return coordinator?.isEditable == true && coordinator?.delegate != nil case #selector(insertNewline(_:)): return selectedRow >= 0 && DataGridView.isDataTableColumn(focusedColumn) case #selector(cancelOperation(_:)): - return false + return !selection.cellSelection.isEmpty default: return super.validateUserInterfaceItem(item) } @@ -247,6 +330,9 @@ final class KeyHandlingTableView: NSTableView { } @objc override func cancelOperation(_ sender: Any?) { + guard !selection.cellSelection.isEmpty else { return } + selection.cellSelection = .none + cellSelectionAnchor = nil } private func deleteSelectedRowsIfPossible() { diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 6f537c89f..803809e9b 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -183,9 +183,14 @@ final class SortableHeaderView: NSTableHeaderView { return } - let isMultiSort = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .contains(.shift) + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + + if modifiers.contains(.command) { + coordinator.selectColumn(dataIndex) + return + } + + let isMultiSort = modifiers.contains(.shift) let transition = HeaderSortCycle.nextTransition( state: coordinator.currentSortState, clickedColumn: dataIndex, diff --git a/TablePro/Views/Results/TableSelection.swift b/TablePro/Views/Results/TableSelection.swift index 545dccd8c..bdffeda1f 100644 --- a/TablePro/Views/Results/TableSelection.swift +++ b/TablePro/Views/Results/TableSelection.swift @@ -3,11 +3,13 @@ import Foundation struct TableSelection: Equatable { var focusedRow: Int = -1 var focusedColumn: Int = -1 + var cellSelection: CellSelection = .none func reloadIndexes(from previous: TableSelection) -> (rows: IndexSet, columns: IndexSet)? { - guard previous.focusedRow != focusedRow || previous.focusedColumn != focusedColumn else { - return nil - } + let focusChanged = previous.focusedRow != focusedRow || previous.focusedColumn != focusedColumn + let cellSelectionChanged = previous.cellSelection != cellSelection + + guard focusChanged || cellSelectionChanged else { return nil } var rows = IndexSet() var columns = IndexSet() @@ -17,6 +19,13 @@ struct TableSelection: Equatable { if focusedRow >= 0 { rows.insert(focusedRow) } if focusedColumn >= 0 { columns.insert(focusedColumn) } + if cellSelectionChanged { + rows.formUnion(previous.cellSelection.affectedRows) + rows.formUnion(cellSelection.affectedRows) + columns.formUnion(previous.cellSelection.affectedColumns) + columns.formUnion(cellSelection.affectedColumns) + } + guard !rows.isEmpty, !columns.isEmpty else { return nil } return (rows, columns) } From e5490e4be752f65b3feb337212618c1bf3cda64b Mon Sep 17 00:00:00 2001 From: shouwang0527 Date: Thu, 28 May 2026 13:50:11 +0800 Subject: [PATCH 02/13] fix(datagrid): address review feedback for cell-range selection - Separate copyRowsAsTSV validation from copy (only rows, not cells) - Write TSV format to clipboard so paste into Excel/Numbers works - Restrict Cmd+click column header to exclude Shift (Cmd+Shift = multi-sort) - Set implicit anchor on column select so Shift+click can extend - Add unit tests for CellSelection and TableSelection - Update keyboard-shortcuts docs with new selection shortcuts Co-Authored-By: Claude Opus 4.6 --- .../Results/DataGridView+RowActions.swift | 6 +- .../Views/Results/KeyHandlingTableView.swift | 4 +- .../Views/Results/SortableHeaderView.swift | 2 +- .../Views/Results/CellSelectionTests.swift | 170 ++++++++++++++++++ docs/features/keyboard-shortcuts.mdx | 4 + 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 TableProTests/Views/Results/CellSelectionTests.swift diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index beb63a716..b7ea16685 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -312,7 +312,7 @@ extension TableViewCoordinator { func selectColumn(_ dataColumnIndex: Int) { guard let keyTableView = tableView as? KeyHandlingTableView else { return } keyTableView.selection.cellSelection = .column(dataColumnIndex) - keyTableView.cellSelectionAnchor = nil + keyTableView.cellSelectionAnchor = CellPosition(row: 0, column: dataColumnIndex) keyTableView.deselectAll(nil) } @@ -346,7 +346,7 @@ extension TableViewCoordinator { lines.append(text) } - ClipboardService.shared.writeText(lines.joined(separator: "\n")) + ClipboardService.shared.writeRows(tsv: lines.joined(separator: "\n"), html: nil) } private func copyCellPositions(_ positions: Set) { @@ -366,6 +366,6 @@ extension TableViewCoordinator { lines.append(text) } - ClipboardService.shared.writeText(lines.joined(separator: "\n")) + ClipboardService.shared.writeRows(tsv: lines.joined(separator: "\n"), html: nil) } } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 88130fef6..c46d328da 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -244,8 +244,10 @@ final class KeyHandlingTableView: NSTableView { switch item.action { case #selector(delete(_:)), #selector(deleteBackward(_:)): return coordinator?.isEditable == true && !selectedRowIndexes.isEmpty - case #selector(copy(_:)), #selector(copyRowsAsTSV(_:)): + case #selector(copy(_:)): return !selection.cellSelection.isEmpty || !selectedRowIndexes.isEmpty + case #selector(copyRowsAsTSV(_:)): + return !selectedRowIndexes.isEmpty case #selector(paste(_:)): return coordinator?.isEditable == true && coordinator?.delegate != nil case #selector(insertNewline(_:)): diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 803809e9b..53dbe935b 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -185,7 +185,7 @@ final class SortableHeaderView: NSTableHeaderView { let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if modifiers.contains(.command) { + if modifiers.contains(.command) && !modifiers.contains(.shift) { coordinator.selectColumn(dataIndex) return } diff --git a/TableProTests/Views/Results/CellSelectionTests.swift b/TableProTests/Views/Results/CellSelectionTests.swift new file mode 100644 index 000000000..0022f5c0e --- /dev/null +++ b/TableProTests/Views/Results/CellSelectionTests.swift @@ -0,0 +1,170 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("CellSelection") +struct CellSelectionTests { + + // MARK: - contains + + @Test("none contains nothing") + func noneContainsNothing() { + let sel = CellSelection.none + #expect(!sel.contains(row: 0, column: 0)) + #expect(!sel.contains(row: 5, column: 3)) + } + + @Test("column contains any row in that column") + func columnContainsMatchingColumn() { + let sel = CellSelection.column(2) + #expect(sel.contains(row: 0, column: 2)) + #expect(sel.contains(row: 999, column: 2)) + #expect(!sel.contains(row: 0, column: 1)) + #expect(!sel.contains(row: 0, column: 3)) + } + + @Test("range contains rows within bounds in correct column") + func rangeContainsWithinBounds() { + let sel = CellSelection.range(column: 1, rows: 3...7) + #expect(sel.contains(row: 3, column: 1)) + #expect(sel.contains(row: 5, column: 1)) + #expect(sel.contains(row: 7, column: 1)) + #expect(!sel.contains(row: 2, column: 1)) + #expect(!sel.contains(row: 8, column: 1)) + #expect(!sel.contains(row: 5, column: 0)) + } + + @Test("cells contains only listed positions") + func cellsContainsExactPositions() { + let sel = CellSelection.cells([ + CellPosition(row: 1, column: 2), + CellPosition(row: 5, column: 3) + ]) + #expect(sel.contains(row: 1, column: 2)) + #expect(sel.contains(row: 5, column: 3)) + #expect(!sel.contains(row: 1, column: 3)) + #expect(!sel.contains(row: 2, column: 2)) + } + + // MARK: - affectedColumns + + @Test("none has no affected columns") + func noneAffectedColumns() { + #expect(CellSelection.none.affectedColumns.isEmpty) + } + + @Test("column reports single affected column") + func columnAffectedColumns() { + let sel = CellSelection.column(4) + #expect(sel.affectedColumns == IndexSet(integer: 4)) + } + + @Test("range reports single affected column") + func rangeAffectedColumns() { + let sel = CellSelection.range(column: 2, rows: 0...10) + #expect(sel.affectedColumns == IndexSet(integer: 2)) + } + + @Test("cells reports all unique columns") + func cellsAffectedColumns() { + let sel = CellSelection.cells([ + CellPosition(row: 0, column: 1), + CellPosition(row: 2, column: 3), + CellPosition(row: 4, column: 1) + ]) + #expect(sel.affectedColumns == IndexSet([1, 3])) + } + + // MARK: - affectedRows + + @Test("column has no affected rows") + func columnAffectedRows() { + #expect(CellSelection.column(0).affectedRows.isEmpty) + } + + @Test("range reports all rows in bounds") + func rangeAffectedRows() { + let sel = CellSelection.range(column: 0, rows: 2...5) + #expect(sel.affectedRows == IndexSet(integersIn: 2...5)) + } + + @Test("cells reports all unique rows") + func cellsAffectedRows() { + let sel = CellSelection.cells([ + CellPosition(row: 1, column: 0), + CellPosition(row: 3, column: 2), + CellPosition(row: 1, column: 5) + ]) + #expect(sel.affectedRows == IndexSet([1, 3])) + } + + // MARK: - isEmpty + + @Test("none is empty") + func noneIsEmpty() { + #expect(CellSelection.none.isEmpty) + } + + @Test("column is not empty") + func columnIsNotEmpty() { + #expect(!CellSelection.column(0).isEmpty) + } + + @Test("range is not empty") + func rangeIsNotEmpty() { + #expect(!CellSelection.range(column: 0, rows: 0...0).isEmpty) + } + + @Test("cells with elements is not empty") + func cellsNotEmpty() { + #expect(!CellSelection.cells([CellPosition(row: 0, column: 0)]).isEmpty) + } + + @Test("cells with empty set is empty") + func cellsEmptySetIsEmpty() { + #expect(CellSelection.cells(Set()).isEmpty) + } +} + +@Suite("TableSelection.reloadIndexes with cellSelection") +struct TableSelectionCellSelectionTests { + + @Test("returns nil when nothing changed") + func noChangeReturnsNil() { + let sel = TableSelection(focusedRow: 1, focusedColumn: 2, cellSelection: .column(3)) + #expect(sel.reloadIndexes(from: sel) == nil) + } + + @Test("returns affected indexes when cellSelection changes from none to range") + func noneToRangeReturnsIndexes() { + let old = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .none) + let new = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .range(column: 2, rows: 3...5)) + let result = new.reloadIndexes(from: old) + #expect(result != nil) + #expect(result?.rows == IndexSet(integersIn: 3...5)) + #expect(result?.columns == IndexSet(integer: 2)) + } + + @Test("returns nil for column-only change because affectedRows is empty") + func columnChangeReturnsNilDueToEmptyRows() { + let old = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .none) + let new = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .column(1)) + let result = new.reloadIndexes(from: old) + #expect(result == nil) + } + + @Test("focus change combined with cell selection returns union of indexes") + func focusAndCellSelectionChange() { + let old = TableSelection(focusedRow: 0, focusedColumn: 0, cellSelection: .cells([CellPosition(row: 2, column: 1)])) + let new = TableSelection(focusedRow: 3, focusedColumn: 1, cellSelection: .cells([CellPosition(row: 4, column: 2)])) + let result = new.reloadIndexes(from: old) + #expect(result != nil) + #expect(result!.rows.contains(0)) + #expect(result!.rows.contains(2)) + #expect(result!.rows.contains(3)) + #expect(result!.rows.contains(4)) + #expect(result!.columns.contains(0)) + #expect(result!.columns.contains(1)) + #expect(result!.columns.contains(2)) + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index b9dd3efe6..a5cbaa1f7 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -125,6 +125,10 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Select cell | Click | | Select row | Click row number | | Select multiple cells | Click + drag | +| Select entire column | `Cmd` + click column header | +| Select cell range in column | Click cell, then `Shift` + click another cell in same column | +| Toggle cell in selection | `Cmd` + click cell | +| Clear cell selection | `Escape` | | Extend selection | Shift + click | | Add to selection | Cmd + click | | Extend selection by row | `Shift+Up` / `Shift+Down` | From b672c893ff194dc1d9e56d795d9d354536c122c3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 20:34:38 +0700 Subject: [PATCH 03/13] fix(tests): use NSToolbar.Identifier string value directly NSToolbar.Identifier is a String typealias; calling .rawValue on it does not compile. --- TableProTests/Services/MainWindowToolbarValidationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TableProTests/Services/MainWindowToolbarValidationTests.swift b/TableProTests/Services/MainWindowToolbarValidationTests.swift index a8c408e74..ca8996ecb 100644 --- a/TableProTests/Services/MainWindowToolbarValidationTests.swift +++ b/TableProTests/Services/MainWindowToolbarValidationTests.swift @@ -145,7 +145,7 @@ struct MainWindowToolbarValidationTests { @Test("Toolbar identifier is stable across instances so AppKit autosave can persist customizations") func toolbarIdentifierIsStable() { - #expect(MainWindowToolbar.toolbarIdentifier.rawValue == "com.TablePro.main.toolbar") + #expect(MainWindowToolbar.toolbarIdentifier == "com.TablePro.main.toolbar") } @Test("Toolbar is configured for user customization and autosave") From 539a1cbe1b7d50c6117660dfae5ea8a56b66a854 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 20:34:52 +0700 Subject: [PATCH 04/13] refactor(datagrid): clean up cell-range selection per review feedback Fix the writeRows compile error by switching to writeText; the cell-copy paths produce a single column of values, not a tabular grid. Extract the per-position copy formatting into a shared helper. Move cellSelectionAnchor into TableSelection so resetting the selection also clears the anchor. Cancel-operation now falls through to super when there is no cell selection to clear. Shift+click in a different column is now a no-op so the original anchor survives. Route the cell-selection tint through DataGridCellPalette instead of hardcoding the alpha in draw. Mirror the same accent tint behind selected column headers via SortableHeaderCell so the selection state is visible. --- .../Results/Cells/DataGridCellPalette.swift | 7 ++- .../Results/Cells/DataGridCellView.swift | 7 ++- .../Results/DataGridView+RowActions.swift | 63 ++++++------------- .../Extensions/DataGridView+Selection.swift | 2 +- .../Views/Results/KeyHandlingTableView.swift | 58 ++++++++++------- .../Views/Results/SortableHeaderCell.swift | 6 ++ .../Views/Results/SortableHeaderView.swift | 15 +++++ TablePro/Views/Results/TableSelection.swift | 1 + .../Views/Results/CellSelectionTests.swift | 61 +++++++++++++----- 9 files changed, 132 insertions(+), 88 deletions(-) diff --git a/TablePro/Views/Results/Cells/DataGridCellPalette.swift b/TablePro/Views/Results/Cells/DataGridCellPalette.swift index 3be1018be..ae151818b 100644 --- a/TablePro/Views/Results/Cells/DataGridCellPalette.swift +++ b/TablePro/Views/Results/Cells/DataGridCellPalette.swift @@ -12,13 +12,15 @@ struct DataGridCellPalette: Equatable { let mediumFont: NSFont let deletedRowText: NSColor let modifiedColumnTint: NSColor + let cellSelectionTint: NSColor static let placeholder = DataGridCellPalette( regularFont: .systemFont(ofSize: NSFont.systemFontSize), italicFont: .systemFont(ofSize: NSFont.systemFontSize), mediumFont: .systemFont(ofSize: NSFont.systemFontSize, weight: .medium), deletedRowText: .secondaryLabelColor, - modifiedColumnTint: .systemYellow + modifiedColumnTint: .systemYellow, + cellSelectionTint: .controlAccentColor.withAlphaComponent(0.12) ) } @@ -29,7 +31,8 @@ extension ThemeEngine { italicFont: dataGridFonts.italic, mediumFont: dataGridFonts.medium, deletedRowText: colors.dataGrid.deletedText, - modifiedColumnTint: colors.dataGrid.modified + modifiedColumnTint: colors.dataGrid.modified, + cellSelectionTint: .controlAccentColor.withAlphaComponent(0.12) ) } } diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index 742e4f0f9..fe4c10977 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -26,6 +26,7 @@ final class DataGridCellView: NSView { private var textFont = NSFont.systemFont(ofSize: NSFont.systemFontSize) private var textColor: NSColor = .labelColor private var modifiedColumnTint: NSColor? + private var cellSelectionTint: NSColor = .controlAccentColor.withAlphaComponent(0.12) private var visualState: RowVisualState = .empty private var isFocusedCell: Bool = false @@ -141,6 +142,10 @@ final class DataGridCellView: NSView { modifiedColumnTint = nextTint needsRedraw = true } + if cellSelectionTint != palette.cellSelectionTint { + cellSelectionTint = palette.cellSelectionTint + needsRedraw = true + } if visualState != state.visualState { visualState = state.visualState @@ -208,7 +213,7 @@ final class DataGridCellView: NSView { } if isInCellSelection && !onEmphasizedSelection { - NSColor.controlAccentColor.withAlphaComponent(0.12).setFill() + cellSelectionTint.setFill() bounds.fill() } diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 1109bf698..123be1160 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -220,19 +220,9 @@ extension TableViewCoordinator { let rowCount = min(totalRows, PluginRowLimits.emergencyMax) guard rowCount > 0 else { return } - let columnType = tableRows.columnTypes.indices.contains(columnIndex) - ? tableRows.columnTypes[columnIndex] - : nil - - var lines: [String] = [] - lines.reserveCapacity(rowCount) - - for rowIndex in 0..) { + private func copyCellPositions(_ positions: [CellPosition]) { let tableRows = tableRowsProvider() - guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } - - let columnType = tableRows.columnTypes.indices.contains(columnIndex) - ? tableRows.columnTypes[columnIndex] - : nil - - var lines: [String] = [] - lines.reserveCapacity(rows.count) - - for rowIndex in rows { - guard let row = displayRow(at: rowIndex), row.values.indices.contains(columnIndex) else { continue } - let text = RowValueCopyFormatter.copyText(cell: row.values[columnIndex], columnType: columnType) ?? "NULL" - lines.append(text) - } - - ClipboardService.shared.writeRows(tsv: lines.joined(separator: "\n"), html: nil) + let formatted = formatCellPositions(positions, tableRows: tableRows) + guard !formatted.isEmpty else { return } + ClipboardService.shared.writeText(formatted.joined(separator: "\n")) } - private func copyCellPositions(_ positions: Set) { - let tableRows = tableRowsProvider() - let sorted = positions.sorted { $0.row != $1.row ? $0.row < $1.row : $0.column < $1.column } - + private func formatCellPositions(_ positions: [CellPosition], tableRows: TableRows) -> [String] { var lines: [String] = [] - lines.reserveCapacity(sorted.count) - - for pos in sorted { - guard pos.column >= 0, pos.column < tableRows.columns.count else { continue } - guard let row = displayRow(at: pos.row), row.values.indices.contains(pos.column) else { continue } + lines.reserveCapacity(positions.count) + for pos in positions { + guard pos.column >= 0, pos.column < tableRows.columns.count, + let row = displayRow(at: pos.row), + row.values.indices.contains(pos.column) else { continue } let columnType = tableRows.columnTypes.indices.contains(pos.column) ? tableRows.columnTypes[pos.column] : nil let text = RowValueCopyFormatter.copyText(cell: row.values[pos.column], columnType: columnType) ?? "NULL" lines.append(text) } - - ClipboardService.shared.writeRows(tsv: lines.joined(separator: "\n"), html: nil) + return lines } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index 316ec9db2..bad04690d 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -40,7 +40,7 @@ extension TableViewCoordinator { if !newSelection.isEmpty && !keyTableView.selection.cellSelection.isEmpty { keyTableView.selection.cellSelection = .none - keyTableView.cellSelectionAnchor = nil + keyTableView.selection.cellSelectionAnchor = nil } let newFocus = resolvedFocus( diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index c46d328da..60e5d5c3e 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -4,7 +4,6 @@ final class KeyHandlingTableView: NSTableView { weak var coordinator: TableViewCoordinator? private var isRaisingOverlay = false - var cellSelectionAnchor: CellPosition? override var acceptsFirstResponder: Bool { true @@ -38,12 +37,22 @@ final class KeyHandlingTableView: NSTableView { if case .column = selection.cellSelection { reloadVisibleColumnCells(selection.cellSelection.affectedColumns) } + updateHeaderSelectionIndicators( + previous: oldValue.cellSelection, + current: selection.cellSelection + ) } guard let (rows, columns) = selection.reloadIndexes(from: oldValue) else { return } scheduleFocusReload(rows: rows, columns: columns) } } + private func updateHeaderSelectionIndicators(previous: CellSelection, current: CellSelection) { + guard let headerView = headerView as? SortableHeaderView else { return } + let union = previous.affectedColumns.union(current.affectedColumns) + headerView.updateColumnSelectionIndicators(selectedColumns: current.affectedColumns, dirtyColumns: union) + } + private var pendingFocusReloadRows: IndexSet? private var pendingFocusReloadColumns: IndexSet? @@ -130,10 +139,7 @@ final class KeyHandlingTableView: NSTableView { } } - if !selection.cellSelection.isEmpty { - selection.cellSelection = .none - cellSelectionAnchor = nil - } + clearCellSelection() let alreadyFocusedHere = clickedRow >= 0 && clickedColumn >= 0 @@ -161,15 +167,10 @@ final class KeyHandlingTableView: NSTableView { } private func handleCmdClickCell(row: Int, dataColumn: Int) { - var positions: Set - switch selection.cellSelection { - case .cells(let existing): - positions = existing - case .column, .range: - positions = Set() - case .none: - positions = Set() - } + var positions: Set = { + if case .cells(let existing) = selection.cellSelection { return existing } + return [] + }() let pos = CellPosition(row: row, column: dataColumn) if positions.contains(pos) { @@ -178,17 +179,18 @@ final class KeyHandlingTableView: NSTableView { positions.insert(pos) } - selection.cellSelection = positions.isEmpty ? .none : .cells(positions) - cellSelectionAnchor = positions.isEmpty ? nil : pos + if positions.isEmpty { + clearCellSelection() + } else { + selection.cellSelection = .cells(positions) + selection.cellSelectionAnchor = pos + } deselectAll(nil) } private func handleShiftClickCell(row: Int, dataColumn: Int) -> Bool { - guard let anchor = cellSelectionAnchor, anchor.column == dataColumn else { - cellSelectionAnchor = CellPosition(row: row, column: dataColumn) - selection.cellSelection = .cells(Set([CellPosition(row: row, column: dataColumn)])) - deselectAll(nil) - return true + guard let anchor = selection.cellSelectionAnchor, anchor.column == dataColumn else { + return false } let low = min(anchor.row, row) let high = max(anchor.row, row) @@ -197,6 +199,12 @@ final class KeyHandlingTableView: NSTableView { return true } + private func clearCellSelection() { + guard !selection.cellSelection.isEmpty || selection.cellSelectionAnchor != nil else { return } + selection.cellSelection = .none + selection.cellSelectionAnchor = nil + } + @objc func delete(_ sender: Any?) { guard coordinator?.isEditable == true else { return } guard !selectedRowIndexes.isEmpty else { return } @@ -332,9 +340,11 @@ final class KeyHandlingTableView: NSTableView { } @objc override func cancelOperation(_ sender: Any?) { - guard !selection.cellSelection.isEmpty else { return } - selection.cellSelection = .none - cellSelectionAnchor = nil + guard !selection.cellSelection.isEmpty else { + super.cancelOperation(sender) + return + } + clearCellSelection() } private func deleteSelectedRowsIfPossible() { diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 28cb6a8d0..aa8465deb 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -9,6 +9,7 @@ import AppKit final class SortableHeaderCell: NSTableHeaderCell { var sortDirection: SortDirection? var sortPriority: Int? + var isColumnSelected: Bool = false private static let indicatorPadding: CGFloat = 4 private static let indicatorSpacing: CGFloat = 2 @@ -30,6 +31,11 @@ final class SortableHeaderCell: NSTableHeaderCell { } override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { + if isColumnSelected { + NSColor.controlAccentColor.withAlphaComponent(0.18).setFill() + cellFrame.fill() + } + drawTitle(in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: sortDirection != nil)) guard let direction = sortDirection else { return } diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 53dbe935b..2176dd6b0 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -112,6 +112,21 @@ final class SortableHeaderView: NSTableHeaderView { } } + func updateColumnSelectionIndicators(selectedColumns: IndexSet, dirtyColumns: IndexSet) { + guard let tableView = tableView, let coordinator = coordinator else { return } + for (columnIndex, column) in tableView.tableColumns.enumerated() { + guard let cell = column.headerCell as? SortableHeaderCell, + let dataIndex = coordinator.dataColumnIndex(from: column.identifier) else { continue } + let shouldBeSelected = selectedColumns.contains(dataIndex) + if cell.isColumnSelected != shouldBeSelected { + cell.isColumnSelected = shouldBeSelected + setNeedsDisplay(headerRect(ofColumn: columnIndex)) + } else if dirtyColumns.contains(dataIndex) { + setNeedsDisplay(headerRect(ofColumn: columnIndex)) + } + } + } + func updateSortIndicators(state: SortState, schema: ColumnIdentitySchema) { guard let tableView = tableView else { return } diff --git a/TablePro/Views/Results/TableSelection.swift b/TablePro/Views/Results/TableSelection.swift index bdffeda1f..ec66752b2 100644 --- a/TablePro/Views/Results/TableSelection.swift +++ b/TablePro/Views/Results/TableSelection.swift @@ -4,6 +4,7 @@ struct TableSelection: Equatable { var focusedRow: Int = -1 var focusedColumn: Int = -1 var cellSelection: CellSelection = .none + var cellSelectionAnchor: CellPosition? func reloadIndexes(from previous: TableSelection) -> (rows: IndexSet, columns: IndexSet)? { let focusChanged = previous.focusedRow != focusedRow || previous.focusedColumn != focusedColumn diff --git a/TableProTests/Views/Results/CellSelectionTests.swift b/TableProTests/Views/Results/CellSelectionTests.swift index 0022f5c0e..5f3cb22fd 100644 --- a/TableProTests/Views/Results/CellSelectionTests.swift +++ b/TableProTests/Views/Results/CellSelectionTests.swift @@ -4,7 +4,6 @@ import Testing @Suite("CellSelection") struct CellSelectionTests { - // MARK: - contains @Test("none contains nothing") @@ -128,7 +127,6 @@ struct CellSelectionTests { @Suite("TableSelection.reloadIndexes with cellSelection") struct TableSelectionCellSelectionTests { - @Test("returns nil when nothing changed") func noChangeReturnsNil() { let sel = TableSelection(focusedRow: 1, focusedColumn: 2, cellSelection: .column(3)) @@ -145,26 +143,57 @@ struct TableSelectionCellSelectionTests { #expect(result?.columns == IndexSet(integer: 2)) } - @Test("returns nil for column-only change because affectedRows is empty") + @Test("column-only change has empty affectedRows so reload uses the visible-column side path") func columnChangeReturnsNilDueToEmptyRows() { let old = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .none) let new = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .column(1)) - let result = new.reloadIndexes(from: old) - #expect(result == nil) + #expect(new.reloadIndexes(from: old) == nil) } @Test("focus change combined with cell selection returns union of indexes") func focusAndCellSelectionChange() { - let old = TableSelection(focusedRow: 0, focusedColumn: 0, cellSelection: .cells([CellPosition(row: 2, column: 1)])) - let new = TableSelection(focusedRow: 3, focusedColumn: 1, cellSelection: .cells([CellPosition(row: 4, column: 2)])) - let result = new.reloadIndexes(from: old) - #expect(result != nil) - #expect(result!.rows.contains(0)) - #expect(result!.rows.contains(2)) - #expect(result!.rows.contains(3)) - #expect(result!.rows.contains(4)) - #expect(result!.columns.contains(0)) - #expect(result!.columns.contains(1)) - #expect(result!.columns.contains(2)) + let old = TableSelection( + focusedRow: 0, + focusedColumn: 0, + cellSelection: .cells([CellPosition(row: 2, column: 1)]) + ) + let new = TableSelection( + focusedRow: 3, + focusedColumn: 1, + cellSelection: .cells([CellPosition(row: 4, column: 2)]) + ) + guard let result = new.reloadIndexes(from: old) else { + Issue.record("expected reload indexes for combined focus and cell selection change") + return + } + #expect(result.rows.contains(0)) + #expect(result.rows.contains(2)) + #expect(result.rows.contains(3)) + #expect(result.rows.contains(4)) + #expect(result.columns.contains(0)) + #expect(result.columns.contains(1)) + #expect(result.columns.contains(2)) + } + + @Test("anchor-only change does not trigger a reload") + func anchorOnlyChangeReturnsNil() { + let old = TableSelection( + focusedRow: -1, + focusedColumn: -1, + cellSelection: .cells([CellPosition(row: 1, column: 2)]), + cellSelectionAnchor: CellPosition(row: 1, column: 2) + ) + let new = TableSelection( + focusedRow: -1, + focusedColumn: -1, + cellSelection: .cells([CellPosition(row: 1, column: 2)]), + cellSelectionAnchor: CellPosition(row: 5, column: 2) + ) + #expect(new.reloadIndexes(from: old) == nil) + } + + @Test("default TableSelection has no anchor") + func defaultHasNoAnchor() { + #expect(TableSelection().cellSelectionAnchor == nil) } } From b1bf02d47b3f3d54c4cfe2484be54e18cb84e6bb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 20:55:20 +0700 Subject: [PATCH 05/13] refactor(datagrid)!: rewrite cell selection on a rectangle-first model Replace the CellSelection enum with a GridSelection value type that stores a list of GridRect rectangles plus an active cell and anchor. A row, a column, a single cell, and a multi-rectangle selection are all the same shape. Move selection rendering off the cells. Cells no longer carry isInCellSelection. DataGridRowView draws the selection fill for cells in its row using NSColor.selectedContentBackgroundColor, and a single GridSelectionOverlay view sits on top of the table painting the rectangle borders. No more hardcoded accent alphas, vibrancy and dark mode are picked up from the system. Mouse handling moves into GridSelectionController. Click and drag now creates a rectangular selection. Shift+click extends from the anchor across any column. Cmd+click toggles cells. Cmd+A selects the whole grid. Shift+Arrow extends the active cell, Cmd+Shift+Arrow jumps to the grid edge. Escape clears the selection and falls through to super otherwise. Cmd+C copies the bounding rectangle as TSV with tabs between columns and newlines between rows, blanks for gaps in non-contiguous selections, so a paste into Numbers or Excel preserves the shape. Drop the old reloadVisibleColumnCells side channel and the cellSelection/anchor fields on TableSelection. Clear the selection on applyFullReplace and releaseData so it cannot drift onto unrelated rows after a query reload. Also includes the one-line NSToolbar.Identifier test fix that was blocking the test target build on main. --- CHANGELOG.md | 2 +- TablePro/Models/UI/ColumnIdentitySchema.swift | 2 + TablePro/Views/Results/CellSelection.swift | 60 ---- .../Results/Cells/DataGridCellContent.swift | 1 - .../Results/Cells/DataGridCellPalette.swift | 7 +- .../Results/Cells/DataGridCellView.swift | 16 - .../Views/Results/DataGridCoordinator.swift | 3 + TablePro/Views/Results/DataGridRowView.swift | 31 +- .../Results/DataGridView+RowActions.swift | 86 ++--- TablePro/Views/Results/DataGridView.swift | 12 + .../Extensions/DataGridView+Columns.swift | 6 - .../Extensions/DataGridView+Selection.swift | 5 +- .../Views/Results/KeyHandlingTableView.swift | 242 +++++++------- .../Views/Results/Selection/GridCoord.swift | 6 + .../Views/Results/Selection/GridRect.swift | 37 +++ .../Results/Selection/GridSelection.swift | 70 ++++ .../Selection/GridSelectionController.swift | 185 +++++++++++ .../Selection/GridSelectionOverlay.swift | 72 +++++ TablePro/Views/Results/TableSelection.swift | 16 +- .../Views/Results/CellSelectionTests.swift | 305 +++++++----------- docs/features/keyboard-shortcuts.mdx | 10 +- 21 files changed, 729 insertions(+), 445 deletions(-) delete mode 100644 TablePro/Views/Results/CellSelection.swift create mode 100644 TablePro/Views/Results/Selection/GridCoord.swift create mode 100644 TablePro/Views/Results/Selection/GridRect.swift create mode 100644 TablePro/Views/Results/Selection/GridSelection.swift create mode 100644 TablePro/Views/Results/Selection/GridSelectionController.swift create mode 100644 TablePro/Views/Results/Selection/GridSelectionOverlay.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3ed239a..3b039a3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Column and cell-range selection in the data grid: Cmd+click a column header to select the entire column, Shift+click to select a vertical range, Cmd+click individual cells for non-contiguous selection (#1446) +- Rectangular cell selection in the data grid. Click and drag to select a range, Shift+click to extend, Cmd+click to add cells, Cmd+click a column header to select the column, Shift+Arrow to extend by one cell, Cmd+A to select the whole grid, Cmd+C to copy as TSV. (#1446) - BigQuery: the sidebar now shows every dataset as an expandable node, with each dataset's tables loading when you open it, instead of showing one dataset at a time behind a picker. - OpenCode Zen as an AI provider. Add it from the provider list and paste an OpenCode key, or leave the key blank to use the free models; the model list loads automatically, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) - Oracle Database 11g (11.1 and 11.2) now connects. Previously only 12c and later worked, so 11g servers failed with a "Server Version Not Supported" error. (#1425) diff --git a/TablePro/Models/UI/ColumnIdentitySchema.swift b/TablePro/Models/UI/ColumnIdentitySchema.swift index 62671ecfb..c807d16f2 100644 --- a/TablePro/Models/UI/ColumnIdentitySchema.swift +++ b/TablePro/Models/UI/ColumnIdentitySchema.swift @@ -56,6 +56,8 @@ struct ColumnIdentitySchema: Equatable { slotByColumnName[name] } + var totalDataColumns: Int { columnNames.count } + static func slotIdentifier(_ slot: Int) -> NSUserInterfaceItemIdentifier { NSUserInterfaceItemIdentifier("\(dataColumnPrefix)\(slot)") } diff --git a/TablePro/Views/Results/CellSelection.swift b/TablePro/Views/Results/CellSelection.swift deleted file mode 100644 index 9594f6892..000000000 --- a/TablePro/Views/Results/CellSelection.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -enum CellSelection: Equatable { - case none - case column(Int) - case range(column: Int, rows: ClosedRange) - case cells(Set) - - func contains(row: Int, column: Int) -> Bool { - switch self { - case .none: - return false - case .column(let col): - return column == col - case .range(let col, let rows): - return column == col && rows.contains(row) - case .cells(let positions): - return positions.contains(CellPosition(row: row, column: column)) - } - } - - var affectedColumns: IndexSet { - switch self { - case .none: - return IndexSet() - case .column(let col): - return IndexSet(integer: col) - case .range(let col, _): - return IndexSet(integer: col) - case .cells(let positions): - return IndexSet(positions.map(\.column)) - } - } - - var affectedRows: IndexSet { - switch self { - case .none: - return IndexSet() - case .column: - return IndexSet() - case .range(_, let rows): - return IndexSet(integersIn: rows) - case .cells(let positions): - return IndexSet(positions.map(\.row)) - } - } - - var isEmpty: Bool { - switch self { - case .none: - return true - case .column: - return false - case .range(_, let rows): - return rows.isEmpty - case .cells(let positions): - return positions.isEmpty - } - } -} diff --git a/TablePro/Views/Results/Cells/DataGridCellContent.swift b/TablePro/Views/Results/Cells/DataGridCellContent.swift index b0215edc2..f8350c2ca 100644 --- a/TablePro/Views/Results/Cells/DataGridCellContent.swift +++ b/TablePro/Views/Results/Cells/DataGridCellContent.swift @@ -20,7 +20,6 @@ struct DataGridCellContent { struct DataGridCellState { let visualState: RowVisualState let isFocused: Bool - let isInCellSelection: Bool let isEditable: Bool let isLargeDataset: Bool let row: Int diff --git a/TablePro/Views/Results/Cells/DataGridCellPalette.swift b/TablePro/Views/Results/Cells/DataGridCellPalette.swift index ae151818b..3be1018be 100644 --- a/TablePro/Views/Results/Cells/DataGridCellPalette.swift +++ b/TablePro/Views/Results/Cells/DataGridCellPalette.swift @@ -12,15 +12,13 @@ struct DataGridCellPalette: Equatable { let mediumFont: NSFont let deletedRowText: NSColor let modifiedColumnTint: NSColor - let cellSelectionTint: NSColor static let placeholder = DataGridCellPalette( regularFont: .systemFont(ofSize: NSFont.systemFontSize), italicFont: .systemFont(ofSize: NSFont.systemFontSize), mediumFont: .systemFont(ofSize: NSFont.systemFontSize, weight: .medium), deletedRowText: .secondaryLabelColor, - modifiedColumnTint: .systemYellow, - cellSelectionTint: .controlAccentColor.withAlphaComponent(0.12) + modifiedColumnTint: .systemYellow ) } @@ -31,8 +29,7 @@ extension ThemeEngine { italicFont: dataGridFonts.italic, mediumFont: dataGridFonts.medium, deletedRowText: colors.dataGrid.deletedText, - modifiedColumnTint: colors.dataGrid.modified, - cellSelectionTint: .controlAccentColor.withAlphaComponent(0.12) + modifiedColumnTint: colors.dataGrid.modified ) } } diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index fe4c10977..f8b6cc847 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -26,11 +26,9 @@ final class DataGridCellView: NSView { private var textFont = NSFont.systemFont(ofSize: NSFont.systemFontSize) private var textColor: NSColor = .labelColor private var modifiedColumnTint: NSColor? - private var cellSelectionTint: NSColor = .controlAccentColor.withAlphaComponent(0.12) private var visualState: RowVisualState = .empty private var isFocusedCell: Bool = false - private var isInCellSelection: Bool = false private var onEmphasizedSelection: Bool = false private var cachedLine: CTLine? @@ -142,10 +140,6 @@ final class DataGridCellView: NSView { modifiedColumnTint = nextTint needsRedraw = true } - if cellSelectionTint != palette.cellSelectionTint { - cellSelectionTint = palette.cellSelectionTint - needsRedraw = true - } if visualState != state.visualState { visualState = state.visualState @@ -156,11 +150,6 @@ final class DataGridCellView: NSView { updateFocusPresentation() needsRedraw = true } - if isInCellSelection != state.isInCellSelection { - isInCellSelection = state.isInCellSelection - needsRedraw = true - } - setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1)) setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1)) @@ -212,11 +201,6 @@ final class DataGridCellView: NSView { bounds.fill() } - if isInCellSelection && !onEmphasizedSelection { - cellSelectionTint.setFill() - bounds.fill() - } - let accessoryRect = computeAccessoryRect() accessoryHitRect = accessoryRect diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 6824bb736..73e24aad3 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -101,6 +101,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let cellRegistry: DataGridCellRegistry let columnPool = DataGridColumnPool() let tableRowsController = TableRowsController() + let selectionController = GridSelectionController() var overlayEditor: CellOverlayEditor? var overlayViewer: CellOverlayViewer? @@ -203,6 +204,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData prewarmResumeTask?.cancel() prewarmResumeTask = nil detachScrollObservers() + selectionController.clear() overlayEditor?.dismiss(commit: false) overlayViewer?.dismiss() settingsCancellable?.cancel() @@ -264,6 +266,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData guard let tableView else { return } invalidateAllDisplayCaches() updateCache() + selectionController.clear() tableView.reloadData() startBackgroundPrewarm() } diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index 958391bde..aae11c82c 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -85,9 +85,34 @@ class DataGridRowView: NSTableRowView { override func drawBackground(in dirtyRect: NSRect) { super.drawBackground(in: dirtyRect) - guard let rowTint, !isSelected else { return } - rowTint.setFill() - bounds.fill() + if let rowTint, !isSelected { + rowTint.setFill() + bounds.fill() + } + drawCellSelectionFill(in: dirtyRect) + } + + private func drawCellSelectionFill(in dirtyRect: NSRect) { + guard let selection = coordinator?.selectionController.selection, + !selection.isEmpty, + let tableView = coordinator?.tableView else { return } + let columns = selection.columns(in: rowIndex) + guard !columns.isEmpty else { return } + + let fillColor: NSColor = isSelected + ? NSColor.unemphasizedSelectedContentBackgroundColor + : NSColor.selectedContentBackgroundColor.withAlphaComponent(0.18) + fillColor.setFill() + + let schema = coordinator?.identitySchema + for dataColumn in columns { + guard let schema, + let tableColumnIndex = DataGridView.tableColumnIndex(for: dataColumn, in: tableView, schema: schema) else { continue } + let columnRect = tableView.rect(ofColumn: tableColumnIndex) + let localRect = NSRect(x: columnRect.minX, y: 0, width: columnRect.width, height: bounds.height) + guard localRect.intersects(dirtyRect) else { continue } + localRect.fill() + } } private func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool { diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 123be1160..2aad46180 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -220,8 +220,17 @@ extension TableViewCoordinator { let rowCount = min(totalRows, PluginRowLimits.emergencyMax) guard rowCount > 0 else { return } - let positions = (0.. [String] { var lines: [String] = [] - lines.reserveCapacity(positions.count) - for pos in positions { - guard pos.column >= 0, pos.column < tableRows.columns.count, - let row = displayRow(at: pos.row), - row.values.indices.contains(pos.column) else { continue } - let columnType = tableRows.columnTypes.indices.contains(pos.column) - ? tableRows.columnTypes[pos.column] - : nil - let text = RowValueCopyFormatter.copyText(cell: row.values[pos.column], columnType: columnType) ?? "NULL" - lines.append(text) + lines.reserveCapacity(rowRange.count) + for rowIndex in rowRange { + guard let row = displayRow(at: rowIndex) else { + lines.append(String(repeating: "\t", count: columnRange.count - 1)) + continue + } + var fields: [String] = [] + fields.reserveCapacity(columnRange.count) + for columnIndex in columnRange { + guard selection.contains(row: rowIndex, column: columnIndex) else { + fields.append("") + continue + } + guard row.values.indices.contains(columnIndex) else { + fields.append("") + continue + } + let columnType = columnTypes.indices.contains(columnIndex) ? columnTypes[columnIndex] : nil + let text = RowValueCopyFormatter.copyText(cell: row.values[columnIndex], columnType: columnType) ?? "NULL" + fields.append(text) + } + lines.append(fields.joined(separator: "\t")) } - return lines + + ClipboardService.shared.writeText(lines.joined(separator: "\n")) } } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 302bc7fd9..c9299595b 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -109,6 +109,7 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView + installSelectionOverlay(tableView: tableView, coordinator: context.coordinator) context.coordinator.attachScrollObservers(scrollView: scrollView) context.coordinator.tableRowsController.attach(tableView) context.coordinator.tableRowsProvider = tableRowsProvider @@ -384,6 +385,17 @@ struct DataGridView: NSViewRepresentable { column.width = columnWidth } + private func installSelectionOverlay(tableView: KeyHandlingTableView, coordinator: TableViewCoordinator) { + let overlay = GridSelectionOverlay(frame: tableView.bounds) + overlay.tableView = tableView + overlay.coordinator = coordinator + tableView.addSubview(overlay) + coordinator.selectionController.tableView = tableView + coordinator.selectionController.overlay = overlay + coordinator.selectionController.coordinator = coordinator + tableView.selectionOverlay = overlay + } + static let firstDataTableColumnIndex: Int = 1 static func isDataTableColumn(_ tableColumnIndex: Int) -> Bool { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index a60381256..89b877133 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -65,11 +65,6 @@ extension TableViewCoordinator { return true }() - let isInCellSelection: Bool = { - guard let keyTableView = tableView as? KeyHandlingTableView else { return false } - return keyTableView.selection.cellSelection.contains(row: row, column: columnIndex) - }() - let isDropdown = dropdownColumns?.contains(columnIndex) == true let isTypePicker = typePickerColumns?.contains(columnIndex) == true let isEnumOrSet = enumOrSetColumns.contains(columnIndex) @@ -92,7 +87,6 @@ extension TableViewCoordinator { let cellState = DataGridCellState( visualState: state, isFocused: isFocused, - isInCellSelection: isInCellSelection, isEditable: isEditable, isLargeDataset: isLargeDataset, row: row, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index bad04690d..9feb58986 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -38,9 +38,8 @@ extension TableViewCoordinator { guard let keyTableView = tableView as? KeyHandlingTableView else { return } - if !newSelection.isEmpty && !keyTableView.selection.cellSelection.isEmpty { - keyTableView.selection.cellSelection = .none - keyTableView.selection.cellSelectionAnchor = nil + if !newSelection.isEmpty, !selectionController.isEmpty { + selectionController.clear() } let newFocus = resolvedFocus( diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 60e5d5c3e..d004697ed 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -2,6 +2,7 @@ import AppKit final class KeyHandlingTableView: NSTableView { weak var coordinator: TableViewCoordinator? + weak var selectionOverlay: GridSelectionOverlay? private var isRaisingOverlay = false @@ -14,10 +15,19 @@ final class KeyHandlingTableView: NSTableView { guard !isRaisingOverlay else { return } isRaisingOverlay = true defer { isRaisingOverlay = false } + raiseSelectionOverlayIfNeeded(subview: subview) raiseOverlayIfNeeded(coordinator?.overlayEditor, subview: subview) raiseOverlayIfNeeded(coordinator?.overlayViewer, subview: subview) } + private func raiseSelectionOverlayIfNeeded(subview: NSView) { + guard let selectionOverlay, + selectionOverlay.superview === self, + subview !== selectionOverlay, + subviews.last !== selectionOverlay else { return } + addSubview(selectionOverlay) + } + private func raiseOverlayIfNeeded(_ overlay: CellOverlayBase?, subview: NSView) { guard let overlay, overlay.isActive, @@ -30,29 +40,11 @@ final class KeyHandlingTableView: NSTableView { var selection = TableSelection() { didSet { - if oldValue.cellSelection != selection.cellSelection { - if case .column = oldValue.cellSelection { - reloadVisibleColumnCells(oldValue.cellSelection.affectedColumns) - } - if case .column = selection.cellSelection { - reloadVisibleColumnCells(selection.cellSelection.affectedColumns) - } - updateHeaderSelectionIndicators( - previous: oldValue.cellSelection, - current: selection.cellSelection - ) - } guard let (rows, columns) = selection.reloadIndexes(from: oldValue) else { return } scheduleFocusReload(rows: rows, columns: columns) } } - private func updateHeaderSelectionIndicators(previous: CellSelection, current: CellSelection) { - guard let headerView = headerView as? SortableHeaderView else { return } - let union = previous.affectedColumns.union(current.affectedColumns) - headerView.updateColumnSelectionIndicators(selectedColumns: current.affectedColumns, dirtyColumns: union) - } - private var pendingFocusReloadRows: IndexSet? private var pendingFocusReloadColumns: IndexSet? @@ -80,19 +72,6 @@ final class KeyHandlingTableView: NSTableView { reloadData(forRowIndexes: validRows, columnIndexes: validColumns) } - private func reloadVisibleColumnCells(_ columns: IndexSet) { - guard !columns.isEmpty, numberOfRows > 0 else { return } - let visibleRows = rows(in: visibleRect) - guard visibleRows.length > 0 else { return } - let rowRange = visibleRows.location..<(visibleRows.location + visibleRows.length) - let tableColumnIndexes = IndexSet(columns.compactMap { dataCol in - guard let schema = coordinator?.identitySchema else { return nil } - return DataGridView.tableColumnIndex(for: dataCol, in: self, schema: schema) - }) - guard !tableColumnIndexes.isEmpty else { return } - reloadData(forRowIndexes: IndexSet(integersIn: rowRange), columnIndexes: tableColumnIndexes) - } - var focusedRow: Int { get { selection.focusedRow } set { selection.focusedRow = newValue } @@ -103,6 +82,15 @@ final class KeyHandlingTableView: NSTableView { set { selection.focusedColumn = newValue } } + private var gridSelection: GridSelectionController? { coordinator?.selectionController } + + private func totalRows() -> Int { numberOfRows } + + private func totalDataColumns() -> Int { + guard let schema = coordinator?.identitySchema else { return 0 } + return schema.totalDataColumns + } + override func mouseDown(with event: NSEvent) { window?.makeFirstResponder(self) @@ -118,91 +106,91 @@ final class KeyHandlingTableView: NSTableView { guard clickedRow >= 0, clickedColumn >= 0, clickedColumn < numberOfColumns else { + gridSelection?.clear() super.mouseDown(with: event) return } let column = tableColumns[clickedColumn] - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let isDataColumn = column.identifier != ColumnIdentitySchema.rowNumberIdentifier + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if isDataColumn, let schema = coordinator?.identitySchema, - let dataColIndex = DataGridView.dataColumnIndex(for: clickedColumn, in: self, schema: schema) { - if modifiers.contains(.command) && !modifiers.contains(.shift) { - handleCmdClickCell(row: clickedRow, dataColumn: dataColIndex) - return - } - if modifiers.contains(.shift) && !modifiers.contains(.command) { - if handleShiftClickCell(row: clickedRow, dataColumn: dataColIndex) { - return - } + guard isDataColumn, + let schema = coordinator?.identitySchema, + let dataColumn = DataGridView.dataColumnIndex(for: clickedColumn, in: self, schema: schema) else { + gridSelection?.clear() + super.mouseDown(with: event) + if !isDataColumn { + focusedRow = -1 + focusedColumn = -1 } + return } - clearCellSelection() - - let alreadyFocusedHere = clickedRow >= 0 - && clickedColumn >= 0 - && clickedRow == focusedRow - && clickedColumn == focusedColumn + let alreadyFocusedHere = clickedRow == focusedRow && clickedColumn == focusedColumn + let coord = GridCoord(row: clickedRow, column: dataColumn) + guard let controller = gridSelection else { + super.mouseDown(with: event) + return + } - super.mouseDown(with: event) + let disposition = controller.beginDrag(at: coord, modifiers: modifiers) - if !isDataColumn { + switch disposition { + case .replaceFocus(let activeCoord): + selectRowIndexes(IndexSet(integer: activeCoord.row), byExtendingSelection: false) + focusedRow = activeCoord.row + focusedColumn = DataGridView.tableColumnIndex(for: activeCoord.column, in: self, schema: schema) ?? clickedColumn + case .clearFocus: + deselectAll(nil) focusedRow = -1 focusedColumn = -1 - return + case .clickThrough: + super.mouseDown(with: event) } - focusedRow = clickedRow - focusedColumn = clickedColumn - - if alreadyFocusedHere && event.clickCount == 1 && selectedRowIndexes.count == 1 { - if let schema = coordinator?.identitySchema, - let dataColumnIndex = DataGridView.dataColumnIndex(for: clickedColumn, in: self, schema: schema), - coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumnIndex) == true { - coordinator?.beginCellEdit(row: clickedRow, tableColumnIndex: clickedColumn) - } + if modifiers.intersection([.command, .shift]).isEmpty { + trackDrag(initial: coord, schema: schema) } - } - private func handleCmdClickCell(row: Int, dataColumn: Int) { - var positions: Set = { - if case .cells(let existing) = selection.cellSelection { return existing } - return [] - }() - - let pos = CellPosition(row: row, column: dataColumn) - if positions.contains(pos) { - positions.remove(pos) - } else { - positions.insert(pos) + if alreadyFocusedHere, + modifiers.isEmpty, + event.clickCount == 1, + selectedRowIndexes.count == 1, + coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumn) == true { + coordinator?.beginCellEdit(row: clickedRow, tableColumnIndex: clickedColumn) } + } - if positions.isEmpty { - clearCellSelection() - } else { - selection.cellSelection = .cells(positions) - selection.cellSelectionAnchor = pos + private func trackDrag(initial: GridCoord, schema: ColumnIdentitySchema) { + guard let window, let controller = gridSelection else { return } + let mask: NSEvent.EventTypeMask = [.leftMouseDragged, .leftMouseUp] + while let event = window.nextEvent(matching: mask) { + if event.type == .leftMouseUp { + controller.endDrag() + return + } + let point = convert(event.locationInWindow, from: nil) + autoscroll(with: event) + let rowIdx = clampRow(row(at: point)) + let columnIdx = clampDataColumn(column(at: point), schema: schema) + guard rowIdx >= 0, columnIdx >= 0 else { continue } + controller.continueDrag(to: GridCoord(row: rowIdx, column: columnIdx)) } - deselectAll(nil) } - private func handleShiftClickCell(row: Int, dataColumn: Int) -> Bool { - guard let anchor = selection.cellSelectionAnchor, anchor.column == dataColumn else { - return false - } - let low = min(anchor.row, row) - let high = max(anchor.row, row) - selection.cellSelection = .range(column: dataColumn, rows: low...high) - deselectAll(nil) - return true + private func clampRow(_ value: Int) -> Int { + guard numberOfRows > 0 else { return -1 } + if value < 0 { return 0 } + if value >= numberOfRows { return numberOfRows - 1 } + return value } - private func clearCellSelection() { - guard !selection.cellSelection.isEmpty || selection.cellSelectionAnchor != nil else { return } - selection.cellSelection = .none - selection.cellSelectionAnchor = nil + private func clampDataColumn(_ value: Int, schema: ColumnIdentitySchema) -> Int { + let firstData = DataGridView.firstDataTableColumnIndex + let candidate = value < firstData ? firstData : value + guard candidate >= 0, candidate < numberOfColumns else { return -1 } + return DataGridView.dataColumnIndex(for: candidate, in: self, schema: schema) ?? -1 } @objc func delete(_ sender: Any?) { @@ -212,19 +200,32 @@ final class KeyHandlingTableView: NSTableView { } @objc func copy(_ sender: Any?) { - if !selection.cellSelection.isEmpty { - coordinator?.copyCellSelection(selection.cellSelection) - } else if let cell = focusedDataCell() { + if let controller = gridSelection, !controller.isEmpty { + coordinator?.copyGridSelection(controller.selection) + return + } + if let cell = focusedDataCell() { coordinator?.copyCellValue(at: cell.row, columnIndex: cell.columnIndex) - } else { - coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) + return } + coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) } @objc func copyRowsAsTSV(_ sender: Any?) { coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) } + @objc override func selectAll(_ sender: Any?) { + let totalRows = totalRows() + let totalColumns = totalDataColumns() + guard totalRows > 0, totalColumns > 0 else { + super.selectAll(sender) + return + } + gridSelection?.selectAll(totalRows: totalRows, totalColumns: totalColumns) + selectRowIndexes(IndexSet(integersIn: 0.. (row: Int, columnIndex: Int)? { guard selectedRowIndexes.count == 1, focusedRow >= 0, @@ -253,15 +254,16 @@ final class KeyHandlingTableView: NSTableView { case #selector(delete(_:)), #selector(deleteBackward(_:)): return coordinator?.isEditable == true && !selectedRowIndexes.isEmpty case #selector(copy(_:)): - return !selection.cellSelection.isEmpty || !selectedRowIndexes.isEmpty + let hasGridSelection = gridSelection?.isEmpty == false + return hasGridSelection || !selectedRowIndexes.isEmpty case #selector(copyRowsAsTSV(_:)): return !selectedRowIndexes.isEmpty case #selector(paste(_:)): return coordinator?.isEditable == true && coordinator?.delegate != nil case #selector(insertNewline(_:)): return selectedRow >= 0 && DataGridView.isDataTableColumn(focusedColumn) - case #selector(cancelOperation(_:)): - return !selection.cellSelection.isEmpty + case #selector(selectAll(_:)): + return numberOfRows > 0 default: return super.validateUserInterfaceItem(item) } @@ -282,28 +284,38 @@ final class KeyHandlingTableView: NSTableView { return } - let row = selectedRow let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let row = selectedRow switch key { case .leftArrow: - handleLeftArrow(currentRow: row) + handleHorizontalArrow(direction: .left, modifiers: modifiers, currentRow: row) return - case .rightArrow: - handleRightArrow(currentRow: row) + handleHorizontalArrow(direction: .right, modifiers: modifiers, currentRow: row) return - - case .upArrow, .downArrow, .home, .end, .pageUp, .pageDown: + case .upArrow: + if modifiers.contains(.shift) { + gridSelection?.extendActiveCell(direction: .up, jumpToEdge: modifiers.contains(.command), totalRows: totalRows(), totalColumns: totalDataColumns()) + return + } + super.keyDown(with: event) + return + case .downArrow: + if modifiers.contains(.shift) { + gridSelection?.extendActiveCell(direction: .down, jumpToEdge: modifiers.contains(.command), totalRows: totalRows(), totalColumns: totalDataColumns()) + return + } + super.keyDown(with: event) + return + case .home, .end, .pageUp, .pageDown: super.keyDown(with: event) return - case .delete, .forwardDelete: if modifiers.isEmpty || modifiers == .command { deleteSelectedRowsIfPossible() return } - default: break } @@ -327,6 +339,18 @@ final class KeyHandlingTableView: NSTableView { interpretKeyEvents([event]) } + private func handleHorizontalArrow(direction: GridSelectionController.Direction, modifiers: NSEvent.ModifierFlags, currentRow: Int) { + if modifiers.contains(.shift), let controller = gridSelection, !controller.isEmpty { + controller.extendActiveCell(direction: direction, jumpToEdge: modifiers.contains(.command), totalRows: totalRows(), totalColumns: totalDataColumns()) + return + } + switch direction { + case .left: handleLeftArrow(currentRow: currentRow) + case .right: handleRightArrow(currentRow: currentRow) + default: break + } + } + @objc override func insertNewline(_ sender: Any?) { let row = selectedRow guard row >= 0, @@ -340,11 +364,11 @@ final class KeyHandlingTableView: NSTableView { } @objc override func cancelOperation(_ sender: Any?) { - guard !selection.cellSelection.isEmpty else { + guard let controller = gridSelection, !controller.isEmpty else { super.cancelOperation(sender) return } - clearCellSelection() + controller.clear() } private func deleteSelectedRowsIfPossible() { diff --git a/TablePro/Views/Results/Selection/GridCoord.swift b/TablePro/Views/Results/Selection/GridCoord.swift new file mode 100644 index 000000000..0b8bb52f2 --- /dev/null +++ b/TablePro/Views/Results/Selection/GridCoord.swift @@ -0,0 +1,6 @@ +import Foundation + +struct GridCoord: Hashable { + var row: Int + var column: Int +} diff --git a/TablePro/Views/Results/Selection/GridRect.swift b/TablePro/Views/Results/Selection/GridRect.swift new file mode 100644 index 000000000..699f45154 --- /dev/null +++ b/TablePro/Views/Results/Selection/GridRect.swift @@ -0,0 +1,37 @@ +import Foundation + +struct GridRect: Hashable { + var rows: ClosedRange + var columns: ClosedRange + + init(rows: ClosedRange, columns: ClosedRange) { + self.rows = rows + self.columns = columns + } + + init(cell: GridCoord) { + self.rows = cell.row...cell.row + self.columns = cell.column...cell.column + } + + static func between(_ a: GridCoord, _ b: GridCoord) -> GridRect { + GridRect( + rows: min(a.row, b.row)...max(a.row, b.row), + columns: min(a.column, b.column)...max(a.column, b.column) + ) + } + + func contains(_ coord: GridCoord) -> Bool { + rows.contains(coord.row) && columns.contains(coord.column) + } + + func clamped(rowLimit: Int, columnLimit: Int) -> GridRect? { + guard rowLimit > 0, columnLimit > 0 else { return nil } + let rLow = max(0, rows.lowerBound) + let rHigh = min(rowLimit - 1, rows.upperBound) + let cLow = max(0, columns.lowerBound) + let cHigh = min(columnLimit - 1, columns.upperBound) + guard rLow <= rHigh, cLow <= cHigh else { return nil } + return GridRect(rows: rLow...rHigh, columns: cLow...cHigh) + } +} diff --git a/TablePro/Views/Results/Selection/GridSelection.swift b/TablePro/Views/Results/Selection/GridSelection.swift new file mode 100644 index 000000000..a68dd04c3 --- /dev/null +++ b/TablePro/Views/Results/Selection/GridSelection.swift @@ -0,0 +1,70 @@ +import Foundation + +struct GridSelection: Equatable { + var rectangles: [GridRect] + var activeCell: GridCoord? + var anchor: GridCoord? + + static let empty = GridSelection(rectangles: [], activeCell: nil, anchor: nil) + + var isEmpty: Bool { rectangles.isEmpty } + + func contains(_ coord: GridCoord) -> Bool { + rectangles.contains { $0.contains(coord) } + } + + func contains(row: Int, column: Int) -> Bool { + contains(GridCoord(row: row, column: column)) + } + + var affectedRows: IndexSet { + var set = IndexSet() + for rect in rectangles { + set.insert(integersIn: rect.rows.lowerBound...rect.rows.upperBound) + } + return set + } + + var affectedColumns: IndexSet { + var set = IndexSet() + for rect in rectangles { + set.insert(integersIn: rect.columns.lowerBound...rect.columns.upperBound) + } + return set + } + + var boundingRectangle: GridRect? { + guard let first = rectangles.first else { return nil } + var minRow = first.rows.lowerBound + var maxRow = first.rows.upperBound + var minColumn = first.columns.lowerBound + var maxColumn = first.columns.upperBound + for rect in rectangles.dropFirst() { + minRow = min(minRow, rect.rows.lowerBound) + maxRow = max(maxRow, rect.rows.upperBound) + minColumn = min(minColumn, rect.columns.lowerBound) + maxColumn = max(maxColumn, rect.columns.upperBound) + } + return GridRect(rows: minRow...maxRow, columns: minColumn...maxColumn) + } + + func columns(in row: Int) -> IndexSet { + var set = IndexSet() + for rect in rectangles where rect.rows.contains(row) { + set.insert(integersIn: rect.columns.lowerBound...rect.columns.upperBound) + } + return set + } + + func union(_ other: GridSelection) -> GridSelection { + GridSelection( + rectangles: rectangles + other.rectangles, + activeCell: other.activeCell ?? activeCell, + anchor: other.anchor ?? anchor + ) + } + + static func single(_ rect: GridRect, anchor: GridCoord, active: GridCoord) -> GridSelection { + GridSelection(rectangles: [rect], activeCell: active, anchor: anchor) + } +} diff --git a/TablePro/Views/Results/Selection/GridSelectionController.swift b/TablePro/Views/Results/Selection/GridSelectionController.swift new file mode 100644 index 000000000..af09bb560 --- /dev/null +++ b/TablePro/Views/Results/Selection/GridSelectionController.swift @@ -0,0 +1,185 @@ +import AppKit + +@MainActor +final class GridSelectionController { + enum Direction { + case up + case down + case left + case right + } + + private(set) var selection: GridSelection = .empty + + weak var tableView: NSTableView? + weak var overlay: GridSelectionOverlay? + weak var coordinator: TableViewCoordinator? + + private var dragOrigin: GridCoord? + var onSelectionChange: ((GridSelection) -> Void)? + + var isEmpty: Bool { selection.isEmpty } + + func update(_ newSelection: GridSelection) { + guard selection != newSelection else { return } + let old = selection + selection = newSelection + overlay?.selection = newSelection + let dirty = reloadColumns(for: old, new: newSelection) + reloadRowsForFill(old: old, new: newSelection, dirtyColumns: dirty) + onSelectionChange?(newSelection) + } + + func clear() { + guard !selection.isEmpty else { return } + update(.empty) + } + + func beginDrag(at coord: GridCoord, modifiers: NSEvent.ModifierFlags) -> MouseDisposition { + let cleanModifiers = modifiers.intersection([.command, .shift, .option, .control]) + if cleanModifiers.contains(.command) && !cleanModifiers.contains(.shift) { + return toggleCell(coord) + } + if cleanModifiers.contains(.shift) && !cleanModifiers.contains(.command) { + return extend(to: coord) + } + dragOrigin = coord + update(.single(GridRect(cell: coord), anchor: coord, active: coord)) + return .replaceFocus(coord) + } + + func continueDrag(to coord: GridCoord) { + guard let origin = dragOrigin else { return } + update(.single(GridRect.between(origin, coord), anchor: origin, active: coord)) + } + + func endDrag() { + dragOrigin = nil + } + + private func toggleCell(_ coord: GridCoord) -> MouseDisposition { + let cellRect = GridRect(cell: coord) + var rectangles = selection.rectangles + + if let index = rectangles.firstIndex(where: { $0 == cellRect }) { + rectangles.remove(at: index) + if rectangles.isEmpty { + update(.empty) + return .clearFocus + } + update(GridSelection(rectangles: rectangles, activeCell: rectangles.last.flatMap(activeCell(in:)), anchor: selection.anchor)) + return .replaceFocus(rectangles.last.flatMap(activeCell(in:)) ?? coord) + } + + rectangles.append(cellRect) + update(GridSelection(rectangles: rectangles, activeCell: coord, anchor: coord)) + return .replaceFocus(coord) + } + + private func extend(to coord: GridCoord) -> MouseDisposition { + let origin = selection.anchor ?? coord + update(.single(GridRect.between(origin, coord), anchor: origin, active: coord)) + return .replaceFocus(coord) + } + + private func activeCell(in rect: GridRect) -> GridCoord { + GridCoord(row: rect.rows.lowerBound, column: rect.columns.lowerBound) + } + + func selectAll(totalRows: Int, totalColumns: Int) { + guard totalRows > 0, totalColumns > 0 else { return } + let rect = GridRect(rows: 0...(totalRows - 1), columns: 0...(totalColumns - 1)) + let active = GridCoord(row: 0, column: 0) + update(.single(rect, anchor: active, active: active)) + } + + func selectEntireColumn(_ column: Int, totalRows: Int) { + guard column >= 0, totalRows > 0 else { return } + let rect = GridRect(rows: 0...(totalRows - 1), columns: column...column) + let anchor = GridCoord(row: 0, column: column) + update(.single(rect, anchor: anchor, active: anchor)) + } + + func selectEntireRow(_ row: Int, totalColumns: Int) { + guard row >= 0, totalColumns > 0 else { return } + let rect = GridRect(rows: row...row, columns: 0...(totalColumns - 1)) + let anchor = GridCoord(row: row, column: 0) + update(.single(rect, anchor: anchor, active: anchor)) + } + + func extendActiveCell(direction: Direction, jumpToEdge: Bool, totalRows: Int, totalColumns: Int) { + guard let active = selection.activeCell else { return } + let next = step(from: active, direction: direction, jumpToEdge: jumpToEdge, totalRows: totalRows, totalColumns: totalColumns) + let origin = selection.anchor ?? active + update(.single(GridRect.between(origin, next), anchor: origin, active: next)) + } + + func moveActiveCell(direction: Direction, jumpToEdge: Bool, totalRows: Int, totalColumns: Int) -> GridCoord? { + guard let active = selection.activeCell else { return nil } + let next = step(from: active, direction: direction, jumpToEdge: jumpToEdge, totalRows: totalRows, totalColumns: totalColumns) + update(.single(GridRect(cell: next), anchor: next, active: next)) + return next + } + + private func step(from coord: GridCoord, direction: Direction, jumpToEdge: Bool, totalRows: Int, totalColumns: Int) -> GridCoord { + switch direction { + case .up: + return GridCoord(row: jumpToEdge ? 0 : max(0, coord.row - 1), column: coord.column) + case .down: + return GridCoord(row: jumpToEdge ? max(0, totalRows - 1) : min(totalRows - 1, coord.row + 1), column: coord.column) + case .left: + return GridCoord(row: coord.row, column: jumpToEdge ? 0 : max(0, coord.column - 1)) + case .right: + return GridCoord(row: coord.row, column: jumpToEdge ? max(0, totalColumns - 1) : min(totalColumns - 1, coord.column + 1)) + } + } + + private func reloadColumns(for old: GridSelection, new: GridSelection) -> IndexSet { + let union = old.affectedColumns.union(new.affectedColumns) + if let headerView = (tableView as? KeyHandlingTableView)?.headerView as? SortableHeaderView { + headerView.updateColumnSelectionIndicators( + selectedColumns: new.affectedColumns, + dirtyColumns: union + ) + } + return union + } + + private func reloadRowsForFill(old: GridSelection, new: GridSelection, dirtyColumns: IndexSet) { + guard let tableView = tableView else { return } + guard tableView.numberOfRows > 0 else { return } + + let visible = tableView.rows(in: tableView.visibleRect) + guard visible.length > 0 else { return } + let visibleRange = visible.location..<(visible.location + visible.length) + + var rowsToReload = IndexSet() + let oldVisible = old.affectedRows.intersection(IndexSet(integersIn: visibleRange)) + let newVisible = new.affectedRows.intersection(IndexSet(integersIn: visibleRange)) + rowsToReload.formUnion(oldVisible.symmetricDifference(newVisible)) + for row in newVisible where new.columns(in: row) != old.columns(in: row) { + rowsToReload.insert(row) + } + if rowsToReload.isEmpty { return } + + for row in rowsToReload { + (tableView.rowView(atRow: row, makeIfNecessary: false) as? DataGridRowView)?.needsDisplay = true + } + } +} + +enum MouseDisposition { + case replaceFocus(GridCoord) + case clearFocus + case clickThrough +} + +private extension IndexSet { + func symmetricDifference(_ other: IndexSet) -> IndexSet { + var result = self + result.formUnion(other) + let common = self.intersection(other) + result.subtract(common) + return result + } +} diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift new file mode 100644 index 000000000..684c151e8 --- /dev/null +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -0,0 +1,72 @@ +import AppKit + +@MainActor +final class GridSelectionOverlay: NSView { + var selection: GridSelection = .empty { + didSet { + guard oldValue != selection else { return } + needsDisplay = true + } + } + + weak var tableView: NSTableView? + weak var coordinator: TableViewCoordinator? + + private static let borderWidth: CGFloat = 1.5 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + autoresizingMask = [.width, .height] + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + autoresizingMask = [.width, .height] + } + + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func draw(_ dirtyRect: NSRect) { + guard let tableView, let coordinator else { return } + let schema = coordinator.identitySchema + + let borderColor = NSColor.selectedContentBackgroundColor + borderColor.setStroke() + + for rect in selection.rectangles { + guard let frame = frame(for: rect, in: tableView, schema: schema) else { continue } + guard frame.intersects(dirtyRect) else { continue } + let inset = frame.insetBy(dx: Self.borderWidth / 2, dy: Self.borderWidth / 2) + let path = NSBezierPath(rect: inset) + path.lineWidth = Self.borderWidth + path.stroke() + } + } + + private func frame(for rect: GridRect, in tableView: NSTableView, schema: ColumnIdentitySchema) -> NSRect? { + guard tableView.numberOfRows > 0, tableView.numberOfColumns > 0 else { return nil } + let firstRow = max(0, rect.rows.lowerBound) + let lastRow = min(tableView.numberOfRows - 1, rect.rows.upperBound) + guard firstRow <= lastRow else { return nil } + + let rowRectTop = tableView.rect(ofRow: firstRow) + let rowRectBottom = tableView.rect(ofRow: lastRow) + let topY = rowRectTop.minY + let bottomY = rowRectBottom.maxY + + var leadingX = CGFloat.infinity + var trailingX = -CGFloat.infinity + for dataColumn in rect.columns.lowerBound...rect.columns.upperBound { + guard let tableColumnIndex = DataGridView.tableColumnIndex(for: dataColumn, in: tableView, schema: schema) else { continue } + let columnRect = tableView.rect(ofColumn: tableColumnIndex) + leadingX = min(leadingX, columnRect.minX) + trailingX = max(trailingX, columnRect.maxX) + } + guard trailingX > leadingX else { return nil } + return NSRect(x: leadingX, y: topY, width: trailingX - leadingX, height: bottomY - topY) + } +} diff --git a/TablePro/Views/Results/TableSelection.swift b/TablePro/Views/Results/TableSelection.swift index ec66752b2..545dccd8c 100644 --- a/TablePro/Views/Results/TableSelection.swift +++ b/TablePro/Views/Results/TableSelection.swift @@ -3,14 +3,11 @@ import Foundation struct TableSelection: Equatable { var focusedRow: Int = -1 var focusedColumn: Int = -1 - var cellSelection: CellSelection = .none - var cellSelectionAnchor: CellPosition? func reloadIndexes(from previous: TableSelection) -> (rows: IndexSet, columns: IndexSet)? { - let focusChanged = previous.focusedRow != focusedRow || previous.focusedColumn != focusedColumn - let cellSelectionChanged = previous.cellSelection != cellSelection - - guard focusChanged || cellSelectionChanged else { return nil } + guard previous.focusedRow != focusedRow || previous.focusedColumn != focusedColumn else { + return nil + } var rows = IndexSet() var columns = IndexSet() @@ -20,13 +17,6 @@ struct TableSelection: Equatable { if focusedRow >= 0 { rows.insert(focusedRow) } if focusedColumn >= 0 { columns.insert(focusedColumn) } - if cellSelectionChanged { - rows.formUnion(previous.cellSelection.affectedRows) - rows.formUnion(cellSelection.affectedRows) - columns.formUnion(previous.cellSelection.affectedColumns) - columns.formUnion(cellSelection.affectedColumns) - } - guard !rows.isEmpty, !columns.isEmpty else { return nil } return (rows, columns) } diff --git a/TableProTests/Views/Results/CellSelectionTests.swift b/TableProTests/Views/Results/CellSelectionTests.swift index 5f3cb22fd..36dd4e7d9 100644 --- a/TableProTests/Views/Results/CellSelectionTests.swift +++ b/TableProTests/Views/Results/CellSelectionTests.swift @@ -2,198 +2,131 @@ import Foundation @testable import TablePro import Testing -@Suite("CellSelection") -struct CellSelectionTests { - // MARK: - contains - - @Test("none contains nothing") - func noneContainsNothing() { - let sel = CellSelection.none - #expect(!sel.contains(row: 0, column: 0)) - #expect(!sel.contains(row: 5, column: 3)) - } - - @Test("column contains any row in that column") - func columnContainsMatchingColumn() { - let sel = CellSelection.column(2) - #expect(sel.contains(row: 0, column: 2)) - #expect(sel.contains(row: 999, column: 2)) - #expect(!sel.contains(row: 0, column: 1)) - #expect(!sel.contains(row: 0, column: 3)) - } - - @Test("range contains rows within bounds in correct column") - func rangeContainsWithinBounds() { - let sel = CellSelection.range(column: 1, rows: 3...7) - #expect(sel.contains(row: 3, column: 1)) - #expect(sel.contains(row: 5, column: 1)) - #expect(sel.contains(row: 7, column: 1)) - #expect(!sel.contains(row: 2, column: 1)) - #expect(!sel.contains(row: 8, column: 1)) - #expect(!sel.contains(row: 5, column: 0)) - } - - @Test("cells contains only listed positions") - func cellsContainsExactPositions() { - let sel = CellSelection.cells([ - CellPosition(row: 1, column: 2), - CellPosition(row: 5, column: 3) - ]) - #expect(sel.contains(row: 1, column: 2)) - #expect(sel.contains(row: 5, column: 3)) - #expect(!sel.contains(row: 1, column: 3)) - #expect(!sel.contains(row: 2, column: 2)) - } - - // MARK: - affectedColumns - - @Test("none has no affected columns") - func noneAffectedColumns() { - #expect(CellSelection.none.affectedColumns.isEmpty) - } - - @Test("column reports single affected column") - func columnAffectedColumns() { - let sel = CellSelection.column(4) - #expect(sel.affectedColumns == IndexSet(integer: 4)) - } - - @Test("range reports single affected column") - func rangeAffectedColumns() { - let sel = CellSelection.range(column: 2, rows: 0...10) - #expect(sel.affectedColumns == IndexSet(integer: 2)) - } - - @Test("cells reports all unique columns") - func cellsAffectedColumns() { - let sel = CellSelection.cells([ - CellPosition(row: 0, column: 1), - CellPosition(row: 2, column: 3), - CellPosition(row: 4, column: 1) - ]) - #expect(sel.affectedColumns == IndexSet([1, 3])) - } - - // MARK: - affectedRows - - @Test("column has no affected rows") - func columnAffectedRows() { - #expect(CellSelection.column(0).affectedRows.isEmpty) - } - - @Test("range reports all rows in bounds") - func rangeAffectedRows() { - let sel = CellSelection.range(column: 0, rows: 2...5) - #expect(sel.affectedRows == IndexSet(integersIn: 2...5)) - } - - @Test("cells reports all unique rows") - func cellsAffectedRows() { - let sel = CellSelection.cells([ - CellPosition(row: 1, column: 0), - CellPosition(row: 3, column: 2), - CellPosition(row: 1, column: 5) - ]) - #expect(sel.affectedRows == IndexSet([1, 3])) - } - - // MARK: - isEmpty - - @Test("none is empty") - func noneIsEmpty() { - #expect(CellSelection.none.isEmpty) - } - - @Test("column is not empty") - func columnIsNotEmpty() { - #expect(!CellSelection.column(0).isEmpty) - } - - @Test("range is not empty") - func rangeIsNotEmpty() { - #expect(!CellSelection.range(column: 0, rows: 0...0).isEmpty) - } - - @Test("cells with elements is not empty") - func cellsNotEmpty() { - #expect(!CellSelection.cells([CellPosition(row: 0, column: 0)]).isEmpty) - } - - @Test("cells with empty set is empty") - func cellsEmptySetIsEmpty() { - #expect(CellSelection.cells(Set()).isEmpty) +@Suite("GridRect") +struct GridRectTests { + @Test("rect from two coords spans the bounding box regardless of order") + func betweenCoordsHandlesOrder() { + let a = GridCoord(row: 5, column: 2) + let b = GridCoord(row: 1, column: 7) + let rect = GridRect.between(a, b) + #expect(rect.rows == 1...5) + #expect(rect.columns == 2...7) + } + + @Test("contains is inclusive on both bounds") + func containsInclusiveBounds() { + let rect = GridRect(rows: 2...4, columns: 1...3) + #expect(rect.contains(GridCoord(row: 2, column: 1))) + #expect(rect.contains(GridCoord(row: 4, column: 3))) + #expect(!rect.contains(GridCoord(row: 1, column: 2))) + #expect(!rect.contains(GridCoord(row: 5, column: 2))) + #expect(!rect.contains(GridCoord(row: 3, column: 4))) + } + + @Test("clamped returns nil when rect lies entirely outside the limits") + func clampedOutsideReturnsNil() { + let rect = GridRect(rows: 10...20, columns: 5...8) + #expect(rect.clamped(rowLimit: 5, columnLimit: 10) == nil) + } + + @Test("clamped reduces a partially outside rect to the visible window") + func clampedPartialOverlap() { + let rect = GridRect(rows: 3...12, columns: -2...4) + let clamped = rect.clamped(rowLimit: 8, columnLimit: 6) + #expect(clamped?.rows == 3...7) + #expect(clamped?.columns == 0...4) } } -@Suite("TableSelection.reloadIndexes with cellSelection") -struct TableSelectionCellSelectionTests { - @Test("returns nil when nothing changed") - func noChangeReturnsNil() { - let sel = TableSelection(focusedRow: 1, focusedColumn: 2, cellSelection: .column(3)) - #expect(sel.reloadIndexes(from: sel) == nil) - } - - @Test("returns affected indexes when cellSelection changes from none to range") - func noneToRangeReturnsIndexes() { - let old = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .none) - let new = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .range(column: 2, rows: 3...5)) - let result = new.reloadIndexes(from: old) - #expect(result != nil) - #expect(result?.rows == IndexSet(integersIn: 3...5)) - #expect(result?.columns == IndexSet(integer: 2)) - } - - @Test("column-only change has empty affectedRows so reload uses the visible-column side path") - func columnChangeReturnsNilDueToEmptyRows() { - let old = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .none) - let new = TableSelection(focusedRow: -1, focusedColumn: -1, cellSelection: .column(1)) - #expect(new.reloadIndexes(from: old) == nil) - } - - @Test("focus change combined with cell selection returns union of indexes") - func focusAndCellSelectionChange() { - let old = TableSelection( - focusedRow: 0, - focusedColumn: 0, - cellSelection: .cells([CellPosition(row: 2, column: 1)]) +@Suite("GridSelection") +struct GridSelectionTests { + private let rect = GridRect(rows: 0...2, columns: 0...1) + private let active = GridCoord(row: 0, column: 0) + + @Test("empty selection contains nothing and has no bounding rect") + func emptySelection() { + let selection = GridSelection.empty + #expect(selection.isEmpty) + #expect(!selection.contains(GridCoord(row: 0, column: 0))) + #expect(selection.boundingRectangle == nil) + #expect(selection.affectedRows.isEmpty) + #expect(selection.affectedColumns.isEmpty) + } + + @Test("single rect selection reports its bounding box") + func singleRectSelection() { + let selection = GridSelection.single(rect, anchor: active, active: active) + #expect(!selection.isEmpty) + #expect(selection.contains(row: 1, column: 1)) + #expect(!selection.contains(row: 3, column: 0)) + #expect(selection.boundingRectangle == rect) + } + + @Test("multiple rectangles report union of affected rows and columns") + func multipleRectanglesUnion() { + let selection = GridSelection( + rectangles: [ + GridRect(rows: 0...0, columns: 0...0), + GridRect(rows: 5...6, columns: 3...4) + ], + activeCell: GridCoord(row: 5, column: 3), + anchor: GridCoord(row: 5, column: 3) ) - let new = TableSelection( - focusedRow: 3, - focusedColumn: 1, - cellSelection: .cells([CellPosition(row: 4, column: 2)]) + #expect(selection.affectedRows == IndexSet([0, 5, 6])) + #expect(selection.affectedColumns == IndexSet([0, 3, 4])) + } + + @Test("bounding rectangle wraps disjoint rectangles") + func boundingRectangleSpansDisjointRects() { + let selection = GridSelection( + rectangles: [ + GridRect(rows: 1...1, columns: 0...0), + GridRect(rows: 7...8, columns: 5...6) + ], + activeCell: nil, + anchor: nil ) - guard let result = new.reloadIndexes(from: old) else { - Issue.record("expected reload indexes for combined focus and cell selection change") - return - } - #expect(result.rows.contains(0)) - #expect(result.rows.contains(2)) - #expect(result.rows.contains(3)) - #expect(result.rows.contains(4)) - #expect(result.columns.contains(0)) - #expect(result.columns.contains(1)) - #expect(result.columns.contains(2)) - } - - @Test("anchor-only change does not trigger a reload") - func anchorOnlyChangeReturnsNil() { - let old = TableSelection( - focusedRow: -1, - focusedColumn: -1, - cellSelection: .cells([CellPosition(row: 1, column: 2)]), - cellSelectionAnchor: CellPosition(row: 1, column: 2) + #expect(selection.boundingRectangle == GridRect(rows: 1...8, columns: 0...6)) + } + + @Test("columns(in:) reports only rects that include the row") + func columnsInRowFiltersByRow() { + let selection = GridSelection( + rectangles: [ + GridRect(rows: 0...2, columns: 1...2), + GridRect(rows: 5...6, columns: 4...4) + ], + activeCell: nil, + anchor: nil ) - let new = TableSelection( - focusedRow: -1, - focusedColumn: -1, - cellSelection: .cells([CellPosition(row: 1, column: 2)]), - cellSelectionAnchor: CellPosition(row: 5, column: 2) + #expect(selection.columns(in: 1) == IndexSet([1, 2])) + #expect(selection.columns(in: 6) == IndexSet(integer: 4)) + #expect(selection.columns(in: 3).isEmpty) + } + + @Test("contains is true if any rectangle includes the coord") + func containsAnyRectangle() { + let selection = GridSelection( + rectangles: [ + GridRect(rows: 0...0, columns: 0...0), + GridRect(rows: 5...6, columns: 3...4) + ], + activeCell: nil, + anchor: nil ) - #expect(new.reloadIndexes(from: old) == nil) - } - - @Test("default TableSelection has no anchor") - func defaultHasNoAnchor() { - #expect(TableSelection().cellSelectionAnchor == nil) + #expect(selection.contains(row: 0, column: 0)) + #expect(selection.contains(row: 6, column: 4)) + #expect(!selection.contains(row: 2, column: 2)) + } + + @Test("union merges rectangles, taking the new active and anchor when present") + func unionPrefersOtherActiveAndAnchor() { + let lhs = GridSelection.single(GridRect(rows: 0...0, columns: 0...0), anchor: active, active: active) + let other = GridCoord(row: 4, column: 4) + let rhs = GridSelection.single(GridRect(rows: 4...4, columns: 4...4), anchor: other, active: other) + let merged = lhs.union(rhs) + #expect(merged.rectangles.count == 2) + #expect(merged.activeCell == other) + #expect(merged.anchor == other) } } diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index a73c97270..537a11d45 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -133,13 +133,13 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut |--------|----------| | Select cell | Click | | Select row | Click row number | -| Select multiple cells | Click + drag | +| Select rectangular range | Click + drag | +| Extend selection | `Shift` + click | +| Add cell to selection | `Cmd` + click | | Select entire column | `Cmd` + click column header | -| Select cell range in column | Click cell, then `Shift` + click another cell in same column | -| Toggle cell in selection | `Cmd` + click cell | +| Extend selection by one cell | `Shift+Arrow` | +| Extend selection to grid edge | `Cmd+Shift+Arrow` | | Clear cell selection | `Escape` | -| Extend selection | Shift + click | -| Add to selection | Cmd + click | | Extend selection by row | `Shift+Up` / `Shift+Down` | | Select to first row | `Shift+Home` | | Select to last row | `Shift+End` | From 6d6ae567771e77f3f4813d1fc5edc56eec5120e0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:04:23 +0700 Subject: [PATCH 06/13] feat(datagrid): close remaining cell-selection gaps from the review Clear the selection on any structural reload (sort, filter, query change) by hooking applyStructuralUpdate so display-indexed coordinates can never refer to a different record after the table reorders. Cmd+drag now appends a fresh rectangle to the existing selection, matching Numbers and Excel additive selection. The drag-tracking loop reports back whether the pointer actually moved so plain cmd+click still toggles a single cell and cmd+drag builds a new rectangle. Right-click inside an existing cell selection preserves the selection instead of collapsing it to a single-row selection, so context-menu actions can act on the rectangle the user already drew. Draw a 2pt accent-color border around the active cell within multi-cell selections so the keyboard or paste target is distinguishable from the rest of the highlight, the same affordance Numbers uses. When VoiceOver is enabled, post an accessibility announcement on every selection change ("5 cells selected, rows 2 to 6, columns 1 to 1" or "Cell selection cleared") so assistive tech users get an audible summary. --- TablePro/Views/Results/DataGridView.swift | 1 + .../Views/Results/KeyHandlingTableView.swift | 22 ++++- .../Selection/GridSelectionController.swift | 79 ++++++++++++--- .../Selection/GridSelectionOverlay.swift | 16 ++- .../Views/Results/CellSelectionTests.swift | 98 +++++++++++++++++++ 5 files changed, 194 insertions(+), 22 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index c9299595b..16e5d310c 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -281,6 +281,7 @@ struct DataGridView: NSViewRepresentable { } if needsFullReload { + coordinator.selectionController.clear() tableView.reloadData() coordinator.startBackgroundPrewarm() } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index d004697ed..658b9df67 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -149,7 +149,8 @@ final class KeyHandlingTableView: NSTableView { super.mouseDown(with: event) } - if modifiers.intersection([.command, .shift]).isEmpty { + let supportsDrag = !modifiers.contains(.shift) + if supportsDrag { trackDrag(initial: coord, schema: schema) } @@ -164,10 +165,11 @@ final class KeyHandlingTableView: NSTableView { private func trackDrag(initial: GridCoord, schema: ColumnIdentitySchema) { guard let window, let controller = gridSelection else { return } + var dragged = false let mask: NSEvent.EventTypeMask = [.leftMouseDragged, .leftMouseUp] while let event = window.nextEvent(matching: mask) { if event.type == .leftMouseUp { - controller.endDrag() + controller.endDrag(dragged: dragged, originalCoord: initial) return } let point = convert(event.locationInWindow, from: nil) @@ -175,7 +177,9 @@ final class KeyHandlingTableView: NSTableView { let rowIdx = clampRow(row(at: point)) let columnIdx = clampDataColumn(column(at: point), schema: schema) guard rowIdx >= 0, columnIdx >= 0 else { continue } - controller.continueDrag(to: GridCoord(row: rowIdx, column: columnIdx)) + let coord = GridCoord(row: rowIdx, column: columnIdx) + if coord != initial { dragged = true } + controller.continueDrag(to: coord) } } @@ -490,9 +494,17 @@ final class KeyHandlingTableView: NSTableView { override func menu(for event: NSEvent) -> NSMenu? { let point = convert(event.locationInWindow, from: nil) let clickedRow = row(at: point) + let clickedColumn = column(at: point) - if clickedRow >= 0, - let rowView = rowView(atRow: clickedRow, makeIfNecessary: false) { + if clickedRow >= 0, let rowView = rowView(atRow: clickedRow, makeIfNecessary: false) { + if let schema = coordinator?.identitySchema, + clickedColumn >= 0, + let dataColumn = DataGridView.dataColumnIndex(for: clickedColumn, in: self, schema: schema), + let controller = gridSelection, + !controller.isEmpty, + controller.selection.contains(row: clickedRow, column: dataColumn) { + return rowView.menu(for: event) + } if !selectedRowIndexes.contains(clickedRow) { selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) } diff --git a/TablePro/Views/Results/Selection/GridSelectionController.swift b/TablePro/Views/Results/Selection/GridSelectionController.swift index af09bb560..3454bef81 100644 --- a/TablePro/Views/Results/Selection/GridSelectionController.swift +++ b/TablePro/Views/Results/Selection/GridSelectionController.swift @@ -16,10 +16,17 @@ final class GridSelectionController { weak var coordinator: TableViewCoordinator? private var dragOrigin: GridCoord? + private var dragMode: DragMode = .replace + private var dragBaseSelection: GridSelection = .empty var onSelectionChange: ((GridSelection) -> Void)? var isEmpty: Bool { selection.isEmpty } + private enum DragMode { + case replace + case additive + } + func update(_ newSelection: GridSelection) { guard selection != newSelection else { return } let old = selection @@ -27,9 +34,38 @@ final class GridSelectionController { overlay?.selection = newSelection let dirty = reloadColumns(for: old, new: newSelection) reloadRowsForFill(old: old, new: newSelection, dirtyColumns: dirty) + postAccessibilityAnnouncement(for: newSelection) onSelectionChange?(newSelection) } + private func postAccessibilityAnnouncement(for newSelection: GridSelection) { + guard NSWorkspace.shared.isVoiceOverEnabled, let tableView else { return } + let announcement: String + if newSelection.isEmpty { + announcement = String(localized: "Cell selection cleared") + } else if let rect = newSelection.boundingRectangle { + let cellCount = newSelection.rectangles.reduce(0) { $0 + ($1.rows.count * $1.columns.count) } + announcement = String( + format: String(localized: "%d cells selected, rows %d to %d, columns %d to %d"), + cellCount, + rect.rows.lowerBound + 1, + rect.rows.upperBound + 1, + rect.columns.lowerBound + 1, + rect.columns.upperBound + 1 + ) + } else { + return + } + NSAccessibility.post( + element: tableView, + notification: .announcementRequested, + userInfo: [ + .announcement: announcement, + .priority: NSAccessibilityPriorityLevel.medium.rawValue + ] + ) + } + func clear() { guard !selection.isEmpty else { return } update(.empty) @@ -38,42 +74,61 @@ final class GridSelectionController { func beginDrag(at coord: GridCoord, modifiers: NSEvent.ModifierFlags) -> MouseDisposition { let cleanModifiers = modifiers.intersection([.command, .shift, .option, .control]) if cleanModifiers.contains(.command) && !cleanModifiers.contains(.shift) { - return toggleCell(coord) + dragOrigin = coord + dragMode = .additive + dragBaseSelection = selection + return .replaceFocus(coord) } if cleanModifiers.contains(.shift) && !cleanModifiers.contains(.command) { return extend(to: coord) } dragOrigin = coord + dragMode = .replace + dragBaseSelection = .empty update(.single(GridRect(cell: coord), anchor: coord, active: coord)) return .replaceFocus(coord) } func continueDrag(to coord: GridCoord) { guard let origin = dragOrigin else { return } - update(.single(GridRect.between(origin, coord), anchor: origin, active: coord)) + switch dragMode { + case .replace: + update(.single(GridRect.between(origin, coord), anchor: origin, active: coord)) + case .additive: + var rectangles = dragBaseSelection.rectangles + rectangles.append(GridRect.between(origin, coord)) + update(GridSelection(rectangles: rectangles, activeCell: coord, anchor: origin)) + } } - func endDrag() { - dragOrigin = nil + func endDrag(dragged: Bool, originalCoord: GridCoord) { + defer { + dragOrigin = nil + dragMode = .replace + dragBaseSelection = .empty + } + guard !dragged, dragMode == .additive else { return } + applyCmdClickToggle(at: originalCoord) } - private func toggleCell(_ coord: GridCoord) -> MouseDisposition { + private func applyCmdClickToggle(at coord: GridCoord) { let cellRect = GridRect(cell: coord) - var rectangles = selection.rectangles + var rectangles = dragBaseSelection.rectangles if let index = rectangles.firstIndex(where: { $0 == cellRect }) { rectangles.remove(at: index) if rectangles.isEmpty { update(.empty) - return .clearFocus + return } - update(GridSelection(rectangles: rectangles, activeCell: rectangles.last.flatMap(activeCell(in:)), anchor: selection.anchor)) - return .replaceFocus(rectangles.last.flatMap(activeCell(in:)) ?? coord) + let last = rectangles[rectangles.count - 1] + let active = GridCoord(row: last.rows.lowerBound, column: last.columns.lowerBound) + update(GridSelection(rectangles: rectangles, activeCell: active, anchor: dragBaseSelection.anchor)) + return } rectangles.append(cellRect) update(GridSelection(rectangles: rectangles, activeCell: coord, anchor: coord)) - return .replaceFocus(coord) } private func extend(to coord: GridCoord) -> MouseDisposition { @@ -82,10 +137,6 @@ final class GridSelectionController { return .replaceFocus(coord) } - private func activeCell(in rect: GridRect) -> GridCoord { - GridCoord(row: rect.rows.lowerBound, column: rect.columns.lowerBound) - } - func selectAll(totalRows: Int, totalColumns: Int) { guard totalRows > 0, totalColumns > 0 else { return } let rect = GridRect(rows: 0...(totalRows - 1), columns: 0...(totalColumns - 1)) diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift index 684c151e8..7e7672d4f 100644 --- a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -13,6 +13,7 @@ final class GridSelectionOverlay: NSView { weak var coordinator: TableViewCoordinator? private static let borderWidth: CGFloat = 1.5 + private static let activeCellBorderWidth: CGFloat = 2.0 override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -34,9 +35,7 @@ final class GridSelectionOverlay: NSView { guard let tableView, let coordinator else { return } let schema = coordinator.identitySchema - let borderColor = NSColor.selectedContentBackgroundColor - borderColor.setStroke() - + NSColor.selectedContentBackgroundColor.setStroke() for rect in selection.rectangles { guard let frame = frame(for: rect, in: tableView, schema: schema) else { continue } guard frame.intersects(dirtyRect) else { continue } @@ -45,6 +44,17 @@ final class GridSelectionOverlay: NSView { path.lineWidth = Self.borderWidth path.stroke() } + + if let active = selection.activeCell, + selection.rectangles.count > 1 || (selection.rectangles.first?.rows.count ?? 0) > 1 || (selection.rectangles.first?.columns.count ?? 0) > 1, + let frame = frame(for: GridRect(cell: active), in: tableView, schema: schema), + frame.intersects(dirtyRect) { + NSColor.controlAccentColor.setStroke() + let inset = frame.insetBy(dx: Self.activeCellBorderWidth / 2, dy: Self.activeCellBorderWidth / 2) + let path = NSBezierPath(rect: inset) + path.lineWidth = Self.activeCellBorderWidth + path.stroke() + } } private func frame(for rect: GridRect, in tableView: NSTableView, schema: ColumnIdentitySchema) -> NSRect? { diff --git a/TableProTests/Views/Results/CellSelectionTests.swift b/TableProTests/Views/Results/CellSelectionTests.swift index 36dd4e7d9..16e9a6d82 100644 --- a/TableProTests/Views/Results/CellSelectionTests.swift +++ b/TableProTests/Views/Results/CellSelectionTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation @testable import TablePro import Testing @@ -130,3 +131,100 @@ struct GridSelectionTests { #expect(merged.anchor == other) } } + +@Suite("GridSelectionController gestures") +@MainActor +struct GridSelectionControllerTests { + @Test("plain mouseDown replaces the selection with the clicked cell") + func plainClickReplacesSelection() { + let controller = GridSelectionController() + let coord = GridCoord(row: 2, column: 3) + _ = controller.beginDrag(at: coord, modifiers: []) + controller.endDrag(dragged: false, originalCoord: coord) + #expect(controller.selection.rectangles == [GridRect(cell: coord)]) + #expect(controller.selection.anchor == coord) + #expect(controller.selection.activeCell == coord) + } + + @Test("drag extends to a rectangle anchored at the mousedown coord") + func dragBuildsRectangle() { + let controller = GridSelectionController() + let origin = GridCoord(row: 1, column: 1) + let target = GridCoord(row: 4, column: 3) + _ = controller.beginDrag(at: origin, modifiers: []) + controller.continueDrag(to: target) + controller.endDrag(dragged: true, originalCoord: origin) + #expect(controller.selection.rectangles == [GridRect(rows: 1...4, columns: 1...3)]) + #expect(controller.selection.anchor == origin) + #expect(controller.selection.activeCell == target) + } + + @Test("shift extends the existing selection from the anchor across columns") + func shiftExtendsAcrossColumns() { + let controller = GridSelectionController() + let origin = GridCoord(row: 2, column: 2) + let target = GridCoord(row: 5, column: 6) + _ = controller.beginDrag(at: origin, modifiers: []) + controller.endDrag(dragged: false, originalCoord: origin) + _ = controller.beginDrag(at: target, modifiers: .shift) + #expect(controller.selection.rectangles == [GridRect(rows: 2...5, columns: 2...6)]) + #expect(controller.selection.anchor == origin) + #expect(controller.selection.activeCell == target) + } + + @Test("cmd+click without drag toggles a single cell") + func cmdClickTogglesCell() { + let controller = GridSelectionController() + let first = GridCoord(row: 0, column: 0) + let second = GridCoord(row: 3, column: 4) + _ = controller.beginDrag(at: first, modifiers: []) + controller.endDrag(dragged: false, originalCoord: first) + _ = controller.beginDrag(at: second, modifiers: .command) + controller.endDrag(dragged: false, originalCoord: second) + #expect(controller.selection.rectangles == [GridRect(cell: first), GridRect(cell: second)]) + + _ = controller.beginDrag(at: second, modifiers: .command) + controller.endDrag(dragged: false, originalCoord: second) + #expect(controller.selection.rectangles == [GridRect(cell: first)]) + } + + @Test("cmd+drag appends a fresh rectangle without clobbering the base selection") + func cmdDragAppendsRectangle() { + let controller = GridSelectionController() + let baseOrigin = GridCoord(row: 0, column: 0) + let baseTarget = GridCoord(row: 1, column: 1) + _ = controller.beginDrag(at: baseOrigin, modifiers: []) + controller.continueDrag(to: baseTarget) + controller.endDrag(dragged: true, originalCoord: baseOrigin) + + let cmdOrigin = GridCoord(row: 5, column: 5) + let cmdTarget = GridCoord(row: 7, column: 7) + _ = controller.beginDrag(at: cmdOrigin, modifiers: .command) + controller.continueDrag(to: cmdTarget) + controller.endDrag(dragged: true, originalCoord: cmdOrigin) + + #expect(controller.selection.rectangles == [ + GridRect(rows: 0...1, columns: 0...1), + GridRect(rows: 5...7, columns: 5...7) + ]) + #expect(controller.selection.activeCell == cmdTarget) + } + + @Test("selectAll covers every cell") + func selectAllSpansGrid() { + let controller = GridSelectionController() + controller.selectAll(totalRows: 4, totalColumns: 3) + #expect(controller.selection.rectangles == [GridRect(rows: 0...3, columns: 0...2)]) + #expect(controller.selection.activeCell == GridCoord(row: 0, column: 0)) + } + + @Test("clear empties the selection") + func clearEmpties() { + let controller = GridSelectionController() + let coord = GridCoord(row: 0, column: 0) + _ = controller.beginDrag(at: coord, modifiers: []) + controller.endDrag(dragged: false, originalCoord: coord) + controller.clear() + #expect(controller.selection.isEmpty) + } +} From deba9b4d04810acf46e045b30be84c4fd58e9303 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:19:23 +0700 Subject: [PATCH 07/13] fix(datagrid): restore double-click edit, drop unwanted single-click selection A plain click no longer creates a 1x1 rectangle in the GridSelection. The previous behaviour painted a perimeter border and an accent-tinted column header on every click, on top of the existing focus ring, so a focused cell looked over-decorated. beginDrag with no modifiers now just arms the drag origin and clears any prior selection; the rectangle only materialises after the pointer actually moves. Cmd+click and Shift+click are unchanged. Double-click now reaches NSTableView's doubleAction. The previous mouseDown swallowed the event without calling super for clickCount >= 2, so handleDoubleClick (the inline-edit entry point) never fired. Click counts >= 2 now forward to super.mouseDown before any selection logic runs. Header column highlight is only drawn for rectangles whose row range covers every row in the table. A single-cell or partial-range selection no longer tints the column header. --- TablePro/Resources/Localizable.xcstrings | 13 +++++++ .../Views/Results/KeyHandlingTableView.swift | 19 ++++----- .../Selection/GridSelectionController.swift | 28 ++++++++----- .../Views/Results/CellSelectionTests.swift | 39 ++++++++++++++----- 4 files changed, 71 insertions(+), 28 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 32af71936..6158570e6 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1541,6 +1541,16 @@ "%1$@, %2$@" : { "shouldTranslate" : false }, + "%d cells selected, rows %d to %d, columns %d to %d" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d cells selected, rows %2$d to %3$d, columns %4$d to %5$d" + } + } + } + }, "%d columns" : { }, @@ -8712,6 +8722,9 @@ } } } + }, + "Cell selection cleared" : { + }, "Cell Value" : { "localizations" : { diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 658b9df67..8ec4b33c0 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -115,6 +115,11 @@ final class KeyHandlingTableView: NSTableView { let isDataColumn = column.identifier != ColumnIdentitySchema.rowNumberIdentifier let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if event.clickCount >= 2 { + super.mouseDown(with: event) + return + } + guard isDataColumn, let schema = coordinator?.identitySchema, let dataColumn = DataGridView.dataColumnIndex(for: clickedColumn, in: self, schema: schema) else { @@ -135,10 +140,10 @@ final class KeyHandlingTableView: NSTableView { } let disposition = controller.beginDrag(at: coord, modifiers: modifiers) - switch disposition { case .replaceFocus(let activeCoord): - selectRowIndexes(IndexSet(integer: activeCoord.row), byExtendingSelection: false) + let byExtending = modifiers.contains(.shift) || modifiers.contains(.command) + selectRowIndexes(IndexSet(integer: activeCoord.row), byExtendingSelection: byExtending) focusedRow = activeCoord.row focusedColumn = DataGridView.tableColumnIndex(for: activeCoord.column, in: self, schema: schema) ?? clickedColumn case .clearFocus: @@ -149,14 +154,10 @@ final class KeyHandlingTableView: NSTableView { super.mouseDown(with: event) } - let supportsDrag = !modifiers.contains(.shift) - if supportsDrag { - trackDrag(initial: coord, schema: schema) - } + trackDrag(initial: coord, schema: schema) - if alreadyFocusedHere, - modifiers.isEmpty, - event.clickCount == 1, + if modifiers.isEmpty, + alreadyFocusedHere, selectedRowIndexes.count == 1, coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumn) == true { coordinator?.beginCellEdit(row: clickedRow, tableColumnIndex: clickedColumn) diff --git a/TablePro/Views/Results/Selection/GridSelectionController.swift b/TablePro/Views/Results/Selection/GridSelectionController.swift index 3454bef81..bb166e00a 100644 --- a/TablePro/Views/Results/Selection/GridSelectionController.swift +++ b/TablePro/Views/Results/Selection/GridSelectionController.swift @@ -80,12 +80,19 @@ final class GridSelectionController { return .replaceFocus(coord) } if cleanModifiers.contains(.shift) && !cleanModifiers.contains(.command) { - return extend(to: coord) + let anchor = selection.anchor ?? coord + dragOrigin = anchor + dragMode = .replace + dragBaseSelection = .empty + update(.single(GridRect.between(anchor, coord), anchor: anchor, active: coord)) + return .replaceFocus(coord) } dragOrigin = coord dragMode = .replace dragBaseSelection = .empty - update(.single(GridRect(cell: coord), anchor: coord, active: coord)) + if !selection.isEmpty { + update(.empty) + } return .replaceFocus(coord) } @@ -131,12 +138,6 @@ final class GridSelectionController { update(GridSelection(rectangles: rectangles, activeCell: coord, anchor: coord)) } - private func extend(to coord: GridCoord) -> MouseDisposition { - let origin = selection.anchor ?? coord - update(.single(GridRect.between(origin, coord), anchor: origin, active: coord)) - return .replaceFocus(coord) - } - func selectAll(totalRows: Int, totalColumns: Int) { guard totalRows > 0, totalColumns > 0 else { return } let rect = GridRect(rows: 0...(totalRows - 1), columns: 0...(totalColumns - 1)) @@ -189,13 +190,22 @@ final class GridSelectionController { let union = old.affectedColumns.union(new.affectedColumns) if let headerView = (tableView as? KeyHandlingTableView)?.headerView as? SortableHeaderView { headerView.updateColumnSelectionIndicators( - selectedColumns: new.affectedColumns, + selectedColumns: fullySelectedColumns(in: new), dirtyColumns: union ) } return union } + private func fullySelectedColumns(in selection: GridSelection) -> IndexSet { + guard let totalRows = tableView?.numberOfRows, totalRows > 0 else { return IndexSet() } + var fully = IndexSet() + for rect in selection.rectangles where rect.rows.lowerBound <= 0 && rect.rows.upperBound >= totalRows - 1 { + fully.insert(integersIn: rect.columns.lowerBound...rect.columns.upperBound) + } + return fully + } + private func reloadRowsForFill(old: GridSelection, new: GridSelection, dirtyColumns: IndexSet) { guard let tableView = tableView else { return } guard tableView.numberOfRows > 0 else { return } diff --git a/TableProTests/Views/Results/CellSelectionTests.swift b/TableProTests/Views/Results/CellSelectionTests.swift index 16e9a6d82..d5e9bf1f5 100644 --- a/TableProTests/Views/Results/CellSelectionTests.swift +++ b/TableProTests/Views/Results/CellSelectionTests.swift @@ -135,15 +135,28 @@ struct GridSelectionTests { @Suite("GridSelectionController gestures") @MainActor struct GridSelectionControllerTests { - @Test("plain mouseDown replaces the selection with the clicked cell") - func plainClickReplacesSelection() { + @Test("plain click without drag leaves the selection empty") + func plainClickWithoutDragHasNoSelection() { let controller = GridSelectionController() let coord = GridCoord(row: 2, column: 3) _ = controller.beginDrag(at: coord, modifiers: []) controller.endDrag(dragged: false, originalCoord: coord) - #expect(controller.selection.rectangles == [GridRect(cell: coord)]) - #expect(controller.selection.anchor == coord) - #expect(controller.selection.activeCell == coord) + #expect(controller.selection.isEmpty) + } + + @Test("plain click on an existing selection clears it without creating a new rect") + func plainClickClearsPreviousSelection() { + let controller = GridSelectionController() + let first = GridCoord(row: 0, column: 0) + let second = GridCoord(row: 3, column: 4) + _ = controller.beginDrag(at: first, modifiers: []) + controller.continueDrag(to: GridCoord(row: 1, column: 2)) + controller.endDrag(dragged: true, originalCoord: first) + #expect(!controller.selection.isEmpty) + + _ = controller.beginDrag(at: second, modifiers: []) + controller.endDrag(dragged: false, originalCoord: second) + #expect(controller.selection.isEmpty) } @Test("drag extends to a rectangle anchored at the mousedown coord") @@ -163,13 +176,16 @@ struct GridSelectionControllerTests { func shiftExtendsAcrossColumns() { let controller = GridSelectionController() let origin = GridCoord(row: 2, column: 2) - let target = GridCoord(row: 5, column: 6) + let dragTo = GridCoord(row: 2, column: 2) _ = controller.beginDrag(at: origin, modifiers: []) - controller.endDrag(dragged: false, originalCoord: origin) - _ = controller.beginDrag(at: target, modifiers: .shift) + controller.continueDrag(to: dragTo) + controller.endDrag(dragged: true, originalCoord: origin) + + let shiftTarget = GridCoord(row: 5, column: 6) + _ = controller.beginDrag(at: shiftTarget, modifiers: .shift) #expect(controller.selection.rectangles == [GridRect(rows: 2...5, columns: 2...6)]) #expect(controller.selection.anchor == origin) - #expect(controller.selection.activeCell == target) + #expect(controller.selection.activeCell == shiftTarget) } @Test("cmd+click without drag toggles a single cell") @@ -177,8 +193,11 @@ struct GridSelectionControllerTests { let controller = GridSelectionController() let first = GridCoord(row: 0, column: 0) let second = GridCoord(row: 3, column: 4) - _ = controller.beginDrag(at: first, modifiers: []) + + _ = controller.beginDrag(at: first, modifiers: .command) controller.endDrag(dragged: false, originalCoord: first) + #expect(controller.selection.rectangles == [GridRect(cell: first)]) + _ = controller.beginDrag(at: second, modifiers: .command) controller.endDrag(dragged: false, originalCoord: second) #expect(controller.selection.rectangles == [GridRect(cell: first), GridRect(cell: second)]) From 28218efa8c94c19d48b761e96aa06f16db4efe81 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:27:31 +0700 Subject: [PATCH 08/13] refactor(datagrid): align selection visuals with native macOS patterns Selection rendering was inverted: a 1.5pt 100% accent border was the dominant feature while the header and body fills were a barely visible 18% accent. The pattern in NSTableView row selection, Numbers, and Excel is the opposite, fill carries the meaning and borders are subtle or absent. Header now fills with NSColor.selectedContentBackgroundColor at full opacity when its column is selected, with text and sort indicator switched to alternateSelectedControlTextColor for contrast. This makes the header the strong anchor of a column selection, the way Numbers does it. Body fill bumped from 0.18 to 0.28 alpha so the selection reads as deliberate without competing with the row striping. Perimeter border now skips full-height rectangles (column selections, full-grid selectAll) because the header and fill already communicate the selection there. For arbitrary rectangles produced by drag and shift+drag, the border is now 1pt at 70% of the fill hue instead of 1.5pt at 100% accent, so it relates visually to the fill instead of overpowering it. Active-cell border is unchanged at 2pt controlAccentColor, only drawn when the selection spans more than one cell. --- TablePro/Views/Results/DataGridRowView.swift | 2 +- .../Selection/GridSelectionOverlay.swift | 12 +++++- .../Views/Results/SortableHeaderCell.swift | 43 +++++++++++-------- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index aae11c82c..5365ec2b7 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -101,7 +101,7 @@ class DataGridRowView: NSTableRowView { let fillColor: NSColor = isSelected ? NSColor.unemphasizedSelectedContentBackgroundColor - : NSColor.selectedContentBackgroundColor.withAlphaComponent(0.18) + : NSColor.selectedContentBackgroundColor.withAlphaComponent(0.28) fillColor.setFill() let schema = coordinator?.identitySchema diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift index 7e7672d4f..bb29d22e6 100644 --- a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -12,8 +12,9 @@ final class GridSelectionOverlay: NSView { weak var tableView: NSTableView? weak var coordinator: TableViewCoordinator? - private static let borderWidth: CGFloat = 1.5 + private static let borderWidth: CGFloat = 1.0 private static let activeCellBorderWidth: CGFloat = 2.0 + private static let borderAlpha: CGFloat = 0.7 override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -34,11 +35,13 @@ final class GridSelectionOverlay: NSView { override func draw(_ dirtyRect: NSRect) { guard let tableView, let coordinator else { return } let schema = coordinator.identitySchema + let totalRows = tableView.numberOfRows - NSColor.selectedContentBackgroundColor.setStroke() + NSColor.selectedContentBackgroundColor.withAlphaComponent(Self.borderAlpha).setStroke() for rect in selection.rectangles { guard let frame = frame(for: rect, in: tableView, schema: schema) else { continue } guard frame.intersects(dirtyRect) else { continue } + if isFullHeight(rect, totalRows: totalRows) { continue } let inset = frame.insetBy(dx: Self.borderWidth / 2, dy: Self.borderWidth / 2) let path = NSBezierPath(rect: inset) path.lineWidth = Self.borderWidth @@ -57,6 +60,11 @@ final class GridSelectionOverlay: NSView { } } + private func isFullHeight(_ rect: GridRect, totalRows: Int) -> Bool { + guard totalRows > 0 else { return false } + return rect.rows.lowerBound <= 0 && rect.rows.upperBound >= totalRows - 1 + } + private func frame(for rect: GridRect, in tableView: NSTableView, schema: ColumnIdentitySchema) -> NSRect? { guard tableView.numberOfRows > 0, tableView.numberOfColumns > 0 else { return nil } let firstRow = max(0, rect.rows.lowerBound) diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index aa8465deb..30598be21 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -32,15 +32,20 @@ final class SortableHeaderCell: NSTableHeaderCell { override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { if isColumnSelected { - NSColor.controlAccentColor.withAlphaComponent(0.18).setFill() + NSColor.selectedContentBackgroundColor.setFill() cellFrame.fill() } - drawTitle(in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: sortDirection != nil)) + let foreground = foregroundColor(emphasized: isColumnSelected) + drawTitle( + in: titleRect(forBounds: cellFrame), + font: titleFont(isSorted: sortDirection != nil), + color: foreground + ) guard let direction = sortDirection else { return } - let indicatorImage = Self.indicatorImage(for: direction) + let indicatorImage = Self.indicatorImage(for: direction, color: foreground) let indicatorSize = indicatorImage?.size ?? Self.defaultIndicatorSize let indicatorOriginX = cellFrame.maxX - Self.indicatorPadding - indicatorSize.width let indicatorOriginY = cellFrame.midY - indicatorSize.height / 2 @@ -53,7 +58,7 @@ final class SortableHeaderCell: NSTableHeaderCell { Self.drawIndicator(image: indicatorImage, in: indicatorRect) if let priorityText = priorityNumberString() { - let priorityWidth = Self.measureWidth(of: priorityText) + let priorityWidth = Self.measureWidth(of: priorityText, color: foreground) let textOriginX = indicatorOriginX - Self.indicatorSpacing - priorityWidth let textRect = NSRect( x: textOriginX, @@ -61,7 +66,7 @@ final class SortableHeaderCell: NSTableHeaderCell { width: priorityWidth, height: cellFrame.height ) - Self.drawPriorityText(priorityText, in: textRect) + Self.drawPriorityText(priorityText, in: textRect, color: foreground) } } @@ -78,10 +83,10 @@ final class SortableHeaderCell: NSTableHeaderCell { private func reservedTrailingWidth() -> CGFloat { guard let direction = sortDirection else { return 0 } - let indicatorWidth = Self.indicatorImage(for: direction)?.size.width + let indicatorWidth = Self.indicatorImage(for: direction, color: .secondaryLabelColor)?.size.width ?? Self.defaultIndicatorSize.width let priorityText = priorityNumberString() - let priorityComponent = priorityText.map { Self.measureWidth(of: $0) + Self.indicatorSpacing } ?? 0 + let priorityComponent = priorityText.map { Self.measureWidth(of: $0, color: .secondaryLabelColor) + Self.indicatorSpacing } ?? 0 return indicatorWidth + Self.indicatorPadding * 2 + priorityComponent } @@ -91,14 +96,18 @@ final class SortableHeaderCell: NSTableHeaderCell { return NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask) } - private func drawTitle(in rect: NSRect, font titleFont: NSFont) { + private func foregroundColor(emphasized: Bool) -> NSColor { + emphasized ? .alternateSelectedControlTextColor : .headerTextColor + } + + private func drawTitle(in rect: NSRect, font titleFont: NSFont, color: NSColor) { let paragraph = NSMutableParagraphStyle() paragraph.alignment = alignment paragraph.lineBreakMode = .byTruncatingTail let attributes: [NSAttributedString.Key: Any] = [ .font: titleFont, - .foregroundColor: NSColor.headerTextColor, + .foregroundColor: color, .paragraphStyle: paragraph ] @@ -142,10 +151,10 @@ final class SortableHeaderCell: NSTableHeaderCell { return String(sortPriority) } - private static func indicatorImage(for direction: SortDirection) -> NSImage? { + private static func indicatorImage(for direction: SortDirection, color: NSColor) -> NSImage? { let symbolName = direction == .ascending ? "chevron.up" : "chevron.down" let configuration = NSImage.SymbolConfiguration(pointSize: priorityFontSize, weight: .semibold) - .applying(.init(hierarchicalColor: .secondaryLabelColor)) + .applying(.init(hierarchicalColor: color)) return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? .withSymbolConfiguration(configuration) } @@ -162,8 +171,8 @@ final class SortableHeaderCell: NSTableHeaderCell { ) } - private static func drawPriorityText(_ text: String, in rect: NSRect) { - let attributes = priorityAttributes() + private static func drawPriorityText(_ text: String, in rect: NSRect, color: NSColor) { + let attributes = priorityAttributes(color: color) let textSize = (text as NSString).size(withAttributes: attributes) let drawRect = NSRect( x: rect.minX, @@ -174,14 +183,14 @@ final class SortableHeaderCell: NSTableHeaderCell { (text as NSString).draw(in: drawRect, withAttributes: attributes) } - private static func measureWidth(of text: String) -> CGFloat { - (text as NSString).size(withAttributes: priorityAttributes()).width + private static func measureWidth(of text: String, color: NSColor) -> CGFloat { + (text as NSString).size(withAttributes: priorityAttributes(color: color)).width } - private static func priorityAttributes() -> [NSAttributedString.Key: Any] { + private static func priorityAttributes(color: NSColor) -> [NSAttributedString.Key: Any] { [ .font: NSFont.systemFont(ofSize: priorityFontSize, weight: .medium), - .foregroundColor: NSColor.secondaryLabelColor + .foregroundColor: color ] } } From 48e1ce61cccc19b4d618f4d9a4e11e6952f2f133 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:32:16 +0700 Subject: [PATCH 09/13] fix(datagrid): suppress cell focus indicators while overlay editor is active The inline editor sits on top of a cell that still draws its own exterior focus ring and, when the row is emphasized, its manual focus border. The editor adds another 2pt keyboardFocusIndicatorColor border on its container, so the user sees two stacked outlines on the same cell in edit mode. DataGridCellView now exposes applyOverlayActive so it can hide the focus ring, the focus-ring mask and the manual drawFocusBorder while an overlay is on top. CellOverlayBase.install and removeOverlay call into this so the cell goes quiet when the editor or viewer mounts and lights up again when it dismisses, leaving the editor border as the only focus signal during editing. --- TablePro/Views/Results/CellOverlayBase.swift | 8 ++++++++ .../Views/Results/Cells/DataGridCellView.swift | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Results/CellOverlayBase.swift b/TablePro/Views/Results/CellOverlayBase.swift index eb940e47a..10b32588d 100644 --- a/TablePro/Views/Results/CellOverlayBase.swift +++ b/TablePro/Views/Results/CellOverlayBase.swift @@ -51,9 +51,14 @@ class CellOverlayBase: NSObject { self.columnIndex = columnIndex tableView.addSubview(container) self.container = container + underlyingCell(in: tableView, row: row, column: column)?.applyOverlayActive(true) installDismissObservers() } + private func underlyingCell(in tableView: NSTableView, row: Int, column: Int) -> DataGridCellView? { + tableView.view(atColumn: column, row: row, makeIfNecessary: false) as? DataGridCellView + } + func handleDismiss(reason: CellOverlayDismissReason) { removeOverlay() } @@ -61,6 +66,9 @@ class CellOverlayBase: NSObject { func removeOverlay() { guard let activeContainer = container else { return } removeDismissObservers() + if let hostTableView { + underlyingCell(in: hostTableView, row: row, column: column)?.applyOverlayActive(false) + } activeContainer.removeFromSuperview() container = nil if let hostTableView { diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index f8b6cc847..8db899ed2 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -30,6 +30,7 @@ final class DataGridCellView: NSView { private var visualState: RowVisualState = .empty private var isFocusedCell: Bool = false private var onEmphasizedSelection: Bool = false + private var hasOverlay: Bool = false private var cachedLine: CTLine? @@ -175,18 +176,26 @@ final class DataGridCellView: NSView { updateFocusPresentation() } + func applyOverlayActive(_ value: Bool) { + guard hasOverlay != value else { return } + hasOverlay = value + updateFocusPresentation() + needsDisplay = true + } + private func updateFocusPresentation() { - focusRingType = (isFocusedCell && !onEmphasizedSelection) ? .exterior : .none + let shouldShowRing = isFocusedCell && !onEmphasizedSelection && !hasOverlay + focusRingType = shouldShowRing ? .exterior : .none noteFocusRingMaskChanged() needsDisplay = true } override var focusRingMaskBounds: NSRect { - onEmphasizedSelection ? .zero : bounds + (onEmphasizedSelection || hasOverlay) ? .zero : bounds } override func drawFocusRingMask() { - guard !onEmphasizedSelection else { return } + guard !onEmphasizedSelection, !hasOverlay else { return } NSBezierPath(rect: bounds).fill() } @@ -210,7 +219,7 @@ final class DataGridCellView: NSView { drawAccessory(in: accessoryRect) NSGraphicsContext.current?.restoreGraphicsState() - if isFocusedCell && onEmphasizedSelection { + if isFocusedCell && onEmphasizedSelection && !hasOverlay { drawFocusBorder() } } From ac2d02f44cdc02ef63581b9f543929b4c9f69332 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:34:09 +0700 Subject: [PATCH 10/13] fix(datagrid): hide overlay text view focus ring The previous fix removed the underlying cell focus indicators but the inline editor's NSTextView still drew its own focus ring when it became first responder, leaving a thin inner outline inside the editor container. Set textView.focusRingType = .none so only the container's keyboardFocusIndicatorColor border remains as the edit-mode signal. --- TablePro/Views/Results/CellOverlayEditor.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 9fcd3fd2b..c195a4cca 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -38,6 +38,7 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate { textView.font = ThemeEngine.shared.dataGridFonts.regular textView.textColor = .labelColor textView.backgroundColor = .textBackgroundColor + textView.focusRingType = .none textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.textContainer?.widthTracksTextView = true From 019e408b6c5d305e805fed4ab90f6491fea2c5b5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:38:50 +0700 Subject: [PATCH 11/13] fix(datagrid): suppress selection border on cell that has an active editor When a cell with an active GridSelection rectangle goes into edit mode, the GridSelectionOverlay kept drawing the rectangle border underneath the editor's 2pt focus border, so the user saw two outlines on the same cell. GridSelectionOverlay now asks the coordinator which cell currently hosts a CellOverlayEditor or CellOverlayViewer and skips drawing the perimeter border for any rectangle that contains that cell, while keeping disjoint rectangles bordered. The active-cell accent border is also suppressed for the edited cell. Selection fills on the row view stay so the user still sees what is selected. CellOverlayBase.install and removeOverlay mark the selection overlay for redraw so the border appears or disappears together with the editor. --- TablePro/Views/Results/CellOverlayBase.swift | 6 ++++++ .../Results/Selection/GridSelectionOverlay.swift | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/TablePro/Views/Results/CellOverlayBase.swift b/TablePro/Views/Results/CellOverlayBase.swift index 10b32588d..c5fc52009 100644 --- a/TablePro/Views/Results/CellOverlayBase.swift +++ b/TablePro/Views/Results/CellOverlayBase.swift @@ -52,6 +52,7 @@ class CellOverlayBase: NSObject { tableView.addSubview(container) self.container = container underlyingCell(in: tableView, row: row, column: column)?.applyOverlayActive(true) + selectionOverlay(in: tableView)?.needsDisplay = true installDismissObservers() } @@ -59,6 +60,10 @@ class CellOverlayBase: NSObject { tableView.view(atColumn: column, row: row, makeIfNecessary: false) as? DataGridCellView } + private func selectionOverlay(in tableView: NSTableView) -> GridSelectionOverlay? { + (tableView as? KeyHandlingTableView)?.selectionOverlay + } + func handleDismiss(reason: CellOverlayDismissReason) { removeOverlay() } @@ -68,6 +73,7 @@ class CellOverlayBase: NSObject { removeDismissObservers() if let hostTableView { underlyingCell(in: hostTableView, row: row, column: column)?.applyOverlayActive(false) + selectionOverlay(in: hostTableView)?.needsDisplay = true } activeContainer.removeFromSuperview() container = nil diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift index bb29d22e6..d95aff893 100644 --- a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -36,12 +36,14 @@ final class GridSelectionOverlay: NSView { guard let tableView, let coordinator else { return } let schema = coordinator.identitySchema let totalRows = tableView.numberOfRows + let editingCell = activeOverlayCell(in: coordinator) NSColor.selectedContentBackgroundColor.withAlphaComponent(Self.borderAlpha).setStroke() for rect in selection.rectangles { guard let frame = frame(for: rect, in: tableView, schema: schema) else { continue } guard frame.intersects(dirtyRect) else { continue } if isFullHeight(rect, totalRows: totalRows) { continue } + if let editingCell, rect.contains(editingCell) { continue } let inset = frame.insetBy(dx: Self.borderWidth / 2, dy: Self.borderWidth / 2) let path = NSBezierPath(rect: inset) path.lineWidth = Self.borderWidth @@ -49,6 +51,7 @@ final class GridSelectionOverlay: NSView { } if let active = selection.activeCell, + editingCell != active, selection.rectangles.count > 1 || (selection.rectangles.first?.rows.count ?? 0) > 1 || (selection.rectangles.first?.columns.count ?? 0) > 1, let frame = frame(for: GridRect(cell: active), in: tableView, schema: schema), frame.intersects(dirtyRect) { @@ -60,6 +63,16 @@ final class GridSelectionOverlay: NSView { } } + private func activeOverlayCell(in coordinator: TableViewCoordinator) -> GridCoord? { + if let editor = coordinator.overlayEditor, editor.isActive { + return GridCoord(row: editor.row, column: editor.columnIndex) + } + if let viewer = coordinator.overlayViewer, viewer.isActive { + return GridCoord(row: viewer.row, column: viewer.columnIndex) + } + return nil + } + private func isFullHeight(_ rect: GridRect, totalRows: Int) -> Bool { guard totalRows > 0 else { return false } return rect.rows.lowerBound <= 0 && rect.rows.upperBound >= totalRows - 1 From 5bbbd105f530e6a692a6c50a150dbaad75e4c678 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:47:37 +0700 Subject: [PATCH 12/13] fix(datagrid): keep row and cell selection mutually exclusive on cmd/shift+click Cmd+click on a data cell used to call selectRowIndexes with byExtendingSelection: true so the row selection grew alongside the cell selection. The tableViewSelectionDidChange delegate then saw the row selection grow while the cell selection was still non-empty and cleared the cell selection mid-gesture; endDrag then rebuilt the cell selection from dragBaseSelection, leaving both row and cell selection simultaneously active. selectRowIndexes is now always called with byExtendingSelection: false from the cell-selection path. Multi-row selection still works via the row-number column where super.mouseDown dispatches NSTableView's native handling. The intermediate row-change is also wrapped in isSyncingSelection so the delegate skips its own clear pass. Reset hasOverlay in DataGridCellView.configure so a recycled cell view never carries a stale overlay state forward. Also fixes two non-code issues flagged in review: duplicate Added entries in CHANGELOG (leftover from the earlier 'tighten unreleased entries' commit) and missing tests for selectEntireColumn, selectEntireRow, extendActiveCell and its jump-to-edge / empty-selection paths. --- .claude/scheduled_tasks.lock | 1 + CHANGELOG.md | 7 --- .../Results/Cells/DataGridCellView.swift | 6 +++ .../Extensions/DataGridView+Selection.swift | 2 +- .../Views/Results/KeyHandlingTableView.swift | 13 ++++- .../Views/Results/CellSelectionTests.swift | 51 +++++++++++++++++++ 6 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 000000000..8d8824454 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"73744344-47f6-4aaa-824f-0fe62b5fc458","pid":44557,"procStart":"Thu May 28 11:33:50 2026","acquiredAt":1779979398116} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b039a3ac..4ec770ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Rectangular cell selection in the data grid. Click and drag to select a range, Shift+click to extend, Cmd+click to add cells, Cmd+click a column header to select the column, Shift+Arrow to extend by one cell, Cmd+A to select the whole grid, Cmd+C to copy as TSV. (#1446) -- BigQuery: the sidebar now shows every dataset as an expandable node, with each dataset's tables loading when you open it, instead of showing one dataset at a time behind a picker. -- OpenCode Zen as an AI provider. Add it from the provider list and paste an OpenCode key, or leave the key blank to use the free models; the model list loads automatically, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) -- Oracle Database 11g (11.1 and 11.2) now connects. Previously only 12c and later worked, so 11g servers failed with a "Server Version Not Supported" error. (#1425) -- Oracle connections can now use a SID instead of a service name. Set Connection Type to SID in the connection form and enter the SID. (#1425) -- Cmd-click a foreign key arrow to open the referenced table in a new tab instead of the current one. The right-click menu has the same Open in New Tab option. (#1421) -- Cells holding JSON or PHP serialized values in text columns now open in the structured viewer automatically, without requiring the column type to be JSON. -- Add and remove buttons in the table structure editor for columns, indexes, and foreign keys, on the bottom status bar alongside the view-mode picker. Cmd+Shift+N adds and Cmd+Delete removes. An empty Indexes or Foreign Keys tab also shows a labelled add button. (#1319) - BigQuery datasets show as expandable nodes in the sidebar, instead of one at a time behind a picker. - OpenCode Zen as an AI provider, with free models when no key is set. (#1400) - Oracle Database 11g (11.1 and 11.2) now connects. (#1425) diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index 8db899ed2..353eb3c42 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -84,6 +84,12 @@ final class DataGridCellView: NSView { cellRow = state.row cellColumnIndex = state.columnIndex + if hasOverlay { + hasOverlay = false + updateFocusPresentation() + needsRedraw = true + } + let nextDisplayText: String let nextFont: NSFont let nextColor: NSColor diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index 9feb58986..c906e5756 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -38,7 +38,7 @@ extension TableViewCoordinator { guard let keyTableView = tableView as? KeyHandlingTableView else { return } - if !newSelection.isEmpty, !selectionController.isEmpty { + if !isSyncingSelection, !newSelection.isEmpty, !selectionController.isEmpty { selectionController.clear() } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 8ec4b33c0..3c72af83f 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -84,6 +84,14 @@ final class KeyHandlingTableView: NSTableView { private var gridSelection: GridSelectionController? { coordinator?.selectionController } + private func withSelectionSync(_ work: () -> Void) { + let coordinator = coordinator + let wasSyncing = coordinator?.isSyncingSelection ?? false + coordinator?.isSyncingSelection = true + work() + coordinator?.isSyncingSelection = wasSyncing + } + private func totalRows() -> Int { numberOfRows } private func totalDataColumns() -> Int { @@ -142,8 +150,9 @@ final class KeyHandlingTableView: NSTableView { let disposition = controller.beginDrag(at: coord, modifiers: modifiers) switch disposition { case .replaceFocus(let activeCoord): - let byExtending = modifiers.contains(.shift) || modifiers.contains(.command) - selectRowIndexes(IndexSet(integer: activeCoord.row), byExtendingSelection: byExtending) + withSelectionSync { + selectRowIndexes(IndexSet(integer: activeCoord.row), byExtendingSelection: false) + } focusedRow = activeCoord.row focusedColumn = DataGridView.tableColumnIndex(for: activeCoord.column, in: self, schema: schema) ?? clickedColumn case .clearFocus: diff --git a/TableProTests/Views/Results/CellSelectionTests.swift b/TableProTests/Views/Results/CellSelectionTests.swift index d5e9bf1f5..37a30ad80 100644 --- a/TableProTests/Views/Results/CellSelectionTests.swift +++ b/TableProTests/Views/Results/CellSelectionTests.swift @@ -237,6 +237,57 @@ struct GridSelectionControllerTests { #expect(controller.selection.activeCell == GridCoord(row: 0, column: 0)) } + @Test("selectEntireColumn covers all rows in that column") + func selectColumnSpansAllRows() { + let controller = GridSelectionController() + controller.selectEntireColumn(2, totalRows: 5) + #expect(controller.selection.rectangles == [GridRect(rows: 0...4, columns: 2...2)]) + #expect(controller.selection.activeCell == GridCoord(row: 0, column: 2)) + #expect(controller.selection.anchor == GridCoord(row: 0, column: 2)) + } + + @Test("selectEntireRow covers all columns in that row") + func selectRowSpansAllColumns() { + let controller = GridSelectionController() + controller.selectEntireRow(3, totalColumns: 6) + #expect(controller.selection.rectangles == [GridRect(rows: 3...3, columns: 0...5)]) + #expect(controller.selection.activeCell == GridCoord(row: 3, column: 0)) + } + + @Test("extendActiveCell moves the active cell and grows the rectangle from the anchor") + func extendActiveCellGrowsRectangle() { + let controller = GridSelectionController() + let origin = GridCoord(row: 2, column: 2) + _ = controller.beginDrag(at: origin, modifiers: []) + controller.continueDrag(to: GridCoord(row: 3, column: 3)) + controller.endDrag(dragged: true, originalCoord: origin) + + controller.extendActiveCell(direction: .down, jumpToEdge: false, totalRows: 10, totalColumns: 10) + #expect(controller.selection.rectangles == [GridRect(rows: 2...4, columns: 2...3)]) + #expect(controller.selection.activeCell == GridCoord(row: 4, column: 3)) + #expect(controller.selection.anchor == origin) + } + + @Test("extendActiveCell with jumpToEdge jumps to the grid edge") + func extendActiveCellJumpsToEdge() { + let controller = GridSelectionController() + let origin = GridCoord(row: 2, column: 2) + _ = controller.beginDrag(at: origin, modifiers: []) + controller.continueDrag(to: origin) + controller.endDrag(dragged: true, originalCoord: origin) + + controller.extendActiveCell(direction: .right, jumpToEdge: true, totalRows: 10, totalColumns: 10) + #expect(controller.selection.activeCell == GridCoord(row: 2, column: 9)) + #expect(controller.selection.rectangles == [GridRect(rows: 2...2, columns: 2...9)]) + } + + @Test("extendActiveCell is a no-op when the selection is empty") + func extendActiveCellNoOpEmpty() { + let controller = GridSelectionController() + controller.extendActiveCell(direction: .down, jumpToEdge: false, totalRows: 10, totalColumns: 10) + #expect(controller.selection.isEmpty) + } + @Test("clear empties the selection") func clearEmpties() { let controller = GridSelectionController() From 08162cbb47fff43086c06c0bf5570a8189acb7ff Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:47:50 +0700 Subject: [PATCH 13/13] chore: drop accidentally committed claude scheduled_tasks.lock --- .claude/scheduled_tasks.lock | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 8d8824454..000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"73744344-47f6-4aaa-824f-0fe62b5fc458","pid":44557,"procStart":"Thu May 28 11:33:50 2026","acquiredAt":1779979398116} \ No newline at end of file