// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "fmt" "math" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/sound" "git.stormux.org/storm/cthulhu/internal/webscripts" ) type ProgressBarEntry struct { beep, speech float64 beepKnown, speechKnown bool } // ProgressBarHistory belongs to the event loop and survives focus changes. // It is bounded and released with the loop. Native identities keep distinct // bars independent even when they occupy the same screen position. type ProgressBarHistory struct { entries map[atspi.ObjectID]ProgressBarEntry order []atspi.ObjectID } func (history *ProgressBarHistory) report(id atspi.ObjectID, percentage float64, settings config.ProgressBarSettings) (bool, bool) { if history.entries == nil { history.entries = make(map[atspi.ObjectID]ProgressBarEntry) } entry, exists := history.entries[id] beep := (settings.OutputMode == "beep" || settings.OutputMode == "both") && (!entry.beepKnown || math.Abs(percentage-entry.beep) >= float64(settings.BeepPercentageInterval)) speak := (settings.OutputMode == "speak" || settings.OutputMode == "both") && (!entry.speechKnown || math.Abs(percentage-entry.speech) >= float64(settings.SpeechPercentageInterval)) if !beep && !speak { return false, false } if beep { entry.beep, entry.beepKnown = percentage, true } if speak { entry.speech, entry.speechKnown = percentage, true } if exists { for index, candidate := range history.order { if candidate == id { history.order = append(history.order[:index], history.order[index+1:]...) break } } } else if len(history.order) == 32 { delete(history.entries, history.order[0]) history.order = history.order[1:] } history.entries[id] = entry history.order = append(history.order, id) return beep, speak } // Handle progress before ordinary focused-object filtering. This path never // changes window ownership, native focus, review or browser mode. func (handler *accessibilityEventHandler) handle_progress_bar(event atspi.Event) bool { percentage, shaderProgress := webscripts.ShaderProgress(event.Source.ApplicationProcess, event.Type, event.Source.Role, event.Source.Name) if !shaderProgress && (!isValueChangeEvent(event.Type) || event.Source.Role != "progress bar") { return false } settings := config.DefaultSettings() if handler.live != nil { settings = handler.live.Load() } if settings.ProgressBar.OutputMode == "off" || event.Source.StatesKnown && (!event.Source.Visible || !event.Source.Showing) { // NVDA falls back to ordinary focused-control value reporting here. return false } if shaderProgress && (event.Source.Defunct || event.Source.Indeterminate) { return false } if !shaderProgress { var valid bool percentage, valid = presentation.ProgressPercentage(event.Source) if !valid { return false } } state := handler.state if handler.foreground != nil && event.Foreground.Epoch != state.foregroundState.Epoch { return true } if handler.focusGate.Locked() { return true } // Like NVDA, refresh braille only if this is already the displayed object. if event.Source.ID() == state.current.ID() { state.current = event.Source state.brailleSnapshot = event.Source if handler.brailleOutput != nil { handler.brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(event.Source, settings.Braille, settings.TextAttributes)) } } background := event.ForegroundMismatch || state.owner.window.Valid() && (eventWindow(event) != state.owner.window || event.Source.BusName != state.owner.applicationBus) if background && !settings.ProgressBar.ReportBackground || handler.focusGate.Suspended() && !settings.ProgressBar.ReportBackground { return true } beep, speak := state.progressBars.report(event.Source.ID(), percentage, settings.ProgressBar) request := speechRequest{Role: "progress bar", Cause: event.Type, Append: true, Window: eventWindow(event), BypassFocusGate: settings.ProgressBar.ReportBackground} if beep { request.Sound = []sound.Cue{{Name: sound.ProgressCueName(percentage)}} } if speak { request.Text = fmt.Sprintf("%d percent", int(percentage)) } if beep || speak { handler.speech.Enqueue(request) } return true }