Fixed character/word navigation inside links.

This commit is contained in:
Storm Dragon committed 2026-10-18 23:43:16 -04:00
1 parent 396efbd7c5
commit ab14244404
12 files changed
+213 -12

No files matched your search

+4
View File
@@ -21,6 +21,10 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
if line == nil && assembled == nil || snapshot.Role == "password text" {
frame := brailleFocusFrameWithOptions(snapshot, options, configured...)
frame.Routes = nil
if snapshot.Role == "link" && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" && !snapshot.Editable {
// Accessible-label review has no native text route.
frame.Cursor = min(result.DocumentPosition.Offset, utf8.RuneCountInString(strings.TrimSpace(snapshot.Name)))
}
textLength := utf8.RuneCountInString(snapshot.Name)
if textLength == 0 {
textLength = utf8.RuneCountInString(snapshot.Text)
@@ -315,6 +315,15 @@ func TestBrowseBrailleUsesVirtualOffsetAndAncestorIndicators(t *testing.T) {
}
}
func TestBrowseBrailleNamedLinkUsesLabelCursorWithoutNativeRoutes(t *testing.T) {
result := review.Result{DocumentPosition: review.DocumentPosition{Offset: 4,
Snapshot: atspi.Snapshot{Role: "link", ExplicitName: true, Name: "É猫 Info", CharacterCount: 1}}}
frame := brailleDocumentFrame(result, config.DefaultSettings().Braille)
if !strings.HasPrefix(frame.Text, "É猫 Info") || frame.Cursor != 4 || len(frame.Routes) != 0 {
t.Fatalf("named link lost label cursor or exposed synthetic native routes: %+v", frame)
}
}
func TestBrowseBrailleFragmentLineKeepsNativeCursor(t *testing.T) {
first := atspi.NewSnapshot("/first", "", "static", "", "")
first.BusName = ":1.999"
+29
View File
@@ -60,6 +60,12 @@ func TestNativeBrowserNamedLinks(t *testing.T) {
text string
}{
{review.CaretNextLine, "Example Game 2 copies"},
{review.CaretNextCharacter, "x"},
{review.CaretPreviousCharacter, "E"},
{review.CaretNextWord, "Example"},
{review.CaretNextWord, "Game"},
{review.CaretLineEnd, "s"},
{review.CaretLineStart, "E"},
{review.CaretNextLine, "Ordinary multiline link"},
{review.CaretNextCharacter, "r"},
{review.CaretNextWord, "Ordinary"},
@@ -101,7 +107,12 @@ func TestNativeBrowserNamedLinks(t *testing.T) {
t.Fatal(err)
}
var username, emoji atspi.ObjectReference
var labelOnly atspi.ObjectReference
for _, node := range document.Nodes {
if node.Snapshot.Role == "link" && node.Snapshot.Name == "Example Info Page" {
labelOnly = node.Reference
t.Logf("label-only native link: explicit=%t count=%d children=%d", node.Snapshot.ExplicitName, node.Snapshot.CharacterCount, len(node.Children))
}
if node.Snapshot.Role != "static" {
continue
}
@@ -112,6 +123,24 @@ func TestNativeBrowserNamedLinks(t *testing.T) {
emoji = node.Reference
}
}
if !labelOnly.Valid() {
t.Fatal("label-only link missing")
}
if _, err := cursor.SyncFocus(ctx, labelOnly.ID()); err != nil {
t.Fatal(err)
}
for _, want := range []rune("xample Info Page.") {
move, err := cursor.MoveCaret(ctx, adapter, review.CaretNextCharacter)
if err != nil || !move.Moved || move.Text != string(want) {
t.Fatalf("label-only character: %+v, %v; want %q", move, err, want)
}
}
for _, want := range []rune("egaP ofnI elpmaxE") {
move, err := cursor.MoveCaret(ctx, adapter, review.CaretPreviousCharacter)
if err != nil || !move.Moved || move.Text != string(want) {
t.Fatalf("reverse label-only character: %+v, %v; want %q", move, err, want)
}
}
if !username.Valid() || !emoji.Valid() {
t.Fatal("separate display-name and emoji text objects missing")
}
+4 -2
View File
@@ -65,11 +65,13 @@ type DocumentCaretMove struct {
TableRowHeader, TableColumnHeader string
Moved bool
// Native text range used for this move, in Unicode character offsets.
// Text range used for this move, in Unicode character offsets. Named
// links use accessible-label offsets rather than native text offsets.
Start, End int
}
// MoveCaret advances the independent browse position using AT-SPI text units.
// MoveCaret advances the independent browse position using AT-SPI text units
// or accessible-label units for explicitly named links.
// It does not depend on Chromium's retained native caret. A stale captured
// document is reported to the worker for one bounded refresh.
func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCaretReader, command DocumentCaretCommand) (DocumentCaretMove, error) {
+12
View File
@@ -50,6 +50,18 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
}
node := doc.Nodes[indices[position]]
if named_link_label(node.Snapshot) != "" && (command == CaretLineStart || command == CaretLineEnd) {
offset = 0
if command == CaretLineEnd {
offset = caret_text_count(node.Snapshot) - 1
}
character, err := read_caret_text(ctx, reader, node, offset, atspi.DocumentCaretCharacter)
if err != nil {
return DocumentCaretMove{}, err
}
place.object, place.offset, place.origin = node.Reference, offset, CaretNavigation
return DocumentCaretMove{Position: documentPosition(doc, *place), Text: character.Text, Moved: true, Start: character.Start, End: character.End}, nil
}
if node.Snapshot.CharacterCount == 0 || wholeCaretNode(node.Snapshot) {
if (command == CaretNextLine || command == CaretPreviousLine) && node.Reference == place.object {
if backwards {
+72
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"testing"
"time"
"unicode"
"unicode/utf8"
@@ -258,6 +259,10 @@ func TestDocumentCaretExplicitLinkPreservesNameInsteadOfBadge(t *testing.T) {
path, text string
}{
{CaretNextLine, "/card", ""},
{CaretNextCharacter, "/card", "x"},
{CaretPreviousCharacter, "/card", "E"},
{CaretNextWord, "/card", "Example "},
{CaretNextWord, "/card", "Game "},
{CaretNextLine, "/ordinary_text", "Ordinary link"},
{CaretNextCharacter, "/ordinary_text", "r"},
{CaretPreviousLine, "/card", ""},
@@ -273,6 +278,73 @@ func TestDocumentCaretExplicitLinkPreservesNameInsteadOfBadge(t *testing.T) {
}
}
func TestDocumentCaretNamedLinkCharacterBoundaries(t *testing.T) {
for _, nativeText := range []string{"", "2", "\ufffc"} {
t.Run(fmt.Sprintf("native=%q", nativeText), func(t *testing.T) {
reader := caretTextReader{webID("/text"): nativeText, webID("/next"): "."}
cursor := newCaretTextCursor(t, reader)
ctx := context.Background()
ref, err := cursor.tree.Reference(webID("/text"))
if err != nil {
t.Fatal(err)
}
snapshot, err := cursor.tree.Resolve(ref)
if err != nil {
t.Fatal(err)
}
snapshot.Role, snapshot.Name, snapshot.ExplicitName = "link", "É猫 Info", true
if err := cursor.tree.Replace(webID("/doc"), []atspi.TreeNode{
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/text"), webID("/next")}},
{Snapshot: snapshot},
{Snapshot: atspi.Snapshot{BusName: webID("/next").BusName, Path: "/next", Role: "static", CharacterCount: 1}},
}); err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(ctx, webID("/text")); err != nil {
t.Fatal(err)
}
for _, step := range []struct {
command DocumentCaretCommand
text string
offset int
}{
{CaretNextCharacter, "猫", 1},
{CaretPreviousCharacter, "É", 0},
{CaretNextCharacter, "猫", 1},
{CaretNextCharacter, " ", 2},
{CaretNextCharacter, "I", 3},
{CaretNextCharacter, "n", 4},
{CaretNextCharacter, "f", 5},
{CaretNextCharacter, "o", 6},
{CaretNextCharacter, ".", 0},
{CaretPreviousCharacter, "o", 6},
{CaretPreviousWord, "Info", 3},
{CaretPreviousWord, "É猫 ", 0},
{CaretLineEnd, "o", 6},
{CaretLineStart, "É", 0},
} {
move, err := cursor.MoveCaret(ctx, reader, step.command)
if err != nil || !move.Moved || move.Text != step.text || move.Position.Offset != step.offset {
t.Fatalf("%v: %+v, %v; want %q at %d", step.command, move, err, step.text, step.offset)
}
}
navigator := &boundaryNavigator{fakeCapturer: &fakeCapturer{}, caretTextReader: reader}
worker, err := NewWorker(navigator, time.Second)
if err != nil {
t.Fatal(err)
}
worker.SyncFocus(webID("/text"))
for index, want := range []string{"猫", " "} {
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextCharacter, 0)
if result.Err != nil || result.CaretErr != nil || result.DocumentCaretText != want ||
result.DocumentPosition.Offset != index+1 || navigator.offset != 0 || navigator.position != result.DocumentPosition.Object {
t.Fatalf("label offset changed native anchor or lost review progress: %+v; native offset=%d", result, navigator.offset)
}
}
})
}
}
func TestDocumentCaretKeepsDocumentFocusAfterTopLeafIsRemoved(t *testing.T) {
reader := caretTextReader{webID("/first"): "First", webID("/next"): "Next"}
document := atspi.NewSnapshot("/doc", "", "document web", "", "")
+9 -8
View File
@@ -32,7 +32,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
} else if backwards {
position--
if position >= 0 {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = caret_text_count(doc.Nodes[indices[position]].Snapshot) - 1
}
} else if place.origin == StructuralNavigation {
// Structural jumps speak the first descendant at offset zero.
@@ -43,7 +43,8 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
return DocumentCaretMove{}, err
}
node := doc.Nodes[indices[position]]
if node.Snapshot.CharacterCount == 0 || wholeCaretNode(node.Snapshot) {
count := caret_text_count(node.Snapshot)
if count == 0 || wholeCaretNode(node.Snapshot) && named_link_label(node.Snapshot) == "" {
if node.Reference != place.object {
place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation
return DocumentCaretMove{Position: documentPosition(doc, *place), Moved: true}, nil
@@ -51,12 +52,12 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
position += step
offset = 0
if backwards && position >= 0 {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = caret_text_count(doc.Nodes[indices[position]].Snapshot) - 1
}
continue
}
if offset >= 0 && offset < node.Snapshot.CharacterCount {
character, err := reader.ReadDocumentCaret(ctx, node.Reference, offset, atspi.DocumentCaretCharacter)
if offset >= 0 && offset < count {
character, err := read_caret_text(ctx, reader, node, offset, atspi.DocumentCaretCharacter)
if err != nil && !errors.Is(err, atspi.ErrNoDocumentCaret) {
return DocumentCaretMove{}, err
}
@@ -70,7 +71,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
place.object, place.offset, place.origin = node.Reference, character.Start, CaretNavigation
return DocumentCaretMove{Position: documentPosition(doc, *place), Text: character.Text, Misspelled: character.Misspelled, Moved: true, Start: character.Start, End: character.End}, nil
}
word, err := reader.ReadDocumentCaret(ctx, node.Reference, offset, atspi.DocumentCaretWord)
word, err := read_caret_text(ctx, reader, node, offset, atspi.DocumentCaretWord)
if err != nil {
return DocumentCaretMove{}, err
}
@@ -89,7 +90,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
nextOffset = word.End
// WORD ranges may contain a trailing delimiter. Query its
// actual character; spoken word text can be normalized.
last, err := reader.ReadDocumentCaret(ctx, node.Reference, word.End-1, atspi.DocumentCaretCharacter)
last, err := read_caret_text(ctx, reader, node, word.End-1, atspi.DocumentCaretCharacter)
if err != nil {
return DocumentCaretMove{}, err
}
@@ -105,7 +106,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
position += step
offset = 0
if backwards && position >= 0 {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = caret_text_count(doc.Nodes[indices[position]].Snapshot) - 1
}
}
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
+60
View File
@@ -0,0 +1,60 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"strings"
"unicode"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func named_link_label(snapshot atspi.Snapshot) string {
if snapshot.Role == "link" && snapshot.ExplicitName && !snapshot.Editable {
return strings.TrimSpace(snapshot.Name)
}
return ""
}
func caret_text_count(snapshot atspi.Snapshot) int {
if label := named_link_label(snapshot); label != "" {
return utf8.RuneCountInString(label)
}
return snapshot.CharacterCount
}
// Named links can expose no Text interface, or only a badge/embedded object.
// Review the label used by line speech, retaining the link for activation.
// These offsets belong to the label and must never become native text offsets.
func read_caret_text(ctx context.Context, reader DocumentCaretReader, node atspi.DocumentNode, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
if err := ctx.Err(); err != nil {
return atspi.DocumentCaretText{}, err
}
label := named_link_label(node.Snapshot)
if label == "" {
return reader.ReadDocumentCaret(ctx, node.Reference, offset, unit)
}
characters := []rune(label)
result := atspi.DocumentCaretText{CharacterCount: len(characters)}
if offset < 0 || offset >= len(characters) {
return result, nil
}
start, end := offset, offset+1
if unit == atspi.DocumentCaretWord {
for start > 0 && !unicode.IsSpace(characters[start-1]) {
start--
}
for end < len(characters) && !unicode.IsSpace(characters[end]) {
end++
}
for end < len(characters) && unicode.IsSpace(characters[end]) {
end++
}
}
result.Start, result.End, result.Text = start, end, string(characters[start:end])
return result, nil
}
+7 -1
View File
@@ -332,7 +332,13 @@ func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Res
}
deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout)
defer cancel()
result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, result.DocumentPosition.Offset)
nativeOffset := result.DocumentPosition.Offset
if named_link_label(result.DocumentPosition.Snapshot) != "" {
// The label's review offset has no relationship to the native badge
// or embedded-object text. Place only the link's native anchor.
nativeOffset = 0
}
result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, nativeOffset)
return result
}