Default laptop key set to capslock. Keys that can be set to fenrir key or script key selectable via checkbox.
This commit is contained in:
1 parent
47f18fdd28
commit
15e04aa8f8
47 files changed
+1560
-351
No files matched your search
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/config"
|
||||
"git.stormux.org/storm/fenrir/internal/input"
|
||||
"git.stormux.org/storm/fenrir/internal/interaction"
|
||||
)
|
||||
|
||||
type modifierOption struct{ name, Label string }
|
||||
|
||||
var modifierOptions = []modifierOption{
|
||||
{name: "KEY_KP0", Label: "Keypad Insert"},
|
||||
{name: "KEY_INSERT", Label: "Insert"},
|
||||
{name: "KEY_CAPSLOCK", Label: "Caps Lock"},
|
||||
{name: "KEY_META", Label: "Super"},
|
||||
}
|
||||
|
||||
func reader_names(settings config.Settings) []string {
|
||||
if settings.Keyboard.FenrirKeys != nil {
|
||||
return settings.Keyboard.FenrirKeys
|
||||
}
|
||||
// Running applications supply the resolved layout's keys at startup.
|
||||
// Standalone forms use the built-in layout defaults.
|
||||
var names []string
|
||||
switch filepath.Base(settings.Keyboard.Layout) {
|
||||
case "laptop", "laptop.toml":
|
||||
names = []string{"KEY_KP0", "KEY_INSERT", "KEY_CAPSLOCK"}
|
||||
default:
|
||||
names = []string{"KEY_KP0", "KEY_INSERT", "KEY_META"}
|
||||
}
|
||||
available, err := input.Available_fenrir_keys(names, settings.General.ScriptKeys)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
func modifier_checked(names []string, name string) bool {
|
||||
keys, _ := input.Modifier_codes(names)
|
||||
option, _ := input.Modifier_codes([]string{name})
|
||||
for key := range option {
|
||||
if keys[key] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func modifier_controls(settings config.Settings) []interaction.Control {
|
||||
var controls []interaction.Control
|
||||
for _, role := range []struct {
|
||||
id, Label string
|
||||
names []string
|
||||
}{
|
||||
{id: "fenrir_key.", Label: "Fenrir keys", names: reader_names(settings)},
|
||||
{id: "script_key.", Label: "Script keys", names: settings.General.ScriptKeys},
|
||||
} {
|
||||
options := slices.Clone(modifierOptions)
|
||||
if role.id == "script_key." {
|
||||
options = append(options, modifierOption{name: "KEY_COMPOSE", Label: "Compose"})
|
||||
}
|
||||
for _, option := range options {
|
||||
controls = append(controls, interaction.Control{ID: role.id + option.name, Label: option.Label, Group: role.Label, Kind: interaction.CheckBox, Checked: modifier_checked(role.names, option.name)})
|
||||
}
|
||||
}
|
||||
return controls
|
||||
}
|
||||
|
||||
func selected_modifiers(values map[string]interaction.Control, prefix string, names []string, options []modifierOption) ([]string, bool, error) {
|
||||
selected := slices.Clone(names)
|
||||
changed := false
|
||||
for _, option := range options {
|
||||
control, ok := values["keyboard."+prefix+option.name]
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("missing modifier checkbox %s", option.name)
|
||||
}
|
||||
if control.Checked == modifier_checked(names, option.name) {
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
if control.Checked {
|
||||
selected = append(selected, option.name)
|
||||
continue
|
||||
}
|
||||
selected = slices.DeleteFunc(selected, func(name string) bool { return modifier_checked([]string{name}, option.name) })
|
||||
}
|
||||
return selected, changed, nil
|
||||
}
|
||||
|
||||
func modifier_settings(values map[string]interaction.Control, base *config.Settings) error {
|
||||
reader, changed, err := selected_modifiers(values, "fenrir_key.", reader_names(*base), modifierOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scripts, _, err := selected_modifiers(values, "script_key.", base.General.ScriptKeys, append(slices.Clone(modifierOptions), modifierOption{name: "KEY_COMPOSE", Label: "Compose"}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := input.Validate_modifier_keys(reader, scripts); err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
base.Keyboard.FenrirKeys = reader
|
||||
}
|
||||
base.General.ScriptKeys = scripts
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/config"
|
||||
"git.stormux.org/storm/fenrir/internal/interaction"
|
||||
)
|
||||
|
||||
func focus_modifier(t *testing.T, s *interaction.Session, id string) {
|
||||
t.Helper()
|
||||
for range 40 {
|
||||
if control, ok := s.Focused_control(); ok && control.ID == id {
|
||||
return
|
||||
}
|
||||
s.Handle(interaction.Key{Name: "tab"})
|
||||
}
|
||||
t.Fatal("could not focus", id)
|
||||
}
|
||||
|
||||
func TestModifierCheckboxesRejectConflictAndSaveSelections(t *testing.T) {
|
||||
for _, key := range []string{"KEY_KP0", "KEY_INSERT", "KEY_CAPSLOCK", "KEY_META"} {
|
||||
base := config.Defaults()
|
||||
base.Keyboard.Devices = []string{}
|
||||
base.Keyboard.FenrirKeys = []string{key}
|
||||
base.General.ScriptKeys = []string{"KEY_COMPOSE", "KEY_F9"}
|
||||
session, err := New_session(base, []string{"keyboard"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"fenrir_key.", "script_key."} {
|
||||
control := session.Values()["keyboard."+role+key]
|
||||
if control.Kind != interaction.CheckBox || control.Checked != (role == "fenrir_key.") {
|
||||
t.Fatal(control)
|
||||
}
|
||||
}
|
||||
focus_modifier(t, session, "script_key."+key)
|
||||
session.Handle(interaction.Key{Name: "space"})
|
||||
if _, err := Settings(session, base); err == nil {
|
||||
t.Fatal("overlapping checkbox roles saved", key)
|
||||
}
|
||||
focus_modifier(t, session, "fenrir_key."+key)
|
||||
session.Handle(interaction.Key{Name: "space"})
|
||||
if _, err := Settings(session, base); err == nil {
|
||||
t.Fatal("all reader keys unchecked accepted")
|
||||
}
|
||||
replacement := "KEY_INSERT"
|
||||
if key == replacement {
|
||||
replacement = "KEY_CAPSLOCK"
|
||||
}
|
||||
focus_modifier(t, session, "fenrir_key."+replacement)
|
||||
session.Handle(interaction.Key{Name: "space"})
|
||||
draft, err := Settings(session, base)
|
||||
if err != nil || !modifier_checked(draft.General.ScriptKeys, key) || !modifier_checked(draft.Keyboard.FenrirKeys, replacement) || modifier_checked(draft.Keyboard.FenrirKeys, key) {
|
||||
t.Fatal(draft.Keyboard, draft.General, err)
|
||||
}
|
||||
if !reflect.DeepEqual(base.Keyboard.FenrirKeys, []string{key}) || len(base.General.ScriptKeys) != 2 {
|
||||
t.Fatal("draft changed original")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "settings.toml")
|
||||
doc, err := config.Open_document(path, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := doc.Save(draft); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, _, err := config.Load(path, nil)
|
||||
if err != nil || !reflect.DeepEqual(saved.Keyboard.FenrirKeys, draft.Keyboard.FenrirKeys) || !reflect.DeepEqual(saved.General.ScriptKeys, draft.General.ScriptKeys) {
|
||||
t.Fatal(saved.Keyboard, saved.General, err)
|
||||
}
|
||||
if !modifier_checked(saved.General.ScriptKeys, "KEY_F9") {
|
||||
t.Fatal("custom script key lost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModifierCheckboxesPreserveSideSpecificSuperAndDefaults(t *testing.T) {
|
||||
base := config.Defaults()
|
||||
base.Keyboard.Devices = []string{}
|
||||
base.Keyboard.FenrirKeys = []string{"KEY_RIGHTMETA"}
|
||||
base.General.ScriptKeys = []string{"KEY_LEFTMETA"}
|
||||
session, err := New_session(base, []string{"keyboard"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unchanged, err := Settings(session, base)
|
||||
if err != nil || !reflect.DeepEqual(unchanged, base) {
|
||||
t.Fatal(unchanged, err)
|
||||
}
|
||||
base = config.Defaults()
|
||||
base.Keyboard.Devices = []string{}
|
||||
base.General.ScriptKeys = []string{"KEY_META", "KEY_COMPOSE"}
|
||||
session, err = New_session(base, []string{"keyboard"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.Values()["keyboard.fenrir_key.KEY_META"].Checked || !session.Values()["keyboard.script_key.KEY_META"].Checked {
|
||||
t.Fatal("defaults claimed existing script key")
|
||||
}
|
||||
unchanged, err = Settings(session, base)
|
||||
if err != nil || !reflect.DeepEqual(unchanged, base) {
|
||||
t.Fatal(unchanged, err)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,9 @@ func new_session(settings config.Settings, order []string, title string, applica
|
||||
return err
|
||||
}
|
||||
}
|
||||
if section.ID == "keyboard" && !application {
|
||||
tab.Controls = append(tab.Controls, modifier_controls(settings)...)
|
||||
}
|
||||
if section.ID == "presentation.speech" {
|
||||
driver, _ := speech.Find_driver(settings.Presentation.Speech.Driver)
|
||||
tab.Controls = append(tab.Controls, interaction.Control{ID: "speech.test", Label: "Test synthesizer", Group: "Speech", Kind: interaction.Button, Action: "test-synthesizer", Hidden: !driver.Hardware})
|
||||
@@ -219,6 +222,11 @@ func settings_values(session *interaction.Session, base config.Settings, applica
|
||||
return config.Settings{}, err
|
||||
}
|
||||
}
|
||||
if !application {
|
||||
if err := modifier_settings(values, &base); err != nil {
|
||||
return config.Settings{}, err
|
||||
}
|
||||
}
|
||||
// Inactive Speech Dispatcher choices stay in the form's draft for switching
|
||||
// back, but cannot be sent to or saved as selections for a hardware driver.
|
||||
for _, definition := range config.Definitions() {
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestSettingsFormCoversSchemaAndReturnsIsolatedDraft(t *testing.T) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
values := session.Values()
|
||||
if len(values) != 55 {
|
||||
if len(values) != 64 {
|
||||
t.Fatal("missing settings or buttons", len(values))
|
||||
}
|
||||
unchanged, err := Settings(session, base)
|
||||
@@ -147,24 +147,48 @@ func TestCapitalCheckboxesRoundTripIndependently(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSettingsEntriesDoNotSubmit(t *testing.T) {
|
||||
for _, field := range []string{"devices", "layout"} {
|
||||
session, err := New_session(config.Defaults(), []string{"keyboard"})
|
||||
func TestInvalidDeviceEntryDoesNotSubmit(t *testing.T) {
|
||||
session, err := New_session(config.Defaults(), []string{"keyboard"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(interaction.Key{Name: "tab"})
|
||||
session.Handle(interaction.Key{Ctrl: true, Text: "a"})
|
||||
session.Handle(interaction.Key{Text: "invalid value"})
|
||||
if _, err := Settings(session, config.Defaults()); err == nil {
|
||||
t.Fatal("invalid devices accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyboardLayoutComboBox(t *testing.T) {
|
||||
for _, saved := range []string{"desktop", "laptop", "/tmp/custom.toml"} {
|
||||
base := config.Defaults()
|
||||
base.Keyboard.Layout = saved
|
||||
session, err := New_session(base, []string{"keyboard"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
control := session.Values()["keyboard.layout"]
|
||||
if control.Kind != interaction.ComboBox || control.Choices[0].ID != "desktop" || control.Choices[1].ID != "laptop" || control.Choices[control.Selected].ID != saved {
|
||||
t.Fatal(control)
|
||||
}
|
||||
session.Handle(interaction.Key{Name: "tab"})
|
||||
session.Handle(interaction.Key{Name: "tab"})
|
||||
if field == "layout" {
|
||||
session.Handle(interaction.Key{Name: "tab"})
|
||||
}
|
||||
session.Handle(interaction.Key{Ctrl: true, Text: "a"})
|
||||
value := "invalid value"
|
||||
if field == "layout" {
|
||||
value = "old.conf"
|
||||
session.Handle(interaction.Key{Text: "old.conf"})
|
||||
draft, err := Settings(session, base)
|
||||
if err != nil || draft.Keyboard.Layout != saved {
|
||||
t.Fatal(draft.Keyboard.Layout, err)
|
||||
}
|
||||
session.Handle(interaction.Key{Text: value})
|
||||
if _, err := Settings(session, config.Defaults()); err == nil {
|
||||
t.Fatal("invalid field accepted", field)
|
||||
if saved == "desktop" {
|
||||
session.Handle(interaction.Key{Name: "down"})
|
||||
draft, err = Settings(session, base)
|
||||
if err != nil || draft.Keyboard.Layout != "laptop" {
|
||||
t.Fatal(draft.Keyboard.Layout, err)
|
||||
}
|
||||
if base.Keyboard.Layout != "desktop" {
|
||||
t.Fatal("draft changed original settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user