Files

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)
}