package app import ( "fmt" "math" "strings" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/review" ) type brailleFrameEnqueuer interface{ EnqueueFrame(braille.Frame) } // Browse cells map to native object offsets under the page's review epoch. func brailleDocumentFrame(result review.Result, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame { snapshot := result.DocumentPosition.Snapshot line, assembled := result.DocumentBrailleLine, result.DocumentCaretLine if line == nil && assembled == nil || snapshot.Role == "password text" { frame := brailleFocusFrameWithOptions(snapshot, options, configured...) frame.Routes = nil textLength := utf8.RuneCountInString(snapshot.Name) if textLength == 0 { textLength = utf8.RuneCountInString(snapshot.Text) } return linkedHeadingBraille(frame, result.DocumentPosition.LinkedHeading, options, textLength) } frame := braille.Frame{Cursor: -1, WordWrap: options.WordWrap} appendContent := func(source atspi.Snapshot, raw, separator string, sourceStart int) { frame.Text += separator start := utf8.RuneCountInString(frame.Text) frame.Text += raw length := utf8.RuneCountInString(raw) if source.ID() == snapshot.ID() && result.DocumentPosition.Offset >= sourceStart && result.DocumentPosition.Offset <= sourceStart+length { frame.Cursor = start + result.DocumentPosition.Offset - sourceStart } if source.ID().Valid() && source.Role != "password text" && length > 0 { frame.Routes = append(frame.Routes, braille.TextRoute{Start: start, End: start + length, SourceStart: sourceStart, TextLength: length, BusName: source.BusName, Path: source.Path, SourceText: raw}) } if options.SelectionIndicator != 0 && source.SelectionEnd > source.SelectionStart { frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) for offset := max(source.SelectionStart, sourceStart); offset < min(source.SelectionEnd, sourceStart+length); offset++ { frame.Dots[start+offset-sourceStart] |= byte(options.SelectionIndicator) } } isLink := source.Role == "link" for _, ancestor := range source.BrailleContext { isLink = isLink || ancestor.Role == "link" } if options.LinkIndicator != 0 && isLink { frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) for offset := start; offset < start+length; offset++ { frame.Dots[offset] |= byte(options.LinkIndicator) } } if len(configured) != 0 { if len(source.TextAttributeRuns) != 0 { for _, run := range source.TextAttributeRuns { markTextAttributeRun(&frame, run, sourceStart, start, length, configured[0]) } } else { markTextAttributeRun(&frame, source.TextAttributeRun, sourceStart, start, length, configured[0]) } } } if assembled != nil { for index, content := range assembled.Contents { raw := content.RawText if index == len(assembled.Contents)-1 { raw = strings.TrimRight(raw, "\r\n") } if content.Snapshot.Role == "password text" { continue } appendContent(content.Snapshot, raw, content.Separator, content.Start) } } else { snapshot.SelectionStart, snapshot.SelectionEnd = line.SelectionStart, line.SelectionEnd snapshot.TextAttributeRuns = line.TextAttributeRuns appendContent(snapshot, strings.TrimRight(line.Text, "\r\n"), "", line.Start) } textLength := utf8.RuneCountInString(frame.Text) if frame.Cursor < 0 || frame.Cursor > textLength { frame.Cursor = -1 } else if frame.Cursor == textLength { frame.Text += " " for index := range frame.Routes { if frame.Routes[index].End == textLength { frame.Routes[index].End++ } } } if result.DocumentFocus.Valid() && result.DocumentPosition.Document.Valid() { doc := result.DocumentPosition.Document.ID() frame.Document = &braille.DocumentRoute{Epoch: result.DocumentEpoch, FocusBus: result.DocumentFocus.BusName, FocusPath: result.DocumentFocus.Path, DocumentBus: doc.BusName, DocumentPath: doc.Path} } else { frame.Routes = nil } ancestors := append([]atspi.Snapshot{snapshot}, result.DocumentCaretContext...) for _, ancestor := range ancestors { if ancestor.Role == "heading" { frame.Text = brailleJoin(frame.Text, fmt.Sprintf("h%d", ancestor.HeadingLevel)) break } } if options.LinkIndicator != 0 && assembled == nil { for _, ancestor := range ancestors { if ancestor.Role == "link" { frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) for index := 0; index < textLength; index++ { frame.Dots[index] |= byte(options.LinkIndicator) } break } } } if len(frame.Dots) != 0 { frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) } return linkedHeadingBraille(frame, result.DocumentPosition.LinkedHeading, options, textLength) } func linkedHeadingBraille(frame braille.Frame, link *atspi.Snapshot, options config.BrailleOptions, textLength int) braille.Frame { if link == nil { return frame } contentLength := utf8.RuneCountInString(frame.Text) textLength = min(contentLength, max(0, textLength)) if options.LinkIndicator != 0 { if len(frame.Dots) < contentLength { frame.Dots = append(frame.Dots, make([]byte, contentLength-len(frame.Dots))...) } for index := 0; index < textLength; index++ { frame.Dots[index] |= byte(options.LinkIndicator) } } role := "lnk" if link.Visited { role = "vlnk" } frame.Text = brailleJoin(frame.Text, role) if len(frame.Dots) != 0 { frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-contentLength)...) } return frame } // Formats the captured object and bounded context using Python formatting.py's // braille rules and object_properties.py's indicators. func brailleFocusFrame(s atspi.Snapshot) braille.Frame { return brailleFocusFrameWithOptions(s, config.DefaultSettings().Braille) } func brailleFocusFrameWithOptions(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame { return braille_context_frame(braille_object_frame(s, options, configured...), s, options) } func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame { if frame, handled := libreOfficeBrailleFrame(s, options); handled { return frame } name, role := strings.TrimSpace(s.Name), s.Role if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown { label := name if role == "terminal" || role == "heading" || role == "static" { label = "" } line := strings.TrimRight(s.BrailleLine, "\r\n") if label == line { label = "" } prefix := "" if label != "" { prefix = label + " " } text := prefix + line end := utf8.RuneCountInString(text) cursor := -1 relative := s.BrailleCaret - s.BrailleLineStart if s.TextCaretKnown && relative >= 0 && relative <= utf8.RuneCountInString(s.BrailleLine) { cursor = utf8.RuneCountInString(prefix) + min(relative, utf8.RuneCountInString(line)) } if role != "terminal" && role != "heading" && !options.DisableEOL { text += " $l" } else if cursor == end { text += " " } frame := braille.Frame{Text: text, Cursor: cursor, WordWrap: options.WordWrap} if options.SelectionIndicator != 0 && s.SelectionEnd > s.SelectionStart { frame.Dots = make([]byte, utf8.RuneCountInString(frame.Text)) start := utf8.RuneCountInString(prefix) for source := max(s.SelectionStart, s.BrailleLineStart); source < min(s.SelectionEnd, s.BrailleLineStart+utf8.RuneCountInString(line)); source++ { frame.Dots[start+source-s.BrailleLineStart] = byte(options.SelectionIndicator) } } if options.LinkIndicator != 0 { for _, ancestor := range s.BrailleContext { if ancestor.Role != "link" { continue } if len(frame.Dots) == 0 { frame.Dots = make([]byte, utf8.RuneCountInString(frame.Text)) } start := utf8.RuneCountInString(prefix) for index := start; index < start+utf8.RuneCountInString(line); index++ { frame.Dots[index] |= byte(options.LinkIndicator) } break } } if len(configured) != 0 { if len(s.TextAttributeRuns) != 0 { for _, run := range s.TextAttributeRuns { markTextAttributeRun(&frame, run, s.BrailleLineStart, utf8.RuneCountInString(prefix), utf8.RuneCountInString(line), configured[0]) } } else { markTextAttributeRun(&frame, s.TextAttributeRun, s.BrailleLineStart, utf8.RuneCountInString(prefix), utf8.RuneCountInString(line), configured[0]) } } // Only actual text and its EOL marker route; labels and state suffixes do not. if s.TextCaretKnown && s.ID().Valid() { frame.Routes = []braille.TextRoute{{Start: utf8.RuneCountInString(prefix), End: utf8.RuneCountInString(text), SourceStart: s.BrailleLineStart, TextLength: utf8.RuneCountInString(line), BusName: s.BusName, Path: s.Path, SourceText: s.BrailleLine}} } if role == "heading" { frame.Text = brailleJoin(frame.Text, fmt.Sprintf("h%d", s.HeadingLevel)) } else if role != "terminal" { if s.Invalid && role != "spin button" && role != "document frame" && role != "document web" { frame.Text += " invalid" } if s.Required { frame.Text += " required" } if s.ReadOnly { frame.Text += " rdonly" } } if len(frame.Dots) != 0 && len(frame.Dots) < utf8.RuneCountInString(frame.Text) { frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) } return frame } // Protected text is never copied, even if a provider supplies it accidentally. if role == "password text" { return braille.Frame{Text: brailleJoin(name, role), Cursor: 0} } roleName := role switch role { case "invalid", "unknown", "redundant object", "filler", "extended", "link": roleName = "" case "heading": roleName = fmt.Sprintf("h%d", s.HeadingLevel) } if options.Verbosity == 0 && role != "heading" { roleName = "" } if options.AbbreviateRoles && roleName != "" && role != "heading" { roleName = shortBrailleRole(roleName) } expanded := "" if s.Expandable { expanded = "collapsed" if s.Expanded { expanded = "expanded" } } available := "" if s.SensitiveKnown && !s.Sensitive { available = "grayed" } indicator := "" if s.Checked { indicator = "" } else if s.Indeterminate { indicator = "<->" } else { indicator = "< >" } value := brailleValue(s) text := "" cursor := 0 switch role { case "check box": text = brailleJoin(indicator, name, roleName) case "check menu item": text = brailleJoin(indicator, name, roleName, available) case "radio button", "radio menu item", "toggle button": indicator = "& y" if s.Checked { indicator = "&=y" } if name == "" { name = s.Description } if role == "toggle button" { text = brailleJoin(indicator, name, expanded, roleName) } else { text = brailleJoin(indicator, name, roleName) if role == "radio menu item" { text = brailleJoin(text, available) } } case "push button", "button": if name == "" { name = s.Description } text = brailleJoin(name, expanded, roleName) case "menu item": text = brailleJoin(name, expanded, available) case "tree item": text = brailleJoin(name, expanded) case "list item": text = brailleJoin(name, expanded, roleName, available) case "menu", "page tab": text = brailleJoin(name, roleName, available) case "combo box": if value == "" { value = s.Description } text = brailleJoin(name, value, roleName) if name != "" && value != "" { cursor = utf8.RuneCountInString(name) + 1 } case "dial", "scroll bar", "slider", "spin button": text = brailleJoin(name, value, roleName) if s.Required { text = brailleJoin(text, "required") } if role == "slider" && s.Invalid { text = brailleJoin(text, "invalid") } case "progress bar": text = brailleJoin(name, value, roleName) default: displayed := s.Text if displayed == name { displayed = "" } text = brailleJoin(name, displayed, value, roleName) if s.Required { text = brailleJoin(text, "required") } if s.Invalid { text = brailleJoin(text, "invalid") } } if text == "" { cursor = -1 } frame := braille.Frame{Text: text, Cursor: cursor, WordWrap: options.WordWrap} if role == "link" && options.LinkIndicator != 0 { frame.Dots = make([]byte, utf8.RuneCountInString(text)) for index := range frame.Dots { frame.Dots[index] |= byte(options.LinkIndicator) } } return frame } func shortBrailleRole(role string) string { if short, ok := map[string]string{ "check box": "chk", "check menu item": "chk", "radio button": "radio", "push button": "btn", "button": "btn", "combo box": "cbo", "list item": "lstitm", "page tab": "pgt", "text": "txt", "link": "lnk", "slider": "sldr", "spin button": "spin", "menu": "mnu", "menu item": "mnuitm", "progress bar": "pgbar", "toggle button": "tglbtn", "password text": "pwd", "label": "lbl", "application": "app", "frame": "frm", "dialog": "dlg", "panel": "pnl", "list": "lst", "table": "tbl", "table cell": "cll", "column header": "colhdr", "row header": "rwhdr", }[role]; ok { return short } return role } func brailleTextRole(role string) bool { switch role { case "entry", "text", "static", "paragraph", "section", "label", "page", "document frame", "document web", "heading", "spin button": return true } return false } func brailleJoin(parts ...string) string { out := make([]string, 0, len(parts)) for _, part := range parts { if part != "" { out = append(out, part) } } return strings.Join(out, " ") } func brailleValue(s atspi.Snapshot) string { if s.Role == "progress bar" { if !s.ValueKnown || s.Indeterminate && s.CurrentValue <= 0 { return "" } if s.MaximumValue == s.MinimumValue { if s.CurrentValue == s.MaximumValue && s.CurrentValue >= 1 && s.CurrentValue <= 100 { return fmt.Sprintf("%.0f%%", math.Trunc(s.CurrentValue)) } return "" } value := (s.CurrentValue - s.MinimumValue) / (s.MaximumValue - s.MinimumValue) * 100 if math.IsNaN(value) || math.IsInf(value, 0) { return "" } return fmt.Sprintf("%.0f%%", math.Trunc(value)) } if s.ValueText != "" { return s.ValueText } if !s.ValueKnown || math.IsNaN(s.CurrentValue) || math.IsInf(s.CurrentValue, 0) { return "" } increment := s.MinimumIncrement if increment < 0 || math.IsNaN(increment) || math.IsInf(increment, 0) { return "" } places := 0 if increment > 0 { places = int(math.Ceil(math.Max(0, -math.Log10(increment)))) } else if math.Abs(s.CurrentValue) < 1 { places = 1 } return fmt.Sprintf("%.*f", places, s.CurrentValue) }