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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Connections strip not scrolling to the entry you switch to.
- Grid cells left at the old column positions until the next click, after a resize, an auto-fit, a reorder, hiding a column, or a row-number width change. (#2449)
- The row-number column draggable out of first place, which walked it to the far right on the next refresh.
- Double-clicking a cell editing the wrong row, after deleting one of several rows added in the same session.
- VoiceOver reading a cell's value under a different row's number after such a delete.

## [0.68.1] - 2026-08-26

Expand Down
21 changes: 18 additions & 3 deletions TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,32 @@ internal final class DataGridCellAccessibilityView: NSView {
internal static let reuseIdentifier = NSUserInterfaceItemIdentifier("DataGridCellAccessibilityView")

private weak var coordinator: TableViewCoordinator?
private var row = 0
private var seededRow = 0
private var dataColumn = 0

/// The row this cell is showing now, asked of the table rather than remembered, for the reason
/// `DataGridRowView.rowIndex` is: an incremental insert or remove moves a mounted cell view to
/// its new slot without asking for it again, so a stored index outlives the row it named and the
/// cell then speaks a different row's value under the old row's number.
private var row: Int {
guard let tableView = coordinator?.tableView else { return seededRow }
let resolved = tableView.row(for: self)
return resolved >= 0 ? resolved : seededRow
}

internal func configure(coordinator: TableViewCoordinator, row: Int, dataColumn: Int) {
self.coordinator = coordinator
self.row = row
seededRow = row
self.dataColumn = dataColumn
setAccessibilityRowIndexRange(NSRange(location: row, length: 1))
setAccessibilityColumnIndexRange(NSRange(location: dataColumn, length: 1))
}

/// Answered live for the same reason, rather than stamped in `configure`, so a client reading the
/// tree after a row moved is told where the cell is rather than where it was built.
override internal func accessibilityRowIndexRange() -> NSRange {
NSRange(location: row, length: 1)
}

override internal func hitTest(_ point: NSPoint) -> NSView? { nil }

override internal func isAccessibilityElement() -> Bool { true }
Expand Down
25 changes: 23 additions & 2 deletions TablePro/Views/Results/DataGridRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,27 @@ class DataGridRowView: NSTableRowView {
}

weak var coordinator: TableViewCoordinator?
var rowIndex: Int = 0

/// The row this view is showing now, asked of the table rather than remembered.
///
/// `insertRows(at:)` and `removeRows(at:)` move an already-built row view to its new slot
/// without calling `tableView(_:rowViewForRow:)` for it again, so an index captured at mount
/// goes stale the moment a row is inserted or removed above this one, and every read of it then
/// names a different row. Measured: after `removeRows(at: [1])` the view at display row 1 still
/// carried 2, the one at 2 carried 3, and an insert left two views both claiming 0, while
/// `row(for:)` answered correctly throughout at 26ns a call. AppKit keeps that mapping itself,
/// so it is asked for it. The seed answers only for a row view that is in no table, which is how
/// the copy tests build one.
var rowIndex: Int {
get {
guard let tableView = coordinator?.tableView else { return seededRowIndex }
let resolved = tableView.row(for: self)
return resolved >= 0 ? resolved : seededRowIndex
}
set { seededRowIndex = newValue }
}

private var seededRowIndex: Int = 0

private(set) var visualState: RowVisualState = .empty
private var rowTint: NSColor?
Expand Down Expand Up @@ -132,13 +152,14 @@ class DataGridRowView: NSTableRowView {
guard let coordinator, let tableView = coordinator.tableView else { return }
let inTableView = view.convert(dirtyRect, to: tableView)
let onEmphasizedSelection = isSelected && isEmphasized
let row = rowIndex

for tableColumnIndex in tableView.columnIndexes(in: inTableView) {
guard tableColumnIndex < tableView.tableColumns.count else { continue }
let identifier = tableView.tableColumns[tableColumnIndex].identifier
guard let dataColumn = coordinator.dataColumnIndex(from: identifier) else { continue }
guard let appearance = coordinator.cellAppearance(
row: rowIndex,
row: row,
columnIndex: dataColumn,
onEmphasizedSelection: onEmphasizedSelection
) else { continue }
Expand Down
173 changes: 173 additions & 0 deletions TableProTests/Views/Results/DataGridRowIdentityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//
// DataGridRowIdentityTests.swift
// TableProTests
//

import AppKit
import SwiftUI
import TableProPluginKit
import Testing

@testable import TablePro

@MainActor
private final class NoopRowIdentityPersister: ColumnLayoutPersisting {
func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil }
func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {}
func clear(for key: ColumnLayoutTableKey) {}
}

/// `insertRows(at:)` and `removeRows(at:)` move an already-built row view to its new slot without
/// asking the delegate for it again, so an index captured when the view was mounted names a
/// different row from then on. Measured: after `removeRows(at: [1])` the view at display row 1 still
/// carried 2 and an insert left two views both claiming 0, which sent a double-click to the wrong
/// row and had an accessibility cell speak one row's value under another row's number.
@Suite("Data grid row identity", .serialized)
@MainActor
struct DataGridRowIdentityTests {
private struct Grid {
let window: NSWindow
let tableView: KeyHandlingTableView
let coordinator: TableViewCoordinator
let rowCount: Box

var rowViews: [DataGridRowView] {
(0 ..< tableView.numberOfRows).compactMap {
tableView.rowView(atRow: $0, makeIfNecessary: false) as? DataGridRowView
}
}
}

/// The provider is read on every access, so the row count has to live somewhere the test can
/// move before it tells the table view about the mutation, exactly as the coordinator does.
private final class Box {
var value: Int
init(_ value: Int) { self.value = value }
}

private static func rows(count: Int) -> TableRows {
TableRows.from(
queryRows: (0 ..< count).map { [.text("id-\($0)"), .text("name-\($0)")] },
columns: ["id", "name"],
columnTypes: [.text(rawType: "TEXT"), .text(rawType: "TEXT")]
)
}

private func makeGrid(rowCount: Int = 5) -> Grid {
let box = Box(rowCount)
let coordinator = TableViewCoordinator(
changeManager: AnyChangeManager(DataChangeManager()),
isEditable: true,
selectedRowIndices: .constant([]),
delegate: nil,
layoutPersister: NoopRowIdentityPersister()
)
coordinator.tableRowsProvider = { Self.rows(count: box.value) }
coordinator.rebuildColumnMetadataCache(from: Self.rows(count: box.value))
coordinator.updateCache()

let tableView = KeyHandlingTableView(frame: NSRect(x: 0, y: 0, width: 600, height: 400))
tableView.columnAutoresizingStyle = .noColumnAutoresizing
tableView.rowHeight = 21
tableView.coordinator = coordinator
tableView.dataSource = coordinator
tableView.delegate = coordinator
tableView.addTableColumn(DataGridView.makeRowNumberColumn())
coordinator.tableView = tableView
coordinator.columnPool.reconcile(
tableView: tableView,
schema: coordinator.identitySchema,
columnTypes: [.text(rawType: "TEXT"), .text(rawType: "TEXT")],
savedLayout: nil,
isEditable: true,
hiddenColumnNames: [],
widthCalculator: { _, _ in 120 }
)

let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 600, height: 400))
scrollView.documentView = tableView
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 400),
styleMask: [.titled],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.contentView = scrollView
tableView.reloadData()
window.layoutIfNeeded()
for row in 0 ..< tableView.numberOfRows {
_ = tableView.rowView(atRow: row, makeIfNecessary: true)
}
return Grid(window: window, tableView: tableView, coordinator: coordinator, rowCount: box)
}

@Test("Every mounted row reports its own display row before any mutation")
func rowsStartCorrect() {
let grid = makeGrid()
#expect(grid.rowViews.map(\.rowIndex) == Array(0 ..< 5))
}

@Test("Removing a row above a mounted one moves its index down")
func removeShiftsTheRowsBelow() throws {
let grid = makeGrid()
let third = try #require(grid.tableView.rowView(atRow: 2, makeIfNecessary: false) as? DataGridRowView)
#expect(third.rowIndex == 2)

grid.rowCount.value = 4
grid.coordinator.updateCache()
grid.tableView.removeRows(at: IndexSet(integer: 1), withAnimation: [])

#expect(third.rowIndex == 1)
#expect(grid.rowViews.map(\.rowIndex) == Array(0 ..< 4))
}

@Test("Inserting a row above a mounted one moves its index up")
func insertShiftsTheRowsBelow() throws {
let grid = makeGrid()
let first = try #require(grid.tableView.rowView(atRow: 0, makeIfNecessary: false) as? DataGridRowView)
#expect(first.rowIndex == 0)

grid.rowCount.value = 6
grid.coordinator.updateCache()
grid.tableView.insertRows(at: IndexSet(integer: 0), withAnimation: [])

#expect(first.rowIndex == 1)
#expect(grid.rowViews.map(\.rowIndex) == Array(0 ..< 6))
}

/// A row view outside any table keeps the index it was handed, which is the shape the copy tests
/// build and the only case the stored seed still answers for.
@Test("A row view in no table falls back to the index it was given")
func detachedRowKeepsItsSeededIndex() {
let rowView = DataGridRowView()
rowView.rowIndex = 7
#expect(rowView.rowIndex == 7)
}

/// The accessibility cell is a real mounted view, so an incremental mutation moves it the same
/// way and it used to keep speaking the row it was built for.
@Test("An accessibility cell moved by a removal speaks its new row")
func accessibilityCellFollowsTheRemoval() throws {
DataGridAccessibility.isActive = true
defer { DataGridAccessibility.isActive = false }
let grid = makeGrid()
grid.tableView.reloadData()
grid.window.layoutIfNeeded()

let dataColumn = try #require(grid.coordinator.tableColumnIndex(for: 0))
let cell = try #require(
grid.tableView.view(atColumn: dataColumn, row: 2, makeIfNecessary: true)
as? DataGridCellAccessibilityView
)
#expect(cell.accessibilityValue() as? String == "id-2")
#expect(cell.accessibilityRowIndexRange().location == 2)

grid.rowCount.value = 4
grid.coordinator.updateCache()
grid.tableView.removeRows(at: IndexSet(integer: 1), withAnimation: [])

#expect(cell.accessibilityRowIndexRange().location == 1)
#expect(cell.accessibilityValue() as? String == "id-1")
}
}
Loading