From 7f1f9462f4bbbc4020537dd1effb1a8bd79d2e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Helleu?= Date: Tue, 26 May 2026 17:21:12 +0200 Subject: [PATCH] core: implement /theme apply with themable enforcement and auto-backup Implement /theme apply for themes currently in the in-memory registry. The file-shadowing branch (read a .theme file from ${weechat_config_dir}/themes/ when no built-in matches) is added in the next commit together with the parser. Apply algorithm (theme_apply in core-theme.c): - Look up the theme in the registry; abort with an error if unknown. - If weechat.look.theme_backup is on and the target name does not begin with "backup-", write a full snapshot of every themable option to ${weechat_config_dir}/themes/backup-.theme via theme_make_backup; abort the apply if the backup cannot be written, so the user can always undo. - Iterate the theme's overrides with theme_applying=1 so the per-option config_change_color skips its gui refresh; for each entry look up the option, refuse it if missing or non-themable (warning to core buffer), otherwise call config_file_option_set. - Perform a single gui_color_init_weechat + gui_window_ask_refresh at the end. - Persist the active label in weechat.look.theme and send signal "theme_applied" with the name as data. Add the new option weechat.look.theme_backup (boolean, default on) which controls the backup-or-abort behaviour described above. Wire the new /theme apply subcommand into core-command.c with the existing /theme registration; update help text accordingly. --- src/core/core-command.c | 20 +- src/core/core-config.c | 13 ++ src/core/core-config.h | 1 + src/core/core-theme.c | 283 ++++++++++++++++++++++++++++ src/core/core-theme.h | 2 + tests/unit/core/test-core-theme.cpp | 204 ++++++++++++++++++++ 6 files changed, 522 insertions(+), 1 deletion(-) diff --git a/src/core/core-command.c b/src/core/core-command.c index 035d0e3fd..a3c453a4c 100644 --- a/src/core/core-command.c +++ b/src/core/core-command.c @@ -7231,6 +7231,13 @@ COMMAND_CALLBACK(theme) return WEECHAT_RC_OK; } + /* "/theme apply ": apply a theme */ + if (string_strcmp (argv[1], "apply") == 0) + { + COMMAND_MIN_ARGS(3, "apply"); + return theme_apply (argv[2]); + } + /* "/theme info ": show details about a theme */ if (string_strcmp (argv[1], "info") == 0) { @@ -9927,10 +9934,13 @@ command_init (void) N_("manage color themes"), /* TRANSLATORS: only text between angle brackets (eg: "") may be translated */ N_("[list]" + " || apply " " || info "), CMD_ARGS_DESC( N_("raw[list]: list registered themes (default action with no " "argument); active theme is marked with \"->\""), + N_("raw[apply]: apply a theme (set every themable option to the " + "value from the theme)"), N_("raw[info]: display details on a theme (name, description, " "creation date, WeeChat version, number of option overrides)"), N_("name: name of a theme"), @@ -9939,8 +9949,16 @@ command_init (void) "option overrides. Built-in themes are registered in memory " "by core/plugins/scripts; user themes are read from files " "in directory \"themes\" inside the WeeChat configuration " - "directory.")), + "directory."), + "", + N_("By default, /theme apply creates a backup of current " + "themable values in directory \"themes\" before applying " + "(file name: \"backup-.theme\"); the previous " + "state can be restored with: /theme apply " + "backup-. This is controlled by the option " + "weechat.look.theme_backup.")), "list" + " || apply" " || info", &command_theme, NULL, NULL); hook_command ( diff --git a/src/core/core-config.c b/src/core/core-config.c index ca731457c..daf5187be 100644 --- a/src/core/core-config.c +++ b/src/core/core-config.c @@ -224,6 +224,7 @@ struct t_config_option *config_look_separator_vertical = NULL; struct t_config_option *config_look_tab_whitespace_char = NULL; struct t_config_option *config_look_tab_width = NULL; struct t_config_option *config_look_theme = NULL; +struct t_config_option *config_look_theme_backup = NULL; struct t_config_option *config_look_time_format = NULL; struct t_config_option *config_look_whitespace_char = NULL; struct t_config_option *config_look_window_auto_zoom = NULL; @@ -4433,6 +4434,18 @@ config_weechat_init_options (void) "only, the theme is not re-applied at startup"), NULL, 0, 0, "", NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + config_look_theme_backup = config_file_new_option ( + weechat_config_file, weechat_config_section_look, + "theme_backup", "boolean", + N_("create a backup theme file with the current themable " + "options before applying a theme with command /theme; if " + "the backup file cannot be written, the apply is aborted " + "(no option is changed); the backup file is written to " + "directory \"themes\" inside the WeeChat configuration " + "directory and can be restored with: /theme apply " + "backup-"), + NULL, 0, 0, "on", NULL, 0, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); config_look_time_format = config_file_new_option ( weechat_config_file, weechat_config_section_look, "time_format", "string", diff --git a/src/core/core-config.h b/src/core/core-config.h index 68396a31f..fe5839084 100644 --- a/src/core/core-config.h +++ b/src/core/core-config.h @@ -278,6 +278,7 @@ extern struct t_config_option *config_look_separator_vertical; extern struct t_config_option *config_look_tab_whitespace_char; extern struct t_config_option *config_look_tab_width; extern struct t_config_option *config_look_theme; +extern struct t_config_option *config_look_theme_backup; extern struct t_config_option *config_look_time_format; extern struct t_config_option *config_look_whitespace_char; extern struct t_config_option *config_look_window_auto_zoom; diff --git a/src/core/core-theme.c b/src/core/core-theme.c index c350d4aa9..e7a33a8ac 100644 --- a/src/core/core-theme.c +++ b/src/core/core-theme.c @@ -25,16 +25,25 @@ #include "config.h" #endif +#include #include #include +#include #include #include "weechat.h" #include "core-arraylist.h" +#include "core-config.h" +#include "core-config-file.h" +#include "core-dir.h" #include "core-hashtable.h" +#include "core-hook.h" #include "core-string.h" #include "core-theme.h" #include "core-version.h" +#include "../gui/gui-chat.h" +#include "../gui/gui-color.h" +#include "../gui/gui-window.h" #include "../plugins/weechat-plugin.h" @@ -248,6 +257,280 @@ theme_list (void) return list; } +/* + * Builds the on-disk path for a user theme: + * "/themes/.theme". + * + * Returned string is allocated; caller frees. Returns NULL on error. + */ + +char * +theme_user_file_path (const char *name) +{ + char *path = NULL; + + if (!name || !name[0]) + return NULL; + string_asprintf (&path, "%s/themes/%s.theme", + weechat_config_dir, name); + return path; +} + +/* + * Builds a unique backup theme name "backup-YYYYMMDD-HHMMSS-uuuuuu". + * + * Returned string is allocated; caller frees. Returns NULL on error. + */ + +char * +theme_make_backup_name (void) +{ + struct timeval tv; + struct tm *local_time; + char buf[128]; + + if (gettimeofday (&tv, NULL) != 0) + return NULL; + local_time = localtime (&tv.tv_sec); + if (!local_time) + return NULL; + snprintf (buf, sizeof (buf), + "backup-%04d%02d%02d-%02d%02d%02d-%06ld", + local_time->tm_year + 1900, + local_time->tm_mon + 1, + local_time->tm_mday, + local_time->tm_hour, + local_time->tm_min, + local_time->tm_sec, + (long)tv.tv_usec); + return strdup (buf); +} + +/* + * Writes a full snapshot of every themable option to a .theme file at + * "/themes/.theme". + * + * The themes directory is created if missing. The file contains an + * [info] section (name, description, date, weechat version) followed by + * an [options] section listing every themable option's current value. + * + * Returns 1 on success, 0 on error. + */ + +int +theme_write_file_full (const char *name, const char *description) +{ + char *path, *dir, *value, *now; + FILE *file; + struct t_config_file *ptr_config; + struct t_config_section *ptr_section; + struct t_config_option *ptr_option; + + if (!name || !name[0]) + return 0; + + path = NULL; + dir = NULL; + string_asprintf (&dir, "%s/themes", weechat_config_dir); + if (!dir) + return 0; + dir_mkdir (dir, 0755); + free (dir); + + path = theme_user_file_path (name); + if (!path) + return 0; + + file = fopen (path, "w"); + free (path); + if (!file) + return 0; + + now = theme_format_now (); + fprintf (file, "[info]\n"); + fprintf (file, "name = \"%s\"\n", name); + fprintf (file, "description = \"%s\"\n", + (description) ? description : ""); + fprintf (file, "date = \"%s\"\n", (now) ? now : ""); + fprintf (file, "weechat = \"%s\"\n", version_get_version ()); + fprintf (file, "\n[options]\n"); + free (now); + + for (ptr_config = config_files; ptr_config; + ptr_config = ptr_config->next_config) + { + for (ptr_section = ptr_config->sections; ptr_section; + ptr_section = ptr_section->next_section) + { + for (ptr_option = ptr_section->options; ptr_option; + ptr_option = ptr_option->next_option) + { + if (!ptr_option->themable) + continue; + value = config_file_option_value_to_string ( + ptr_option, 0, 1, 0); + fprintf (file, "%s.%s.%s = %s\n", + ptr_config->name, ptr_section->name, + ptr_option->name, + (value) ? value : "\"\""); + free (value); + } + } + } + + fclose (file); + return 1; +} + +/* + * Creates a timestamped backup theme file with the current themable state. + * + * Returned string is the backup name (caller frees), NULL on failure. + */ + +char * +theme_make_backup (void) +{ + char *name; + + name = theme_make_backup_name (); + if (!name) + return NULL; + if (!theme_write_file_full ( + name, + _("Automatic backup written before /theme apply"))) + { + free (name); + return NULL; + } + return name; +} + +/* + * Applies one override entry (callback for hashtable_map during apply). + * + * Refuses entries pointing to options that do not exist or that are not + * themable, logging a warning to the core buffer; the apply itself still + * proceeds with the remaining entries. + */ + +void +theme_apply_set_option_cb (void *data, + struct t_hashtable *hashtable, + const void *key, + const void *value) +{ + struct t_config_option *option = NULL; + + /* make C compiler happy */ + (void) data; + (void) hashtable; + + config_file_search_with_string ((const char *)key, + NULL, NULL, &option, NULL); + if (!option) + { + gui_chat_printf (NULL, + _("%sTheme: option \"%s\" not found, skipped"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + (const char *)key); + return; + } + if (!option->themable) + { + gui_chat_printf ( + NULL, + _("%sTheme: option \"%s\" is not themable, skipped"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + (const char *)key); + return; + } + config_file_option_set (option, (const char *)value, 1); +} + +/* + * Applies a theme registered in memory. + * + * If weechat.look.theme_backup is on (and the target name does not begin + * with "backup-"), a backup file is written first; on backup failure the + * apply is aborted before any option is changed. + * + * Iterates the theme's overrides with theme_applying=1 so the per-option + * change callbacks skip their gui refresh; a single refresh is performed + * at the end. + * + * Returns WEECHAT_RC_OK on success, WEECHAT_RC_ERROR if the theme name + * is unknown or the backup could not be created. + */ + +int +theme_apply (const char *name) +{ + struct t_theme *theme; + char *backup_name = NULL; + + if (!name || !name[0]) + return WEECHAT_RC_ERROR; + + theme = theme_search (name); + if (!theme) + { + gui_chat_printf (NULL, + _("%sTheme \"%s\" not found"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], + name); + return WEECHAT_RC_ERROR; + } + + /* create a backup of current themable state, if enabled */ + if (CONFIG_BOOLEAN(config_look_theme_backup) + && (strncmp (name, "backup-", 7) != 0)) + { + backup_name = theme_make_backup (); + if (!backup_name) + { + gui_chat_printf ( + NULL, + _("%sUnable to create theme backup; aborting apply " + "(disable option weechat.look.theme_backup to force)"), + gui_chat_prefix[GUI_CHAT_PREFIX_ERROR]); + return WEECHAT_RC_ERROR; + } + } + + /* apply each override; per-option refreshes are suppressed via the + theme_applying flag (see config_change_color) */ + theme_applying = 1; + hashtable_map (theme->overrides, &theme_apply_set_option_cb, NULL); + theme_applying = 0; + + /* single refresh at the end */ + if (gui_init_ok) + { + gui_color_init_weechat (); + gui_window_ask_refresh (1); + } + + /* persist the active theme label */ + config_file_option_set (config_look_theme, name, 1); + + /* tell the user about the backup */ + if (backup_name) + { + gui_chat_printf ( + NULL, + _("Previous state saved as theme \"%s\"; to restore: " + "/theme apply %s"), + backup_name, backup_name); + free (backup_name); + } + + hook_signal_send ("theme_applied", + WEECHAT_HOOK_SIGNAL_STRING, (char *)name); + + return WEECHAT_RC_OK; +} + /* * Initializes the theme subsystem. * diff --git a/src/core/core-theme.h b/src/core/core-theme.h index 52c4b295b..35af5dd8e 100644 --- a/src/core/core-theme.h +++ b/src/core/core-theme.h @@ -44,6 +44,8 @@ extern struct t_theme *theme_search (const char *name); extern struct t_theme *theme_register (const char *name, struct t_hashtable *overrides); extern struct t_arraylist *theme_list (void); +extern int theme_apply (const char *name); +extern char *theme_make_backup (void); extern void theme_init (void); extern void theme_end (void); diff --git a/tests/unit/core/test-core-theme.cpp b/tests/unit/core/test-core-theme.cpp index e9bb5a4aa..c6204c192 100644 --- a/tests/unit/core/test-core-theme.cpp +++ b/tests/unit/core/test-core-theme.cpp @@ -29,15 +29,25 @@ extern "C" { #include +#include #include +#include +#include #include "src/core/core-arraylist.h" +#include "src/core/core-config.h" +#include "src/core/core-config-file.h" #include "src/core/core-hashtable.h" +#include "src/core/core-string.h" #include "src/core/core-theme.h" +#include "src/core/weechat.h" #include "src/plugins/plugin.h" extern char *theme_format_now (void); extern struct t_theme *theme_alloc (const char *name); extern void theme_free (struct t_theme *theme); +extern char *theme_user_file_path (const char *name); +extern char *theme_make_backup_name (void); +extern int theme_write_file_full (const char *name, const char *description); } TEST_GROUP(CoreTheme) @@ -261,6 +271,200 @@ TEST(CoreTheme, List) arraylist_free (list); } +/* + * Test functions: + * theme_user_file_path + */ + +TEST(CoreTheme, UserFilePath) +{ + char *path, *expected; + + /* NULL / empty => NULL */ + POINTERS_EQUAL(NULL, theme_user_file_path (NULL)); + POINTERS_EQUAL(NULL, theme_user_file_path ("")); + + /* "name" => "/themes/name.theme" */ + expected = NULL; + string_asprintf (&expected, "%s/themes/dark.theme", weechat_config_dir); + path = theme_user_file_path ("dark"); + CHECK(path != NULL); + STRCMP_EQUAL(expected, path); + free (path); + free (expected); +} + +/* + * Test functions: + * theme_make_backup_name + */ + +TEST(CoreTheme, MakeBackupName) +{ + char *name; + int i; + + name = theme_make_backup_name (); + CHECK(name != NULL); + + /* format: "backup-YYYYMMDD-HHMMSS-uuuuuu" (29 chars) */ + LONGS_EQUAL(29, (long)strlen (name)); + STRNCMP_EQUAL("backup-", name, 7); + + /* 8 digits for date */ + for (i = 7; i < 15; i++) + CHECK(isdigit ((unsigned char)name[i])); + CHECK(name[15] == '-'); + /* 6 digits for time */ + for (i = 16; i < 22; i++) + CHECK(isdigit ((unsigned char)name[i])); + CHECK(name[22] == '-'); + /* 6 digits for microseconds */ + for (i = 23; i < 29; i++) + CHECK(isdigit ((unsigned char)name[i])); + + free (name); +} + +/* + * Test functions: + * theme_write_file_full + */ + +TEST(CoreTheme, WriteFileFull) +{ + char *path, line[8192]; + FILE *file; + int saw_info, saw_name, saw_description, saw_date, saw_weechat; + int saw_options_section, saw_an_option; + + /* refuse empty/NULL */ + LONGS_EQUAL(0, theme_write_file_full (NULL, NULL)); + LONGS_EQUAL(0, theme_write_file_full ("", NULL)); + + /* write a valid file */ + LONGS_EQUAL(1, theme_write_file_full ("test_wrt", "a description")); + + path = theme_user_file_path ("test_wrt"); + CHECK(path != NULL); + + file = fopen (path, "r"); + CHECK(file != NULL); + + saw_info = saw_name = saw_description = saw_date = saw_weechat = 0; + saw_options_section = saw_an_option = 0; + while (fgets (line, sizeof (line) - 1, file)) + { + if (strncmp (line, "[info]", 6) == 0) + saw_info = 1; + else if (strncmp (line, "[options]", 9) == 0) + saw_options_section = 1; + else if (strncmp (line, "name = \"test_wrt\"", 17) == 0) + saw_name = 1; + else if (strncmp (line, "description = \"a description\"", 29) == 0) + saw_description = 1; + else if (strncmp (line, "date = \"", 8) == 0) + saw_date = 1; + else if (strncmp (line, "weechat = \"", 11) == 0) + saw_weechat = 1; + else if (saw_options_section + && (strchr (line, '=') != NULL) + && (strchr (line, '.') != NULL)) + saw_an_option = 1; + } + fclose (file); + + LONGS_EQUAL(1, saw_info); + LONGS_EQUAL(1, saw_name); + LONGS_EQUAL(1, saw_description); + LONGS_EQUAL(1, saw_date); + LONGS_EQUAL(1, saw_weechat); + LONGS_EQUAL(1, saw_options_section); + LONGS_EQUAL(1, saw_an_option); + + unlink (path); + free (path); +} + +/* + * Test functions: + * theme_make_backup + */ + +TEST(CoreTheme, MakeBackup) +{ + char *name, *path; + struct stat st; + + name = theme_make_backup (); + CHECK(name != NULL); + STRNCMP_EQUAL("backup-", name, 7); + LONGS_EQUAL(29, (long)strlen (name)); + + /* the backup file must exist on disk */ + path = theme_user_file_path (name); + CHECK(path != NULL); + LONGS_EQUAL(0, stat (path, &st)); + CHECK(st.st_size > 0); + + unlink (path); + free (path); + free (name); +} + +/* + * Test functions: + * theme_apply_set_option_cb + * theme_apply + */ + +TEST(CoreTheme, Apply) +{ + struct t_hashtable *overrides; + struct t_config_option *opt_prefix_error; + char *saved_prefix_error, *saved_theme_label; + int saved_backup; + + /* NULL / empty / missing name => error */ + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_apply (NULL)); + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_apply ("")); + LONGS_EQUAL(WEECHAT_RC_ERROR, theme_apply ("does_not_exist")); + + /* snapshot the option we will mutate + supporting state */ + opt_prefix_error = NULL; + config_file_search_with_string ("weechat.look.prefix_error", + NULL, NULL, &opt_prefix_error, NULL); + CHECK(opt_prefix_error != NULL); + saved_prefix_error = strdup (CONFIG_STRING(opt_prefix_error)); + saved_theme_label = strdup (CONFIG_STRING(config_look_theme)); + saved_backup = CONFIG_BOOLEAN(config_look_theme_backup); + + /* disable backup so the test does not touch the filesystem */ + config_file_option_set (config_look_theme_backup, "off", 1); + + /* register a theme that flips one themable option, then apply */ + overrides = make_overrides ("weechat.look.prefix_error", "TEST!", + NULL, NULL); + theme_register ("apply_test", overrides); + hashtable_free (overrides); + + LONGS_EQUAL(WEECHAT_RC_OK, theme_apply ("apply_test")); + + /* override took effect */ + STRCMP_EQUAL("TEST!", CONFIG_STRING(opt_prefix_error)); + /* active label persisted */ + STRCMP_EQUAL("apply_test", CONFIG_STRING(config_look_theme)); + + /* restore previous state */ + config_file_option_set (opt_prefix_error, saved_prefix_error, 1); + config_file_option_set (config_look_theme, saved_theme_label, 1); + config_file_option_set (config_look_theme_backup, + (saved_backup) ? "on" : "off", 1); + + free (saved_prefix_error); + free (saved_theme_label); +} + /* * Test functions: * theme_init