aboutsummaryrefslogtreecommitdiff
path: root/patches/ttym-full-2.0.diff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 19:02:35 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 19:02:35 +0200
commit8f99f1650e4a7363af1d83190b4082fb72a9f73f (patch)
tree86ed3b714c376e95f98d33d7572e76d6b43f06d3 /patches/ttym-full-2.0.diff
parent2f6b29abd0d766ef477eb29c5a7bf6c1e88e9630 (diff)
downloadttym-8f99f1650e4a7363af1d83190b4082fb72a9f73f.tar.gz
ttym-8f99f1650e4a7363af1d83190b4082fb72a9f73f.zip
ttym 3.0: fix terminal and config bugs, fold terminal-only patches into base
Correctness fixes: - restore the terminal on SIGQUIT and SIGHUP. atexit never runs on an uncaught fatal signal, so Ctrl+\ left the cursor hidden and autowrap disabled. - drop the unconditional newline in restore_tty. Every run emitted a stray blank line, including piped output. - treat EINTR as an interruption rather than a poll() failure, which delayed Ctrl+C by up to one 100ms frame. config-file patch: - make comment stripping quote-aware so a value may contain '#', and strip one matched pair of quotes. bar_fill = "#" was silently dropped, despite '#' being the glyph of the hash style this build ships. - resolve bar_fill/bar_empty inside resolve_bar and clear them on --bar, so command-line flags beat the config file as documented. Previously config glyphs silently overrode --bar. - report unknown keys, unparseable booleans and malformed lines on stderr. flash = enabled silently meant off. - never write to disk. --dump-config prints a commented template on stdout instead of the binary creating ~/.config/ttym/config on first run. Patch set: - fold persist-alert, flash and bar-styles into the base. The base is now terminal-only: it opens /dev/tty, draws and exits, with no file writes and no subprocesses. Everything still in patches/ changes what the program touches. - seven patches become four; config-file's prerequisites drop from six patches to one (notify), so it is regenerable mechanically. - regenerate every patch. All apply with zero fuzz and compile. Source: - rewrite argv in place rather than into a malloc'd copy (C99 5.1.2.2.1), removing die(), four free() calls and <stdarg.h>. - fold the duplicated elapsed-time expression into elapsed(). - drop dead code: have_any, (void)cfg, and an unreachable branch in parse_duration. - replace hand-counted padding with \033[K. Docs and build: - timer.1: -c is the third hook argument, not the fourth. Add EXIT STATUS (a stopwatch always exits 130 -- it has no natural end) and ENVIRONMENT. Document config precedence and quoting. - wire VERSION into a dist target; it was defined and never used. - track .gitignore, which previously ignored itself, so a fresh clone had no ignore rules. Claude-Session: https://claude.ai/code/session_01APLBs8RB1FcUaC4viVkzbP
Diffstat (limited to 'patches/ttym-full-2.0.diff')
-rw-r--r--patches/ttym-full-2.0.diff1057
1 files changed, 0 insertions, 1057 deletions
diff --git a/patches/ttym-full-2.0.diff b/patches/ttym-full-2.0.diff
deleted file mode 100644
index a344275..0000000
--- a/patches/ttym-full-2.0.diff
+++ /dev/null
@@ -1,1057 +0,0 @@
-diff -ruN a/config.def.h b/config.def.h
---- a/config.def.h 2026-05-20 14:08:08.221029371 +0200
-+++ b/config.def.h 2026-05-20 14:08:08.221029371 +0200
-@@ -1,5 +1,47 @@
- /* 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, 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, 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}";
-+
-+/* 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 = "";
-+
-+/* log location is ~/.config/ttym/ttym.log (honours $XDG_CONFIG_HOME).
-+ * One-shot migration: ~/.config/timer/ -> ~/.config/ttym/ if the new
-+ * directory doesn't already exist; legacy log file inside is renamed.
-+ */
-+
-+/* 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 14:08:08.221029371 +0200
-+++ b/ttym.c 2026-05-20 14:08:08.221029371 +0200
-@@ -5,13 +5,17 @@
- #include <ctype.h>
- #include <errno.h>
- #include <fcntl.h>
-+#include <locale.h>
- #include <poll.h>
- #include <signal.h>
- #include <stdarg.h>
- #include <stdio.h>
- #include <stdlib.h>
- #include <string.h>
-+#include <strings.h>
- #include <sys/ioctl.h>
-+#include <sys/stat.h>
-+#include <sys/wait.h>
- #include <termios.h>
- #include <time.h>
- #include <unistd.h>
-@@ -23,6 +27,16 @@
- typedef struct {
- int quiet; /* -q: suppress completion bell */
- int silent; /* -s: no bell, no extras */
-+ 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 */
-+ 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;
-
- static struct termios oldtios;
-@@ -80,6 +94,13 @@
- interrupted = 1;
- }
-
-+static int
-+parse_bool_str(const char *v)
-+{
-+ return !strcasecmp(v, "true") || !strcasecmp(v, "yes") ||
-+ !strcmp(v, "1") || !strcasecmp(v, "on");
-+}
-+
- static void
- restore_tty(void)
- {
-@@ -87,7 +108,7 @@
- tcsetattr(ttyfd, TCSANOW, &oldtios);
- rawset = 0;
- if (out_tty)
-- fputs("\033[?7h\033[?25h", stdout); /* re-enable wrap, show cursor */
-+ fputs("\033[?5l\033[?7h\033[?25h", stdout); /* clear reverse, re-enable wrap, show cursor */
- fputc('\n', stdout);
- fflush(stdout);
- }
-@@ -186,7 +207,8 @@
- /* ===== drawing ===== */
-
- static void
--draw_countdown(long total, long elapsed_ms, int paused, int barw)
-+draw_countdown(long total, long elapsed_ms, int paused, int barw,
-+ const char *fill_glyph, const char *empty_glyph, int minimal)
- {
- if (total <= 0) return;
- long total_ms = total * 1000L;
-@@ -207,8 +229,15 @@
- const char *status = paused ? "PAUSED " : " ";
-
- printf("\r%sT-%s\033[0m \033[2m+%s\033[0m [", color, rem, el);
-- for (int i = 0; i < fill; i++) fputs(BAR_FILL, stdout);
-- for (int i = 0; i < empty; i++) fputs(BAR_EMPTY, stdout);
-+ if (minimal) {
-+ fputs("\033[7m", stdout);
-+ for (int i = 0; i < fill; i++) fputc(' ', stdout);
-+ fputs("\033[27m", stdout);
-+ for (int i = 0; i < empty; i++) fputc(' ', stdout);
-+ } else {
-+ for (int i = 0; i < fill; i++) fputs(fill_glyph, stdout);
-+ for (int i = 0; i < empty; i++) fputs(empty_glyph, stdout);
-+ }
- printf("] %3ld%% %s", pct, status);
- fflush(stdout);
- }
-@@ -226,6 +255,740 @@
- fflush(stdout);
- }
-
-+/* ===== alert loop =====
-+ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap.
-+ */
-+
-+static int
-+is_foreground(void)
-+{
-+ if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) return 0;
-+ pid_t pgrp = tcgetpgrp(STDIN_FILENO);
-+ if (pgrp < 0) return 0;
-+ return pgrp == getpgrp();
-+}
-+
-+static void
-+alert_loop(int flash)
-+{
-+ fputs("\033[?25h\033[?7h", stdout);
-+ int ticks = 0;
-+ while (!interrupted) {
-+ fputc('\a', stdout);
-+ if (flash) fputs("\033[?5h", stdout);
-+ fflush(stdout);
-+ sleep_ms(150);
-+ if (flash) fputs("\033[?5l", stdout);
-+ fflush(stdout);
-+
-+ int acked = 0;
-+ for (int i = 0; i < 15 && !interrupted; i++) {
-+ if (ttyfd < 0) { sleep_ms(100); continue; }
-+ struct pollfd pfd = { .fd = ttyfd, .events = POLLIN };
-+ int pr = poll(&pfd, 1, 100);
-+ if (pr > 0 && (pfd.revents & POLLIN)) {
-+ char ch;
-+ ssize_t rd = read(ttyfd, &ch, 1);
-+ if (rd >= 0) { acked = 1; break; }
-+ } else if (pr < 0) {
-+ sleep_ms(100);
-+ }
-+ }
-+ if (acked) break;
-+ if (++ticks > 160) break;
-+ }
-+ fflush(stdout);
-+}
-+
-+/* ===== subprocess + placeholder expansion =====
-+ * Tokens split on whitespace; {title} and {msg} expanded within each token.
-+ * No shell involved — argv stays argv.
-+ */
-+
-+static int
-+build_argv(const char *cmd, const Config *c, char ***out)
-+{
-+ char buf[2048];
-+ snprintf(buf, sizeof(buf), "%s", cmd);
-+ char **argv = calloc(64, sizeof(char *));
-+ if (!argv) return -1;
-+ int argc = 0;
-+ char *p = buf;
-+ while (*p && argc < 63) {
-+ while (*p && isspace((unsigned char)*p)) p++;
-+ if (!*p) break;
-+ char *start = p;
-+ while (*p && !isspace((unsigned char)*p)) p++;
-+ if (*p) *p++ = '\0';
-+ char tok[2048]; size_t oi = 0;
-+ for (char *q = start; *q && oi < sizeof(tok) - 1; ) {
-+ const char *sub = NULL; size_t skip = 0;
-+ if (!strncmp(q, "{msg}", 5)) { sub = c->msg; skip = 5; }
-+ else if (!strncmp(q, "{title}", 7)) { sub = c->title; skip = 7; }
-+ if (sub) {
-+ size_t sl = strlen(sub);
-+ if (oi + sl >= sizeof(tok)) sl = sizeof(tok) - 1 - oi;
-+ memcpy(tok + oi, sub, sl);
-+ oi += sl; q += skip;
-+ } else { tok[oi++] = *q++; }
-+ }
-+ tok[oi] = '\0';
-+ argv[argc++] = strdup(tok);
-+ }
-+ argv[argc] = NULL;
-+ *out = argv;
-+ return argc;
-+}
-+
-+static void
-+free_argv(char **argv)
-+{
-+ if (!argv) return;
-+ for (int i = 0; argv[i]; i++) free(argv[i]);
-+ free(argv);
-+}
-+
-+static void
-+spawn_bg(char *const argv[])
-+{
-+ pid_t pid = fork();
-+ if (pid == 0) {
-+ setsid();
-+ int devnull = open("/dev/null", O_RDWR);
-+ if (devnull >= 0) {
-+ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2);
-+ if (devnull > 2) close(devnull);
-+ }
-+ execvp(argv[0], argv);
-+ _exit(127);
-+ }
-+ (void)pid;
-+}
-+
-+static int
-+command_exists(const char *name)
-+{
-+ const char *path = getenv("PATH");
-+ if (!path) return 0;
-+ char buf[512];
-+ const char *p = path;
-+ while (*p) {
-+ const char *e = strchr(p, ':');
-+ size_t len = e ? (size_t)(e - p) : strlen(p);
-+ if (len > 0 && len + strlen(name) + 2 < sizeof(buf)) {
-+ snprintf(buf, sizeof(buf), "%.*s/%s", (int)len, p, name);
-+ if (access(buf, X_OK) == 0) return 1;
-+ }
-+ if (!e) break;
-+ p = e + 1;
-+ }
-+ return 0;
-+}
-+
-+static const char *
-+detect_sound_player(void)
-+{
-+ static const char *cands[] = { "paplay", "aplay", "mpv", "ffplay", NULL };
-+ for (int i = 0; cands[i]; i++)
-+ if (command_exists(cands[i])) return cands[i];
-+ return NULL;
-+}
-+
-+static const char *
-+default_sound_path(void)
-+{
-+ static const char *paths[] = {
-+ "/usr/share/sounds/freedesktop/stereo/complete.oga",
-+ "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga",
-+ "/usr/share/sounds/alsa/Front_Center.wav",
-+ NULL
-+ };
-+ for (int i = 0; paths[i]; i++)
-+ if (access(paths[i], R_OK) == 0) return paths[i];
-+ return NULL;
-+}
-+
-+static void
-+notify_and_sound(const Config *c)
-+{
-+ if (c->notify_cmd[0] && command_exists("notify-send")) {
-+ char **a;
-+ if (build_argv(c->notify_cmd, c, &a) > 0) {
-+ spawn_bg(a);
-+ free_argv(a);
-+ }
-+ }
-+ if (!c->quiet) {
-+ if (c->sound_cmd[0]) {
-+ char **a;
-+ if (build_argv(c->sound_cmd, c, &a) > 0) {
-+ spawn_bg(a);
-+ free_argv(a);
-+ }
-+ } else {
-+ const char *pl = detect_sound_player();
-+ const char *sp = default_sound_path();
-+ if (pl && sp) {
-+ char *a[] = { (char *)pl, (char *)sp, NULL };
-+ spawn_bg(a);
-+ }
-+ }
-+ }
-+}
-+
-+/* ===== log (TSV: ts<TAB>seconds<TAB>comment) ===== */
-+
-+static int
-+build_config_path(char *buf, size_t bufsz, const char *suffix)
-+{
-+ const char *home = getenv("HOME");
-+ const char *xdg = getenv("XDG_CONFIG_HOME");
-+ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, suffix);
-+ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, suffix);
-+ else return -1;
-+ return 0;
-+}
-+
-+static void
-+migrate_legacy_dir(void)
-+{
-+ const char *home = getenv("HOME");
-+ const char *xdg = getenv("XDG_CONFIG_HOME");
-+ char base[512];
-+ if (xdg && *xdg) snprintf(base, sizeof(base), "%s", xdg);
-+ else if (home && *home) snprintf(base, sizeof(base), "%s/.config", home);
-+ else return;
-+ char old_dir[600], new_dir[600];
-+ snprintf(old_dir, sizeof(old_dir), "%s/timer", base);
-+ snprintf(new_dir, sizeof(new_dir), "%s/ttym", base);
-+ struct stat sb;
-+ if (stat(new_dir, &sb) == 0) return;
-+ if (stat(old_dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) return;
-+ if (rename(old_dir, new_dir) != 0) return;
-+ char old_log[700], new_log[700];
-+ snprintf(old_log, sizeof(old_log), "%s/timer.log", new_dir);
-+ snprintf(new_log, sizeof(new_log), "%s/ttym.log", new_dir);
-+ if (stat(old_log, &sb) == 0) rename(old_log, new_log);
-+}
-+
-+static int
-+mkdir_p(const char *path, mode_t mode)
-+{
-+ char buf[1024];
-+ snprintf(buf, sizeof(buf), "%s", path);
-+ for (char *p = buf + 1; *p; p++) {
-+ if (*p == '/') {
-+ *p = '\0';
-+ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1;
-+ *p = '/';
-+ }
-+ }
-+ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1;
-+ return 0;
-+}
-+
-+static void
-+iso_local(char *buf, size_t bufsz)
-+{
-+ time_t t = time(NULL);
-+ struct tm tm;
-+ localtime_r(&t, &tm);
-+ strftime(buf, bufsz, "%Y-%m-%d %H:%M:%S", &tm);
-+}
-+
-+static void
-+sanitize_field(char *s)
-+{
-+ for (; *s; s++)
-+ if (*s == '\t' || *s == '\n' || *s == '\r') *s = ' ';
-+}
-+
-+static int
-+is_old_format(const char *line)
-+{
-+ return strlen(line) >= 11 && line[10] == 'T';
-+}
-+
-+static void
-+migrate_line(const char *line, char *out, size_t outsz)
-+{
-+ if (strlen(line) < 19) { snprintf(out, outsz, "%s", line); return; }
-+ char date[11], tpart[9];
-+ memcpy(date, line, 10); date[10] = '\0';
-+ memcpy(tpart, line + 11, 8); tpart[8] = '\0';
-+ const char *tab = strchr(line, '\t');
-+ if (!tab) { snprintf(out, outsz, "%s %s\n", date, tpart); return; }
-+ snprintf(out, outsz, "%s %s%s", date, tpart, tab);
-+}
-+
-+static void
-+migrate_log_if_needed(const char *path)
-+{
-+ FILE *f = fopen(path, "r");
-+ if (!f) return;
-+ char probe[2048];
-+ int needs = 0;
-+ while (fgets(probe, sizeof(probe), f))
-+ if (is_old_format(probe)) { needs = 1; break; }
-+ if (!needs) { fclose(f); return; }
-+ rewind(f);
-+ char tmp[1024];
-+ snprintf(tmp, sizeof(tmp), "%s.tmp", path);
-+ FILE *out = fopen(tmp, "w");
-+ if (!out) { fclose(f); return; }
-+ char line[2048];
-+ while (fgets(line, sizeof(line), f)) {
-+ if (is_old_format(line)) {
-+ char conv[2048];
-+ migrate_line(line, conv, sizeof(conv));
-+ fputs(conv, out);
-+ } else fputs(line, out);
-+ }
-+ fclose(f);
-+ fclose(out);
-+ rename(tmp, path);
-+}
-+
-+static void
-+write_log(long duration_s, const char *comment)
-+{
-+ char path[512];
-+ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return;
-+ char dir[512];
-+ snprintf(dir, sizeof(dir), "%s", path);
-+ char *slash = strrchr(dir, '/');
-+ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); }
-+ migrate_log_if_needed(path);
-+ int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
-+ if (fd < 0) return;
-+ char ts[32]; iso_local(ts, sizeof(ts));
-+ char safe[512]; snprintf(safe, sizeof(safe), "%s", comment ? comment : "");
-+ sanitize_field(safe);
-+ char line[1024];
-+ int n = snprintf(line, sizeof(line), "%s\t%ld\t%s\n", ts, duration_s, safe);
-+ if (n > 0) { ssize_t w = write(fd, line, (size_t)n); (void)w; }
-+ close(fd);
-+}
-+
-+static int
-+read_log_lines(char ***out_lines, size_t *out_n)
-+{
-+ char path[512];
-+ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return -1;
-+ migrate_log_if_needed(path);
-+ FILE *f = fopen(path, "r");
-+ if (!f) { *out_lines = NULL; *out_n = 0; return 0; }
-+ size_t cap = 64, n = 0;
-+ char **lines = malloc(cap * sizeof(char *));
-+ if (!lines) { fclose(f); return -1; }
-+ char buf[2048];
-+ while (fgets(buf, sizeof(buf), f)) {
-+ size_t l = strlen(buf);
-+ if (l && buf[l-1] == '\n') buf[l-1] = '\0';
-+ if (!*buf) continue;
-+ if (n >= cap) {
-+ cap *= 2;
-+ char **nl = realloc(lines, cap * sizeof(char *));
-+ if (!nl) break;
-+ lines = nl;
-+ }
-+ lines[n++] = strdup(buf);
-+ }
-+ fclose(f);
-+ *out_lines = lines;
-+ *out_n = n;
-+ return 0;
-+}
-+
-+static void
-+free_lines(char **lines, size_t n)
-+{
-+ if (!lines) return;
-+ for (size_t i = 0; i < n; i++) free(lines[i]);
-+ free(lines);
-+}
-+
-+static int
-+split_log_line(char *line, char **ts, long *secs, char **comment)
-+{
-+ char *t1 = strchr(line, '\t'); if (!t1) return -1;
-+ *t1 = '\0';
-+ char *t2 = strchr(t1 + 1, '\t'); if (!t2) return -1;
-+ *t2 = '\0';
-+ char *end;
-+ long v = strtol(t1 + 1, &end, 10);
-+ if (end == t1 + 1) return -1;
-+ *ts = line; *secs = v; *comment = t2 + 1;
-+ return 0;
-+}
-+
-+static void
-+fmt_long(long s, char *buf, size_t bufsz)
-+{
-+ if (s < 0) s = 0;
-+ long d = s / 86400; s %= 86400;
-+ long h = s / 3600; s %= 3600;
-+ long m = s / 60;
-+ long sec = s % 60;
-+ snprintf(buf, bufsz, "%02ld %02ld:%02ld:%02ld", d, h, m, sec);
-+}
-+
-+static int
-+cmd_read(int argc, char **argv)
-+{
-+ int n_tail = 0, raw = 0;
-+ for (int i = 0; i < argc; i++) {
-+ if (!strcmp(argv[i], "-n") && i + 1 < argc) {
-+ n_tail = atoi(argv[++i]);
-+ if (n_tail < 0) n_tail = 0;
-+ } else if (!strcmp(argv[i], "--raw")) {
-+ raw = 1;
-+ } else {
-+ fprintf(stderr, "timer -r: unknown arg: %s\n", argv[i]);
-+ return 1;
-+ }
-+ }
-+ char **lines = NULL; size_t n = 0;
-+ if (read_log_lines(&lines, &n) < 0) return 1;
-+ if (n == 0) { free_lines(lines, n); return 0; }
-+ size_t start = 0;
-+ if (n_tail > 0 && (size_t)n_tail < n) start = n - n_tail;
-+ if (raw) {
-+ for (size_t i = start; i < n; i++) puts(lines[i]);
-+ } else {
-+ printf("%-19s %-11s %s\n", "TIMESTAMP", "DURATION", "COMMENT");
-+ for (size_t i = start; i < n; i++) {
-+ char *copy = strdup(lines[i]);
-+ char *ts, *cmt; long secs;
-+ if (split_log_line(copy, &ts, &secs, &cmt) == 0) {
-+ char dur[32];
-+ fmt_long(secs, dur, sizeof(dur));
-+ printf("%-19s %-11s %s\n", ts, dur, cmt);
-+ } else {
-+ puts(lines[i]);
-+ }
-+ free(copy);
-+ }
-+ }
-+ free_lines(lines, n);
-+ return 0;
-+}
-+
-+static int
-+parse_log_ts(const char *ts, struct tm *out)
-+{
-+ memset(out, 0, sizeof(*out));
-+ if (sscanf(ts, "%d-%d-%d %d:%d:%d",
-+ &out->tm_year, &out->tm_mon, &out->tm_mday,
-+ &out->tm_hour, &out->tm_min, &out->tm_sec) != 6) return -1;
-+ out->tm_year -= 1900;
-+ out->tm_mon -= 1;
-+ out->tm_isdst = -1;
-+ return 0;
-+}
-+
-+typedef struct TagAcc {
-+ char tag[128];
-+ long secs;
-+ long count;
-+ struct TagAcc *next;
-+} TagAcc;
-+
-+static void
-+tag_add(TagAcc **head, const char *tag, long secs)
-+{
-+ for (TagAcc *a = *head; a; a = a->next)
-+ if (!strcmp(a->tag, tag)) { a->secs += secs; a->count++; return; }
-+ TagAcc *n = calloc(1, sizeof(*n));
-+ if (!n) return;
-+ snprintf(n->tag, sizeof(n->tag), "%s", tag);
-+ n->secs = secs;
-+ n->count = 1;
-+ n->next = *head;
-+ *head = n;
-+}
-+
-+static void
-+extract_tags(const char *comment, long secs, TagAcc **head)
-+{
-+ const char *p = comment;
-+ int found = 0;
-+ while (*p) {
-+ int at_boundary = (p == comment) || isspace((unsigned char)*(p-1));
-+ if (*p == '+' && at_boundary) {
-+ const char *s = p + 1, *e = s;
-+ while (*e && !isspace((unsigned char)*e)) e++;
-+ if (e > s) {
-+ char buf[128];
-+ size_t len = (size_t)(e - s);
-+ if (len >= sizeof(buf)) len = sizeof(buf) - 1;
-+ memcpy(buf, s, len); buf[len] = '\0';
-+ tag_add(head, buf, secs);
-+ found = 1;
-+ }
-+ p = e;
-+ } else p++;
-+ }
-+ if (!found) tag_add(head, "(untagged)", secs);
-+}
-+
-+static int
-+cmd_stats(int argc, char **argv)
-+{
-+ const char *period = (argc > 0) ? argv[0] : "today";
-+ time_t now = time(NULL);
-+ struct tm now_tm; localtime_r(&now, &now_tm);
-+ time_t cutoff = 0;
-+ if (!strcmp(period, "all")) cutoff = 0;
-+ else if (!strcmp(period, "today")) {
-+ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0;
-+ cutoff = mktime(&t);
-+ } else if (!strcmp(period, "week")) {
-+ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0;
-+ int back = (t.tm_wday == 0) ? 6 : t.tm_wday - 1;
-+ t.tm_mday -= back;
-+ cutoff = mktime(&t);
-+ } else if (!strcmp(period, "month")) {
-+ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; t.tm_mday = 1;
-+ cutoff = mktime(&t);
-+ } else {
-+ fprintf(stderr, "stats: unknown period '%s' (today|week|month|all)\n", period);
-+ return 1;
-+ }
-+ char **lines = NULL; size_t n = 0;
-+ if (read_log_lines(&lines, &n) < 0) return 1;
-+ long total = 0, count = 0;
-+ TagAcc *tags = NULL;
-+ for (size_t i = 0; i < n; i++) {
-+ char *copy = strdup(lines[i]);
-+ char *ts, *cmt; long secs;
-+ if (split_log_line(copy, &ts, &secs, &cmt) != 0) { free(copy); continue; }
-+ struct tm tm;
-+ if (parse_log_ts(ts, &tm) != 0) { free(copy); continue; }
-+ time_t entry = mktime(&tm);
-+ if (cutoff && entry < cutoff) { free(copy); continue; }
-+ total += secs; count++;
-+ extract_tags(cmt, secs, &tags);
-+ free(copy);
-+ }
-+ free_lines(lines, n);
-+ char tot[32]; fmt_long(total, tot, sizeof(tot));
-+ printf("\033[1m== timer stats: %s ==\033[0m\n", period);
-+ printf("entries: %ld total: %s\n\n", count, tot);
-+ int tag_n = 0;
-+ for (TagAcc *a = tags; a; a = a->next) tag_n++;
-+ if (tag_n > 0) {
-+ TagAcc **arr = malloc(tag_n * sizeof(*arr));
-+ int i = 0;
-+ for (TagAcc *a = tags; a; a = a->next) arr[i++] = a;
-+ for (int x = 0; x < tag_n - 1; x++)
-+ for (int y = x + 1; y < tag_n; y++)
-+ if (arr[y]->secs > arr[x]->secs) {
-+ TagAcc *t = arr[x]; arr[x] = arr[y]; arr[y] = t;
-+ }
-+ printf("%-24s %-11s %5s\n", "TAG", "DURATION", "COUNT");
-+ for (int z = 0; z < tag_n; z++) {
-+ char d[32]; fmt_long(arr[z]->secs, d, sizeof(d));
-+ printf("%-24s %-11s %5ld\n", arr[z]->tag, d, arr[z]->count);
-+ }
-+ free(arr);
-+ }
-+ while (tags) { TagAcc *next = tags->next; free(tags); tags = next; }
-+ return 0;
-+}
-+
-+/* ===== hooks ===== */
-+
-+static int
-+build_hook_path(char *buf, size_t bufsz, const char *name)
-+{
-+ const char *home = getenv("HOME");
-+ const char *xdg = getenv("XDG_CONFIG_HOME");
-+ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/hooks/%s", xdg, name);
-+ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/hooks/%s", home, name);
-+ else return -1;
-+ return 0;
-+}
-+
-+static void
-+run_hook(const char *name, const char *mode, long duration_s, const char *comment)
-+{
-+ char path[512];
-+ if (build_hook_path(path, sizeof(path), name) < 0) return;
-+ if (access(path, X_OK) != 0) return;
-+ char dur[32];
-+ snprintf(dur, sizeof(dur), "%ld", duration_s);
-+ pid_t pid = fork();
-+ if (pid == 0) {
-+ int devnull = open("/dev/null", O_RDWR);
-+ if (devnull >= 0) {
-+ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2);
-+ if (devnull > 2) close(devnull);
-+ }
-+ char *argv[] = { path, (char *)mode, dur,
-+ (char *)(comment ? comment : ""), NULL };
-+ execvp(argv[0], argv);
-+ _exit(127);
-+ } else if (pid > 0) {
-+ waitpid(pid, NULL, 0);
-+ }
-+}
-+
-+/* ===== 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;
-+
-+static const BarStyle bar_styles[] = {
-+ { "unicode", "\xe2\x96\x88", "\xe2\x96\x91" }, /* U+2588 U+2591 */
-+ { "ascii", "=", "-" },
-+ { "hash", "#", "." },
-+ { "dots", "\xe2\x80\xa2", "\xc2\xb7" }, /* U+2022 U+00B7 */
-+ { "line", "\xe2\x94\x81", "\xe2\x94\x80" }, /* U+2501 U+2500 */
-+ { "block", "\xe2\x96\x93", "\xe2\x96\x91" }, /* U+2593 U+2591 */
-+ { "arrow", "\xe2\x96\xb6", "\xe2\x96\xb7" }, /* U+25B6 U+25B7 */
-+ { "minimal", "", "" },
-+ { NULL, NULL, NULL }
-+};
-+
-+static const BarStyle *
-+find_bar_style(const char *name)
-+{
-+ if (!name || !*name) return NULL;
-+ for (int i = 0; bar_styles[i].name; i++)
-+ if (!strcmp(bar_styles[i].name, name)) return &bar_styles[i];
-+ return NULL;
-+}
-+
-+static int
-+is_utf8(void)
-+{
-+ const char *l = setlocale(LC_CTYPE, "");
-+ if (!l) return 0;
-+ return strstr(l, "UTF-8") || strstr(l, "utf8") || strstr(l, "utf-8");
-+}
-+
-+static void
-+resolve_bar(const Config *c, const char **fill, const char **empty, int *minimal)
-+{
-+ *minimal = 0;
-+ const char *name = (c->bar_style[0]) ? c->bar_style : BAR_STYLE;
-+ const BarStyle *s = find_bar_style(name);
-+ if (!s) s = find_bar_style(is_utf8() ? "unicode" : "ascii");
-+ if (s && !strcmp(s->name, "minimal")) {
-+ *minimal = 1;
-+ *fill = " "; *empty = " ";
-+ return;
-+ }
-+ *fill = s ? s->fill : BAR_FILL;
-+ *empty = s ? s->empty : BAR_EMPTY;
-+}
-+
- /* ===== usage =====
- * Patches MUST update this in lockstep with new flags so -h always
- * reflects the build.
-@@ -236,17 +999,25 @@
- {
- fprintf(f,
- "usage:\n"
--" timer [FLAGS] DURATION countdown\n"
--" timer [FLAGS] stopwatch\n"
-+" timer [FLAGS] DURATION [MESSAGE...] countdown\n"
-+" timer [FLAGS] stopwatch\n"
-+" timer -r [-n N] [--raw] read log\n"
-+" timer --stats [today|week|month|all] stats (default: today)\n"
- "\n"
- "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n"
- "FLAGS:\n"
-+" -c TEXT comment for log (+tag tokens are extracted)\n"
- " -q no sound\n"
- " -s silent\n"
-+" --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n"
-+" --no-persist skip alert-until-keypress loop\n"
-+" --flash on|off terminal flash during alert loop\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"
-+"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n"
-+"config: ~/.config/ttym/config (auto-generated on first run)\n");
- }
-
- /* ===== run loop ===== */
-@@ -259,6 +1030,14 @@
- int barw = cols - 40;
- if (barw < 10) barw = 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)) {
- if (tcgetattr(ttyfd, &oldtios) == 0) {
-@@ -280,6 +1059,9 @@
- if (out_tty)
- fputs("\033[?25l\033[?7l", stdout);
-
-+ run_hook("on_start", stopwatch ? "stopwatch" : "countdown",
-+ stopwatch ? 0 : total, cfg->comment);
-+
- long start_ms = now_ms();
- long pause_accum = 0;
- long pause_start = 0;
-@@ -300,7 +1082,8 @@
- draw_stopwatch(elapsed_ms, paused);
- } else {
- if (out_tty)
-- draw_countdown(total, elapsed_ms, paused, barw);
-+ draw_countdown(total, elapsed_ms, paused, barw,
-+ fill_glyph, empty_glyph, minimal);
- if (!paused && elapsed_ms >= total * 1000L) break;
- }
-
-@@ -356,6 +1139,7 @@
- printf("\r\033[32mSTOP %s%*s\033[0m\n", el, 30, "");
- else
- printf("STOP %s\n", el);
-+ run_hook("on_quit", "stopwatch", elapsed_s, cfg->comment);
- } else if (user_quit) {
- char el[24];
- fmt_clock(elapsed_s, el, sizeof(el));
-@@ -363,6 +1147,7 @@
- printf("\r\033[33mQUIT at %s%*s\033[0m\n", el, 30, "");
- else
- printf("QUIT at %s\n", el);
-+ run_hook("on_quit", "countdown", elapsed_s, cfg->comment);
- } else {
- char tb[24];
- fmt_clock(total, tb, sizeof(tb));
-@@ -370,14 +1155,21 @@
- printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, "");
- else
- printf("DONE %s 100%%\n", tb);
-+ run_hook("on_done", "countdown", total, cfg->comment);
-+ if (!cfg->silent) notify_and_sound(cfg);
- if (!cfg->silent && !cfg->quiet && out_tty) {
- fputc('\a', stdout);
- fflush(stdout);
- sleep_ms(50);
- }
-+ if (!cfg->silent && cfg->alert_persist && is_foreground()) {
-+ interrupted = 0;
-+ alert_loop(cfg->flash);
-+ }
- }
-
- (void)cfg;
-+ write_log(elapsed_s, cfg->comment);
- return user_quit ? 130 : 0;
- }
-
-@@ -386,7 +1178,21 @@
- int
- main(int argc, char **argv)
- {
-+ migrate_legacy_dir();
-+
-+ if (argc >= 2 && !strcmp(argv[1], "-r"))
-+ return cmd_read(argc - 2, argv + 2);
-+ if (argc >= 2 && !strcmp(argv[1], "--stats"))
-+ return cmd_stats(argc - 2, argv + 2);
-+
- Config cfg = {0};
-+ cfg.alert_persist = ALERT_PERSIST;
-+ cfg.flash = FLASH;
-+ 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;
-@@ -399,15 +1205,32 @@
- new_argv[new_argc++] = argv[i];
- continue;
- }
-+ if (!past_dd && !strcmp(argv[i], "--no-persist")) {
-+ cfg.alert_persist = 0;
-+ continue;
-+ }
-+ if (!past_dd && !strncmp(argv[i], "--flash=", 8)) {
-+ cfg.flash = parse_bool_str(argv[i] + 8);
-+ continue;
-+ }
-+ if (!past_dd && !strcmp(argv[i], "--flash") && i + 1 < argc) {
-+ cfg.flash = parse_bool_str(argv[++i]);
-+ continue;
-+ }
-+ if (!past_dd && !strcmp(argv[i], "--bar") && i + 1 < argc) {
-+ snprintf(cfg.bar_style, sizeof(cfg.bar_style), "%s", argv[++i]);
-+ continue;
-+ }
- new_argv[new_argc++] = argv[i];
- }
- new_argv[new_argc] = NULL;
-
- int opt;
-- while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) {
-+ while ((opt = getopt(new_argc, new_argv, "qsc:h")) != -1) {
- switch (opt) {
- case 'q': cfg.quiet = 1; break;
- case 's': cfg.silent = 1; break;
-+ case 'c': snprintf(cfg.comment, sizeof(cfg.comment), "%s", optarg); break;
- case 'h': usage(stdout); free(new_argv); return 0;
- default: usage(stderr); free(new_argv); return 1;
- }
-@@ -424,6 +1247,16 @@
- free(new_argv);
- return 1;
- }
-+ if (rem_argc > 1) {
-+ size_t off = 0;
-+ cfg.msg[0] = '\0';
-+ for (int i = 1; i < rem_argc && off < sizeof(cfg.msg) - 2; i++) {
-+ int n = snprintf(cfg.msg + off, sizeof(cfg.msg) - off,
-+ "%s%s", (i > 1 ? " " : ""), rem_argv[i]);
-+ if (n < 0) break;
-+ off += (size_t)n;
-+ }
-+ }
- }
-
- int rc = run(total, stopwatch, &cfg);