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