Start cthulhu in laptop layout with -l.

This commit is contained in:
Storm Dragon committed 2026-10-26 21:14:22 -04:00
1 parent 7723bd7e09
commit d8b59aa05b
10 files changed
+259 -5

No files matched your search

+11
View File
@@ -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
+2
View File
@@ -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,
+55
View File
@@ -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")
@@ -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"
+10 -1
View File
@@ -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
+139
View File
@@ -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")
}
}
+1
View File
@@ -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
+32
View File
@@ -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
}
+4
View File
@@ -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{}
+4 -3
View File
@@ -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)