From cde1b612c907057048390e8856527ede9768f9b4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 29 Aug 2026 18:37:47 +0700 Subject: [PATCH] feat(datagrid): move the cell editor to the row above or below with the arrow keys --- CHANGELOG.md | 5 + .../Views/Results/CellEditorMovement.swift | 57 +++++ .../Views/Results/CellOverlayEditor.swift | 48 +++- .../Extensions/DataGridView+Editing.swift | 61 ++++-- .../Views/Results/KeyHandlingTableView.swift | 10 +- .../Results/CellEditorArrowExitTests.swift | 104 +++++++++ .../CellEditorMovementTargetTests.swift | 205 ++++++++++++++++++ .../CellOverlayEditorMovementTests.swift | 157 ++++++++++++++ docs/features/keyboard-shortcuts.mdx | 4 + 9 files changed, 617 insertions(+), 34 deletions(-) create mode 100644 TablePro/Views/Results/CellEditorMovement.swift create mode 100644 TableProTests/Views/Results/CellEditorArrowExitTests.swift create mode 100644 TableProTests/Views/Results/CellEditorMovementTargetTests.swift create mode 100644 TableProTests/Views/Results/CellOverlayEditorMovementTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 689b5e709..0e1665a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Per-connection MongoDB shell state, so a variable or function survives from one statement to the next. - Cursor method autocomplete after `find()` and `aggregate()`. - Copy To and Duplicate Database in the sidebar and the Database menu, carrying structure, data or both to any connection. (#2487) +- `Up` and `Down` while editing a cell, moving the editor to the same column of the row above or below. (#2569) - Tab rows in Settings > General > Tabs, wrapping the strip instead of scrolling it. (#2438) - Autoscrolling while dragging a tab, so a tab can be moved past the run currently on screen. (#2438) @@ -31,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tab drag released on a neighbour's exact centre leaving the order unchanged. (#2438) - Compare & Sync unable to drop an overloaded PostgreSQL routine, or any trigger. - PostgreSQL sequence DDL naming the schema it was read from, in SQL export and the structure editor. +- Half-composed input method text saved and left behind when `Tab` moved the cell editor. +- Cell editor opening off screen when `Tab` wrapped onto a row below the visible ones. +- Cell cursor left on the old column after `Tab` carried the editor to the next one. +- Every data grid switching to its accessibility layout after one `Tab` press, with no assistive app attached. ## [0.69.0] - 2026-08-27 diff --git a/TablePro/Views/Results/CellEditorMovement.swift b/TablePro/Views/Results/CellEditorMovement.swift new file mode 100644 index 000000000..65965bacd --- /dev/null +++ b/TablePro/Views/Results/CellEditorMovement.swift @@ -0,0 +1,57 @@ +// +// CellEditorMovement.swift +// TablePro +// + +import Foundation + +/// Which way an inline cell editor was left. +/// +/// The cases are AppKit's own vocabulary for leaving one field for the next: `NSTextMovement` +/// declares `up` and `down` beside `tab` and `backtab` as "movement codes for movement between +/// fields". The overlay editor is a standalone text view rather than a field editor, so it reads +/// the four selectors itself and reports them here. +enum CellEditorMovement { + case tab + case backtab + case up + case down +} + +/// Whether Up or Down in an inline cell editor moves the caret or leaves the cell. +/// +/// A cell value can hold line breaks, so the arrow keys belong to the value's own lines first and +/// the editor is left only from the line at that end. A value on one line has no line to move to +/// in either direction, so both arrows leave it, including straight after the editor opens with +/// the whole value selected. +/// +/// A line break is the only thing that starts a line here, because the overlay never wraps text +/// (`CellOverlayBase.applyCellTextLayout`, pinned by `CellOverlayTextLayoutTests`). +struct CellEditorArrowExit { + let canExitUp: Bool + let canExitDown: Bool + + init(text: NSString, selection: NSRange) { + let length = text.length + let start = min(max(selection.location, 0), length) + let end = start + min(max(selection.length, 0), length - start) + let breakAbove = Self.containsLineBreak(text, NSRange(location: 0, length: start)) + let breakBelow = Self.containsLineBreak(text, NSRange(location: end, length: length - end)) + + guard start != end else { + canExitUp = !breakAbove + canExitDown = !breakBelow + return + } + + let isSingleLine = !breakAbove && !breakBelow + && !Self.containsLineBreak(text, NSRange(location: start, length: end - start)) + canExitUp = isSingleLine + canExitDown = isSingleLine + } + + private static func containsLineBreak(_ text: NSString, _ range: NSRange) -> Bool { + guard range.length > 0 else { return false } + return text.rangeOfCharacter(from: .newlines, range: range).location != NSNotFound + } +} diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 82f63c59d..2c3fc8b53 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -11,7 +11,7 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate { private var initialValue: String = "" var onCommit: ((_ row: Int, _ columnIndex: Int, _ newValue: String) -> Void)? - var onTabNavigation: ((_ row: Int, _ column: Int, _ forward: Bool) -> Void)? + var onMovement: ((_ row: Int, _ column: Int, _ movement: CellEditorMovement) -> Void)? func show( in tableView: NSTableView, @@ -90,21 +90,51 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate { } if commandSelector == #selector(NSResponder.insertTab(_:)) { - let dismissRow = row, dismissColumn = column - dismiss(commit: true) - onTabNavigation?(dismissRow, dismissColumn, true) - return true + return leave(with: .tab, from: textView) } if commandSelector == #selector(NSResponder.insertBacktab(_:)) { - let dismissRow = row, dismissColumn = column - dismiss(commit: true) - onTabNavigation?(dismissRow, dismissColumn, false) - return true + return leave(with: .backtab, from: textView) + } + + if commandSelector == #selector(NSResponder.moveUp(_:)) { + return leaveVertically(.up, from: textView) + } + + if commandSelector == #selector(NSResponder.moveDown(_:)) { + return leaveVertically(.down, from: textView) } return false } + + /// Only the plain arrows are read. Shift, Option and Command each map to a selector of their + /// own, so extending a selection or jumping to the end of the value keeps its native meaning. + /// + /// An unhandled arrow moves the caret inside marked text, which is what it is for, so a + /// composition takes it back rather than having it swallowed. + private func leaveVertically(_ movement: CellEditorMovement, from textView: NSTextView) -> Bool { + guard !textView.hasMarkedText() else { return false } + let exit = CellEditorArrowExit( + text: textView.string as NSString, + selection: textView.selectedRange() + ) + let leaves = movement == .up ? exit.canExitUp : exit.canExitDown + guard leaves else { return false } + return leave(with: movement, from: textView) + } + + /// A composition in progress owns the keystroke. Until the input method commits it the text + /// view holds provisional text, and leaving the cell would save that half-composed value and + /// carry the editor off it. The key is swallowed rather than passed back, because a literal + /// tab in a cell is not what Tab was pressed for. + private func leave(with movement: CellEditorMovement, from textView: NSTextView) -> Bool { + guard !textView.hasMarkedText() else { return true } + let dismissRow = row, dismissColumn = column + dismiss(commit: true) + onMovement?(dismissRow, dismissColumn, movement) + return true + } } private final class OverlayTextView: NSTextView { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 2e40cca0a..88afc4c93 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -97,8 +97,8 @@ extension TableViewCoordinator { editor.onCommit = { [weak self] row, columnIndex, newValue in self?.commitCellEdit(row: row, columnIndex: columnIndex, newValue: newValue) } - editor.onTabNavigation = { [weak self] row, column, forward in - self?.handleOverlayTabNavigation(row: row, column: column, forward: forward) + editor.onMovement = { [weak self] row, column, movement in + self?.handleOverlayMovement(row: row, column: column, movement: movement) } overlayViewer?.dismiss() editor.show(in: tableView, row: row, column: column, columnIndex: columnIndex, value: value) @@ -116,30 +116,31 @@ extension TableViewCoordinator { viewer.show(in: tableView, row: row, column: column, columnIndex: columnIndex, value: value) } - func handleOverlayTabNavigation(row: Int, column: Int, forward: Bool) { - guard let tableView = tableView, - let target = tabNavigationTarget(from: (row, column), forward: forward, in: tableView) + /// The cell cursor moves with the editor, through the same `focusCell` the grid's own Tab uses. + /// Selecting the row alone left the cursor on the column the editor came from, so closing the + /// editor put it back where the editing was not, and it never scrolled the target row into + /// view, so a wrap onto the row below the last visible one opened the editor off screen. + func handleOverlayMovement(row: Int, column: Int, movement: CellEditorMovement) { + guard let tableView = tableView as? KeyHandlingTableView, + let target = movementTarget(from: (row, column), movement: movement, in: tableView) else { return } - let nextRow = target.row - let nextColumn = target.column - tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - scrollColumnToVisible(tableColumnIndex: nextColumn) + tableView.focusCell(row: target.row, column: target.column) - guard let nextColumnIndex = DataGridView.dataColumnIndex( - for: nextColumn, + guard let targetColumnIndex = DataGridView.dataColumnIndex( + for: target.column, in: tableView, schema: identitySchema ), - nextColumnIndex >= 0, - case .editable(let value) = editEligibility(row: nextRow, columnIndex: nextColumnIndex) + targetColumnIndex >= 0, + case .editable(let value) = editEligibility(row: target.row, columnIndex: targetColumnIndex) else { return } showOverlayEditor( tableView: tableView, - row: nextRow, - column: nextColumn, - columnIndex: nextColumnIndex, + row: target.row, + column: target.column, + columnIndex: targetColumnIndex, value: value ) } @@ -148,22 +149,34 @@ extension TableViewCoordinator { /// previous row's last. Both ends are resolved rather than assumed: the window's spacers and /// the pool's surplus slots are attached columns too, so neither end of `tableColumns` holds a /// data column and a fixed position lands on a spacer that swallows the keystroke. - private func tabNavigationTarget( + /// + /// Up and Down hold the column and step one row, and neither wraps: a column is a column of one + /// kind of value, so carrying the editor from the last row round to the first is a jump the + /// user did not ask for. + func movementTarget( from cell: (row: Int, column: Int), - forward: Bool, + movement: CellEditorMovement, in tableView: NSTableView ) -> (row: Int, column: Int)? { - if forward { + switch movement { + case .tab: if let next = nextPresentedColumnIndex(after: cell.column) { return (cell.row, next) } guard cell.row + 1 < tableView.numberOfRows, let first = firstPresentedColumnIndex() else { return nil } return (cell.row + 1, first) + case .backtab: + if let previous = previousPresentedColumnIndex(before: cell.column) { + return (cell.row, previous) + } + guard cell.row > 0, let last = lastPresentedColumnIndex() else { return nil } + return (cell.row - 1, last) + case .up: + guard cell.row > 0 else { return nil } + return (cell.row - 1, cell.column) + case .down: + guard cell.row + 1 < tableView.numberOfRows else { return nil } + return (cell.row + 1, cell.column) } - if let previous = previousPresentedColumnIndex(before: cell.column) { - return (cell.row, previous) - } - guard cell.row > 0, let last = lastPresentedColumnIndex() else { return nil } - return (cell.row - 1, last) } } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 97d32ba01..bf89ae92b 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -521,7 +521,13 @@ final class KeyHandlingTableView: NSTableView { /// /// A cell is drawn rather than mounted, so the element comes from the row's own accessibility /// children rather than from a cell view. + /// + /// Nothing is posted until a client has asked the grid something. The element does not exist + /// before that, so the notification had nowhere to land, and asking for it was itself enough to + /// mount a view per visible cell in every grid: the cost `#2381` removed, charged to a session + /// that pressed Tab once. internal func postCellCursorMoved() { + guard DataGridAccessibility.isActive else { return } guard selectedRow >= 0, presentsDataColumn(at: focusedColumn) else { return } guard let element = accessibilityCellElement(row: selectedRow, tableColumnIndex: focusedColumn) else { return } NSAccessibility.post(element: element, notification: .focusedUIElementChanged) @@ -621,7 +627,9 @@ final class KeyHandlingTableView: NSTableView { return true } - private func focusCell(row: Int, column: Int) { + /// The one way the cell cursor is moved by a keystroke, used by Tab inside the grid and by the + /// inline editor's own Tab and arrow navigation. + internal func focusCell(row: Int, column: Int) { selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) focusedRow = row focusedColumn = column diff --git a/TableProTests/Views/Results/CellEditorArrowExitTests.swift b/TableProTests/Views/Results/CellEditorArrowExitTests.swift new file mode 100644 index 000000000..9bd2c5df8 --- /dev/null +++ b/TableProTests/Views/Results/CellEditorArrowExitTests.swift @@ -0,0 +1,104 @@ +// +// CellEditorArrowExitTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +/// Up and Down carry the inline editor to the adjacent row, so a value that holds line breaks has +/// to keep them for its own lines and give them up only at the line at that end (#2569). +@Suite("Cell editor arrow exit") +struct CellEditorArrowExitTests { + private func exit(_ value: String, selection: NSRange) -> CellEditorArrowExit { + CellEditorArrowExit(text: value as NSString, selection: selection) + } + + @Test("A single line leaves the cell in both directions") + func singleLineLeavesInBothDirections() { + let placement = exit("alpha", selection: NSRange(location: 2, length: 0)) + + #expect(placement.canExitUp) + #expect(placement.canExitDown) + } + + /// The editor opens with the whole value selected, which is where the first arrow press lands. + @Test("A fully selected single line still leaves the cell") + func fullySelectedSingleLineStillLeaves() { + let placement = exit("alpha", selection: NSRange(location: 0, length: 5)) + + #expect(placement.canExitUp) + #expect(placement.canExitDown) + } + + @Test("An empty value leaves the cell in both directions") + func emptyValueLeaves() { + let placement = exit("", selection: NSRange(location: 0, length: 0)) + + #expect(placement.canExitUp) + #expect(placement.canExitDown) + } + + @Test("The middle line of a multi-line value keeps both arrows") + func middleLineKeepsBothArrows() { + let placement = exit("a\nb\nc", selection: NSRange(location: 2, length: 0)) + + #expect(!placement.canExitUp) + #expect(!placement.canExitDown) + } + + @Test("The first line of a multi-line value leaves upwards only") + func firstLineLeavesUpwardsOnly() { + let placement = exit("a\nb\nc", selection: NSRange(location: 0, length: 0)) + + #expect(placement.canExitUp) + #expect(!placement.canExitDown) + } + + @Test("The last line of a multi-line value leaves downwards only") + func lastLineLeavesDownwardsOnly() { + let placement = exit("a\nb\nc", selection: NSRange(location: 5, length: 0)) + + #expect(!placement.canExitUp) + #expect(placement.canExitDown) + } + + /// A caret sitting just before the closing break is still on the line above the empty one. + @Test("A trailing break still counts as a line below") + func trailingBreakCountsAsLineBelow() { + let placement = exit("a\n", selection: NSRange(location: 1, length: 0)) + + #expect(placement.canExitUp) + #expect(!placement.canExitDown) + } + + /// A selection is a text-editing gesture in its own right, so the arrow collapses it first and + /// the press after that is the one that leaves. + @Test("A selection inside a multi-line value keeps both arrows") + func selectionInsideMultiLineKeepsBothArrows() { + let placement = exit("a\nb\nc", selection: NSRange(location: 0, length: 5)) + + #expect(!placement.canExitUp) + #expect(!placement.canExitDown) + } + + @Test("A carriage return starts a line the same way a newline does") + func carriageReturnStartsALine() { + let placement = exit("a\r\nb", selection: NSRange(location: 4, length: 0)) + + #expect(!placement.canExitUp) + #expect(placement.canExitDown) + } + + /// The selection comes from the text view, so it is already inside the value, but a stale one + /// must not read past the end. + @Test("A selection past the end of the value is clamped") + func selectionPastTheEndIsClamped() { + let placement = exit("alpha", selection: NSRange(location: 40, length: 10)) + + #expect(placement.canExitUp) + #expect(placement.canExitDown) + } +} diff --git a/TableProTests/Views/Results/CellEditorMovementTargetTests.swift b/TableProTests/Views/Results/CellEditorMovementTargetTests.swift new file mode 100644 index 000000000..fea514409 --- /dev/null +++ b/TableProTests/Views/Results/CellEditorMovementTargetTests.swift @@ -0,0 +1,205 @@ +// +// CellEditorMovementTargetTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +private final class StubLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +/// Where the inline editor goes when it is left with Tab, Shift+Tab, Up or Down, and what moving +/// the cell cursor there costs. Tab wraps across rows because that is what Tab means; Up and Down +/// hold the column and stop at the ends (#2569). +@Suite("Cell editor movement target") +@MainActor +struct CellEditorMovementTargetTests { + private struct Grid { + let coordinator: TableViewCoordinator + let tableView: KeyHandlingTableView + let dataColumns: [Int] + } + + private func makeGrid(rowCount: Int, columnNames: [String]) -> Grid { + let tableView = KeyHandlingTableView() + tableView.columnAutoresizingStyle = .noColumnAutoresizing + tableView.style = .plain + let rowNumberColumn = NSTableColumn(identifier: ColumnIdentitySchema.rowNumberIdentifier) + rowNumberColumn.width = 40 + tableView.addTableColumn(rowNumberColumn) + + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: StubLayoutPersister() + ) + let rows = TableRows.from( + queryRows: (0.. (row: Int, column: Int)? { + grid.coordinator.movementTarget( + from: (row: row, column: column), + movement: movement, + in: grid.tableView + ) + } + + @Test("The grid harness presents every data column") + func harnessPresentsEveryDataColumn() { + let grid = makeGrid(rowCount: 3, columnNames: ["id", "name"]) + + #expect(grid.dataColumns.count == 2) + #expect(grid.tableView.numberOfRows == 3) + } + + @Test("Down steps one row and holds the column") + func downStepsOneRowAndHoldsTheColumn() throws { + let grid = makeGrid(rowCount: 3, columnNames: ["id", "name"]) + let column = try #require(grid.dataColumns.last) + + let moved = try #require(target(grid, row: 0, column: column, movement: .down)) + + #expect(moved.row == 1) + #expect(moved.column == column) + } + + @Test("Up steps one row and holds the column") + func upStepsOneRowAndHoldsTheColumn() throws { + let grid = makeGrid(rowCount: 3, columnNames: ["id", "name"]) + let column = try #require(grid.dataColumns.first) + + let moved = try #require(target(grid, row: 2, column: column, movement: .up)) + + #expect(moved.row == 1) + #expect(moved.column == column) + } + + @Test("Down on the last row does not wrap") + func downOnTheLastRowDoesNotWrap() throws { + let grid = makeGrid(rowCount: 3, columnNames: ["id", "name"]) + let column = try #require(grid.dataColumns.first) + + let past = target(grid, row: 2, column: column, movement: .down) + #expect(past == nil) + } + + @Test("Up on the first row does not wrap") + func upOnTheFirstRowDoesNotWrap() throws { + let grid = makeGrid(rowCount: 3, columnNames: ["id", "name"]) + let column = try #require(grid.dataColumns.first) + + let past = target(grid, row: 0, column: column, movement: .up) + #expect(past == nil) + } + + @Test("Tab walks the row and wraps onto the next row's first column") + func tabWalksTheRowAndWraps() throws { + let grid = makeGrid(rowCount: 3, columnNames: ["id", "name"]) + let first = try #require(grid.dataColumns.first) + let last = try #require(grid.dataColumns.last) + + let within = try #require(target(grid, row: 0, column: first, movement: .tab)) + #expect(within.row == 0) + #expect(within.column == last) + + let wrapped = try #require(target(grid, row: 0, column: last, movement: .tab)) + #expect(wrapped.row == 1) + #expect(wrapped.column == first) + + let past = target(grid, row: 2, column: last, movement: .tab) + #expect(past == nil) + } + + @Test("Shift+Tab walks back and wraps onto the previous row's last column") + func backtabWalksBackAndWraps() throws { + let grid = makeGrid(rowCount: 3, columnNames: ["id", "name"]) + let first = try #require(grid.dataColumns.first) + let last = try #require(grid.dataColumns.last) + + let within = try #require(target(grid, row: 1, column: last, movement: .backtab)) + #expect(within.row == 1) + #expect(within.column == first) + + let wrapped = try #require(target(grid, row: 1, column: first, movement: .backtab)) + #expect(wrapped.row == 0) + #expect(wrapped.column == last) + + let past = target(grid, row: 0, column: first, movement: .backtab) + #expect(past == nil) + } + + /// A vertical step keeps whatever position it was given, so the row-number column and the + /// pool's spacers never come into it the way they do for Tab. + @Test("A vertical step never resolves a column of its own") + func verticalStepNeverResolvesAColumn() throws { + let grid = makeGrid(rowCount: 3, columnNames: ["id", "name", "email"]) + let middle = grid.dataColumns[1] + + let moved = try #require(target(grid, row: 1, column: middle, movement: .down)) + + #expect(moved.column == middle) + } + + /// Reaching for a cell's accessibility element is what marks the grid active, and an active + /// grid mounts a view per visible cell. Moving the cursor asked for one every time, so a single + /// Tab press bought every grid in the session the cost `#2381` removed. + @Test("Moving the cell cursor leaves the accessibility layout alone") + func movingTheCursorLeavesAccessibilityAlone() throws { + let wasActive = DataGridAccessibility.isActive + DataGridAccessibility.isActive = false + defer { DataGridAccessibility.isActive = wasActive } + let grid = makeGrid(rowCount: 3, columnNames: ["id", "name"]) + let column = try #require(grid.dataColumns.first) + + grid.tableView.focusCell(row: 1, column: column) + + #expect(!DataGridAccessibility.isActive) + #expect(grid.tableView.focusedRow == 1) + #expect(grid.tableView.focusedColumn == column) + } +} diff --git a/TableProTests/Views/Results/CellOverlayEditorMovementTests.swift b/TableProTests/Views/Results/CellOverlayEditorMovementTests.swift new file mode 100644 index 000000000..ca9ae76ec --- /dev/null +++ b/TableProTests/Views/Results/CellOverlayEditorMovementTests.swift @@ -0,0 +1,157 @@ +// +// CellOverlayEditorMovementTests.swift +// TableProTests +// + +import AppKit +import Foundation +import Testing + +@testable import TablePro + +/// The overlay is a text view rather than a field editor, so the four selectors AppKit would have +/// turned into an `NSTextMovement` are read here instead (#2569). +@Suite("Cell overlay editor movement") +@MainActor +struct CellOverlayEditorMovementTests { + private struct Editing { + let editor: CellOverlayEditor + let textView: NSTextView + let tableView: KeyHandlingTableView + } + + private func makeEditing(value: String, selection: NSRange) -> Editing { + let tableView = KeyHandlingTableView() + let editor = CellOverlayEditor() + editor.install( + in: tableView, + row: 4, + column: 2, + columnIndex: 1, + container: CellOverlayContainerView(frame: NSRect(x: 0, y: 0, width: 80, height: 24)) + ) + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 80, height: 24)) + CellOverlayBase.applyCellTextLayout(to: textView) + textView.string = value + textView.setSelectedRange(selection) + return Editing(editor: editor, textView: textView, tableView: tableView) + } + + private struct Outcome { + let handled: Bool + let movement: CellEditorMovement? + let cell: (row: Int, column: Int)? + } + + private func send(_ selector: Selector, to editing: Editing) -> Outcome { + var movement: CellEditorMovement? + var cell: (row: Int, column: Int)? + editing.editor.onMovement = { row, column, reported in + movement = reported + cell = (row, column) + } + let handled = editing.editor.textView(editing.textView, doCommandBy: selector) + editing.editor.removeOverlay() + return Outcome(handled: handled, movement: movement, cell: cell) + } + + @Test("Down leaves a single-line value for the row below") + func downLeavesSingleLineValue() throws { + let editing = makeEditing(value: "alpha", selection: NSRange(location: 0, length: 5)) + + let result = send(#selector(NSResponder.moveDown(_:)), to: editing) + + #expect(result.handled) + #expect(result.movement == .down) + let cell = try #require(result.cell) + #expect(cell.row == 4) + #expect(cell.column == 2) + } + + @Test("Up leaves a single-line value for the row above") + func upLeavesSingleLineValue() { + let editing = makeEditing(value: "alpha", selection: NSRange(location: 2, length: 0)) + + let result = send(#selector(NSResponder.moveUp(_:)), to: editing) + + #expect(result.handled) + #expect(result.movement == .up) + } + + @Test("Down inside a multi-line value stays in the text view") + func downInsideMultiLineValueStays() { + let editing = makeEditing(value: "a\nb\nc", selection: NSRange(location: 0, length: 0)) + + let result = send(#selector(NSResponder.moveDown(_:)), to: editing) + + #expect(!result.handled) + #expect(result.movement == nil) + } + + @Test("Down from the last line of a multi-line value leaves the cell") + func downFromLastLineLeaves() { + let editing = makeEditing(value: "a\nb\nc", selection: NSRange(location: 5, length: 0)) + + let result = send(#selector(NSResponder.moveDown(_:)), to: editing) + + #expect(result.handled) + #expect(result.movement == .down) + } + + /// Shift+Down is `moveDownAndModifySelection:`, so extending a selection keeps its own meaning. + @Test("A selection-extending arrow is left to the text view") + func selectionExtendingArrowIsLeftAlone() { + let editing = makeEditing(value: "alpha", selection: NSRange(location: 0, length: 0)) + + let result = send(#selector(NSResponder.moveDownAndModifySelection(_:)), to: editing) + + #expect(!result.handled) + #expect(result.movement == nil) + } + + /// The text view holds provisional text until the input method commits it, so a movement here + /// would save a half-composed value. The arrow goes back to the composition, which is what + /// moves the caret through it; Tab is swallowed rather than turned into a literal tab. + @Test("An arrow during an IME composition stays with the composition") + func arrowDuringCompositionStaysWithTheComposition() { + let editing = makeEditing(value: "", selection: NSRange(location: 0, length: 0)) + editing.textView.setMarkedText( + "\u{304B}", + selectedRange: NSRange(location: 1, length: 0), + replacementRange: NSRange(location: 0, length: 0) + ) + + let result = send(#selector(NSResponder.moveDown(_:)), to: editing) + + #expect(editing.textView.hasMarkedText()) + #expect(!result.handled) + #expect(result.movement == nil) + } + + @Test("Tab during an IME composition is swallowed rather than leaving the cell") + func tabDuringCompositionIsSwallowed() { + let editing = makeEditing(value: "", selection: NSRange(location: 0, length: 0)) + editing.textView.setMarkedText( + "\u{304B}", + selectedRange: NSRange(location: 1, length: 0), + replacementRange: NSRange(location: 0, length: 0) + ) + + let result = send(#selector(NSResponder.insertTab(_:)), to: editing) + + #expect(result.handled) + #expect(result.movement == nil) + } + + @Test("Tab and Shift+Tab report their own movements") + func tabAndBacktabReportTheirMovements() { + let caret = NSRange(location: 0, length: 0) + let forward = send(#selector(NSResponder.insertTab(_:)), to: makeEditing(value: "alpha", selection: caret)) + let backward = send(#selector(NSResponder.insertBacktab(_:)), to: makeEditing(value: "alpha", selection: caret)) + + #expect(forward.handled) + #expect(forward.movement == .tab) + #expect(backward.handled) + #expect(backward.movement == .backtab) + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index fec9fa8c7..e826f0298 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -100,6 +100,8 @@ Every row except Find, Find Next and Find Previous is built into the editor and | Action | Shortcut | |--------|----------| | Edit cell | `Enter` | +| Edit the next / previous cell | `Tab` / `Shift+Tab` | +| Edit the cell above / below | `Up` / `Down` | | Insert a line break while editing | `Option+Enter` | | Cancel edit | `Escape` | | Add row | `Cmd+Shift+N` | @@ -111,6 +113,8 @@ Every row except Find, Find Next and Find Previous is built into the editor and | Undo change | `Cmd+Z` | | Redo change | `Cmd+Shift+Z` | +`Tab`, `Shift+Tab`, `Up` and `Down` save the cell before they move. In a value that holds line breaks, `Up` and `Down` move the insertion point between its lines and leave the cell only from the first or last one. + Truncate table opens a dialog with **Cascade** and **Ignore foreign key checks**, then stages a pending truncate that `Cmd+S` runs. ### Clipboard