diff --git a/.claude/rules/data-grid.md b/.claude/rules/data-grid.md index ef60182664..e86d304b26 100644 --- a/.claude/rules/data-grid.md +++ b/.claude/rules/data-grid.md @@ -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. diff --git a/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift b/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift index 71e665d3b0..c74cde2a24 100644 --- a/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridAccessibilityCellView.swift @@ -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.. [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() diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift index 9553566c2e..ec17de9e21 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift @@ -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() diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController.swift b/apple/TablePro/Views/DataGrid/DataGridViewController.swift index ea5af37b7b..dd7176f7af 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController.swift @@ -29,7 +29,7 @@ final class DataGridViewController: NSViewController, DataGridMountedView { var knownWidths: [DataGridColumnID: CGFloat] = [:] var knownWidthsSawRows = false var savedWidthIDs: Set = [] - var accessibilityMountedRows = NSRange(location: 0, length: 0) + var isAccessibilityRemountScheduled = false var needsViewportRestore = false var pendingFocus = false var lastDataGridSettings = AppSettingsManager.shared.dataGrid @@ -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() } diff --git a/apple/TableProTests/Views/DataGrid/DataGridAccessibilityMountTests.swift b/apple/TableProTests/Views/DataGrid/DataGridAccessibilityMountTests.swift new file mode 100644 index 0000000000..411c6357ad --- /dev/null +++ b/apple/TableProTests/Views/DataGrid/DataGridAccessibilityMountTests.swift @@ -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..