From 75f34ea7679b709374d381acf55cce23a8ef1ded Mon Sep 17 00:00:00 2001 From: Hunter Jozwiak Date: Mon, 2 Nov 2026 02:49:33 -0500 Subject: [PATCH] Add global Wine preferences tab Phase 3 of the Wine/Proton stage (decisions 6, 9, 13, 19 in docs/testing/wine-proton.md): a global-only Wine tab modeled on the Pronunciation page, with the controller-only checkbox and a typed editable program list. Wine values flow through the preferences protocol so a global Apply reaches the live Wine settings from Phase 2 with no reader restart. Entries are validated as edited and named in-tab, blocking Apply until fixed; case-insensitive duplicates collapse. Per-application preferences expose no Wine editing and an app-scoped Apply cannot change the governing list. --- internal/app/preferences_apply.go | 8 ++ internal/app/wine_preferences_test.go | 108 ++++++++++++++++++++++++ internal/preferences/native.c | 27 +++++- internal/preferences/native.h | 7 ++ internal/preferences/native_internal.h | 1 + internal/preferences/native_wine.c | 85 +++++++++++++++++++ internal/preferences/preferences.ui | 10 +++ internal/preferences/protocol.go | 3 + internal/preferences/session.go | 2 + internal/preferences/ui.go | 6 +- internal/preferences/ui_values.go | 14 ++++ internal/preferences/values.go | 10 ++- internal/preferences/wine.go | 112 +++++++++++++++++++++++++ internal/preferences/wine_test.go | 97 +++++++++++++++++++++ tests/native_preferences.py | 58 +++++++++++++ 15 files changed, 543 insertions(+), 5 deletions(-) create mode 100644 internal/app/wine_preferences_test.go create mode 100644 internal/preferences/native_wine.c create mode 100644 internal/preferences/wine.go create mode 100644 internal/preferences/wine_test.go diff --git a/internal/app/preferences_apply.go b/internal/app/preferences_apply.go index 849842c..39e01bc 100644 --- a/internal/app/preferences_apply.go +++ b/internal/app/preferences_apply.go @@ -34,6 +34,14 @@ func (worker *preferencesWorker) apply(ctx context.Context, request preferences. if worker.editingActive { base = worker.editing } + if worker.editingActive && worker.editApp != "" { + // Wine settings are global-only and the Wine tab is absent from + // application preferences, so an application-scoped Apply never + // edits them: the base values pass through unchanged and only a + // global Apply can republish the governing Wine list. + request.Values.Wine = config.WineSettings{ControllerOnly: base.Wine.ControllerOnly, + ExcludedPrograms: append([]string(nil), base.Wine.ExcludedPrograms...)} + } next, err := request.Values.Merge(base) if err != nil { return worker.response("Settings were not applied: "+err.Error(), false) diff --git a/internal/app/wine_preferences_test.go b/internal/app/wine_preferences_test.go new file mode 100644 index 0000000..f3ecb18 --- /dev/null +++ b/internal/app/wine_preferences_test.go @@ -0,0 +1,108 @@ +package app + +import ( + "context" + "log/slog" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/speech" + "git.stormux.org/storm/cthulhu/internal/wine" +) + +// The Wine tab is global-only: a global preferences Apply carries the edited +// Wine values through the preferences protocol into the live global Wine +// source the manager reads, persists them through TOML, and needs no reader +// restart. An application-scoped Apply never changes the governing values. +func TestWinePreferencesGlobalApplyGovernsAndPersists(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + global := config.DefaultSettings() + global.Path = path + global.Wine = config.WineSettings{ExcludedPrograms: []string{"Game.exe"}} + store, err := config.NewStore(path) + if err != nil { + t.Fatal(err) + } + if err := store.Save(global); err != nil { + t.Fatal(err) + } + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global, + settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil })) + worker.live = &liveSettings{} + worker.live.Store(global) + worker.live.StoreGlobalWine(global.Wine) + manager := newWineManager(slog.New(slog.DiscardHandler), worker.live) + governing := func() wine.Settings { return manager.SettingsSource() } + ctx := context.Background() + + // An invalid entry is named and blocks the global Apply. + if !worker.beginEdit(ctx, "") { + t.Fatal("global preferences did not open") + } + invalid := preferences.FromSettings(worker.editing) + invalid.Wine.ExcludedPrograms = []string{"Game.exe", `C:\Games\Sneaky.exe`} + response := worker.apply(ctx, preferences.Request{Values: invalid, Save: true}) + if response.Accepted || !strings.Contains(response.Message, `C:\Games\Sneaky.exe`) { + t.Fatalf("invalid Wine entry was not named and blocked: %+v", response) + } + if want := (wine.Settings{ExcludedPrograms: []string{"Game.exe"}}); !reflect.DeepEqual(governing(), want) { + t.Fatalf("rejected Apply changed governing Wine settings: %+v", governing()) + } + + // A valid global Apply reaches the same manager with no restart and + // persists through TOML. + values := preferences.FromSettings(worker.editing) + values.Wine = config.WineSettings{ControllerOnly: true, + ExcludedPrograms: []string{"Game.exe", "New Game.exe", "new game.EXE"}} + response = worker.apply(ctx, preferences.Request{Values: values, Save: true}) + if !response.Accepted { + t.Fatalf("global Wine Apply rejected: %+v", response) + } + want := wine.Settings{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe", "New Game.exe"}} + if !reflect.DeepEqual(governing(), want) { + t.Fatalf("governing Wine settings after Apply = %+v, want %+v", governing(), want) + } + loaded, err := store.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(loaded.Wine, config.WineSettings(want)) { + t.Fatalf("persisted Wine settings = %+v, want %+v", loaded.Wine, want) + } + worker.editingActive = false + + // An application-scoped Apply has no Wine editing surface. Even a + // request carrying different Wine values cannot change the governing + // list, the global profile, or the application profile's Wine values. + worker.switchApplication(ctx, "Example Editor") + if !worker.beginEdit(ctx, "\x00") || worker.editApp != "Example Editor" { + t.Fatal("application preferences did not open") + } + appValues := preferences.FromSettings(worker.editing) + appValues.TimeFormat = "24-hour" + appValues.Wine = config.WineSettings{ExcludedPrograms: []string{"Sneaky.exe"}} + response = worker.apply(ctx, preferences.Request{Values: appValues, Save: true}) + if !response.Accepted || worker.settings.TimeFormat != "24-hour" { + t.Fatalf("application Apply rejected: %+v", response) + } + if !reflect.DeepEqual(governing(), want) { + t.Fatalf("application Apply changed governing Wine settings: %+v", governing()) + } + if !reflect.DeepEqual(worker.globalSettings.Wine, config.WineSettings(want)) { + t.Fatalf("application Apply changed global Wine settings: %+v", worker.globalSettings.Wine) + } + if !reflect.DeepEqual(worker.appSettings["Example Editor"].Wine, config.WineSettings(want)) { + t.Fatalf("application Apply rewrote Wine values: %+v", worker.appSettings["Example Editor"].Wine) + } + reloaded, err := store.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(reloaded.Wine, config.WineSettings(want)) { + t.Fatalf("application Apply persisted into the global Wine profile: %+v", reloaded.Wine) + } +} diff --git a/internal/preferences/native.c b/internal/preferences/native.c index 5678caa..5db30d9 100644 --- a/internal/preferences/native.c +++ b/internal/preferences/native.c @@ -12,6 +12,16 @@ static const char *echoControls[] = {"echoRateScale", "echoPitchScale", "echoVol "echoSpeechServers", "echoSpeechFamilies"}; // Borrowed from the builder for the duration of one Apply/OK request. static GtkWidget *busy_focus; +// Apply and OK stay insensitive while the Wine tab names an invalid entry; +// prefs_busy reapplies this alongside the busy/can-save state it owns. +static int wine_valid = 1; +static int busy_state; +static int can_save_state; +static void apply_sensitivity(void) { + gtk_widget_set_sensitive(prefs_widget("notebook"), !busy_state); + gtk_widget_set_sensitive(prefs_widget("apply"), !busy_state && wine_valid); + gtk_widget_set_sensitive(prefs_widget("save"), !busy_state && can_save_state && wine_valid); +} void prefs_set_action(int value) { action = value; } GtkWidget *prefs_widget(const char *id) { return GTK_WIDGET(gtk_builder_get_object(builder, id)); } static void name_accessible_tree(AtkObject *object, const char *name) { @@ -165,6 +175,7 @@ int prefs_init(const char *xml) { prefs_init_modifiers(); prefs_init_interruption(); prefs_init_pronunciation(); + prefs_init_wine(); prefs_init_text_attributes(); prefs_init_tentacles(); AtkObject *reading_accessible = gtk_widget_get_accessible(prefs_widget("readingSettingsTreeview")); @@ -284,6 +295,9 @@ void prefs_set_application(const char *name) { // page entirely so application preferences expose no global controls. GtkNotebook *notebook = GTK_NOTEBOOK(prefs_widget("notebook")); gtk_notebook_remove_page(notebook, gtk_notebook_page_num(notebook, prefs_widget("tentacles_page"))); + // Wine settings are global-only, so application preferences expose no + // Wine page at all. + gtk_notebook_remove_page(notebook, gtk_notebook_page_num(notebook, prefs_widget("wine_page"))); gchar *title = g_strdup_printf("Cthulhu Preferences for %s", name); gtk_window_set_title(GTK_WINDOW(prefs_widget("window")), title); g_free(title); @@ -300,14 +314,21 @@ int prefs_echo_preview(int *focused) { return result; } void prefs_busy(int busy, int can_save) { - gtk_widget_set_sensitive(prefs_widget("notebook"), !busy); - gtk_widget_set_sensitive(prefs_widget("apply"), !busy); - gtk_widget_set_sensitive(prefs_widget("save"), !busy && can_save); + busy_state = busy; + can_save_state = can_save; + apply_sensitivity(); if (!busy && busy_focus) { if (gtk_widget_get_sensitive(busy_focus)) gtk_widget_grab_focus(busy_focus); busy_focus = NULL; } } +void prefs_wine_validation(const char *message) { + gtk_label_set_text(GTK_LABEL(prefs_widget("wineValidationLabel")), message ? message : ""); + atk_object_set_name(gtk_widget_get_accessible(prefs_widget("wineValidationLabel")), + message && message[0] ? message : "Wine program validation"); + wine_valid = !message || !message[0]; + apply_sensitivity(); +} void prefs_present(void) { gtk_window_present(GTK_WINDOW(prefs_widget("window"))); } void prefs_close(void) { prefs_extensions_close(); busy_focus = NULL; gtk_widget_destroy(prefs_widget("window")); g_object_unref(builder); builder = NULL; } void prefs_status(const char *message, int focus) { diff --git a/internal/preferences/native.h b/internal/preferences/native.h index 574508e..c65ea37 100644 --- a/internal/preferences/native.h +++ b/internal/preferences/native.h @@ -53,6 +53,13 @@ void prefs_pronunciation_clear(void); void prefs_pronunciation_append(const char *word, const char *replacement); int prefs_pronunciation_count(void); char *prefs_pronunciation_value(int row, int column); +void prefs_init_wine(void); +void prefs_wine_clear(void); +void prefs_wine_append(const char *program); +int prefs_wine_count(void); +char *prefs_wine_value(int row); +int prefs_wine_changed(void); +void prefs_wine_validation(const char *message); void prefs_init_text_attributes(void); void prefs_text_attributes_clear(void); void prefs_text_attribute_append(const char *key, const char *label, int speak, int braille); diff --git a/internal/preferences/native_internal.h b/internal/preferences/native_internal.h index 242d791..7ad3cf4 100644 --- a/internal/preferences/native_internal.h +++ b/internal/preferences/native_internal.h @@ -15,6 +15,7 @@ void prefs_init_modifiers(void); void prefs_init_interruption(void); void prefs_init_reading_settings(void); void prefs_init_pronunciation(void); +void prefs_init_wine(void); void prefs_init_text_attributes(void); void prefs_init_tentacles(void); #endif diff --git a/internal/preferences/native_wine.c b/internal/preferences/native_wine.c new file mode 100644 index 0000000..c0bcdb1 --- /dev/null +++ b/internal/preferences/native_wine.c @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_internal.h" + +enum { PROGRAM }; +static GtkListStore *wine_programs; +static int wine_changed; + +static void wine_edited(GtkCellRendererText *renderer, gchar *path, gchar *text, gpointer data) { + (void)renderer; (void)data; + GtkTreeIter iter; + if (gtk_tree_model_get_iter_from_string(GTK_TREE_MODEL(wine_programs), &iter, path)) { + gtk_list_store_set(wine_programs, &iter, PROGRAM, text, -1); + wine_changed = 1; + } +} + +static void wine_add(GtkButton *button, gpointer data) { + (void)button; (void)data; + GtkTreeIter iter; + gtk_list_store_append(wine_programs, &iter); + gtk_list_store_set(wine_programs, &iter, PROGRAM, "", -1); + wine_changed = 1; + GtkTreePath *path = gtk_tree_model_get_path(GTK_TREE_MODEL(wine_programs), &iter); + GtkTreeView *view = GTK_TREE_VIEW(prefs_widget("wineTreeView")); + gtk_widget_grab_focus(GTK_WIDGET(view)); + gtk_tree_view_set_cursor(view, path, gtk_tree_view_get_column(view, PROGRAM), TRUE); + gtk_tree_path_free(path); +} + +static void wine_delete(GtkButton *button, gpointer data) { + (void)button; (void)data; + GtkTreeIter iter; + GtkTreeSelection *selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(prefs_widget("wineTreeView"))); + if (gtk_tree_selection_get_selected(selection, NULL, &iter)) { + gtk_list_store_remove(wine_programs, &iter); + wine_changed = 1; + } + gtk_widget_grab_focus(prefs_widget("wineTreeView")); +} + +static gboolean wine_key(GtkWidget *widget, GdkEventKey *event, gpointer data) { + (void)data; + if (event->keyval != GDK_KEY_Return && event->keyval != GDK_KEY_KP_Enter && event->keyval != GDK_KEY_F2) return FALSE; + GtkTreePath *path = NULL; + GtkTreeViewColumn *column = NULL; + gtk_tree_view_get_cursor(GTK_TREE_VIEW(widget), &path, &column); + if (!path) return FALSE; + gtk_tree_view_set_cursor(GTK_TREE_VIEW(widget), path, column ? column : gtk_tree_view_get_column(GTK_TREE_VIEW(widget), 0), TRUE); + gtk_tree_path_free(path); + return TRUE; +} + +void prefs_init_wine(void) { + GtkTreeView *view = GTK_TREE_VIEW(prefs_widget("wineTreeView")); + wine_programs = gtk_list_store_new(1, G_TYPE_STRING); + gtk_tree_view_set_model(view, GTK_TREE_MODEL(wine_programs)); + GtkCellRenderer *renderer = gtk_cell_renderer_text_new(); + g_object_set(renderer, "editable", TRUE, NULL); + g_signal_connect(renderer, "edited", G_CALLBACK(wine_edited), NULL); + gtk_tree_view_append_column(view, gtk_tree_view_column_new_with_attributes("Program", renderer, "text", PROGRAM, NULL)); + atk_object_set_name(gtk_widget_get_accessible(GTK_WIDGET(view)), "Controller-only programs"); + atk_object_set_description(gtk_widget_get_accessible(GTK_WIDGET(view)), "Select a cell and press Enter to edit it. Tab moves to the next control."); + g_signal_connect(view, "key-press-event", G_CALLBACK(wine_key), NULL); + g_signal_connect(prefs_widget("wineAddButton"), "clicked", G_CALLBACK(wine_add), NULL); + g_signal_connect(prefs_widget("wineDeleteButton"), "clicked", G_CALLBACK(wine_delete), NULL); + g_object_unref(wine_programs); +} + +int prefs_wine_changed(void) { int result = wine_changed; wine_changed = 0; return result; } +void prefs_wine_clear(void) { gtk_list_store_clear(wine_programs); } +void prefs_wine_append(const char *program) { + GtkTreeIter iter; + gtk_list_store_append(wine_programs, &iter); + gtk_list_store_set(wine_programs, &iter, PROGRAM, program, -1); +} +int prefs_wine_count(void) { return gtk_tree_model_iter_n_children(GTK_TREE_MODEL(wine_programs), NULL); } +char *prefs_wine_value(int row) { + GtkTreeIter iter; + if (!gtk_tree_model_iter_nth_child(GTK_TREE_MODEL(wine_programs), &iter, NULL, row)) return NULL; + char *value = NULL; + gtk_tree_model_get(GTK_TREE_MODEL(wine_programs), &iter, PROGRAM, &value, -1); + return value; +} diff --git a/internal/preferences/preferences.ui b/internal/preferences/preferences.ui index 33ff8c9..f39920a 100644 --- a/internal/preferences/preferences.ui +++ b/internal/preferences/preferences.ui @@ -317,6 +317,16 @@ Tentacles + + vertical128 + _Controller-only mode for all Wine applicationsTrueTrue + Listed programs keep controller speech but lose standard control reading. Type executable names only, for example Game.exe. Do not type a path.True0 + True0 + Controller-only _programs:TruewineTreeView0 + TrueTrue + end_NewTrueTrue_DeleteTrueTrue + + Wine _StatusTruestatus0 diff --git a/internal/preferences/protocol.go b/internal/preferences/protocol.go index fa393c2..30c0c7f 100644 --- a/internal/preferences/protocol.go +++ b/internal/preferences/protocol.go @@ -18,6 +18,9 @@ import ( type Values struct { StructuralNavigationTriggersFocusMode bool + // Wine is global-only: it is edited only in global preferences, and + // application preferences carry the base values through unchanged. + Wine config.WineSettings Pronunciations []config.PronunciationEntry TextAttributes config.TextAttributeSettings OCR config.OCRSettings diff --git a/internal/preferences/session.go b/internal/preferences/session.go index d8c8674..7f924e5 100644 --- a/internal/preferences/session.go +++ b/internal/preferences/session.go @@ -16,6 +16,8 @@ import ( type uiSession struct { extensionPages []ExtensionPage progressBar config.ProgressBarSettings + wine config.WineSettings + wineEditable bool catalog speech.Catalog speechDefaults speech.Settings contractionTables []braille.TableOption diff --git a/internal/preferences/ui.go b/internal/preferences/ui.go index 2c452d3..c7a4568 100644 --- a/internal/preferences/ui.go +++ b/internal/preferences/ui.go @@ -64,7 +64,8 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error { C.prefs_set_application(name) C.free(unsafe.Pointer(name)) } - session := &uiSession{catalog: initial.Catalog, speechDefaults: initial.SpeechDefaults, soundThemes: []string{"default", "none"}} + session := &uiSession{catalog: initial.Catalog, speechDefaults: initial.SpeechDefaults, soundThemes: []string{"default", "none"}, + wineEditable: initial.Application == ""} session.setValues(initial.Values) if err := session.set_extension_pages(initial.ExtensionPages); err != nil { return err @@ -182,6 +183,9 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error { // The parent restores any active preview when this helper exits. return nil } + if session.wineEditable && C.prefs_wine_changed() != 0 { + session.refreshWineValidation() + } if C.prefs_voice_changed() != 0 { previewDirty = true } diff --git a/internal/preferences/ui_values.go b/internal/preferences/ui_values.go index 2eb3393..1fc5e83 100644 --- a/internal/preferences/ui_values.go +++ b/internal/preferences/ui_values.go @@ -5,6 +5,13 @@ package preferences func (session *uiSession) setValues(values Values) { + // The Wine page exists only in global preferences. Application + // preferences retain the values their base carries and submit them + // unchanged, so they are never a Wine editing surface. + session.wine = cloneWineSettings(values.Wine) + if session.wineEditable { + setWineValues(values.Wine) + } setPronunciations(values.Pronunciations) setTextAttributes(values.TextAttributes) setOCRValues(values.OCR) @@ -22,6 +29,13 @@ func (session *uiSession) setValues(values Values) { func (session *uiSession) getValues() (Values, error) { var values Values var err error + if session.wineEditable { + if values.Wine, err = getWineValues(); err != nil { + return values, err + } + } else { + values.Wine = cloneWineSettings(session.wine) + } if values.Pronunciations, err = getPronunciations(); err != nil { return values, err } diff --git a/internal/preferences/values.go b/internal/preferences/values.go index 98844d6..d5b6707 100644 --- a/internal/preferences/values.go +++ b/internal/preferences/values.go @@ -12,7 +12,7 @@ import ( ) func FromSettings(settings config.Settings) Values { - return Values{StructuralNavigationTriggersFocusMode: settings.StructuralNavigationTriggersFocusMode, OCR: settings.OCR, Pronunciations: append([]config.PronunciationEntry(nil), settings.Pronunciations...), + return Values{StructuralNavigationTriggersFocusMode: settings.StructuralNavigationTriggersFocusMode, Wine: cloneWineSettings(settings.Wine), OCR: settings.OCR, Pronunciations: append([]config.PronunciationEntry(nil), settings.Pronunciations...), TextAttributes: config.TextAttributeSettings{Choices: append([]config.TextAttributeChoice(nil), settings.TextAttributes.Choices...), Indicator: settings.TextAttributes.Indicator}, KeyboardLayout: settings.KeyboardLayout, CthulhuModifierKeys: append([]string(nil), settings.CthulhuModifierKeys...), KeyBindings: input.CloneBindingOverrides(settings.KeyBindings), TimeFormat: settings.TimeFormat, DateFormat: settings.DateFormat, @@ -71,6 +71,14 @@ func FromSettings(settings config.Settings) Values { } func (values Values) Merge(settings config.Settings) (config.Settings, error) { + // Validate Wine before changing anything: an invalid entry is named and + // rejected here rather than silently dropped, so a rejected Apply leaves + // the governing settings untouched. + wine, err := normalizedWineSettings(values.Wine) + if err != nil { + return settings, err + } + settings.Wine = wine settings.Pronunciations = append([]config.PronunciationEntry(nil), values.Pronunciations...) settings.TextAttributes = config.TextAttributeSettings{Choices: append([]config.TextAttributeChoice(nil), values.TextAttributes.Choices...), Indicator: values.TextAttributes.Indicator} settings.OCR = values.OCR diff --git a/internal/preferences/wine.go b/internal/preferences/wine.go new file mode 100644 index 0000000..5f46f16 --- /dev/null +++ b/internal/preferences/wine.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "fmt" + "strings" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func cloneWineSettings(wine config.WineSettings) config.WineSettings { + wine.ExcludedPrograms = append([]string(nil), wine.ExcludedPrograms...) + return wine +} + +// validateWinePrograms reports the first entry the Wine tab cannot accept, +// naming it so the user can fix or delete exactly that entry. Config +// validation remains the backstop for these rules. +func validateWinePrograms(programs []string) error { + if len(programs) > 256 { + return fmt.Errorf("Wine program \"%s\" is entry %d. At most 256 programs can be listed; delete an entry.", programs[256], 257) + } + for index, name := range programs { + switch { + case strings.TrimSpace(name) == "": + return fmt.Errorf("Wine program entry %d is blank. Type an executable name such as Game.exe, or delete the entry.", index+1) + case strings.ContainsAny(name, `/\`): + return fmt.Errorf("Wine program \"%s\" is a path. Type only the executable name, such as Game.exe, or delete the entry.", name) + case len(name) > 255: + return fmt.Errorf("Wine program \"%s\" is too long. Executable names may be at most 255 characters; fix or delete the entry.", name) + case strings.TrimSpace(name) != name || strings.ContainsAny(name, "\x00\r\n"): + return fmt.Errorf("Wine program \"%s\" is not a valid executable name. Remove the surrounding spaces or control characters, or delete the entry.", name) + } + } + return nil +} + +// collapseWinePrograms keeps the first spelling of each program and drops +// later case-insensitive duplicates. An empty list stays nil so merged +// settings keep their existing shape. +func collapseWinePrograms(programs []string) []string { + var collapsed []string + seen := make(map[string]bool, len(programs)) + for _, name := range programs { + key := strings.ToLower(name) + if seen[key] { + continue + } + seen[key] = true + collapsed = append(collapsed, name) + } + return collapsed +} + +func normalizedWineSettings(wine config.WineSettings) (config.WineSettings, error) { + if err := validateWinePrograms(wine.ExcludedPrograms); err != nil { + return config.WineSettings{}, err + } + wine.ExcludedPrograms = collapseWinePrograms(wine.ExcludedPrograms) + return wine, nil +} + +func setWineValidation(message string) { + text := C.CString(message) + defer C.free(unsafe.Pointer(text)) + C.prefs_wine_validation(text) +} + +func setWineValues(wine config.WineSettings) { + setCheck("wineControllerOnlyCheckButton", wine.ControllerOnly) + C.prefs_wine_clear() + for _, program := range wine.ExcludedPrograms { + name := C.CString(program) + C.prefs_wine_append(name) + C.free(unsafe.Pointer(name)) + } + setWineValidation("") +} + +func getWinePrograms() []string { + var programs []string + for row := 0; row < int(C.prefs_wine_count()); row++ { + name := C.prefs_wine_value(C.int(row)) + programs = append(programs, C.GoString(name)) + C.g_free(C.gpointer(name)) + } + return programs +} + +func (session *uiSession) refreshWineValidation() { + message := "" + if err := validateWinePrograms(getWinePrograms()); err != nil { + message = err.Error() + } + setWineValidation(message) +} + +func getWineValues() (config.WineSettings, error) { + wine := config.WineSettings{ControllerOnly: getCheck("wineControllerOnlyCheckButton"), + ExcludedPrograms: getWinePrograms()} + return normalizedWineSettings(wine) +} diff --git a/internal/preferences/wine_test.go b/internal/preferences/wine_test.go new file mode 100644 index 0000000..0ff3d35 --- /dev/null +++ b/internal/preferences/wine_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "reflect" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestWineValuesRoundTripAndOwnership(t *testing.T) { + settings := config.DefaultSettings() + settings.Wine = config.WineSettings{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe", "Another Game.exe"}} + values := FromSettings(settings) + if !reflect.DeepEqual(values.Wine, settings.Wine) { + t.Fatalf("Wine values = %+v", values.Wine) + } + values.Wine.ExcludedPrograms[0] = "Changed.exe" + if settings.Wine.ExcludedPrograms[0] != "Game.exe" { + t.Fatal("FromSettings aliases the Wine program list") + } + values.Wine.ExcludedPrograms[0] = "Game.exe" + values.Wine.ControllerOnly = false + values.Wine.ExcludedPrograms = append(values.Wine.ExcludedPrograms, "New Game.exe") + merged, err := values.Merge(settings) + if err != nil { + t.Fatal(err) + } + if got := FromSettings(merged); !reflect.DeepEqual(got, values) { + t.Fatalf("Wine values did not round trip:\n got %+v\nwant %+v", got.Wine, values.Wine) + } + values.Wine.ExcludedPrograms[0] = "Changed.exe" + if merged.Wine.ExcludedPrograms[0] != "Game.exe" { + t.Fatal("merged Wine programs alias the UI values") + } +} + +func TestWineValuesMergeCollapsesCaseInsensitiveDuplicates(t *testing.T) { + settings := config.DefaultSettings() + values := FromSettings(settings) + values.Wine.ExcludedPrograms = []string{"Game.exe", "game.EXE", "Other.exe", "GAME.exe", "other.exe"} + merged, err := values.Merge(settings) + if err != nil { + t.Fatal(err) + } + want := []string{"Game.exe", "Other.exe"} + if !reflect.DeepEqual(merged.Wine.ExcludedPrograms, want) { + t.Fatalf("collapsed Wine programs = %q, want %q", merged.Wine.ExcludedPrograms, want) + } + values.Wine.ExcludedPrograms = nil + merged, err = values.Merge(settings) + if err != nil { + t.Fatal(err) + } + if merged.Wine.ExcludedPrograms != nil { + t.Fatalf("empty Wine list became %q, want nil", merged.Wine.ExcludedPrograms) + } +} + +func TestWineValuesMergeRejectsInvalidEntriesByName(t *testing.T) { + overlong := strings.Repeat("a", 252) + ".exe" + cases := []struct { + name string + entry string + wantSub string + }{ + {name: "windows path", entry: `C:\Games\Game.exe`, wantSub: `C:\Games\Game.exe`}, + {name: "unix path", entry: "Games/Game.exe", wantSub: "Games/Game.exe"}, + {name: "blank", entry: "", wantSub: "blank"}, + {name: "spaces only", entry: " ", wantSub: "blank"}, + {name: "overlong", entry: overlong, wantSub: overlong}, + {name: "leading space", entry: " Game.exe", wantSub: "Game.exe"}, + {name: "trailing space", entry: "Game.exe ", wantSub: "Game.exe"}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + settings := config.DefaultSettings() + settings.Wine.ExcludedPrograms = []string{"Kept.exe"} + values := FromSettings(settings) + values.Wine.ExcludedPrograms = []string{"Kept.exe", test.entry} + merged, err := values.Merge(settings) + if err == nil { + t.Fatalf("Merge accepted Wine entry %q", test.entry) + } + if !strings.Contains(err.Error(), test.wantSub) { + t.Fatalf("Merge error %q does not name %q", err, test.wantSub) + } + if !reflect.DeepEqual(merged.Wine.ExcludedPrograms, []string{"Kept.exe"}) { + t.Fatalf("rejected Merge changed Wine programs to %q", merged.Wine.ExcludedPrograms) + } + }) + } +} diff --git a/tests/native_preferences.py b/tests/native_preferences.py index 8dcc107..eb1844d 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -161,6 +161,9 @@ def inside(): if os.environ.get("CTHULHU_PREFS_EXTENSIONS_ONLY") == "1": from native_tentacle_extensions import probe_pages initial["ExtensionPages"] = probe_pages() + if os.environ.get("CTHULHU_PREFS_WINE_ONLY") == "1": + initial["Values"]["Wine"] = {"ControllerOnly": False, + "ExcludedPrograms": ["Game.exe"]} if application: initial["Application"] = application def send(value): @@ -551,6 +554,60 @@ def inside(): assert process.wait(timeout=2) == 0 print("PASS: pronunciation and text attribute tables, add/delete, selection, order, indicator, Apply/reload, reset") return + if os.environ.get("CTHULHU_PREFS_WINE_ONLY") == "1": + tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) + assert not application or all( + tabs.getChildAtIndex(index).name != "Wine" + for index in range(tabs.childCount)), "Wine tab is not global-only" + if application: + print("PASS: Wine tab absent from application preferences") + return + assert tabs.querySelection().selectChild(tabs.childCount - 1), "cannot select Wine tab" + wait_for(lambda: named("Wine").getState().contains(pyatspi.STATE_SELECTED), "Wine page missing") + table = named("Controller-only programs").queryTable() + assert table.nRows == 1 and table.getAccessibleAt(0, 0).name == "Game.exe", table.nRows + controller_only = named("Controller-only mode for all Wine applications") + delete = [n for n in nodes if n.name == "Delete" and + n.getRole() == pyatspi.ROLE_PUSH_BUTTON][-1] + def validation_text(): + for node in walk(window): + if node.name and node.name.startswith("Wine program"): + return node.name + return "" + def add_program(text): + rows = table.nRows + focus(named("New")); key("space") + wait_for(lambda: table.nRows == rows + 1, "New did not add a Wine row") + subprocess.run(["xdotool", "type", "--clearmodifiers", text], check=True) + key("Return") + wait_for(lambda: table.getAccessibleAt(rows, 0).name == text, + "inline Wine program editing failed") + focus(controller_only); key("space") + wait_for(lambda: controller_only.getState().contains(pyatspi.STATE_CHECKED), + "controller-only checkbox did not toggle") + add_program(r"C:\Games\Bad.exe") + wait_for(lambda: r"C:\Games\Bad.exe" in validation_text(), + "path entry was not flagged by name in the Wine tab") + wait_for(lambda: not named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), + "invalid Wine entry did not block Apply") + focus(table.getAccessibleAt(1, 0)) + focus(delete); key("space") + wait_for(lambda: table.nRows == 1, "Delete did not remove the Wine row") + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), + "Apply stayed blocked after deleting the invalid entry") + add_program("New Game.exe") + focus(named("Apply")); key("space") + applied = request() + assert applied["Values"]["Wine"] == {"ControllerOnly": True, + "ExcludedPrograms": ["Game.exe", "New Game.exe"]}, applied + send(dict(initial, Values=applied["Values"], Message="Wine preferences saved.")) + wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply stayed busy") + wait_for(lambda: table.nRows == 2 and table.getAccessibleAt(1, 0).name == "New Game.exe", + "applied Wine values were not reloaded") + focus(named("Cancel")); key("space") + assert process.wait(timeout=2) == 0 + print("PASS: Wine tab checkbox, program table, add/delete, named validation blocking Apply, Apply/reload") + return if os.environ.get("CTHULHU_PREFS_NUMBERS_ONLY") == "1": focus(named("Desktop")) key("ctrl+Page_Down", "ctrl+Page_Down") @@ -1270,6 +1327,7 @@ def inside(): "Key Bindings", "Pronunciation", "Text Attributes", "OCR"} if not application: expected_tabs.add("Tentacles") + expected_tabs.add("Wine") main_tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) tabs = {main_tabs.getChildAtIndex(index).name for index in range(main_tabs.childCount) if main_tabs.getChildAtIndex(index).getRole() == pyatspi.ROLE_PAGE_TAB}