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: 1 addition & 1 deletion .claude/rules/data-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ Three layers, knowledge pointing one way: an owner's adapter, then `DataGridMode
- **AppKit builds the AXCell tree from cell views only**, so while `DataGridAccessibility.isActive` the main table mounts a `DataGridAccessibilityCellView` (identifier `data-grid-cell`) for each on-screen cell. It draws nothing and takes no clicks. Keep that path working whenever row drawing changes.
- **A label reads "Row N, column M: value"**: N is the row number on screen, page offset included, and M the display position. UI tests find cells by it (`.claude/rules/ui-tests.md`, The grid).
- **The frozen pane is hidden from accessibility.** A frozen column's stand-in in the main table reports the frame the frozen pane shows it at.
- **Only the viewport mounts.** Mounting for every row starved the main thread behind a 1,000-row page (#2392); rows scrolled into view remount.
- **Only the viewport mounts.** Mounting for every row starved the main thread behind a 1,000-row page (#2392). A client's read prepares the rows it walks, off-screen ones without a stand-in, and AppKit never asks for a prepared row again. So each on-screen row still without one remounts after a scroll, a layout of the grid and a read of the table: a fitted grid grows to its rows, and a page moves a grid, with no scroll.
27 changes: 20 additions & 7 deletions apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,19 +156,32 @@ extension DataGridViewController {
return cell
}

// A row prepared off screen was answered with no view and is never asked again, so revealed rows reload.
// A row prepared off screen was answered with no view and is never asked again, so an on-screen row without
// its stand-in reloads. Asked of the rows themselves: a client's read prepares rows the grid never showed.
func remountAccessibilityCells(force: Bool = false) {
guard DataGridAccessibility.isActive, !isEditingCell else { return }
let table = main.tableView
let visible = table.rows(in: table.visibleRect)
guard visible.length > 0, force || !NSEqualRanges(visible, accessibilityMountedRows) else { return }
let upperBound = min(visible.location + visible.length, table.numberOfRows)
guard visible.location >= 0, upperBound > visible.location, table.numberOfColumns > 0 else { return }
accessibilityMountedRows = visible
table.reloadData(
forRowIndexes: IndexSet(integersIn: visible.location..<upperBound),
columnIndexes: IndexSet(integersIn: 0..<table.numberOfColumns)
)
let rows = IndexSet(integersIn: visible.location..<upperBound).filteredIndexSet { row in
guard table.rowView(atRow: row, makeIfNecessary: false) != nil else { return false }
return force || !(table.view(atColumn: 0, row: row, makeIfNecessary: false) is DataGridAccessibilityCellView)
}
guard !rows.isEmpty else { return }
table.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integersIn: 0..<table.numberOfColumns))
}

// Rows also come on screen with no scroll: a fitted grid grows to its rows, a pane resizes, a page moves the
// grid. Deferred past the layout pass or accessibility read that asks, which a reload inside would re-enter.
func scheduleAccessibilityRemount() {
guard DataGridAccessibility.isActive, !isAccessibilityRemountScheduled else { return }
isAccessibilityRemountScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.isAccessibilityRemountScheduled = false
self.remountAccessibilityCells()
}
}

