From d8b59aa05bd024002a4abb0e387134fcf8c50fcf Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 26 Oct 2026 21:14:22 -0400 Subject: [PATCH] Start cthulhu in laptop layout with -l. --- README.md | 11 ++ cmd/cthulhu/main.go | 2 + cmd/cthulhu/main_test.go | 55 +++++++ .../Arch-Linux/cthulhu-git/PKGBUILD | 2 +- internal/app/app.go | 11 +- internal/app/laptop_layout_test.go | 139 ++++++++++++++++++ internal/app/preferences.go | 1 + internal/app/preferences_application.go | 32 ++++ internal/app/preferences_apply.go | 4 + internal/app/progress_settings.go | 7 +- 10 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 internal/app/laptop_layout_test.go diff --git a/README.md b/README.md index 9e1f5b9..3e7d48a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,17 @@ Cthulhu is a Linux screen reader written in Go. AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions). +## Start with the laptop keyboard layout + +Run `cthulhu -l` to use the laptop layout with Caps Lock as the Cthulhu +modifier for this session. No number pad is needed. The selection stays +active when switching applications and does not change the saved profile. +Starting without `-l` uses the saved keyboard settings again. + +Press Caps Lock+Space to open Preferences. Applying preferences replaces the +temporary selection; save the laptop layout there to make it permanent. +Use `cthulhu -l -s` to open Preferences at startup. + ## Login greeter settings Copy your saved active profile into the LightDM account's default Cthulhu diff --git a/cmd/cthulhu/main.go b/cmd/cthulhu/main.go index 2671602..a8fb3ee 100644 --- a/cmd/cthulhu/main.go +++ b/cmd/cthulhu/main.go @@ -71,6 +71,7 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (res greeter := options.String("greeter", "", "copy the saved active profile to a greeter account and exit (supported: lightdm)") shortSettings := options.Bool("s", false, "open the preferences window at startup") settingsFlag := options.Bool("settings", false, "open the preferences window at startup") + laptopLayout := options.Bool("l", false, "start with laptop layout and Caps Lock as the Cthulhu key for this session") preferencesUI := options.Bool("preferences-ui", false, "run the internal preferences helper (private pipe protocol)") notificationListUI := options.Bool("notification-list-ui", false, "run the internal notification list helper (private pipe protocol)") speechHistoryUI := options.Bool("speech-history-ui", false, "run the internal speech history helper (private pipe protocol)") @@ -248,6 +249,7 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (res TentacleCgroupParent: *tentacleParent, EnableRemote: true, OpenPreferences: *shortSettings || *settingsFlag, + LaptopLayout: *laptopLayout, Logger: logger, SpeechSocket: *speechSocket, Settings: &settings, diff --git a/cmd/cthulhu/main_test.go b/cmd/cthulhu/main_test.go index 0d6060d..cffabd0 100644 --- a/cmd/cthulhu/main_test.go +++ b/cmd/cthulhu/main_test.go @@ -27,6 +27,7 @@ import ( "testing" "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/version" "git.stormux.org/storm/cthulhu/internal/wine" ) @@ -62,12 +63,66 @@ func TestHelp(t *testing.T) { } if !strings.HasPrefix(help, versionOutput.String()+"\nUsage: cthulhu [options]\n\nOptions:\n") || !strings.Contains(help, "-s") || !strings.Contains(help, "-settings") || + !strings.Contains(help, "-l\t") || !strings.Contains(help, "laptop layout") || !strings.Contains(help, "-r") || !strings.Contains(help, "-replace") { t.Fatalf("help output does not contain expected version, usage, and flags: %q", help) } } } +func TestLaptopStartupDoesNotSaveKeyboard(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "settings.toml") + store, err := config.NewStore(path) + if err != nil { + t.Fatal(err) + } + settings := config.DefaultSettings() + settings.EnableSound = false + if err := store.Save(settings); err != nil { + t.Fatal(err) + } + original, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, laptop := range []bool{true, false} { + logPath := filepath.Join(directory, "startup.log") + arguments := []string{"--config", path, "--debug-file", logPath, + "--speech-socket", filepath.Join(directory, "absent.sock")} + if laptop { + arguments = append(arguments, "-l") + } + var output bytes.Buffer + err := run(t.Context(), arguments, &output, &output) + if err == nil || !strings.Contains(err.Error(), "connect to Speech Dispatcher") { + t.Fatalf("expected isolated startup failure, got %v", err) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), `"message":"using temporary laptop keyboard layout"`) != laptop { + t.Fatalf("startup laptop override = %t: %s", laptop, data) + } + current, err := os.ReadFile(path) + if err != nil || !bytes.Equal(current, original) { + t.Fatalf("startup changed saved profile: %v", err) + } + } +} + +func TestLaptopCheckConfigDoesNotCreateProfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.toml") + var output bytes.Buffer + if err := run(t.Context(), []string{"-l", "--check-config", "--config", path}, &output, &output); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("checking built-in defaults created a profile: %v", err) + } +} + func TestCheckConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "user-settings.toml") contents := []byte("[general]\nstartingProfile = [\"Default\", \"default\"]\n[profiles.default]\nspeakDescription = false\n") diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index 8db9bc3..fb493c2 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -4,7 +4,7 @@ pkgbase=cthulhu-git pkgname=cthulhu-git _pkgname=cthulhu -pkgver=2026.10.25.r566.g7ae2801 +pkgver=2026.10.26.r569.g7723bd7 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" diff --git a/internal/app/app.go b/internal/app/app.go index 3a42dc9..ec1a9eb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -110,6 +110,7 @@ type Options struct { TentacleCgroupParent string EnableRemote bool OpenPreferences bool + LaptopLayout bool newCommands func() commandListener onPreferences func() newSound func() soundService @@ -169,6 +170,12 @@ func Run(ctx context.Context, options Options) error { if options.Settings != nil { settings = *options.Settings } + savedSettings := settings + if options.LaptopLayout { + settings = laptopSettings(settings) + logger.Info("using temporary laptop keyboard layout", "keyboard_layout", settings.KeyboardLayout, + "cthulhu_modifier_keys", settings.CthulhuModifierKeys) + } if options.newAdapter == nil { options.newAdapter = func() Adapter { adapter := atspi.New() @@ -282,7 +289,9 @@ func Run(ctx context.Context, options Options) error { focusGate.tentacleOutput = tentacles.output services = append(services, lifecycle.Service{Name: "tentacles", Start: tentacles.Start, Stop: tentacles.Stop}) } - preferences := newPreferencesWorker(logger, settings, settingsUpdater) + preferences := newPreferencesWorker(logger, savedSettings, settingsUpdater) + preferences.laptopLayout = options.LaptopLayout + preferences.settings = settings preferences.tentacles = tentacles if tentacles != nil { tentacles.readerSettings = preferences diff --git a/internal/app/laptop_layout_test.go b/internal/app/laptop_layout_test.go new file mode 100644 index 0000000..3cd17c0 --- /dev/null +++ b/internal/app/laptop_layout_test.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "bytes" + "errors" + "log/slog" + "os" + "path/filepath" + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/preferences" +) + +func TestLaptopLayoutSessionAndExplicitPreferences(t *testing.T) { + global := config.DefaultSettings() + global.Path = filepath.Join(t.TempDir(), "settings.toml") + store, err := config.NewStore(global.Path) + if err != nil { + t.Fatal(err) + } + if err := store.Save(global); err != nil { + t.Fatal(err) + } + app := global + app.CthulhuModifierKeys = []string{"Insert"} + app.TimeFormat = "24-hour" + for _, name := range []string{"Cached Application", "New Application"} { + if err := store.SaveForApplication(name, app); err != nil { + t.Fatal(err) + } + } + original, err := os.ReadFile(global.Path) + if err != nil { + t.Fatal(err) + } + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global, acceptingRemoteSpeechSettings{}) + worker.laptopLayout = true + worker.settings = worker.sessionKeyboard(global) + worker.live = &liveSettings{} + worker.live.Store(worker.settings) + recorder := &keyboardRecorder{} + worker.bindings = recorder + checkLaptop := func(settings config.Settings) { + t.Helper() + if settings.KeyboardLayout != int(input.LaptopKeyboardLayout) || + !reflect.DeepEqual(settings.CthulhuModifierKeys, []string{"Caps_Lock"}) { + t.Fatalf("laptop keyboard lost: layout=%d keys=%v", settings.KeyboardLayout, settings.CthulhuModifierKeys) + } + } + checkLaptop(worker.settings) + worker.cycle_progress_output() + if worker.globalSettings.KeyboardLayout != global.KeyboardLayout || !reflect.DeepEqual(worker.globalSettings.CthulhuModifierKeys, global.CthulhuModifierKeys) { + t.Fatal("progress shortcut cached the startup keyboard in global settings") + } + for _, name := range []string{"Cached Application", "Other Application", "Cached Application", "New Application"} { + worker.switchApplication(t.Context(), name) + if worker.activeApp != name { + t.Fatal("application switch failed") + } + checkLaptop(worker.settings) + checkLaptop(worker.live.Load()) + if name != "Other Application" && worker.settings.TimeFormat != "24-hour" { + t.Fatal("startup keyboard override discarded application preferences") + } + } + current, err := os.ReadFile(global.Path) + if err != nil || !bytes.Equal(current, original) { + t.Fatalf("switching applications saved the override: %v", err) + } + // A remote session change and the speech-save shortcut must not promote + // the startup keyboard override into global or cached profile settings. + next := worker.settings + next.EnableKeyEcho = true + if response := worker.applyNext(t.Context(), next, false, false); !response.Accepted { + t.Fatal(response.Message) + } + worker.cycle_progress_output() + cached := worker.appSettings[worker.activeApp] + if cached.KeyboardLayout != app.KeyboardLayout || !reflect.DeepEqual(cached.CthulhuModifierKeys, app.CthulhuModifierKeys) { + t.Fatal("session changes cached the startup keyboard in application settings") + } + worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting) + worker.handleSpeechCommand(t.Context(), input.SaveSpeechSettings) + loaded, err := store.Load() + if err != nil || loaded.KeyboardLayout != global.KeyboardLayout || !reflect.DeepEqual(loaded.CthulhuModifierKeys, global.CthulhuModifierKeys) { + t.Fatalf("speech save changed the saved keyboard: %v", err) + } + worker.switchApplication(t.Context(), "Other Application") + worker.switchApplication(t.Context(), "New Application") + checkLaptop(worker.settings) + if !worker.settings.EnableKeyEcho { + t.Fatal("remote application change was lost") + } + if !worker.beginEdit(t.Context(), "") { + t.Fatal("could not open global preferences") + } + checkLaptop(worker.editing) + values := preferences.FromSettings(worker.editing) + values.KeyboardLayout = global.KeyboardLayout + values.CthulhuModifierKeys = global.CthulhuModifierKeys + recorder.err = errors.New("grab conflict") + if response := worker.apply(t.Context(), preferences.Request{Values: values}); response.Accepted || !worker.laptopLayout { + t.Fatal("failed preferences apply discarded the startup override") + } + checkLaptop(worker.settings) + // Saving the displayed laptop choice explicitly makes it permanent. + recorder.err = nil + values = preferences.FromSettings(worker.editing) + if response := worker.apply(t.Context(), preferences.Request{Values: values, Save: true}); !response.Accepted || worker.laptopLayout { + t.Fatalf("explicit save did not replace the startup override: %+v", response) + } + loaded, err = store.Load() + if err != nil { + t.Fatal(err) + } + checkLaptop(loaded) + worker.editingActive = false + if !worker.beginEdit(t.Context(), "") { + t.Fatal("could not reopen preferences") + } + values = preferences.FromSettings(worker.editing) + values.KeyboardLayout = global.KeyboardLayout + values.CthulhuModifierKeys = global.CthulhuModifierKeys + if response := worker.apply(t.Context(), preferences.Request{Values: values}); !response.Accepted { + t.Fatal(response.Message) + } + worker.editingActive = false + worker.switchApplication(t.Context(), "Another Application") + if worker.settings.KeyboardLayout != global.KeyboardLayout || !reflect.DeepEqual(worker.settings.CthulhuModifierKeys, global.CthulhuModifierKeys) { + t.Fatal("preferences could not replace the laptop keyboard for the session") + } +} diff --git a/internal/app/preferences.go b/internal/app/preferences.go index 98dff3f..7634fcb 100644 --- a/internal/app/preferences.go +++ b/internal/app/preferences.go @@ -47,6 +47,7 @@ type preferencesWorker struct { globalSettings config.Settings appSettings map[string]config.Settings quickSpeech quickSpeechSettings + laptopLayout bool activeApp string editApp string editing config.Settings diff --git a/internal/app/preferences_application.go b/internal/app/preferences_application.go index 20a5af1..fbcf732 100644 --- a/internal/app/preferences_application.go +++ b/internal/app/preferences_application.go @@ -9,8 +9,38 @@ import ( "strings" "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" ) +func laptopSettings(settings config.Settings) config.Settings { + settings.KeyboardLayout = int(input.LaptopKeyboardLayout) + settings.CthulhuModifierKeys = []string{"Caps_Lock"} + return settings +} + +// Keep the startup keyboard choice out of profile settings and speech saves. +// Explicit preferences Apply replaces it; otherwise it lasts until exit. +func (worker *preferencesWorker) sessionKeyboard(settings config.Settings) config.Settings { + if worker.laptopLayout { + return laptopSettings(settings) + } + return settings +} + +// Shortcut and remote changes start from runtime settings. Keep the profile's +// keyboard choice beneath the startup override when caching those changes. +func (worker *preferencesWorker) retainProfileKeyboard(settings config.Settings, application string) config.Settings { + if worker.laptopLayout { + base := worker.globalSettings + if cached, ok := worker.appSettings[application]; ok { + base = cached + } + settings.KeyboardLayout = base.KeyboardLayout + settings.CthulhuModifierKeys = append([]string(nil), base.CthulhuModifierKeys...) + } + return settings +} + func (worker *preferencesWorker) applicationSettings(application string) (config.Settings, error) { if settings, ok := worker.appSettings[application]; ok { return worker.quickSpeech.apply(settings), nil @@ -42,6 +72,7 @@ func (worker *preferencesWorker) switchApplication(ctx context.Context, applicat } settings, err := worker.applicationSettings(application) if err == nil { + settings = worker.sessionKeyboard(settings) err = worker.applyRuntime(ctx, settings) } if err != nil { @@ -83,5 +114,6 @@ func (worker *preferencesWorker) beginEdit(ctx context.Context, application stri } worker.editing = settings } + worker.editing = worker.sessionKeyboard(worker.editing) return true } diff --git a/internal/app/preferences_apply.go b/internal/app/preferences_apply.go index 90ae05f..d2a8255 100644 --- a/internal/app/preferences_apply.go +++ b/internal/app/preferences_apply.go @@ -68,6 +68,10 @@ func (worker *preferencesWorker) applyNext(ctx context.Context, next config.Sett if err := worker.applyRuntime(ctx, next); err != nil { return worker.response(err.Error(), false) } + if worker.editingActive { + worker.laptopLayout = false + } + next = worker.retainProfileKeyboard(next, scopeApp) // An explicit preferences Apply replaces the session's quick speech choices. if worker.editingActive { worker.quickSpeech = quickSpeechSettings{} diff --git a/internal/app/progress_settings.go b/internal/app/progress_settings.go index 5cea800..5afd2fc 100644 --- a/internal/app/progress_settings.go +++ b/internal/app/progress_settings.go @@ -22,13 +22,14 @@ func (worker *preferencesWorker) cycle_progress_output() { default: next.ProgressBar.OutputMode, message = "both", "Beep and speak progress bar updates" } + profileNext := worker.retainProfileKeyboard(next, worker.activeApp) if worker.activeApp != "" { - worker.appSettings[worker.activeApp] = next + worker.appSettings[worker.activeApp] = profileNext } else { for application, settings := range worker.appSettings { - worker.appSettings[application] = config.RebaseApplicationSettings(worker.globalSettings, next, settings) + worker.appSettings[application] = config.RebaseApplicationSettings(worker.globalSettings, profileNext, settings) } - worker.globalSettings = next + worker.globalSettings = profileNext } worker.commit_settings(next) worker.announceSpeechSetting(message)