Recreated-node caret recovery, tolerant caret validation, per-root retained document trees and cursors across tab switches, document warm-up, coalesced structural moves, and hung-provider fast-fail with an 'Application is not responding' announcement.
244 lines
7.6 KiB
Go
244 lines
7.6 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package review
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
// moveTable uses provider coordinates, never child order as a rectangular grid.
|
|
// The virtual coordinate survives a spanning cell so reversing direction keeps
|
|
// the user's column/row. Nested tables have independent coordinate spaces.
|
|
func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentSnapshot, place *documentPlace, command DocumentCaretCommand) (DocumentCaretMove, error) {
|
|
tableIndex, found := document_table_index(doc, place.object.ID())
|
|
message := func(text string) (DocumentCaretMove, error) {
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place), Text: text}, nil
|
|
}
|
|
if !found {
|
|
return message("Not in a table.")
|
|
}
|
|
table := doc.Nodes[tableIndex]
|
|
tableEnd := doc.SubtreeEnd(tableIndex)
|
|
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, tableIndex, tableEnd); err != nil {
|
|
return DocumentCaretMove{}, err
|
|
}
|
|
var current atspi.DocumentNode
|
|
for id, depth := place.object.ID(), 0; id.Valid() && id != table.Reference.ID() && depth < 128; depth++ {
|
|
node, ok := doc.Node(id)
|
|
if !ok {
|
|
break
|
|
}
|
|
role := node.Snapshot.Role
|
|
if (role == "table cell" || role == "row header" || role == "column header") && !node.Snapshot.TablePositionKnown {
|
|
return message("Table coordinates unavailable.")
|
|
}
|
|
if node.Snapshot.TablePositionKnown {
|
|
current = node
|
|
break
|
|
}
|
|
id = node.Parent
|
|
}
|
|
rows, columns := table.Snapshot.TableRowCount, table.Snapshot.TableColumnCount
|
|
if rows <= 0 || columns <= 0 {
|
|
return message("Table coordinates unavailable.")
|
|
}
|
|
row, col := current.Snapshot.TableRowIndex, current.Snapshot.TableColumnIndex
|
|
if current.Reference.Valid() && place.tableCell == current.Reference.ID() {
|
|
row, col = place.tableRow, place.tableColumn
|
|
}
|
|
dr, dc := 0, 0
|
|
switch command {
|
|
case TableLeft:
|
|
dc = -1
|
|
case TableRight:
|
|
dc = 1
|
|
case TableUp:
|
|
dr = -1
|
|
case TableDown:
|
|
dr = 1
|
|
case TableFirst:
|
|
row, col = 0, 0
|
|
case TableLast:
|
|
row, col = rows-1, columns-1
|
|
}
|
|
// A table structural stop starts at its first cell on directional movement.
|
|
if current.Reference.Valid() {
|
|
row += dr
|
|
col += dc
|
|
} else if command != TableLast {
|
|
row, col = 0, 0
|
|
}
|
|
cells := make([]atspi.DocumentNode, 0)
|
|
for index := tableIndex + 1; index <= tableEnd; index++ {
|
|
node := doc.Nodes[index]
|
|
if node.Snapshot.Role == "table" || node.Snapshot.Role == "tree table" {
|
|
index = doc.SubtreeEnd(index)
|
|
continue
|
|
}
|
|
if node.Snapshot.TablePositionKnown && !node.Hidden {
|
|
cells = append(cells, node)
|
|
}
|
|
}
|
|
for attempts := 0; attempts <= len(cells)+1; attempts++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return DocumentCaretMove{}, err
|
|
}
|
|
if col < 0 {
|
|
return message("Beginning of row.")
|
|
}
|
|
if col >= columns {
|
|
return message("End of row.")
|
|
}
|
|
if row < 0 {
|
|
return message("Top of column.")
|
|
}
|
|
if row >= rows {
|
|
return message("Bottom of column.")
|
|
}
|
|
var target atspi.DocumentNode
|
|
for _, node := range cells {
|
|
s := node.Snapshot
|
|
if row >= s.TableRowIndex && row < s.TableRowIndex+max(1, s.TableRowSpan) && col >= s.TableColumnIndex && col < s.TableColumnIndex+max(1, s.TableColumnSpan) {
|
|
target = node
|
|
break
|
|
}
|
|
}
|
|
if !target.Reference.Valid() && (dr != 0 || dc != 0) {
|
|
target, row, col = next_available_table_cell(cells, current, row, col, dr, dc)
|
|
}
|
|
if !target.Reference.Valid() {
|
|
return message("Table cell unavailable.")
|
|
}
|
|
s := target.Snapshot
|
|
if target.Reference == current.Reference && (dr != 0 || dc != 0) {
|
|
// Jump over the span rather than iterating potentially huge dimensions.
|
|
if dr > 0 {
|
|
row = s.TableRowIndex + max(1, s.TableRowSpan)
|
|
}
|
|
if dr < 0 {
|
|
row = s.TableRowIndex - 1
|
|
}
|
|
if dc > 0 {
|
|
col = s.TableColumnIndex + max(1, s.TableColumnSpan)
|
|
}
|
|
if dc < 0 {
|
|
col = s.TableColumnIndex - 1
|
|
}
|
|
continue
|
|
}
|
|
parts := []string{}
|
|
rowHeader, columnHeader := "", ""
|
|
if s.TableRowHeaders != "" && s.TableRowHeaders != current.Snapshot.TableRowHeaders {
|
|
rowHeader = s.TableRowHeaders
|
|
parts = append(parts, rowHeader)
|
|
}
|
|
if s.TableColumnHeaders != "" && s.TableColumnHeaders != current.Snapshot.TableColumnHeaders {
|
|
columnHeader = s.TableColumnHeaders
|
|
parts = append(parts, columnHeader)
|
|
}
|
|
label := strings.TrimSpace(s.Name)
|
|
prefix := strings.Join(parts, " ")
|
|
prefixCount := len(parts)
|
|
if label == "" {
|
|
label = strings.TrimSpace(strings.ReplaceAll(s.Text, "\ufffc", ""))
|
|
}
|
|
if label == "" {
|
|
label = "blank"
|
|
}
|
|
parts = append(parts, label, fmt.Sprintf("Row %d, column %d.", s.TableRowIndex+1, s.TableColumnIndex+1))
|
|
if s.TableRowSpan > 1 {
|
|
parts = append(parts, fmt.Sprintf("Spans %d rows.", s.TableRowSpan))
|
|
}
|
|
if s.TableColumnSpan > 1 {
|
|
parts = append(parts, fmt.Sprintf("Spans %d columns.", s.TableColumnSpan))
|
|
}
|
|
suffix := strings.Join(parts[prefixCount+1:], " ")
|
|
place.object, place.offset, place.origin = target.Reference, 0, StructuralNavigation
|
|
place.tableCell, place.tableRow, place.tableColumn = target.Reference.ID(), row, col
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place), Text: strings.Join(parts, " "), TablePrefix: prefix, TableSuffix: suffix,
|
|
TableRowHeader: rowHeader, TableColumnHeader: columnHeader, Moved: true}, nil
|
|
}
|
|
return message("Table cell unavailable.")
|
|
}
|
|
|
|
// Use the immutable document index and avoid rebuilding whole-page parent maps.
|
|
func document_table_index(doc atspi.DocumentSnapshot, id atspi.ObjectID) (int, bool) {
|
|
for depth := 0; id.Valid() && depth < 128; depth++ {
|
|
index, ok := doc.Index(id)
|
|
if !ok {
|
|
break
|
|
}
|
|
node := doc.Nodes[index]
|
|
if node.Snapshot.Role == "table" || node.Snapshot.Role == "tree table" {
|
|
return index, true
|
|
}
|
|
id = node.Parent
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// Search captured cells rather than iterating potentially enormous sparse
|
|
// dimensions. Horizontal neighbors can occupy a lower row of a spanning cell.
|
|
func next_available_table_cell(cells []atspi.DocumentNode, current atspi.DocumentNode, row, col, dr, dc int) (atspi.DocumentNode, int, int) {
|
|
var target atspi.DocumentNode
|
|
bestRow, bestCol := row, col
|
|
for _, node := range cells {
|
|
if node.Reference == current.Reference {
|
|
continue
|
|
}
|
|
s := node.Snapshot
|
|
rowEnd, colEnd := s.TableRowIndex+max(1, s.TableRowSpan)-1, s.TableColumnIndex+max(1, s.TableColumnSpan)-1
|
|
candidateRow, candidateCol := row, col
|
|
if dr != 0 {
|
|
if col < s.TableColumnIndex || col > colEnd {
|
|
continue
|
|
}
|
|
if dr > 0 {
|
|
candidateRow = max(row, s.TableRowIndex)
|
|
if candidateRow > rowEnd {
|
|
continue
|
|
}
|
|
} else {
|
|
candidateRow = min(row, rowEnd)
|
|
if candidateRow < s.TableRowIndex {
|
|
continue
|
|
}
|
|
}
|
|
if target.Reference.Valid() && (dr > 0 && candidateRow >= bestRow || dr < 0 && candidateRow <= bestRow) {
|
|
continue
|
|
}
|
|
} else {
|
|
lastRow := row
|
|
if current.Reference.Valid() {
|
|
lastRow = max(row, current.Snapshot.TableRowIndex+max(1, current.Snapshot.TableRowSpan)-1)
|
|
}
|
|
candidateRow = max(row, s.TableRowIndex)
|
|
if candidateRow > rowEnd || candidateRow > lastRow {
|
|
continue
|
|
}
|
|
if dc > 0 {
|
|
candidateCol = max(col, s.TableColumnIndex)
|
|
if candidateCol > colEnd {
|
|
continue
|
|
}
|
|
} else {
|
|
candidateCol = min(col, colEnd)
|
|
if candidateCol < s.TableColumnIndex {
|
|
continue
|
|
}
|
|
}
|
|
if target.Reference.Valid() && (candidateRow > bestRow || candidateRow == bestRow && (dc > 0 && candidateCol >= bestCol || dc < 0 && candidateCol <= bestCol)) {
|
|
continue
|
|
}
|
|
}
|
|
target, bestRow, bestCol = node, candidateRow, candidateCol
|
|
}
|
|
return target, bestRow, bestCol
|
|
}
|