// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package speechhistoryui /* #cgo pkg-config: gtk+-3.0 #include #include "native.h" */ import "C" import ( "bufio" "context" "encoding/json" "errors" "io" "runtime" "time" "unsafe" ) type Message struct { Mode string `json:"mode,omitempty"` Items []string `json:"items"` Selected int `json:"selected,omitempty"` Append string `json:"append,omitempty"` Present bool `json:"present,omitempty"` Close bool `json:"close,omitempty"` } type Request struct { Action string `json:"action"` Selection string `json:"selection,omitempty"` Row int `json:"row,omitempty"` } // Run keeps GTK on one OS thread and exchanges bounded messages with Cthulhu. 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 Message, 16) readErrors := make(chan error, 1) go func() { scanner := bufio.NewScanner(input) scanner.Buffer(make([]byte, 4096), 1024*1024) for scanner.Scan() { var update Message if err := json.Unmarshal(scanner.Bytes(), &update); err != nil { readErrors <- err return } select { case updates <- update: case <-ctx.Done(): return } } readErrors <- scanner.Err() }() var initial Message select { case initial = <-updates: case err := <-readErrors: return err case <-ctx.Done(): return ctx.Err() } monitor := initial.Mode == "monitor" mode := C.int(0) if monitor { mode = 1 } if C.history_init(mode) == 0 { return errors.New("cannot open speech history on this display") } defer C.history_close() if !monitor { setItems(initial.Items, initial.Selected) } encoder := json.NewEncoder(output) // AT-SPI requests are served by GTK's main context. A longer polling // interval is paid once for every synchronous accessibility query, so a // single arrow key can accumulate hundreds of milliseconds of delay. ticker := time.NewTicker(time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case err := <-readErrors: return err case update := <-updates: if update.Close { return nil } if update.Present { C.history_present() } if update.Items != nil { setItems(update.Items, update.Selected) } if update.Append != "" { value := C.CString(update.Append) C.history_append(value) C.free(unsafe.Pointer(value)) } case <-ticker.C: C.history_iterate() switch C.history_action() { case 1: if C.history_copy() != 0 { if err := encoder.Encode(Request{Action: "copy"}); err != nil { return err } } else if err := encoder.Encode(Request{Action: "empty"}); err != nil { return err } case 2: if err := encoder.Encode(Request{Action: "remove", Selection: C.GoString(C.history_selected()), Row: int(C.history_selected_row())}); err != nil { return err } case 3: return nil case 4: C.history_clear() } } } } func setItems(items []string, selected int) { values := make([]*C.char, len(items)) for i, item := range items { values[i] = C.CString(item) } defer func() { for _, value := range values { C.free(unsafe.Pointer(value)) } }() if len(values) == 0 { C.history_set_items(nil, 0, 0) return } C.history_set_items((**C.char)(unsafe.Pointer(&values[0])), C.int(len(values)), C.int(selected)) }