389 lines
12 KiB
Go
389 lines
12 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"
|
|
"io"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/braille"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/interaction"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
type internalUIKeys interface {
|
|
InternalUISession() uint64
|
|
BeginInternalUI(context.Context) (uint64, error)
|
|
EndInternalUI(context.Context, uint64) error
|
|
}
|
|
type accessibleActionSource interface {
|
|
AccessibleActions(context.Context, atspi.ObjectID) (atspi.ActionSet, error)
|
|
PerformAccessibleAction(context.Context, atspi.ActionTarget, string) error
|
|
}
|
|
type internalUIResult struct {
|
|
Activated string
|
|
Values map[string]interaction.Value
|
|
Cancelled bool
|
|
Err error
|
|
}
|
|
type internalUINotice struct {
|
|
ctx context.Context
|
|
value interaction.Presentation
|
|
}
|
|
type internalUIRequest struct {
|
|
ctx context.Context
|
|
session *interaction.Session
|
|
result chan internalUIResult
|
|
ready chan error
|
|
}
|
|
|
|
// internalUI owns one modal session on its worker. The bounded event channel
|
|
// keeps AT-SPI/clipboard latency off the native input and command consumers.
|
|
// Core clients can Open a form and receive its submitted values/cancellation.
|
|
type internalUI struct {
|
|
keys internalUIKeys
|
|
actions accessibleActionSource
|
|
interest interface {
|
|
ObjectOfInterest(context.Context) (review.Interest, error)
|
|
}
|
|
speech speechEnqueuer
|
|
braille *brailleWorker
|
|
gate *focusGate
|
|
notices chan internalUINotice
|
|
events chan input.CommandEvent
|
|
requests chan internalUIRequest
|
|
done chan struct{}
|
|
}
|
|
|
|
func (ui *internalUI) Open(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) {
|
|
session, err := interaction.NewSession(form)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1)}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-ui.done:
|
|
return nil, errors.New("internal UI stopped")
|
|
default:
|
|
}
|
|
select {
|
|
case ui.requests <- request:
|
|
default:
|
|
return nil, errors.New("internal UI busy")
|
|
}
|
|
select {
|
|
case err := <-request.ready:
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return request.result, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-ui.done:
|
|
return nil, errors.New("internal UI stopped")
|
|
}
|
|
}
|
|
func (ui *internalUI) Notice(ctx context.Context, value tentaclewire.Presentation) {
|
|
if value.Speech == "" && value.Braille == "" {
|
|
return
|
|
}
|
|
select {
|
|
case ui.notices <- internalUINotice{ctx, interaction.Presentation{Speech: value.Speech, Braille: value.Braille, Cursor: -1}}:
|
|
default:
|
|
}
|
|
}
|
|
func (ui *internalUI) Handle(event input.CommandEvent) bool {
|
|
if ui == nil || event.Command != input.ShowActions && event.Command != input.InternalUIKey && event.Command != input.InternalUIDismiss {
|
|
return false
|
|
}
|
|
select {
|
|
case ui.events <- event:
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (ui *internalUI) present(output interaction.Presentation) {
|
|
if ui.gate.Locked() || ui.gate.Suspended() {
|
|
return
|
|
}
|
|
if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok {
|
|
speech.InterruptInternalUI()
|
|
} else {
|
|
ui.speech.Interrupt()
|
|
}
|
|
if output.Speech != "" {
|
|
ui.speech.Enqueue(speechRequest{Text: output.Speech, Role: "system", Cause: "internal-ui"})
|
|
}
|
|
if ui.braille != nil && output.Braille != "" {
|
|
ui.braille.SetInternalUIFrame(&braille.Frame{Text: output.Braille, Cursor: output.Cursor})
|
|
}
|
|
}
|
|
|
|
func (ui *internalUI) run(ctx context.Context) {
|
|
defer close(ui.done)
|
|
var session *interaction.Session
|
|
var sessionID uint64
|
|
var target atspi.ActionTarget
|
|
var result chan internalUIResult
|
|
var requestDone <-chan struct{}
|
|
finish := func(reply internalUIResult) error {
|
|
var closeErr error
|
|
if sessionID != 0 {
|
|
closeCtx, cancel := context.WithTimeout(ctx, time.Second)
|
|
closeErr = ui.keys.EndInternalUI(closeCtx, sessionID)
|
|
cancel()
|
|
}
|
|
if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok {
|
|
speech.InterruptInternalUI()
|
|
} else {
|
|
ui.speech.Interrupt()
|
|
}
|
|
if ui.braille != nil {
|
|
ui.braille.SetInternalUIFrame(nil)
|
|
}
|
|
if result != nil {
|
|
if reply.Err == nil {
|
|
reply.Err = closeErr
|
|
}
|
|
result <- reply
|
|
close(result)
|
|
}
|
|
session, sessionID, result, requestDone = nil, 0, nil, nil
|
|
target = atspi.ActionTarget{}
|
|
return closeErr
|
|
}
|
|
defer func() { finish(internalUIResult{Cancelled: true}) }()
|
|
// Native capture can be revoked by lock, focus change or handoff without a
|
|
// command being delivered (secure input deliberately drains command queues).
|
|
ticker := time.NewTicker(50 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-requestDone:
|
|
finish(internalUIResult{Cancelled: true})
|
|
case <-ticker.C:
|
|
if sessionID != 0 && ui.keys.InternalUISession() != sessionID {
|
|
finish(internalUIResult{Cancelled: true})
|
|
}
|
|
case notice := <-ui.notices:
|
|
if sessionID != 0 && requestDone == notice.ctx.Done() && ui.keys.InternalUISession() == sessionID {
|
|
ui.present(notice.value)
|
|
}
|
|
case request := <-ui.requests:
|
|
if sessionID != 0 {
|
|
request.ready <- errors.New("internal UI busy")
|
|
close(request.result)
|
|
continue
|
|
}
|
|
var err error
|
|
sessionID, err = ui.keys.BeginInternalUI(request.ctx)
|
|
if err != nil {
|
|
request.ready <- err
|
|
close(request.result)
|
|
continue
|
|
}
|
|
session, result, requestDone = request.session, request.result, request.ctx.Done()
|
|
ui.present(session.Opening())
|
|
request.ready <- nil
|
|
case event := <-ui.events:
|
|
if event.Command == input.ShowActions {
|
|
if sessionID != 0 {
|
|
finish(internalUIResult{Cancelled: true})
|
|
}
|
|
sessionID = event.UISession
|
|
if sessionID == 0 || ui.actions == nil || ui.interest == nil {
|
|
finish(internalUIResult{Err: errors.New("actions unavailable")})
|
|
ui.speech.Enqueue(speechRequest{Text: "Actions unavailable", Role: "system", Cause: "internal-ui"})
|
|
if ui.braille != nil {
|
|
ui.braille.EnqueueMessage("Actions unavailable")
|
|
}
|
|
continue
|
|
}
|
|
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
interest, err := ui.interest.ObjectOfInterest(lookupCtx)
|
|
var actions atspi.ActionSet
|
|
if err == nil {
|
|
actions, err = ui.actions.AccessibleActions(lookupCtx, interest.Object)
|
|
}
|
|
cancel()
|
|
if ui.keys.InternalUISession() != sessionID {
|
|
finish(internalUIResult{Cancelled: true})
|
|
continue
|
|
}
|
|
if err != nil || len(actions.Actions) == 0 {
|
|
finish(internalUIResult{Err: err})
|
|
message := "No actions found"
|
|
if err != nil {
|
|
message = "Actions unavailable"
|
|
}
|
|
ui.speech.Interrupt()
|
|
ui.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "internal-ui"})
|
|
if ui.braille != nil {
|
|
ui.braille.EnqueueMessage(message)
|
|
}
|
|
continue
|
|
}
|
|
items := make([]interaction.Item, 0, len(actions.Actions))
|
|
for _, action := range actions.Actions {
|
|
items = append(items, interaction.Item{ID: action.Name, Label: action.Label})
|
|
}
|
|
session, err = interaction.NewSession(interaction.Form{Title: "Actions", Elements: []interaction.Element{{ID: "actions", Label: "Actions", Kind: interaction.List, Items: items}}})
|
|
if err != nil {
|
|
finish(internalUIResult{Err: err})
|
|
continue
|
|
}
|
|
target = actions.Target
|
|
ui.present(session.Opening())
|
|
continue
|
|
}
|
|
if session == nil || event.UISession != sessionID {
|
|
continue
|
|
}
|
|
if event.Command == input.InternalUIDismiss {
|
|
finish(internalUIResult{Cancelled: true})
|
|
continue
|
|
}
|
|
key := internal_ui_event(event)
|
|
effect := session.Handle(key)
|
|
if effect.Cancelled {
|
|
finish(internalUIResult{Cancelled: true})
|
|
continue
|
|
}
|
|
if ui.keys.InternalUISession() != sessionID {
|
|
finish(internalUIResult{Cancelled: true})
|
|
continue
|
|
}
|
|
if effect.Paste {
|
|
clipboardCtx, cancel := context.WithTimeout(ctx, time.Second)
|
|
text, err := read_internal_clipboard(clipboardCtx)
|
|
cancel()
|
|
if ui.keys.InternalUISession() != sessionID {
|
|
finish(internalUIResult{Cancelled: true})
|
|
continue
|
|
}
|
|
if err == nil {
|
|
effect = session.Insert(text)
|
|
} else {
|
|
effect.Presentation = interaction.Presentation{Speech: "Clipboard text unavailable", Braille: "Clipboard text unavailable", Cursor: -1}
|
|
}
|
|
}
|
|
if effect.Copy != nil {
|
|
copyCtx, cancel := context.WithTimeout(ctx, time.Second)
|
|
err := (xclipOCRCopier{}).Copy(copyCtx, *effect.Copy)
|
|
cancel()
|
|
if ui.keys.InternalUISession() != sessionID {
|
|
finish(internalUIResult{Cancelled: true})
|
|
continue
|
|
}
|
|
message := "Copied"
|
|
if err != nil {
|
|
message = "Copy failed"
|
|
} else if strings.EqualFold(key.Text, "x") {
|
|
effect = session.Handle(interaction.Event{Key: interaction.Delete})
|
|
message = "Cut"
|
|
}
|
|
effect.Presentation = session.Presentation()
|
|
effect.Speech = message
|
|
}
|
|
if effect.Activated != "" {
|
|
values := session.Values()
|
|
actionTarget := target
|
|
closeErr := finish(internalUIResult{Activated: effect.Activated, Values: values})
|
|
if actionTarget.Object.Valid() && closeErr == nil {
|
|
actionCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
err := ui.actions.PerformAccessibleAction(actionCtx, actionTarget, effect.Activated)
|
|
cancel()
|
|
if err != nil && !ui.gate.Locked() && !ui.gate.Suspended() {
|
|
ui.speech.Enqueue(speechRequest{Text: "Action unavailable", Role: "system", Cause: "internal-ui"})
|
|
if ui.braille != nil {
|
|
ui.braille.EnqueueMessage("Action unavailable")
|
|
}
|
|
}
|
|
} else if actionTarget.Object.Valid() && !ui.gate.Locked() && !ui.gate.Suspended() {
|
|
ui.speech.Enqueue(speechRequest{Text: "Action unavailable", Role: "system", Cause: "internal-ui"})
|
|
if ui.braille != nil {
|
|
ui.braille.EnqueueMessage("Action unavailable")
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if effect.Speech != "" || effect.Braille != "" {
|
|
ui.present(effect.Presentation)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func internal_ui_event(event input.CommandEvent) interaction.Event {
|
|
key := interaction.Text
|
|
switch event.Chord.Symbol {
|
|
case 0xff52, 0xff97:
|
|
key = interaction.Up
|
|
case 0xff54, 0xff99:
|
|
key = interaction.Down
|
|
case 0xff51, 0xff96:
|
|
key = interaction.Left
|
|
case 0xff53, 0xff98:
|
|
key = interaction.Right
|
|
case 0xff50, 0xff95:
|
|
key = interaction.Home
|
|
case 0xff57, 0xff9c:
|
|
key = interaction.End
|
|
case 0xff55, 0xff9a:
|
|
key = interaction.PageUp
|
|
case 0xff56, 0xff9b:
|
|
key = interaction.PageDown
|
|
case 0xff09, 0xfe20:
|
|
key = interaction.Tab
|
|
case 0xff0d, 0xff8d:
|
|
key = interaction.Enter
|
|
case 0xff1b:
|
|
key = interaction.Escape
|
|
case 0xff08:
|
|
key = interaction.Backspace
|
|
case 0xffff, 0xff9f:
|
|
key = interaction.Delete
|
|
}
|
|
text := event.Text
|
|
if event.Chord.CthulhuModifier {
|
|
text = ""
|
|
}
|
|
return interaction.Event{Key: key, Text: text, Shift: event.Chord.Modifiers&1 != 0, Control: event.Chord.Modifiers&4 != 0, Alt: event.Chord.Modifiers&(8|64) != 0}
|
|
}
|
|
|
|
func read_internal_clipboard(ctx context.Context) (string, error) {
|
|
command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-out")
|
|
output, err := command.StdoutPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err = command.Start(); err != nil {
|
|
return "", err
|
|
}
|
|
data, readErr := io.ReadAll(io.LimitReader(output, 32769))
|
|
if len(data) > 32768 || readErr != nil {
|
|
_ = command.Process.Kill()
|
|
_ = command.Wait()
|
|
return "", errors.New("clipboard text too large")
|
|
}
|
|
if err = command.Wait(); err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|