1
0
mirror of https://github.com/weechat/weechat.git synced 2026-06-28 13:56:37 +02:00

core: implement theme file parsing and transient file reads in /theme apply

Add a small INI-style parser for *.theme files and wire it into the
/theme command so user themes living in directory "themes" inside the
WeeChat configuration directory can be applied (and inspected) without
ever being cached.

Parser (theme_file_parse in core-theme.c) accepts two sections:

  [info]
  name = "..."          \ shown by /theme info; ignored for apply
  description = "..."   |
  date = "..."          |
  weechat = "..."       /
  (unknown keys are ignored with a warning)

  [options]
  full.option.name = "value"

Surrounding single or double quotes around a value are stripped (same
rule used by the regular config file reader). The parsed result is a
heap-allocated t_theme; the caller frees with theme_free.

Resolution rule in theme_apply: if the path
"${weechat_config_dir}/themes/<name>.theme" is readable it is parsed
and used (file shadows any built-in of the same name); otherwise the
built-in registry is consulted. The transient t_theme is freed before
the final refresh, so user themes have no steady-state memory
footprint regardless of how many .theme files have accumulated.

/theme list now also scans the themes directory and appends user
files to the listing (each marked "(file)"). backup-*.theme are
hidden by default; pass "-backups" to include them.

