From 767b1c594762ef803c6a0d91fe19e7eb192b3257 Mon Sep 17 00:00:00 2001 From: AnmiTaliDev Date: Fri, 31 Jul 2026 12:06:39 +0500 Subject: [PATCH] feat: Add color themes Introduce built-in 256-color dark/light themes plus a mono fallback that reproduces the original reverse-video/underline look. Resolve the active theme from --theme=NAME, DAF_THEME, a minimal key=value config file, or a meson-configured build default, in that order. Rework render.c to switch styles via reset+role instead of ad hoc SGR toggles, since backgrounds can't be un-set the way reverse/underline bits can. --- README.md | 15 +++++++- meson.build | 5 +++ meson_options.txt | 14 +++++++ src/config.c | 89 +++++++++++++++++++++++++++++++++++++++++++++ src/config.h | 21 +++++++++++ src/editor.c | 4 ++ src/editor.h | 2 + src/main.c | 50 ++++++++++++++++++++++--- src/meson.build | 2 + src/render.c | 73 +++++++++++++++++++++++++++---------- src/theme.c | 66 +++++++++++++++++++++++++++++++++ src/theme.h | 24 ++++++++++++ tests/meson.build | 5 ++- tests/test_config.c | 54 +++++++++++++++++++++++++++ tests/test_theme.c | 33 +++++++++++++++++ 15 files changed, 429 insertions(+), 28 deletions(-) create mode 100644 meson_options.txt create mode 100644 src/config.c create mode 100644 src/config.h create mode 100644 src/theme.c create mode 100644 src/theme.h create mode 100644 tests/test_config.c create mode 100644 tests/test_theme.c diff --git a/README.md b/README.md index 4e5334f..2c8d518 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,20 @@ Daf (Hebrew `דף` — "sheet/page") is a simple console text editor written in C. -## Planned Features +## Themes -Theming, and similar features are planned for future versions. +Three built-in 256-color themes: `dark` (default), `light`, and `mono` (the +original reverse-video/underline look, for terminals or tastes that don't +want real colors). Resolved in this order: + +1. `--theme=NAME` on the command line +2. the `DAF_THEME` environment variable +3. `theme=NAME` in the config file (`$XDG_CONFIG_HOME/daf/config`, or + `~/.config/daf/config`) — `#` starts a comment +4. the build-time default (`meson configure -Ddefault_theme=NAME`) + +The config file's own location can be changed at build time too, with +`meson configure -Dconfig_subpath=...`. ## Building diff --git a/meson.build b/meson.build index 4a86d81..3efd756 100644 --- a/meson.build +++ b/meson.build @@ -11,6 +11,11 @@ project( ) 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') + '"', + language: 'c', +) subdir('src') subdir('tests') diff --git a/meson_options.txt b/meson_options.txt new file mode 100644 index 0000000..afa26b3 --- /dev/null +++ b/meson_options.txt @@ -0,0 +1,14 @@ +option( + 'default_theme', + type: 'combo', + choices: ['dark', 'light', 'mono'], + value: 'dark', + description: 'Theme used when nothing else (flag, env var, config file) picks one', +) + +option( + 'config_subpath', + type: 'string', + value: 'daf/config', + description: 'Path to the user config file, relative to $XDG_CONFIG_HOME (or $HOME/.config)', +) diff --git a/src/config.c b/src/config.c new file mode 100644 index 0000000..104b8ad --- /dev/null +++ b/src/config.c @@ -0,0 +1,89 @@ +/* SPDX-FileCopyrightText: AnmiTaliDev + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "config.h" + +#include +#include +#include + +#ifndef DAF_CONFIG_SUBPATH +#define DAF_CONFIG_SUBPATH "daf/config" +#endif + +static void build_config_path(char *out, size_t out_size) +{ + const char *xdg = getenv("XDG_CONFIG_HOME"); + if (xdg != NULL && xdg[0] != '\0') { + snprintf(out, out_size, "%s/%s", xdg, DAF_CONFIG_SUBPATH); + return; + } + + const char *home = getenv("HOME"); + if (home != NULL && home[0] != '\0') { + snprintf(out, out_size, "%s/.config/%s", home, DAF_CONFIG_SUBPATH); + 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 void apply_line(daf_config_t *out, char *line) +{ + char *s = trim(line); + if (s[0] == '\0' || s[0] == '#') { + return; + } + + char *eq = strchr(s, '='); + if (eq == NULL) { + return; + } + *eq = '\0'; + char *key = trim(s); + char *value = trim(eq + 1); + + if (strcmp(key, "theme") == 0) { + snprintf(out->theme_name, sizeof(out->theme_name), "%s", value); + } +} + +void config_load(daf_config_t *out) +{ + char path[512]; + build_config_path(path, sizeof(path)); + if (path[0] == '\0') { + return; + } + + FILE *fp = fopen(path, "r"); + if (fp == NULL) { + return; + } + + char line[256]; + while (fgets(line, sizeof(line), fp) != NULL) { + apply_line(out, line); + } + + fclose(fp); +} + +const char *config_subpath(void) +{ + return DAF_CONFIG_SUBPATH; +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..b231e1a --- /dev/null +++ b/src/config.h @@ -0,0 +1,21 @@ +/* SPDX-FileCopyrightText: AnmiTaliDev + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#ifndef DAF_CONFIG_H +#define DAF_CONFIG_H + +typedef struct { + char theme_name[64]; /* empty if the config file didn't set one */ +} daf_config_t; + +/* Reads the user config file (key=value lines, '#' comments) if it exists. + * `out` must be zero-initialized by the caller; a missing file, or one that + * doesn't set a given key, simply leaves the matching field untouched. */ +void config_load(daf_config_t *out); + +/* Path to the config file, relative to $XDG_CONFIG_HOME (or $HOME/.config), + * baked in at build time via `meson configure -Dconfig_subpath=...`. */ +const char *config_subpath(void); + +#endif /* DAF_CONFIG_H */ diff --git a/src/editor.c b/src/editor.c index 598a15a..831c989 100644 --- a/src/editor.c +++ b/src/editor.c @@ -209,6 +209,10 @@ void editor_init(editor_t *ed, int screen_rows, int screen_cols) ed->screen_cols = screen_cols; ed->mode = MODE_EDIT; ed->filetype = "Plain Text"; + ed->theme = theme_find(theme_build_default_name()); + if (ed->theme == NULL) { + ed->theme = theme_find("dark"); + } editor_set_status(ed, "daf %s | Ctrl-S save Ctrl-Q quit Ctrl-F find Ctrl-G go to line", DAF_VERSION); } diff --git a/src/editor.h b/src/editor.h index bfceea6..8b95119 100644 --- a/src/editor.h +++ b/src/editor.h @@ -10,6 +10,7 @@ #include "buffer.h" #include "search.h" +#include "theme.h" typedef enum { MODE_EDIT, @@ -61,6 +62,7 @@ typedef struct { char status_msg[256]; const char *filetype; + const theme_t *theme; edit_record_t *undo_stack; size_t undo_count; diff --git a/src/main.c b/src/main.c index 7c84360..166b187 100644 --- a/src/main.c +++ b/src/main.c @@ -4,12 +4,15 @@ #include #include +#include #include +#include "config.h" #include "editor.h" #include "input.h" #include "render.h" #include "terminal.h" +#include "theme.h" static int text_rows_for(int terminal_rows) { @@ -17,13 +20,49 @@ static int text_rows_for(int terminal_rows) return text_rows < 1 ? 1 : text_rows; } +static const theme_t *resolve_theme(const char *cli_theme_name) +{ + daf_config_t config; + memset(&config, 0, sizeof(config)); + config_load(&config); + + const char *name = cli_theme_name; + if (name == NULL) { + name = getenv("DAF_THEME"); + } + if (name == NULL && config.theme_name[0] != '\0') { + name = config.theme_name; + } + if (name == NULL) { + name = theme_build_default_name(); + } + + const theme_t *theme = theme_find(name); + if (theme == NULL) { + fprintf(stderr, "daf: unknown theme \"%s\", falling back to \"dark\"\n", name); + theme = theme_find("dark"); + } + return theme; +} + int main(int argc, char **argv) { - if (argc > 2) { - fprintf(stderr, "usage: daf [file]\n"); - return EXIT_FAILURE; + const char *cli_theme_name = NULL; + const char *filename = NULL; + + for (int i = 1; i < argc; i++) { + if (strncmp(argv[i], "--theme=", 8) == 0) { + cli_theme_name = argv[i] + 8; + } else if (filename == NULL) { + filename = argv[i]; + } else { + fprintf(stderr, "usage: daf [--theme=name] [file]\n"); + return EXIT_FAILURE; + } } + const theme_t *theme = resolve_theme(cli_theme_name); + terminal_enable_raw_mode(); int rows; @@ -35,9 +74,10 @@ int main(int argc, char **argv) editor_t ed; editor_init(&ed, text_rows_for(rows), cols); + ed.theme = theme; - if (argc == 2) { - editor_open(&ed, argv[1]); + if (filename != NULL) { + editor_open(&ed, filename); } while (!ed.should_quit) { diff --git a/src/meson.build b/src/meson.build index 9ea56c4..9f46da4 100644 --- a/src/meson.build +++ b/src/meson.build @@ -3,6 +3,8 @@ daf_lib_sources = files( 'terminal.c', 'encoding.c', 'filetype.c', + 'theme.c', + 'config.c', 'buffer.c', 'search.c', 'render.c', diff --git a/src/render.c b/src/render.c index 881daa3..f5c5cab 100644 --- a/src/render.c +++ b/src/render.c @@ -88,6 +88,7 @@ static size_t active_search_query_len(const editor_t *ed) static void render_gutter(abuf_t *ab, const editor_t *ed, size_t file_row, bool is_current) { + const theme_t *theme = ed->theme; int width = editor_gutter_width(ed); char digits[32]; int digit_len = snprintf(digits, sizeof(digits), "%zu", file_row + 1); @@ -96,28 +97,38 @@ static void render_gutter(abuf_t *ab, const editor_t *ed, size_t file_row, bool } int pad = width - 1 - digit_len; + abuf_append_str(ab, theme->gutter); for (int i = 0; i < pad; i++) { abuf_append(ab, " ", 1); } if (is_current) { - abuf_append_str(ab, "\x1b[7m"); + abuf_append_str(ab, theme->reset); + abuf_append_str(ab, theme->gutter_current); } abuf_append(ab, digits, (size_t)digit_len); if (is_current) { - abuf_append_str(ab, "\x1b[27m"); + abuf_append_str(ab, theme->reset); + abuf_append_str(ab, theme->gutter); } abuf_append(ab, " ", 1); + abuf_append_str(ab, theme->reset); } static void render_empty_gutter(abuf_t *ab, const editor_t *ed) { + const theme_t *theme = ed->theme; int width = editor_gutter_width(ed); + abuf_append_str(ab, theme->gutter); for (int i = 0; i < width; i++) { abuf_append(ab, " ", 1); } + abuf_append_str(ab, theme->reset); + abuf_append_str(ab, theme->tilde); abuf_append_str(ab, "~"); + abuf_append_str(ab, theme->reset); + abuf_append_str(ab, theme->normal); } static bool byte_in_search_match(const editor_t *ed, size_t row, size_t byte_col) @@ -132,8 +143,37 @@ static bool byte_in_search_match(const editor_t *ed, size_t row, size_t byte_col return false; } +typedef enum { + ROW_STYLE_UNSET = -1, + ROW_STYLE_NORMAL, + ROW_STYLE_MATCH, + ROW_STYLE_SELECTION, +} row_style_t; + +static void set_row_style(abuf_t *ab, const theme_t *theme, row_style_t *current, row_style_t wanted) +{ + if (wanted == *current) { + return; + } + abuf_append_str(ab, theme->reset); + switch (wanted) { + case ROW_STYLE_SELECTION: + abuf_append_str(ab, theme->selection); + break; + case ROW_STYLE_MATCH: + abuf_append_str(ab, theme->search_match); + break; + case ROW_STYLE_NORMAL: + default: + abuf_append_str(ab, theme->normal); + break; + } + *current = wanted; +} + static void render_text_row(abuf_t *ab, const editor_t *ed, size_t file_row) { + const theme_t *theme = ed->theme; const line_t *line = &ed->buf.lines[file_row]; int text_cols = editor_text_cols(ed); size_t coloff = ed->coloff; @@ -157,21 +197,16 @@ static void render_text_row(abuf_t *ab, const editor_t *ed, size_t file_row) size_t i = 0; size_t rx = 0; int emitted = 0; - bool hl_active = false; - bool match_active = false; + row_style_t style = ROW_STYLE_UNSET; + set_row_style(ab, theme, &style, ROW_STYLE_NORMAL); while (i < line->len && emitted < text_cols) { unsigned char c = (unsigned char)line->chars[i]; bool should_hl = row_has_hl && i >= hl_start && i < hl_end; bool should_match = show_matches && byte_in_search_match(ed, file_row, i); - if (should_hl != hl_active) { - abuf_append_str(ab, should_hl ? "\x1b[7m" : "\x1b[27m"); - hl_active = should_hl; - } - if (should_match != match_active) { - abuf_append_str(ab, should_match ? "\x1b[4m" : "\x1b[24m"); - match_active = should_match; - } + row_style_t wanted = + should_hl ? ROW_STYLE_SELECTION : (should_match ? ROW_STYLE_MATCH : ROW_STYLE_NORMAL); + set_row_style(ab, theme, &style, wanted); if (c == '\t') { size_t width = DAF_TAB_STOP - (rx % DAF_TAB_STOP); @@ -197,17 +232,12 @@ static void render_text_row(abuf_t *ab, const editor_t *ed, size_t file_row) } } - if (hl_active) { - abuf_append_str(ab, "\x1b[27m"); - } - if (match_active) { - abuf_append_str(ab, "\x1b[24m"); - } + set_row_style(ab, theme, &style, ROW_STYLE_NORMAL); } static void render_status_bar(abuf_t *ab, const editor_t *ed) { - abuf_append_str(ab, "\x1b[7m"); + abuf_append_str(ab, ed->theme->status_bar); char left[256]; const char *name = ed->buf.filename != NULL ? ed->buf.filename : "[No Name]"; @@ -238,12 +268,13 @@ static void render_status_bar(abuf_t *ab, const editor_t *ed) abuf_append(ab, right, (size_t)right_len); } - abuf_append_str(ab, "\x1b[0m"); + abuf_append_str(ab, ed->theme->reset); abuf_append_str(ab, "\r\n"); } static void render_message_bar(abuf_t *ab, const editor_t *ed) { + abuf_append_str(ab, ed->theme->normal); abuf_append_str(ab, "\x1b[K"); if (ed->mode != MODE_EDIT) { abuf_append_str(ab, prompt_label(ed->mode)); @@ -266,6 +297,7 @@ static void render_message_bar(abuf_t *ab, const editor_t *ed) } abuf_append(ab, ed->status_msg, len); } + abuf_append_str(ab, ed->theme->reset); } void render_screen(editor_t *ed) @@ -285,6 +317,7 @@ void render_screen(editor_t *ed) render_empty_gutter(&ab, ed); } abuf_append_str(&ab, "\x1b[K"); + abuf_append_str(&ab, ed->theme->reset); abuf_append_str(&ab, "\r\n"); } diff --git a/src/theme.c b/src/theme.c new file mode 100644 index 0000000..b6da247 --- /dev/null +++ b/src/theme.c @@ -0,0 +1,66 @@ +/* SPDX-FileCopyrightText: AnmiTaliDev + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "theme.h" + +#include + +#ifndef DAF_DEFAULT_THEME +#define DAF_DEFAULT_THEME "dark" +#endif + +static const theme_t k_theme_dark = { + .name = "dark", + .normal = "\x1b[38;5;253;48;5;234m", + .gutter = "\x1b[38;5;245;48;5;236m", + .gutter_current = "\x1b[38;5;220;48;5;238m", + .selection = "\x1b[38;5;234;48;5;75m", + .search_match = "\x1b[38;5;234;48;5;221m", + .status_bar = "\x1b[38;5;234;48;5;250m", + .tilde = "\x1b[38;5;238;48;5;234m", + .reset = "\x1b[0m", +}; + +static const theme_t k_theme_light = { + .name = "light", + .normal = "\x1b[38;5;236;48;5;255m", + .gutter = "\x1b[38;5;249;48;5;253m", + .gutter_current = "\x1b[38;5;130;48;5;251m", + .selection = "\x1b[38;5;255;48;5;68m", + .search_match = "\x1b[38;5;236;48;5;222m", + .status_bar = "\x1b[38;5;255;48;5;60m", + .tilde = "\x1b[38;5;250;48;5;255m", + .reset = "\x1b[0m", +}; + +static const theme_t k_theme_mono = { + .name = "mono", + .normal = "", + .gutter = "", + .gutter_current = "\x1b[7m", + .selection = "\x1b[7m", + .search_match = "\x1b[4m", + .status_bar = "\x1b[7m", + .tilde = "", + .reset = "\x1b[0m", +}; + +const theme_t *theme_find(const char *name) +{ + if (strcmp(name, "dark") == 0) { + return &k_theme_dark; + } + if (strcmp(name, "light") == 0) { + return &k_theme_light; + } + if (strcmp(name, "mono") == 0) { + return &k_theme_mono; + } + return NULL; +} + +const char *theme_build_default_name(void) +{ + return DAF_DEFAULT_THEME; +} diff --git a/src/theme.h b/src/theme.h new file mode 100644 index 0000000..2c45538 --- /dev/null +++ b/src/theme.h @@ -0,0 +1,24 @@ +/* SPDX-FileCopyrightText: AnmiTaliDev + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#ifndef DAF_THEME_H +#define DAF_THEME_H + +typedef struct { + const char *name; + const char *normal; + const char *gutter; + const char *gutter_current; + const char *selection; + const char *search_match; + const char *status_bar; + const char *tilde; + const char *reset; +} theme_t; + +const theme_t *theme_find(const char *name); + +const char *theme_build_default_name(void); + +#endif /* DAF_THEME_H */ diff --git a/tests/meson.build b/tests/meson.build index 78e8ab2..9fbf216 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -1,4 +1,7 @@ -test_names = ['buffer', 'search', 'encoding', 'filetype', 'editor', 'undo', 'clipboard', 'replace', 'autoindent'] +test_names = [ + 'buffer', 'search', 'encoding', 'filetype', 'editor', 'undo', 'clipboard', + 'replace', 'autoindent', 'theme', 'config', +] foreach name : test_names test_exe = executable( diff --git a/tests/test_config.c b/tests/test_config.c new file mode 100644 index 0000000..86ebe00 --- /dev/null +++ b/tests/test_config.c @@ -0,0 +1,54 @@ +/* SPDX-FileCopyrightText: AnmiTaliDev + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include +#include +#include +#include +#include +#include + +#include "config.h" + +static void write_file(const char *path, const char *content) +{ + FILE *fp = fopen(path, "w"); + assert(fp != NULL); + fputs(content, fp); + fclose(fp); +} + +int main(void) +{ + char base[256]; + snprintf(base, sizeof(base), "/tmp/daf_config_test_%d", (int)getpid()); + mkdir(base, 0700); + + char full_path[600]; + snprintf(full_path, sizeof(full_path), "%s/%s", base, config_subpath()); + + char dir_path[600]; + strcpy(dir_path, full_path); + char *slash = strrchr(dir_path, '/'); + if (slash != NULL) { + *slash = '\0'; + mkdir(dir_path, 0700); + } + + write_file(full_path, "# a comment\n\n theme = light \nignored line\n"); + setenv("XDG_CONFIG_HOME", base, 1); + + daf_config_t config; + memset(&config, 0, sizeof(config)); + config_load(&config); + assert(strcmp(config.theme_name, "light") == 0); + + daf_config_t missing; + memset(&missing, 0, sizeof(missing)); + setenv("XDG_CONFIG_HOME", "/tmp/daf_config_test_does_not_exist", 1); + config_load(&missing); + assert(missing.theme_name[0] == '\0'); + + return 0; +} diff --git a/tests/test_theme.c b/tests/test_theme.c new file mode 100644 index 0000000..f46bb3f --- /dev/null +++ b/tests/test_theme.c @@ -0,0 +1,33 @@ +/* SPDX-FileCopyrightText: AnmiTaliDev + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include +#include + +#include "theme.h" + +int main(void) +{ + const theme_t *dark = theme_find("dark"); + assert(dark != NULL); + assert(strcmp(dark->name, "dark") == 0); + + const theme_t *light = theme_find("light"); + assert(light != NULL); + assert(strcmp(light->name, "light") == 0); + + const theme_t *mono = theme_find("mono"); + assert(mono != NULL); + assert(strcmp(mono->name, "mono") == 0); + assert(strcmp(mono->normal, "") == 0); + assert(strcmp(mono->gutter_current, "\x1b[7m") == 0); + assert(strcmp(mono->search_match, "\x1b[4m") == 0); + + assert(theme_find("does-not-exist") == NULL); + + const char *default_name = theme_build_default_name(); + assert(theme_find(default_name) != NULL); + + return 0; +}