// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "reflect" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/preferences" ) func (worker *preferencesWorker) response(message string, accepted bool) preferences.Response { settings := worker.settings if worker.editingActive { settings = worker.editing } store, err := config.NewStore(settings.Path) if err == nil { _, err = store.Load() } return preferences.Response{Values: preferences.FromSettings(settings), CanSave: err == nil, Message: message, Accepted: accepted} } func (worker *preferencesWorker) apply(ctx context.Context, request preferences.Request) preferences.Response { base := worker.settings if worker.editingActive { base = worker.editing } next, err := request.Values.Merge(base) if err != nil { return worker.response("Settings were not applied: "+err.Error(), false) } return worker.applyNext(ctx, next, request.Save, request.Close) } func (worker *preferencesWorker) applyNext(ctx context.Context, next config.Settings, save, close bool) preferences.Response { scopeApp := worker.activeApp if worker.editingActive { scopeApp = worker.editApp } if err := config.Validate(next); err != nil { return worker.response("Settings were not applied: "+err.Error(), false) } if save && !worker.response("", false).CanSave { return worker.response("This settings file cannot be saved by the Go preview. Use Apply for this session.", false) } if err := worker.applyRuntime(ctx, next); err != nil { return worker.response(err.Error(), false) } if worker.editingActive { worker.editing = next } if scopeApp != "" { worker.appSettings[scopeApp] = next } else { oldGlobal := worker.globalSettings for application, settings := range worker.appSettings { worker.appSettings[application] = config.RebaseApplicationSettings(oldGlobal, next, settings) } worker.globalSettings = next } message := "Settings applied for this session." if save { store, err := config.NewStore(next.Path) if err == nil { if scopeApp != "" { err = store.SaveForApplication(scopeApp, next) } else { err = store.Save(next) } } if err != nil { message = "Applied for this session, but could not save: " + err.Error() } else { message = "Settings applied and saved." } } response := worker.response(message, true) response.Close = close && save && message == "Settings applied and saved." if worker.editingActive && worker.editApp != worker.activeApp { previousApp := worker.activeApp worker.activeApp = "" if previousApp != "" { worker.switchApplication(ctx, previousApp) } } return response } func (worker *preferencesWorker) applyRuntime(ctx context.Context, next config.Settings) error { keyboardChanged := next.KeyboardLayout != worker.settings.KeyboardLayout || !reflect.DeepEqual(next.CthulhuModifierKeys, worker.settings.CthulhuModifierKeys) bindingsChanged := keyboardChanged || !reflect.DeepEqual(next.KeyBindings, worker.settings.KeyBindings) bindingsCommitted := false if bindingsChanged { if worker.bindings == nil { return errors.New("Key bindings cannot be changed by this input backend.") } if err := worker.apply_keyboard(ctx, next, keyboardChanged); err != nil { return errors.New("Key bindings were not applied: " + err.Error()) } defer func() { if !bindingsCommitted { rollbackCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := worker.apply_keyboard(rollbackCtx, worker.settings, keyboardChanged); err != nil { worker.logger.Error("restore key bindings after failed preferences apply", "error", err) } } }() } speechChanged := next.SpeechServerFactory != worker.settings.SpeechServerFactory || !reflect.DeepEqual(next.UppercaseVoice, worker.settings.UppercaseVoice) || !reflect.DeepEqual(next.HyperlinkVoice, worker.settings.HyperlinkVoice) || !reflect.DeepEqual(next.SystemVoice, worker.settings.SystemVoice) || next.HardwareSpeechDevice != worker.settings.HardwareSpeechDevice || next.HardwareSpeechBaudRate != worker.settings.HardwareSpeechBaudRate || !reflect.DeepEqual(speechSettingsFromConfig(next), speechSettingsFromConfig(worker.settings)) || next.UseCustomEchoVoice != worker.settings.UseCustomEchoVoice || (next.UseCustomEchoVoice && !reflect.DeepEqual(echoSpeechSettings(next), echoSpeechSettings(worker.settings))) if next.EnableSpeech && (speechChanged || !worker.settings.EnableSpeech) { if message := worker.apply_speech_settings(ctx, next); message != "" { return errors.New(message) } } if next.EnableBraille != worker.settings.EnableBraille { if worker.braille == nil { return errors.New("Braille is unavailable in this build.") } if err := worker.braille.Configure(ctx, next.EnableBraille); err != nil { return errors.New("Braille settings were not applied: " + err.Error()) } } if next.EnableContractedBraille != worker.settings.EnableContractedBraille || next.BrailleContractionTable != worker.settings.BrailleContractionTable { if updater, ok := worker.braille.(interface{ SetContraction(bool, string) }); ok { updater.SetContraction(next.EnableContractedBraille, next.BrailleContractionTable) } } bindingsCommitted = true worker.commit_settings(next) return nil } func (worker *preferencesWorker) apply_keyboard(ctx context.Context, settings config.Settings, keyboardChanged bool) error { if updater, ok := worker.bindings.(interface { SetKeyboardConfiguration(context.Context, input.KeyboardLayout, []string, []input.BindingOverride) error }); ok { return updater.SetKeyboardConfiguration(ctx, input.KeyboardLayout(settings.KeyboardLayout), settings.CthulhuModifierKeys, settings.KeyBindings) } if keyboardChanged { return errors.New("this input backend cannot change Cthulhu keys or keyboard layout") } return worker.bindings.SetKeyBindings(ctx, settings.KeyBindings) } func (worker *preferencesWorker) apply_speech_settings(ctx context.Context, next config.Settings) string { var err error if worker.updater == nil { return "Speech is disabled or this backend cannot change settings." } if updater, ok := worker.updater.(interface { UpdateConfiguration(context.Context, config.Settings) error }); ok { err = updater.UpdateConfiguration(ctx, next) } else if updater, ok := worker.updater.(speechSettingsUpdater); ok { if next.UseCustomEchoVoice { return "This speech backend cannot use a custom echo voice." } err = updater.UpdateSettings(ctx, speechSettingsFromConfig(next)) } else { return "This backend cannot change speech settings." } if err != nil { return "Settings were not applied: " + err.Error() } return "" } // Commit follows the existing backend updates and keyboard rollback boundary. // It publishes settings to runtime consumers without changing their ownership. func (worker *preferencesWorker) commit_settings(next config.Settings) { soundChanged := soundConfiguration(next) != soundConfiguration(worker.settings) worker.settings = next if updater, ok := worker.braille.(interface{ SetWordWrap(bool) }); ok { updater.SetWordWrap(next.Braille.WordWrap) } if worker.speechWorker != nil && !next.EnableSpeech { worker.speechWorker.SetEnabled(false) } if worker.history != nil { worker.history.setLimit(next.SpeechHistoryLimit) if worker.refreshHistory != nil { worker.refreshHistory() } } if worker.documentReview != nil { worker.documentReview.SetDocumentBrailleEnabled(next.EnableBraille) } // Configure cancels playback. An application switch or unrelated setting // must leave the startup cue running when its sound policy is unchanged. if worker.sound != nil && soundChanged { worker.sound.Configure(soundConfiguration(next)) } if worker.geometry != nil { worker.geometry.SetCaptureSoundGeometry(captureSoundGeometry(next)) } if worker.textAttributes != nil { worker.textAttributes.SetCaptureTextAttributes(captureTextAttributes(next)) } if worker.live != nil { worker.live.Store(next) } if worker.speechWorker != nil && next.EnableSpeech { worker.speechWorker.SetEnabled(true) } }