Files
deedra 15d527c619 Respect Cthulhu key echo settings for Wine typing
Wine editor insertion events previously reached speech and Braille without
checking the user's typing echo preferences. Turning off key echo or an
individual character category therefore left Wine typing feedback enabled.

Read the live settings for each insertion and reuse the native text
presentation policy for alphabetic, numeric, punctuation, symbol, space and
diacritical input. Honor character echo and the master key echo switch;
apply the action-key setting to the helper's tab and new-line labels.
Reject unexpected multi-character insertion labels rather than treating
these ordered character events as paste notifications.

Preserve navigation, selection and deletion feedback when typing echo is
disabled. Retain repeated characters, case, uppercase/custom voice routing,
focus ordering and the existing stale-source/password protections.

Add regression coverage for live disable/re-enable, independent categories,
Unicode, character-echo overrides, and unaffected editor actions. Extend
the private D-Bus test to exercise settings changes through the real Wine
service signature. Document behavior, current word/sentence echo limits,
and live Notepad acceptance checks in the README.

Validation:
- Reproduced disabled typing reaching output with the new test before fixing.
- Full go test -race ./... -count=1 passed outside the socket-restricted sandbox.
- Private-session TestNativeSessionBusWineEvents passed with -race.
- Focused typing/editor regressions passed, including a fresh pre-commit run.
- go vet passed for internal/app, presentation, remote and wine.
- Reader and BrlAPI helper builds passed; reader --version succeeded.
- gofmt and git diff --check passed; independent review found no blockers.

Live audible speech, physical keyboard and physical Braille acceptance
remain pending. No running reader was replaced. Changes target deedra.
2026-10-14 22:32:37 -07:00

240 lines
7.5 KiB
Go

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