387 lines
16 KiB
Go
387 lines
16 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 (
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/config"
|
|
"git.stormux.org/storm/fenrir/internal/i18n"
|
|
"git.stormux.org/storm/fenrir/internal/speech"
|
|
)
|
|
|
|
type modifierSettings struct{ fenrirKeys, scriptKeys []string }
|
|
|
|
// applicationSettings separates the effective configuration from the save draft.
|
|
// The draft retains next-launch menu choices; session mute, automatic reading,
|
|
// and attribute overrides are not implicitly copied into it.
|
|
type applicationSettings struct {
|
|
pendingModifiers *modifierSettings
|
|
live config.Settings
|
|
saveDraft config.Settings
|
|
resetBase config.Settings // Last loaded or successfully saved settings.
|
|
remoteFields map[string]uint64
|
|
remoteRevision uint64
|
|
speechAvailable bool // The startup connection cannot be enabled by a remote setting.
|
|
}
|
|
|
|
// configure_reader applies startup settings, including next-launch-only fields.
|
|
func (a *application) configure_reader() {
|
|
a.configure_screen()
|
|
a.engine.Configure(a.settings.live.Reading.Automatic, a.settings.live.Reading.CursorEcho)
|
|
a.engine.Configure_indentation(a.settings.live.Reading.Indentation)
|
|
a.engine.Configure_attributes(a.settings.live.Reading.Attributes, a.settings.live.Reading.AttributeFormat)
|
|
a.engine.Configure_progress(a.settings.live.Presentation.Progress.Enabled)
|
|
a.engine.Configure_tracking(a.settings.live.Reading.Mode)
|
|
a.engine.Configure_review(a.settings.live.Review.LineBreak, a.settings.live.Review.EndOfScreen, a.settings.live.Review.LeaveReviewOnCursorChange, a.settings.live.Review.LeaveReviewOnScreenChange)
|
|
a.engine.Configure_echo(a.settings.live.Keyboard.CharacterEcho, a.settings.live.Keyboard.WordEcho, a.settings.live.Keyboard.EchoOnCapsLock)
|
|
}
|
|
|
|
func (a *application) configure_screen() {
|
|
if !a.options.TerminalMode {
|
|
if source, ok := a.source.(interface{ Configure_ignored_vts([]int) }); ok {
|
|
source.Configure_ignored_vts(a.settings.live.Screen.IgnoredVTs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// preview_spelling invalidates queued feedback when menu previews change.
|
|
// The modal host restores the previous values on cancellation.
|
|
func (a *application) preview_spelling(reading config.Reading) {
|
|
if a.settings.live.Reading.Spellcheck != reading.Spellcheck || a.settings.live.Reading.SpellcheckSound != reading.SpellcheckSound ||
|
|
a.settings.live.Reading.SpellcheckReadWord != reading.SpellcheckReadWord || a.settings.live.Reading.SpellcheckSayMisspelled != reading.SpellcheckSayMisspelled {
|
|
a.spellFeedback++
|
|
}
|
|
a.settings.live.Reading.Spellcheck = reading.Spellcheck
|
|
a.settings.live.Reading.SpellcheckReadWord = reading.SpellcheckReadWord
|
|
a.settings.live.Reading.SpellcheckSayMisspelled = reading.SpellcheckSayMisspelled
|
|
a.settings.live.Reading.SpellcheckSound = reading.SpellcheckSound
|
|
}
|
|
|
|
// apply_saved_settings keeps the complete saved draft but applies only fields
|
|
// supported live. Device, layout and backend changes wait until the next launch.
|
|
func (a *application) apply_saved_settings(saved config.Settings, speechApplied bool) {
|
|
if speechApplied {
|
|
copy_speech_selection(&a.settings.live.Presentation.Speech, saved.Presentation.Speech)
|
|
}
|
|
if !slices.Equal(saved.Keyboard.FenrirKeys, a.settings.live.Keyboard.FenrirKeys) || !slices.Equal(saved.General.ScriptKeys, a.settings.live.General.ScriptKeys) {
|
|
a.settings.pendingModifiers = &modifierSettings{fenrirKeys: slices.Clone(saved.Keyboard.FenrirKeys), scriptKeys: slices.Clone(saved.General.ScriptKeys)}
|
|
} else {
|
|
a.settings.pendingModifiers = nil
|
|
}
|
|
a.settings.saveDraft = saved
|
|
a.settings.resetBase = saved
|
|
a.settings.live.InternalUI = saved.InternalUI
|
|
a.settings.live.Screen = saved.Screen
|
|
a.configure_screen()
|
|
a.preview_spelling(saved.Reading)
|
|
a.settings.live.Presentation.DateTime = saved.Presentation.DateTime
|
|
a.settings.live.Reading.PunctuationLevel = saved.Reading.PunctuationLevel
|
|
a.settings.live.Reading.Emoticons = saved.Reading.Emoticons
|
|
a.settings.live.Reading.RepeatedSymbols = saved.Reading.RepeatedSymbols
|
|
a.settings.live.Reading.RepeatedSymbolsMin = saved.Reading.RepeatedSymbolsMin
|
|
a.settings.live.Reading.Attributes = saved.Reading.Attributes
|
|
a.settings.live.Reading.AttributeFormat = saved.Reading.AttributeFormat
|
|
a.engine.Configure_attributes(saved.Reading.Attributes, saved.Reading.AttributeFormat)
|
|
a.settings.live.Reading.Indentation = saved.Reading.Indentation
|
|
a.engine.Configure_indentation(saved.Reading.Indentation)
|
|
a.settings.live.Reading.RaisePitchForCapitals = saved.Reading.RaisePitchForCapitals
|
|
a.settings.live.Reading.BeepForCapitals = saved.Reading.BeepForCapitals
|
|
a.settings.live.Reading.SayCapForCapitals = saved.Reading.SayCapForCapitals
|
|
a.voice.punctuationLevel = saved.Reading.PunctuationLevel
|
|
a.settings.live.Presentation.Speech.Rate = saved.Presentation.Speech.Rate
|
|
a.settings.live.Presentation.Speech.Pitch = saved.Presentation.Speech.Pitch
|
|
a.settings.live.Presentation.Speech.Volume = saved.Presentation.Speech.Volume
|
|
a.voice.Configure(speech_parameters(a.settings.live))
|
|
a.settings.live.Keyboard.CharacterEcho = saved.Keyboard.CharacterEcho
|
|
a.settings.live.Keyboard.WordEcho = saved.Keyboard.WordEcho
|
|
a.settings.live.Keyboard.EchoOnCapsLock = saved.Keyboard.EchoOnCapsLock
|
|
a.engine.Configure_echo(a.settings.live.Keyboard.CharacterEcho, a.settings.live.Keyboard.WordEcho, a.settings.live.Keyboard.EchoOnCapsLock)
|
|
a.quick.changed = false
|
|
a.quick.selectionChanged = false
|
|
a.settings.live.Presentation.Sound.Volume = saved.Presentation.Sound.Volume
|
|
if a.cues != nil {
|
|
a.cues.Set_volume(saved.Presentation.Sound.Volume)
|
|
}
|
|
a.settings.live.Presentation.Progress = saved.Presentation.Progress
|
|
a.engine.Configure_progress(saved.Presentation.Progress.Enabled)
|
|
if a.cues != nil {
|
|
a.cues.Clear_progress()
|
|
}
|
|
a.settings.live.Clipboard = saved.Clipboard
|
|
a.clipboards.configure(saved.Clipboard)
|
|
}
|
|
|
|
// apply_pending_modifiers runs on the event loop after menu closure. Native
|
|
// adapters defer while keys are held; failure leaves the prior runtime intact.
|
|
func (a *application) apply_pending_modifiers() {
|
|
pending := a.settings.pendingModifiers
|
|
if pending == nil || a.ui.active {
|
|
return
|
|
}
|
|
applied, err := a.keyboards.Update_modifiers(pending.fenrirKeys, pending.scriptKeys)
|
|
if err != nil {
|
|
a.settings.pendingModifiers = nil
|
|
a.voice.Say(i18n.Format("Settings saved, but key changes could not be applied: {0}. Previous keys remain active.", err.Error()))
|
|
return
|
|
}
|
|
if !applied {
|
|
return
|
|
}
|
|
a.settings.live.Keyboard.FenrirKeys = pending.fenrirKeys
|
|
a.settings.live.General.ScriptKeys = pending.scriptKeys
|
|
a.settings.pendingModifiers = nil
|
|
}
|
|
|
|
// Quick commands have already changed live values and configured speech when
|
|
// needed. Refresh the reader without copying session overrides into the draft.
|
|
func (a *application) apply_quick_settings() {
|
|
a.voice.punctuationLevel = a.settings.live.Reading.PunctuationLevel
|
|
a.engine.Configure_echo(a.settings.live.Keyboard.CharacterEcho, a.settings.live.Keyboard.WordEcho, a.settings.live.Keyboard.EchoOnCapsLock)
|
|
a.engine.Configure_indentation(a.settings.live.Reading.Indentation)
|
|
a.engine.Configure_attributes(a.settings.live.Reading.Attributes, a.settings.live.Reading.AttributeFormat)
|
|
}
|
|
|
|
func (a *application) open_settings() {
|
|
if a.saver.busy || a.speechPending {
|
|
a.ui.failed("Could not open Fenrir Menu: {0}", fmt.Errorf("settings save in progress"))
|
|
return
|
|
}
|
|
a.settings.live.Reading.Mode = a.engine.Tracking_mode()
|
|
a.ui.speechOverride = nil
|
|
if a.quick.selectionChanged {
|
|
selected := a.settings.live.Presentation.Speech
|
|
a.ui.speechOverride = &selected
|
|
}
|
|
if selected := a.overrideCurrent; selected != nil {
|
|
a.ui.open_application_settings(a.settings_to_save(), selected.path, selected.base, i18n.T(selected.adapter.Name()))
|
|
return
|
|
}
|
|
if a.quick.changed {
|
|
a.ui.open_settings(a.settings.live, a.options.ConfigPath, a.settings.live)
|
|
} else {
|
|
a.ui.open_settings(a.settings.live, a.options.ConfigPath)
|
|
}
|
|
}
|
|
|
|
func (a *application) save_settings() {
|
|
if a.saves_unavailable() {
|
|
a.voice.Say(i18n.T("Save unavailable."))
|
|
return
|
|
}
|
|
if a.speechPending {
|
|
a.voice.Say(i18n.T("Failed to save settings."))
|
|
return
|
|
}
|
|
if a.start_settings_save(a.settings_path(), a.settings_to_save()) {
|
|
a.saver.revision = a.settings.remoteRevision
|
|
}
|
|
}
|
|
|
|
// Menu writes share admission and completion with quick/remote saves. Only
|
|
// presentation belongs to the modal generation; a committed write belongs to
|
|
// the application even after Escape or a session handoff closes its form.
|
|
func (a *application) save_menu_settings(doc *config.Document, draft config.Settings, done func(error)) bool {
|
|
if a.saves_unavailable() {
|
|
a.voice.Say(i18n.T("Save unavailable."))
|
|
return false
|
|
}
|
|
if a.speechPending {
|
|
a.ui.failed("Could not save settings: {0}", fmt.Errorf("speech selection in progress"))
|
|
return false
|
|
}
|
|
previousDraft := a.settings.saveDraft
|
|
speechApplied := a.settings.speechAvailable && draft.Presentation.Speech.Driver == a.settings.live.Presentation.Speech.Driver && !same_speech_selection(draft.Presentation.Speech, a.settings.saveDraft.Presentation.Speech)
|
|
if !a.saver.start(a.ctx, draft, func() error {
|
|
var prepared speech.Transport
|
|
if speechApplied {
|
|
var err error
|
|
prepared, err = a.prepareSpeech(draft.Presentation.Speech)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := doc.Save(draft); err != nil {
|
|
if prepared != nil {
|
|
prepared.Close()
|
|
}
|
|
return err
|
|
}
|
|
if prepared != nil {
|
|
if err := a.replace_speech(prepared); err != nil {
|
|
return &savedSpeechError{err: err}
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}) {
|
|
a.ui.failed("Could not save settings: {0}", fmt.Errorf("settings save in progress"))
|
|
return false
|
|
}
|
|
a.saver.revision = a.settings.remoteRevision
|
|
quickRevision := a.quick.revision
|
|
a.saver.menuDone = func(err error) {
|
|
if settings_committed(err) {
|
|
merged := draft
|
|
// settings_save_result already removed overrides included in the
|
|
// commit. Remaining remote fields were assigned after admission.
|
|
copy_remote_settings(&merged, a.settings.saveDraft, a.settings.remoteFields)
|
|
newerQuick := false
|
|
for _, definition := range config.Definitions() {
|
|
if a.quick.fields[definition.Path] > quickRevision {
|
|
if err := definition.Set(&merged, definition.Value(a.settings.live)); err != nil {
|
|
panic(err)
|
|
}
|
|
newerQuick = true
|
|
}
|
|
}
|
|
// Progress is shortcut/file-only and has no editable definition.
|
|
if a.quick.fields["presentation.progress.enabled"] > quickRevision {
|
|
merged.Presentation.Progress = a.settings.live.Presentation.Progress
|
|
}
|
|
if a.overrideCurrent != nil {
|
|
live := config.Apply_application_settings(a.effective_settings(), merged)
|
|
if merged.Presentation.Speech.Enabled == previousDraft.Presentation.Speech.Enabled {
|
|
live.Presentation.Speech.Enabled = !a.muted
|
|
}
|
|
if merged.Reading.Automatic == previousDraft.Reading.Automatic {
|
|
live.Reading.Automatic = a.engine.Automatic_reading()
|
|
}
|
|
if !speechApplied || err != nil {
|
|
copy_speech_selection(&live.Presentation.Speech, a.settings.live.Presentation.Speech)
|
|
}
|
|
a.apply_remote_state(live, false)
|
|
a.configure_reader()
|
|
a.settings.saveDraft = merged
|
|
a.quick.selectionChanged = false
|
|
} else {
|
|
a.apply_saved_settings(merged, speechApplied && err == nil)
|
|
}
|
|
a.settings.resetBase = draft // RESET reflects disk, not later overrides.
|
|
a.quick.changed = newerQuick
|
|
}
|
|
done(err)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (a *application) settings_to_save() config.Settings {
|
|
saved := a.settings.saveDraft
|
|
copy_quick_settings(&saved, a.settings.live)
|
|
if a.quick.selectionChanged {
|
|
copy_speech_selection(&saved.Presentation.Speech, a.settings.live.Presentation.Speech)
|
|
}
|
|
return saved
|
|
}
|
|
|
|
func (a *application) effective_settings() config.Settings {
|
|
base := a.settings.live
|
|
base.Presentation.Speech.Enabled = !a.muted
|
|
base.Reading.Automatic = a.engine.Automatic_reading()
|
|
base.Reading.Mode = a.engine.Tracking_mode()
|
|
return base
|
|
}
|
|
|
|
// Remote assignments are validated against effective session state, then
|
|
// separately overlaid on the save draft so unrelated next-launch choices survive.
|
|
func (a *application) apply_remote_settings(text string) string {
|
|
if err := a.check_application_remote(text); err != nil {
|
|
return err.Error()
|
|
}
|
|
updated, err := remote_settings(a.effective_settings(), text)
|
|
if err != nil {
|
|
return err.Error()
|
|
}
|
|
if updated.Presentation.Speech.Enabled && !a.settings.speechAvailable {
|
|
return "Speech was disabled at startup; enable it in settings and relaunch"
|
|
}
|
|
// Apply only the requested assignments to the persistence
|
|
// draft, retaining unrelated next-launch menu settings.
|
|
draftBase := a.settings.saveDraft
|
|
_, parameters, _ := strings.Cut(text, " ")
|
|
for _, assignment := range remote_assignments(parameters) {
|
|
path, _, _ := strings.Cut(assignment, "=")
|
|
switch strings.ToLower(strings.TrimSpace(path)) {
|
|
case "reading#mode", "focus#cursor", "focus#highlight":
|
|
// Resolve paired flags against the running mode, even if the
|
|
// next-launch draft started with a different mode.
|
|
draftBase.Reading.Mode = a.engine.Tracking_mode()
|
|
}
|
|
}
|
|
persisted, err := remote_settings(draftBase, text)
|
|
if err != nil {
|
|
return err.Error()
|
|
}
|
|
a.settings.saveDraft = persisted
|
|
if !same_speech_selection(updated.Presentation.Speech, a.settings.live.Presentation.Speech) {
|
|
a.quick.selectionChanged = false
|
|
}
|
|
a.settings.remoteRevision++
|
|
restoreSpeech := false
|
|
for _, assignment := range remote_assignments(parameters) {
|
|
path, _, _ := strings.Cut(assignment, "=")
|
|
path = strings.ToLower(strings.TrimSpace(path))
|
|
a.settings.remoteFields[path] = a.settings.remoteRevision
|
|
if path == "speech#enabled" {
|
|
restoreSpeech = updated.Presentation.Speech.Enabled
|
|
}
|
|
}
|
|
a.apply_remote_state(updated, restoreSpeech)
|
|
return ""
|
|
}
|
|
|
|
func (a *application) apply_remote_state(updated config.Settings, restoreSpeech bool) {
|
|
base := a.effective_settings()
|
|
speechChanged := updated.Presentation.Speech.Enabled != !a.muted
|
|
restored := restoreSpeech && updated.Presentation.Speech.Enabled && a.temporarySilence
|
|
if restored {
|
|
a.temporarySilence = false
|
|
}
|
|
if speechChanged {
|
|
a.muted = a.voice.Toggle_mute()
|
|
}
|
|
a.settings.live = updated
|
|
a.configure_screen()
|
|
if speech_parameters(base) != speech_parameters(updated) {
|
|
a.voice.Configure(speech_parameters(updated))
|
|
}
|
|
a.voice.punctuationLevel = updated.Reading.PunctuationLevel
|
|
if base.Presentation.Sound.Volume != updated.Presentation.Sound.Volume && a.cues != nil {
|
|
a.cues.Set_volume(updated.Presentation.Sound.Volume)
|
|
}
|
|
a.engine.Configure(a.settings.live.Reading.Automatic, a.settings.live.Reading.CursorEcho)
|
|
a.engine.Configure_progress(a.settings.live.Presentation.Progress.Enabled)
|
|
if !a.settings.live.Presentation.Sound.Enabled && a.cues != nil {
|
|
a.cues.Clear_progress()
|
|
a.engine.Reset_progress()
|
|
}
|
|
a.engine.Configure_echo(a.settings.live.Keyboard.CharacterEcho, a.settings.live.Keyboard.WordEcho, a.settings.live.Keyboard.EchoOnCapsLock)
|
|
a.engine.Configure_tracking(a.settings.live.Reading.Mode)
|
|
if base.Reading.Indentation != updated.Reading.Indentation {
|
|
a.engine.Configure_indentation(updated.Reading.Indentation)
|
|
}
|
|
if base.Reading.Attributes != updated.Reading.Attributes || base.Reading.AttributeFormat != updated.Reading.AttributeFormat {
|
|
a.engine.Configure_attributes(updated.Reading.Attributes, updated.Reading.AttributeFormat)
|
|
}
|
|
if speechChanged || restored {
|
|
a.present_speech_state()
|
|
}
|
|
}
|