Files
cthulhu/internal/app/speech_interrupt.go

209 lines
6.0 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"
"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)
}
}