shithub: puzzles

Download patch

ref: 6c66e2b2de63b6a8159835516ee229bda3253bc2
parent: 4752c7a2d9bd83d41d418b31b931a9bb9af219fa
author: Simon Tatham <anakin@pobox.com>
date: Sun Apr 23 06:58:53 EDT 2023

Support preferences in the GTK frontend.

Finally, some user-visible behaviour changes as a payoff for all that
preparation work! In this commit, the GTK puzzles get a 'Preferences'
option in the menu, which presents a dialog box to configure the
preference settings.

On closing that dialog box, the puzzle preferences are enacted
immediately, and also saved to a configuration file where the next run
of the same puzzle will reload them.

The default file location is ~/.config/sgt-puzzles/<puzzlename>.conf,
although you can override the .config dir via $XDG_CONFIG_HOME or
override the Puzzles-specific subdir with $SGT_PUZZLES_DIR.

This is the first commit that actually exposes all the new preferences
work to the user, and therefore, I've also added documentation of all
the current preference options.

--- a/gtk.c
+++ b/gtk.c
@@ -20,6 +20,9 @@
 #endif
 #include <unistd.h>
 
+#include <fcntl.h>
+#include <sys/stat.h>
+#include <sys/types.h>
 #include <sys/time.h>
 #include <sys/resource.h>
 
@@ -141,6 +144,8 @@
  */
 
 static void changed_preset(frontend *fe);
+static void load_prefs(frontend *fe);
+static char *save_prefs(frontend *fe);
 
 struct font {
 #ifdef USE_PANGO
@@ -1917,9 +1922,17 @@
     if (err)
 	error_box(fe->cfgbox, err);
     else {
+        if (fe->cfg_which == CFG_PREFS) {
+            char *prefs_err = save_prefs(fe);
+            if (prefs_err) {
+                error_box(fe->cfgbox, prefs_err);
+                sfree(prefs_err);
+            }
+        }
 	fe->cfgret = true;
 	gtk_widget_destroy(fe->cfgbox);
-	changed_preset(fe);
+        if (fe->cfg_which != CFG_PREFS)
+            changed_preset(fe);
     }
 }
 
@@ -2742,6 +2755,8 @@
 		thegame.free_params(params);
 	    }
 
+            load_prefs(fe);
+
             midend_new_game(nme);
             err = midend_print_puzzle(nme, fe->doc, fe->printsolns);
         }
@@ -2953,6 +2968,140 @@
     }
 }
 
