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