309 lines
9.6 KiB
Go
309 lines
9.6 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package presentation
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
)
|
|
|
|
type Options struct {
|
|
Reading *config.ReadingSettings
|
|
EnableSound bool
|
|
PlaySoundForRole bool
|
|
PlaySoundForState bool
|
|
RoleSoundPresentation string
|
|
SpatializeObjectSounds bool
|
|
SoundCueAvailable func(string) bool
|
|
SpeakDescription bool
|
|
ReadFullRowInGUITable bool
|
|
SpeechVerbosityLevel int
|
|
SpeakBlankLines bool
|
|
SayAllStyle string
|
|
EnableKeyEcho bool
|
|
EnableEchoByCharacter bool
|
|
EnableEchoByWord bool
|
|
EnableEchoBySentence bool
|
|
EnableAlphabeticKeys bool
|
|
EnableNumericKeys bool
|
|
EnablePunctuationKeys bool
|
|
EnableSpace bool
|
|
EnableDiacriticalKeys bool
|
|
|
|
PlaySoundForPositionInSet bool
|
|
}
|
|
|
|
func DefaultOptions() Options {
|
|
return Options{
|
|
SpeakDescription: true,
|
|
ReadFullRowInGUITable: true,
|
|
SpeechVerbosityLevel: 1,
|
|
SpeakBlankLines: true,
|
|
SayAllStyle: "line",
|
|
EnableKeyEcho: true,
|
|
EnableAlphabeticKeys: true,
|
|
EnableNumericKeys: true,
|
|
EnablePunctuationKeys: true,
|
|
EnableSpace: true,
|
|
}
|
|
}
|
|
|
|
func FocusText(snapshot atspi.Snapshot) string {
|
|
return FocusTextWithOptions(snapshot, DefaultOptions())
|
|
}
|
|
|
|
func FocusTextWithOptions(snapshot atspi.Snapshot, options Options) string {
|
|
return FocusSpeechWithOptions(snapshot, options).Text
|
|
}
|
|
|
|
func FocusSpeechWithOptions(snapshot atspi.Snapshot, options Options) Speech {
|
|
snapshot = libreOfficeFocusSnapshot(snapshot, options)
|
|
// GTK sliders can expose the previous displayed number through Text
|
|
// while Value already contains the current number. Present Value once.
|
|
if snapshot.Role == "slider" && controlValue(snapshot) != "" {
|
|
snapshot.Text = ""
|
|
}
|
|
reading := reading_options(options)
|
|
var parts []string
|
|
role := snapshot.Role
|
|
if role == "entry" && snapshot.Autocomplete {
|
|
role = "autocomplete"
|
|
}
|
|
if role == "heading" && snapshot.HeadingLevel > 0 {
|
|
role = fmt.Sprintf("heading level %d", snapshot.HeadingLevel)
|
|
}
|
|
if role == "link" && snapshot.Visited {
|
|
role = "visited link"
|
|
}
|
|
if role == "table cell" {
|
|
role = ""
|
|
if FullRowEnabled(snapshot, options) && snapshot.TableRowText != "" {
|
|
if snapshot.Text == snapshot.Name {
|
|
snapshot.Text = ""
|
|
}
|
|
snapshot.Name = snapshot.TableRowText
|
|
}
|
|
}
|
|
if reading.OnlySpeakDisplayedText {
|
|
return SpeechWithOptions([]string{displayed_focus_text(snapshot)}, options)
|
|
}
|
|
if role == "table" && snapshot.TableRowCount > 0 && snapshot.TableColumnCount > 0 {
|
|
if options.SpeechVerbosityLevel == 0 {
|
|
role = "table"
|
|
} else {
|
|
rowWord, columnWord := "rows", "columns"
|
|
if snapshot.TableRowCount == 1 {
|
|
rowWord = "row"
|
|
}
|
|
if snapshot.TableColumnCount == 1 {
|
|
columnWord = "column"
|
|
}
|
|
role = fmt.Sprintf("table with %d %s %d %s", snapshot.TableRowCount, rowWord,
|
|
snapshot.TableColumnCount, columnWord)
|
|
}
|
|
}
|
|
if options.SpeechVerbosityLevel == 0 && (role == "icon" || role == "canvas") {
|
|
role = ""
|
|
}
|
|
_, replaceRole := roleSoundCue(snapshot, options)
|
|
if options.RoleSoundPresentation == "sound_only" && replaceRole {
|
|
role = ""
|
|
}
|
|
if options.SpeechVerbosityLevel != 0 && snapshot.ChildCountKnown && snapshot.ChildCount > 0 {
|
|
switch snapshot.Role {
|
|
case "list", "list box":
|
|
role = itemCountText(snapshot.ChildCount)
|
|
}
|
|
}
|
|
fields := []string{}
|
|
if snapshot.Role == "radio button" && snapshot.RadioGroupName != snapshot.Name {
|
|
fields = append(fields, snapshot.RadioGroupName)
|
|
}
|
|
if snapshot.Role == "table cell" {
|
|
fields = append(fields,
|
|
TableHeaderText(snapshot.TableRowHeaders, "row header", options.SpeechVerbosityLevel),
|
|
TableHeaderText(snapshot.TableColumnHeaders, "column header", options.SpeechVerbosityLevel))
|
|
}
|
|
fields = append(fields, snapshot.Name, role)
|
|
states := controlStates(snapshot)
|
|
_, replaceState := stateSoundCue(snapshot, options)
|
|
if options.RoleSoundPresentation == "sound_only" && replaceState {
|
|
states = removeReplacedState(states)
|
|
}
|
|
fields = append(fields, states...)
|
|
fields = append(fields, controlValue(snapshot))
|
|
if options.SpeakDescription {
|
|
fields = append(fields, snapshot.Description)
|
|
}
|
|
if snapshot.Role != "spin button" {
|
|
fields = append(fields, snapshot.Text)
|
|
}
|
|
if snapshot.Autocomplete && snapshot.SelectionEnd > snapshot.SelectionStart {
|
|
fields = append(fields, "selected")
|
|
}
|
|
if reading.EnablePositionSpeaking && snapshot.PositionInSet > 0 && snapshot.SetSize >= snapshot.PositionInSet {
|
|
fields = append(fields, fmt.Sprintf("%d of %d", snapshot.PositionInSet, snapshot.SetSize))
|
|
}
|
|
if reading.EnableMnemonicSpeaking && snapshot.Mnemonic != "" {
|
|
fields = append(fields, snapshot.Mnemonic)
|
|
}
|
|
if reading.EnableTutorialMessages {
|
|
fields = append(fields, control_tutorial(snapshot))
|
|
}
|
|
if reading.SpeakMisspelledIndicator && snapshot.Misspelled {
|
|
fields = append(fields, "misspelled")
|
|
}
|
|
for _, part := range fields {
|
|
if part != "" && !contains(parts, part) {
|
|
parts = append(parts, part)
|
|
}
|
|
}
|
|
return SpeechWithOptions(parts, options)
|
|
}
|
|
|
|
func removeReplacedState(states []string) []string {
|
|
for index, state := range states {
|
|
switch state {
|
|
case "partially checked", "checked", "not checked", "selected", "not selected", "on", "off", "pressed", "not pressed":
|
|
return append(states[:index:index], states[index+1:]...)
|
|
}
|
|
}
|
|
return states
|
|
}
|
|
|
|
func contains(values []string, candidate string) bool {
|
|
for _, value := range values {
|
|
if value == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func EventText(event atspi.Event) string {
|
|
return EventTextWithOptions(event, DefaultOptions())
|
|
}
|
|
|
|
func EventTextWithOptions(event atspi.Event, options Options) string {
|
|
return EventSpeechWithOptions(event, options).Text
|
|
}
|
|
|
|
func EventSpeechWithOptions(event atspi.Event, options Options) Speech {
|
|
switch event.Type {
|
|
case atspi.WindowActivatedEvent:
|
|
speech := FocusSpeechWithOptions(event.Source, options)
|
|
focus := speech.Text
|
|
if event.Source.Role == "table cell" && event.Source.TableName != "" {
|
|
table := atspi.Snapshot{Role: "table", TableRowCount: event.Source.TableRowCount,
|
|
TableColumnCount: event.Source.TableColumnCount, ApplicationProcess: event.Source.ApplicationProcess,
|
|
LibreOffice: event.Source.LibreOffice}
|
|
speech = speech.Prepend(FocusTextWithOptions(table, options), ", ")
|
|
speech = speech.Prepend(event.Source.TableName, ", ")
|
|
focus = speech.Text
|
|
}
|
|
title := strings.TrimSpace(event.WindowTitle)
|
|
if options.SpeakDescription && event.WindowDescription != "" && event.WindowDescription != event.Source.Description {
|
|
speech = speech.Append(event.WindowDescription, ", ")
|
|
focus = speech.Text
|
|
}
|
|
if title == "" || title == event.Source.Name {
|
|
return speech.WithText(focus)
|
|
}
|
|
if focus == "" {
|
|
return Speech{Text: title}
|
|
}
|
|
return speech.Prepend(title, ", ")
|
|
case atspi.DocumentLoadCompleteEvent:
|
|
if atspi.IsWebDocumentRole(event.Source.Role) {
|
|
return Speech{Text: strings.TrimSpace(event.Source.Name)}
|
|
}
|
|
return Speech{}
|
|
case atspi.CaretEvent:
|
|
if event.Source.CaretCharacter != "" {
|
|
return Speech{Text: caretCharacterText(event.Source.CaretCharacter)}
|
|
}
|
|
if event.Source.Text != "" {
|
|
return Speech{Text: event.Source.Text}
|
|
}
|
|
case atspi.TextInsertedEvent:
|
|
if !shouldEchoInsertion(event.Payload, options) {
|
|
return Speech{}
|
|
}
|
|
if event.Payload == " " {
|
|
return Speech{Text: "space"}
|
|
}
|
|
return Speech{Text: event.Payload}
|
|
case atspi.TextDeletedEvent:
|
|
if utf8.RuneCountInString(event.Payload) == 1 && unicode.IsSpace(firstRune(event.Payload)) {
|
|
return Speech{Text: caretCharacterText(event.Payload)}
|
|
}
|
|
return Speech{Text: event.Payload}
|
|
case atspi.ValueChangedEvent, atspi.AccessibleValueEvent:
|
|
return Speech{Text: controlValue(event.Source)}
|
|
case "object:state-changed:checked",
|
|
"object:state-changed:expanded",
|
|
"object:state-changed:indeterminate",
|
|
"object:state-changed:invalid-entry",
|
|
"object:state-changed:pressed",
|
|
"object:state-changed:read-only",
|
|
"object:state-changed:required",
|
|
"object:state-changed:selected",
|
|
"object:state-changed:sensitive":
|
|
if reading_options(options).OnlySpeakDisplayedText {
|
|
return Speech{}
|
|
}
|
|
text := stateChangeText(event.Source, event.Type)
|
|
if options.RoleSoundPresentation == "sound_only" && isReplaceableStateEvent(event.Type) {
|
|
if _, replaced := stateSoundCue(event.Source, options); replaced {
|
|
return Speech{}
|
|
}
|
|
}
|
|
return Speech{Text: text}
|
|
}
|
|
return FocusSpeechWithOptions(event.Source, options)
|
|
}
|
|
|
|
func isReplaceableStateEvent(eventType string) bool {
|
|
switch eventType {
|
|
case "object:state-changed:checked", "object:state-changed:indeterminate", "object:state-changed:pressed":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func caretCharacterText(character string) string {
|
|
switch character {
|
|
case " ":
|
|
return "space"
|
|
case "\t":
|
|
return "tab"
|
|
case "\u00a0":
|
|
return "no break space"
|
|
}
|
|
if unicode.IsSpace(firstRune(character)) {
|
|
return "blank"
|
|
}
|
|
return character
|
|
}
|