Files
fenrir/internal/app/runtime_settings.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()
}
}