Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,217 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user