Files

183 lines
5.3 KiB
Go

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