From 8cc70041dbb0abf17f03d2be9e07574cde57f0e0 Mon Sep 17 00:00:00 2001 From: AnmiTaliDev Date: Fri, 31 Jul 2026 13:59:42 +0500 Subject: [PATCH] feat: Add custom theme files Extend theme resolution so any name that isn't a built-in (dark/light/mono) is looked up as $XDG_CONFIG_HOME/daf/themes/ NAME.conf: key=value lines picking a base theme to inherit from and overriding individual roles as 256-color fg,bg pairs. --- README.md | 19 ++++++ meson.build | 1 + meson_options.txt | 7 +++ src/main.c | 2 +- src/theme.c | 153 +++++++++++++++++++++++++++++++++++++++++++++ src/theme.h | 4 ++ tests/test_theme.c | 88 +++++++++++++++++++++++++- 7 files changed, 272 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c8d518..4eae2de 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,25 @@ want real colors). Resolved in this order: The config file's own location can be changed at build time too, with `meson configure -Dconfig_subpath=...`. +### Custom themes + +Any `--theme=NAME` (or `DAF_THEME`, or config `theme=`) that isn't +`dark`/`light`/`mono` is looked up as a file at +`$XDG_CONFIG_HOME/daf/themes/NAME.conf` (or +`~/.config/daf/themes/NAME.conf` — directory changeable at build time via +`meson configure -Dtheme_dir_subpath=...`). It's `key=value` lines, each +value a 256-color palette index as `fg` or `fg,bg`: + +``` +# ~/.config/daf/themes/mytheme.conf +base=dark # start from a built-in theme; defaults to dark +selection=196,231 # override just the roles you want +``` + +Unset roles are inherited from `base`. Available roles: `normal`, `gutter`, +`gutter_current` (the current line's line-number digit), `selection`, +`search_match`, `status_bar`, `tilde`. + ## Building Built with [Meson](https://mesonbuild.com/): diff --git a/meson.build b/meson.build index 3efd756..9d0df4c 100644 --- a/meson.build +++ b/meson.build @@ -14,6 +14,7 @@ add_project_arguments('-D_POSIX_C_SOURCE=200809L', language: 'c') add_project_arguments( '-DDAF_DEFAULT_THEME="' + get_option('default_theme') + '"', '-DDAF_CONFIG_SUBPATH="' + get_option('config_subpath') + '"', + '-DDAF_THEME_DIR_SUBPATH="' + get_option('theme_dir_subpath') + '"', language: 'c', ) diff --git a/meson_options.txt b/meson_options.txt index afa26b3..da7292a 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -12,3 +12,10 @@ option( value: 'daf/config', description: 'Path to the user config file, relative to $XDG_CONFIG_HOME (or $HOME/.config)', ) + +option( + 'theme_dir_subpath', + type: 'string', + value: 'daf/themes', + description: 'Directory of custom theme files (.conf), relative to $XDG_CONFIG_HOME (or $HOME/.config)', +) diff --git a/src/main.c b/src/main.c index 166b187..a3a6363 100644 --- a/src/main.c +++ b/src/main.c @@ -37,7 +37,7 @@ static const theme_t *resolve_theme(const char *cli_theme_name) name = theme_build_default_name(); } - const theme_t *theme = theme_find(name); + const theme_t *theme = theme_load(name); if (theme == NULL) { fprintf(stderr, "daf: unknown theme \"%s\", falling back to \"dark\"\n", name); theme = theme_find("dark"); diff --git a/src/theme.c b/src/theme.c index b6da247..f29f2fc 100644 --- a/src/theme.c +++ b/src/theme.c @@ -4,12 +4,20 @@ #include "theme.h" +#include +#include #include +#include "common.h" + #ifndef DAF_DEFAULT_THEME #define DAF_DEFAULT_THEME "dark" #endif +#ifndef DAF_THEME_DIR_SUBPATH +#define DAF_THEME_DIR_SUBPATH "daf/themes" +#endif + static const theme_t k_theme_dark = { .name = "dark", .normal = "\x1b[38;5;253;48;5;234m", @@ -64,3 +72,148 @@ const char *theme_build_default_name(void) { return DAF_DEFAULT_THEME; } + +const char *theme_dir_subpath(void) +{ + return DAF_THEME_DIR_SUBPATH; +} + +static void build_theme_path(char *out, size_t out_size, const char *name) +{ + const char *xdg = getenv("XDG_CONFIG_HOME"); + if (xdg != NULL && xdg[0] != '\0') { + snprintf(out, out_size, "%s/%s/%s.conf", xdg, DAF_THEME_DIR_SUBPATH, name); + return; + } + + const char *home = getenv("HOME"); + if (home != NULL && home[0] != '\0') { + snprintf(out, out_size, "%s/.config/%s/%s.conf", home, DAF_THEME_DIR_SUBPATH, name); + return; + } + + out[0] = '\0'; +} + +static char *trim(char *s) +{ + while (*s == ' ' || *s == '\t') { + s++; + } + size_t len = strlen(s); + while (len > 0 && + (s[len - 1] == ' ' || s[len - 1] == '\t' || s[len - 1] == '\n' || s[len - 1] == '\r')) { + s[--len] = '\0'; + } + return s; +} + +static char *build_sgr(const char *spec) +{ + int fg = -1; + int bg = -1; + sscanf(spec, "%d,%d", &fg, &bg); + if (fg < 0) { + return NULL; + } + + char buf[64]; + if (bg >= 0) { + snprintf(buf, sizeof(buf), "\x1b[38;5;%d;48;5;%dm", fg, bg); + } else { + snprintf(buf, sizeof(buf), "\x1b[38;5;%dm", fg); + } + return xstrdup(buf); +} + +static void apply_role(theme_t *custom, const char *key, char *sgr) +{ + if (strcmp(key, "normal") == 0) { + custom->normal = sgr; + } else if (strcmp(key, "gutter") == 0) { + custom->gutter = sgr; + } else if (strcmp(key, "gutter_current") == 0) { + custom->gutter_current = sgr; + } else if (strcmp(key, "selection") == 0) { + custom->selection = sgr; + } else if (strcmp(key, "search_match") == 0) { + custom->search_match = sgr; + } else if (strcmp(key, "status_bar") == 0) { + custom->status_bar = sgr; + } else if (strcmp(key, "tilde") == 0) { + custom->tilde = sgr; + } else { + free(sgr); + } +} + +const theme_t *theme_load(const char *name) +{ + const theme_t *builtin = theme_find(name); + if (builtin != NULL) { + return builtin; + } + + char path[512]; + build_theme_path(path, sizeof(path), name); + if (path[0] == '\0') { + return NULL; + } + + FILE *fp = fopen(path, "r"); + if (fp == NULL) { + return NULL; + } + + const theme_t *base = &k_theme_dark; + char line[256]; + while (fgets(line, sizeof(line), fp) != NULL) { + char *s = trim(line); + if (s[0] == '\0' || s[0] == '#') { + continue; + } + char *eq = strchr(s, '='); + if (eq == NULL) { + continue; + } + *eq = '\0'; + char *key = trim(s); + char *value = trim(eq + 1); + if (strcmp(key, "base") == 0) { + const theme_t *found = theme_find(value); + if (found != NULL) { + base = found; + } + } + } + + theme_t *custom = xmalloc(sizeof(theme_t)); + *custom = *base; + custom->name = xstrdup(name); + + rewind(fp); + while (fgets(line, sizeof(line), fp) != NULL) { + char *s = trim(line); + if (s[0] == '\0' || s[0] == '#') { + continue; + } + char *eq = strchr(s, '='); + if (eq == NULL) { + continue; + } + *eq = '\0'; + char *key = trim(s); + char *value = trim(eq + 1); + if (strcmp(key, "base") == 0) { + continue; + } + + char *sgr = build_sgr(value); + if (sgr != NULL) { + apply_role(custom, key, sgr); + } + } + + fclose(fp); + return custom; +} diff --git a/src/theme.h b/src/theme.h index 2c45538..fce36a2 100644 --- a/src/theme.h +++ b/src/theme.h @@ -19,6 +19,10 @@ typedef struct { const theme_t *theme_find(const char *name); +const theme_t *theme_load(const char *name); + const char *theme_build_default_name(void); +const char *theme_dir_subpath(void); + #endif /* DAF_THEME_H */ diff --git a/tests/test_theme.c b/tests/test_theme.c index f46bb3f..23d74aa 100644 --- a/tests/test_theme.c +++ b/tests/test_theme.c @@ -3,11 +3,23 @@ */ #include +#include +#include #include +#include +#include #include "theme.h" -int main(void) +static void write_file(const char *path, const char *content) +{ + FILE *fp = fopen(path, "w"); + assert(fp != NULL); + fputs(content, fp); + fclose(fp); +} + +static void test_builtin_themes(void) { const theme_t *dark = theme_find("dark"); assert(dark != NULL); @@ -28,6 +40,80 @@ int main(void) const char *default_name = theme_build_default_name(); assert(theme_find(default_name) != NULL); +} + +static void test_load_prefers_builtin(void) +{ + assert(theme_load("dark") == theme_find("dark")); +} + +static void test_load_missing_returns_null(void) +{ + setenv("XDG_CONFIG_HOME", "/tmp/daf_theme_test_does_not_exist", 1); + assert(theme_load("no-such-custom-theme") == NULL); +} + +static const char *setup_theme_dir(void) +{ + static char base[256]; + snprintf(base, sizeof(base), "/tmp/daf_theme_test_%d", (int)getpid()); + mkdir(base, 0700); + + char subpath_copy[256]; + snprintf(subpath_copy, sizeof(subpath_copy), "%s", theme_dir_subpath()); + + char path[600]; + snprintf(path, sizeof(path), "%s", base); + char *segment = strtok(subpath_copy, "/"); + while (segment != NULL) { + size_t len = strlen(path); + snprintf(path + len, sizeof(path) - len, "/%s", segment); + mkdir(path, 0700); + segment = strtok(NULL, "/"); + } + + setenv("XDG_CONFIG_HOME", base, 1); + return base; +} + +static void test_load_custom_theme_with_base(void) +{ + const char *base = setup_theme_dir(); + char path[700]; + snprintf(path, sizeof(path), "%s/%s/mytheme.conf", base, theme_dir_subpath()); + write_file(path, "# custom theme\nbase=light\nselection=196,231\n"); + + const theme_t *custom = theme_load("mytheme"); + assert(custom != NULL); + assert(strcmp(custom->name, "mytheme") == 0); + assert(strcmp(custom->selection, "\x1b[38;5;196;48;5;231m") == 0); + /* Roles not overridden inherit from the requested base (light). */ + const theme_t *light = theme_find("light"); + assert(strcmp(custom->normal, light->normal) == 0); + assert(strcmp(custom->status_bar, light->status_bar) == 0); +} + +static void test_load_custom_theme_defaults_to_dark_base(void) +{ + const char *base = setup_theme_dir(); + char path[700]; + snprintf(path, sizeof(path), "%s/%s/nobase.conf", base, theme_dir_subpath()); + write_file(path, "tilde=250\n"); + + const theme_t *custom = theme_load("nobase"); + assert(custom != NULL); + assert(strcmp(custom->tilde, "\x1b[38;5;250m") == 0); + const theme_t *dark = theme_find("dark"); + assert(strcmp(custom->normal, dark->normal) == 0); +} + +int main(void) +{ + test_builtin_themes(); + test_load_prefers_builtin(); + test_load_missing_returns_null(); + test_load_custom_theme_with_base(); + test_load_custom_theme_defaults_to_dark_base(); return 0; }