+static char *prefs_dir(void)
+{
+    const char *var;
+    if ((var = getenv("SGT_PUZZLES_DIR")) != NULL)
+        return dupstr(var);
+    if ((var = getenv("XDG_CONFIG_HOME")) != NULL) {
+        size_t size = strlen(var) + 20;
+        char *dir = snewn(size, char);
+        sprintf(dir, "%s/sgt-puzzles", var);
+        return dir;
+    }
+    if ((var = getenv("HOME")) != NULL) {
+        size_t size = strlen(var) + 32;
+        char *dir = snewn(size, char);
+        sprintf(dir, "%s/.config/sgt-puzzles", var);
+        return dir;
+    }
+    return NULL;
+}
+
+static char *prefs_path_general(const char *suffix)
+{
+    char *dir, *path;
+
+    dir = prefs_dir();
+    if (!dir)
+        return NULL;
+
+    path = make_prefs_path(dir, "/", &thegame, suffix);
+
+    sfree(dir);
+    return path;
+}
+
+static char *prefs_path(void)
+{
+    return prefs_path_general(".conf");
+}
+
+static char *prefs_tmp_path(void)
+{
+    return prefs_path_general(".conf.tmp");
+}
+
+static void load_prefs(frontend *fe)
+{
+    char *path = prefs_path();
+    if (!path)
+        return;
+    FILE *fp = fopen(path, "r");
+    if (!fp)
+        return;
+    const char *err = midend_load_prefs(fe->me, savefile_read, fp);
+    fclose(fp);
+    if (err)
+        fprintf(stderr, "Unable to load preferences file %s:\n%s\n",
+                path, err);
+    sfree(path);
+}
+
+static char *save_prefs(frontend *fe)
+{
+    char *dir_path = prefs_dir();
+    char *file_path = prefs_path();
+    char *tmp_path = prefs_tmp_path();
+    struct savefile_write_ctx wctx[1];
+    int fd;
+    bool cleanup_dir = false, cleanup_tmpfile = false;
+    char *err = NULL;
+
+    if (!dir_path || !file_path || !tmp_path) {
+        sprintf(err = snewn(256, char),
+                "Unable to save preferences:\n"
+                "Could not determine pathname for configuration files");
+        goto out;
+    }
+
+    if (mkdir(dir_path, 0777) < 0) {
+        /* Ignore errors while trying to make the directory. It may
+         * well already exist, and even if we got some error code
+         * other than EEXIST, it's still worth at least _trying_ to
+         * make the file inside it, and see if that goes wrong. */
+    } else {
+        cleanup_dir = true;
+    }
+
+    fd = open(tmp_path, O_CREAT | O_WRONLY | O_TRUNC | O_EXCL, 0666);
+    if (fd < 0) {
+        const char *os_err = strerror(errno);
+        sprintf(err = snewn(256 + strlen(tmp_path) + strlen(os_err), char),
+                "Unable to save preferences:\n"
+                "Unable to create file '%s': %s", tmp_path, os_err);
+        goto out;
+    } else {
+        cleanup_tmpfile = true;
+    }
+
+    wctx->error = 0;
+    wctx->fp = fdopen(fd, "w");
+    midend_save_prefs(fe->me, savefile_write, wctx);
+    fclose(wctx->fp);
+    if (wctx->error) {
+        const char *os_err = strerror(wctx->error);
+        sprintf(err = snewn(80 + strlen(tmp_path) + strlen(os_err), char),
+                "Unable to write file '%s': %s", tmp_path, os_err);
+        goto out;
+    }
+
+    if (rename(tmp_path, file_path) < 0) {
+        const char *os_err = strerror(wctx->error);
+        sprintf(err = snewn(256 + strlen(tmp_path) + strlen(file_path) +
+                            strlen(os_err), char),
+                "Unable to save preferences:\n"
+                "Unable to rename '%s' to '%s': %s", tmp_path, file_path,
+                os_err);
+        goto out;
+    } else {
+        cleanup_dir = false;
+        cleanup_tmpfile = false;
+    }
+
+  out:
+    if (cleanup_tmpfile) {
+        if (unlink(tmp_path) < 0) { /* can't do anything about this */ }
+    }
+    if (cleanup_dir) {
+        if (rmdir(dir_path) < 0) { /* can't do anything about this */ }
+    }
+    sfree(dir_path);
+    sfree(file_path);
+    sfree(tmp_path);
+    return err;
+}
+
 #ifdef USE_PRINTING
 static void menu_print_event(GtkMenuItem *menuitem, gpointer data)
 {
@@ -2994,7 +3143,9 @@
     if (!get_config(fe, which))
 	return;
 
-    midend_new_game(fe->me);
+    if (which != CFG_PREFS)
+        midend_new_game(fe->me);
+
     resize_fe(fe);
     midend_redraw(fe->me);
 }
@@ -3214,6 +3365,7 @@
     fe->timer_id = -1;
 
     fe->me = midend_new(fe, &thegame, &gtk_drawing, fe);
+    load_prefs(fe);
 
     fe->dr_api = &internal_drawing;
 
@@ -3477,6 +3629,16 @@
                          G_CALLBACK(menu_solve_event), fe);
 	gtk_widget_show(menuitem);
     }
+
+    add_menu_separator(GTK_CONTAINER(menu));
+    menuitem = gtk_menu_item_new_with_label("Preferences...");
+    gtk_container_add(GTK_CONTAINER(menu), menuitem);
+    g_object_set_data(G_OBJECT(menuitem), "user-data",
+                      GINT_TO_POINTER(CFG_PREFS));
+    g_signal_connect(G_OBJECT(menuitem), "activate",
+                     G_CALLBACK(menu_config_event), fe);
+    gtk_widget_show(menuitem);
+
     add_menu_separator(GTK_CONTAINER(menu));
     add_menu_ui_item(fe, GTK_CONTAINER(menu), "Exit", UI_QUIT, 'q', 0);
 
--- a/misc.c
+++ b/misc.c
@@ -3,6 +3,7 @@
  */
 
 #include <assert.h>
+#include <ctype.h>
 #ifdef NO_TGMATH_H
 #  include <math.h>
 #else
