Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,280 @@
|
||||
// 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<- struct{}
|
||||
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:
|
||||
logger.Info("where am I command received")
|
||||
select {
|
||||
case reviewRequests <- struct{}{}:
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user