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,182 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
"git.stormux.org/storm/cthulhu/internal/lifecycle"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
)
|
||||
|
||||
type pointReviewAdapter interface {
|
||||
ReviewAtPoint(context.Context, int, int, int) (atspi.PointReview, error)
|
||||
}
|
||||
|
||||
type mouseReviewState struct {
|
||||
ready bool
|
||||
last atspi.PointReview
|
||||
spoken string
|
||||
position xorg.PointerPosition
|
||||
}
|
||||
|
||||
func (state *mouseReviewState) reset() { *state = mouseReviewState{} }
|
||||
|
||||
func (state *mouseReviewState) clearItem(position xorg.PointerPosition) {
|
||||
state.ready = true
|
||||
state.last = atspi.PointReview{}
|
||||
state.spoken = ""
|
||||
state.position = position
|
||||
}
|
||||
|
||||
func (state *mouseReviewState) text(point atspi.PointReview, settings config.Settings) string {
|
||||
return state.presentation(point, settings).Text
|
||||
}
|
||||
|
||||
func (state *mouseReviewState) presentation(point atspi.PointReview, settings config.Settings) presentation.Speech {
|
||||
if !point.Snapshot.ID().Valid() || point.Snapshot.Defunct {
|
||||
return presentation.Speech{}
|
||||
}
|
||||
if !state.ready {
|
||||
state.ready = true
|
||||
state.last = point
|
||||
return presentation.Speech{}
|
||||
}
|
||||
changedObject := point.Snapshot.ID() != state.last.Snapshot.ID() || point.Window != state.last.Window
|
||||
var value string
|
||||
var spoken presentation.Speech
|
||||
if changedObject {
|
||||
snapshot := point.Snapshot
|
||||
if snapshot.Role != "password text" && point.Line != snapshot.Name {
|
||||
snapshot.Text = point.Line
|
||||
}
|
||||
spoken = presentation.FocusSpeechWithOptions(snapshot, presentationSettingsFromConfig(settings, true))
|
||||
value = spoken.Text
|
||||
} else if point.LineStart != state.last.LineStart && point.Line != "" {
|
||||
value = point.Line
|
||||
} else if point.Word != "" && point.Word != state.last.Word {
|
||||
value = point.Word
|
||||
}
|
||||
state.last = point
|
||||
value = strings.TrimSpace(value)
|
||||
if value == state.spoken && !changedObject {
|
||||
return presentation.Speech{}
|
||||
}
|
||||
if value != "" {
|
||||
state.spoken = value
|
||||
}
|
||||
spoken.Text = value
|
||||
return spoken
|
||||
}
|
||||
|
||||
// Mouse review owns its pointer connection and last spoken item for one
|
||||
// enabled session. Only copied snapshots leave the AT-SPI owner thread.
|
||||
func mouseReviewService(logger *slog.Logger, adapter pointReviewAdapter, speech speechEnqueuer,
|
||||
braille interface{ EnqueueMessage(string) bool }, live *liveSettings, gate *focusGate) lifecycle.Service {
|
||||
var cancel context.CancelFunc
|
||||
var done chan struct{}
|
||||
return lifecycle.Service{Name: "mouse review", Start: func(ctx context.Context) error {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
done = make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
pointer := xorg.OpenPointerReader()
|
||||
if pointer == nil {
|
||||
logger.Warn("mouse review pointer unavailable")
|
||||
return
|
||||
}
|
||||
defer pointer.Close()
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
var state mouseReviewState
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
settings := live.Load()
|
||||
if !settings.Reading.EnableMouseReview || gate.Locked() {
|
||||
state.reset()
|
||||
continue
|
||||
}
|
||||
position := pointer.Read()
|
||||
if !position.Known {
|
||||
state.reset()
|
||||
continue
|
||||
}
|
||||
if position.PID <= 0 {
|
||||
state.clearItem(position)
|
||||
continue
|
||||
}
|
||||
if state.ready && position == state.position {
|
||||
continue
|
||||
}
|
||||
queryCtx, stop := context.WithTimeout(ctx, 2*time.Second)
|
||||
point, err := adapter.ReviewAtPoint(queryCtx, position.X, position.Y, position.PID)
|
||||
stop()
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
logger.Debug("mouse review hit test", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if pointer.Read() != position {
|
||||
continue
|
||||
}
|
||||
settings = live.Load()
|
||||
if !settings.Reading.EnableMouseReview || gate.Locked() {
|
||||
state.reset()
|
||||
continue
|
||||
}
|
||||
if !point.Snapshot.ID().Valid() {
|
||||
state.clearItem(position)
|
||||
continue
|
||||
}
|
||||
if point.Snapshot.Role == "tool tip" && !settings.Reading.PresentTooltips {
|
||||
state.position = position
|
||||
continue
|
||||
}
|
||||
objectChanged := point.Snapshot.ID() != state.last.Snapshot.ID() || point.Window != state.last.Window
|
||||
dx, dy := position.X-state.position.X, position.Y-state.position.Y
|
||||
interrupt := objectChanged || dx*dx+dy*dy > 25*25
|
||||
spoken := state.presentation(point, settings)
|
||||
message := spoken.Text
|
||||
state.position = position
|
||||
if message == "" {
|
||||
continue
|
||||
}
|
||||
if interrupt {
|
||||
speech.Interrupt()
|
||||
}
|
||||
speech.Enqueue(speechRequest{Text: message, Chunks: spoken.Chunks, Role: point.Snapshot.Role, Cause: "mouse-review"})
|
||||
braille.EnqueueMessage(message)
|
||||
logger.Debug("mouse review presentation", "bus", point.Snapshot.BusName,
|
||||
"path", point.Snapshot.Path, "window", point.Window.Path, "command", "mouse-review",
|
||||
"x", position.X, "y", position.Y, "native_offset", point.Offset,
|
||||
"virtual_offset", -1, "selection_start", -1, "selection_end", -1,
|
||||
"speech", message, "braille", message)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}, Stop: func(ctx context.Context) error {
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
Reference in new issue
Block a user