Initial support for application specific overrides.
This commit is contained in:
1 parent
901fd58ba8
commit
b45f800489
37 files changed
+3909
-475
No files matched your search
@@ -28,6 +28,7 @@ import (
|
||||
"git.stormux.org/storm/fenrir/internal/presentation"
|
||||
"git.stormux.org/storm/fenrir/internal/prompt"
|
||||
"git.stormux.org/storm/fenrir/internal/reader"
|
||||
"git.stormux.org/storm/fenrir/internal/screen"
|
||||
"git.stormux.org/storm/fenrir/internal/speech"
|
||||
"git.stormux.org/storm/fenrir/internal/terminal"
|
||||
)
|
||||
@@ -60,11 +61,16 @@ type application struct {
|
||||
|
||||
scriptGeneration, spellSession, spellFeedback uint64
|
||||
|
||||
lastTerminal int
|
||||
lastActive bool
|
||||
lastGeneration uint64
|
||||
lastRows, lastCols int
|
||||
lastNumLock bool
|
||||
lastTerminal int
|
||||
lastActive bool
|
||||
lastGeneration uint64
|
||||
lastRows, lastCols int
|
||||
lastNumLock bool
|
||||
lastSnapshot screen.Snapshot
|
||||
overrides map[int]*applicationOverride
|
||||
overrideCurrent *applicationOverride
|
||||
overrideFailed *applicationOverride
|
||||
overrideTransition, overrideRestoreFailed, overrideDirty bool
|
||||
}
|
||||
|
||||
func new_application(ctx context.Context, source Source, keyboards Keyboards, voice Speaker, options RunOptions) (*application, error) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/command"
|
||||
"git.stormux.org/storm/fenrir/internal/config"
|
||||
"git.stormux.org/storm/fenrir/internal/input"
|
||||
"git.stormux.org/storm/fenrir/internal/interaction"
|
||||
"git.stormux.org/storm/fenrir/internal/screen"
|
||||
"git.stormux.org/storm/fenrir/internal/speech"
|
||||
)
|
||||
|
||||
func override_screen(terminal int, kind string) screen.Snapshot {
|
||||
s := screen.Snapshot{Terminal: terminal, Active: true, Rows: 24, Cols: 100, Y: 23, Cells: make([]rune, 2400), Attributes: make([]screen.Attribute, 2400)}
|
||||
for index := range s.Cells {
|
||||
s.Cells[index] = ' '
|
||||
}
|
||||
switch kind {
|
||||
case "ninjam":
|
||||
copy(s.Line(0), []rune("LOCAL"))
|
||||
copy(s.Line(1), []rune(" master: [ ]mute [0.0dB center] | metronome: [ ]mute [0.0dB center]"))
|
||||
copy(s.Line(16), []rune("CHAT"))
|
||||
copy(s.Line(23), []rune("[QUIT NINJAM] : fixture.invalid : 120.0BPM 16BPI"))
|
||||
s.Attributes[111] = screen.Attribute{Foreground: screen.ColorIndexed, Background: screen.ColorIndexed | 7}
|
||||
case "networkmanager":
|
||||
copy(s.Line(2), []rune("┌──────── NetworkManager TUI ─────────┐"))
|
||||
default:
|
||||
copy(s.Line(23), []rune(kind))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOverrideRestoresSynthesizerAndKeepsSaveBlockedUntilCompletion(t *testing.T) {
|
||||
for _, failed := range []bool{false, true} {
|
||||
a, voice, _ := selection_application(t, true)
|
||||
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return &preparedVoice{}, nil }
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.select_override("ninjam")
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
baseline := a.settings.saveDraft
|
||||
if response := request_selection(t, a, "SET speech#voice=Temporary;speech#module=fixture"); response != "" {
|
||||
t.Fatal(response)
|
||||
}
|
||||
if !reflect.DeepEqual(a.settings.saveDraft, baseline) {
|
||||
t.Fatal("temporary voice modified draft")
|
||||
}
|
||||
if failed {
|
||||
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return nil, errors.New("fixture unavailable") }
|
||||
}
|
||||
a.select_override("default")
|
||||
if !a.speechPending || !a.saves_unavailable() {
|
||||
t.Fatal("restoration did not retain save guard")
|
||||
}
|
||||
a.handle_command(command.NextQuickValue)
|
||||
if voice.messages[len(voice.messages)-1] != "Settings are busy" {
|
||||
t.Fatal("settings changed during restoration")
|
||||
}
|
||||
finish_selection(t, a)
|
||||
if failed {
|
||||
if !a.saves_unavailable() || !a.overrideRestoreFailed {
|
||||
t.Fatal("failed restoration released save guard")
|
||||
}
|
||||
override_read(t, a, override_screen(1, "shell output remains readable"))
|
||||
if a.overrideCurrent != nil || !strings.Contains(voice.messages[len(voice.messages)-1], "shell output remains readable") {
|
||||
t.Fatal("failed speech restoration suppressed ordinary reading", voice.messages)
|
||||
}
|
||||
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return &preparedVoice{}, nil }
|
||||
a.select_override("default")
|
||||
finish_selection(t, a)
|
||||
}
|
||||
if a.saves_unavailable() || !same_speech_selection(a.settings.live.Presentation.Speech, baseline.Presentation.Speech) {
|
||||
t.Fatal("speech selection not restored")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitWaitsForPendingSpeechChangeAndRestoresBeforeShellOutput(t *testing.T) {
|
||||
a, _, _ := selection_application(t, true)
|
||||
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return &preparedVoice{}, nil }
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.select_override("ninjam")
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
if response := request_selection(t, a, "SET speech#voice=Temporary;speech#module=fixture"); response != "" {
|
||||
t.Fatal(response)
|
||||
}
|
||||
override_read(t, a, override_screen(1, "[Username@host directory] $"))
|
||||
if !a.speechPending || a.overrides[1] == nil {
|
||||
t.Fatal("exit did not wait for restoration")
|
||||
}
|
||||
finish_selection(t, a)
|
||||
override_read(t, a, override_screen(1, "[Username@host directory] $"))
|
||||
if a.overrides[1] != nil || a.overrideCurrent != nil || a.engine.Tracking_mode() != "cursor" {
|
||||
t.Fatal("async exit missed")
|
||||
}
|
||||
}
|
||||
|
||||
func override_read(t *testing.T, a *application, snapshot screen.Snapshot) {
|
||||
t.Helper()
|
||||
a.source.(*fakeSource).snapshot = snapshot
|
||||
if err := a.read_screen(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationsMenuActivatesOnlyOnEnterAndReleasesCapture(t *testing.T) {
|
||||
a, voice := persistence_application(t, true)
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.open_applications()
|
||||
if !a.ui.active || !strings.Contains(voice.messages[len(voice.messages)-1], "Default") {
|
||||
t.Fatal(voice.messages)
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "down"})
|
||||
a.ui.handle(interaction.Key{Name: "down"})
|
||||
if len(a.overrides) != 0 {
|
||||
t.Fatal("navigation activated override")
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "escape"})
|
||||
if len(a.overrides) != 0 || a.ui.active {
|
||||
t.Fatal("cancel activated override")
|
||||
}
|
||||
a.open_applications()
|
||||
a.ui.handle(interaction.Key{Name: "down"})
|
||||
a.ui.handle(interaction.Key{Name: "down"})
|
||||
a.ui.handle(interaction.Key{Name: "enter"})
|
||||
if a.ui.active || a.overrides[1] == nil || a.overrides[1].id != "ninjam" {
|
||||
t.Fatal("activation failed")
|
||||
}
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
if a.engine.Tracking_mode() != "highlight" {
|
||||
t.Fatal("settings not applied")
|
||||
}
|
||||
a.open_applications()
|
||||
if a.ui.session.Values()["applications.application"].Selected != 2 {
|
||||
t.Fatal("active item not selected")
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "tab"})
|
||||
a.ui.handle(interaction.Key{Name: "enter"})
|
||||
if a.ui.active || a.overrides[1] == nil {
|
||||
t.Fatal("Cancel button changed selection")
|
||||
}
|
||||
a.open_applications()
|
||||
a.ui.handle(interaction.Key{Name: "down"}) // list wraps to Default
|
||||
a.ui.handle(interaction.Key{Name: "enter"})
|
||||
if a.saves_unavailable() || a.engine.Tracking_mode() != "cursor" {
|
||||
t.Fatal("default failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverrideCannotSaveOrContaminateDraftAndRestoresSession(t *testing.T) {
|
||||
a, voice := persistence_application(t, true)
|
||||
a.handle_command(command.NextQuickValue) // Preserve a pre-existing unsaved adjustment.
|
||||
a.handle_command(command.AutoRead)
|
||||
a.handle_command(command.ToggleAttributes)
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
baseSettings, baseQuick, baseRuntime := a.settings, a.quick.revision, a.engine.Capture_session()
|
||||
before, err := os.ReadFile(a.options.ConfigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.select_override("ninjam")
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
for _, id := range []command.ID{command.NextQuickValue, command.ProgressMonitor, command.ToggleTracking, command.AutoRead, command.ToggleAttributes} {
|
||||
a.handle_command(id)
|
||||
}
|
||||
if response := a.apply_remote_settings("SET speech#rate=0.77;keyboard#word_echo=True"); response != "" {
|
||||
t.Fatal(response)
|
||||
}
|
||||
if !reflect.DeepEqual(a.settings.saveDraft, baseSettings.saveDraft) {
|
||||
t.Fatal("override modified save draft")
|
||||
}
|
||||
a.save_settings()
|
||||
if a.saver.busy || voice.messages[len(voice.messages)-1] != "Save unavailable." {
|
||||
t.Fatal("shortcut save admitted")
|
||||
}
|
||||
for _, text := range []string{"SETTING SAVE", "SETTING SAVEAS " + a.options.ConfigPath} {
|
||||
if got := runtime_request(a, text); got != "Save unavailable." {
|
||||
t.Fatal(text, got)
|
||||
}
|
||||
}
|
||||
a.open_settings()
|
||||
receive_ui(t, a.ui)
|
||||
for range 100 {
|
||||
if control, ok := a.ui.session.Focused_control(); ok && control.Action == "save" {
|
||||
break
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "tab"})
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "enter"})
|
||||
if !a.ui.active || a.saver.busy || voice.messages[len(voice.messages)-1] != "Save unavailable." {
|
||||
t.Fatal("menu save admitted", voice.messages)
|
||||
}
|
||||
a.ui.close()
|
||||
a.select_override("default")
|
||||
if !reflect.DeepEqual(a.settings, baseSettings) || a.quick.revision != baseQuick || !reflect.DeepEqual(a.engine.Capture_session(), baseRuntime) {
|
||||
t.Fatal("pre-override session was not restored")
|
||||
}
|
||||
after, _ := os.ReadFile(a.options.ConfigPath)
|
||||
if !slices.Equal(before, after) {
|
||||
t.Fatal("configuration changed")
|
||||
}
|
||||
a.save_settings()
|
||||
finish_persistence(t, a, false)
|
||||
saved, _, err := config.Load(a.options.ConfigPath, nil)
|
||||
if err != nil || saved.Presentation.Speech.Rate != baseSettings.live.Presentation.Speech.Rate || saved.Keyboard.WordEcho {
|
||||
t.Fatal("override leaked after clearing", saved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverridesSuspendPerTerminalAndClearOnlyOnFreshShell(t *testing.T) {
|
||||
a, _ := persistence_application(t, false)
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.select_override("ninjam")
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.handle_command(command.NextQuickValue)
|
||||
appRate := a.settings.live.Presentation.Speech.Rate
|
||||
override_read(t, a, override_screen(2, "other terminal"))
|
||||
if a.overrideCurrent != nil || a.engine.Tracking_mode() != "cursor" || a.settings.live.Presentation.Speech.Rate == appRate {
|
||||
t.Fatal("override leaked across VT")
|
||||
}
|
||||
override_read(t, a, override_screen(2, "networkmanager"))
|
||||
a.select_override("networkmanager")
|
||||
override_read(t, a, override_screen(2, "networkmanager"))
|
||||
if a.overrideCurrent == nil || a.overrideCurrent.id != "networkmanager" {
|
||||
t.Fatal("second terminal activation")
|
||||
}
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
if a.overrideCurrent == nil || a.overrideCurrent.id != "ninjam" || a.settings.live.Presentation.Speech.Rate != appRate {
|
||||
t.Fatal("resume lost temporary state")
|
||||
}
|
||||
for _, text := range []string{"Password:", "Confirm? [y/n]", "unrecognized screen", "<Username> $"} {
|
||||
override_read(t, a, override_screen(1, text))
|
||||
if a.overrides[1] == nil || a.overrideCurrent != nil || a.engine.Tracking_mode() != "cursor" {
|
||||
t.Fatal("bad suspension", text)
|
||||
}
|
||||
}
|
||||
s := override_screen(1, "[Username@host directory] $")
|
||||
s.Generation = 2 // Returning from an alternate buffer must still detect exit.
|
||||
override_read(t, a, s)
|
||||
if a.overrides[1] != nil || a.overrides[2] == nil {
|
||||
t.Fatal("exit affected wrong terminal")
|
||||
}
|
||||
a.select_override("default")
|
||||
if a.overrides[2] == nil {
|
||||
t.Fatal("Default cleared another terminal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverrideRestoresUserRegionsAndPreservesRawReview(t *testing.T) {
|
||||
a, voice := persistence_application(t, true)
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.engine.Command(command.CurrentLine)
|
||||
a.engine.Set_mark()
|
||||
a.engine.Command(command.SetLiveRegion)
|
||||
before := a.engine.Command(command.ReadLiveRegion1).Text
|
||||
a.select_override("ninjam")
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.handle_command(command.ReadLiveRegion1)
|
||||
if voice.messages[len(voice.messages)-1] != "No chat message received" {
|
||||
t.Fatal(voice.messages)
|
||||
}
|
||||
a.handle_command(command.ReadScreen)
|
||||
if !strings.Contains(voice.messages[len(voice.messages)-1], "[ ]mute") {
|
||||
t.Fatal("raw screen replaced", voice.messages)
|
||||
}
|
||||
a.select_override("default")
|
||||
if after := a.engine.Command(command.ReadLiveRegion1).Text; after != before {
|
||||
t.Fatal("user region lost", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationExitInvalidatesSettingsFormAndItsPreviews(t *testing.T) {
|
||||
a, _ := persistence_application(t, true)
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
baseline := a.settings
|
||||
a.select_override("ninjam")
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.open_settings()
|
||||
receive_ui(t, a.ui)
|
||||
generation := a.ui.generation
|
||||
reading := a.settings.live.Reading
|
||||
reading.Spellcheck = !reading.Spellcheck
|
||||
a.preview_spelling(reading)
|
||||
override_read(t, a, override_screen(1, "[Username@host directory] $"))
|
||||
if a.ui.active || !reflect.DeepEqual(a.settings, baseline) {
|
||||
t.Fatal("old form or preview survived exit")
|
||||
}
|
||||
a.ui.update(uiUpdate{generation: generation, apply: func() { t.Fatal("stale form callback applied") }})
|
||||
a.save_settings()
|
||||
finish_persistence(t, a, false)
|
||||
saved, _, err := config.Load(a.options.ConfigPath, nil)
|
||||
if err != nil || saved.Reading.Mode != baseline.saveDraft.Reading.Mode || saved.Reading.Spellcheck != baseline.saveDraft.Reading.Spellcheck {
|
||||
t.Fatal("old application form contaminated subsequent save", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivationRejectsPendingOperationsAndRestartStartsWithoutOverride(t *testing.T) {
|
||||
a, _ := persistence_application(t, true)
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.saver.busy = true
|
||||
a.select_override("ninjam")
|
||||
if len(a.overrides) != 0 {
|
||||
t.Fatal("activated during save")
|
||||
}
|
||||
a.saver.busy, a.speechPending = false, true
|
||||
a.select_override("ninjam")
|
||||
if len(a.overrides) != 0 {
|
||||
t.Fatal("activated during speech selection")
|
||||
}
|
||||
a.speechPending = false
|
||||
a.select_override("ninjam")
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
saved, _, err := config.Load(a.options.ConfigPath, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh, err := new_application(a.ctx, &fakeSource{}, &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}, &fakeVoice{}, RunOptions{Settings: saved})
|
||||
if err != nil || fresh.saves_unavailable() || fresh.engine.Tracking_mode() != "cursor" {
|
||||
t.Fatal("restart retained override", err)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ func (a *application) handle_input(decision input.Decision) {
|
||||
"interrupt", decision.Interrupt, "keypress", decision.Keypress, "typing", decision.Typing, "deletion", decision.Deletion,
|
||||
"ui_active", a.ui.active)
|
||||
if decision.Interrupt {
|
||||
if decision.Forward && !a.ui.active && a.overrideCurrent != nil {
|
||||
if observer, ok := a.overrideCurrent.adapter.(interface{ Note_key(uint16) }); ok {
|
||||
observer.Note_key(decision.Code)
|
||||
}
|
||||
}
|
||||
a.spellFeedback++
|
||||
a.engine.Reset_progress()
|
||||
if a.cues != nil {
|
||||
@@ -75,6 +80,16 @@ func (a *application) handle_input(decision input.Decision) {
|
||||
}
|
||||
|
||||
func (a *application) handle_command(id command.ID) {
|
||||
if a.overrideTransition && id != "" && id != command.Quit && id != command.Mute {
|
||||
a.voice.Say(i18n.T("Settings are busy"))
|
||||
return
|
||||
}
|
||||
if selected := a.overrideCurrent; selected != nil && selected.recognized {
|
||||
if result, handled := selected.adapter.Command(id); handled {
|
||||
a.present(result.Text, result.Cue)
|
||||
return
|
||||
}
|
||||
}
|
||||
switch id {
|
||||
case "":
|
||||
case command.Help:
|
||||
@@ -99,12 +114,16 @@ func (a *application) handle_command(id command.ID) {
|
||||
a.spellMenu.open(a.engine.Spellcheck_word())
|
||||
case command.FenrirMenu:
|
||||
a.open_settings()
|
||||
case command.ApplicationsMenu:
|
||||
a.open_applications()
|
||||
case command.SaveSettings:
|
||||
a.save_settings()
|
||||
case command.ProgressMonitor:
|
||||
a.quick.mark_changed("presentation.progress.enabled")
|
||||
a.settings.live.Presentation.Progress.Enabled = !a.settings.live.Presentation.Progress.Enabled
|
||||
a.settings.saveDraft.Presentation.Progress = a.settings.live.Presentation.Progress
|
||||
if a.overrideCurrent == nil {
|
||||
a.settings.saveDraft.Presentation.Progress = a.settings.live.Presentation.Progress
|
||||
}
|
||||
a.engine.Configure_progress(a.settings.live.Presentation.Progress.Enabled)
|
||||
if a.settings.live.Presentation.Progress.Enabled {
|
||||
a.voice.Say(i18n.T("Progress monitoring enabled"))
|
||||
@@ -157,7 +176,9 @@ func (a *application) review_command(id command.ID) {
|
||||
}
|
||||
switch id {
|
||||
case command.ToggleTracking:
|
||||
a.settings.saveDraft.Reading.Mode = a.engine.Tracking_mode()
|
||||
if a.overrideCurrent == nil {
|
||||
a.settings.saveDraft.Reading.Mode = a.engine.Tracking_mode()
|
||||
}
|
||||
}
|
||||
if spelling := result.Spelling; spelling != nil && !a.temporarySilence {
|
||||
a.voice.say_spelling(*spelling)
|
||||
|
||||
@@ -57,6 +57,11 @@ func run(ctx context.Context, source Source, keyboards Keyboards, voices Speaker
|
||||
return err
|
||||
}
|
||||
defer func() { a.ui.close(); a.ui.testWorkers.Wait() }()
|
||||
defer func() {
|
||||
for _, selected := range a.overrides {
|
||||
selected.adapter.Close()
|
||||
}
|
||||
}()
|
||||
return a.loop(ticks)
|
||||
}
|
||||
|
||||
@@ -111,6 +116,9 @@ func (a *application) loop(ticks <-chan time.Time) error {
|
||||
a.quick.discovery = discovery
|
||||
case result := <-a.speechResults:
|
||||
a.speech_result(result)
|
||||
if len(a.overrides) != 0 || a.overrideCurrent != nil {
|
||||
a.overrideDirty = true
|
||||
}
|
||||
case result := <-spellResults:
|
||||
if err := a.poll_input(false); err != nil {
|
||||
return err
|
||||
@@ -175,6 +183,12 @@ func (a *application) loop(ticks <-chan time.Time) error {
|
||||
return err
|
||||
}
|
||||
case <-ticks:
|
||||
if a.overrideDirty {
|
||||
a.overrideDirty = false
|
||||
if err := a.read_screen(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := a.poll_input(false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ import (
|
||||
func (a *application) remote_settings_request(text string, request control.Request) (string, bool) {
|
||||
verb, parameter, _ := strings.Cut(text, " ")
|
||||
verb, parameter = strings.ToUpper(verb), strings.TrimSpace(parameter)
|
||||
if a.saves_unavailable() && (verb == "SAVE" || verb == "SAVEAS") {
|
||||
return "Save unavailable.", false
|
||||
}
|
||||
if a.overrideTransition {
|
||||
return "Settings are busy; try again after the current operation", false
|
||||
}
|
||||
if a.speechPending {
|
||||
return "Settings are busy; try again after the current operation", false
|
||||
}
|
||||
@@ -143,6 +149,10 @@ func (a *application) reset_remote_settings() string {
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.overrideCurrent != nil {
|
||||
a.apply_remote_state(updated, false)
|
||||
return ""
|
||||
}
|
||||
a.settings.saveDraft = persisted
|
||||
_, speechOverridden := a.settings.remoteFields["speech#enabled"]
|
||||
a.apply_remote_state(updated, speechOverridden)
|
||||
|
||||
@@ -147,6 +147,10 @@ func (a *application) open_settings() {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -160,6 +164,10 @@ func (a *application) save_settings() {
|
||||
// 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
|
||||
@@ -248,6 +256,10 @@ func (a *application) apply_remote_settings(text string) string {
|
||||
if updated.Presentation.Speech.Enabled && !a.settings.speechAvailable {
|
||||
return "Speech was disabled at startup; enable it in settings and relaunch"
|
||||
}
|
||||
if a.overrideCurrent != nil {
|
||||
a.apply_remote_state(updated, false)
|
||||
return ""
|
||||
}
|
||||
// Apply only the requested assignments to the persistence
|
||||
// draft, retaining unrelated next-launch menu settings.
|
||||
draftBase := a.settings.saveDraft
|
||||
|
||||
@@ -58,8 +58,16 @@ func (a *application) read_screen() error {
|
||||
}
|
||||
}
|
||||
a.clipboards.terminal = snapshot.Terminal
|
||||
a.lastSnapshot = snapshot
|
||||
frame, ready := a.observe_override(snapshot)
|
||||
if !ready {
|
||||
a.lastTerminal, a.lastActive = snapshot.Terminal, snapshot.Active
|
||||
a.lastGeneration, a.lastRows, a.lastCols = snapshot.Generation, snapshot.Rows, snapshot.Cols
|
||||
a.lastNumLock = snapshot.NumLock
|
||||
return nil
|
||||
}
|
||||
updated := time.Now()
|
||||
output := a.engine.Update(snapshot)
|
||||
output := a.engine.Update_application(snapshot, frame)
|
||||
if a.options.Spellcheck != nil && a.settings.live.Reading.Spellcheck && !a.ui.active && !a.muted && !a.temporarySilence {
|
||||
for _, word := range a.engine.Completed_words() {
|
||||
a.options.Spellcheck.Submit(spellcheck.Request{Operation: spellcheck.Automatic, Word: word, Generation: a.spellSession, Revision: a.spellFeedback})
|
||||
|
||||
Reference in new issue
Block a user