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