// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package presentation import ( "fmt" "strings" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" ) func reading_options(options Options) config.ReadingSettings { if options.Reading != nil { return *options.Reading } return config.DefaultSettings().Reading } func join_speech_parts(parts []string, options Options) string { if !reading_options(options).EnablePauseBreaks { return strings.Join(parts, " ") } return strings.Join(parts, ", ") } func displayed_focus_text(snapshot atspi.Snapshot) string { var parts []string for _, text := range []string{snapshot.Name, snapshot.Text, controlValue(snapshot)} { if text != "" && !contains(parts, text) { parts = append(parts, text) } } return strings.Join(parts, " ") } func control_tutorial(snapshot atspi.Snapshot) string { switch snapshot.Role { case "push button", "button": return "Press Space to activate." case "check box", "toggle button": return "Press Space to toggle." case "radio button": return "Use arrow keys to select an option." case "combo box": return "Press Alt+Down to open the list." case "slider", "spin button": return "Use arrow keys to change the value." case "entry", "text": if snapshot.Editable && !snapshot.ReadOnly { return "Type to enter text." } case "list", "list box", "tree", "tree table": return "Use arrow keys to move between items." } return "" } func FullRowEnabled(snapshot atspi.Snapshot, options Options) bool { reading := reading_options(options) if snapshot.Spreadsheet { return reading.ReadFullRowInSpreadSheet } if snapshot.GUITable { return options.ReadFullRowInGUITable } return reading.ReadFullRowInDocumentTable } // ContextType matches Python's separate container and landmark preferences. func ContextType(snapshot atspi.Snapshot) string { switch snapshot.Role { case "block quote", "blockquote": return "blockquote" case "list", "list box": return "list" case "table", "tree table": if !snapshot.LayoutTable { return "table" } case "panel", "grouping": return "panel" case "landmark": return "landmark" case "form": if snapshot.Name == "" { return "form" } return "landmark" } for _, role := range strings.Fields(snapshot.XMLRoles) { switch role { case "banner", "complementary", "contentinfo", "main", "navigation", "search", "region": return "landmark" case "form": if snapshot.Name != "" { return "landmark" } return "form" } } if strings.EqualFold(snapshot.HTMLTag, "blockquote") { return "blockquote" } return "" } func ContextEnabled(kind string, reading config.ReadingSettings) bool { if reading.OnlySpeakDisplayedText { return false } switch kind { case "blockquote": return reading.SpeakContextBlockquote case "list": return reading.SpeakContextList case "table": return reading.SpeakContextTable case "panel": return reading.SpeakContextPanel case "landmark": return reading.SpeakContextLandmark case "form": return reading.SpeakContextNonLandmarkForm } return false } // Say All has its own context choices, independent of navigation speech. func SayAllContextEnabled(kind string, reading config.ReadingSettings) bool { if reading.OnlySpeakDisplayedText { return false } switch kind { case "blockquote": return reading.SayAllContextBlockquote case "list": return reading.SayAllContextList case "table": return reading.SayAllContextTable case "panel": return reading.SayAllContextPanel case "landmark": return reading.SayAllContextLandmark case "form": return reading.SayAllContextNonLandmarkForm } return false } func ContextLabel(snapshot atspi.Snapshot) string { role := ContextType(snapshot) if role == "blockquote" { role = "block quote" } if role == "landmark" && snapshot.XMLRoles != "" { role = strings.Fields(snapshot.XMLRoles)[0] + " landmark" } if snapshot.Name != "" { return snapshot.Name + " " + role } return role } func itemCountText(count int) string { if count == 1 { return "1 item" } return fmt.Sprintf("%d items", count) } // TableHeaderText preserves the provider's header label and adds the role // wording used by verbose speech. Brief speech keeps the label alone. func TableHeaderText(label, role string, verbosity int) string { if label == "" || verbosity == 0 { return label } return label + " " + role } // ContextCountLabel adds provider-backed list size to a new context entry. // The ordinary context label remains stable for exits and brief speech. func ContextCountLabel(snapshot atspi.Snapshot, verbosity int) string { if verbosity != 0 && snapshot.ChildCountKnown && snapshot.ChildCount > 0 && (snapshot.Role == "list" || snapshot.Role == "list box") { if snapshot.Name != "" { return snapshot.Name + ", " + itemCountText(snapshot.ChildCount) } return itemCountText(snapshot.ChildCount) } return ContextLabel(snapshot) }