diff -ruN a/config.def.h b/config.def.h --- a/config.def.h 2026-05-20 13:18:06.149571116 +0200 +++ b/config.def.h 2026-05-20 13:19:12.046956614 +0200 @@ -1,23 +1,29 @@ /* See LICENSE file for copyright and license details. */ -/* progress bar glyphs (ASCII default) */ +/* progress bar glyphs (ASCII default). + * Runtime config file ~/.config/ttym/config can override these via + * bar_fill / bar_empty keys. + */ static const char *const BAR_FILL = ">"; static const char *const BAR_EMPTY = "-"; /* persistent alert: keep ringing the bell until any key is pressed. * Foreground-only; backgrounded runs always fall through. - * Override per-run with --no-persist. + * Override per-run with --no-persist, or via the alert_persist key + * in the runtime config file (~/.config/ttym/config). */ static const int ALERT_PERSIST = 1; /* reverse-video flash during the alert loop. - * Override per-run with --flash on|off. + * Override per-run with --flash on|off, or via the flash key in the + * runtime config file (~/.config/ttym/config). */ static const int FLASH = 1; /* desktop notification on countdown completion. * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. * Set NULL or "" to disable. + * The runtime config file can override these via title / notify_cmd keys. */ static const char *const TITLE = "Timer"; static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}"; @@ -25,6 +31,7 @@ /* sound on countdown completion. If SOUND_CMD is "" the binary autodetects * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound. * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD. + * The runtime config file can override this via the sound_cmd key. */ static const char *const SOUND_CMD = ""; @@ -35,5 +42,6 @@ /* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii). * Available: unicode | ascii | hash | dots | line | block | arrow | minimal. + * The runtime config file can override this via the bar_style key. */ static const char *const BAR_STYLE = ""; diff -ruN a/ttym.c b/ttym.c --- a/ttym.c 2026-05-20 13:18:06.149571116 +0200 +++ b/ttym.c 2026-05-20 13:19:12.046956614 +0200 @@ -30,10 +30,12 @@ int alert_persist; /* alert loop until keypress; --no-persist disables */ int flash; /* reverse-video flash during alert loop */ char comment[512]; /* -c TEXT: logged with each entry */ + char bar_fill[16]; /* override (utf-8 ok); from runtime config */ + char bar_empty[16]; /* override */ char bar_style[32]; /* --bar STYLE */ - const char *title; - const char *notify_cmd; - const char *sound_cmd; + char title[256]; /* override; from runtime config */ + char notify_cmd[512]; /* override; from runtime config */ + char sound_cmd[512]; /* override; from runtime config */ char msg[1024]; } Config; @@ -409,7 +411,7 @@ static void notify_and_sound(const Config *c) { - if (c->notify_cmd && c->notify_cmd[0] && command_exists("notify-send")) { + if (c->notify_cmd[0] && command_exists("notify-send")) { char **a; if (build_argv(c->notify_cmd, c, &a) > 0) { spawn_bg(a); @@ -417,7 +419,7 @@ } } if (!c->quiet) { - if (c->sound_cmd && c->sound_cmd[0]) { + if (c->sound_cmd[0]) { char **a; if (build_argv(c->sound_cmd, c, &a) > 0) { spawn_bg(a); @@ -832,6 +834,112 @@ } } +/* ===== runtime config file (~/.config/ttym/config) ===== + * key = value lines; '#' comments. Unknown keys ignored. + */ + +static void +trim(char *s) +{ + char *p = s; + while (*p && isspace((unsigned char)*p)) p++; + if (p != s) memmove(s, p, strlen(p) + 1); + size_t n = strlen(s); + while (n > 0 && isspace((unsigned char)s[n-1])) s[--n] = '\0'; +} + +static void +ensure_default_config_file(const char *path) +{ + if (access(path, F_OK) == 0) return; + char dir[512]; + snprintf(dir, sizeof(dir), "%s", path); + char *slash = strrchr(dir, '/'); + if (slash) { *slash = '\0'; mkdir_p(dir, 0700); } + FILE *f = fopen(path, "w"); + if (!f) return; + fputs( +"# ttym configuration file\n" +"#\n" +"# Location : ~/.config/ttym/config (/etc/ttym.conf is read first, if present)\n" +"# Syntax : key = value -- one entry per line; '#' begins a comment.\n" +"# A commented-out or absent key keeps its built-in default.\n" +"# Command-line flags override anything set in this file.\n" +"#\n" +"# Every key below is recognised by this build. Uncomment and edit to taste.\n" +"# ---------------------------------------------------------------------------\n" +"\n" +"# Progress-bar glyphs. Set BOTH or NEITHER -- mismatched display widths\n" +"# break bar alignment. Multi-byte UTF-8 is fine. These override bar_style.\n" +"# bar_fill = >\n" +"# bar_empty = -\n" +"\n" +"# Named progress-bar style. One of:\n" +"# unicode ascii hash dots line block arrow minimal\n" +"# Leave unset to auto-detect (unicode on a UTF-8 locale, otherwise ascii).\n" +"# bar_style = unicode\n" +"\n" +"# Reverse-video flash during the completion alert. on | off\n" +"# flash = on\n" +"\n" +"# Persistent alert: keep ringing the bell until a key is pressed\n" +"# (foreground runs only). on | off\n" +"# alert_persist = on\n" +"\n" +"# Desktop-notification title.\n" +"# title = Timer\n" +"\n" +"# Desktop-notification command run when a countdown finishes. Tokens are\n" +"# split on whitespace; {title}/{msg} placeholders expand. Empty disables.\n" +"# notify_cmd = notify-send -u critical {title} {msg}\n" +"\n" +"# Sound played when a countdown finishes. Leave unset to auto-detect a\n" +"# player (paplay/aplay/mpv/ffplay) and play a default system sound.\n" +"# Tokens are split on whitespace; {title}/{msg} placeholders expand.\n" +"# sound_cmd = paplay /usr/share/sounds/freedesktop/stereo/complete.oga\n", + f); + fclose(f); + chmod(path, 0600); +} + +static void +load_config_file(Config *c, const char *path) +{ + FILE *f = fopen(path, "r"); + if (!f) return; + char line[1024]; + while (fgets(line, sizeof(line), f)) { + char *hash = strchr(line, '#'); + if (hash) *hash = '\0'; + char *eq = strchr(line, '='); + if (!eq) continue; + *eq = '\0'; + char *k = line, *v = eq + 1; + trim(k); trim(v); + if (!*k) continue; + if (!strcmp(k, "bar_fill")) snprintf(c->bar_fill, sizeof(c->bar_fill), "%s", v); + else if (!strcmp(k, "bar_empty")) snprintf(c->bar_empty, sizeof(c->bar_empty), "%s", v); + else if (!strcmp(k, "bar_style")) snprintf(c->bar_style, sizeof(c->bar_style), "%s", v); + else if (!strcmp(k, "flash")) c->flash = parse_bool_str(v); + else if (!strcmp(k, "alert_persist")) c->alert_persist = parse_bool_str(v); + else if (!strcmp(k, "title")) snprintf(c->title, sizeof(c->title), "%s", v); + else if (!strcmp(k, "notify_cmd")) snprintf(c->notify_cmd, sizeof(c->notify_cmd), "%s", v); + else if (!strcmp(k, "sound_cmd")) snprintf(c->sound_cmd, sizeof(c->sound_cmd), "%s", v); + } + fclose(f); +} + +static void +load_config(Config *c) +{ + load_config_file(c, "/etc/ttym.conf"); + char path[512]; + if (build_config_path(path, sizeof(path), "config") == 0) { + ensure_default_config_file(path); + load_config_file(c, path); + } +} + /* ===== bar styles ===== */ typedef struct { const char *name, *fill, *empty; } BarStyle; @@ -908,7 +1016,8 @@ " -h help\n" "\n" "controls: [space] pause/resume [q | Ctrl+C] quit\n" -"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n"); +"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n" +"config: ~/.config/ttym/config (auto-generated on first run)\n"); } /* ===== run loop ===== */ @@ -924,6 +1033,10 @@ const char *fill_glyph, *empty_glyph; int minimal = 0; resolve_bar(cfg, &fill_glyph, &empty_glyph, &minimal); + /* explicit per-glyph overrides from the runtime config file + * (bar_fill / bar_empty) win over the named --bar style. */ + if (cfg->bar_fill[0]) fill_glyph = cfg->bar_fill; + if (cfg->bar_empty[0]) empty_glyph = cfg->bar_empty; ttyfd = open("/dev/tty", O_RDONLY); if (ttyfd >= 0 && isatty(ttyfd)) { @@ -1075,10 +1188,11 @@ Config cfg = {0}; cfg.alert_persist = ALERT_PERSIST; cfg.flash = FLASH; - cfg.title = TITLE; - cfg.notify_cmd = NOTIFY_CMD; - cfg.sound_cmd = SOUND_CMD; + snprintf(cfg.title, sizeof(cfg.title), "%s", TITLE ? TITLE : ""); + snprintf(cfg.notify_cmd, sizeof(cfg.notify_cmd), "%s", NOTIFY_CMD ? NOTIFY_CMD : ""); + snprintf(cfg.sound_cmd, sizeof(cfg.sound_cmd), "%s", SOUND_CMD ? SOUND_CMD : ""); snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up"); + load_config(&cfg); /* Pre-strip long-form flags before getopt. Stop at "--". */ int new_argc = 0;