/theme info <name> works for both sources: file path is shown when the
information comes from disk; "built-in (in-memory)" otherwise.
This commit is contained in:
Sébastien Helleu
2026-05-26 18:43:09 +02:00
parent b36bc05773
commit eecf1e3e29
4 changed files with 557 additions and 31 deletions
+149 -22
View File
@@ -7181,16 +7181,65 @@ COMMAND_CALLBACK(sys)
COMMAND_ERROR;
}
/*
* Callback for "dir_exec_on_files": collects names of files matching
* "*.theme" into an arraylist passed as data; "backup-*.theme" is
* excluded when *(int *)data->show_backups is zero.
*
* The arraylist is iterated outside this callback so all dirent
* processing can happen in one place.
*/
struct t_command_theme_dir_collect
{
struct t_arraylist *names; /* arraylist of char * (owned) */
int show_backups; /* include backup-*.theme ? */
};
void
command_theme_collect_file_cb (void *data, const char *filename)
{
struct t_command_theme_dir_collect *ctx;
const char *base;
char *name;
size_t len;
ctx = (struct t_command_theme_dir_collect *)data;
base = strrchr (filename, '/');
base = (base) ? base + 1 : filename;
len = strlen (base);
if ((len < 7) || (strcmp (base + len - 6, ".theme") != 0))
return;
if (!ctx->show_backups && (strncmp (base, "backup-", 7) == 0))
return;
name = string_strndup (base, len - 6);
if (name)
arraylist_add (ctx->names, name);
}
int
command_theme_strcmp_cb (void *data, struct t_arraylist *arraylist,
void *pointer1, void *pointer2)
{
/* make C compiler happy */
(void) data;
(void) arraylist;
return strcmp ((const char *)pointer1, (const char *)pointer2);
}
/*
* Callback for command "/theme": list or display details on themes.
*/
COMMAND_CALLBACK(theme)
{
struct t_arraylist *list;
struct t_theme *ptr_theme;
const char *ptr_active;
int i, size;
struct t_arraylist *list, *file_names;
struct t_command_theme_dir_collect collect;
struct t_theme *ptr_theme, *file_theme;
const char *ptr_active, *ptr_name;
char *path, *dir;
int i, size, show_backups;
/* make C compiler happy */
(void) pointer;
@@ -7198,20 +7247,46 @@ COMMAND_CALLBACK(theme)
(void) buffer;
(void) argv_eol;
/* "/theme" or "/theme list": list themes */
/* "/theme" or "/theme list [-backups]": list themes */
if ((argc == 1) || (string_strcmp (argv[1], "list") == 0))
{
show_backups = ((argc >= 3)
&& (string_strcmp (argv[2], "-backups") == 0));
ptr_active = CONFIG_STRING(config_look_theme);
list = theme_list ();
if (!list || (arraylist_size (list) == 0))
/* scan ${weechat_config_dir}/themes/ for *.theme files */
file_names = arraylist_new (8, 1, 0,
&command_theme_strcmp_cb, NULL,
NULL, NULL);
if (file_names)
{
gui_chat_printf (NULL, _("No theme registered"));
dir = NULL;
string_asprintf (&dir, "%s/themes", weechat_config_dir);
if (dir)
{
collect.names = file_names;
collect.show_backups = show_backups;
dir_exec_on_files (dir, 0, 0,
&command_theme_collect_file_cb,
&collect);
free (dir);
}
}
if ((!list || (arraylist_size (list) == 0))
&& (!file_names || (arraylist_size (file_names) == 0)))
{
gui_chat_printf (NULL, _("No theme available"));
arraylist_free (list);
arraylist_free (file_names);
return WEECHAT_RC_OK;
}
ptr_active = CONFIG_STRING(config_look_theme);
gui_chat_printf (NULL, "");
gui_chat_printf (NULL, _("Themes:"));
size = arraylist_size (list);
size = (list) ? arraylist_size (list) : 0;
for (i = 0; i < size; i++)
{
ptr_theme = (struct t_theme *)arraylist_get (list, i);
@@ -7227,6 +7302,26 @@ COMMAND_CALLBACK(theme)
? ": " : "",
(ptr_theme->description) ? ptr_theme->description : "");
}
size = (file_names) ? arraylist_size (file_names) : 0;
for (i = 0; i < size; i++)
{
ptr_name = (const char *)arraylist_get (file_names, i);
gui_chat_printf (
NULL,
" %s %s%s%s (file)",
(ptr_active && (strcmp (ptr_active, ptr_name) == 0))
? "->" : " ",
GUI_COLOR(GUI_COLOR_CHAT_BUFFER),
ptr_name,
GUI_COLOR(GUI_COLOR_CHAT));
}
if (file_names)
{
size = arraylist_size (file_names);
for (i = 0; i < size; i++)
free (arraylist_get (file_names, i));
arraylist_free (file_names);
}
arraylist_free (list);
return WEECHAT_RC_OK;
}
@@ -7242,21 +7337,46 @@ COMMAND_CALLBACK(theme)
if (string_strcmp (argv[1], "info") == 0)
{
COMMAND_MIN_ARGS(3, "info");
ptr_theme = theme_search (argv[2]);
if (!ptr_theme)
/* file shadows registry: try user file first */
path = theme_user_file_path (argv[2]);
file_theme = NULL;
if (path && (access (path, R_OK) == 0))
file_theme = theme_file_parse (path);
if (!file_theme)
{
gui_chat_printf (NULL,
_("%sTheme \"%s\" not found"),
gui_chat_prefix[GUI_CHAT_PREFIX_ERROR],
argv[2]);
return WEECHAT_RC_ERROR;
free (path);
path = NULL;
ptr_theme = theme_search (argv[2]);
if (!ptr_theme)
{
gui_chat_printf (NULL,
_("%sTheme \"%s\" not found"),
gui_chat_prefix[GUI_CHAT_PREFIX_ERROR],
argv[2]);
return WEECHAT_RC_ERROR;
}
}
else
{
ptr_theme = file_theme;
}
gui_chat_printf (NULL, "");
gui_chat_printf (NULL,
_("Theme \"%s%s%s\":"),
GUI_COLOR(GUI_COLOR_CHAT_BUFFER),
ptr_theme->name,
(ptr_theme->name && ptr_theme->name[0])
? ptr_theme->name : argv[2],
GUI_COLOR(GUI_COLOR_CHAT));
if (path)
{
gui_chat_printf (NULL,
_(" source : %s"), path);
}
else
{
gui_chat_printf (NULL,
_(" source : built-in (in-memory)"));
}
gui_chat_printf (NULL,
_(" description : %s"),
(ptr_theme->description) ? ptr_theme->description : "");
@@ -7271,6 +7391,8 @@ COMMAND_CALLBACK(theme)
_(" overrides : %d"),
(ptr_theme->overrides)
? ptr_theme->overrides->items_count : 0);
free (path);
theme_free (file_theme);
return WEECHAT_RC_OK;
}
@@ -9933,14 +10055,19 @@ command_init (void)
NULL, "theme",
N_("manage color themes"),
/* TRANSLATORS: only text between angle brackets (eg: "<name>") may be translated */
N_("[list]"
N_("[list [-backups]]"
" || apply <name>"
" || info <name>"),
CMD_ARGS_DESC(
N_("raw[list]: list registered themes (default action with no "
"argument); active theme is marked with \"->\""),
N_("raw[list]: list registered themes and any *.theme files in "
"the WeeChat configuration directory; the active theme "
"(matching weechat.look.theme) is marked with \"->\". By "
"default backup-*.theme files are hidden; pass \"-backups\" "
"to include them"),
N_("raw[apply]: apply a theme (set every themable option to the "
"value from the theme)"),
"value from the theme); if a file named <name>.theme "
"exists in directory \"themes\" it shadows any built-in "
"theme of the same name"),
N_("raw[info]: display details on a theme (name, description, "
"creation date, WeeChat version, number of option overrides)"),
N_("name: name of a theme"),
@@ -9957,7 +10084,7 @@ command_init (void)
"state can be restored with: /theme apply "
"backup-<timestamp>. This is controlled by the option "
"weechat.look.theme_backup.")),
"list"
"list -backups"
" || apply"
" || info",
&command_theme, NULL, NULL);
+251 -9
View File
@@ -30,6 +30,7 @@
#include <string.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#include "weechat.h"
#include "core-arraylist.h"
@@ -448,6 +449,216 @@ theme_apply_set_option_cb (void *data,
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.
*
@@ -466,21 +677,48 @@ theme_apply_set_option_cb (void *data,
int
theme_apply (const char *name)
{
struct t_theme *theme;
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;
theme = theme_search (name);
if (!theme)
/* 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))
{
gui_chat_printf (NULL,
_("%sTheme \"%s\" not found"),
gui_chat_prefix[GUI_CHAT_PREFIX_ERROR],
name);
return WEECHAT_RC_ERROR;
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)
@@ -489,6 +727,7 @@ theme_apply (const char *name)
backup_name = theme_make_backup ();
if (!backup_name)
{
theme_free (file_theme);
gui_chat_printf (
NULL,
_("%sUnable to create theme backup; aborting apply "
@@ -501,9 +740,12 @@ theme_apply (const char *name)
/* 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);
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)
{
+3
View File
@@ -46,6 +46,9 @@ extern struct t_theme *theme_register (const char *name,
extern struct t_arraylist *theme_list (void);
extern int theme_apply (const char *name);
extern char *theme_make_backup (void);
extern char *theme_user_file_path (const char *name);
extern struct t_theme *theme_file_parse (const char *path);
extern void theme_free (struct t_theme *theme);
extern void theme_init (void);
extern void theme_end (void);
+154
View File
@@ -48,6 +48,8 @@ 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);
extern char *theme_file_strip_quotes (char *value);
extern struct t_theme *theme_file_parse (const char *path);
}
TEST_GROUP(CoreTheme)
@@ -465,6 +467,158 @@ TEST(CoreTheme, Apply)
free (saved_theme_label);
}
/*
* Test functions:
* theme_file_strip_quotes
*/
TEST(CoreTheme, FileStripQuotes)
{
char buf[64];
/* NULL passes through */
POINTERS_EQUAL(NULL, theme_file_strip_quotes (NULL));
/* len < 2: too short to be a matched quote pair */
strcpy (buf, "");
STRCMP_EQUAL("", theme_file_strip_quotes (buf));
strcpy (buf, "a");
STRCMP_EQUAL("a", theme_file_strip_quotes (buf));
strcpy (buf, "\"");
STRCMP_EQUAL("\"", theme_file_strip_quotes (buf));
/* no quotes: returned as-is */
strcpy (buf, "hello");
STRCMP_EQUAL("hello", theme_file_strip_quotes (buf));
/* matched double quotes are stripped */
strcpy (buf, "\"hello\"");
STRCMP_EQUAL("hello", theme_file_strip_quotes (buf));
/* matched single quotes are stripped */
strcpy (buf, "'world'");
STRCMP_EQUAL("world", theme_file_strip_quotes (buf));
/* mismatched: unchanged */
strcpy (buf, "\"unmatched'");
STRCMP_EQUAL("\"unmatched'", theme_file_strip_quotes (buf));
strcpy (buf, "'unmatched\"");
STRCMP_EQUAL("'unmatched\"", theme_file_strip_quotes (buf));
/* exactly two quotes => empty string after stripping */
strcpy (buf, "\"\"");
STRCMP_EQUAL("", theme_file_strip_quotes (buf));
/* internal quotes only on one side: unchanged */
strcpy (buf, "no\"quote");
STRCMP_EQUAL("no\"quote", theme_file_strip_quotes (buf));
}
/*
* Test functions:
* theme_file_parse
*/
TEST(CoreTheme, FileParse)
{
const char *path = "/tmp/weechat_test_theme_parse.theme";
FILE *file;
struct t_theme *theme;
/* NULL and missing file => NULL */
POINTERS_EQUAL(NULL, theme_file_parse (NULL));
unlink (path); /* belt-and-suspenders */
POINTERS_EQUAL(NULL, theme_file_parse (path));
/* write a well-formed file: [info] + [options], mixed quoting,
blanks and comments scattered around */
file = fopen (path, "w");
CHECK(file != NULL);
fprintf (file, "# leading comment\n");
fprintf (file, "\n");
fprintf (file, "[info]\n");
fprintf (file, "name = \"solarized_light\"\n");
fprintf (file, "description = \"Light-bg theme\"\n");
fprintf (file, "date = \"2026-05-26 09:42:10\"\n");
fprintf (file, "weechat = \"4.10.0-dev\"\n");
fprintf (file, "unknown_info_key = \"ignored\"\n");
fprintf (file, "\n");
fprintf (file, "[options]\n");
fprintf (file, "weechat.color.chat = default\n"); /* unquoted */
fprintf (file, " weechat.color.separator = \"blue\"\n"); /* whitespace + quotes */
fprintf (file, "irc.color.input_nick = 'lightcyan'\n"); /* single quotes */
fclose (file);
theme = theme_file_parse (path);
CHECK(theme != NULL);
/* [info] fields populated */
STRCMP_EQUAL("solarized_light", theme->name);
STRCMP_EQUAL("Light-bg theme", theme->description);
STRCMP_EQUAL("2026-05-26 09:42:10", theme->date);
STRCMP_EQUAL("4.10.0-dev", theme->weechat_version);
/* [options] entries: three known keys, "unknown_info_key" must NOT
leak in (it lives under [info]) */
LONGS_EQUAL(3, theme->overrides->items_count);
STRCMP_EQUAL("default",
(const char *)hashtable_get (theme->overrides,
"weechat.color.chat"));
STRCMP_EQUAL("blue",
(const char *)hashtable_get (theme->overrides,
"weechat.color.separator"));
STRCMP_EQUAL("lightcyan",
(const char *)hashtable_get (theme->overrides,
"irc.color.input_nick"));
POINTERS_EQUAL(NULL, hashtable_get (theme->overrides,
"unknown_info_key"));
theme_free (theme);
unlink (path);
/* parse a file that has only [info]: overrides hashtable empty,
missing [info] keys default to empty string */
file = fopen (path, "w");
CHECK(file != NULL);
fprintf (file, "[info]\n");
fprintf (file, "name = \"only_info\"\n");
fclose (file);
theme = theme_file_parse (path);
CHECK(theme != NULL);
STRCMP_EQUAL("only_info", theme->name);
STRCMP_EQUAL("", theme->description);
STRCMP_EQUAL("", theme->date);
STRCMP_EQUAL("", theme->weechat_version);
LONGS_EQUAL(0, theme->overrides->items_count);
theme_free (theme);
unlink (path);
/* malformed lines must not crash; a missing-'=' line and a stray
section header are tolerated, the rest of the file still parses */
file = fopen (path, "w");
CHECK(file != NULL);
fprintf (file, "[info]\n");
fprintf (file, "name = \"robust\"\n");
fprintf (file, "broken line without equals\n");
fprintf (file, "[unknown_section]\n");
fprintf (file, "ignored = value\n");
fprintf (file, "[options]\n");
fprintf (file, "weechat.color.chat = red\n");
fclose (file);
theme = theme_file_parse (path);
CHECK(theme != NULL);
STRCMP_EQUAL("robust", theme->name);
LONGS_EQUAL(1, theme->overrides->items_count);
STRCMP_EQUAL("red",
(const char *)hashtable_get (theme->overrides,
"weechat.color.chat"));
POINTERS_EQUAL(NULL, hashtable_get (theme->overrides, "ignored"));
theme_free (theme);
unlink (path);
}
/*
* Test functions:
* theme_init