432 lines
12 KiB
Go
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()
|
|
}
|
|
}
|