@@ -498,6 +499,36 @@
 
     /* should never get here */
     return NULL;
+}
+
+char *make_prefs_path(const char *dir, const char *sep,
+                      const game *game, const char *suffix)
+{
+    size_t dirlen = strlen(dir);
+    size_t seplen = strlen(sep);
+    size_t gamelen = strlen(game->name);
+    size_t suffixlen = strlen(suffix);
+    char *path, *p;
+    const char *q;
+
+    path = snewn(dirlen + seplen + gamelen + suffixlen + 1, char);
+    p = path;
+
+    memcpy(p, dir, dirlen);
+    p += dirlen;
+
+    memcpy(p, sep, seplen);
+    p += seplen;
+
+    for (q = game->name; *q; q++)
+        if (*q != ' ')
+            *p++ = tolower((unsigned char)*q);
+
+    memcpy(p, suffix, suffixlen);
+    p += suffixlen;
+
+    *p = '\0';
+    return path;
 }
 
 /* vim: set shiftwidth=4 tabstop=8: */
--- a/puzzles.but
+++ b/puzzles.but
@@ -177,6 +177,22 @@
 
 \dd Closes the application entirely.
 
+\dt \i\e{Preferences}
+
+\dd Where supported (currently only on Unix), brings up a dialog
+allowing you to configure personal preferences about a particular
+game. Some of these preferences will be specific to a particular game;
+others will be common to all games.
+
+\lcont{
+
+One option common to all games allows you to turn off the one-key
+shortcuts like \q{N} for new game or \q{Q} for quit, so that there's
+less chance of hitting them by accident. You can still access the same
+shortcuts with the Ctrl key.
+
+}
+
 \H{common-id} Specifying games with the \ii{game ID}
 
 There are two ways to save a game specification out of a puzzle and
@@ -621,8 +637,9 @@
 space will move as many tiles as necessary to move the space to the
 mouse pointer.
 
-The arrow keys will move a tile adjacent to the space in the direction
-indicated (moving the space in the \e{opposite} direction).
+By default, the arrow keys will move a tile adjacent to the space in
+the direction indicated (moving the space in the \e{opposite}
+direction).
 
 Pressing \q{h} will make a suggested move.  Pressing \q{h} enough
 times will solve the game, but it may scramble your progress while
@@ -636,7 +653,19 @@
 menu are \e{Width} and \e{Height}, which are self-explanatory. (Once
 you've changed these, it's not a \q{15-puzzle} any more, of course!)
 
+\H{fifteen-prefs} \I{preferences, for Fifteen}Fifteen user preferences
 
+On platforms that support user preferences, the \q{Preferences} option
+on the \q{Game} menu will let you configure the sense of the arrow
+keys. With the default setting, \q{Move the tile}, the arrow key you
+press indicates the direction that you want a tile to move, so that
+(for example) if you want to move the tile left of the gap rightwards
+into the gap, you'd press Right. With the opposite setting, \q{Move
+the gap}, the behaviour of the arrow keys is reversed, and you would
+press Left to move the tile left of the gap into the gap, so that the
+\e{gap} ends up one square left of where it was.
+
+
 \C{sixteen} \i{Sixteen}
 
 \cfg{winhelp-topic}{games.sixteen}
@@ -1768,7 +1797,13 @@
 deduce something about still other squares.) Even at Hard level,
 guesswork and backtracking should never be necessary.
 
+\H{slant-prefs} \I{preferences, for Slant}Slant user preferences
 
+On platforms that support user preferences, the \q{Preferences} option
+on the \q{Game} menu will let you configure which way round the mouse
+buttons work.
+
+
 \C{lightup} \i{Light Up}
 
 \cfg{winhelp-topic}{games.lightup}
@@ -1851,7 +1886,13 @@
 backtracking or guessing, \q{Hard} means that some guesses will
 probably be necessary.
 
+\H{lightup-prefs} \I{preferences, for Light Up}Light Up user preferences
 
+On platforms that support user preferences, the \q{Preferences} option
+on the \q{Game} menu will let you configure whether \q{this is not a
+light} marks are shown when the square is also lit.
+
+
 \C{map} \i{Map}
 
 \cfg{winhelp-topic}{games.map}
@@ -1944,7 +1985,13 @@
 
 }
 
+\H{map-prefs} \I{preferences, for Map}Map user preferences
 
