// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package preferences /* #cgo pkg-config: gtk+-3.0 #include #include "native.h" */ import "C" import ( "context" _ "embed" "encoding/json" "errors" "io" "runtime" "time" "unsafe" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/tentacle" ) //go:embed preferences.ui var layout string // Run owns all GTK calls on one OS thread. Its caller supplies private pipes; // closing the parent pipe closes the window. No widget invokes speech directly. func Run(ctx context.Context, input io.Reader, output io.Writer) error { runtime.LockOSThread() defer runtime.UnlockOSThread() ctx, cancel := context.WithCancel(ctx) defer cancel() updates := make(chan Response, 1) errorsIn := make(chan error, 1) go read_responses(ctx, input, updates, errorsIn) var initial Response select { case initial = <-updates: case err := <-errorsIn: return err case <-ctx.Done(): return ctx.Err() } if initial.ChooseApplication { response, chosen, err := choose_application(ctx, updates, errorsIn, output) if err != nil || !chosen { return err } initial = response } xml := C.CString(layout) defer C.free(unsafe.Pointer(xml)) if C.prefs_init(xml) == 0 { return errors.New("cannot open GTK preferences on this display") } defer C.prefs_close() if initial.Application != "" { name := C.CString(initial.Application) C.prefs_set_application(name) C.free(unsafe.Pointer(name)) } session := &uiSession{catalog: initial.Catalog, soundThemes: []string{"default", "none"}} session.setValues(initial.Values) tentacles := &tentaclePage{} tentacles.populate(initial.Tentacles) var tentaclePending *tentacle.ManagementRequest C.prefs_voice_changed() status(initial.Message, false) canSave, busy := initial.CanSave, false previewPending, previewDirty := false, false C.prefs_busy(0, boolean(canSave)) encoder := json.NewEncoder(output) // AT-SPI calls into this GTK process are served by the same event pump. // Focus snapshots make several synchronous calls, so a 10 ms poll adds // noticeable delay to every Tab stop in the dialog. ticker := time.NewTicker(time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case err := <-errorsIn: return err case response := <-updates: if response.Present { C.prefs_present() continue } if response.Preview { previewPending = false if !response.Accepted { status(response.Message, true) } continue } if response.Close { return nil } if tentaclePending != nil { settingsOpened := tentaclePending.Action == "settings" && response.Accepted // Management commits independently of the ordinary settings draft. // Its response does not contain Values or CanSave. if response.Tentacles != nil { tentacles.populate(response.Tentacles) if response.Accepted { tentacles.accepted(tentaclePending) } } tentaclePending, busy = nil, false C.prefs_busy(0, boolean(canSave)) if !settingsOpened { status(response.Message, true) } continue } busy, canSave = false, response.CanSave if response.Accepted { session.setValues(response.Values) C.prefs_voice_changed() } C.prefs_busy(0, boolean(canSave)) status(response.Message, !response.Accepted) case <-ticker.C: C.prefs_iterate() session.refreshEchoVoices() session.refreshSpeechVoices() session.refreshKeyBindingLayout() action := C.prefs_action() if action >= 9 && action <= 20 && !busy { request, err := tentacles.action(int(action)) if err != nil { status(err.Error(), true) continue } if request != nil { if err := encoder.Encode(Request{Tentacle: request}); err != nil { return err } tentaclePending, busy = request, true // Keep the focused Settings button enabled while the internal // form takes keyboard ownership without moving native focus. if request.Action != "settings" { C.prefs_busy(1, boolean(canSave)) } if request.Action == "install" || request.Action == "install-enable" { status("Downloading and checking the tentacle package. Close preferences to cancel before it is saved.", true) } } continue } if action == 8 { setTextAttributes(config.DefaultTextAttributes()) continue } if action >= 4 && action <= 7 { session.editKeyBinding(int(action)) continue } if action == 3 { // The parent restores any active preview when this helper exits. return nil } if C.prefs_voice_changed() != 0 { previewDirty = true } if (action == 1 || action == 2) && !busy { values, err := session.getValues() if err != nil { status(err.Error(), true) continue } if err := encoder.Encode(Request{Values: values, Save: canSave, Close: action == 2}); err != nil { return err } busy = true previewDirty = false C.prefs_busy(1, boolean(canSave)) continue } if !previewDirty || previewPending || busy { continue } values, err := session.getValues() if err != nil { status(err.Error(), true) previewDirty = false continue } if err := encoder.Encode(Request{Values: values, Preview: true}); err != nil { return err } previewPending = true previewDirty = false } } }