Files
2026-10-27 00:40:49 -04:00

178 lines
5.4 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"fmt"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/messaging"
)
type messageCopyRequest struct {
ctx context.Context
serial uint64
text string
}
type messageCopyResult struct {
messageCopyRequest
err error
}
// messagingController belongs to the accessibility event loop. Histories survive
// focus changes, retire with their native window/transcript, and clear on lock.
// Its one clipboard worker has a bounded queue and never performs native queries.
type messagingController struct {
history messaging.History
speech speechEnqueuer
braille interface {
Flash(string, config.BrailleOptions) bool
}
live *liveSettings
gate *focusGate
ctx context.Context
cancelWorker context.CancelFunc
cancelCopy context.CancelFunc
serial uint64
requests chan messageCopyRequest
results chan messageCopyResult
done chan struct{}
}
func newMessagingController(ctx context.Context, speech speechEnqueuer, gate *focusGate, live *liveSettings, write func(context.Context, string) error) *messagingController {
ctx, cancelWorker := context.WithCancel(ctx)
controller := &messagingController{ctx: ctx, cancelWorker: cancelWorker, speech: speech, gate: gate, live: live,
requests: make(chan messageCopyRequest, 1), results: make(chan messageCopyResult, 1), done: make(chan struct{})}
go func() {
defer close(controller.done)
for {
select {
case <-ctx.Done():
return
case request := <-controller.requests:
if request.ctx.Err() != nil || gate.Locked() {
continue
}
err := write(request.ctx, request.text)
if errors.Is(request.ctx.Err(), context.Canceled) {
continue
}
select {
case controller.results <- messageCopyResult{request, err}:
case <-ctx.Done():
return
}
}
}
}()
return controller
}
func (controller *messagingController) handle(event atspi.Event) bool {
if controller == nil {
return false
}
if event.Type == atspi.WindowDestroyedEvent || event.Type == atspi.DefunctStateEvent && event.Detail1 != 0 {
id := event.Source.ID()
if event.Type == atspi.WindowDestroyedEvent && event.Window.Valid() {
id = event.Window
}
controller.history.Retire(id.BusName, id.Path)
}
message := event.ChatMessage
if message == nil || event.Type != atspi.TextInsertedEvent || event.Source.ApplicationProcess != "pidgin" ||
event.Source.Defunct || event.Source.Role != "text" ||
message.Provider != event.Source.BusName || message.Conversation != event.Source.Path ||
!event.Source.ID().Valid() || event.Window != atspi.NewObjectID(message.Provider, message.Window) {
return false
}
if !controller.gate.Locked() && controller.history.Add(*message) {
stored, _ := controller.history.Previous(message.Provider, 0)
controller.present(stored.Text, true)
}
return true
}
func (controller *messagingController) present(text string, background bool) {
if controller.gate.Locked() {
return
}
if controller.braille != nil {
options := config.DefaultSettings().Braille
if controller.live != nil {
options = controller.live.Load().Braille
}
controller.braille.Flash(text, options)
}
// The controller owns the flash; avoid a duplicate system-message flash.
controller.speech.Enqueue(speechRequest{Text: text, Role: "notification", Cause: "chat-message", BypassFocusGate: background})
}
func (controller *messagingController) review(command input.Command, current atspi.Snapshot) {
index, copyMessage, ok := input.MessageCommandIndex(command)
if !ok || controller.gate.Locked() || controller.gate.Suspended() || current.ApplicationProcess != "pidgin" {
return
}
message, exists := controller.history.Previous(current.BusName, index)
if !exists {
return
} // Legacy leaves empty history slots silent.
if !copyMessage {
controller.present(message.Text, false)
return
}
controller.cancelCopyRequest()
copyCtx, cancel := context.WithTimeout(controller.ctx, clipboardReadTimeout)
controller.cancelCopy = cancel
request := messageCopyRequest{ctx: copyCtx, serial: controller.serial, text: message.Text}
// Only the event loop produces requests. Replace an obsolete queued copy
// rather than dropping the newest command while the worker cancels a write.
select {
case <-controller.requests:
default:
}
select {
case controller.requests <- request:
default:
cancel()
}
}
func (controller *messagingController) copied(result messageCopyResult) {
if result.serial != controller.serial || errors.Is(result.ctx.Err(), context.Canceled) || controller.gate.Locked() || controller.gate.Suspended() {
return
}
text := fmt.Sprintf("Copied %s to clipboard.", result.text)
if result.err != nil {
text = "Error accessing clipboard"
}
controller.present(text, false)
controller.cancelCopyRequest()
}
func (controller *messagingController) cancelCopyRequest() {
controller.serial++
if controller.cancelCopy != nil {
controller.cancelCopy()
controller.cancelCopy = nil
}
}
func (controller *messagingController) reset() {
controller.cancelCopyRequest()
controller.history.Reset()
}
func (controller *messagingController) stop() {
controller.reset()
controller.cancelWorker()
<-controller.done
}