diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index eb93fd760..eca6ebe0e 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -51,6 +51,7 @@ set(LIB_CORE_SRC core-signal.c core-signal.h core-string.c core-string.h core-sys.c core-sys.h + core-theme.c core-theme.h core-upgrade.c core-upgrade.h core-upgrade-file.c core-upgrade-file.h core-url.c core-url.h diff --git a/src/core/core-theme.c b/src/core/core-theme.c new file mode 100644 index 000000000..c350d4aa9 --- /dev/null +++ b/src/core/core-theme.c @@ -0,0 +1,284 @@ +/* + * 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 "weechat.h" +#include "core-arraylist.h" +#include "core-hashtable.h" +#include "core-string.h" +#include "core-theme.h" +#include "core-version.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; +} + +/* + * 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; +} diff --git a/src/core/core-theme.h b/src/core/core-theme.h new file mode 100644 index 000000000..52c4b295b --- /dev/null +++ b/src/core/core-theme.h @@ -0,0 +1,51 @@ +/* + * 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 . + */ + +#ifndef WEECHAT_THEME_H +#define WEECHAT_THEME_H + +struct t_hashtable; +struct t_arraylist; + +struct t_theme +{ + char *name; /* "dark", "solarized", ... */ + char *description; /* free-form text */ + char *date; /* "YYYY-MM-DD HH:MM:SS" */ + char *weechat_version; /* version at registration time */ + struct t_hashtable *overrides; /* full_option_name -> value */ + struct t_theme *prev_theme; /* link to previous theme */ + struct t_theme *next_theme; /* link to next theme */ +}; + +extern struct t_theme *themes; +extern struct t_theme *last_theme; +extern int theme_applying; /* gate for config_change_color */ + +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 void theme_init (void); +extern void theme_end (void); + +#endif /* WEECHAT_THEME_H */ diff --git a/src/core/weechat.c b/src/core/weechat.c index 356feb6f7..5422b9278 100644 --- a/src/core/weechat.c +++ b/src/core/weechat.c @@ -75,6 +75,7 @@ #include "core-secure-config.h" #include "core-signal.h" #include "core-string.h" +#include "core-theme.h" #include "core-upgrade.h" #include "core-url.h" #include "core-utf8.h" @@ -384,6 +385,7 @@ weechat_init (int argc, char *argv[], void (*gui_init_cb)(void)) weechat_shutdown (EXIT_FAILURE, 0); if (!secure_config_init ()) /* init secured data options (sec.*)*/ weechat_shutdown (EXIT_FAILURE, 0); + theme_init (); /* initialize theme registry */ if (!config_weechat_init ()) /* init WeeChat options (weechat.*) */ weechat_shutdown (EXIT_FAILURE, 0); args_parse (argc, argv); /* parse command line args */ @@ -449,6 +451,7 @@ weechat_end (void (*gui_end_cb)(int clean_exit)) unhook_all (); /* remove all hooks */ hdata_end (); /* end hdata */ secure_end (); /* end secured data */ + theme_end (); /* end theme registry */ string_end (); /* end string */ weeurl_end (); weechat_shutdown (-1, 0); /* end other things */ diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 97a39b6fa..2fed50c47 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -75,6 +75,7 @@ set(LIB_WEECHAT_UNIT_TESTS_CORE_SRC core/test-core-secure.cpp core/test-core-signal.cpp core/test-core-string.cpp + core/test-core-theme.cpp core/test-core-url.cpp core/test-core-utf8.cpp core/test-core-util.cpp diff --git a/tests/unit/core/test-core-theme.cpp b/tests/unit/core/test-core-theme.cpp new file mode 100644 index 000000000..e9bb5a4aa --- /dev/null +++ b/tests/unit/core/test-core-theme.cpp @@ -0,0 +1,295 @@ +/* + * 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 . + */ + +/* Test theme functions */ + +#include "CppUTest/TestHarness.h" + +#include "tests.h" +#include "tests-record.h" + +extern "C" +{ +#include +#include +#include "src/core/core-arraylist.h" +#include "src/core/core-hashtable.h" +#include "src/core/core-theme.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); +} + +TEST_GROUP(CoreTheme) +{ + void setup () + { + /* start every test with a clean registry */ + theme_end (); + theme_init (); + } + + void teardown () + { + theme_end (); + } + + struct t_hashtable *make_overrides (const char *key1, const char *val1, + const char *key2, const char *val2) + { + struct t_hashtable *hashtable; + + hashtable = hashtable_new (8, + WEECHAT_HASHTABLE_STRING, + WEECHAT_HASHTABLE_STRING, + NULL, + NULL); + if (key1) + hashtable_set (hashtable, key1, val1); + if (key2) + hashtable_set (hashtable, key2, val2); + return hashtable; + } +}; + +/* + * Test functions: + * theme_search + */ + +TEST(CoreTheme, Search) +{ + struct t_hashtable *overrides; + + /* empty registry */ + POINTERS_EQUAL(NULL, theme_search ("dark")); + POINTERS_EQUAL(NULL, theme_search (NULL)); + + overrides = make_overrides ("weechat.color.chat", "default", NULL, NULL); + theme_register ("dark", overrides); + hashtable_free (overrides); + + /* registered name found */ + CHECK(theme_search ("dark") != NULL); + STRCMP_EQUAL("dark", theme_search ("dark")->name); + + /* unknown / case mismatch / NULL */ + POINTERS_EQUAL(NULL, theme_search ("light")); + POINTERS_EQUAL(NULL, theme_search ("Dark")); + POINTERS_EQUAL(NULL, theme_search (NULL)); +} + +/* + * Test functions: + * theme_format_now + */ + +TEST(CoreTheme, FormatNow) +{ + char *str; + int i; + + str = theme_format_now (); + CHECK(str != NULL); + LONGS_EQUAL(19, (long)strlen (str)); + + /* format: YYYY-MM-DD HH:MM:SS */ + for (i = 0; i < 4; i++) + CHECK(isdigit ((unsigned char)str[i])); + CHECK(str[4] == '-'); + CHECK(isdigit ((unsigned char)str[5])); + CHECK(isdigit ((unsigned char)str[6])); + CHECK(str[7] == '-'); + CHECK(isdigit ((unsigned char)str[8])); + CHECK(isdigit ((unsigned char)str[9])); + CHECK(str[10] == ' '); + CHECK(isdigit ((unsigned char)str[11])); + CHECK(isdigit ((unsigned char)str[12])); + CHECK(str[13] == ':'); + CHECK(isdigit ((unsigned char)str[14])); + CHECK(isdigit ((unsigned char)str[15])); + CHECK(str[16] == ':'); + CHECK(isdigit ((unsigned char)str[17])); + CHECK(isdigit ((unsigned char)str[18])); + + free (str); +} + +/* + * Test functions: + * theme_alloc + */ + +TEST(CoreTheme, Alloc) +{ + struct t_theme *theme; + + theme = theme_alloc ("solarized_light"); + CHECK(theme != NULL); + STRCMP_EQUAL("solarized_light", theme->name); + STRCMP_EQUAL("", theme->description); + CHECK(theme->date != NULL); + LONGS_EQUAL(19, (long)strlen (theme->date)); + CHECK(theme->weechat_version != NULL); + CHECK(theme->weechat_version[0] != '\0'); + CHECK(theme->overrides != NULL); + LONGS_EQUAL(0, theme->overrides->items_count); + POINTERS_EQUAL(NULL, theme->prev_theme); + POINTERS_EQUAL(NULL, theme->next_theme); + + theme_free (theme); +} + +/* + * Test functions: + * theme_free + */ + +TEST(CoreTheme, Free) +{ + struct t_theme *theme; + + /* free(NULL) is a no-op, must not crash */ + theme_free (NULL); + + /* free a valid theme that is NOT in the registry */ + theme = theme_alloc ("unknown"); + theme_free (theme); +} + +/* + * Test functions: + * theme_merge_overrides_cb + * theme_register + */ + +TEST(CoreTheme, Register) +{ + struct t_hashtable *o1, *o2; + struct t_theme *t1, *t2; + + /* NULL / empty name => NULL */ + POINTERS_EQUAL(NULL, theme_register (NULL, NULL)); + POINTERS_EQUAL(NULL, theme_register ("", NULL)); + + /* register a new theme */ + o1 = make_overrides ("weechat.color.chat", "default", + "weechat.color.separator", "blue"); + t1 = theme_register ("dark", o1); + hashtable_free (o1); + CHECK(t1 != NULL); + STRCMP_EQUAL("dark", t1->name); + LONGS_EQUAL(2, t1->overrides->items_count); + STRCMP_EQUAL("default", (const char *)hashtable_get (t1->overrides, + "weechat.color.chat")); + STRCMP_EQUAL("blue", (const char *)hashtable_get (t1->overrides, + "weechat.color.separator")); + + /* second call with same name merges into the existing theme */ + o2 = make_overrides ("irc.color.input_nick", "lightcyan", + "weechat.color.separator", "darkgray"); + t2 = theme_register ("dark", o2); + hashtable_free (o2); + POINTERS_EQUAL(t1, t2); /* same struct, merged into */ + LONGS_EQUAL(3, t1->overrides->items_count); + /* new key added */ + STRCMP_EQUAL("lightcyan", (const char *)hashtable_get (t1->overrides, + "irc.color.input_nick")); + /* duplicate key overridden */ + STRCMP_EQUAL("darkgray", (const char *)hashtable_get (t1->overrides, + "weechat.color.separator")); + + /* registering with NULL overrides only creates the theme */ + t2 = theme_register ("empty", NULL); + CHECK(t2 != NULL); + LONGS_EQUAL(0, t2->overrides->items_count); +} + +/* + * Test functions: + * theme_list_cmp_cb + * theme_list + */ + +TEST(CoreTheme, List) +{ + struct t_arraylist *list; + + /* empty list when nothing registered */ + list = theme_list (); + CHECK(list != NULL); + LONGS_EQUAL(0, arraylist_size (list)); + arraylist_free (list); + + /* register three themes in non-alphabetical order */ + theme_register ("solarized", NULL); + theme_register ("dark", NULL); + theme_register ("nord", NULL); + + list = theme_list (); + CHECK(list != NULL); + LONGS_EQUAL(3, arraylist_size (list)); + + /* sorted by name */ + STRCMP_EQUAL("dark", + ((struct t_theme *)arraylist_get (list, 0))->name); + STRCMP_EQUAL("nord", + ((struct t_theme *)arraylist_get (list, 1))->name); + STRCMP_EQUAL("solarized", + ((struct t_theme *)arraylist_get (list, 2))->name); + + arraylist_free (list); +} + +/* + * Test functions: + * theme_init + */ + +TEST(CoreTheme, Init) +{ + /* register something so we can prove init wipes it */ + theme_register ("dark", NULL); + CHECK(themes != NULL); + + theme_init (); + POINTERS_EQUAL(NULL, themes); + POINTERS_EQUAL(NULL, last_theme); + LONGS_EQUAL(0, theme_applying); +} + +/* + * Test functions: + * theme_end + */ + +TEST(CoreTheme, End) +{ + theme_register ("dark", NULL); + theme_register ("light", NULL); + CHECK(themes != NULL); + + theme_end (); + POINTERS_EQUAL(NULL, themes); + POINTERS_EQUAL(NULL, last_theme); +} diff --git a/tests/unit/tests.cpp b/tests/unit/tests.cpp index 9f8c2408c..4732a8032 100644 --- a/tests/unit/tests.cpp +++ b/tests/unit/tests.cpp @@ -77,6 +77,7 @@ IMPORT_TEST_GROUP(CoreNetwork); IMPORT_TEST_GROUP(CoreSecure); IMPORT_TEST_GROUP(CoreSignal); IMPORT_TEST_GROUP(CoreString); +IMPORT_TEST_GROUP(CoreTheme); IMPORT_TEST_GROUP(CoreUrl); IMPORT_TEST_GROUP(CoreUtf8); IMPORT_TEST_GROUP(CoreUtil);