diff -ruN a/config.def.h b/config.def.h --- a/config.def.h 2026-08-25 19:07:53.233903707 +0200 +++ b/config.def.h 2026-08-25 19:07:53.233903707 +0200 @@ -1,5 +1,10 @@ /* See LICENSE file for copyright and license details. */ +/* Defaults below are compile-time. Many can be overridden at runtime by + * /etc/ttym.conf and ~/.config/ttym/config; command-line flags override + * both. Run `timer --dump-config` for a commented template. See timer(1). + */ + /* progress bar glyphs (ASCII default) */ static const char *const BAR_FILL = ">"; static const char *const BAR_EMPTY = "-"; diff -ruN a/ttym.c b/ttym.c --- a/ttym.c 2026-08-25 19:07:53.233903707 +0200 +++ b/ttym.c 2026-08-25 19:07:53.233903707 +0200 @@ -25,13 +25,15 @@ typedef struct { int quiet; /* -q: suppress completion bell */ int silent; /* -s: no bell, no extras */ - const char *title; - const char *notify_cmd; - const char *sound_cmd; + char title[256]; /* runtime config may override */ + char notify_cmd[512]; /* runtime config may override */ + char sound_cmd[512]; /* runtime config may override */ char msg[1024]; int alert_persist; /* alert loop until keypress; --no-persist disables */ int flash; /* reverse-video flash during alert loop */ char bar_style[32]; /* --bar STYLE */ + char bar_fill[16]; /* runtime config: explicit glyph, beats bar_style */ + char bar_empty[16]; /* runtime config: explicit glyph, beats bar_style */ } Config; static struct termios oldtios; @@ -352,7 +354,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); @@ -360,7 +362,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); @@ -469,6 +471,178 @@ } *fill = s ? s->fill : BAR_FILL; *empty = s ? s->empty : BAR_EMPTY; + /* explicit glyphs from the config file beat the named style; --bar + * clears them so a flag always wins over the file. */ + if (c->bar_fill[0]) *fill = c->bar_fill; + if (c->bar_empty[0]) *empty = c->bar_empty; +} + +/* ===== runtime config file ===== + * key = value lines. '#' starts a comment only at the start of a line or + * after whitespace, and never inside quotes. Quote a value to protect + * surrounding space or a leading '#'; one matched pair is stripped. Unknown keys and + * unparseable values are reported on stderr rather than silently dropped. + * Precedence: command-line flags > ~/.config/ttym/config > /etc/ttym.conf + * > the compile-time defaults in config.h. + */ + +static int +build_cfg_path(char *buf, size_t bufsz, const char *name) +{ + const char *xdg = getenv("XDG_CONFIG_HOME"); + const char *home = getenv("HOME"); + if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, name); + else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, name); + else return -1; + return 0; +} + +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 +strip_comment(char *s) +{ + int q = 0; + for (char *p = s; *p; p++) { + if (q) { + if (*p == q) q = 0; + } else if (*p == '"' || *p == '\'') { + q = *p; + } else if (*p == '#' && (p == s || isspace((unsigned char)p[-1]))) { + *p = '\0'; + return; + } + } +} + +static void +unquote(char *s) +{ + size_t n = strlen(s); + if (n >= 2 && (s[0] == '"' || s[0] == '\'') && s[n - 1] == s[0]) { + memmove(s, s + 1, n - 2); + s[n - 2] = '\0'; + } +} + +static int +cfg_bool(const char *path, int ln, const char *k, const char *v, int cur) +{ + if (!strcasecmp(v, "true") || !strcasecmp(v, "yes") || + !strcasecmp(v, "on") || !strcmp(v, "1")) return 1; + if (!strcasecmp(v, "false") || !strcasecmp(v, "no") || + !strcasecmp(v, "off") || !strcmp(v, "0")) return 0; + fprintf(stderr, "%s:%d: %s: expected on or off, got: %s\n", path, ln, k, v); + return cur; +} + +static void +load_config_file(Config *c, const char *path) +{ + FILE *f = fopen(path, "r"); + if (!f) return; + char line[1024]; + int ln = 0; + while (fgets(line, sizeof(line), f)) { + ln++; + strip_comment(line); + char *eq = strchr(line, '='); + if (!eq) { + trim(line); + if (*line) + fprintf(stderr, "%s:%d: not key = value: %s\n", path, ln, line); + continue; + } + *eq = '\0'; + char *k = line, *v = eq + 1; + trim(k); trim(v); unquote(v); + if (!*k) { + fprintf(stderr, "%s:%d: missing key\n", path, ln); + 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, "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); + else if (!strcmp(k, "flash")) c->flash = cfg_bool(path, ln, k, v, c->flash); + else if (!strcmp(k, "alert_persist")) c->alert_persist = cfg_bool(path, ln, k, v, c->alert_persist); + else if (!strcmp(k, "bar_style")) { + if (find_bar_style(v)) + snprintf(c->bar_style, sizeof(c->bar_style), "%s", v); + else + fprintf(stderr, "%s:%d: unknown bar style: %s\n", path, ln, v); + } + else fprintf(stderr, "%s:%d: unknown key: %s\n", path, ln, k); + } + fclose(f); +} + +static void +load_config(Config *c) +{ + load_config_file(c, "/etc/ttym.conf"); + char path[512]; + if (build_cfg_path(path, sizeof(path), "config") == 0) + load_config_file(c, path); +} + +/* Written to stdout by --dump-config; ttym never creates this file itself. + * timer --dump-config > ~/.config/ttym/config + */ +static void +dump_config(FILE *f) +{ + fputs( +"# ttym configuration\n" +"#\n" +"# Read from /etc/ttym.conf first, then ~/.config/ttym/config\n" +"# (honours $XDG_CONFIG_HOME). Command-line flags override both; an absent\n" +"# or commented-out key keeps the compile-time default from config.h.\n" +"#\n" +"# Syntax: key = value. '#' begins a comment at the start of a line or after\n" +"# whitespace. Quote a value to keep surrounding spaces or a leading '#';\n" +"# one matched pair of quotes is stripped.\n" +"# ---------------------------------------------------------------------------\n" +"\n" +"# Progress-bar glyphs. Set BOTH or NEITHER -- mismatched display widths\n" +"# break bar alignment. Multi-byte UTF-8 is fine. These beat bar_style,\n" +"# but --bar on the command line beats them.\n" +"# bar_fill = > # a '#' glyph must be quoted: 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); } /* ===== usage ===== @@ -491,10 +665,12 @@ " --no-persist skip alert-until-keypress loop\n" " --flash on|off terminal flash during alert loop\n" " --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n" +" --dump-config print a commented config template on stdout and exit\n" " -- end of options\n" " -h help\n" "\n" -"controls: [space] pause/resume [q | Ctrl+C] quit\n"); +"controls: [space] pause/resume [q | Ctrl+C] quit\n" +"config: /etc/ttym.conf, then ~/.config/ttym/config (--dump-config)\n"); } /* ===== run loop ===== */ @@ -633,12 +809,13 @@ main(int argc, char **argv) { Config cfg = {0}; - 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"); cfg.alert_persist = ALERT_PERSIST; cfg.flash = FLASH; + load_config(&cfg); /* Pre-strip long-form flags before getopt. Stop at "--". */ int nargc = 0, past_dd = 0; @@ -659,8 +836,13 @@ } if (!past_dd && !strcmp(argv[i], "--bar") && i + 1 < argc) { snprintf(cfg.bar_style, sizeof(cfg.bar_style), "%s", argv[++i]); + cfg.bar_fill[0] = cfg.bar_empty[0] = '\0'; continue; } + if (!past_dd && !strcmp(argv[i], "--dump-config")) { + dump_config(stdout); + return 0; + } argv[nargc++] = argv[i]; } argv[nargc] = NULL;