// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "strings" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/sound" ) // handleAutocomplete may rewrite insertion payload before normal presentation. // A true consumed result discards popup and address-bar echo events in place. func (handler *accessibilityEventHandler) handleAutocomplete(event atspi.Event, activity input.Activity, intent input.Intent) (atspi.Event, speechRequest, bool) { state := handler.state live := handler.live logger := handler.logger speak := handler.speak var autocompleteRequest speechRequest state.autocompleteAvailable.focus(event) if event.Type == atspi.TextInsertedEvent && supportsAutocomplete(event.Source) { if live != nil { available := state.presentationOptions.SoundCueAvailable state.presentationOptions = presentationSettingsFromConfig(live.Load(), true) state.presentationOptions.SoundCueAvailable = available } if address := state.autocompleteAvailable.offered(event, activity); address != "" { autocompleteRequest = speechRequest{Text: address, Role: "autocomplete", Cause: "autocomplete-available"} if state.presentationOptions.EnableSound && state.presentationOptions.RoleSoundPresentation != "speech_only" && (state.presentationOptions.SoundCueAvailable == nil || state.presentationOptions.SoundCueAvailable("autocomplete")) { autocompleteRequest.Sound = []sound.Cue{{Name: "autocomplete"}} } } } if event.Source.AutocompletePopup { explicitItem := isAutocompletePopupItem(event.Source.Role) && ((isFocusGainEvent(event) && intent == input.LineIntent) || (event.Type == "object:state-changed:selected" && event.Detail1 != 0 && intent == input.LineIntent)) if explicitItem && event.Source.ID() != state.lastAutocompletePopupItem { state.lastAutocompletePopupItem = event.Source.ID() presentationEvent := event presentationEvent.Type = atspi.FocusEvent speak(presentationEvent) } logger.Debug("skipped autocomplete popup noise", "type", event.Type, "role", event.Source.Role) return event, autocompleteRequest, true } if event.Source.Autocomplete { if isFocusGainEvent(event) { state.lastAutocompletePopupItem = atspi.ObjectID{} } if (isTextChangeEvent(event.Type) || event.Type == atspi.CaretEvent) && intent == input.LineIntent { return event, autocompleteRequest, true // Up/Down presents the popup item, not the entry's replacement text. } if event.Type == atspi.TextDeletedEvent && intent == input.TypingIntent { return event, autocompleteRequest, true // Replacing the selected URL or completion is not a user deletion. } if event.Type == atspi.TextInsertedEvent && intent == input.TypingIntent { event.Payload = state.autocompleteTyping.insertion(event, activity) if autocompleteRequest.Text != "" { event.Payload = "" // The selected address replaces this insertion's character echo. } } } return event, autocompleteRequest, false } func isBrowserUINameNoise(event atspi.Event, focus atspi.Snapshot) bool { if event.Type != atspi.AccessibleNameEvent { return false } // Browser chrome labels, status bars, and frames can change independently // of focus. Current controls and rows still need their new names presented. // Orca's web UI noise filter likewise limits these ignored roles. switch strings.ToLower(event.Source.ToolkitName) { case "chromium", "gecko", "webkit", "webkitgtk": return event.Source.Role == "status bar" || event.Source.Role == "label" || event.Source.Role == "frame" } if event.Source.AutocompletePopup { return true } return focus.Autocomplete && event.Source.ID().BusName != "" && event.Source.ID().BusName == focus.ID().BusName } func isAutocompletePopupItem(role string) bool { return role == "list item" || role == "menu item" }