Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+217
View File
@@ -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
}
}