Files

432 lines
12 KiB
Go

// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// 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"
"errors"
"log/slog"
"os"
"os/user"
"reflect"
"strings"
"time"
"unicode"
"git.stormux.org/storm/fenrir/internal/clipboard"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/punctuation"
"git.stormux.org/storm/fenrir/internal/reader"
)
type ClipboardTransfers interface {
Submit(clipboard.Request) bool
Results() <-chan clipboard.Result
Cancel()
Close()
}
type echoSpeaker struct {
Speaker
last string
punctuation *punctuation.Processor
punctuationLevel string
settings *config.Settings
play func(presentation.Cue) bool
}
func (s *echoSpeaker) Say(text string) {
s.Speaker.Say(s.prepare_text(text))
}
func (s *echoSpeaker) Say_after(text string, before func(context.Context)) {
s.Speaker.Say_after(s.prepare_text(text), before)
}
func (s *echoSpeaker) prepare_text(text string) string {
if text != "" {
s.last = text
}
if s.punctuation != nil {
options := punctuation.TextOptions{}
if s.settings != nil {
reading := s.settings.Reading
options = punctuation.TextOptions{Emoticons: reading.Emoticons, RepeatedSymbols: reading.RepeatedSymbols, RepeatedSymbolsMin: reading.RepeatedSymbolsMin}
}
text = s.punctuation.Process_text(text, s.punctuationLevel, options)
}
return text
}
// Character feedback bypasses text expansion, including punctuation in names.
func (s *echoSpeaker) say_character(char rune, suffix ...string) {
text := presentation.Character(char)
if s.punctuation != nil {
text = s.punctuation.Character(char)
}
s.last = text + strings.Join(suffix, "")
for _, part := range suffix {
if s.punctuation != nil {
part = s.punctuation.Text(part, s.punctuationLevel)
}
text += part
}
if s.settings == nil || !unicode.IsUpper(char) {
s.Speaker.Say(text)
return
}
choices := s.settings.Reading
if choices.BeepForCapitals && s.play != nil {
s.play(presentation.Capital)
}
if choices.SayCapForCapitals {
text = i18n.T("cap") + " " + text
}
if choices.RaisePitchForCapitals {
// Python Fenrir's capital_pitch=0.8 maps to 60 on the SSIP scale.
// Keep a rise above a higher normal pitch, up to the backend limit.
pitch := min(100, max(60, s.settings.Presentation.Speech.Pitch+40))
s.Speaker.Say_with_pitch(text, pitch)
return
}
s.Speaker.Say(text)
}
// clipboardHost owns history, sync observations and revisions on the app loop.
// Workers return data only; stale imports and feedback cannot mutate new state.
type clipboardHost struct {
history *clipboard.History
historyStore *clipboard.Persistence
settings config.Clipboard
worker ClipboardTransfers
engine *reader.Reader
present func(string, presentation.Cue)
lastEcho func() string
queuePaste func(string) error
revision, feedback uint64
dirty, busy bool
observed string
next []clipboard.Request
terminal int
}
func new_clipboard(settings config.Clipboard, engine *reader.Reader, worker ClipboardTransfers, present func(string, presentation.Cue), lastEcho func() string) *clipboardHost {
return &clipboardHost{history: clipboard.New_history(settings.NumberOfClipboards), settings: settings, engine: engine, worker: worker, present: present, lastEcho: lastEcho}
}
func (c *clipboardHost) configure(settings config.Clipboard) {
wasPersistent := c.settings.Persistent
c.settings.Sync = settings.Sync
c.settings.Persistent = settings.Persistent
c.settings.NumberOfClipboards = settings.NumberOfClipboards
c.history.Resize(settings.NumberOfClipboards)
c.changed(c.dirty)
if c.historyStore != nil {
switch {
case wasPersistent && !settings.Persistent:
c.historyStore.Disable()
case !wasPersistent && settings.Persistent:
c.historyStore.Enable(c.history.Snapshot(), settings.NumberOfClipboards)
case settings.Persistent:
c.historyStore.Resize(settings.NumberOfClipboards)
}
}
c.next = nil
c.poll()
}
func (c *clipboardHost) say(text string) { c.present(i18n.T(text), "") }
func (c *clipboardHost) changed(dirty bool) {
c.revision++
c.dirty = dirty
if c.worker != nil {
c.worker.Cancel()
}
}
func (c *clipboardHost) add(text string, dirty bool) bool {
if err := c.history.Add(text); err != nil {
c.present(err.Error(), presentation.Error)
return false
}
c.changed(dirty)
if c.settings.Persistent && c.historyStore != nil {
if dirty {
c.historyStore.Add(text, c.settings.NumberOfClipboards)
} else {
c.historyStore.Import(text, c.settings.NumberOfClipboards)
}
}
return true
}
func (c *clipboardHost) history_update(update clipboard.HistoryUpdate) {
if update.Sequence != c.historyStore.Sequence() {
return // A newer local action has not been acknowledged yet.
}
c.settings.Persistent = update.Enabled
if !update.Enabled {
return // Disabling persistence retains the in-memory clipboard.
}
before := c.history.Snapshot()
if err := c.history.Refresh(update.State); err != nil {
c.present(err.Error(), presentation.Error)
return
}
if !reflect.DeepEqual(before.Entries, c.history.Snapshot().Entries) {
// Remote changes are silent and must not export an old X selection.
c.changed(false)
}
}
func (c *clipboardHost) session_changed(terminal int) {
c.terminal = terminal
c.revision++
c.feedback++
c.next = nil
if c.worker != nil {
c.worker.Cancel()
}
}
func clipboard_path(path string) string {
name := os.Getenv("SUDO_USER")
if name == "" {
if current, err := user.Current(); err == nil {
name = current.Username
}
}
for _, token := range []string{"$user", "$USER", "$User"} {
path = strings.ReplaceAll(path, token, name)
}
return path
}
func (c *clipboardHost) transfer(id command.ID, text string) {
if c.worker == nil {
c.say("Clipboard import failed")
return
}
request := clipboard.Request{Command: id, Revision: c.revision, Feedback: c.feedback, Text: text, Path: clipboard_path(c.settings.ExportPath), Display: c.settings.SyncDisplay, Terminal: c.terminal}
c.next = append(c.next, request)
if c.busy {
c.worker.Cancel()
}
c.poll()
}
func (c *clipboardHost) poll() {
if c.worker == nil || c.busy {
return
}
var request clipboard.Request
if len(c.next) > 0 {
request = c.next[0]
c.next = c.next[1:]
if request.Revision != c.revision && (request.Command == command.PasteClipboard || request.Command == command.ImportClipboard || request.Command == command.ImportXClipboard) {
c.poll()
return
}
} else {
if !c.settings.Sync {
return
}
request = clipboard.Request{Revision: c.revision, Feedback: c.feedback, Display: c.settings.SyncDisplay, Background: true}
if text, ok := c.history.Current(); c.dirty && ok && text != "" {
request.Command, request.Text = command.ExportXClipboard, text
}
}
if c.worker.Submit(request) {
c.busy = true
} else {
c.next = append([]clipboard.Request{request}, c.next...)
}
}
func (c *clipboardHost) command(id command.ID) bool {
switch id {
case command.SetMark:
message := c.engine.Set_mark()
c.present(message.Text, message.Cue)
case command.RemoveMarks:
c.engine.Clear_marks()
c.say("Remove marks")
case command.MarkedText, command.CopyMarked:
text, ok := c.engine.Marked_text(id == command.CopyMarked)
if !ok {
if id == command.CopyMarked {
c.say("One or two marks are needed")
} else {
c.say("please set begin and endmark")
}
return true
}
if id == command.CopyMarked {
if c.add(text, true) {
c.engine.Clear_marks()
c.present(text, presentation.CopyToClipboard)
}
} else if strings.TrimSpace(text) == "" {
c.present(i18n.T("blank"), presentation.Blank)
} else {
c.present(text, "")
}
case command.CopyLastEcho:
text := c.lastEcho()
if strings.TrimSpace(text) != "" {
text = strings.TrimRight(text, " \t\r\n")
}
if c.add(text, true) {
c.present(text, presentation.CopyToClipboard)
}
case command.ClearClipboard:
c.history.Clear()
c.changed(false)
if c.historyStore != nil {
c.historyStore.Clear()
}
c.say("clipboard cleared")
case command.CurrentClipboard, command.PreviousClipboard, command.NextClipboard, command.FirstClipboard, command.LastClipboard:
if _, ok := c.history.Current(); !ok {
c.say("clipboard empty")
return true
}
if id != command.CurrentClipboard {
switch id {
case command.PreviousClipboard:
c.history.Move(-1)
case command.NextClipboard:
c.history.Move(1)
case command.FirstClipboard:
c.history.First()
case command.LastClipboard:
c.history.Last()
}
c.changed(true)
if c.settings.Persistent && c.historyStore != nil {
c.historyStore.Select(c.history.Snapshot())
}
}
if id == command.PreviousClipboard || id == command.NextClipboard {
if c.history.At_first() {
c.say("First clipboard ")
}
if c.history.At_last() {
c.say("Last clipboard ")
}
}
text, _ := c.history.Current()
c.present(text, "")
case command.ImportClipboard, command.ImportXClipboard:
c.changed(c.dirty)
c.transfer(id, "")
case command.PasteClipboard, command.ExportClipboard, command.ExportXClipboard:
text, ok := c.history.Current()
if !ok {
c.say("clipboard empty")
return true
}
if id == command.PasteClipboard && c.queuePaste != nil {
if err := c.queuePaste(text); err != nil {
c.present(err.Error(), presentation.Error)
} else {
c.present(i18n.T("paste clipboard"), presentation.PasteClipboard)
}
} else {
c.transfer(id, text)
}
default:
return false
}
return true
}
func (c *clipboardHost) result(result clipboard.Result) {
c.busy = false
request := result.Request
if request.Revision != c.revision {
if len(c.next) > 0 {
c.poll()
}
return
}
feedback := request.Feedback == c.feedback && !request.Background
if result.Err != nil {
if !errors.Is(result.Err, context.Canceled) {
// Transfer diagnostics deliberately omit text, paths and raw errors.
slog.Debug("clipboard transfer failed", "command", request.Command, "at", time.Now())
if feedback {
switch request.Command {
case command.ExportXClipboard:
c.say("failed to export to X clipboard. No available display found.")
case command.ImportXClipboard:
c.say("Clipboard import failed")
case command.ImportClipboard:
if errors.Is(result.Err, os.ErrNotExist) {
c.say("File does not exist")
} else {
c.present(result.Err.Error(), "")
}
default:
c.present(result.Err.Error(), presentation.Error)
}
}
}
} else {
switch request.Command {
case command.ExportXClipboard:
c.observed, c.dirty = request.Text, false
if feedback {
c.say("exported to the X session.")
}
case command.ExportClipboard:
if feedback {
c.say("clipboard exported to file")
}
case command.PasteClipboard:
if feedback {
c.present(i18n.T("paste clipboard"), presentation.PasteClipboard)
}
default:
text := result.Text
if request.Command == command.ImportClipboard || text != "" && (request.Command == command.ImportXClipboard || text != c.observed) {
current, ok := c.history.Current()
if request.Command != "" || !ok || text != current {
c.add(text, request.Command == command.ImportClipboard)
}
if request.Command != command.ImportClipboard {
c.observed, c.dirty = text, false
}
if feedback {
c.present(i18n.T("Import to Clipboard"), presentation.CopyToClipboard)
c.present(text, "")
}
} else if feedback && request.Command == command.ImportXClipboard {
c.say("No text found in clipboard or no accessible display")
}
}
}
if len(c.next) > 0 {
c.poll()
}
}