// 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" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" ) // runtimeWorkers owns the event-loop companion workers. Only the event loop // consumes reviewRequests; command and clipboard workers send to bounded queues. // Each done channel closes after its worker exits. commandError is read after // commandDone closes, so no additional synchronization is needed. type runtimeWorkers struct { reviewRequests <-chan input.Command attributeRequests <-chan struct{} commandDone <-chan struct{} clipboardDone <-chan struct{} inputDone <-chan struct{} copyDone <-chan struct{} autoClipboardDone <-chan struct{} commandError error internalUIDone <-chan struct{} internalUI *internalUI } // startRuntimeWorkers preserves startup order: clipboard, command, input, copy. // The caller cancels ctx and joins these workers before stopping output services. func startRuntimeWorkers(ctx context.Context, command commandRuntime, inputListener InputListener, settings config.Settings, copyChanges <-chan struct{}, braille *brailleWorker) *runtimeWorkers { speech := gatedSpeech{speech: command.speechOutput, gate: command.focusGate} learn := &learnModePresenter{tentacles: command.tentacles, ctx: ctx, logger: command.logger, speech: speech, braille: braille, live: command.live} if owner, ok := command.commands.(interface{ BrowseNavigationEnabled() bool }); ok { learn.browse = owner } command.learn = learn clipboardRequests := make(chan struct{}, 1) clipboardDone := runClipboardWorker(ctx, clipboardRequests, speech, readClipboardMessage) command.clipboardRequests = clipboardRequests reviewRequests := make(chan input.Command, 1) command.reviewRequests = reviewRequests attributeRequests := make(chan struct{}, 1) command.attributeRequests = attributeRequests commandDone := make(chan struct{}) workers := &runtimeWorkers{ reviewRequests: reviewRequests, attributeRequests: attributeRequests, commandDone: commandDone, clipboardDone: clipboardDone, } if keys, ok := command.commands.(internalUIKeys); ok { ui := &internalUI{notices: make(chan internalUINotice, 4), keys: keys, actions: command.actionSource, interest: command.objectReview, speech: speech, braille: braille, gate: command.focusGate, events: make(chan input.CommandEvent, 32), requests: make(chan internalUIRequest, 1), done: make(chan struct{})} command.internalUI = ui if command.tentacles != nil { command.tentacles.forms = ui command.tentacles.focusedItem = focusedTentacleItem(command.actionSource, ui) } workers.internalUI = ui workers.internalUIDone = ui.done go ui.run(ctx) } go func() { defer close(commandDone) workers.commandError = command.run(ctx) }() inputDone := make(chan struct{}) if inputListener != nil { go func() { defer close(inputDone) processInputEvents(ctx, command.logger, speech, inputListener, inputEchoSettingsFromConfig(settings), command.live, command.commands, command.ocr) }() } else { close(inputDone) } workers.inputDone = inputDone workers.copyDone = runCopyFeedback(ctx, copyChanges, inputListener, speech, command.live, command.ocr) workers.autoClipboardDone = run_automatic_clipboard(ctx, command.logger, command.live, command.focusGate, speech) return workers }