Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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

Expand Down
57 changes: 57 additions & 0 deletions TablePro/Views/Results/CellEditorMovement.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
48 changes: 39 additions & 9 deletions TablePro/Views/Results/CellOverlayEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 37 additions & 24 deletions TablePro/Views/Results/Extensions/DataGridView+Editing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
)
}
Expand All @@ -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)
}
}
10 changes: 9 additions & 1 deletion TablePro/Views/Results/KeyHandlingTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions TableProTests/Views/Results/CellEditorArrowExitTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading