285 lines
9.4 KiB
Go
285 lines
9.4 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/notification"
|
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
// commandRuntime owns command dispatch while the event loop owns accessibility state.
|
|
type commandRuntime struct {
|
|
internalUI *internalUI
|
|
actionSource accessibleActionSource
|
|
tentacles *tentacleRuntime
|
|
logger *slog.Logger
|
|
commands commandListener
|
|
focusChanges <-chan xorg.FocusState
|
|
focusGate *focusGate
|
|
speechMessages *speechWorker
|
|
speechOutput speechEnqueuer
|
|
learn *learnModePresenter
|
|
browser *browserModeController
|
|
preferences *preferencesWorker
|
|
onPreferences func()
|
|
onSpeechHistory func()
|
|
onSpeechMonitor func()
|
|
onNotificationList func()
|
|
live *liveSettings
|
|
clipboardRequests chan<- struct{}
|
|
reviewRequests chan<- input.Command
|
|
attributeRequests chan<- struct{}
|
|
objectReview *review.Worker
|
|
ocr *ocrSession
|
|
notifications *notification.Presenter
|
|
stopRun context.CancelFunc
|
|
}
|
|
|
|
func (worker commandRuntime) run(ctx context.Context) error {
|
|
logger := worker.logger
|
|
commands := worker.commands
|
|
focusChanges := worker.focusChanges
|
|
focusGate := worker.focusGate
|
|
speechMessages := worker.speechMessages
|
|
speechOutput := worker.speechOutput
|
|
learn := worker.learn
|
|
browser := worker.browser
|
|
preferences := worker.preferences
|
|
onPreferences := worker.onPreferences
|
|
onSpeechHistory := worker.onSpeechHistory
|
|
onSpeechMonitor := worker.onSpeechMonitor
|
|
onNotificationList := worker.onNotificationList
|
|
live := worker.live
|
|
clipboardRequests := worker.clipboardRequests
|
|
reviewRequests := worker.reviewRequests
|
|
objectReview := worker.objectReview
|
|
notifications := worker.notifications
|
|
stopRun := worker.stopRun
|
|
var tentacleAnnouncements <-chan string
|
|
var tentacleFailures <-chan error
|
|
if worker.tentacles != nil {
|
|
tentacleFailures = worker.tentacles.failures
|
|
tentacleAnnouncements = worker.tentacles.announcements
|
|
}
|
|
var ocrFailures <-chan error
|
|
if worker.ocr != nil {
|
|
ocrFailures = worker.ocr.failures
|
|
}
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case message := <-tentacleAnnouncements:
|
|
if !focusGate.Locked() && !focusGate.Suspended() {
|
|
learn.present(message, "tentacle-status")
|
|
}
|
|
case err := <-tentacleFailures:
|
|
stopRun()
|
|
return err
|
|
case err := <-ocrFailures:
|
|
stopRun()
|
|
return err
|
|
case err := <-commands.Failures():
|
|
stopRun()
|
|
return err
|
|
case state := <-focusChanges:
|
|
if !state.Known {
|
|
continue
|
|
}
|
|
if focusGate.SetSuspended(state.XTerm) {
|
|
if state.XTerm {
|
|
logger.Info("suspended for XTerm focus handoff")
|
|
} else {
|
|
logger.Info("resumed after XTerm focus handoff")
|
|
}
|
|
}
|
|
case state := <-commands.LockStates():
|
|
if focusGate.Locked() {
|
|
continue
|
|
}
|
|
if speechMessages != nil {
|
|
speechMessages.ProtectCommand(state.Timestamp)
|
|
}
|
|
focusGate.dismiss_confirmation()
|
|
speechOutput.Enqueue(speechRequest{Text: state.Text(), Role: "system", Cause: "lock-state"})
|
|
logger.Debug("keyboard lock state changed", "key", state.Name, "locked", state.Locked)
|
|
case event := <-commands.Commands():
|
|
focusGate.retireTentacleOutput()
|
|
if focusGate.Locked() {
|
|
continue
|
|
}
|
|
command := event.Command
|
|
if speechMessages != nil {
|
|
speechMessages.ProtectCommand(event.Timestamp)
|
|
}
|
|
focusGate.dismiss_confirmation()
|
|
logger.Debug("keyboard command received", "command", command, "input_timestamp", event.Timestamp)
|
|
if worker.internalUI.Handle(event) {
|
|
continue
|
|
}
|
|
if worker.internalUI != nil && (command == input.ShowActions || command == input.InternalUIKey || command == input.InternalUIDismiss) {
|
|
stopRun()
|
|
return errors.New("internal UI event queue overflow")
|
|
}
|
|
if worker.ocr.Handle(event) {
|
|
continue
|
|
}
|
|
switch command {
|
|
case input.ToggleLearnMode, input.LearnKey, input.LearnDocumentation, input.LearnDefaultShortcuts, input.LearnApplicationShortcuts:
|
|
learn.handle(event)
|
|
continue
|
|
}
|
|
if !focusGate.Suspended() && worker.tentacles.Invoke(ctx, command) {
|
|
continue
|
|
}
|
|
if browser != nil {
|
|
browser.observeCommand(command)
|
|
}
|
|
switch command {
|
|
case input.DisableTentacles:
|
|
worker.tentacles.Disable()
|
|
learn.present("Tentacles disabled.", "tentacles-disabled")
|
|
case input.CycleSpeechMode:
|
|
if speechMessages != nil {
|
|
mode := speechMessages.cycle_speech_mode()
|
|
speechOutput.Enqueue(speechRequest{Text: mode.announcement(), Role: "system", Cause: "speech-mode", ForceSpeech: true})
|
|
}
|
|
case input.Quit:
|
|
logger.Info("quit command received")
|
|
stopRun()
|
|
return nil
|
|
case input.ToggleDocumentMode, input.StickyFocusMode, input.StickyBrowseMode:
|
|
if browser != nil {
|
|
select {
|
|
case browser.toggles <- command:
|
|
default:
|
|
}
|
|
}
|
|
case input.SelectPreviousSpeechSetting, input.SelectNextSpeechSetting, input.DecreaseSpeechSetting, input.IncreaseSpeechSetting, input.SaveSpeechSettings, input.CycleProgressBarOutput:
|
|
logger.Debug("speech setting command received", "command", command)
|
|
preferences.SpeechCommand(command)
|
|
case input.Preferences:
|
|
logger.Info("preferences command received")
|
|
if onPreferences != nil {
|
|
onPreferences()
|
|
}
|
|
case input.ApplicationPreferences:
|
|
logger.Info("application preferences command received")
|
|
if preferences != nil {
|
|
preferences.OpenApplication()
|
|
}
|
|
case input.ChooseApplicationPreferences:
|
|
if preferences != nil {
|
|
preferences.ChooseApplication()
|
|
}
|
|
case input.PresentTime, input.PresentDate:
|
|
presentDateTime(speechOutput, live.Load(), command, time.Now())
|
|
case input.PresentClipboard:
|
|
select {
|
|
case clipboardRequests <- struct{}{}:
|
|
default:
|
|
}
|
|
case input.ShowSpeechHistory:
|
|
if onSpeechHistory != nil {
|
|
onSpeechHistory()
|
|
}
|
|
case input.ToggleSpeechMonitor:
|
|
if onSpeechMonitor != nil {
|
|
onSpeechMonitor()
|
|
}
|
|
case input.PresentVersion:
|
|
presentVersion(
|
|
speechOutput,
|
|
atspi.Version(),
|
|
os.Getenv("XDG_SESSION_TYPE"),
|
|
os.Getenv("XDG_SESSION_DESKTOP"),
|
|
)
|
|
case input.PresentLastNotification:
|
|
presentNotifications(speechOutput, notifications.PresentLast())
|
|
case input.PresentPreviousNotification:
|
|
presentNotifications(speechOutput, notifications.PresentPrevious())
|
|
case input.PresentNextNotification:
|
|
presentNotifications(speechOutput, notifications.PresentNext())
|
|
case input.ShowNotificationList:
|
|
if onNotificationList != nil {
|
|
onNotificationList()
|
|
}
|
|
case input.WhereAmI, input.PresentCellFormula:
|
|
if command == input.WhereAmI {
|
|
logger.Info("where am I command received")
|
|
} else {
|
|
logger.Info("cell formula command received")
|
|
}
|
|
select {
|
|
case reviewRequests <- command:
|
|
default:
|
|
}
|
|
case input.PresentCharacterAttributes:
|
|
select {
|
|
case worker.attributeRequests <- struct{}{}:
|
|
default:
|
|
}
|
|
case input.ObjectParent:
|
|
queueObjectReviewMove(logger, objectReview, review.Parent)
|
|
case input.ObjectFirstChild:
|
|
queueObjectReviewMove(logger, objectReview, review.FirstChild)
|
|
case input.ObjectPreviousSibling:
|
|
queueObjectReviewMove(logger, objectReview, review.PreviousSibling)
|
|
case input.ObjectNextSibling:
|
|
queueObjectReviewMove(logger, objectReview, review.NextSibling)
|
|
case input.DocumentActivate:
|
|
if objectReview != nil {
|
|
if err := objectReview.ActivateDocumentCaret(); err != nil {
|
|
logger.Warn("queue document activation", "error", err)
|
|
}
|
|
}
|
|
case input.ReadAll:
|
|
if objectReview != nil && speechMessages != nil {
|
|
if err := objectReview.ReadAll(speechMessages.BeginReadAll(ctx, event.Timestamp)); err != nil {
|
|
logger.Warn("queue read all", "error", err)
|
|
}
|
|
}
|
|
default:
|
|
if target, level, backwards := documentReviewCommand(command); target != 0 {
|
|
if err := objectReview.MoveDocument(target, level, backwards); err != nil {
|
|
logger.Warn("queue document navigation", "error", err)
|
|
}
|
|
} else if caretCommand := documentCaretCommand(command); caretCommand != 0 {
|
|
if err := objectReview.MoveDocumentCaret(caretCommand); err != nil {
|
|
logger.Warn("queue document caret navigation", "error", err)
|
|
}
|
|
} else if unit, step := flatReviewCommand(command); unit != 0 {
|
|
logger.Info("flat review command received", "command", command)
|
|
if err := objectReview.ReadFlatWithSpeech(unit, step, flat_review_speech(command)); err != nil {
|
|
logger.Warn("queue flat review", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|