207 lines
6.6 KiB
Go
207 lines
6.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 (
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/sound"
|
|
)
|
|
|
|
// ProgressPercentage normalizes the native range, retaining fractional values.
|
|
// Text percentages are used when a provider has no AT-SPI Value interface.
|
|
func ProgressPercentage(snapshot atspi.Snapshot) (float64, bool) {
|
|
if snapshot.Role != "progress bar" || snapshot.Defunct || snapshot.Indeterminate {
|
|
return 0, false
|
|
}
|
|
var percentage float64
|
|
if snapshot.ValueKnown {
|
|
if math.IsNaN(snapshot.CurrentValue) || math.IsInf(snapshot.CurrentValue, 0) ||
|
|
math.IsNaN(snapshot.MinimumValue) || math.IsInf(snapshot.MinimumValue, 0) ||
|
|
math.IsNaN(snapshot.MaximumValue) || math.IsInf(snapshot.MaximumValue, 0) || snapshot.MaximumValue <= snapshot.MinimumValue {
|
|
return 0, false
|
|
}
|
|
percentage = (snapshot.CurrentValue - snapshot.MinimumValue) * 100 / (snapshot.MaximumValue - snapshot.MinimumValue)
|
|
} else {
|
|
value := strings.Trim(strings.TrimSpace(snapshot.ValueText), "%\x00")
|
|
var err error
|
|
percentage, err = strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
}
|
|
if math.IsNaN(percentage) || math.IsInf(percentage, 0) {
|
|
return 0, false
|
|
}
|
|
return min(100, max(0, percentage)), true
|
|
}
|
|
|
|
func ProgressValueSound(snapshot atspi.Snapshot) string {
|
|
percentage, valid := ProgressPercentage(snapshot)
|
|
if !valid {
|
|
return ""
|
|
}
|
|
return sound.ProgressCueName(percentage)
|
|
}
|
|
|
|
// SoundCues uses the same immutable accessible snapshot as speech presentation.
|
|
// Role and state cues remain categorized until their independent replacement
|
|
// decisions have been made.
|
|
func SoundCues(snapshot atspi.Snapshot, options Options) []sound.Cue {
|
|
if !options.EnableSound {
|
|
return nil
|
|
}
|
|
if options.RoleSoundPresentation == "speech_only" {
|
|
return nil
|
|
}
|
|
|
|
var cues []sound.Cue
|
|
if cue, ok := roleSoundCue(snapshot, options); ok {
|
|
cues = append(cues, cue)
|
|
}
|
|
if cue, ok := stateSoundCue(snapshot, options); ok {
|
|
cues = append(cues, cue)
|
|
}
|
|
if cue, ok := position_sound_cue(snapshot, options); ok {
|
|
cues = append(cues, cue)
|
|
}
|
|
return cues
|
|
}
|
|
|
|
func position_sound_cue(snapshot atspi.Snapshot, options Options) (sound.Cue, bool) {
|
|
if !options.EnableSound || !options.PlaySoundForPositionInSet || options.RoleSoundPresentation == "speech_only" ||
|
|
snapshot.Defunct || snapshot.SetSize <= 1 || snapshot.PositionInSet < 1 || snapshot.PositionInSet > snapshot.SetSize {
|
|
return sound.Cue{}, false
|
|
}
|
|
position := float64(snapshot.PositionInSet-1) / float64(snapshot.SetSize-1)
|
|
name := sound.PositionCueName(position)
|
|
if !cueAvailable(options, name) {
|
|
return sound.Cue{}, false
|
|
}
|
|
return sound.Cue{Name: name, Pan: objectSoundPan(snapshot, options)}, true
|
|
}
|
|
|
|
// EventSoundCues avoids replaying a role cue for an event which presents only
|
|
// a changed state. Focus and other object presentation still include both.
|
|
func EventSoundCues(event atspi.Event, options Options) []sound.Cue {
|
|
if event.Type == "object:state-changed:focused" {
|
|
return SoundCues(event.Source, options)
|
|
}
|
|
if strings.HasPrefix(event.Type, "object:state-changed:") {
|
|
if !isReplaceableStateEvent(event.Type) {
|
|
return nil
|
|
}
|
|
if cue, ok := stateSoundCue(event.Source, options); ok {
|
|
return []sound.Cue{cue}
|
|
}
|
|
return nil
|
|
}
|
|
return SoundCues(event.Source, options)
|
|
}
|
|
|
|
func roleSoundCue(snapshot atspi.Snapshot, options Options) (sound.Cue, bool) {
|
|
if !options.EnableSound || !options.PlaySoundForRole || options.RoleSoundPresentation == "speech_only" {
|
|
return sound.Cue{}, false
|
|
}
|
|
name := map[string]string{
|
|
"push button": "button",
|
|
"button": "button",
|
|
"toggle button": "button",
|
|
"combo box": "combobox",
|
|
}[snapshot.Role]
|
|
if snapshot.Role == "link" {
|
|
name = "link"
|
|
if snapshot.Visited {
|
|
name = "link_visited"
|
|
}
|
|
}
|
|
if name == "" || !cueAvailable(options, name) {
|
|
return sound.Cue{}, false
|
|
}
|
|
return sound.Cue{Name: name, Pan: objectSoundPan(snapshot, options)}, true
|
|
}
|
|
|
|
func stateSoundCue(snapshot atspi.Snapshot, options Options) (sound.Cue, bool) {
|
|
if !options.EnableSound || !options.PlaySoundForState || options.RoleSoundPresentation == "speech_only" {
|
|
return sound.Cue{}, false
|
|
}
|
|
name := ""
|
|
stateBase := map[string]string{
|
|
"check box": "checkbox",
|
|
"check menu item": "checkbox",
|
|
"radio button": "radiobutton",
|
|
"radio menu item": "radiobutton",
|
|
"toggle button": "togglebutton",
|
|
"switch": "switch",
|
|
}[snapshot.Role]
|
|
switch {
|
|
case stateBase == "":
|
|
case snapshot.Indeterminate && stateBase == "checkbox":
|
|
name = stateBase + "_mixed"
|
|
case stateBase == "switch":
|
|
if snapshot.Checked {
|
|
name = "switch_on"
|
|
} else {
|
|
name = "switch_off"
|
|
}
|
|
case snapshot.Checked:
|
|
name = stateBase + "_checked"
|
|
default:
|
|
name = stateBase + "_unchecked"
|
|
}
|
|
if name == "" || !cueAvailable(options, name) {
|
|
return sound.Cue{}, false
|
|
}
|
|
return sound.Cue{Name: name, Pan: objectSoundPan(snapshot, options)}, true
|
|
}
|
|
|
|
func cueAvailable(options Options, name string) bool {
|
|
return options.SoundCueAvailable == nil || options.SoundCueAvailable(name)
|
|
}
|
|
|
|
func objectSoundPan(snapshot atspi.Snapshot, options Options) float64 {
|
|
if !options.SpatializeObjectSounds {
|
|
return 0
|
|
}
|
|
geometry := snapshot.SoundGeometry
|
|
object, reference := geometry.Object, geometry.Reference
|
|
if !geometry.ObjectKnown || !geometry.ReferenceKnown || !object.Valid() || !reference.Valid() {
|
|
return 0
|
|
}
|
|
objectLeft := int64(object.X)
|
|
objectRight := objectLeft + int64(object.Width)
|
|
referenceLeft := int64(reference.X)
|
|
referenceRight := referenceLeft + int64(reference.Width)
|
|
if objectLeft < referenceLeft || objectRight > referenceRight {
|
|
return 0
|
|
}
|
|
center := float64(objectLeft) + float64(object.Width)/2
|
|
position := (center - float64(referenceLeft)) / float64(reference.Width)
|
|
const edge = 0.05
|
|
if position <= edge {
|
|
return -1
|
|
}
|
|
if position >= 1-edge {
|
|
return 1
|
|
}
|
|
return ((position-edge)/(1-2*edge)*2 - 1)
|
|
}
|