Attempt to make modifier key changes take affect when the dialogue closes. This means you don't have to restart Fenrir to apply them.

This commit is contained in:
Storm Dragon committed 2026-10-29 18:27:20 -04:00
1 parent 15e04aa8f8
commit 22f6df6af9
23 files changed
+454 -58

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ type Keyboards interface {
Scan() error
Pass_next()
Capture(bool) error
Update_modifiers([]string, []string) (bool, error)
Close() error
}
+13 -6
View File
@@ -475,12 +475,13 @@ func (f *fakeSource) Read() (screen.Snapshot, error) {
}
type fakeKeys struct {
poll func(bool, func(input.Decision)) error
closed, passed bool
events chan error
captures []bool
captureErr error
onCapture func(bool)
updateModifiers func([]string, []string) (bool, error)
poll func(bool, func(input.Decision)) error
closed, passed bool
events chan error
captures []bool
captureErr error
onCapture func(bool)
}
func (f *fakeKeys) Events() <-chan error { return f.events }
@@ -496,6 +497,12 @@ func (f *fakeKeys) Capture(enabled bool) error {
}
return f.captureErr
}
func (f *fakeKeys) Update_modifiers(reader, script []string) (bool, error) {
if f.updateModifiers != nil {
return f.updateModifiers(reader, script)
}
return true, nil
}
func (f *fakeKeys) Pass_next() { f.passed = true }
func (f *fakeKeys) Close() error { f.closed = true; return nil }
+86
View File
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/screen"
)
func TestSavedModifiersWaitForMenuCloseAndIdleKeys(t *testing.T) {
base := config.Defaults()
base.Keyboard.FenrirKeys = []string{"KEY_INSERT"}
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}}
voice := &fakeVoice{}
calls := 0
idle := false
keys := &fakeKeys{updateModifiers: func(reader, script []string) (bool, error) {
calls++
if !reflect.DeepEqual(reader, []string{"KEY_CAPSLOCK"}) || !reflect.DeepEqual(script, []string{"KEY_META"}) {
t.Fatal(reader, script)
}
return idle, nil
}}
a, err := new_application(context.Background(), source, keys, voice, RunOptions{Settings: base})
if err != nil {
t.Fatal(err)
}
saved := base
saved.Keyboard.FenrirKeys = []string{"KEY_CAPSLOCK"}
saved.General.ScriptKeys = []string{"KEY_META"}
a.ui.active = true
a.apply_saved_settings(saved, false)
a.apply_pending_modifiers()
if calls != 0 || a.settings.pendingModifiers == nil {
t.Fatal("changed during modal capture")
}
a.ui.active = false
a.apply_pending_modifiers()
if calls != 1 || !reflect.DeepEqual(a.settings.live.Keyboard.FenrirKeys, base.Keyboard.FenrirKeys) || !reflect.DeepEqual(a.settings.live.General.ScriptKeys, base.General.ScriptKeys) {
t.Fatal("changed while held", a.settings.live)
}
idle = true
a.apply_pending_modifiers()
if calls != 2 || a.settings.pendingModifiers != nil || !reflect.DeepEqual(a.settings.live.Keyboard.FenrirKeys, saved.Keyboard.FenrirKeys) || !reflect.DeepEqual(a.settings.live.General.ScriptKeys, saved.General.ScriptKeys) {
t.Fatal("new keys not applied", a.settings.live)
}
a.apply_pending_modifiers()
if calls != 2 {
t.Fatal("update repeated after success")
}
}
func TestModifierApplyFailureKeepsPreviousRuntimeAndAllowsRetry(t *testing.T) {
base := config.Defaults()
base.Keyboard.FenrirKeys = []string{"KEY_INSERT"}
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}}
voice := &fakeVoice{}
keys := &fakeKeys{updateModifiers: func([]string, []string) (bool, error) { return false, errors.New("fixture grab conflict") }}
a, err := new_application(context.Background(), source, keys, voice, RunOptions{Settings: base})
if err != nil {
t.Fatal(err)
}
saved := base
saved.Keyboard.FenrirKeys = []string{"KEY_META"}
a.apply_saved_settings(saved, false)
a.apply_pending_modifiers()
if a.settings.pendingModifiers != nil || !reflect.DeepEqual(a.settings.live.Keyboard.FenrirKeys, base.Keyboard.FenrirKeys) || !reflect.DeepEqual(a.settings.saveDraft.Keyboard.FenrirKeys, saved.Keyboard.FenrirKeys) {
t.Fatal(a.settings)
}
if len(voice.messages) != 1 || !strings.Contains(voice.messages[0], "Previous keys remain active") {
t.Fatal(voice.messages)
}
keys.updateModifiers = func([]string, []string) (bool, error) { return true, nil }
a.apply_saved_settings(saved, false)
a.apply_pending_modifiers()
if !reflect.DeepEqual(a.settings.live.Keyboard.FenrirKeys, saved.Keyboard.FenrirKeys) {
t.Fatal("retry failed")
}
}
+36 -6
View File
@@ -19,6 +19,7 @@ package app
import (
"fmt"
"slices"
"strings"
"git.stormux.org/storm/fenrir/internal/config"
@@ -26,16 +27,19 @@ import (
"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 {
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.
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.
@@ -77,6 +81,11 @@ func (a *application) apply_saved_settings(saved config.Settings, speechApplied
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
@@ -120,6 +129,27 @@ func (a *application) apply_saved_settings(saved config.Settings, speechApplied
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() {
+1
View File
@@ -128,6 +128,7 @@ func (a *application) poll_input(inputReady bool) error {
return err
}
}
a.apply_pending_modifiers()
return nil
}
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build linux && cgo
package console
import (
"testing"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/input"
)
func TestConsoleModifierUpdateWaitsForCaptureAndHeldChord(t *testing.T) {
layout, _, err := input.Load_bindings([]string{"../../config/keyboard/laptop.toml"})
if err != nil {
t.Fatal(err)
}
router, err := input.New_router(layout)
if err != nil {
t.Fatal(err)
}
if err := router.Configure_modifiers([]string{"KEY_INSERT"}, []string{"KEY_COMPOSE"}); err != nil {
t.Fatal(err)
}
if err := router.Configure_scripts([]string{"KEY_COMPOSE"}, []input.ScriptBinding{{ID: "script:example", Keys: []string{"KEY_SCRIPT", "KEY_U"}}}); err != nil {
t.Fatal(err)
}
keys := &Keyboards{router: router}
update := func(want bool) {
t.Helper()
applied, err := keys.Update_modifiers([]string{"KEY_META"}, []string{"KEY_CAPSLOCK"})
if err != nil || applied != want {
t.Fatal(applied, err)
}
}
keys.router.Capture(true)
update(false)
keys.router.Capture(false)
keys.router.Key(1, input.KeyInsert, 1, false, false)
if got := keys.router.Key(1, 23, 1, false, false); got.Command != command.CurrentLine {
t.Fatal(got)
}
update(false)
keys.router.Key(1, input.KeyInsert, 0, false, false)
update(false)
if got := keys.router.Key(1, 23, 0, false, false); got.Forward {
t.Fatal("old chord release leaked", got)
}
update(true)
keys.router.Key(1, input.KeyMeta, 1, false, false)
if got := keys.router.Key(1, 23, 1, false, false); got.Command != command.CurrentLine {
t.Fatal("new reader key failed", got)
}
keys.router.Key(1, input.KeyMeta, 0, false, false)
keys.router.Key(1, 23, 0, false, false)
keys.router.Key(1, input.KeyCapsLock, 1, false, false)
if got := keys.router.Key(1, 22, 1, false, false); got.Command != "script:example" {
t.Fatal("script binding lost", got)
}
}
+19
View File
@@ -308,6 +308,25 @@ func (k *Keyboards) Close() error {
return errors.Join(errs...)
}
// Update_modifiers waits for every keyboard to release its old chord before
// replacing the router; the existing evdev grabs remain owned throughout.
func (k *Keyboards) Update_modifiers(fenrirKeys, scriptKeys []string) (bool, error) {
if k.router.Captured() || !k.router.Idle() {
return false, nil
}
for _, device := range k.devices {
if device.syncing || device.held() {
return false, nil
}
}
candidate := k.router
if err := candidate.Configure_modifiers(fenrirKeys, scriptKeys); err != nil {
return false, err
}
k.router = candidate
return true, nil
}
func (k *Keyboards) Poll(textMode, numLock bool, handle func(input.Decision)) error {
if !textMode {
for _, device := range k.devices {
+10 -4
View File
@@ -697,7 +697,7 @@ msgid "Could not open Emoji Menu: {0}"
msgstr "Emoji-Menü konnte nicht geöffnet werden: {0}"
#: internal/app/fenrir_menu.go:40 internal/app/fenrir_menu.go:69
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:134
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:164
msgid "Could not open Fenrir Menu: {0}"
msgstr "Fenrir-Menü konnte nicht geöffnet werden: {0}"
@@ -715,7 +715,7 @@ msgid "Could not save exceptions: {0}"
msgstr "Ausnahmen konnten nicht gespeichert werden: {0}"
#: internal/app/fenrir_menu.go:203 internal/app/fenrir_menu.go:209
#: internal/app/runtime_settings.go:177 internal/app/runtime_settings.go:205
#: internal/app/runtime_settings.go:207 internal/app/runtime_settings.go:235
msgid "Could not save settings: {0}"
msgstr "Einstellungen konnten nicht gespeichert werden: {0}"
@@ -1027,7 +1027,7 @@ msgstr "Gesicht mit herausgestreckter zunge"
msgid "Face with symbols"
msgstr "Gesicht mit Symbolen"
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:160
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:190
msgid "Failed to save settings."
msgstr "Einstellungen konnten nicht gespeichert werden."
@@ -2349,7 +2349,7 @@ msgstr "Salutierendes Gesicht"
msgid "Santa"
msgstr "Weihnachtsmann"
#: internal/app/runtime_settings.go:156 internal/app/runtime_settings.go:173
#: internal/app/runtime_settings.go:186 internal/app/runtime_settings.go:203
msgid "Save unavailable."
msgstr "Speichern nicht verfügbar."
@@ -2454,6 +2454,12 @@ msgstr "Setze Fensteranfangs- und -endmarken"
msgid "Settings are busy"
msgstr "Einstellungen sind beschäftigt"
#: internal/app/runtime_settings.go:142
msgid ""
"Settings saved, but key changes could not be applied: {0}. Previous keys "
"remain active."
msgstr "Einstellungen gespeichert, aber die Tastenänderungen konnten nicht angewendet werden: {0}. Die bisherigen Tasten bleiben aktiv."
#: internal/app/quick_settings.go:298
msgid "Settings saved."
msgstr "Einstellungen gespeichert."
+10 -4
View File
@@ -699,7 +699,7 @@ msgid "Could not open Emoji Menu: {0}"
msgstr "No se pudo abrir el menú de emojis: {0}"
#: internal/app/fenrir_menu.go:40 internal/app/fenrir_menu.go:69
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:134
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:164
msgid "Could not open Fenrir Menu: {0}"
msgstr "No se pudo abrir el menú de Fenrir: {0}"
@@ -717,7 +717,7 @@ msgid "Could not save exceptions: {0}"
msgstr "No se pudieron guardar las excepciones: {0}"
#: internal/app/fenrir_menu.go:203 internal/app/fenrir_menu.go:209
#: internal/app/runtime_settings.go:177 internal/app/runtime_settings.go:205
#: internal/app/runtime_settings.go:207 internal/app/runtime_settings.go:235
msgid "Could not save settings: {0}"
msgstr "No se pudo guardar la configuración: {0}"
@@ -1030,7 +1030,7 @@ msgstr "Cara sacando la lengua"
msgid "Face with symbols"
msgstr "Cara con símbolos"
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:160
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:190
msgid "Failed to save settings."
msgstr "No se pudo guardar la configuración."
@@ -2358,7 +2358,7 @@ msgstr "Cara saludando militarmente"
msgid "Santa"
msgstr "Papá Noel"
#: internal/app/runtime_settings.go:156 internal/app/runtime_settings.go:173
#: internal/app/runtime_settings.go:186 internal/app/runtime_settings.go:203
msgid "Save unavailable."
msgstr "Guardado no disponible."
@@ -2463,6 +2463,12 @@ msgstr "Establece una marca de principio y otra para el final de la ventana"
msgid "Settings are busy"
msgstr "La configuración está ocupada"
#: internal/app/runtime_settings.go:142
msgid ""
"Settings saved, but key changes could not be applied: {0}. Previous keys "
"remain active."
msgstr "Ajustes guardados, pero no se pudieron aplicar los cambios de teclas: {0}. Las teclas anteriores siguen activas."
#: internal/app/quick_settings.go:298
msgid "Settings saved."
msgstr "Configuración guardada."
+8 -4
View File
@@ -681,7 +681,7 @@ msgstr ""
msgid "Could not open Emoji Menu: {0}"
msgstr ""
#: internal/app/fenrir_menu.go:40 internal/app/fenrir_menu.go:69 internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:134
#: internal/app/fenrir_menu.go:40 internal/app/fenrir_menu.go:69 internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:164
msgid "Could not open Fenrir Menu: {0}"
msgstr ""
@@ -697,7 +697,7 @@ msgstr ""
msgid "Could not save exceptions: {0}"
msgstr ""
#: internal/app/fenrir_menu.go:203 internal/app/fenrir_menu.go:209 internal/app/runtime_settings.go:177 internal/app/runtime_settings.go:205
#: internal/app/fenrir_menu.go:203 internal/app/fenrir_menu.go:209 internal/app/runtime_settings.go:207 internal/app/runtime_settings.go:235
msgid "Could not save settings: {0}"
msgstr ""
@@ -997,7 +997,7 @@ msgstr ""
msgid "Face with symbols"
msgstr ""
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:160
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:190
msgid "Failed to save settings."
msgstr ""
@@ -2309,7 +2309,7 @@ msgstr ""
msgid "Santa"
msgstr ""
#: internal/app/runtime_settings.go:156 internal/app/runtime_settings.go:173
#: internal/app/runtime_settings.go:186 internal/app/runtime_settings.go:203
msgid "Save unavailable."
msgstr ""
@@ -2413,6 +2413,10 @@ msgstr ""
msgid "Settings are busy"
msgstr ""
#: internal/app/runtime_settings.go:142
msgid "Settings saved, but key changes could not be applied: {0}. Previous keys remain active."
msgstr ""
#: internal/app/quick_settings.go:298
msgid "Settings saved."
msgstr ""
+10 -4
View File
@@ -699,7 +699,7 @@ msgid "Could not open Emoji Menu: {0}"
msgstr "Nie można otworzyć menu emoji: {0}"
#: internal/app/fenrir_menu.go:40 internal/app/fenrir_menu.go:69
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:134
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:164
msgid "Could not open Fenrir Menu: {0}"
msgstr "Nie można otworzyć menu Fenrira: {0}"
@@ -717,7 +717,7 @@ msgid "Could not save exceptions: {0}"
msgstr "Nie można zapisać wyjątków: {0}"
#: internal/app/fenrir_menu.go:203 internal/app/fenrir_menu.go:209
#: internal/app/runtime_settings.go:177 internal/app/runtime_settings.go:205
#: internal/app/runtime_settings.go:207 internal/app/runtime_settings.go:235
msgid "Could not save settings: {0}"
msgstr "Nie można zapisać ustawień: {0}"
@@ -1029,7 +1029,7 @@ msgstr "twarz wystawiająca język"
msgid "Face with symbols"
msgstr "Twarz z symbolami"
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:160
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:190
msgid "Failed to save settings."
msgstr "Nie udało się zapisać ustawień."
@@ -2352,7 +2352,7 @@ msgstr "Twarz salutująca"
msgid "Santa"
msgstr "Święty Mikołaj"
#: internal/app/runtime_settings.go:156 internal/app/runtime_settings.go:173
#: internal/app/runtime_settings.go:186 internal/app/runtime_settings.go:203
msgid "Save unavailable."
msgstr "Zapisywanie niedostępne."
@@ -2457,6 +2457,12 @@ msgstr "Ustaw początek i koniec okna."
msgid "Settings are busy"
msgstr "Ustawienia są zajęte"
#: internal/app/runtime_settings.go:142
msgid ""
"Settings saved, but key changes could not be applied: {0}. Previous keys "
"remain active."
msgstr "Ustawienia zapisano, ale nie udało się zastosować zmian klawiszy: {0}. Poprzednie klawisze pozostają aktywne."
#: internal/app/quick_settings.go:298
msgid "Settings saved."
msgstr "Ustawienia zapisane."
+10 -4
View File
@@ -697,7 +697,7 @@ msgid "Could not open Emoji Menu: {0}"
msgstr "Não foi possível abrir o menu de emojis: {0}"
#: internal/app/fenrir_menu.go:40 internal/app/fenrir_menu.go:69
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:134
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:164
msgid "Could not open Fenrir Menu: {0}"
msgstr "Não foi possível abrir o menu do Fenrir: {0}"
@@ -715,7 +715,7 @@ msgid "Could not save exceptions: {0}"
msgstr "Não foi possível salvar as exceções: {0}"
#: internal/app/fenrir_menu.go:203 internal/app/fenrir_menu.go:209
#: internal/app/runtime_settings.go:177 internal/app/runtime_settings.go:205
#: internal/app/runtime_settings.go:207 internal/app/runtime_settings.go:235
msgid "Could not save settings: {0}"
msgstr "Não foi possível salvar as configurações: {0}"
@@ -1029,7 +1029,7 @@ msgstr "Rosto mostrando a língua"
msgid "Face with symbols"
msgstr "Rosto com símbolos"
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:160
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:190
msgid "Failed to save settings."
msgstr "Falha ao salvar as configurações."
@@ -2356,7 +2356,7 @@ msgstr "Cara saudando militarmente"
msgid "Santa"
msgstr "Papai Noel"
#: internal/app/runtime_settings.go:156 internal/app/runtime_settings.go:173
#: internal/app/runtime_settings.go:186 internal/app/runtime_settings.go:203
msgid "Save unavailable."
msgstr "Salvamento indisponível."
@@ -2461,6 +2461,12 @@ msgstr "Definir marcas de início e fim da janela"
msgid "Settings are busy"
msgstr "As configurações estão ocupadas"
#: internal/app/runtime_settings.go:142
msgid ""
"Settings saved, but key changes could not be applied: {0}. Previous keys "
"remain active."
msgstr "Configurações salvas, mas não foi possível aplicar as alterações de teclas: {0}. As teclas anteriores permanecem ativas."
#: internal/app/quick_settings.go:298
msgid "Settings saved."
msgstr "Configurações salvas."
+10 -4
View File
@@ -696,7 +696,7 @@ msgid "Could not open Emoji Menu: {0}"
msgstr "Не удалось открыть меню эмодзи: {0}"
#: internal/app/fenrir_menu.go:40 internal/app/fenrir_menu.go:69
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:134
#: internal/app/fenrir_menu.go:92 internal/app/runtime_settings.go:164
msgid "Could not open Fenrir Menu: {0}"
msgstr "Не удалось открыть меню Fenrir: {0}"
@@ -714,7 +714,7 @@ msgid "Could not save exceptions: {0}"
msgstr "Не удалось сохранить исключения: {0}"
#: internal/app/fenrir_menu.go:203 internal/app/fenrir_menu.go:209
#: internal/app/runtime_settings.go:177 internal/app/runtime_settings.go:205
#: internal/app/runtime_settings.go:207 internal/app/runtime_settings.go:235
msgid "Could not save settings: {0}"
msgstr "Не удалось сохранить настройки: {0}"
@@ -1025,7 +1025,7 @@ msgstr "Показывает язык"
msgid "Face with symbols"
msgstr "Лицо с символами"
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:160
#: internal/app/quick_settings.go:295 internal/app/runtime_settings.go:190
msgid "Failed to save settings."
msgstr "Не удалось сохранить настройки."
@@ -2348,7 +2348,7 @@ msgstr "Отдающее честь лицо"
msgid "Santa"
msgstr "Дед Мороз"
#: internal/app/runtime_settings.go:156 internal/app/runtime_settings.go:173
#: internal/app/runtime_settings.go:186 internal/app/runtime_settings.go:203
msgid "Save unavailable."
msgstr "Сохранение недоступно."
@@ -2453,6 +2453,12 @@ msgstr "Установка начала и конца окна"
msgid "Settings are busy"
msgstr "Настройки заняты"
#: internal/app/runtime_settings.go:142
msgid ""
"Settings saved, but key changes could not be applied: {0}. Previous keys "
"remain active."
msgstr "Настройки сохранены, но изменения клавиш не удалось применить: {0}. Прежние клавиши остаются активными."
#: internal/app/quick_settings.go:298
msgid "Settings saved."
msgstr "Настройки сохранены"
+1
View File
@@ -149,6 +149,7 @@ func (r *Router) Configure_modifiers(fenrirKeys, scriptKeys []string) error {
if err := candidate.Configure_scripts(scriptKeys, nil); err != nil {
return err
}
candidate.scriptBindings = r.scriptBindings
*r = candidate
return nil
}
+17
View File
@@ -94,3 +94,20 @@ func TestCapsLockScriptModifierKeepsShiftToggle(t *testing.T) {
t.Fatal("script chord toggled capitalization", got)
}
}
func TestModifierReconfigurationPreservesInstalledScriptBindings(t *testing.T) {
r := desktop_router(t)
if err := r.Configure_modifiers([]string{"KEY_INSERT"}, []string{"KEY_COMPOSE"}); err != nil {
t.Fatal(err)
}
if err := r.Configure_scripts([]string{"KEY_COMPOSE"}, []ScriptBinding{{ID: "script:example", Keys: []string{"KEY_SCRIPT", "KEY_U"}}}); err != nil {
t.Fatal(err)
}
if err := r.Configure_modifiers([]string{"KEY_META"}, []string{"KEY_CAPSLOCK"}); err != nil {
t.Fatal(err)
}
r.Key(0, KeyCapsLock, 1, false, false)
if got := r.Key(0, 22, 1, false, false); got.Command != "script:example" {
t.Fatal("script binding lost", got)
}
}
+3
View File
@@ -134,6 +134,9 @@ func (r *Router) Forwarded(device int) []uint16 {
return keys
}
// Idle reports whether all devices have released their routed keys.
func (r *Router) Idle() bool { return r.empty() }
// Held reports physical state for one device, including consumed keys whose
// release has not arrived yet.
func (r *Router) Held(device int) bool { return len(r.keys[device]) != 0 }
+30
View File
@@ -72,6 +72,36 @@ func (k *Keyboard) Close() error {
return nil
}
// Update_modifiers applies only between complete chords and outside modal capture.
// The native transaction retains the previous grabs if any new grab is refused.
func (k *Keyboard) Update_modifiers(fenrirKeys, scriptKeys []string) (bool, error) {
s := k.session
if s.router.Captured() || s.grabbed || s.captureBlocked || len(s.held) != 0 || len(s.replayed) != 0 {
return false, nil
}
candidate := s.router
if err := candidate.Configure_modifiers(fenrirKeys, scriptKeys); err != nil {
return false, err
}
var keys []C.int
for _, key := range append(candidate.Reader_keys(), candidate.Script_keys()...) {
keys = append(keys, C.int(key))
}
var keyPointer *C.int
if len(keys) > 0 {
keyPointer = &keys[0]
}
status := C.terminal_x_update_keys(s.x, keyPointer, C.int(len(keys)))
if err := native_error(status); err != nil {
return false, fmt.Errorf("update X11 modifier keys: %w", err)
}
if status != 0 {
return false, nil
}
s.router = candidate
return true, nil
}
// capture_keyboard treats desktop/focus contention as a refused chord, not a
// terminal-session failure. Keep modal intent while discarding stale key state.
func (s *Session) capture_keyboard() (int, error) {
+62 -17
View File
@@ -175,7 +175,7 @@ int terminal_key(VTerm *vt, const TerminalEvent *event, char *bytes, int size) {
return length;
}
struct TerminalX { Display *display; Window window; unsigned int numlock; int grabbed; XIM im; XIC ic; int scriptKeys[KEY_MAX+1]; int scriptKeyCount; };
struct TerminalX { Display *display; Window window; unsigned int numlock; int grabbed; XIM im; XIC ic; int scriptKeys[KEY_MAX+1]; int scriptKeyCount; unsigned char grabbedKeys[256]; };
static int x_error;
static int trap_error(Display *display, XErrorEvent *event) {
(void)display;
@@ -206,8 +206,7 @@ static void observe_windows(TerminalX *x, Window window, int depth, int *remaini
}
if (children) XFree(children);
}
static void install_grabs(TerminalX *x) {
Window window = x->window;
static void collect_grabs(TerminalX *x, const int *keys, int count, unsigned char wanted[256]) {
x->numlock = 0;
XModifierKeymap *modifiers = XGetModifierMapping(x->display);
KeyCode numlock = XKeysymToKeycode(x->display, XK_Num_Lock);
@@ -218,14 +217,14 @@ static void install_grabs(TerminalX *x) {
x->numlock |= 1u << mask;
XFreeModifiermap(modifiers);
}
KeySym keys[] = {XK_Insert,
KeySym fixedKeys[] = {XK_Insert,
XK_KP_Insert, XK_KP_End, XK_KP_Down, XK_KP_Page_Down, XK_KP_Left,
XK_KP_Begin, XK_KP_Right, XK_KP_Home, XK_KP_Up, XK_KP_Page_Up,
XK_KP_Delete, XK_KP_Enter, XK_KP_Add, XK_KP_Subtract, XK_KP_Divide,
XK_Control_L, XK_Control_R};
for (unsigned int i=0; i<sizeof(keys)/sizeof(keys[0]); i++) {
KeyCode code = XKeysymToKeycode(x->display, keys[i]);
if (code) XGrabKey(x->display, code, AnyModifier, window, False, GrabModeAsync, GrabModeSync);
for (unsigned int i=0; i<sizeof(fixedKeys)/sizeof(fixedKeys[0]); i++) {
KeyCode code = XKeysymToKeycode(x->display, fixedKeys[i]);
if (code) wanted[code] = 1;
}
// Script modifiers may be any Linux key, not just Compose. Resolve through
// the same symbol translation as event routing instead of assuming that
@@ -235,14 +234,63 @@ static void install_grabs(TerminalX *x) {
for (int code=first; code<=last; code++) {
KeySym symbol = XkbKeycodeToKeysym(x->display, code, 0, 0);
int key = terminal_key_code(symbol, code);
for (int i=0; i<x->scriptKeyCount; i++) {
if (key == x->scriptKeys[i]) {
XGrabKey(x->display, code, AnyModifier, window, False, GrabModeAsync, GrabModeSync);
for (int i=0; i<count; i++) {
if (key == keys[i]) {
wanted[code] = 1;
break;
}
}
}
}
static int replace_grabs(TerminalX *x, const int *keys, int count, int idleOnly) {
if (count < 0 || count > KEY_MAX+1) return -EINVAL;
// Freeze other clients only for the bounded grab transaction. This closes
// the gap between the idle check, adding grabs and switching key sets.
XGrabServer(x->display);
XSync(x->display, False);
if (idleOnly) {
char down[32];
XQueryKeymap(x->display, down);
int busy = XPending(x->display) != 0;
for (int i=0; i<32; i++) busy |= down[i] != 0;
if (busy) { XUngrabServer(x->display); XFlush(x->display); return 1; }
}
unsigned char wanted[256] = {0};
XErrorHandler previous = begin_trap(x->display);
collect_grabs(x, keys, count, wanted);
for (int code=0; code<256; code++) {
if (wanted[code] && !x->grabbedKeys[code])
XGrabKey(x->display, code, AnyModifier, x->window, False, GrabModeAsync, GrabModeSync);
}
int error = end_trap(x->display, previous);
if (error) {
previous = begin_trap(x->display);
for (int code=0; code<256; code++) {
if (wanted[code] && !x->grabbedKeys[code])
XUngrabKey(x->display, code, AnyModifier, x->window);
}
end_trap(x->display, previous);
} else {
for (int code=0; code<256; code++) {
if (x->grabbedKeys[code] && !wanted[code])
XUngrabKey(x->display, code, AnyModifier, x->window);
}
memcpy(x->grabbedKeys, wanted, sizeof(wanted));
x->scriptKeyCount = count;
for (int i=0; i<count; i++) x->scriptKeys[i] = keys[i];
}
XUngrabServer(x->display);
XSync(x->display, False);
return error ? -error : 0;
}
static void install_grabs(TerminalX *x) {
unsigned char wanted[256] = {0};
collect_grabs(x, x->scriptKeys, x->scriptKeyCount, wanted);
for (int code=0; code<256; code++) {
if (wanted[code]) XGrabKey(x->display, code, AnyModifier, x->window, False, GrabModeAsync, GrabModeSync);
}
memcpy(x->grabbedKeys, wanted, sizeof(wanted));
}
int terminal_x_caps_lock(TerminalX *x, int enabled) {
if (!XkbLockModifiers(x->display, XkbUseCoreKbd, LockMask, enabled ? LockMask : 0)) return -EIO;
XSync(x->display, False);
@@ -254,13 +302,10 @@ int terminal_x_toggle_caps(TerminalX *x) {
return terminal_x_caps_lock(x, !(state.locked_mods & LockMask));
}
int terminal_x_script_keys(TerminalX *x, const int *keys, int count) {
if (count < 0 || count > KEY_MAX+1) return -EINVAL;
XErrorHandler previous = begin_trap(x->display);
x->scriptKeyCount = count;
for (int i=0; i<count; i++) x->scriptKeys[i] = keys[i];
install_grabs(x);
int error = end_trap(x->display, previous);
return error ? -error : 0;
return replace_grabs(x, keys, count, 0);
}
int terminal_x_update_keys(TerminalX *x, const int *keys, int count) {
return replace_grabs(x, keys, count, 1);
}
TerminalX *terminal_x_open(unsigned long window, int *error) {
TerminalX *x = calloc(1, sizeof(*x));
+1
View File
@@ -29,6 +29,7 @@ TerminalX *terminal_x_open(unsigned long window, int *error);
int terminal_x_caps_lock(TerminalX *x, int enabled);
int terminal_x_toggle_caps(TerminalX *x);
int terminal_x_script_keys(TerminalX *x, const int *keys, int count);
int terminal_x_update_keys(TerminalX *x, const int *keys, int count);
void terminal_x_close(TerminalX *x);
int terminal_x_fd(TerminalX *x);
int terminal_x_down(TerminalX *x, int code);