// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package review import ( "context" "sort" "strings" "git.stormux.org/storm/cthulhu/internal/atspi" ) // FlatUnit describes sequential reading, independently of keyboard layout. type FlatUnit int const ( Line FlatUnit = iota + 1 Word Character WindowStart WindowEnd WordAbove WordBelow LineEnd ) // FlatPosition is owned text at the review location. Offset counts Unicode // code points in Object's AT-SPI text; synthetic accessible names have no // offset. Bounds belong to the whole captured zone, never guessed characters. type FlatPosition struct { Text string Object atspi.ObjectID Offset int HasOffset bool Bounds atspi.Rectangle } type flatLocation struct{ line, zone, character int } // FlatCursor is owned by the review worker. It rebuilds from one atomic owned // window snapshot before every command, preserving an independent location by // object and character offset when focus has not changed. No native queries or // output calls occur here. Failed refreshes leave the previous location intact // but return an error, so stale content is never presented as current. type FlatCursor struct { lines []flatLine location flatLocation focus atspi.ObjectID tree *atspi.AccessibleTree references map[atspi.ObjectID]atspi.ObjectReference parents map[atspi.ObjectID]atspi.ObjectID verticalTargetX int64 hasVerticalTarget bool started bool } func (cursor *FlatCursor) Refresh(ctx context.Context, tree *atspi.AccessibleTree, focus atspi.ObjectID) error { if tree == nil { return ErrNoCurrent } snapshot, err := tree.ReviewWindow(ctx, focus) if err != nil { return err } nodes := snapshot.Nodes lines, err := buildFlatLines(ctx, nodes) if err != nil { return err } next := FlatCursor{lines: lines, focus: focus, tree: tree, references: snapshot.References, parents: make(map[atspi.ObjectID]atspi.ObjectID, len(nodes))} for _, n := range nodes { for _, child := range n.Children { next.parents[child] = n.Snapshot.ID() } } // Exact identity takes priority over a displayed descendant of focus. if !next.seek(focus, 0, false) { next.seekDescendant(focus) } for _, n := range nodes { if n.Snapshot.ID() == focus && n.Text.CaretKnown { next.seek(focus, n.Text.CaretOffset, true) break } } if cursor.focus == focus && cursor.tree == tree && len(cursor.lines) > 0 { old := cursor.position(Character) if cursor.references[old.Object] == next.references[old.Object] { if next.seek(old.Object, old.Offset, old.HasOffset) { next.verticalTargetX = cursor.verticalTargetX next.hasVerticalTarget = cursor.hasVerticalTarget } } } next.started = cursor.started && cursor.focus == focus *cursor = next return nil } func (cursor *FlatCursor) seekDescendant(object atspi.ObjectID) bool { for li, line := range cursor.lines { for zi, zone := range line.zones { for id := cursor.parents[zone.object]; id.Valid(); id = cursor.parents[id] { if id == object { cursor.location = flatLocation{line: li, zone: zi} return true } } } } return false } func (cursor *FlatCursor) seek(object atspi.ObjectID, offset int, hasOffset bool) bool { for li, line := range cursor.lines { for zi, zone := range line.zones { if zone.object != object { continue } if hasOffset && (!zone.hasOffset || offset < zone.start || offset >= zone.start+len(zone.runes)) { continue } character := 0 if hasOffset { character = offset - zone.start } else if !zone.hasOffset && offset >= 0 && offset < len(zone.runes) { character = offset } cursor.location = flatLocation{li, zi, character} return true } } // End-of-line/text caret positions are blank, but still have an exact // source offset. Prefer an actual character in a following range above. if hasOffset { for li, line := range cursor.lines { for zi, zone := range line.zones { if zone.object == object && zone.hasOffset && offset == zone.start+len(zone.runes) { cursor.location = flatLocation{li, zi, len(zone.runes)} return true } } } } return false } // Read moves by -1, 0, or +1 units and returns the resulting text. It crosses // zone and line boundaries, but never wraps from the window's end to its start. // A boundary returns moved=false and leaves the location unchanged. func (cursor *FlatCursor) Read(unit FlatUnit, step int) (FlatPosition, bool, error) { if unit < Line || unit > LineEnd || step < -1 || step > 1 || (unit > Character && step != 0) { return FlatPosition{}, false, ErrInvalidDirection } if len(cursor.lines) == 0 { return FlatPosition{}, false, ErrNoReviewContent } old := cursor.location switch unit { case WindowStart: cursor.location = flatLocation{} if cursor.location != old { cursor.hasVerticalTarget = false } return cursor.position(Line), cursor.location != old, nil case WindowEnd: line := len(cursor.lines) - 1 zone := len(cursor.lines[line].zones) - 1 character := len(cursor.lines[line].zones[zone].runes) - 1 cursor.location = flatLocation{line: line, zone: zone, character: character} if cursor.location != old { cursor.hasVerticalTarget = false } return cursor.position(Line), cursor.location != old, nil case WordAbove: if cursor.moveVertically(-1, cursor.verticalX()) { return cursor.position(Word), true, nil } return cursor.position(Word), false, nil case WordBelow: if cursor.moveVertically(1, cursor.verticalX()) { return cursor.position(Word), true, nil } return cursor.position(Word), false, nil case LineEnd: line := cursor.location.line zone := len(cursor.lines[line].zones) - 1 character := len(cursor.lines[line].zones[zone].runes) - 1 cursor.location = flatLocation{line: line, zone: zone, character: character} if cursor.location != old { cursor.hasVerticalTarget = false } return cursor.position(Character), cursor.location != old, nil } if step != 0 { switch unit { case Line: line := old.line + step if line >= 0 && line < len(cursor.lines) { cursor.location = flatLocation{line: line} } case Character: cursor.advanceCharacter(step) case Word: cursor.advanceWord(step) } } moved := cursor.location != old if moved { cursor.hasVerticalTarget = false } return cursor.position(unit), moved, nil } func (cursor *FlatCursor) verticalX() int64 { if !cursor.hasVerticalTarget { cursor.verticalTargetX = characterCenterX(cursor.zone(), cursor.location.character) cursor.hasVerticalTarget = true } return cursor.verticalTargetX } func (cursor *FlatCursor) moveVertically(step int, targetX int64) bool { destinationLine := cursor.location.line + step if destinationLine < 0 || destinationLine >= len(cursor.lines) { return false } zones := append([]flatZone(nil), cursor.lines[destinationLine].zones...) sort.SliceStable(zones, func(i, j int) bool { return zones[i].bounds.X < zones[j].bounds.X }) for _, zone := range zones { for character := range zone.runes { if characterRightX(zone, character) >= targetX { cursor.location = flatLocation{line: destinationLine, zone: zoneIndexInLine(cursor.lines[destinationLine], zone), character: character} return true } } } zone := zones[len(zones)-1] cursor.location = flatLocation{line: destinationLine, zone: zoneIndexInLine(cursor.lines[destinationLine], zone), character: len(zone.runes) - 1} return true } func zoneIndexInLine(line flatLine, zone flatZone) int { for index, candidate := range line.zones { if candidate.object == zone.object && candidate.start == zone.start { return index } } return 0 } func characterCenterX(zone flatZone, character int) int64 { if len(zone.runes) == 0 { return int64(zone.bounds.X) } return int64(zone.bounds.X) + int64(zone.bounds.Width)*int64(2*character+1)/(int64(2*len(zone.runes))) } func characterRightX(zone flatZone, character int) int64 { if len(zone.runes) == 0 { return int64(zone.bounds.X) } return int64(zone.bounds.X) + int64(zone.bounds.Width)*int64(character+1)/int64(len(zone.runes)) } func (cursor *FlatCursor) zone() flatZone { return cursor.lines[cursor.location.line].zones[cursor.location.zone] } func (cursor *FlatCursor) advanceZone(step int) bool { next := cursor.location next.zone += step if next.zone < 0 { next.line-- if next.line < 0 { return false } next.zone = len(cursor.lines[next.line].zones) - 1 } else if next.zone >= len(cursor.lines[next.line].zones) { next.line++ if next.line >= len(cursor.lines) { return false } next.zone = 0 } next.character = 0 cursor.location = next return true } func (cursor *FlatCursor) advanceCharacter(step int) bool { next := cursor.location.character + step if next >= 0 && next < len(cursor.zone().runes) { cursor.location.character = next return true } if !cursor.advanceZone(step) { return false } if step < 0 { cursor.location.character = len(cursor.zone().runes) - 1 } return true } func (cursor *FlatCursor) advanceWord(step int) { old := cursor.location zone := cursor.zone() index := wordIndex(zone, old.character) if next := index + step; next >= 0 && next < len(zone.words) { cursor.location.character = zone.words[next][0] return } for cursor.advanceZone(step) { zone = cursor.zone() if len(zone.words) == 0 { if isBlankLineZone(zone) { cursor.location.character = 0 return } continue } index = 0 if step < 0 { index = len(zone.words) - 1 } cursor.location.character = zone.words[index][0] return } cursor.location = old } func isBlankLineZone(zone flatZone) bool { if len(zone.runes) == 0 { return false } for _, r := range zone.runes { if r != '\n' && r != '\r' { return false } } return true } func wordIndex(zone flatZone, character int) int { for i, span := range zone.words { if character < span[1] { return i } } return len(zone.words) - 1 } func (cursor *FlatCursor) position(unit FlatUnit) FlatPosition { zone := cursor.zone() start, end := cursor.location.character, min(cursor.location.character+1, len(zone.runes)) text := "" switch unit { case Line: var parts []string for _, z := range cursor.lines[cursor.location.line].zones { parts = append(parts, z.text) } text = strings.Join(parts, " ") case Word: if index := wordIndex(zone, start); index >= 0 { start, end = zone.words[index][0], zone.words[index][1] } } if unit != Line { text = string(zone.runes[start:end]) } return FlatPosition{Text: text, Object: zone.object, Offset: zone.start + start, HasOffset: zone.hasOffset, Bounds: zone.bounds} }