Files
cthulhu/internal/app/event_autocomplete.go

94 lines
3.9 KiB
Go

// 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"
}