Fixed character/word navigation inside links.
This commit is contained in:
1 parent
396efbd7c5
commit
ab14244404
12 files changed
+213
-12
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", "", "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user