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.
This commit is contained in:
1 parent
dc6a961032
commit
75f34ea767
15 files changed
+543
-5
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -317,6 +317,16 @@
|
||||
</object></child>
|
||||
</object></child>
|
||||
<child type="tab"><object class="GtkLabel"><property name="label">Tentacles</property></object></child>
|
||||
|
||||
<child><object class="GtkBox" id="wine_page"><property name="orientation">vertical</property><property name="border-width">12</property><property name="spacing">8</property>
|
||||
<child><object class="GtkCheckButton" id="wineControllerOnlyCheckButton"><property name="label">_Controller-only mode for all Wine applications</property><property name="use-underline">True</property><property name="can-focus">True</property></object></child>
|
||||
<child><object class="GtkLabel"><property name="label">Listed programs keep controller speech but lose standard control reading. Type executable names only, for example Game.exe. Do not type a path.</property><property name="wrap">True</property><property name="xalign">0</property></object></child>
|
||||
<child><object class="GtkLabel" id="wineValidationLabel"><property name="label"></property><property name="wrap">True</property><property name="xalign">0</property></object></child>
|
||||
<child><object class="GtkLabel"><property name="label">Controller-only _programs:</property><property name="use-underline">True</property><property name="mnemonic-widget">wineTreeView</property><property name="xalign">0</property></object></child>
|
||||
<child><object class="GtkScrolledWindow"><property name="expand">True</property><child><object class="GtkTreeView" id="wineTreeView"><property name="can-focus">True</property></object></child></object></child>
|
||||
<child><object class="GtkButtonBox"><property name="layout-style">end</property><child><object class="GtkButton" id="wineAddButton"><property name="label">_New</property><property name="use-underline">True</property><property name="can-focus">True</property></object></child><child><object class="GtkButton" id="wineDeleteButton"><property name="label">_Delete</property><property name="use-underline">True</property><property name="can-focus">True</property></object></child></object></child>
|
||||
</object></child>
|
||||
<child type="tab"><object class="GtkLabel"><property name="label">Wine</property></object></child>
|
||||
</object>
|
||||
</child>
|
||||
<child><object class="GtkLabel" id="status_label"><property name="label">_Status</property><property name="use-underline">True</property><property name="mnemonic-widget">status</property><property name="xalign">0</property></object></child>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <stdlib.h>
|
||||
#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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user