package app import ( "fmt" "log/slog" "slices" "strings" "sync" "time" "unicode/utf8" "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/remote" ) // wineEventPresenter coalesces transient Wine focus bursts before presenting // normalized helper events. The helper never passes native object references. type wineEventPresenter struct { // mu serializes D-Bus actions with timer output and shutdown. Native // objects remain in Wine; focused/protected live until the next focus event. mu sync.Mutex present func(string) bool windowTitle func(string) logger *slog.Logger timer *time.Timer timerGeneration uint64 pending *winePresentation focused string protected bool window string lastKey string lastTime time.Time closed bool } type winePresentation struct { source, window, message string parts []string } func newWineEventPresenter(present func(string) bool) *wineEventPresenter { return &wineEventPresenter{present: present} } // wineTypingEchoEnabled applies the native insertion policy to the helper's // character labels. Navigation and deletion do not pass through this filter. func wineTypingEchoEnabled(text string, settings config.Settings) bool { switch text { case "tab", "new line": return settings.EnableKeyEcho && settings.EnableActionKeys case "space": text = " " } // The helper sends one accepted character per insertion. Do not treat an // unexpected label as a multi-character paste, which has a different policy. if utf8.RuneCountInString(text) != 1 { return false } event := atspi.Event{Type: atspi.TextInsertedEvent, Payload: text} return presentation.EventTextWithOptions(event, presentationSettingsFromConfig(settings, false)) != "" } func (owner *wineEventPresenter) Present(event remote.AccessibleEvent) bool { return owner.present_event(event, nil) } func (owner *wineEventPresenter) present_event(event remote.AccessibleEvent, editor func(remote.AccessibleEvent) bool) bool { owner.mu.Lock() defer owner.mu.Unlock() kind := strings.ToLower(event.EventType) if owner.closed { return false } if kind == "focus" { owner.focused = event.SourceID owner.protected = event.Description == "password" || slices.Contains(strings.Split(event.States, ", "), "password") } else if kind != "value" && kind != "selection" && kind != "text" && kind != "name" && kind != "state" { return false } else if event.SourceID != owner.focused { window := owner.window if owner.pending != nil { window = owner.pending.window } if kind != "selection" || event.WindowTitle == "" || event.WindowTitle != window { return false } } if kind == "state" { owner.protected = slices.Contains(strings.Split(event.States, ", "), "password") || event.Description == "password" } if owner.protected && event.SourceID == owner.focused { event.Value = "" } if kind == "text" { if owner.protected || event.Name == "" { return false } // Establish the queued focus before the action. Neither focus merging // nor duplicate suppression may consume repeated characters/actions. owner.flush_pending() if owner.logger != nil { owner.logger.Debug("Wine editor feedback", "source", event.SourceID, "native_utf16_selection", event.States, "text", event.Name) } // Retain action metadata so character output uses the reader's // uppercase and custom typing voices after focus/password filtering. if editor != nil { return editor(event) } return owner.present(event.Name) } role := event.Role if strings.EqualFold(role, "client") { role = "" } parts := make([]string, 0, 5) for _, part := range []string{event.Name, role, event.Value, event.Description} { if part != "" { parts = append(parts, part) } } // Only the helper's normalized MSAA states are presentation data. Editor // offsets/action kinds in this same field stay in debug diagnostics. for _, state := range strings.Split(event.States, ", ") { switch state { case "checked", "not checked", "half checked", "selected", "pressed", "expanded", "collapsed", "unavailable", "read only", "password", "busy": if !slices.Contains(parts, state) { parts = append(parts, state) } } } if event.Position > 0 && event.Count > 0 { parts = append(parts, fmt.Sprintf("%d of %d", event.Position, event.Count)) } if len(parts) == 0 && (kind != "focus" || event.WindowTitle == "") { return false } message := strings.Join(parts, ", ") key := event.SourceID + "\x00" + message if message == "" { key = event.SourceID + "\x00" + event.WindowTitle } // Losing native focus emits a state event for the previous control before // the new focus arrives. Focus bookkeeping is absent from the normalized // states, so an unchanged semantic presentation must stay quiet even when // the last announcement was more than 100 ms ago. if kind == "state" && key == owner.lastKey { if owner.logger != nil { owner.logger.Debug("Wine state unchanged", "source", event.SourceID, "text", message) } return false } if key == owner.lastKey && time.Since(owner.lastTime) < 100*time.Millisecond { if kind == "focus" { owner.pending = nil } return false } if kind == "focus" { owner.pending = &winePresentation{source: event.SourceID, window: event.WindowTitle, message: message, parts: parts} if owner.timer == nil { generation := owner.timerGeneration owner.timer = time.AfterFunc(50*time.Millisecond, func() { owner.flush_generation(generation) }) } return true } if pending := owner.pending; pending != nil && (pending.source == event.SourceID || event.WindowTitle != "" && pending.window == event.WindowTitle) { pending.source = event.SourceID pending.message = message pending.parts = parts return true } owner.lastKey, owner.lastTime = key, time.Now() return owner.present(message) } func (owner *wineEventPresenter) flush() { owner.mu.Lock() defer owner.mu.Unlock() owner.flush_pending() } func (owner *wineEventPresenter) flush_generation(generation uint64) { owner.mu.Lock() defer owner.mu.Unlock() if generation == owner.timerGeneration { owner.flush_pending() } } // flush_pending requires mu to be held, including for immediate editor // actions. Stop the old timer so it cannot flush a later focus burst early. func (owner *wineEventPresenter) flush_pending() { owner.timerGeneration++ if owner.timer != nil { owner.timer.Stop() } owner.timer = nil pending := owner.pending owner.pending = nil if owner.closed || pending == nil { return } message := pending.message if owner.windowTitle != nil && message == pending.window { // A client-name-only focus is a title report, not a control label. message = "" } if pending.window != "" { if pending.window != owner.window && owner.windowTitle != nil { owner.windowTitle(pending.window) } else if owner.windowTitle == nil && pending.window != owner.window && !slices.Contains(pending.parts, pending.window) { if message != "" { message += ", " } message += pending.window } owner.window = pending.window } owner.lastKey = pending.source + "\x00" + pending.message owner.lastTime = time.Now() if message != "" { owner.present(message) } } func (owner *wineEventPresenter) Close() { owner.mu.Lock() defer owner.mu.Unlock() owner.closed = true owner.pending = nil if owner.timer != nil { owner.timer.Stop() owner.timer = nil } }