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,284 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package review
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
// A visual line is temporary move-owned data. The retained cursor always uses
|
||||
// an actual object's native offset, never an offset in the joined string.
|
||||
type visualLinePart struct {
|
||||
index int
|
||||
text atspi.DocumentCaretText
|
||||
}
|
||||
|
||||
type visualLineReader struct {
|
||||
DocumentCaretReader
|
||||
cache map[visualLineKey]atspi.DocumentCaretText
|
||||
}
|
||||
|
||||
type visualLineKey struct {
|
||||
object atspi.ObjectReference
|
||||
offset int
|
||||
unit atspi.DocumentCaretUnit
|
||||
}
|
||||
|
||||
func (reader *visualLineReader) ReadDocumentCaret(ctx context.Context, object atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return atspi.DocumentCaretText{}, err
|
||||
}
|
||||
key := visualLineKey{object, offset, unit}
|
||||
if text, ok := reader.cache[key]; ok {
|
||||
return text, nil
|
||||
}
|
||||
text, err := reader.DocumentCaretReader.ReadDocumentCaret(ctx, object, offset, unit)
|
||||
if err == nil {
|
||||
reader.cache[key] = text
|
||||
}
|
||||
return text, err
|
||||
}
|
||||
|
||||
func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, lookup *caretRangeLookup, current int, command DocumentCaretCommand, owners map[int]atspi.ObjectID) (DocumentCaretMove, error) {
|
||||
reader := &visualLineReader{DocumentCaretReader: source, cache: make(map[visualLineKey]atspi.DocumentCaretText)}
|
||||
next := *place
|
||||
// Move beyond the entire current line, even when character/word movement
|
||||
// left the cursor in a middle fragment or structural navigation chose its
|
||||
// heading container. The object-local mover still handles empty controls.
|
||||
if command == CaretNextLine || command == CaretPreviousLine {
|
||||
position := lookup.containing(indices, current, place.offset)
|
||||
if place.origin == StructuralNavigation && lookup.after[current] < len(indices) && (position < 0 || lookup.after[current] < position) {
|
||||
position = lookup.after[current]
|
||||
}
|
||||
if position >= 0 {
|
||||
parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, place.offset, true)
|
||||
if err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
part := parts[0]
|
||||
if command == CaretNextLine {
|
||||
part = parts[len(parts)-1]
|
||||
}
|
||||
next.object, next.offset, next.origin = doc.Nodes[part.index].Reference, part.text.Start, CaretNavigation
|
||||
current = part.index
|
||||
}
|
||||
}
|
||||
}
|
||||
movement, err := move_caret_object_line(ctx, reader, doc, &next, indices, lookup, current, command)
|
||||
if err != nil || !movement.Moved {
|
||||
if err == nil {
|
||||
movement.Position = documentPosition(doc, *place)
|
||||
}
|
||||
return movement, err
|
||||
}
|
||||
nextIndex, _ := doc.Index(next.object.ID())
|
||||
position := lookup.containing(indices, nextIndex, next.offset)
|
||||
if position >= 0 {
|
||||
parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, next.offset, command != CaretReadLine)
|
||||
if err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
part := parts[0]
|
||||
if command == CaretLineEnd || command == CaretDocumentEnd {
|
||||
part = parts[len(parts)-1]
|
||||
}
|
||||
next.object, next.offset = doc.Nodes[part.index].Reference, part.text.Start
|
||||
if command == CaretLineEnd || command == CaretDocumentEnd {
|
||||
next.offset = part.text.End - 1
|
||||
}
|
||||
movement.Start, movement.End = part.text.Start, part.text.End
|
||||
line, raw, misspelled := assemble_visual_line(tree, doc, parts)
|
||||
movement.Line, movement.RawText, movement.Misspelled = &line, raw, movement.Misspelled || misspelled
|
||||
movement.Text = strings.Join(strings.Fields(movement.RawText), " ")
|
||||
if command == CaretLineStart || command == CaretLineEnd {
|
||||
character, err := reader.ReadDocumentCaret(ctx, next.object, next.offset, atspi.DocumentCaretCharacter)
|
||||
if err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
movement.Text = character.Text
|
||||
}
|
||||
movement.Position = documentPosition(doc, next)
|
||||
}
|
||||
}
|
||||
*place = next
|
||||
return movement, nil
|
||||
}
|
||||
|
||||
// Retain semantic boundaries even if unrelated blocks have matching y values.
|
||||
// Inline spans/emphasis do not introduce boundaries. Links remain independent
|
||||
// stops with their existing activation, voice, state and sound presentation.
|
||||
func visual_line_owners(nodes []atspi.DocumentNode) map[int]atspi.ObjectID {
|
||||
owners := make(map[int]atspi.ObjectID, len(nodes))
|
||||
byID := make(map[atspi.ObjectID]atspi.ObjectID, len(nodes))
|
||||
for index, node := range nodes {
|
||||
owner := byID[node.Parent]
|
||||
snapshot := node.Snapshot
|
||||
switch snapshot.Role {
|
||||
case "paragraph", "heading", "list item", "table cell", "row header", "column header", "link", "document web", "document frame", "embedded":
|
||||
owner = node.Reference.ID()
|
||||
case "section":
|
||||
if snapshot.HTMLTag != "span" {
|
||||
owner = node.Reference.ID()
|
||||
}
|
||||
}
|
||||
if snapshot.Editable || wholeCaretNode(snapshot) || snapshot.Role == "password text" ||
|
||||
snapshot.HTMLTag == "pre" || snapshot.HTMLTag == "code" || strings.Contains(" "+snapshot.XMLRoles+" ", " code ") {
|
||||
// Exclude the whole subtree, including static code descendants.
|
||||
owner = atspi.ObjectID{}
|
||||
byID[node.Reference.ID()] = owner
|
||||
owners[index] = owner
|
||||
continue
|
||||
}
|
||||
// An excluded ancestor must stay excluded through nested blocks.
|
||||
if parent, known := byID[node.Parent]; known && !parent.Valid() {
|
||||
owner = atspi.ObjectID{}
|
||||
}
|
||||
byID[node.Reference.ID()] = owner
|
||||
owners[index] = owner
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
func collect_visual_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, indices []caretRange, owners map[int]atspi.ObjectID, position, offset int, left bool) ([]visualLinePart, error) {
|
||||
index := indices[position].index
|
||||
node := doc.Nodes[index]
|
||||
if !owners[index].Valid() || node.Snapshot.Editable || wholeCaretNode(node.Snapshot) || node.Snapshot.CharacterCount <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
unit := atspi.DocumentCaretLine
|
||||
if !left {
|
||||
unit = atspi.DocumentCaretLineRemainder
|
||||
}
|
||||
text, err := readCaretRange(ctx, reader, doc, indices[position], min(offset, indices[position].end-1), unit)
|
||||
if errors.Is(err, atspi.ErrNoDocumentCaret) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !visual_line_text(text) {
|
||||
return nil, nil
|
||||
}
|
||||
if !left && text.RawText != "" {
|
||||
// Native remainder speech keeps the full raw line for indentation;
|
||||
// assembly needs only the characters after the starting caret.
|
||||
runes := []rune(text.RawText)
|
||||
if length := text.End - text.Start; length < len(runes) {
|
||||
text.RawText = string(runes[len(runes)-length:])
|
||||
}
|
||||
}
|
||||
parts := []visualLinePart{{index, text}}
|
||||
characters := utf8.RuneCountInString(text.Text)
|
||||
for _, direction := range []int{-1, 1} {
|
||||
if direction < 0 && !left {
|
||||
continue
|
||||
}
|
||||
for next := position + direction; next >= 0 && next < len(indices); next += direction {
|
||||
candidate := indices[next].index
|
||||
if owners[candidate] != owners[index] {
|
||||
break
|
||||
}
|
||||
probe := indices[next].start
|
||||
if direction < 0 {
|
||||
probe = indices[next].end - 1
|
||||
}
|
||||
piece, err := readCaretRange(ctx, reader, doc, indices[next], probe, atspi.DocumentCaretLine)
|
||||
if errors.Is(err, atspi.ErrNoDocumentCaret) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !visual_line_text(piece) || !visual_line_bounds(text.Bounds, piece.Bounds) {
|
||||
break
|
||||
}
|
||||
// A fragment containing a hard newline ends the line, even when
|
||||
// a provider reports identical/overlapping bounds for both rows.
|
||||
previous, following := parts[len(parts)-1].text, piece
|
||||
if direction < 0 {
|
||||
previous, following = piece, parts[0].text
|
||||
}
|
||||
if strings.ContainsAny(previous.RawText, "\r\n") || strings.ContainsAny(previous.Text, "\r\n") || strings.HasPrefix(following.RawText, "\n") || strings.HasPrefix(following.Text, "\n") {
|
||||
break
|
||||
}
|
||||
characters += utf8.RuneCountInString(piece.Text)
|
||||
if len(parts) >= 256 || characters > 65536 {
|
||||
return nil, errors.New("document visual line exceeds capture limit")
|
||||
}
|
||||
part := visualLinePart{candidate, piece}
|
||||
if direction < 0 {
|
||||
parts = append([]visualLinePart{part}, parts...)
|
||||
} else {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func visual_line_text(text atspi.DocumentCaretText) bool {
|
||||
return text.Bounds.Valid() && text.Start >= 0 && text.End > text.Start && text.End <= text.CharacterCount &&
|
||||
!strings.ContainsRune(text.Text, '\ufffc') && (text.Text != "" || text.RawText != "")
|
||||
}
|
||||
|
||||
func visual_line_bounds(first, second atspi.Rectangle) bool {
|
||||
if !first.Valid() || !second.Valid() {
|
||||
return false
|
||||
}
|
||||
top := max(int64(first.Y), int64(second.Y))
|
||||
bottom := min(int64(first.Y)+int64(first.Height), int64(second.Y)+int64(second.Height))
|
||||
return bottom > top && math.Abs(float64(first.Y)+float64(first.Height)/2-float64(second.Y)-float64(second.Height)/2) <= 5
|
||||
}
|
||||
|
||||
func visual_line_space(first, second atspi.DocumentCaretText) bool {
|
||||
left, right := first.RawText, second.RawText
|
||||
if left == "" {
|
||||
left = first.Text
|
||||
}
|
||||
if right == "" {
|
||||
right = second.Text
|
||||
}
|
||||
last, _ := utf8.DecodeLastRuneInString(left)
|
||||
start, _ := utf8.DecodeRuneInString(right)
|
||||
gap := max(int64(second.Bounds.X)-int64(first.Bounds.X)-int64(first.Bounds.Width), int64(first.Bounds.X)-int64(second.Bounds.X)-int64(second.Bounds.Width))
|
||||
return gap > 1 && !unicode.IsSpace(last) && !unicode.IsSpace(start)
|
||||
}
|
||||
|
||||
func assemble_visual_line(tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, parts []visualLinePart) (atspi.DocumentLine, string, bool) {
|
||||
misspelled := false
|
||||
line := atspi.DocumentLine{Document: doc.Context.TopLevel}
|
||||
var raw strings.Builder
|
||||
for i, part := range parts {
|
||||
value := part.text.RawText
|
||||
if value == "" {
|
||||
value = part.text.Text
|
||||
}
|
||||
// Preserve native spacing and punctuation across span boundaries.
|
||||
separator := ""
|
||||
if i > 0 && visual_line_space(parts[i-1].text, part.text) {
|
||||
separator = " "
|
||||
}
|
||||
raw.WriteString(separator)
|
||||
raw.WriteString(value)
|
||||
snapshot := doc.Nodes[part.index].Snapshot
|
||||
if context, err := tree.DocumentContext(doc.Nodes[part.index].Reference); err == nil {
|
||||
snapshot.BrailleContext = context.Ancestors
|
||||
}
|
||||
snapshot.Misspelled = part.text.Misspelled
|
||||
misspelled = misspelled || part.text.Misspelled
|
||||
line.Contents = append(line.Contents, atspi.DocumentContent{Reference: doc.Nodes[part.index].Reference, Snapshot: snapshot,
|
||||
Start: part.text.Start, End: part.text.End, Text: part.text.Text, RawText: value, Separator: separator, Bounds: part.text.Bounds})
|
||||
}
|
||||
return line, raw.String(), misspelled
|
||||
}
|
||||
Reference in new issue
Block a user