// 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 . 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() } }