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