// 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) { nodes := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes)) parents := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes)) for _, node := range doc.Nodes { nodes[node.Reference.ID()] = node for _, child := range node.Children { parents[child] = node.Reference.ID() } } nearestTable := func(id atspi.ObjectID) atspi.ObjectID { for depth := 0; id.Valid() && depth < 128; depth++ { if nodes[id].Snapshot.Role == "table" || nodes[id].Snapshot.Role == "tree table" { return id } id = parents[id] } return atspi.ObjectID{} } table := nearestTable(place.object.ID()) message := func(text string) (DocumentCaretMove, error) { return DocumentCaretMove{Position: documentPosition(doc, *place), Text: text}, nil } if !table.Valid() { return message("Not in a table.") } var current atspi.DocumentNode for id, depth := place.object.ID(), 0; id.Valid() && id != table && depth < 128; id, depth = parents[id], depth+1 { role := nodes[id].Snapshot.Role if (role == "table cell" || role == "row header" || role == "column header") && !nodes[id].Snapshot.TablePositionKnown { return message("Table coordinates unavailable.") } if nodes[id].Snapshot.TablePositionKnown { current = nodes[id] break } } rows, columns := nodes[table].Snapshot.TableRowCount, nodes[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 _, node := range doc.Nodes { if node.Snapshot.TablePositionKnown && !node.Hidden && nearestTable(node.Reference.ID()) == table { 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() { 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.") }