392 lines
11 KiB
Go
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}
|
|
}
|