diff --git a/CHANGELOG.md b/CHANGELOG.md index b66a87740..4ec770ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ 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 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/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/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/CellOverlayBase.swift b/TablePro/Views/Results/CellOverlayBase.swift index eb940e47a..c5fc52009 100644 --- a/TablePro/Views/Results/CellOverlayBase.swift +++ b/TablePro/Views/Results/CellOverlayBase.swift @@ -51,9 +51,19 @@ class CellOverlayBase: NSObject { self.columnIndex = columnIndex tableView.addSubview(container) self.container = container + underlyingCell(in: tableView, row: row, column: column)?.applyOverlayActive(true) + selectionOverlay(in: tableView)?.needsDisplay = true installDismissObservers() } + private func underlyingCell(in tableView: NSTableView, row: Int, column: Int) -> DataGridCellView? { + 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() } @@ -61,6 +71,10 @@ class CellOverlayBase: NSObject { func removeOverlay() { guard let activeContainer = container else { return } removeDismissObservers() + if let hostTableView { + underlyingCell(in: hostTableView, row: row, column: column)?.applyOverlayActive(false) + selectionOverlay(in: hostTableView)?.needsDisplay = true + } activeContainer.removeFromSuperview() container = nil if let hostTableView { 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 diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index 8a6267203..353eb3c42 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? @@ -83,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 @@ -150,7 +157,6 @@ final class DataGridCellView: NSView { updateFocusPresentation() needsRedraw = true } - setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1)) setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1)) @@ -176,18 +182,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() } @@ -211,7 +225,7 @@ final class DataGridCellView: NSView { drawAccessory(in: accessoryRect) NSGraphicsContext.current?.restoreGraphicsState() - if isFocusedCell && onEmphasizedSelection { + if isFocusedCell && onEmphasizedSelection && !hasOverlay { drawFocusBorder() } } 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..5365ec2b7 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.28) + 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 bfa91dca8..2aad46180 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -226,13 +226,12 @@ extension TableViewCoordinator { var lines: [String] = [] lines.reserveCapacity(rowCount) - for rowIndex in 0.. Bool { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index 7ab81ee9c..c906e5756 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -38,6 +38,10 @@ extension TableViewCoordinator { guard let keyTableView = tableView as? KeyHandlingTableView else { return } + if !isSyncingSelection, !newSelection.isEmpty, !selectionController.isEmpty { + selectionController.clear() + } + let newFocus = resolvedFocus( previous: previousSelection, current: newSelection, diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 54822f472..3c72af83f 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, @@ -72,6 +82,23 @@ final class KeyHandlingTableView: NSTableView { set { selection.focusedColumn = newValue } } + 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 { + guard let schema = coordinator?.identitySchema else { return 0 } + return schema.totalDataColumns + } + override func mouseDown(with event: NSEvent) { window?.makeFirstResponder(self) @@ -84,38 +111,102 @@ 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 { + gridSelection?.clear() + super.mouseDown(with: event) return } let column = tableColumns[clickedColumn] - if column.identifier == ColumnIdentitySchema.rowNumberIdentifier { + 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 { + gridSelection?.clear() + super.mouseDown(with: event) + if !isDataColumn { + focusedRow = -1 + focusedColumn = -1 + } + return + } + + let alreadyFocusedHere = clickedRow == focusedRow && clickedColumn == focusedColumn + let coord = GridCoord(row: clickedRow, column: dataColumn) + guard let controller = gridSelection else { + super.mouseDown(with: event) + return + } + + let disposition = controller.beginDrag(at: coord, modifiers: modifiers) + switch disposition { + case .replaceFocus(let activeCoord): + withSelectionSync { + 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 + trackDrag(initial: coord, schema: schema) - 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.isEmpty, + alreadyFocusedHere, + selectedRowIndexes.count == 1, + coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumn) == true { + coordinator?.beginCellEdit(row: clickedRow, tableColumnIndex: clickedColumn) + } + } + + 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(dragged: dragged, originalCoord: initial) + 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 } + let coord = GridCoord(row: rowIdx, column: columnIdx) + if coord != initial { dragged = true } + controller.continueDrag(to: coord) } } + 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 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?) { guard coordinator?.isEditable == true else { return } guard !selectedRowIndexes.isEmpty else { return } @@ -123,17 +214,32 @@ final class KeyHandlingTableView: NSTableView { } @objc func copy(_ sender: Any?) { + 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, @@ -161,14 +267,17 @@ 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(_:)): + 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 false + case #selector(selectAll(_:)): + return numberOfRows > 0 default: return super.validateUserInterfaceItem(item) } @@ -189,28 +298,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 } @@ -234,6 +353,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, @@ -247,6 +378,11 @@ final class KeyHandlingTableView: NSTableView { } @objc override func cancelOperation(_ sender: Any?) { + guard let controller = gridSelection, !controller.isEmpty else { + super.cancelOperation(sender) + return + } + controller.clear() } private func deleteSelectedRowsIfPossible() { @@ -368,9 +504,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/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..bb166e00a --- /dev/null +++ b/TablePro/Views/Results/Selection/GridSelectionController.swift @@ -0,0 +1,246 @@ +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? + 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 + selection = newSelection + 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) + } + + func beginDrag(at coord: GridCoord, modifiers: NSEvent.ModifierFlags) -> MouseDisposition { + let cleanModifiers = modifiers.intersection([.command, .shift, .option, .control]) + if cleanModifiers.contains(.command) && !cleanModifiers.contains(.shift) { + dragOrigin = coord + dragMode = .additive + dragBaseSelection = selection + return .replaceFocus(coord) + } + if cleanModifiers.contains(.shift) && !cleanModifiers.contains(.command) { + 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 + if !selection.isEmpty { + update(.empty) + } + return .replaceFocus(coord) + } + + func continueDrag(to coord: GridCoord) { + guard let origin = dragOrigin else { return } + 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(dragged: Bool, originalCoord: GridCoord) { + defer { + dragOrigin = nil + dragMode = .replace + dragBaseSelection = .empty + } + guard !dragged, dragMode == .additive else { return } + applyCmdClickToggle(at: originalCoord) + } + + private func applyCmdClickToggle(at coord: GridCoord) { + let cellRect = GridRect(cell: coord) + var rectangles = dragBaseSelection.rectangles + + if let index = rectangles.firstIndex(where: { $0 == cellRect }) { + rectangles.remove(at: index) + if rectangles.isEmpty { + update(.empty) + return + } + 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)) + } + + 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: 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 } + + 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..d95aff893 --- /dev/null +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -0,0 +1,103 @@ +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.0 + private static let activeCellBorderWidth: CGFloat = 2.0 + private static let borderAlpha: CGFloat = 0.7 + + 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 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 + path.stroke() + } + + 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) { + 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 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 + } + + 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/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 28cb6a8d0..30598be21 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,11 +31,21 @@ final class SortableHeaderCell: NSTableHeaderCell { } override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { - drawTitle(in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: sortDirection != nil)) + if isColumnSelected { + NSColor.selectedContentBackgroundColor.setFill() + cellFrame.fill() + } + + 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 @@ -47,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, @@ -55,7 +66,7 @@ final class SortableHeaderCell: NSTableHeaderCell { width: priorityWidth, height: cellFrame.height ) - Self.drawPriorityText(priorityText, in: textRect) + Self.drawPriorityText(priorityText, in: textRect, color: foreground) } } @@ -72,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 } @@ -85,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 ] @@ -136,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) } @@ -156,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, @@ -168,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 ] } } diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 6f537c89f..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 } @@ -183,9 +198,14 @@ final class SortableHeaderView: NSTableHeaderView { return } - let isMultiSort = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .contains(.shift) + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + + if modifiers.contains(.command) && !modifiers.contains(.shift) { + coordinator.selectColumn(dataIndex) + return + } + + let isMultiSort = modifiers.contains(.shift) let transition = HeaderSortCycle.nextTransition( state: coordinator.currentSortState, clickedColumn: dataIndex, 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") diff --git a/TableProTests/Views/Results/CellSelectionTests.swift b/TableProTests/Views/Results/CellSelectionTests.swift new file mode 100644 index 000000000..37a30ad80 --- /dev/null +++ b/TableProTests/Views/Results/CellSelectionTests.swift @@ -0,0 +1,300 @@ +import AppKit +import Foundation +@testable import TablePro +import Testing + +@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("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) + ) + #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 + ) + #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 + ) + #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(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) + } +} + +@Suite("GridSelectionController gestures") +@MainActor +struct GridSelectionControllerTests { + @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.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") + 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 dragTo = GridCoord(row: 2, column: 2) + _ = controller.beginDrag(at: origin, modifiers: []) + 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 == shiftTarget) + } + + @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: .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)]) + + _ = 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("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() + let coord = GridCoord(row: 0, column: 0) + _ = controller.beginDrag(at: coord, modifiers: []) + controller.endDrag(dragged: false, originalCoord: coord) + controller.clear() + #expect(controller.selection.isEmpty) + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 8bc831a4f..537a11d45 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -133,9 +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 | -| Extend selection | Shift + click | -| Add to selection | Cmd + click | +| Select rectangular range | Click + drag | +| Extend selection | `Shift` + click | +| Add cell to selection | `Cmd` + click | +| Select entire column | `Cmd` + click column header | +| Extend selection by one cell | `Shift+Arrow` | +| Extend selection to grid edge | `Cmd+Shift+Arrow` | +| Clear cell selection | `Escape` | | Extend selection by row | `Shift+Up` / `Shift+Down` | | Select to first row | `Shift+Home` | | Select to last row | `Shift+End` |