Files
cthulhu/internal/app/runtime_commands.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)
}
}
}
}
}
}