Reset master to testing content
This commit is contained in:
1 parent
0f2587b719
commit
42afa2a56c
1483 files changed
+145308
-938069
No files matched your search
@@ -0,0 +1,410 @@
|
||||
// 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
|
||||
completeness FlatCompleteness
|
||||
warnedIncomplete 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, completeness, err := build_flat_layout(ctx, nodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next := FlatCursor{lines: lines, focus: focus, tree: tree, references: snapshot.References, completeness: completeness,
|
||||
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) {
|
||||
if !next.seekDescendant(focus) {
|
||||
next.seek_ancestor(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
|
||||
next.warnedIncomplete = cursor.warnedIncomplete && cursor.focus == focus && cursor.tree == tree && cursor.completeness == completeness
|
||||
*cursor = next
|
||||
return nil
|
||||
}
|
||||
|
||||
// A focused leaf can have no displayed zone while its parent supplies the
|
||||
// text. Choose only an actual ancestor zone, never a sibling or window title.
|
||||
func (cursor *FlatCursor) seek_ancestor(object atspi.ObjectID) bool {
|
||||
for id := cursor.parents[object]; id.Valid(); id = cursor.parents[id] {
|
||||
if !cursor.parents[id].Valid() {
|
||||
return false
|
||||
}
|
||||
if cursor.seek(id, 0, false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
Reference in new issue
Block a user