/* * SPDX-FileCopyrightText: 2026 Sébastien Helleu * * SPDX-License-Identifier: GPL-3.0-or-later * * This file is part of WeeChat, the extensible chat client. * * WeeChat is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * WeeChat is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with WeeChat. If not, see . */ /* Themes: named bundles of option overrides applied via /theme command */ #ifdef HAVE_CONFIG_H #include "config.h" #endif #include #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" struct t_theme *themes = NULL; struct t_theme *last_theme = NULL; int theme_applying = 0; /* * Searches for a theme by name in the in-memory registry. * * Returns pointer to theme found, NULL if not found. */ struct t_theme * theme_search (const char *name) { struct t_theme *ptr_theme; if (!name) return NULL; for (ptr_theme = themes; ptr_theme; ptr_theme = ptr_theme->next_theme) { if (strcmp (ptr_theme->name, name) == 0) return ptr_theme; } return NULL; } /* * Builds a "YYYY-MM-DD HH:MM:SS" timestamp string for "now" (local time). * * Returned string is allocated; caller frees. */ char * theme_format_now (void) { time_t time_now; struct tm *local_time; char buf[32]; time_now = time (NULL); local_time = localtime (&time_now); if (!local_time) return strdup (""); if (strftime (buf, sizeof (buf), "%Y-%m-%d %H:%M:%S", local_time) == 0) return strdup (""); return strdup (buf); } /* * Allocates a new theme with name and empty metadata; does not link it * into the registry. * * Returns the new theme, NULL on error. */ struct t_theme * theme_alloc (const char *name) { struct t_theme *new_theme; new_theme = calloc (1, sizeof (*new_theme)); if (!new_theme) return NULL; new_theme->name = strdup (name); new_theme->description = strdup (""); new_theme->date = theme_format_now (); new_theme->weechat_version = strdup (version_get_version ()); new_theme->overrides = hashtable_new (32, WEECHAT_HASHTABLE_STRING, WEECHAT_HASHTABLE_STRING, NULL, NULL); if (!new_theme->name || !new_theme->description || !new_theme->date || !new_theme->weechat_version || !new_theme->overrides) { free (new_theme->name); free (new_theme->description); free (new_theme->date); free (new_theme->weechat_version); hashtable_free (new_theme->overrides); free (new_theme); return NULL; } return new_theme; } /* * Frees a theme (does not unlink from registry; caller handles that). */ void theme_free (struct t_theme *theme) { if (!theme) return; free (theme->name); free (theme->description); free (theme->date); free (theme->weechat_version); hashtable_free (theme->overrides); free (theme); } /* * Merges entries from src into dst (overwrites duplicate keys). */ void theme_merge_overrides_cb (void *data, struct t_hashtable *hashtable, const void *key, const void *value) { struct t_hashtable *dst = (struct t_hashtable *)data; /* make C compiler happy */ (void) hashtable; hashtable_set (dst, (const char *)key, (const char *)value); } /* * Registers a theme by name with a set of option overrides. * * If a theme with the given name already exists, the provided overrides * are merged into the existing theme's hashtable (later registrations * override earlier ones for duplicate keys). This lets plugins/scripts * register their per-theme contributions without coordinating with core. * * The "overrides" hashtable passed in is read-only from this function's * perspective; the caller retains ownership and may free it. * * Returns pointer to theme (existing or newly created), NULL on error. */ struct t_theme * theme_register (const char *name, struct t_hashtable *overrides) { struct t_theme *theme; if (!name || !name[0]) return NULL; theme = theme_search (name); if (!theme) { theme = theme_alloc (name); if (!theme) return NULL; theme->prev_theme = last_theme; theme->next_theme = NULL; if (last_theme) last_theme->next_theme = theme; else themes = theme; last_theme = theme; } if (overrides) { hashtable_map (overrides, &theme_merge_overrides_cb, theme->overrides); } return theme; } /* * Compares two themes by name (callback used by arraylist sort). * * Returns negative, zero, or positive value (like strcmp). */ int theme_list_cmp_cb (void *data, struct t_arraylist *arraylist, void *pointer1, void *pointer2) { /* make C compiler happy */ (void) data; (void) arraylist; return strcmp (((struct t_theme *)pointer1)->name, ((struct t_theme *)pointer2)->name); } /* * Returns an arraylist of t_theme * for all registered themes (built-ins). * * The returned arraylist owns no data; callers must not free its items. * Returns NULL on allocation failure. */ struct t_arraylist * theme_list (void) { struct t_arraylist *list; struct t_theme *ptr_theme; list = arraylist_new (8, 1, 0, &theme_list_cmp_cb, NULL, NULL, NULL); if (!list) return NULL; for (ptr_theme = themes; ptr_theme; ptr_theme = ptr_theme->next_theme) arraylist_add (list, ptr_theme); 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); } /* * Strips one optional pair of matching surrounding quotes (' or ") from * the in-place string; returns a pointer that may differ from the input * (advances past an opening quote). */ char * theme_file_strip_quotes (char *value) { size_t len; if (!value) return value; len = strlen (value); if ((len >= 2) && (((value[0] == '"') && (value[len - 1] == '"')) || ((value[0] == '\'') && (value[len - 1] == '\'')))) { value[len - 1] = '\0'; return value + 1; } return value; } /* * Parses a .theme file into a transient t_theme. * * The file uses two INI-like sections: [info] (keys: name, description, * date, weechat) and [options] (key = full option name like * "irc.color.input_nick", value = string). Unknown [info] keys produce a * warning and are ignored; unknown sections produce a warning and the * lines in them are skipped. * * Returns a heap-allocated t_theme (caller frees with theme_free), or * NULL if the file cannot be opened. */ struct t_theme * theme_file_parse (const char *path) { FILE *file; char line[8192], *ptr, *end, *eq, *key, *value; int line_number, in_options; struct t_theme *theme; if (!path) return NULL; file = fopen (path, "r"); if (!file) return NULL; theme = theme_alloc (""); if (!theme) { fclose (file); return NULL; } /* clear the placeholder name; the file should provide it */ free (theme->name); theme->name = NULL; /* description/date/weechat_version come from the file too */ free (theme->description); theme->description = NULL; free (theme->date); theme->date = NULL; free (theme->weechat_version); theme->weechat_version = NULL; line_number = 0; in_options = 0; while (fgets (line, sizeof (line) - 1, file)) { line_number++; /* trim trailing CR / LF */ end = strchr (line, '\r'); if (end) *end = '\0'; end = strchr (line, '\n'); if (end) *end = '\0'; /* skip leading whitespace */ ptr = line; while ((ptr[0] == ' ') || (ptr[0] == '\t')) ptr++; /* skip empty lines and comments */ if (!ptr[0] || (ptr[0] == '#')) continue; /* section header */ if (ptr[0] == '[') { end = strchr (ptr, ']'); if (!end) { gui_chat_printf ( NULL, _("%s%s: line %d: malformed section header"), gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], path, line_number); continue; } *end = '\0'; if (strcmp (ptr + 1, "info") == 0) { in_options = 0; } else if (strcmp (ptr + 1, "options") == 0) { in_options = 1; } else { gui_chat_printf ( NULL, _("%s%s: line %d: ignoring unknown section \"%s\""), gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], path, line_number, ptr + 1); in_options = -1; /* skip lines until next known section */ } continue; } if (in_options < 0) continue; /* "key = value" */ eq = strchr (ptr, '='); if (!eq) { gui_chat_printf ( NULL, _("%s%s: line %d: missing '=' separator"), gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], path, line_number); continue; } /* trim key */ key = ptr; end = eq - 1; while ((end > key) && ((end[0] == ' ') || (end[0] == '\t'))) end--; end[1] = '\0'; /* trim value */ value = eq + 1; while ((value[0] == ' ') || (value[0] == '\t')) value++; end = value + strlen (value) - 1; while ((end > value) && ((end[0] == ' ') || (end[0] == '\t'))) end--; end[1] = '\0'; value = theme_file_strip_quotes (value); if (in_options) { hashtable_set (theme->overrides, key, value); } else { /* [info] section */ if (strcmp (key, "name") == 0) { free (theme->name); theme->name = strdup (value); } else if (strcmp (key, "description") == 0) { free (theme->description); theme->description = strdup (value); } else if (strcmp (key, "date") == 0) { free (theme->date); theme->date = strdup (value); } else if (strcmp (key, "weechat") == 0) { free (theme->weechat_version); theme->weechat_version = strdup (value); } else { gui_chat_printf ( NULL, _("%s%s: line %d: ignoring unknown [info] key \"%s\""), gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], path, line_number, key); } } } fclose (file); if (!theme->name) theme->name = strdup (""); if (!theme->description) theme->description = strdup (""); if (!theme->date) theme->date = strdup (""); if (!theme->weechat_version) theme->weechat_version = strdup (""); return theme; } /* * 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 *file_theme = NULL; struct t_theme *registry_theme = NULL; struct t_hashtable *overrides = NULL; char *path = NULL; char *backup_name = NULL; if (!name || !name[0]) return WEECHAT_RC_ERROR; /* Resolution: a user file with the given name shadows any built-in of the same name. Read the file transiently (parse, apply, free) so user themes have no steady-state memory footprint. */ path = theme_user_file_path (name); if (path && (access (path, R_OK) == 0)) { file_theme = theme_file_parse (path); if (!file_theme) { gui_chat_printf (NULL, _("%sFailed to parse theme file \"%s\""), gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], path); free (path); return WEECHAT_RC_ERROR; } overrides = file_theme->overrides; } else { registry_theme = theme_search (name); if (!registry_theme) { gui_chat_printf (NULL, _("%sTheme \"%s\" not found"), gui_chat_prefix[GUI_CHAT_PREFIX_ERROR], name); free (path); return WEECHAT_RC_ERROR; } overrides = registry_theme->overrides; } free (path); /* 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) { theme_free (file_theme); 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 (overrides, &theme_apply_set_option_cb, NULL); theme_applying = 0; /* file_theme (if any) is transient: discard now */ theme_free (file_theme); /* 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. * * The registry starts empty; built-in themes are registered later (by * core and by plugins/scripts at their own init time). */ void theme_init (void) { themes = NULL; last_theme = NULL; theme_applying = 0; } /* * Frees all registered themes and clears the registry. */ void theme_end (void) { struct t_theme *ptr_theme, *next_theme; ptr_theme = themes; while (ptr_theme) { next_theme = ptr_theme->next_theme; theme_free (ptr_theme); ptr_theme = next_theme; } themes = NULL; last_theme = NULL; }