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