260 lines
8.1 KiB
Go
260 lines
8.1 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
|
|
editable bool
|
|
echoEpoch uint64
|
|
echoTitle string
|
|
foreground foregroundMonitor
|
|
titles windowTitleSource
|
|
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, Source: atspi.Snapshot{Role: "text", Editable: true}}
|
|
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.editable = wineEchoEditField(event)
|
|
owner.echoTitle = event.WindowTitle
|
|
if owner.foreground != nil {
|
|
owner.echoEpoch = owner.foreground.Foreground().Epoch
|
|
}
|
|
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" {
|
|
if event.Role != "" {
|
|
owner.editable = wineEchoEditField(event)
|
|
} else if slices.Contains(strings.Split(event.States, ", "), "read only") {
|
|
owner.editable = false
|
|
}
|
|
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 {
|
|
if owner.editable {
|
|
event.Role = "edit"
|
|
} else {
|
|
event.Role = ""
|
|
}
|
|
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
|
|
}
|
|
}
|