// Clamped to the visible rows: AppKit would otherwise ask for every cell of a million-row selection.
Expand Down
10 changes: 8 additions & 2 deletions apple/TablePro/Views/DataGrid/DataGridTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,17 +258,23 @@ final class DataGridTableView: NSTableView {

override func accessibilityChildren() -> [Any]? {
guard paneKind == .main else { return [] }
DataGridAccessibility.markActive()
noteAccessibilityRead()
return super.accessibilityChildren()
}

override func accessibilityCell(forColumn column: Int, row: Int) -> Any? {
guard paneKind == .main else { return nil }
DataGridAccessibility.markActive()
noteAccessibilityRead()
return view(atColumn: column, row: row, makeIfNecessary: false) as? DataGridAccessibilityCellView
?? super.accessibilityCell(forColumn: column, row: row)
}

// A read prepares every row it walks, off screen ones without a stand-in, and some reach the screen with no scroll.
private func noteAccessibilityRead() {
DataGridAccessibility.markActive()
controller?.scheduleAccessibilityRemount()
}

override func accessibilitySelectedCells() -> [Any]? {
guard paneKind == .main else { return [] }
return controller?.accessibilitySelectedCells() ?? super.accessibilitySelectedCells()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ extension DataGridViewController {
for pane in panes {
pane.tableView.reloadData()
}
accessibilityMountedRows = NSRange(location: 0, length: 0)
drawnSelection = model.selection
// A view-based reloadData() drops the selection without telling anyone (probe P2), so it is written back.
mirrorSelection()
Expand Down
3 changes: 2 additions & 1 deletion apple/TablePro/Views/DataGrid/DataGridViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ final class DataGridViewController: NSViewController, DataGridMountedView {
var knownWidths: [DataGridColumnID: CGFloat] = [:]
var knownWidthsSawRows = false
var savedWidthIDs: Set<DataGridColumnID> = []
var accessibilityMountedRows = NSRange(location: 0, length: 0)
var isAccessibilityRemountScheduled = false
var needsViewportRestore = false
var pendingFocus = false
var lastDataGridSettings = AppSettingsManager.shared.dataGrid
Expand Down Expand Up @@ -159,6 +159,7 @@ final class DataGridViewController: NSViewController, DataGridMountedView {
applyStagedViewport()
}
updateFittingHeight()
scheduleAccessibilityRemount()
if pendingPageReveal != nil {
// The page scrolls once its own layout is done, so its document already holds the grid's new height.
DispatchQueue.main.async { [weak self] in self?.performPageReveal() }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// DataGridAccessibilityMountTests.swift
// TableProTests
//

import AppKit
@testable import TablePro
import TableProTestSupport
import Testing

@MainActor
private final class FlippedPage: NSView {
override var isFlipped: Bool { true }
}

// A fitsRows grid on a page that scrolls, placed by hand the way the Definition page places it.
@MainActor
private final class FittedGridOnPage {
let grid: HostedDataGrid
private let page = NSScrollView(frame: NSRect(x: 0, y: 0, width: 800, height: 400))
private let document = FlippedPage(frame: .zero)

init(rows: TableRows, top: CGFloat, height: CGFloat) {
var configuration = DataGridConfiguration()
configuration.fitsRows = true
grid = HostedDataGrid(rows: rows, configuration: configuration)
page.hasVerticalScroller = true
page.documentView = document
grid.window.contentView = page
let gridView = grid.controller.view
gridView.removeFromSuperview()
gridView.autoresizingMask = []
document.addSubview(gridView)
place(top: top, height: height)
// HostedDataGrid first laid the grid out in a plain window; a reload starts it over where it now sits.
grid.model.reloadRows()
grid.layout()
}

var fittedHeight: CGFloat { grid.controller.measuredFittingHeight }

func place(top: CGFloat, height: CGFloat) {
document.frame = NSRect(x: 0, y: 0, width: page.bounds.width, height: max(page.bounds.height, top + height))
grid.controller.view.frame = NSRect(x: 0, y: top, width: page.bounds.width, height: height)
grid.layout()
}

// What an assistive client does on its first look: down to each cell's children, which prepares the cell's row.
func readEveryRow() {
let rows = grid.main.accessibilityAttributeValue(.children) as? [NSObject] ?? []
for row in rows {
let cells = row.accessibilityAttributeValue(.children) as? [NSObject] ?? []
for cell in cells {
_ = cell.accessibilityAttributeValue(.children)
}
}
}

var preparedRows: IndexSet {
IndexSet((0..<grid.main.numberOfRows).filter { grid.main.rowView(atRow: $0, makeIfNecessary: false) != nil })
}

var mountedRows: IndexSet {
IndexSet((0..<grid.main.numberOfRows).filter {
grid.main.view(atColumn: 0, row: $0, makeIfNecessary: false) is DataGridAccessibilityCellView
})
}

var visibleRows: IndexSet {
let range = grid.main.rows(in: grid.main.visibleRect)
return IndexSet(integersIn: range.location..<(range.location + range.length))
}

func close() {
grid.close()
}
}

extension GlobalStateSuites {
@MainActor
struct DataGridAccessibilityMountTests {
private let everyRow = IndexSet(integersIn: 0..<5)

// A fitted grid mounts at height 0 and grows once it has measured its rows. A client that reads it in
// between prepares every row while none is on screen, so none got a stand-in, and growing is no scroll.
@Test("A fitted grid read before it grows publishes every row once it has grown")
func rowsReadBeforeTheGridGrowsMount() async throws {
let wasActive = DataGridAccessibility.isActive
DataGridAccessibility.isActive = true
defer { DataGridAccessibility.isActive = wasActive }
let page = FittedGridOnPage(rows: GridFixtureRows.people, top: 40, height: 0)
defer { page.close() }

page.readEveryRow()
try #require(page.preparedRows == everyRow)
try #require(page.mountedRows.isEmpty)

page.place(top: 40, height: page.fittedHeight)
#expect(page.visibleRows == everyRow)

try await waitUntil { page.mountedRows == everyRow }
}

// The page moves a grid when a section above it collapses, which neither scrolls nor resizes the grid.
@Test("A grid moved onto the page publishes its rows to the next read")
func rowsMovedOntoThePageMountOnTheNextRead() async throws {
let wasActive = DataGridAccessibility.isActive
DataGridAccessibility.isActive = true
defer { DataGridAccessibility.isActive = wasActive }
let page = FittedGridOnPage(rows: GridFixtureRows.people, top: 40, height: 0)
defer { page.close() }
let height = page.fittedHeight
page.place(top: 1_000, height: height)
page.readEveryRow()
try #require(page.visibleRows.isEmpty)

page.place(top: 40, height: height)
try #require(page.visibleRows == everyRow)
page.readEveryRow()

try await waitUntil { page.mountedRows == everyRow }
}

@Test("Only the rows on the page get a stand-in, however many a client reads")
func onlyRowsOnThePageMount() async throws {
let wasActive = DataGridAccessibility.isActive
DataGridAccessibility.isActive = true
defer { DataGridAccessibility.isActive = wasActive }
let page = FittedGridOnPage(rows: GridFixtureRows.wide(rows: 80, columns: 3), top: 0, height: 0)
defer { page.close() }
page.place(top: 0, height: page.fittedHeight)
let onPage = page.visibleRows
try #require(!onPage.isEmpty && onPage.count < 80)

page.readEveryRow()
try await waitUntil { page.mountedRows == onPage }
}
}
}
Loading