// 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 }