// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "strings" "sync" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/speech" ) type speechSettingsUpdater interface { UpdateSettings(context.Context, speech.Settings) error } type brailleSettingsUpdater interface { Configure(context.Context, bool) error } // preferencesWorker owns session settings and at most one UI child. Open has a // capacity-one, coalescing mailbox: repeated commands cannot queue windows or // block input. The child permits one outstanding apply; messages are capped at // 512 KiB. Shutdown cancels backend I/O, terminates the child, and joins the worker. type preferencesWorker struct { tentacles *tentacleRuntime logger *slog.Logger settings config.Settings globalSettings config.Settings appSettings map[string]config.Settings activeApp string editApp string editing config.Settings editingActive bool choosingApplication bool updater any open chan string applicationChanges chan string done chan struct{} cancel context.CancelFunc once sync.Once live *liveSettings history *speechHistory speechWorker *speechWorker refreshHistory func() sound soundSettingsUpdater geometry soundGeometryCaptureConfigurer textAttributes textAttributeCaptureConfigurer bindings interface { SetKeyBindings(context.Context, []input.BindingOverride) error } braille brailleSettingsUpdater documentReview *review.Worker socket string speechCommands chan input.Command remoteChanges chan remoteSettingsChange speechSetting int announce func(string) announceApply func(string) catalog speech.Catalog discover func(context.Context, string) (speech.Catalog, error) } func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker { return &preferencesWorker{logger: logger, settings: settings, globalSettings: settings, appSettings: make(map[string]config.Settings), updater: updater, speechCommands: make(chan input.Command, 16), remoteChanges: make(chan remoteSettingsChange, 16), discover: speech.Discover, open: make(chan string, 1), applicationChanges: make(chan string, 1), done: make(chan struct{})} } func (worker *preferencesWorker) Start(ctx context.Context) error { ctx, worker.cancel = context.WithCancel(ctx) go func() { defer close(worker.done) for { select { case <-ctx.Done(): return case command := <-worker.speechCommands: worker.handleSpeechCommand(ctx, command) case change := <-worker.remoteChanges: change.result <- worker.applyRemoteChange(ctx, change) case application := <-worker.applicationChanges: worker.switchApplication(ctx, application) case application := <-worker.open: worker.choosingApplication = application == "\x01" if application != "\x01" && !worker.beginEdit(ctx, application) { continue } if err := worker.window(ctx); err != nil && ctx.Err() == nil { worker.logger.Error("preferences window", "error", err) } worker.editingActive = false worker.choosingApplication = false select { case application := <-worker.applicationChanges: worker.switchApplication(ctx, application) default: } } } }() return nil } func (worker *preferencesWorker) Open() { select { case worker.open <- "": default: } } func (worker *preferencesWorker) OpenApplication() { select { case worker.open <- "\x00": default: } } func (worker *preferencesWorker) ChooseApplication() { select { case worker.open <- "\x01": default: } } // Focus handling only publishes the latest foreground application and never // waits for settings I/O on the native event loop. func (worker *preferencesWorker) SetApplication(application string) { application = strings.TrimSpace(application) if application == "" || strings.HasPrefix(strings.ToLower(application), "cthulhu") { return } if worker.live != nil { worker.live.requestedApplication.Store(&application) worker.live.notify_clipboard() } select { case worker.applicationChanges <- application: default: select { case <-worker.applicationChanges: default: } select { case worker.applicationChanges <- application: default: } } } func (worker *preferencesWorker) Stop(ctx context.Context) error { worker.once.Do(func() { if worker.cancel != nil { worker.cancel() } }) select { case <-worker.done: return nil case <-ctx.Done(): return ctx.Err() } }