Files

392 lines
11 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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}
}