Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "native.h"
#include <gtk/gtk.h>
#include <gdk/gdk.h>
enum { NONE, COPY, REMOVE, CLOSE, CLEAR };
static GtkWidget *window, *filter, *tree, *text_view;
static GtkListStore *store;
static GtkTreeModel *filtered;
static GtkTreeSelection *selection;
static int action_code, monitor_mode, line_count;
static char *selected_cache;
static void action(GtkButton *button, gpointer code) {
(void)button;
if (!action_code) action_code = GPOINTER_TO_INT(code);
}
static gboolean deleted(GtkWidget *widget, GdkEvent *event, gpointer data) {
(void)widget; (void)event; (void)data;
if (!action_code) action_code = CLOSE;
return TRUE;
}
static gboolean key_press(GtkWidget *widget, GdkEventKey *event, gpointer data) {
(void)widget; (void)data;
if (event->keyval == GDK_KEY_Escape) { action_code = CLOSE; return TRUE; }
if (monitor_mode || !filter || !gtk_widget_has_focus(tree)) return FALSE;
if (event->keyval == GDK_KEY_BackSpace) {
const char *current = gtk_entry_get_text(GTK_ENTRY(filter));
glong length = g_utf8_strlen(current, -1);
if (length > 0) {
char *shorter = g_utf8_substring(current, 0, length - 1);
gtk_entry_set_text(GTK_ENTRY(filter), shorter);
gtk_editable_set_position(GTK_EDITABLE(filter), -1);
g_free(shorter);
return TRUE;
}
return FALSE;
}
if (event->state & (GDK_CONTROL_MASK | GDK_MOD1_MASK | GDK_SUPER_MASK | GDK_META_MASK)) return FALSE;
gunichar ch = gdk_keyval_to_unicode(event->keyval);
if (!ch || !g_unichar_isprint(ch)) return FALSE;
char utf8[7] = {0};
g_unichar_to_utf8(ch, utf8);
char *updated = g_strconcat(gtk_entry_get_text(GTK_ENTRY(filter)), utf8, NULL);
gtk_entry_set_text(GTK_ENTRY(filter), updated);
gtk_editable_set_position(GTK_EDITABLE(filter), -1);
gtk_widget_grab_focus(filter);
g_free(updated);
return TRUE;
}
static gboolean visible(GtkTreeModel *model, GtkTreeIter *iter, gpointer data) {
(void)data;
char *query = g_strstrip(g_strdup(gtk_entry_get_text(GTK_ENTRY(filter))));
if (!query[0]) { g_free(query); return TRUE; }
char *value = NULL;
gtk_tree_model_get(model, iter, 0, &value, -1);
char *folded_value = g_utf8_casefold(value ? value : "", -1);
char *folded_query = g_utf8_casefold(query, -1);
gboolean match = g_str_has_prefix(folded_value, folded_query);
g_free(value); g_free(query); g_free(folded_value); g_free(folded_query);
return match;
}
static void filter_changed(GtkEditable *editable, gpointer data) {
(void)editable; (void)data;
gtk_tree_model_filter_refilter(GTK_TREE_MODEL_FILTER(filtered));
GtkTreeIter first;
if (gtk_tree_model_get_iter_first(filtered, &first)) {
GtkTreePath *path = gtk_tree_model_get_path(filtered, &first);
gtk_tree_selection_select_path(selection, path);
gtk_tree_path_free(path);
} else gtk_tree_selection_unselect_all(selection);
}
static GtkWidget *button(GtkWidget *box, const char *label, int code) {
GtkWidget *widget = gtk_button_new_with_label(label);
g_signal_connect(widget, "clicked", G_CALLBACK(action), GINT_TO_POINTER(code));
gtk_box_pack_start(GTK_BOX(box), widget, FALSE, FALSE, 0);
return widget;
}
int history_init(int monitor) {
if (!gtk_init_check(NULL, NULL)) return 0;
monitor_mode = monitor;
line_count = 0;
g_set_application_name(monitor ? "Cthulhu Speech Monitor" : "Cthulhu Speech History");
window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), monitor ? "Speech Monitor - Cthulhu" : "Speech History - Cthulhu");
gtk_window_set_default_size(GTK_WINDOW(window), monitor ? 900 : 700, monitor ? 320 : 420);
gtk_container_set_border_width(GTK_CONTAINER(window), 10);
if (monitor) {
gtk_window_set_keep_above(GTK_WINDOW(window), TRUE);
gtk_window_set_focus_on_map(GTK_WINDOW(window), FALSE);
gtk_window_set_type_hint(GTK_WINDOW(window), GDK_WINDOW_TYPE_HINT_UTILITY);
} else gtk_window_set_modal(GTK_WINDOW(window), TRUE);
g_signal_connect(window, "delete-event", G_CALLBACK(deleted), NULL);
g_signal_connect(window, "key-press-event", G_CALLBACK(key_press), NULL);
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_container_add(GTK_CONTAINER(window), box);
if (!monitor) {
GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
GtkWidget *label = gtk_label_new_with_mnemonic("_Filter:");
filter = gtk_entry_new();
gtk_widget_set_hexpand(filter, TRUE);
gtk_label_set_mnemonic_widget(GTK_LABEL(label), filter);
gtk_box_pack_start(GTK_BOX(row), label, FALSE, FALSE, 0);
gtk_box_pack_start(GTK_BOX(row), filter, TRUE, TRUE, 0);
gtk_box_pack_start(GTK_BOX(box), row, FALSE, FALSE, 0);
store = gtk_list_store_new(1, G_TYPE_STRING);
filtered = gtk_tree_model_filter_new(GTK_TREE_MODEL(store), NULL);
gtk_tree_model_filter_set_visible_func(GTK_TREE_MODEL_FILTER(filtered), visible, NULL, NULL);
g_signal_connect(filter, "changed", G_CALLBACK(filter_changed), NULL);
}
GtkWidget *scroll = gtk_scrolled_window_new(NULL, NULL);
gtk_widget_set_vexpand(scroll, TRUE);
gtk_box_pack_start(GTK_BOX(box), scroll, TRUE, TRUE, 0);
if (monitor) {
text_view = gtk_text_view_new();
gtk_text_view_set_editable(GTK_TEXT_VIEW(text_view), FALSE);
gtk_text_view_set_cursor_visible(GTK_TEXT_VIEW(text_view), FALSE);
gtk_widget_set_can_focus(text_view, FALSE);
gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(text_view), GTK_WRAP_WORD_CHAR);
AtkObject *accessible = gtk_widget_get_accessible(text_view);
if (accessible) atk_object_set_name(accessible, "Speech monitor output");
gtk_container_add(GTK_CONTAINER(scroll), text_view);
} else {
tree = gtk_tree_view_new_with_model(filtered);
GtkCellRenderer *renderer = gtk_cell_renderer_text_new();
g_object_set(renderer, "wrap-width", 640, "wrap-mode", PANGO_WRAP_WORD_CHAR, NULL);
GtkTreeViewColumn *column = gtk_tree_view_column_new_with_attributes("Spoken Text", renderer, "text", 0, NULL);
gtk_tree_view_column_set_expand(column, TRUE);
gtk_tree_view_column_set_resizable(column, TRUE);
gtk_tree_view_append_column(GTK_TREE_VIEW(tree), column);
selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree));
gtk_tree_selection_set_mode(selection, GTK_SELECTION_SINGLE);
gtk_container_add(GTK_CONTAINER(scroll), tree);
}
GtkWidget *buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(buttons, GTK_ALIGN_END);
gtk_box_pack_start(GTK_BOX(box), buttons, FALSE, FALSE, 0);
if (monitor) button(buttons, "Clear", CLEAR);
else {
button(buttons, "Copy to clipboard", COPY);
button(buttons, "Remove from history", REMOVE);
}
button(buttons, "Close", CLOSE);
gtk_widget_show_all(window);
if (!monitor) gtk_widget_grab_focus(filter);
return 1;
}
void history_set_items(char **items, int count, int selected) {
if (!store) return;
gtk_list_store_clear(store);
for (int i = 0; i < count; i++) {
GtkTreeIter iter;
gtk_list_store_append(store, &iter);
gtk_list_store_set(store, &iter, 0, items[i], -1);
}
if (count > 0) {
GtkTreePath *path = gtk_tree_path_new_from_indices(selected >= 0 && selected < count ? selected : 0, -1);
gtk_tree_selection_select_path(selection, path);
gtk_tree_path_free(path);
gtk_widget_grab_focus(tree);
} else gtk_widget_grab_focus(filter);
}
void history_append(const char *text) {
if (!text_view || !text) return;
GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(text_view));
GtkTextIter end;
gtk_text_buffer_get_end_iter(buffer, &end);
gtk_text_buffer_insert(buffer, &end, text, -1);
gtk_text_buffer_insert(buffer, &end, "\n", 1);
if (++line_count > 500) {
GtkTextIter start, cut;
gtk_text_buffer_get_start_iter(buffer, &start);
gtk_text_buffer_get_iter_at_line(buffer, &cut, line_count - 500);
gtk_text_buffer_delete(buffer, &start, &cut);
line_count = 500;
}
gtk_text_buffer_get_end_iter(buffer, &end);
GtkTextMark *mark = gtk_text_buffer_create_mark(buffer, NULL, &end, FALSE);
gtk_text_view_scroll_mark_onscreen(GTK_TEXT_VIEW(text_view), mark);
gtk_text_buffer_delete_mark(buffer, mark);
}
void history_present(void) { if (window) gtk_window_present(GTK_WINDOW(window)); }
void history_iterate(void) { while (gtk_events_pending()) gtk_main_iteration(); }
int history_action(void) { int result = action_code; action_code = NONE; return result; }
const char *history_selected(void) {
GtkTreeIter iter; GtkTreeModel *model = NULL;
if (!selection || !gtk_tree_selection_get_selected(selection, &model, &iter)) return "";
g_free(selected_cache);
gtk_tree_model_get(model, &iter, 0, &selected_cache, -1);
return selected_cache ? selected_cache : "";
}
int history_selected_row(void) {
GtkTreeIter iter; GtkTreeModel *model = NULL;
if (!selection || !gtk_tree_selection_get_selected(selection, &model, &iter)) return 0;
GtkTreePath *path = gtk_tree_model_get_path(model, &iter);
int *indices = gtk_tree_path_get_indices(path);
int row = indices ? indices[0] : 0;
gtk_tree_path_free(path);
return row;
}
int history_copy(void) {
const char *text = history_selected();
if (!text || !text[0]) return 0;
GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
gtk_clipboard_set_text(clipboard, text, -1);
gtk_clipboard_store(clipboard);
return 1;
}
void history_clear(void) {
if (!text_view) return;
gtk_text_buffer_set_text(gtk_text_view_get_buffer(GTK_TEXT_VIEW(text_view)), "", 0);
line_count = 0;
}
void history_close(void) {
if (window) gtk_widget_destroy(window);
window = filter = tree = text_view = NULL;
store = NULL; filtered = NULL; selection = NULL;
g_free(selected_cache); selected_cache = NULL;
}
+14
View File
@@ -0,0 +1,14 @@
#ifndef CTHULHU_SPEECH_HISTORY_UI_H
#define CTHULHU_SPEECH_HISTORY_UI_H
int history_init(int monitor);
void history_set_items(char **items, int count, int selected);
void history_append(const char *text);
void history_present(void);
void history_iterate(void);
int history_action(void);
const char *history_selected(void);
int history_selected_row(void);
int history_copy(void);
void history_clear(void);
void history_close(void);
#endif
+151
View File
@@ -0,0 +1,151 @@
// 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 <stdlib.h>
#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))
}