// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package review import ( "strings" "unicode" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" ) func wholeCaretNode(snapshot atspi.Snapshot) bool { if wholeCaretControl(snapshot) { return true } // An explicitly named custom clickable is an atomic stop just like a // named link. Ordinary clickable text remains readable by character/word. return snapshot.SupportsClick && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" && !snapshot.Editable && !atspi.IsWebDocumentRole(snapshot.Role) } func wholeCaretControl(snapshot atspi.Snapshot) bool { if snapshot.Role == "link" { return snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" } if snapshot.Role == "image" { return snapshot.Name != "" || snapshot.Description != "" } switch snapshot.Role { case "button", "check box", "check menu item", "list box", "menu item", "page tab", "radio menu item", "radio button", "push button", "toggle button": return true } return false } // Passive tooltips appear when their owner receives focus and disappear when // caret placement moves focus away. Entering their text can strand navigation // on a removed object and repeatedly recover to the owner's container. Match // Orca's web caret policy while retaining focusable/editable tooltip content. func passive_caret_tooltip(snapshot atspi.Snapshot) bool { return snapshot.Role == "tool tip" && !snapshot.Focusable && !snapshot.Editable } // caret_node_indices preserves provider order and treats whole controls as one // stop. MoveCaret holds the document cursor lock while using these indices. func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference) ([]int, int, map[atspi.ObjectID]bool) { // Container text normally embeds its child text. Whole controls are one // caret stop even when their label is a child or they have no text interface. // Python's web _treatObjectAsWhole makes the same distinction. indices := make([]int, 0, len(nodes)) current := -1 skipped := make(map[atspi.ObjectID]bool) protected := make(map[atspi.ObjectID]bool, len(nodes)) // Named custom clickables can contain independent controls, including // disabled entries. Keep the container's label as a stop without pruning // those controls. Ordinary labels and semantic controls remain atomic. controls := make(map[atspi.ObjectID]bool) for index := len(nodes) - 1; index >= 0; index-- { node := nodes[index] snapshot := node.Snapshot field := documentFormFieldRole(snapshot) && (snapshot.Role != "text" || snapshot.Editable || snapshot.Focusable || snapshot.ReadOnly) if !node.Hidden && (controls[node.Reference.ID()] || field || snapshot.Editable || snapshot.Role == "link") { controls[node.Parent] = true } } expanded := make(map[atspi.ObjectID]bool) for _, node := range nodes { if controls[node.Reference.ID()] && wholeCaretNode(node.Snapshot) && !wholeCaretControl(node.Snapshot) { expanded[node.Reference.ID()] = true } } var skipChildren func([]atspi.ObjectID) children := make(map[atspi.ObjectID][]atspi.ObjectID, len(nodes)) for _, node := range nodes { children[node.Reference.ID()] = node.Children } skipChildren = func(ids []atspi.ObjectID) { for _, id := range ids { if skipped[id] { continue } skipped[id] = true skipChildren(children[id]) } } for index, node := range nodes { snapshot := node.Snapshot // Icon-font text inside links, controls, editable text or code can // carry meaning. Preserve that entire subtree, including static leaves. protected[node.Reference.ID()] = protected[node.Parent] || snapshot.Editable || !atspi.IsWebDocumentRole(snapshot.Role) && (snapshot.Focusable || snapshot.SupportsClick || snapshot.ExplicitName) || snapshot.Role == "link" || wholeCaretNode(snapshot) || snapshot.HTMLTag == "pre" || snapshot.HTMLTag == "code" || strings.Contains(" "+snapshot.XMLRoles+" ", " code ") if node.Reference == object { current = index } if node.Hidden || skipped[node.Reference.ID()] { continue } if snapshot.Role == "password text" || passive_caret_tooltip(snapshot) { skipChildren(node.Children) continue } if !protected[node.Reference.ID()] && decorative_caret_glyph(node) { continue } if wholeCaretNode(node.Snapshot) { indices = append(indices, index) if !expanded[node.Reference.ID()] { skipChildren(node.Children) } } else if node.Snapshot.Editable && (node.Snapshot.Role == "entry" || node.Snapshot.Role == "text") && (node.Snapshot.CharacterCount == 0 || len(node.Children) == 0) { indices = append(indices, index) } else if len(node.Children) == 0 && node.Snapshot.CharacterCount > 0 { // Rich editors expose their contents as editable text leaves. // Browse reading must descend into them just like ordinary text; // the entry's embedded-object placeholders are not its contents. indices = append(indices, index) } } return indices, current, expanded } // Python's web hasValidName/isUselessEmptyElement rejects standalone unnamed // private-use icons. Keep this narrower: only a known non-interactive static // leaf with one matching name/text character is omitted from browse reading. func decorative_caret_glyph(node atspi.DocumentNode) bool { snapshot := node.Snapshot if snapshot.Role != "static" || !snapshot.StatesKnown || snapshot.Focusable || snapshot.Editable || node.ParentEditable || snapshot.SupportsClick || snapshot.ExplicitName || snapshot.Description != "" || len(node.Children) != 0 || snapshot.CharacterCount != 1 || (snapshot.Text != "" && snapshot.Text != snapshot.Name) { return false } character, size := utf8.DecodeRuneInString(snapshot.Name) return size == len(snapshot.Name) && unicode.Is(unicode.Co, character) }