Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user