Start cthulhu in laptop layout with -l.
This commit is contained in:
1 parent
7723bd7e09
commit
d8b59aa05b
10 files changed
+259
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user