Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,306 @@
|
||||
// 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 {
|
||||
// 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}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user