diff --git a/README.md b/README.md index a1cb2932..f5da156e 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,10 @@ The keyboard layout combo box offers Desktop and Laptop. The Keyboard tab also has separate Fenrir-key and script-key checkboxes for keypad Insert, Insert, Caps Lock, and Super, plus Compose for scripts. Uncheck a key in one group before selecting it in the other. At least one Fenrir key must remain selected. -Key assignments take effect on the next launch. +After **OK** saves the changes, modifier assignments apply when the menu closes +and all held keys are released. If X11 cannot acquire a new key, Fenrir reports +the failure and keeps the previous assignments active. The saved choices remain +available for retrying or editing in the menu. Backend, device, keyboard layout, sound theme, and other launch-only changes take effect the next time Fenrir starts. diff --git a/config/settings/settings.toml b/config/settings/settings.toml index 107d7c7c..02e1fc34 100644 --- a/config/settings/settings.toml +++ b/config/settings/settings.toml @@ -49,6 +49,7 @@ multi_press_timeout_ms = 200 layout = "desktop" # Unset uses the layout's Fenrir keys, excluding configured script keys. # The Keyboard tab checkboxes save an explicit selection that overrides the layout. +# Saved modifier changes apply when the menu closes and all held keys are released. # Names: KEY_KP0 (NumLock off), KEY_INSERT, KEY_CAPSLOCK, KEY_META (both Super keys). # A physical key cannot serve as both a Fenrir key and a script key. # fenrir_keys = ["KEY_KP0", "KEY_INSERT", "KEY_META"] diff --git a/docs/user.md b/docs/user.md index ab0fb3f6..e01842d9 100644 --- a/docs/user.md +++ b/docs/user.md @@ -598,7 +598,12 @@ keys** checkbox groups for keypad Insert, Insert, Caps Lock, and Super; Compose is also available for scripts. Uncheck a key in one group before assigning it to the other. A physical key cannot serve both roles, including through an alias such as `KEY_META` and `KEY_RIGHTMETA`. At least one Fenrir key must remain -selected. Changes take effect on the next launch. +selected. After **OK** saves the changes, modifier assignments apply when the +menu closes and all held keys are released. Cancel leaves them unchanged. +If X11 cannot acquire the new keys, Fenrir reports the failure and retains the +previous active assignments. The saved choices remain in the file; reopen the +menu to edit or retry them. Desktop/laptop layout changes still require a new +launch. The Fenrir-key checkboxes save `keyboard.fenrir_keys`, which overrides the layout's defaults. When that setting is omitted, the layout supplies defaults diff --git a/internal/app/app.go b/internal/app/app.go index 0aa7c7d6..1ae44dc3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -45,6 +45,7 @@ type Keyboards interface { Scan() error Pass_next() Capture(bool) error + Update_modifiers([]string, []string) (bool, error) Close() error } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 99220ee1..fcb915a0 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 } diff --git a/internal/app/live_modifiers_test.go b/internal/app/live_modifiers_test.go new file mode 100644 index 00000000..04cfefff --- /dev/null +++ b/internal/app/live_modifiers_test.go @@ -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") + } +} diff --git a/internal/app/runtime_settings.go b/internal/app/runtime_settings.go index a2505427..5e5fc51d 100644 --- a/internal/app/runtime_settings.go +++ b/internal/app/runtime_settings.go @@ -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() { diff --git a/internal/app/screen_updates.go b/internal/app/screen_updates.go index 99dc6976..8bcbb43a 100644 --- a/internal/app/screen_updates.go +++ b/internal/app/screen_updates.go @@ -128,6 +128,7 @@ func (a *application) poll_input(inputReady bool) error { return err } } + a.apply_pending_modifiers() return nil } diff --git a/internal/console/live_modifiers_linux_test.go b/internal/console/live_modifiers_linux_test.go new file mode 100644 index 00000000..f6544b87 --- /dev/null +++ b/internal/console/live_modifiers_linux_test.go @@ -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) + } +} diff --git a/internal/console/native_linux.go b/internal/console/native_linux.go index a62e989b..49bf2e93 100644 --- a/internal/console/native_linux.go +++ b/internal/console/native_linux.go @@ -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 { diff --git a/internal/i18n/locale/de/LC_MESSAGES/fenrir.po b/internal/i18n/locale/de/LC_MESSAGES/fenrir.po index e96d492c..f2b9dcd1 100644 --- a/internal/i18n/locale/de/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/de/LC_MESSAGES/fenrir.po @@ -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." diff --git a/internal/i18n/locale/es/LC_MESSAGES/fenrir.po b/internal/i18n/locale/es/LC_MESSAGES/fenrir.po index e399bbaf..938b2954 100644 --- a/internal/i18n/locale/es/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/es/LC_MESSAGES/fenrir.po @@ -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." diff --git a/internal/i18n/locale/fenrir.pot b/internal/i18n/locale/fenrir.pot index 3c55bd6f..62b95e63 100644 --- a/internal/i18n/locale/fenrir.pot +++ b/internal/i18n/locale/fenrir.pot @@ -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 "" diff --git a/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po b/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po index 1c929ef7..d34e38f7 100644 --- a/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/pl/LC_MESSAGES/fenrir.po @@ -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." diff --git a/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po b/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po index d68cd6e4..288116e1 100644 --- a/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/pt/LC_MESSAGES/fenrir.po @@ -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." diff --git a/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po b/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po index c2dc536c..2325ce87 100644 --- a/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po +++ b/internal/i18n/locale/ru/LC_MESSAGES/fenrir.po @@ -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 "Настройки сохранены" diff --git a/internal/input/modifiers_linux.go b/internal/input/modifiers_linux.go index 72c31ea7..9092fa3a 100644 --- a/internal/input/modifiers_linux.go +++ b/internal/input/modifiers_linux.go @@ -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 } diff --git a/internal/input/modifiers_test.go b/internal/input/modifiers_test.go index af289208..5e5290ad 100644 --- a/internal/input/modifiers_test.go +++ b/internal/input/modifiers_test.go @@ -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) + } +} diff --git a/internal/input/router.go b/internal/input/router.go index f4eb9fe0..fb2e9a71 100644 --- a/internal/input/router.go +++ b/internal/input/router.go @@ -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 } diff --git a/internal/terminal/keyboard_linux.go b/internal/terminal/keyboard_linux.go index 7a0bb8aa..e7787f69 100644 --- a/internal/terminal/keyboard_linux.go +++ b/internal/terminal/keyboard_linux.go @@ -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) { diff --git a/internal/terminal/native.c b/internal/terminal/native.c index ee7b962a..41c04d12 100644 --- a/internal/terminal/native.c +++ b/internal/terminal/native.c @@ -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; idisplay, keys[i]); - if (code) XGrabKey(x->display, code, AnyModifier, window, False, GrabModeAsync, GrabModeSync); + for (unsigned int i=0; idisplay, 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; iscriptKeyCount; i++) { - if (key == x->scriptKeys[i]) { - XGrabKey(x->display, code, AnyModifier, window, False, GrabModeAsync, GrabModeSync); + for (int i=0; i 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; iscriptKeys[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; iscriptKeys[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)); diff --git a/internal/terminal/native.h b/internal/terminal/native.h index ac6273e8..d96245fc 100644 --- a/internal/terminal/native.h +++ b/internal/terminal/native.h @@ -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); diff --git a/tests/native_terminal.py b/tests/native_terminal.py index d9babdfb..211b20b3 100644 --- a/tests/native_terminal.py +++ b/tests/native_terminal.py @@ -231,6 +231,20 @@ class KeyboardFixture: for name in reversed(keys): self.event(name, False) + def grab_key(self, window, name): + self.x11.XGrabKey.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_uint, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, ctypes.c_int] + symbol = self.x11.XStringToKeysym(name.encode()) + code = self.x11.XKeysymToKeycode(self.display, symbol) + self.x11.XGrabKey(self.display, code, 1 << 15, int(window), False, 1, 1) + self.x11.XSync(self.display, 0) + + def ungrab_key(self, window, name): + self.x11.XUngrabKey.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_uint, ctypes.c_ulong] + symbol = self.x11.XStringToKeysym(name.encode()) + code = self.x11.XKeysymToKeycode(self.display, symbol) + self.x11.XUngrabKey(self.display, code, 1 << 15, int(window)) + self.x11.XSync(self.display, 0) + def transient_focus(self, count): # Another client can destroy its focused window while Fenrir walks # that window's parent chain. Use real X requests to exercise the race. @@ -437,17 +451,53 @@ def main(): spoken("cannot be both a Fenrir key and a script key", start) assert (root / "settings.toml").read_bytes() == originalSettings key("Shift_L+Tab", "Shift_L+Tab", "space") # Remove Script Super. + keyboard.grab_key(window, "Super_L") + start = len(server.events) save_menu() + spoken("Previous keys remain active", start) + start = len(server.events) + key("Caps_Lock+i") + spoken("prompt", start) + key("Multi_key+z") + spoken("script modifier works", start) + keyboard.grab_key(window, "Super_R") # No partially added grab leaked. + keyboard.ungrab_key(window, "Super_R") + keyboard.ungrab_key(window, "Super_L") + # Retry the same saved choices, while holding the closing key + # and old modifier across the asynchronous save completion. + key("Caps_Lock+f", "Right") + for _ in range(32): + before = len(server.events) + key("Tab") + text = wait_for(lambda: next((item for item in reversed(server.events[before:]) if item != "CANCEL"), None), "retry next control") + if text == "OK button": + break + else: + raise AssertionError("Retry did not reach OK") + start = len(server.events) + keyboard.event("Caps_Lock", True) + keyboard.event("Return", True) + spoken("Fenrir Menu closed", start) + time.sleep(0.15) + keyboard.event("Return", False) + start = len(server.events) + key("i") # Old Caps modifier still owns its held chord. + spoken("prompt", start) + keyboard.event("Caps_Lock", False) + time.sleep(0.1) saved = tomllib.loads((root / "settings.toml").read_text()) readerKeys = saved["keyboard"]["fenrir_keys"] scriptKeys = saved["general"]["script_keys"] assert "KEY_CAPSLOCK" not in readerKeys and "KEY_META" in readerKeys, saved assert "KEY_CAPSLOCK" in scriptKeys and "KEY_META" not in scriptKeys, saved start = len(server.events) - key("Caps_Lock+i") # Saving must not change the running modifiers. + key("Super_L+i") # New assignments work without a restart. spoken("prompt", start) + start = len(server.events) + key("Caps_Lock+z") + spoken("script modifier works", start) assert recorded() == b"", recorded() - key("Caps_Lock+q") + key("Super_L+q") wait_for(lambda: (root / "finished").exists(), "modifier first session quit") assert json.loads((root / "finished").read_text())["status"] == 0 for name in ["started", "finished", "size", "input", "shell-pid"]: @@ -470,7 +520,7 @@ def main(): key("Super_L+q") wait_for(lambda: (root / "finished").exists(), "modifier second session quit") assert json.loads((root / "finished").read_text()) == {"status": 0, "restored": True, "flagsRestored": True} - print("PASS: modifier checkbox announcements, rejected overlap without saving, key role changes saved for next launch, restarted Super review, Caps Lock script dispatch, Shift Caps toggle and ordinary typing") + print("PASS: modifier checkbox announcements, rejected overlap without saving, passive grab conflict rollback, retry after held-key release, immediate key role changes, restarted Super review, Caps Lock script dispatch, Shift Caps toggle and ordinary typing") return if os.environ.get("FENRIR_CAPS_TEST_ONLY") == "1":