+On platforms that support user preferences, the \q{Preferences} option
+on the \q{Game} menu will let you configure the style of the victory
+flash.
+
+
 \C{loopy} \i{Loopy}
 
 \cfg{winhelp-topic}{games.loopy}
@@ -2016,7 +2063,35 @@
 \#{FIXME: what distinguishes Easy, Medium, and Hard? In particular,
 when are backtracking/guesswork required, if ever?}
 
+\H{loopy-prefs} \I{preferences, for Loopy}Loopy user preferences
 
+On platforms that support user preferences, the \q{Preferences} option
+on the \q{Game} menu will let you configure the following things:
+
+\q{Draw excluded grid lines faintly}. This is on by default: when a
+line of the grid has been explicitly excluded from the solution by
+right-clicking it, the line is still drawn, just in a faint grey
+colour. If you turn this option off, excluded lines are not drawn at
+all.
+
+\q{Auto-follow unique paths of edges}. This is off by default. When
+it's on, clicking to change the status of a single grid line will
+potentially propagate the change along multiple lines, if one or both
+ends of the line you clicked connect to only one other line. (The idea
+is that if two lines meet at a vertex and no other lines do at all,
+then those lines are either both part of the loop or neither, so
+there's no reason you should have to click separately to toggle each
+one.)
+
+In the mode \q{Based on grid only}, the effects of a click will only
+propagate across vertices that have degree 2 in the underlying grid.
+For example, in the square grid, the effect will \e{only} occur at the
+four grid corners.
+
+In the mode \q{Based on grid and game state}, the propagation will
+also take account of edges you've already excluded from the solution,
+so that it will do even more work for you.
+
 \C{inertia} \i{Inertia}
 
 \cfg{winhelp-topic}{games.inertia}
@@ -2720,7 +2795,15 @@
 still be unique. The remaining levels require increasingly complex
 reasoning to avoid having to backtrack.
 
+\H{towers-prefs} \I{preferences, for Towers}Towers user preferences
 
+On platforms that support user preferences, the \q{Preferences} option
+on the \q{Game} menu will let you configure the style of the game
+display. If you don't like the three-dimensional mode, selecting
+\q{2D} will switch to a simpler display style in which towers are
+shown by just writing their height in the square.
+
+
 \C{singles} \i{Singles}
 
 \cfg{winhelp-topic}{games.singles}
@@ -2926,6 +3009,13 @@
 (the start at the top left, and the end at the bottom right). If false the start
 and end squares are placed randomly (although always both shown). 
 
+\H{signpost-prefs} \I{preferences, for Signpost}Signpost user preferences
+
+On platforms that support user preferences, the \q{Preferences} option
+on the \q{Game} menu will let you configure the style of the victory
+effect.
+
+
 \C{range} \i{Range}
 
 \cfg{winhelp-topic}{games.range}
@@ -2988,6 +3078,13 @@
 
 \dd Size of grid in squares.
 
+\H{range-prefs} \I{preferences, for Range}Range user preferences
+
+On platforms that support user preferences, the \q{Preferences} option
+on the \q{Game} menu will let you configure which way round the mouse
+buttons work.
+
+
 \C{pearl} \i{Pearl}
 
 \cfg{winhelp-topic}{games.pearl}
@@ -3077,6 +3174,17 @@
 possible to deduce it step by step.
 
 }
+
+\H{pearl-prefs} \I{preferences, for Pearl}Pearl user preferences
+
+On platforms that support user preferences, the \q{Preferences} option
+on the \q{Game} menu will let you configure the style of the game
+display. \q{Traditional} is the default mode, in which the loop runs
+between centres of grid squares, and each clue occupies a square.
+\q{Loopy-style} is an alternative mode that looks more like Loopy
+(\k{loopy}), in which the loop runs between grid \e{vertices}, and the
+clues also occupy vertices.
+
 
 \C{undead} \i{Undead}
 
--- a/puzzles.h
+++ b/puzzles.h
@@ -388,6 +388,8 @@
 void free_keys(key_label *keys, int nkeys);
 void obfuscate_bitmap(unsigned char *bmp, int bits, bool decode);
 char *fgetline(FILE *fp);
+char *make_prefs_path(const char *dir, const char *sep,
+                      const game *game, const char *suffix);
 
 /* allocates output each time. len is always in bytes of binary data.
  * May assert (or just go wrong) if lengths are unchecked. */