Initial support for application specific overrides.

This commit is contained in:
Storm Dragon committed 2026-10-28 03:31:33 -04:00
1 parent 901fd58ba8
commit b45f800489
37 files changed
+3909 -475

No files matched your search

+262
View File
@@ -0,0 +1,262 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"maps"
"slices"
"git.stormux.org/storm/fenrir/internal/applicationoverrides"
"git.stormux.org/storm/fenrir/internal/applicationoverrides/networkmanager"
"git.stormux.org/storm/fenrir/internal/applicationoverrides/ninjam"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/interaction"
"git.stormux.org/storm/fenrir/internal/prompt"
"git.stormux.org/storm/fenrir/internal/reader"
"git.stormux.org/storm/fenrir/internal/screen"
)
type overrideSnapshot struct {
settings applicationSettings
quick quickSettings
runtime reader.SessionState
muted, silence bool
}
type applicationOverride struct {
id string
terminal int
adapter applicationoverrides.Adapter
underlying, temporary overrideSnapshot
shell prompt.ShellDetector
initialized, recognized, exitPending bool
}
func (a *application) saves_unavailable() bool { return len(a.overrides) != 0 || a.overrideTransition }
func (a *application) capture_override(terminal int) overrideSnapshot {
settings := a.settings
settings.remoteFields = maps.Clone(settings.remoteFields)
quick := *a.quick
quick.fields = maps.Clone(quick.fields)
quick.categories = slices.Clone(quick.categories)
return overrideSnapshot{settings, quick, a.engine.Capture_terminal_session(terminal), a.muted, a.temporarySilence}
}
func (a *application) install_override(saved overrideSnapshot, terminal int) {
live := saved.settings.live
live.Presentation.Speech.Enabled = !saved.muted
a.apply_remote_state(live, false)
a.settings = saved.settings
*a.quick = saved.quick
a.muted, a.temporarySilence = saved.muted, saved.silence
a.configure_reader()
a.engine.Restore_session(saved.runtime, terminal)
a.spellSession++
a.reading_regions_changed()
}
// A synthesizer selection can require asynchronous restoration. Until it
// completes, no new context is installed and all settings mutations are gated.
func (a *application) restore_override(done func()) bool {
current := a.overrideCurrent
if current == nil {
current = a.overrideFailed
}
if current == nil {
done()
return true
}
if a.speechPending || a.overrideTransition || a.overrideRestoreFailed {
return false
}
// A form opened under the application must never outlive that context:
// its previews and save draft belong to the old effective configuration.
a.ui.close()
if a.overrideCurrent != nil {
current.temporary = a.capture_override(current.terminal)
}
restore := func() {
a.install_override(current.underlying, current.terminal)
a.overrideCurrent = nil
a.overrideFailed = nil
current.adapter.Reset()
done()
}
if same_speech_selection(a.settings.live.Presentation.Speech, current.underlying.settings.live.Presentation.Speech) {
restore()
return true
}
a.overrideTransition = true
a.change_speech(current.underlying.settings.live.Presentation.Speech, func(err error) {
a.overrideTransition = false
if err != nil {
a.overrideRestoreFailed = true
// Restore ordinary reading even if the old synthesizer cannot be
// prepared. Keep its actual current selection and retain the save
// guard until an explicit Default/selection retries restoration.
fallback := current.underlying
copy_speech_selection(&fallback.settings.live.Presentation.Speech, a.settings.live.Presentation.Speech)
a.install_override(fallback, current.terminal)
a.overrideCurrent, a.overrideFailed = nil, current
current.adapter.Reset()
a.voice.Say(i18n.Format("Could not restore application settings: {0}", err.Error()))
return
}
restore()
})
return false
}
func (a *application) select_override(id string) {
if a.saver.busy || a.speechPending || a.overrideTransition {
a.voice.Say(i18n.T("Settings are busy"))
return
}
terminal := a.lastTerminal
if terminal == 0 || !a.lastActive {
return
}
a.overrideRestoreFailed = false // An explicit selection retries restoration.
a.restore_override(func() {
a.overrideDirty = true
if old := a.overrides[terminal]; old != nil {
old.adapter.Close()
delete(a.overrides, terminal)
}
if id == "default" {
a.voice.Say(i18n.T("Default settings restored"))
return
}
var adapter applicationoverrides.Adapter
switch id {
case "ninjam":
adapter = &ninjam.Adapter{}
case "networkmanager":
adapter = &networkmanager.Adapter{}
default:
return
}
if a.overrides == nil {
a.overrides = make(map[int]*applicationOverride)
}
selected := &applicationOverride{id: id, terminal: terminal, adapter: adapter}
selected.shell.Observe(a.lastSnapshot)
a.overrides[terminal] = selected
a.voice.Say(i18n.Format("{0} application override selected", i18n.T(adapter.Name())))
})
}
// Called before reader.Update so exit detection and restored settings affect
// the first shell snapshot, rather than interpreting it with the old adapter.
func (a *application) observe_override(next screen.Snapshot) (applicationoverrides.Frame, bool) {
if a.overrideTransition {
return applicationoverrides.Frame{}, false
}
if a.overrideRestoreFailed {
return applicationoverrides.Frame{}, true
}
selected := a.overrides[next.Terminal]
if a.overrideCurrent != nil && (a.overrideCurrent != selected || !next.Active) {
if !a.restore_override(func() {}) {
return applicationoverrides.Frame{}, false
}
}
if selected == nil || !next.Active {
return applicationoverrides.Frame{}, true
}
frame := selected.adapter.Observe(next)
freshShell := selected.shell.Observe(next)
if !frame.Recognized {
selected.recognized = false
selected.exitPending = selected.exitPending || freshShell
if a.overrideCurrent == selected {
if !a.restore_override(func() {}) {
return applicationoverrides.Frame{}, false
}
}
if selected.exitPending {
selected.adapter.Close()
delete(a.overrides, next.Terminal)
a.voice.Say(i18n.T("Default settings restored"))
}
return applicationoverrides.Frame{}, true
}
if a.overrideCurrent != selected {
if a.speechPending {
return applicationoverrides.Frame{}, false
}
a.ui.close()
selected.underlying = a.capture_override(next.Terminal)
if !selected.initialized {
settings := a.effective_settings()
selected.adapter.Configure(&settings)
a.apply_remote_state(settings, false)
a.configure_reader()
a.engine.Clear_session_regions(next.Terminal)
selected.initialized = true
} else {
// Numeric/runtime settings are synchronous. Speech selection is
// restored separately before resuming a suspended adapter.
if !same_speech_selection(a.settings.live.Presentation.Speech, selected.temporary.settings.live.Presentation.Speech) {
a.overrideTransition = true
a.change_speech(selected.temporary.settings.live.Presentation.Speech, func(err error) {
a.overrideTransition = false
if err != nil {
a.overrideRestoreFailed = true
a.voice.Say(i18n.Format("Could not restore application settings: {0}", err.Error()))
return
}
a.install_override(selected.temporary, next.Terminal)
selected.adapter.Reset()
a.overrideCurrent = selected
})
return applicationoverrides.Frame{}, false
}
a.install_override(selected.temporary, next.Terminal)
}
a.overrideCurrent = selected
}
selected.recognized = true
return frame, true
}
func (a *application) open_applications() {
if a.saver.busy || a.speechPending || a.overrideTransition {
a.voice.Say(i18n.T("Settings are busy"))
return
}
items := []string{i18n.T("Default"), i18n.T("NetworkManager"), i18n.T("Ninjam")}
ids := []string{"default", "networkmanager", "ninjam"}
position := 0
if selected := a.overrides[a.lastTerminal]; selected != nil {
position = slices.Index(ids, selected.id)
}
session, err := interaction.New_session(interaction.Form{Type: interaction.TabForm, Title: "Applications", Tabs: []interaction.Tab{{ID: "applications", Label: "Applications", Controls: []interaction.Control{
{ID: "application", Label: "Application", Kind: interaction.List, Items: items, Selected: position, Action: "activate"},
{ID: "cancel", Label: "Cancel", Kind: interaction.Button, Action: "cancel"},
}}}})
if err != nil {
a.ui.failed("Could not open Applications: {0}", err)
return
}
if _, err := a.ui.begin("Applications"); err != nil {
a.ui.failed("Could not open Applications: {0}", err)
return
}
session.Handle(interaction.Key{Name: "tab"}) // Start on the list, not its tab strip.
a.ui.session = session
a.ui.action = func(action string, session *interaction.Session) {
if action == "cancel" {
a.ui.close()
return
}
if action == "activate" {
selected := session.Values()["applications.application"].Selected
a.ui.close()
a.select_override(ids[selected])
}
}
a.voice.Say(session.Open())
}