183 lines
5.3 KiB
Go
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
|
|
}}
|
|
}
|