From 8f99f1650e4a7363af1d83190b4082fb72a9f73f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 25 Aug 2026 19:02:35 +0200 Subject: 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 . - 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 --- .gitignore | 9 + Makefile | 12 +- README.md | 19 +- config.def.h | 16 + config.mk | 2 +- patches/README | 109 ++-- patches/ttym-bar-styles-2.0.diff | 155 ----- patches/ttym-config-file-2.0.diff | 237 -------- patches/ttym-config-file-3.0.diff | 293 ++++++++++ patches/ttym-flash-2.0.diff | 113 ---- patches/ttym-full-2.0.diff | 1057 ----------------------------------- patches/ttym-full-3.0.diff | 961 +++++++++++++++++++++++++++++++ patches/ttym-hooks-2.0.diff | 127 ----- patches/ttym-hooks-3.0.diff | 130 +++++ patches/ttym-logging-2.0.diff | 447 --------------- patches/ttym-logging-3.0.diff | 466 +++++++++++++++ patches/ttym-notify-2.0.diff | 232 -------- patches/ttym-notify-3.0.diff | 259 +++++++++ patches/ttym-persist-alert-2.0.diff | 111 ---- timer.1 | 153 +++-- ttym.c | 253 ++++++--- 21 files changed, 2511 insertions(+), 2650 deletions(-) create mode 100644 .gitignore delete mode 100644 patches/ttym-bar-styles-2.0.diff delete mode 100644 patches/ttym-config-file-2.0.diff create mode 100644 patches/ttym-config-file-3.0.diff delete mode 100644 patches/ttym-flash-2.0.diff delete mode 100644 patches/ttym-full-2.0.diff create mode 100644 patches/ttym-full-3.0.diff delete mode 100644 patches/ttym-hooks-2.0.diff create mode 100644 patches/ttym-hooks-3.0.diff delete mode 100644 patches/ttym-logging-2.0.diff create mode 100644 patches/ttym-logging-3.0.diff delete mode 100644 patches/ttym-notify-2.0.diff create mode 100644 patches/ttym-notify-3.0.diff delete mode 100644 patches/ttym-persist-alert-2.0.diff diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81b9c00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# build artifacts -- regenerate with `make` +config.h +*.o +timer +ttym-*.tar.gz + +# local Claude Code settings +CLAUDE.md +.claude/settings.local.json diff --git a/Makefile b/Makefile index fd103de..72b5053 100644 --- a/Makefile +++ b/Makefile @@ -23,11 +23,19 @@ $(BIN): $(OBJ) $(CC) -o $@ $(OBJ) $(LDFLAGS) clean: - rm -f $(BIN) $(OBJ) + rm -f $(BIN) $(OBJ) ttym-$(VERSION).tar.gz distclean: clean rm -f config.h +dist: clean + mkdir -p ttym-$(VERSION) + cp -R LICENSE Makefile README.md config.def.h config.mk timer.1 ttym.c \ + patches ttym-$(VERSION) + tar -cf ttym-$(VERSION).tar ttym-$(VERSION) + gzip ttym-$(VERSION).tar + rm -rf ttym-$(VERSION) + install: all mkdir -p $(DESTDIR)$(PREFIX)/bin cp -f $(BIN) $(DESTDIR)$(PREFIX)/bin/$(BIN) @@ -40,4 +48,4 @@ uninstall: rm -f $(DESTDIR)$(PREFIX)/bin/$(BIN) rm -f $(DESTDIR)$(MANPREFIX)/man1/timer.1 -.PHONY: all clean distclean install uninstall +.PHONY: all clean distclean dist install uninstall diff --git a/README.md b/README.md index a8ebce5..5c0e3ea 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,21 @@ ttym - terminal countdown / stopwatch ===================================== A small countdown timer and stopwatch in C. Single binary, libc-only, -no daemon. Default build is intentionally minimal: +no daemon. The base build is terminal-only -- it writes no files and +spawns no processes: - T-24:13 +00:47 [>>>>>>>>>>------------------------------] 25% + T-24:13 +00:47 [████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 25% - countdown when given a duration; stopwatch when not - pause/resume with [space], quit with [q] or Ctrl+C - - ASCII progress bar - - one-shot terminal bell on completion + - progress bar with eight styles (`--bar`, auto-detected by locale) + - alert until keypress on completion (`--no-persist` to disable), + with an optional reverse-video flash (`--flash on|off`) -Anything beyond that is an optional patch under patches/. See -patches/README for the available set and apply order. +Every feature that touches the filesystem, runs a subprocess or needs +an external binary is an optional patch under patches/ -- desktop +notifications, session logging, hooks and a runtime config file. See +patches/README for the set and apply order. Requirements @@ -54,6 +58,9 @@ Usage FLAGS: -q no sound -s silent + --no-persist skip alert-until-keypress loop + --flash on|off terminal flash during alert loop + --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal -- end of options -h help diff --git a/config.def.h b/config.def.h index af19705..2038fc9 100644 --- a/config.def.h +++ b/config.def.h @@ -3,3 +3,19 @@ /* progress bar glyphs (ASCII default) */ 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. + */ +static const int ALERT_PERSIST = 1; + +/* reverse-video flash during the alert loop. + * Override per-run with --flash on|off. + */ +static const int FLASH = 1; + +/* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii). + * Available: unicode | ascii | hash | dots | line | block | arrow | minimal. + */ +static const char *const BAR_STYLE = ""; diff --git a/config.mk b/config.mk index f07e004..53a270d 100644 --- a/config.mk +++ b/config.mk @@ -1,5 +1,5 @@ # ttym version -VERSION = 2.0 +VERSION = 3.0 # paths PREFIX = /usr/local diff --git a/patches/README b/patches/README index cd4e12a..51595b9 100644 --- a/patches/README +++ b/patches/README @@ -4,31 +4,48 @@ ttym patches Each patch is a unified diff against the pristine base. Apply with: cd ttym - patch -p1 < patches/ttym--2.0.diff + patch -p1 < patches/ttym--3.0.diff Then rebuild: rm -f config.h ; make To get every feature at once, skip the individual patches entirely and -apply ttym-full-2.0.diff -- one combined diff, no conflicts to resolve. +apply ttym-full-3.0.diff -- one combined diff, no conflicts to resolve. See "The full build" below. +What is in the base +------------------- + +The base build is terminal-only: it opens /dev/tty, draws, and exits. +It does not write files, spawn processes or depend on anything outside +libc. That includes the progress-bar styles, the alert loop and the +terminal flash, which were separate patches through 2.0: + + - countdown and stopwatch, pause/resume, quit + - progress bar with eight built-in styles (--bar, BAR_STYLE) + - alert-until-keypress on completion (--no-persist, ALERT_PERSIST) + - reverse-video flash during that alert (--flash, FLASH) + +Every remaining patch changes what the program *touches* -- the +filesystem, subprocesses, or external binaries. That is the line the +split is drawn on. + + The full build -------------- - ttym-full-2.0.diff - Every feature in a single patch: persist-alert, flash, notify, - logging, hooks, bar-styles and config-file, with all inter-patch - conflicts already resolved. Apply it to the pristine base and - rebuild: + ttym-full-3.0.diff + Every feature in a single patch: notify, logging, hooks and + config-file, with all inter-patch conflicts already resolved. + Apply it to the pristine base and rebuild: - patch -p1 < patches/ttym-full-2.0.diff + patch -p1 < patches/ttym-full-3.0.diff rm -f config.h ; make This is the recommended route to the full-featured build. It is - equivalent to stacking all seven feature patches below and hand- + equivalent to stacking the four feature patches below and hand- resolving every collision, but deterministic and conflict-free. Use the individual patches only when you want a specific subset. @@ -36,86 +53,68 @@ The full build Available patches ----------------- - ttym-persist-alert-2.0.diff - After a countdown completes, keep ringing the bell every ~1.5s - until any key is pressed. Foreground-only; backgrounded runs - fall through. Adds: --no-persist flag, ALERT_PERSIST knob. - - ttym-flash-2.0.diff [requires persist-alert] - Add reverse-video terminal flash to the persistent alert loop. - Adds: --flash on|off flag, FLASH knob. - - ttym-notify-2.0.diff + ttym-notify-3.0.diff One-shot desktop notification and sound on countdown completion, plus a MESSAGE positional argument piped through {title}/{msg} - placeholders. Adds: TITLE, NOTIFY_CMD, SOUND_CMD knobs. + placeholders. Spawns notify-send and a sound player. + Adds: TITLE, NOTIFY_CMD, SOUND_CMD knobs. - ttym-logging-2.0.diff + ttym-logging-3.0.diff TSV log at ~/.config/ttym/ttym.log, +tag extraction, the -r reader (-n, --raw), and --stats (today|week|month|all). Adds one-shot directory migration from ~/.config/timer/. Adds: -c TEXT flag. - ttym-hooks-2.0.diff + ttym-hooks-3.0.diff Optional executables at ~/.config/ttym/hooks/{on_start,on_done, on_quit} invoked with: mode duration_seconds comment. Adds: -c TEXT flag (passed as 3rd hook arg). - ttym-bar-styles-2.0.diff - Eight built-in bar styles (unicode, ascii, hash, dots, line, - block, arrow, minimal) with locale-aware auto-detect. - Adds: --bar STYLE flag, BAR_STYLE knob. - - ttym-config-file-2.0.diff [capstone -- see Dependencies] - Runtime config at ~/.config/ttym/config (key = value), auto- - generated and self-documenting on first run. Exposes eight keys: + ttym-config-file-3.0.diff [requires notify] + Runtime config read from /etc/ttym.conf, then + ~/.config/ttym/config (key = value). Exposes eight keys: bar_fill, bar_empty, bar_style, flash, alert_persist, title, - notify_cmd, sound_cmd. A system-wide /etc/ttym.conf is read - first. Because it wires up knobs owned by other patches, it must - be applied LAST. + notify_cmd, sound_cmd. Unknown keys and unparseable values are + reported on stderr. Nothing is written to disk: --dump-config + prints a commented template on stdout, so a user does + timer --dump-config > ~/.config/ttym/config -Dependencies ------------- + It needs notify only for the title / notify_cmd / sound_cmd + keys; the bar and alert keys it exposes now live in the base. -flash depends on persist-alert. -config-file is the capstone: it reads knobs introduced by persist- -alert, flash, notify, logging and bar-styles, and extends a usage() -line added by hooks -- so all six must be applied before it. Apply -config-file last. +Dependencies +------------ -All other patches are independent of each other and of the base. +config-file requires notify. Everything else is independent of the +base and of each other. Stacking -------- -The simplest route to the full build is ttym-full-2.0.diff (see "The +The simplest route to the full build is ttym-full-3.0.diff (see "The full build" above). The notes here apply only when stacking the feature patches by hand to assemble a subset. Every patch in this directory applies and compiles cleanly against the -pristine base -- except flash, which builds on persist-alert, and -config-file, the capstone, which builds on six other patches (see -Dependencies). That is the property the verification step guarantees. +pristine base -- except config-file, which builds on notify. Combining multiple optional patches will produce conflicts at shared regions: the Config struct, usage(), the getopt() option string, and in some cases #include lines. This is normal suckless territory -- -resolve by hand, or apply ttym-full-2.0.diff instead. The base is laid -out so most patches add their new code in fresh sections (before -usage(), before main()) where they do not collide; the inevitable -collisions are in points where every patch must add a line or field. +resolve by hand, or apply ttym-full-3.0.diff instead. Two known +collisions when stacking logging with hooks: both own the -c flag, so +keep one Config member and one usage() line for it. Recommended order when stacking: - persist-alert -> flash -> notify -> logging -> hooks -> - bar-styles -> config-file + notify -> logging -> hooks -> config-file -config-file must come last -- after bar-styles in particular, since it -overrides the resolved bar glyphs. Apply, resolve any .rej files, -rebuild. +config-file must come last -- it overrides the resolved bar glyphs and +extends usage() with lines the other patches also touch. Apply, +resolve any .rej files, rebuild. Authoring new patches @@ -129,5 +128,5 @@ Match the discipline used here: - Verify with `patch -p1` against a fresh copy of the base -- or, for a patch with prerequisites, the base plus those -- and confirm the result compiles before publishing. - - When the patch set changes, regenerate ttym-full-2.0.diff so the + - When the patch set changes, regenerate ttym-full-3.0.diff so the one-shot full build stays in sync. diff --git a/patches/ttym-bar-styles-2.0.diff b/patches/ttym-bar-styles-2.0.diff deleted file mode 100644 index 2653cf5..0000000 --- a/patches/ttym-bar-styles-2.0.diff +++ /dev/null @@ -1,155 +0,0 @@ -diff -ruN a/config.def.h b/config.def.h ---- a/config.def.h 2026-05-20 12:05:11.699957443 +0200 -+++ b/config.def.h 2026-05-20 12:05:11.703957526 +0200 -@@ -3,3 +3,8 @@ - /* progress bar glyphs (ASCII default) */ - static const char *const BAR_FILL = ">"; - static const char *const BAR_EMPTY = "-"; -+ -+/* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii). -+ * Available: unicode | ascii | hash | dots | line | block | arrow | minimal. -+ */ -+static const char *const BAR_STYLE = ""; -diff -ruN a/ttym.c b/ttym.c ---- a/ttym.c 2026-05-20 12:05:11.699957443 +0200 -+++ b/ttym.c 2026-05-20 12:06:26.161496315 +0200 -@@ -5,6 +5,7 @@ - #include - #include - #include -+#include - #include - #include - #include -@@ -23,6 +24,7 @@ - typedef struct { - int quiet; /* -q: suppress completion bell */ - int silent; /* -s: no bell, no extras */ -+ char bar_style[32]; /* --bar STYLE */ - } Config; - - static struct termios oldtios; -@@ -186,7 +188,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 +210,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 +236,55 @@ - fflush(stdout); - } - -+/* ===== 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. -@@ -243,6 +302,7 @@ - "FLAGS:\n" - " -q no sound\n" - " -s silent\n" -+" --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n" - " -- end of options\n" - " -h help\n" - "\n" -@@ -259,6 +319,10 @@ - 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); -+ - ttyfd = open("/dev/tty", O_RDONLY); - if (ttyfd >= 0 && isatty(ttyfd)) { - if (tcgetattr(ttyfd, &oldtios) == 0) { -@@ -300,7 +364,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; - } - -@@ -399,6 +464,10 @@ - new_argv[new_argc++] = 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; diff --git a/patches/ttym-config-file-2.0.diff b/patches/ttym-config-file-2.0.diff deleted file mode 100644 index 422d724..0000000 --- a/patches/ttym-config-file-2.0.diff +++ /dev/null @@ -1,237 +0,0 @@ -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; diff --git a/patches/ttym-config-file-3.0.diff b/patches/ttym-config-file-3.0.diff new file mode 100644 index 0000000..031f105 --- /dev/null +++ b/patches/ttym-config-file-3.0.diff @@ -0,0 +1,293 @@ +diff -ruN a/config.def.h b/config.def.h +--- a/config.def.h 2026-08-25 17:18:15.738123728 +0200 ++++ b/config.def.h 2026-08-25 17:18:15.738123728 +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/config.h b/config.h +--- a/config.h 2026-08-25 17:18:15.738123728 +0200 ++++ b/config.h 2026-08-25 17:18:15.738123728 +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 = "-"; +Binary files a/timer and b/timer differ +diff -ruN a/ttym.c b/ttym.c +--- a/ttym.c 2026-08-25 17:18:15.738123728 +0200 ++++ b/ttym.c 2026-08-25 17:18:15.738123728 +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; diff --git a/patches/ttym-flash-2.0.diff b/patches/ttym-flash-2.0.diff deleted file mode 100644 index 0ee30b5..0000000 --- a/patches/ttym-flash-2.0.diff +++ /dev/null @@ -1,113 +0,0 @@ -diff -ruN a/config.def.h b/config.def.h ---- a/config.def.h 2026-05-20 12:14:52.639963552 +0200 -+++ b/config.def.h 2026-05-20 12:14:52.639963552 +0200 -@@ -9,3 +9,8 @@ - * Override per-run with --no-persist. - */ - static const int ALERT_PERSIST = 1; -+ -+/* reverse-video flash during the alert loop. -+ * Override per-run with --flash on|off. -+ */ -+static const int FLASH = 1; -diff -ruN a/ttym.c b/ttym.c ---- a/ttym.c 2026-05-20 12:14:52.639963552 +0200 -+++ b/ttym.c 2026-05-20 12:17:37.203364538 +0200 -@@ -11,6 +11,7 @@ - #include - #include - #include -+#include - #include - #include - #include -@@ -24,6 +25,7 @@ - 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 */ - } Config; - - static struct termios oldtios; -@@ -81,6 +83,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) - { -@@ -88,7 +97,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); - } -@@ -241,14 +250,17 @@ - } - - static void --alert_loop(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++) { -@@ -287,6 +299,7 @@ - " -q no sound\n" - " -s silent\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" -@@ -421,7 +434,7 @@ - } - if (!cfg->silent && cfg->alert_persist && is_foreground()) { - interrupted = 0; -- alert_loop(); -+ alert_loop(cfg->flash); - } - } - -@@ -436,6 +449,7 @@ - { - Config cfg = {0}; - cfg.alert_persist = ALERT_PERSIST; -+ cfg.flash = FLASH; - - /* Pre-strip long-form flags before getopt. Stop at "--". */ - int new_argc = 0; -@@ -452,6 +466,14 @@ - 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; -+ } - new_argv[new_argc++] = argv[i]; - } - new_argv[new_argc] = NULL; 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 - #include - #include -+#include - #include - #include - #include - #include - #include - #include -+#include - #include -+#include -+#include - #include - #include - #include -@@ -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: tssecondscomment) ===== */ -+ -+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); diff --git a/patches/ttym-full-3.0.diff b/patches/ttym-full-3.0.diff new file mode 100644 index 0000000..166c869 --- /dev/null +++ b/patches/ttym-full-3.0.diff @@ -0,0 +1,961 @@ +diff -ruN a/config.def.h b/config.def.h +--- a/config.def.h 2026-08-25 17:18:15.750123947 +0200 ++++ b/config.def.h 2026-08-25 17:18:15.750123947 +0200 +@@ -1,9 +1,32 @@ + /* 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 = "-"; + ++/* desktop notification on countdown completion. ++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. ++ * Set NULL or "" to disable. ++ */ ++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. ++ */ ++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. ++ */ ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +diff -ruN a/config.h b/config.h +--- a/config.h 2026-08-25 17:18:15.750123947 +0200 ++++ b/config.h 2026-08-25 17:18:15.750123947 +0200 +@@ -1,9 +1,32 @@ + /* 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 = "-"; + ++/* desktop notification on countdown completion. ++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. ++ * Set NULL or "" to disable. ++ */ ++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. ++ */ ++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. ++ */ ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +Binary files a/timer and b/timer differ +diff -ruN a/ttym.c b/ttym.c +--- a/ttym.c 2026-08-25 17:18:15.750123947 +0200 ++++ b/ttym.c 2026-08-25 17:18:15.750123947 +0200 +@@ -13,6 +13,10 @@ + #include + #include + #include ++#include ++#include ++#include ++#include + #include + #include + #include +@@ -24,9 +28,16 @@ + typedef struct { + int quiet; /* -q: suppress completion bell */ + int silent; /* -s: no bell, no extras */ ++ 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]; ++ char comment[512]; /* -c TEXT: logged, and passed as 3rd arg to hooks */ + 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; +@@ -236,6 +247,540 @@ + 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: tssecondscomment) ===== */ ++ ++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); ++ } ++} ++ + /* ===== alert loop ===== + * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap. + */ +@@ -328,6 +873,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 ===== +@@ -340,20 +1057,26 @@ + { + 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" + " --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" ++"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n" ++"config: /etc/ttym.conf, then ~/.config/ttym/config (--dump-config)\n"); + } + + /* ===== run loop ===== */ +@@ -393,6 +1116,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; +@@ -457,6 +1183,7 @@ + printf("\r\033[32mSTOP %s\033[0m\033[K\n", el); + 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)); +@@ -464,6 +1191,7 @@ + printf("\r\033[33mQUIT at %s\033[0m\033[K\n", el); + 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)); +@@ -471,6 +1199,8 @@ + printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb); + else + printf("DONE %s 100%%\n", tb); ++ if (!cfg->silent) notify_and_sound(cfg); ++ run_hook("on_done", "countdown", total, cfg->comment); + if (!cfg->silent && !cfg->quiet && out_tty) { + fputc('\a', stdout); + fflush(stdout); +@@ -482,6 +1212,7 @@ + } + } + ++ write_log(elapsed_s, cfg->comment); + return user_quit ? 130 : 0; + } + +@@ -490,9 +1221,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}; ++ 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; +@@ -513,17 +1256,23 @@ + } + 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; + + int opt; +- while ((opt = getopt(nargc, argv, "qsh")) != -1) { ++ while ((opt = getopt(nargc, 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); return 0; + default: usage(stderr); return 1; + } +@@ -539,6 +1288,16 @@ + fprintf(stderr, "bad duration: %s\n", rem_argv[0]); + 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; ++ } ++ } + } + + return run(total, stopwatch, &cfg); diff --git a/patches/ttym-hooks-2.0.diff b/patches/ttym-hooks-2.0.diff deleted file mode 100644 index dbb4f6a..0000000 --- a/patches/ttym-hooks-2.0.diff +++ /dev/null @@ -1,127 +0,0 @@ -diff -ruN a/ttym.c b/ttym.c ---- a/ttym.c 2026-05-20 12:02:00.123998202 +0200 -+++ b/ttym.c 2026-05-20 12:24:33.731972801 +0200 -@@ -12,6 +12,8 @@ - #include - #include - #include -+#include -+#include - #include - #include - #include -@@ -23,6 +25,7 @@ - typedef struct { - int quiet; /* -q: suppress completion bell */ - int silent; /* -s: no bell, no extras */ -+ char comment[512]; /* passed as 3rd arg to hooks */ - } Config; - - static struct termios oldtios; -@@ -226,6 +229,43 @@ - fflush(stdout); - } - -+/* ===== 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); -+ } -+} -+ - /* ===== usage ===== - * Patches MUST update this in lockstep with new flags so -h always - * reflects the build. -@@ -241,12 +281,14 @@ - "\n" - "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" - "FLAGS:\n" -+" -c TEXT comment, passed as 3rd arg to hooks\n" - " -q no sound\n" - " -s silent\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"); - } - - /* ===== run loop ===== */ -@@ -280,6 +322,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; -@@ -356,6 +401,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 +409,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,6 +417,7 @@ - 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 && !cfg->quiet && out_tty) { - fputc('\a', stdout); - fflush(stdout); -@@ -404,10 +452,11 @@ - 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; - } diff --git a/patches/ttym-hooks-3.0.diff b/patches/ttym-hooks-3.0.diff new file mode 100644 index 0000000..fd505c9 --- /dev/null +++ b/patches/ttym-hooks-3.0.diff @@ -0,0 +1,130 @@ +Binary files a/timer and b/timer differ +diff -ruN a/ttym.c b/ttym.c +--- a/ttym.c 2026-08-25 17:18:15.722123437 +0200 ++++ b/ttym.c 2026-08-25 17:18:15.726123510 +0200 +@@ -13,6 +13,8 @@ + #include + #include + #include ++#include ++#include + #include + #include + #include +@@ -24,6 +26,7 @@ + typedef struct { + int quiet; /* -q: suppress completion bell */ + int silent; /* -s: no bell, no extras */ ++ char comment[512]; /* passed as 3rd arg to hooks */ + int alert_persist; /* alert loop until keypress; --no-persist disables */ + int flash; /* reverse-video flash during alert loop */ + char bar_style[32]; /* --bar STYLE */ +@@ -236,6 +239,43 @@ + fflush(stdout); + } + ++/* ===== 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); ++ } ++} ++ + /* ===== alert loop ===== + * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap. + */ +@@ -345,6 +385,7 @@ + "\n" + "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" + "FLAGS:\n" ++" -c TEXT comment, passed as 3rd arg to hooks\n" + " -q no sound\n" + " -s silent\n" + " --no-persist skip alert-until-keypress loop\n" +@@ -353,7 +394,8 @@ + " -- 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"); + } + + /* ===== run loop ===== */ +@@ -393,6 +435,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; +@@ -457,6 +502,7 @@ + printf("\r\033[32mSTOP %s\033[0m\033[K\n", el); + 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)); +@@ -464,6 +510,7 @@ + printf("\r\033[33mQUIT at %s\033[0m\033[K\n", el); + 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)); +@@ -471,6 +518,7 @@ + printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb); + else + printf("DONE %s 100%%\n", tb); ++ run_hook("on_done", "countdown", total, cfg->comment); + if (!cfg->silent && !cfg->quiet && out_tty) { + fputc('\a', stdout); + fflush(stdout); +@@ -520,10 +568,11 @@ + argv[nargc] = NULL; + + int opt; +- while ((opt = getopt(nargc, argv, "qsh")) != -1) { ++ while ((opt = getopt(nargc, 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); return 0; + default: usage(stderr); return 1; + } diff --git a/patches/ttym-logging-2.0.diff b/patches/ttym-logging-2.0.diff deleted file mode 100644 index d654d50..0000000 --- a/patches/ttym-logging-2.0.diff +++ /dev/null @@ -1,447 +0,0 @@ -diff -ruN a/config.def.h b/config.def.h ---- a/config.def.h 2026-05-20 11:57:26.722347891 +0200 -+++ b/config.def.h 2026-05-20 11:57:26.726347974 +0200 -@@ -3,3 +3,8 @@ - /* progress bar glyphs (ASCII default) */ - static const char *const BAR_FILL = ">"; - static const char *const BAR_EMPTY = "-"; -+ -+/* 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. -+ */ -diff -ruN a/ttym.c b/ttym.c ---- a/ttym.c 2026-05-20 11:57:26.722347891 +0200 -+++ b/ttym.c 2026-05-20 11:57:26.726347974 +0200 -@@ -12,6 +12,7 @@ - #include - #include - #include -+#include - #include - #include - #include -@@ -23,6 +24,7 @@ - typedef struct { - int quiet; /* -q: suppress completion bell */ - int silent; /* -s: no bell, no extras */ -+ char comment[512]; /* -c TEXT: logged with each entry */ - } Config; - - static struct termios oldtios; -@@ -226,6 +228,367 @@ - fflush(stdout); - } - -+/* ===== log (TSV: tssecondscomment) ===== */ -+ -+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; -+} -+ - /* ===== usage ===== - * Patches MUST update this in lockstep with new flags so -h always - * reflects the build. -@@ -238,9 +601,12 @@ - "usage:\n" - " timer [FLAGS] DURATION 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" - " -- end of options\n" -@@ -378,6 +744,7 @@ - } - - (void)cfg; -+ write_log(elapsed_s, cfg->comment); - return user_quit ? 130 : 0; - } - -@@ -386,6 +753,13 @@ - 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}; - - /* Pre-strip long-form flags before getopt. Stop at "--". */ -@@ -404,10 +778,11 @@ - 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; - } diff --git a/patches/ttym-logging-3.0.diff b/patches/ttym-logging-3.0.diff new file mode 100644 index 0000000..0b16351 --- /dev/null +++ b/patches/ttym-logging-3.0.diff @@ -0,0 +1,466 @@ +diff -ruN a/config.def.h b/config.def.h +--- a/config.def.h 2026-08-25 17:18:15.710123219 +0200 ++++ b/config.def.h 2026-08-25 17:18:15.714123291 +0200 +@@ -4,6 +4,11 @@ + static const char *const BAR_FILL = ">"; + static const char *const BAR_EMPTY = "-"; + ++/* 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. ++ */ ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +diff -ruN a/config.h b/config.h +--- a/config.h 2026-08-25 17:18:15.710123219 +0200 ++++ b/config.h 2026-08-25 17:18:15.714123291 +0200 +@@ -4,6 +4,11 @@ + static const char *const BAR_FILL = ">"; + static const char *const BAR_EMPTY = "-"; + ++/* 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. ++ */ ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +Binary files a/timer and b/timer differ +diff -ruN a/ttym.c b/ttym.c +--- a/ttym.c 2026-08-25 17:18:15.710123219 +0200 ++++ b/ttym.c 2026-08-25 17:18:15.714123291 +0200 +@@ -13,6 +13,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -24,6 +25,7 @@ + typedef struct { + int quiet; /* -q: suppress completion bell */ + int silent; /* -s: no bell, no extras */ ++ char comment[512]; /* -c TEXT: logged with each entry */ + int alert_persist; /* alert loop until keypress; --no-persist disables */ + int flash; /* reverse-video flash during alert loop */ + char bar_style[32]; /* --bar STYLE */ +@@ -236,6 +238,367 @@ + fflush(stdout); + } + ++/* ===== log (TSV: tssecondscomment) ===== */ ++ ++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; ++} ++ + /* ===== alert loop ===== + * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap. + */ +@@ -342,9 +705,12 @@ + "usage:\n" + " timer [FLAGS] DURATION 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" + " --no-persist skip alert-until-keypress loop\n" +@@ -482,6 +848,7 @@ + } + } + ++ write_log(elapsed_s, cfg->comment); + return user_quit ? 130 : 0; + } + +@@ -490,6 +857,13 @@ + 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; +@@ -520,10 +894,11 @@ + argv[nargc] = NULL; + + int opt; +- while ((opt = getopt(nargc, argv, "qsh")) != -1) { ++ while ((opt = getopt(nargc, 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); return 0; + default: usage(stderr); return 1; + } diff --git a/patches/ttym-notify-2.0.diff b/patches/ttym-notify-2.0.diff deleted file mode 100644 index a80eef3..0000000 --- a/patches/ttym-notify-2.0.diff +++ /dev/null @@ -1,232 +0,0 @@ -diff -ruN a/config.def.h b/config.def.h ---- a/config.def.h 2026-05-20 12:03:30.597867999 +0200 -+++ b/config.def.h 2026-05-20 12:03:30.597867999 +0200 -@@ -3,3 +3,16 @@ - /* progress bar glyphs (ASCII default) */ - static const char *const BAR_FILL = ">"; - static const char *const BAR_EMPTY = "-"; -+ -+/* desktop notification on countdown completion. -+ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. -+ * Set NULL or "" to disable. -+ */ -+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. -+ */ -+static const char *const SOUND_CMD = ""; -diff -ruN a/ttym.c b/ttym.c ---- a/ttym.c 2026-05-20 12:03:30.597867999 +0200 -+++ b/ttym.c 2026-05-20 12:03:53.438340036 +0200 -@@ -12,6 +12,7 @@ - #include - #include - #include -+#include - #include - #include - #include -@@ -23,6 +24,10 @@ - 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 msg[1024]; - } Config; - - static struct termios oldtios; -@@ -226,6 +231,142 @@ - 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 && 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 && 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); -+ } -+ } -+ } -+} -+ - /* ===== usage ===== - * Patches MUST update this in lockstep with new flags so -h always - * reflects the build. -@@ -236,8 +377,8 @@ - { - fprintf(f, - "usage:\n" --" timer [FLAGS] DURATION countdown\n" --" timer [FLAGS] stopwatch\n" -+" timer [FLAGS] DURATION [MESSAGE...] countdown\n" -+" timer [FLAGS] stopwatch\n" - "\n" - "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" - "FLAGS:\n" -@@ -370,6 +511,7 @@ - printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, ""); - else - printf("DONE %s 100%%\n", tb); -+ if (!cfg->silent) notify_and_sound(cfg); - if (!cfg->silent && !cfg->quiet && out_tty) { - fputc('\a', stdout); - fflush(stdout); -@@ -387,6 +529,10 @@ - main(int argc, char **argv) - { - Config cfg = {0}; -+ cfg.title = TITLE; -+ cfg.notify_cmd = NOTIFY_CMD; -+ cfg.sound_cmd = SOUND_CMD; -+ snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up"); - - /* Pre-strip long-form flags before getopt. Stop at "--". */ - int new_argc = 0; -@@ -424,6 +570,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); diff --git a/patches/ttym-notify-3.0.diff b/patches/ttym-notify-3.0.diff new file mode 100644 index 0000000..9ccfd5c --- /dev/null +++ b/patches/ttym-notify-3.0.diff @@ -0,0 +1,259 @@ +diff -ruN a/config.def.h b/config.def.h +--- a/config.def.h 2026-08-25 17:18:15.698123000 +0200 ++++ b/config.def.h 2026-08-25 17:18:15.702123073 +0200 +@@ -4,6 +4,19 @@ + static const char *const BAR_FILL = ">"; + static const char *const BAR_EMPTY = "-"; + ++/* desktop notification on countdown completion. ++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. ++ * Set NULL or "" to disable. ++ */ ++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. ++ */ ++static const char *const SOUND_CMD = ""; ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +diff -ruN a/config.h b/config.h +--- a/config.h 2026-08-25 17:18:15.698123000 +0200 ++++ b/config.h 2026-08-25 17:18:15.702123073 +0200 +@@ -4,6 +4,19 @@ + static const char *const BAR_FILL = ">"; + static const char *const BAR_EMPTY = "-"; + ++/* desktop notification on countdown completion. ++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. ++ * Set NULL or "" to disable. ++ */ ++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. ++ */ ++static const char *const SOUND_CMD = ""; ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +Binary files a/timer and b/timer differ +diff -ruN a/ttym.c b/ttym.c +--- a/ttym.c 2026-08-25 17:18:15.698123000 +0200 ++++ b/ttym.c 2026-08-25 17:18:15.702123073 +0200 +@@ -13,6 +13,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -24,6 +25,10 @@ + 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 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 */ +@@ -236,6 +241,142 @@ + 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 && 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 && 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); ++ } ++ } ++ } ++} ++ + /* ===== alert loop ===== + * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap. + */ +@@ -340,8 +481,8 @@ + { + fprintf(f, + "usage:\n" +-" timer [FLAGS] DURATION countdown\n" +-" timer [FLAGS] stopwatch\n" ++" timer [FLAGS] DURATION [MESSAGE...] countdown\n" ++" timer [FLAGS] stopwatch\n" + "\n" + "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" + "FLAGS:\n" +@@ -471,6 +612,7 @@ + printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb); + else + printf("DONE %s 100%%\n", tb); ++ if (!cfg->silent) notify_and_sound(cfg); + if (!cfg->silent && !cfg->quiet && out_tty) { + fputc('\a', stdout); + fflush(stdout); +@@ -491,6 +633,10 @@ + main(int argc, char **argv) + { + Config cfg = {0}; ++ cfg.title = TITLE; ++ cfg.notify_cmd = NOTIFY_CMD; ++ cfg.sound_cmd = SOUND_CMD; ++ snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up"); + cfg.alert_persist = ALERT_PERSIST; + cfg.flash = FLASH; + +@@ -539,6 +685,16 @@ + fprintf(stderr, "bad duration: %s\n", rem_argv[0]); + 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; ++ } ++ } + } + + return run(total, stopwatch, &cfg); diff --git a/patches/ttym-persist-alert-2.0.diff b/patches/ttym-persist-alert-2.0.diff deleted file mode 100644 index 7e9d64b..0000000 --- a/patches/ttym-persist-alert-2.0.diff +++ /dev/null @@ -1,111 +0,0 @@ -diff -ruN a/config.def.h b/config.def.h ---- a/config.def.h 2026-05-20 11:57:26.574344832 +0200 -+++ b/config.def.h 2026-05-20 11:57:26.578344915 +0200 -@@ -3,3 +3,9 @@ - /* progress bar glyphs (ASCII default) */ - 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. -+ */ -+static const int ALERT_PERSIST = 1; -diff -ruN a/ttym.c b/ttym.c ---- a/ttym.c 2026-05-20 11:57:26.574344832 +0200 -+++ b/ttym.c 2026-05-20 11:57:26.578344915 +0200 -@@ -23,6 +23,7 @@ - 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 */ - } Config; - - static struct termios oldtios; -@@ -226,6 +227,48 @@ - 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(void) -+{ -+ fputs("\033[?25h\033[?7h", stdout); -+ int ticks = 0; -+ while (!interrupted) { -+ fputc('\a', stdout); -+ fflush(stdout); -+ sleep_ms(150); -+ -+ 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); -+} -+ - /* ===== usage ===== - * Patches MUST update this in lockstep with new flags so -h always - * reflects the build. -@@ -243,6 +286,7 @@ - "FLAGS:\n" - " -q no sound\n" - " -s silent\n" -+" --no-persist skip alert-until-keypress loop\n" - " -- end of options\n" - " -h help\n" - "\n" -@@ -375,6 +419,10 @@ - fflush(stdout); - sleep_ms(50); - } -+ if (!cfg->silent && cfg->alert_persist && is_foreground()) { -+ interrupted = 0; -+ alert_loop(); -+ } - } - - (void)cfg; -@@ -387,6 +435,7 @@ - main(int argc, char **argv) - { - Config cfg = {0}; -+ cfg.alert_persist = ALERT_PERSIST; - - /* Pre-strip long-form flags before getopt. Stop at "--". */ - int new_argc = 0; -@@ -399,6 +448,10 @@ - new_argv[new_argc++] = argv[i]; - continue; - } -+ if (!past_dd && !strcmp(argv[i], "--no-persist")) { -+ cfg.alert_persist = 0; -+ continue; -+ } - new_argv[new_argc++] = argv[i]; - } - new_argv[new_argc] = NULL; diff --git a/timer.1 b/timer.1 index 57f84bb..e72566f 100644 --- a/timer.1 +++ b/timer.1 @@ -1,9 +1,25 @@ -.TH TIMER 1 "ttym 2.0" +.TH TIMER 1 "ttym 3.0" .SH NAME timer \- terminal countdown and stopwatch .SH SYNOPSIS .B timer .RB [ \-qs ] +.RB [ \-\-no\-persist ] +.RB [ \-\-flash +.BR on | off ] +.RB [ \-\-bar +.IR style ] +.RB [ \-\- ] +.RI [ duration ] +.PP +The remaining forms exist only when the corresponding patch is applied; +each is annotated +.RI [patch: name ] +under +.BR OPTIONS . +.PP +.B timer +.RB [ \-qs ] .RB [ \-c .IR text ] .RB [ \-\-no\-persist ] @@ -11,6 +27,7 @@ timer \- terminal countdown and stopwatch .BR on | off ] .RB [ \-\-bar .IR style ] +.RB [ \-\-dump\-config ] .RB [ \-\- ] .RI [ duration ] .RI [ message @@ -32,9 +49,11 @@ The progress bar, current elapsed time and time remaining are redrawn on the same line ten times per second. .PP The binary is intentionally small and modular. -Beyond the base build (countdown, stopwatch, ASCII progress bar, -pause/resume, one\-shot terminal bell on completion) every feature is -an optional patch under +The base build is terminal\-only: countdown, stopwatch, pause/resume, +the progress bar and its eight styles, and the alert\-until\-keypress +loop with its optional terminal flash. It writes no files and spawns no +processes. Every feature that touches the filesystem, runs a subprocess +or depends on an external binary is an optional patch under .IR patches/ . Each section below describing a non\-base feature is annotated with .RI [patch: name ]; @@ -46,8 +65,11 @@ always reflects the actual build. .SS Base .TP .B \-q -Suppress the one\-shot completion bell. -Persistent alert loops, if enabled by a patch, still ring the bell. +Suppress the one\-shot completion bell. With the +.B notify +patch it also suppresses the completion sound, though not the desktop +notification. Persistent alert loops, if enabled by a patch, still ring +the bell. .TP .B \-s Silent. No bell, no notification, no alert loop. @@ -60,36 +82,22 @@ Print usage and exit. Reflects the actual build. End of options. Subsequent arguments are treated as positional even if they begin with .BR \- . -.SS Optional, added by patches -.TP -.BR \-c \ \fItext\fR -.RI [patch: logging, -.RI hooks] -Comment associated with this session. With the -.B logging -patch, it is written as the third TSV field and any -.BI + tag -tokens within it are picked up by -.BR "timer \-\-stats" . -With the -.B hooks -patch, it is passed as the fourth argument to each hook script. .TP .B \-\-no\-persist -.RI [patch: persist\-alert] Skip the alert\-until\-keypress loop. .B timer -emits one bell and one notification, then exits. +emits one bell, then exits. .TP .BR \-\-flash \ \fBon\fR | \fBoff\fR -.RI [patch: flash] Toggle the reverse\-video terminal flash that runs during the persistent alert loop. Accepts .BR on / off / true / false / 1 / 0 / yes / no . -Default is build\-dependent. +Default is +.B FLASH +in +.IR config.h . .TP .BR \-\-bar \ \fIstyle\fR -.RI [patch: bar\-styles] Progress bar style. One of .BR unicode , .BR ascii , @@ -99,12 +107,40 @@ Progress bar style. One of .BR block , .BR arrow , .BR minimal . -With no patch applied the bar uses the compile\-time +Unset (the default) auto\-detects: unicode on a UTF\-8 locale, +otherwise ascii. The .B BAR_FILL and .B BAR_EMPTY -glyphs from -.IR config.h . +glyphs in +.I config.h +are the fallback when no style matches. +.SS Optional, added by patches +.TP +.BR \-c \ \fItext\fR +.RI [patch: logging, +.RI hooks] +Comment associated with this session. With the +.B logging +patch, it is written as the third TSV field and any +.BI + tag +tokens within it are picked up by +.BR "timer \-\-stats" . +With the +.B hooks +patch, it is passed as the third argument to each hook script. +.TP +.B \-\-dump\-config +.RI [patch: config\-file] +Print a commented configuration template on stdout and exit. +.B timer +never creates the file itself: +.PP +.RS +.nf +timer \-\-dump\-config > ~/.config/ttym/config +.fi +.RE .SH SUBCOMMANDS .SS timer \-r [\-n N] [\-\-raw] .RI [patch: logging] @@ -178,7 +214,6 @@ Quit immediately. Logs the partial session if logging is enabled. Same as .BR q . .SH ALERT LOOP -.RI [patch: persist\-alert] When a countdown reaches zero, instead of emitting one bell and exiting, the program rings the bell every \(ti1.5 s and polls for a keypress. Any key acknowledges and exits. @@ -191,12 +226,14 @@ Background jobs .IR ... \ &) get one bell and exit normally, leaving the shell quiet. .PP -If the -.B flash -patch is also applied, each beat of the loop briefly inverts the -terminal colours (reverse\-video). The flash can be suppressed without -disabling the loop via -.BR "\-\-flash off" . +Each beat of the loop briefly inverts the terminal colours +(reverse\-video). The flash can be suppressed without disabling the +loop via +.BR "\-\-flash off" , +or permanently via +.B FLASH +in +.IR config.h . .SH HOOKS .RI [patch: hooks] On state transitions @@ -228,12 +265,54 @@ or the others come from the running session. Hooks must be marked executable. Standard streams are redirected to .IR /dev/null . +.SH EXIT STATUS +.TP +.B 0 +A countdown ran to completion. +.TP +.B 1 +Usage error: an unknown option, or a duration that could not be parsed. +.TP +.B 130 +The session was cut short, either by +.B q +or by +.BR SIGINT , +.BR SIGTERM , +.BR SIGQUIT +or +.BR SIGHUP . +The terminal is restored in every case. +A stopwatch always exits this way \(em it has no natural end. +.SH ENVIRONMENT +.TP +.B XDG_CONFIG_HOME +Base directory for the files below. Defaults to +.I ~/.config +when unset or empty. .SH FILES .TP +.I /etc/ttym.conf +.RI [patch: config\-file] +System\-wide runtime configuration, read before the per\-user file. +.TP .I ~/.config/ttym/config .RI [patch: config\-file] -Runtime configuration. Auto\-generated on first run with all keys -commented out. +Per\-user runtime configuration; +.BR key " = " value +lines. A +.B # +begins a comment at the start of a line or after whitespace, and never +inside quotes; quote a value to protect surrounding spaces or a leading +.BR # . +Unknown keys and unparseable values are reported on stderr. +Command\-line flags override this file, which overrides +.IR /etc/ttym.conf , +which overrides the compile\-time defaults in +.IR config.h . +.B timer +never creates this file \(em see +.BR \-\-dump\-config . .TP .I ~/.config/ttym/ttym.log .RI [patch: logging] diff --git a/ttym.c b/ttym.c index fa46132..fb33d1f 100644 --- a/ttym.c +++ b/ttym.c @@ -5,12 +5,13 @@ #include #include #include +#include #include #include -#include #include #include #include +#include #include #include #include @@ -23,6 +24,9 @@ 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 bar_style[32]; /* --bar STYLE */ } Config; static struct termios oldtios; @@ -38,17 +42,6 @@ static int out_tty = 0; /* ===== helpers ===== */ -static void -die(const char *fmt, ...) -{ - va_list ap; - va_start(ap, fmt); - vfprintf(stderr, fmt, ap); - va_end(ap); - fputc('\n', stderr); - exit(1); -} - static void sleep_ms(int ms) { @@ -73,6 +66,13 @@ term_cols(void) return 80; } +static long +elapsed(long start_ms, long pause_accum, long pause_start, int paused) +{ + long ms = (paused ? pause_start : now_ms()) - start_ms - pause_accum; + return ms < 0 ? 0 : ms; +} + static void on_signal(int sig) { @@ -80,16 +80,23 @@ on_signal(int sig) 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) { if (rawset && ttyfd >= 0) tcsetattr(ttyfd, TCSANOW, &oldtios); rawset = 0; - if (out_tty) - fputs("\033[?7h\033[?25h", stdout); /* re-enable wrap, show cursor */ - fputc('\n', stdout); - fflush(stdout); + if (out_tty) { + fputs("\033[?5l\033[?7h\033[?25h", stdout); /* clear reverse, re-enable wrap, show cursor */ + fflush(stdout); + } } /* ===== duration parsing ===== @@ -142,18 +149,15 @@ parse_duration(const char *s) if (nf == 3) { if (v[1] >= 60 || v[2] >= 60) return -1; total = v[0] * 3600 + v[1] * 60 + v[2]; - } else if (nf == 2) { + } else { if (v[1] >= 60) return -1; total = v[0] * 60 + v[1]; - } else { - return -1; } return total > 0 ? total : -1; } long total = 0; size_t i = 0, start = 0; - int have_any = 0; while (i < len) { if (isdigit((unsigned char)s[i])) { i++; continue; } if (s[i] != 'h' && s[i] != 'm' && s[i] != 's') return -1; @@ -164,11 +168,10 @@ parse_duration(const char *s) case 'm': total += v * 60; break; case 's': total += v; break; } - have_any = 1; i++; start = i; } - if (!have_any || start != len) return -1; + if (start != len) return -1; return total > 0 ? total : -1; } @@ -186,7 +189,8 @@ fmt_clock(long s, char *buf, size_t bufsz) /* ===== 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 +211,15 @@ draw_countdown(long total, long elapsed_ms, int paused, int barw) 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); } @@ -221,11 +232,104 @@ draw_stopwatch(long elapsed_ms, int paused) fmt_clock(es, el, sizeof(el)); const char *color = paused ? "\033[36m" : "\033[32m"; const char *status = paused ? "PAUSED " : " "; - printf("\r%sT+%s\033[0m %s ", - color, el, status); + printf("\r%sT+%s\033[0m %s\033[K", color, el, status); 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); +} + +/* ===== 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. @@ -243,6 +347,9 @@ usage(FILE *f) "FLAGS:\n" " -q no sound\n" " -s silent\n" +" --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" " -- end of options\n" " -h help\n" "\n" @@ -259,6 +366,10 @@ run(long total, int stopwatch, const Config *cfg) 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); + ttyfd = open("/dev/tty", O_RDONLY); if (ttyfd >= 0 && isatty(ttyfd)) { if (tcgetattr(ttyfd, &oldtios) == 0) { @@ -276,6 +387,8 @@ run(long total, int stopwatch, const Config *cfg) sa.sa_handler = on_signal; sigaction(SIGINT, &sa, NULL); sigaction(SIGTERM, &sa, NULL); + sigaction(SIGQUIT, &sa, NULL); + sigaction(SIGHUP, &sa, NULL); if (out_tty) fputs("\033[?25l\033[?7l", stdout); @@ -289,18 +402,15 @@ run(long total, int stopwatch, const Config *cfg) for (;;) { if (interrupted) { user_quit = 1; break; } - long now = now_ms(); - long elapsed_ms = paused - ? pause_start - start_ms - pause_accum - : now - start_ms - pause_accum; - if (elapsed_ms < 0) elapsed_ms = 0; + long elapsed_ms = elapsed(start_ms, pause_accum, pause_start, paused); if (stopwatch) { if (out_tty) 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; } @@ -318,19 +428,17 @@ run(long total, int stopwatch, const Config *cfg) ssize_t n = read(ttyfd, &c, 1); if (n == 1) { if (c == ' ') { - if (paused) { - pause_accum += now_ms() - pause_start; - paused = 0; - } else { - pause_start = now_ms(); - paused = 1; - } + long t = now_ms(); + if (paused) + pause_accum += t - pause_start; + pause_start = t; + paused = !paused; } else if (c == 'q' || c == 'Q') { user_quit = 1; goto done; } } - } else if (pr == 0) { + } else if (pr == 0 || (pr < 0 && errno == EINTR)) { break; } else { sleep_ms((int)rem_frame); @@ -340,34 +448,27 @@ run(long total, int stopwatch, const Config *cfg) } done: - { - long final_now = now_ms(); - long final_ms = paused - ? pause_start - start_ms - pause_accum - : final_now - start_ms - pause_accum; - if (final_ms < 0) final_ms = 0; - elapsed_s = final_ms / 1000; - } + elapsed_s = elapsed(start_ms, pause_accum, pause_start, paused) / 1000; if (stopwatch) { char el[24]; fmt_clock(elapsed_s, el, sizeof(el)); if (out_tty) - printf("\r\033[32mSTOP %s%*s\033[0m\n", el, 30, ""); + printf("\r\033[32mSTOP %s\033[0m\033[K\n", el); else printf("STOP %s\n", el); } else if (user_quit) { char el[24]; fmt_clock(elapsed_s, el, sizeof(el)); if (out_tty) - printf("\r\033[33mQUIT at %s%*s\033[0m\n", el, 30, ""); + printf("\r\033[33mQUIT at %s\033[0m\033[K\n", el); else printf("QUIT at %s\n", el); } else { char tb[24]; fmt_clock(total, tb, sizeof(tb)); if (out_tty) - printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, ""); + printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb); else printf("DONE %s 100%%\n", tb); if (!cfg->silent && !cfg->quiet && out_tty) { @@ -375,9 +476,12 @@ done: fflush(stdout); sleep_ms(50); } + if (!cfg->silent && cfg->alert_persist && is_foreground()) { + interrupted = 0; + alert_loop(cfg->flash); + } } - (void)cfg; return user_quit ? 130 : 0; } @@ -387,33 +491,45 @@ int main(int argc, char **argv) { Config cfg = {0}; + cfg.alert_persist = ALERT_PERSIST; + cfg.flash = FLASH; /* Pre-strip long-form flags before getopt. Stop at "--". */ - int new_argc = 0; - char **new_argv = malloc(sizeof(char *) * (argc + 1)); - if (!new_argv) die("oom"); - int past_dd = 0; + int nargc = 0, past_dd = 0; for (int i = 0; i < argc; i++) { - if (!past_dd && !strcmp(argv[i], "--")) { + if (!past_dd && !strcmp(argv[i], "--")) past_dd = 1; - new_argv[new_argc++] = argv[i]; + 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]; + argv[nargc++] = argv[i]; } - new_argv[new_argc] = NULL; + argv[nargc] = NULL; int opt; - while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) { + while ((opt = getopt(nargc, argv, "qsh")) != -1) { switch (opt) { case 'q': cfg.quiet = 1; break; case 's': cfg.silent = 1; break; - case 'h': usage(stdout); free(new_argv); return 0; - default: usage(stderr); free(new_argv); return 1; + case 'h': usage(stdout); return 0; + default: usage(stderr); return 1; } } - int rem_argc = new_argc - optind; - char **rem_argv = new_argv + optind; + int rem_argc = nargc - optind; + char **rem_argv = argv + optind; int stopwatch = (rem_argc < 1); long total = 0; @@ -421,12 +537,9 @@ main(int argc, char **argv) total = parse_duration(rem_argv[0]); if (total < 0) { fprintf(stderr, "bad duration: %s\n", rem_argv[0]); - free(new_argv); return 1; } } - int rc = run(total, stopwatch, &cfg); - free(new_argv); - return rc; + return run(total, stopwatch, &cfg); } -- cgit v1.3