// 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" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" ) // Interrupt drops retained requests and wakes the owned cancellation worker. // It must never perform backend I/O in the caller's event loop. func (worker *speechWorker) Interrupt() { worker.mutex.Lock() defer worker.mutex.Unlock() if worker.confirmationID != 0 || worker.has_uninterruptible_locked() { return } worker.interruptLocked() } // The preferences worker changes speech availability after the backend has // accepted a new configuration. Disabling also retires pending and active // utterances before the new setting becomes visible to event producers. func (worker *speechWorker) SetEnabled(enabled bool) { worker.mutex.Lock() defer worker.mutex.Unlock() if worker.enabled == enabled { return } worker.enabled = enabled if !enabled { worker.startupUntil = time.Time{} worker.interruptLocked() } } func (worker *speechWorker) interruptLocked() { if worker.uninterruptibleCancel != nil { worker.uninterruptibleCancel() } worker.presentationDelivery = nil worker.confirmationID = 0 if worker.beepCancel != nil { worker.beepCancel() } worker.titleInput.protectRepeats = false worker.titleWindow = atspi.ObjectID{} if worker.readAllCancel != nil { worker.readAllCancel() worker.readAllCancel = nil } if time.Now().Before(worker.startupUntil) { return } if !worker.running { return } completeSpeechRequests(worker.pending, context.Canceled) worker.pending = nil worker.generation++ worker.interrupting = true select { case worker.interrupt <- struct{}{}: default: } } func (worker *speechWorker) BeginReadAll(ctx context.Context, timestamp uint32) context.Context { worker.mutex.Lock() defer worker.mutex.Unlock() worker.startupUntil = time.Time{} if !worker.has_uninterruptible_locked() { worker.interruptLocked() } else if worker.readAllCancel != nil { worker.readAllCancel() } readContext, cancel := context.WithCancel(ctx) worker.readAllCancel = cancel worker.readAllTimestamp = timestamp return readContext } func (worker *speechWorker) InterruptForInput(event input.Event) bool { worker.mutex.Lock() defer worker.mutex.Unlock() if event.Timestamp != 0 && worker.commandTimestamp != 0 && int32(event.Timestamp-worker.commandTimestamp) <= 0 { return false } // XRecord and command grabs arrive on separate goroutines. A delayed // copy of the initiating chord must not cancel the reading it just began. if worker.readAllCancel != nil && event.Timestamp != 0 && worker.readAllTimestamp != 0 && int32(event.Timestamp-worker.readAllTimestamp) <= 0 { return false } if worker.readAllCancel == nil && worker.titleInput.preserves(event) { worker.logger.Debug("preserved title speech during held-key repeat", "input_timestamp", event.Timestamp, "input_keycode", event.Keycode) return false } if event.Timestamp != 0 { worker.lastInputTimestamp = event.Timestamp } if worker.has_uninterruptible_locked() { return false } worker.startupUntil = time.Time{} worker.interruptLocked() return true } // ProtectCommand ignores delayed copies of input which already dispatched a // command. A newly dispatched command also interrupts confirmation speech, // even when its XRecord input has not arrived yet. func (worker *speechWorker) ProtectCommand(timestamp uint32) { worker.mutex.Lock() defer worker.mutex.Unlock() if timestamp != 0 && worker.commandTimestamp != 0 && int32(timestamp-worker.commandTimestamp) <= 0 { return } if timestamp != 0 { worker.commandTimestamp = timestamp } if worker.confirmationID != 0 && !worker.has_uninterruptible_locked() { worker.interruptLocked() } } // Startup announcements need a brief window in which automatic focus events // append instead of replacing them. A real key press ends that protection. func (worker *speechWorker) EnqueueStartup(request speechRequest) { worker.mutex.Lock() worker.startupUntil = time.Now().Add(3 * time.Second) worker.mutex.Unlock() worker.Enqueue(request) } func (worker *speechWorker) InterruptForUser() { worker.mutex.Lock() defer worker.mutex.Unlock() if worker.has_uninterruptible_locked() { return } worker.startupUntil = time.Time{} worker.interruptLocked() } func (worker *speechWorker) cancelActiveSpeech(ctx context.Context) { defer close(worker.cancelDone) for { select { case <-ctx.Done(): return case <-worker.interrupt: } worker.mutex.Lock() select { case <-worker.interrupt: default: } generation := worker.generation activeDone := worker.activeDone worker.mutex.Unlock() worker.cancelSpeech(ctx) if activeDone != nil { select { case <-ctx.Done(): return case <-activeDone: } // SPEAK may have been accepted after the first CANCEL. Keep new // requests behind the barrier until that in-flight call is settled. worker.cancelSpeech(ctx) } worker.mutex.Lock() if generation == worker.generation { worker.interrupting = false } worker.mutex.Unlock() select { case worker.wake <- struct{}{}: default: } } } func (worker *speechWorker) cancelSpeech(ctx context.Context) { if err := worker.speaker.Cancel(ctx); err != nil && ctx.Err() == nil { worker.logger.Warn("interrupt speech", "error", err) } }