diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-05-20 14:23:09 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-05-20 14:23:09 +0200 |
| commit | 9e3fcd953e7136b900d6aabafe1c64c083a927be (patch) | |
| tree | 87d504609b5518e47a515b8d269143831b206e39 | |
| download | ttym-9e3fcd953e7136b900d6aabafe1c64c083a927be.tar.gz ttym-9e3fcd953e7136b900d6aabafe1c64c083a927be.zip | |
initial commit
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | Makefile | 43 | ||||
| -rw-r--r-- | README | 67 | ||||
| -rw-r--r-- | config.def.h | 5 | ||||
| -rw-r--r-- | config.mk | 11 | ||||
| -rw-r--r-- | patches/README | 133 | ||||
| -rw-r--r-- | patches/ttym-bar-styles-2.0.diff | 155 | ||||
| -rw-r--r-- | patches/ttym-config-file-2.0.diff | 237 | ||||
| -rw-r--r-- | patches/ttym-flash-2.0.diff | 113 | ||||
| -rw-r--r-- | patches/ttym-full-2.0.diff | 1057 | ||||
| -rw-r--r-- | patches/ttym-hooks-2.0.diff | 127 | ||||
| -rw-r--r-- | patches/ttym-logging-2.0.diff | 447 | ||||
| -rw-r--r-- | patches/ttym-notify-2.0.diff | 232 | ||||
| -rw-r--r-- | patches/ttym-persist-alert-2.0.diff | 111 | ||||
| -rw-r--r-- | timer.1 | 338 | ||||
| -rw-r--r-- | ttym.c | 432 |
16 files changed, 3529 insertions, 0 deletions
@@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Łukasz Kasprzak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fd103de --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +# ttym - terminal countdown / stopwatch +# See LICENSE file for copyright and license details. +.POSIX: + +include config.mk + +BIN = timer +SRC = ttym.c +OBJ = $(SRC:.c=.o) +HDR = config.h + +all: $(BIN) + +config.h: + cp config.def.h $@ + +.c.o: + $(CC) -c $(CFLAGS) $< + +$(OBJ): $(HDR) config.mk + +$(BIN): $(OBJ) + $(CC) -o $@ $(OBJ) $(LDFLAGS) + +clean: + rm -f $(BIN) $(OBJ) + +distclean: clean + rm -f config.h + +install: all + mkdir -p $(DESTDIR)$(PREFIX)/bin + cp -f $(BIN) $(DESTDIR)$(PREFIX)/bin/$(BIN) + chmod 755 $(DESTDIR)$(PREFIX)/bin/$(BIN) + mkdir -p $(DESTDIR)$(MANPREFIX)/man1 + cp -f timer.1 $(DESTDIR)$(MANPREFIX)/man1/timer.1 + chmod 644 $(DESTDIR)$(MANPREFIX)/man1/timer.1 + +uninstall: + rm -f $(DESTDIR)$(PREFIX)/bin/$(BIN) + rm -f $(DESTDIR)$(MANPREFIX)/man1/timer.1 + +.PHONY: all clean distclean install uninstall @@ -0,0 +1,67 @@ +ttym - terminal countdown / stopwatch +===================================== + +A small countdown timer and stopwatch in C. Single binary, libc-only, +no daemon. Default build is intentionally minimal: + + 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 + +Anything beyond that is an optional patch under patches/. See +patches/README for the available set and apply order. + + +Requirements +------------ + +A C compiler and POSIX make. No external libraries. + + +Installation +------------ + +Edit config.mk if needed, then: + + make + sudo make install # to $(PREFIX)/bin/timer ; default /usr/local + +Override the prefix at build time: + + make PREFIX=$HOME/.local install + +The installed binary is named `timer`. + + +Configuration +------------- + +Compile-time knobs live in config.def.h. On first build the Makefile +copies it to config.h; edit config.h and re-`make` to change defaults. + + +Usage +----- + + timer [FLAGS] DURATION countdown + timer [FLAGS] stopwatch + + DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00 + + FLAGS: + -q no sound + -s silent + -- end of options + -h help + +`timer -h` always reflects the build: each patch updates the help text +in lockstep with the flags it adds. + + +License +------- + +MIT. See LICENSE. diff --git a/config.def.h b/config.def.h new file mode 100644 index 0000000..af19705 --- /dev/null +++ b/config.def.h @@ -0,0 +1,5 @@ +/* See LICENSE file for copyright and license details. */ + +/* progress bar glyphs (ASCII default) */ +static const char *const BAR_FILL = ">"; +static const char *const BAR_EMPTY = "-"; diff --git a/config.mk b/config.mk new file mode 100644 index 0000000..f07e004 --- /dev/null +++ b/config.mk @@ -0,0 +1,11 @@ +# ttym version +VERSION = 2.0 + +# paths +PREFIX = /usr/local +MANPREFIX = $(PREFIX)/share/man + +# compiler & linker +CC = cc +CFLAGS = -std=c99 -pedantic -Wall -Wextra -Os +LDFLAGS = diff --git a/patches/README b/patches/README new file mode 100644 index 0000000..cd4e12a --- /dev/null +++ b/patches/README @@ -0,0 +1,133 @@ +ttym patches +============ + +Each patch is a unified diff against the pristine base. Apply with: + + cd ttym + patch -p1 < patches/ttym-<name>-2.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. +See "The full build" below. + + +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: + + patch -p1 < patches/ttym-full-2.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- + resolving every collision, but deterministic and conflict-free. + Use the individual patches only when you want a specific subset. + + +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 + 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. + + ttym-logging-2.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 + 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: + 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. + + +Dependencies +------------ + +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. + +All other patches are independent of each other and of the base. + + +Stacking +-------- + +The simplest route to the full build is ttym-full-2.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. + +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. + +Recommended order when stacking: + + persist-alert -> flash -> notify -> logging -> hooks -> + bar-styles -> 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. + + +Authoring new patches +--------------------- + +Match the discipline used here: + + - Add compile-time knobs to config.def.h, never inline in ttym.c. + - Update usage() in the same hunk as the option parser change, so + `timer -h` never drifts from what the build actually supports. + - 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 + 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 new file mode 100644 index 0000000..2653cf5 --- /dev/null +++ b/patches/ttym-bar-styles-2.0.diff @@ -0,0 +1,155 @@ +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 <ctype.h> + #include <errno.h> + #include <fcntl.h> ++#include <locale.h> + #include <poll.h> + #include <signal.h> + #include <stdarg.h> +@@ -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 new file mode 100644 index 0000000..422d724 --- /dev/null +++ b/patches/ttym-config-file-2.0.diff @@ -0,0 +1,237 @@ +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-flash-2.0.diff b/patches/ttym-flash-2.0.diff new file mode 100644 index 0000000..0ee30b5 --- /dev/null +++ b/patches/ttym-flash-2.0.diff @@ -0,0 +1,113 @@ +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 <stdio.h> + #include <stdlib.h> + #include <string.h> ++#include <strings.h> + #include <sys/ioctl.h> + #include <termios.h> + #include <time.h> +@@ -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 new file mode 100644 index 0000000..a344275 --- /dev/null +++ b/patches/ttym-full-2.0.diff @@ -0,0 +1,1057 @@ +diff -ruN a/config.def.h b/config.def.h +--- a/config.def.h 2026-05-20 14:08:08.221029371 +0200 ++++ b/config.def.h 2026-05-20 14:08:08.221029371 +0200 +@@ -1,5 +1,47 @@ + /* See LICENSE file for copyright and license details. */ + +-/* progress bar glyphs (ASCII default) */ ++/* progress bar glyphs (ASCII default). ++ * Runtime config file ~/.config/ttym/config can override these via ++ * bar_fill / bar_empty keys. ++ */ + static const char *const BAR_FILL = ">"; + static const char *const BAR_EMPTY = "-"; ++ ++/* persistent alert: keep ringing the bell until any key is pressed. ++ * Foreground-only; backgrounded runs always fall through. ++ * Override per-run with --no-persist, or via the alert_persist key ++ * in the runtime config file (~/.config/ttym/config). ++ */ ++static const int ALERT_PERSIST = 1; ++ ++/* reverse-video flash during the alert loop. ++ * Override per-run with --flash on|off, or via the flash key in the ++ * runtime config file (~/.config/ttym/config). ++ */ ++static const int FLASH = 1; ++ ++/* desktop notification on countdown completion. ++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. ++ * Set NULL or "" to disable. ++ * The runtime config file can override these via title / notify_cmd keys. ++ */ ++static const char *const TITLE = "Timer"; ++static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}"; ++ ++/* sound on countdown completion. If SOUND_CMD is "" the binary autodetects ++ * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound. ++ * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD. ++ * The runtime config file can override this via the sound_cmd key. ++ */ ++static const char *const SOUND_CMD = ""; ++ ++/* log location is ~/.config/ttym/ttym.log (honours $XDG_CONFIG_HOME). ++ * One-shot migration: ~/.config/timer/ -> ~/.config/ttym/ if the new ++ * directory doesn't already exist; legacy log file inside is renamed. ++ */ ++ ++/* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii). ++ * Available: unicode | ascii | hash | dots | line | block | arrow | minimal. ++ * The runtime config file can override this via the bar_style key. ++ */ ++static const char *const BAR_STYLE = ""; +diff -ruN a/ttym.c b/ttym.c +--- a/ttym.c 2026-05-20 14:08:08.221029371 +0200 ++++ b/ttym.c 2026-05-20 14:08:08.221029371 +0200 +@@ -5,13 +5,17 @@ + #include <ctype.h> + #include <errno.h> + #include <fcntl.h> ++#include <locale.h> + #include <poll.h> + #include <signal.h> + #include <stdarg.h> + #include <stdio.h> + #include <stdlib.h> + #include <string.h> ++#include <strings.h> + #include <sys/ioctl.h> ++#include <sys/stat.h> ++#include <sys/wait.h> + #include <termios.h> + #include <time.h> + #include <unistd.h> +@@ -23,6 +27,16 @@ + typedef struct { + int quiet; /* -q: suppress completion bell */ + int silent; /* -s: no bell, no extras */ ++ int alert_persist; /* alert loop until keypress; --no-persist disables */ ++ int flash; /* reverse-video flash during alert loop */ ++ char comment[512]; /* -c TEXT: logged with each entry */ ++ char bar_fill[16]; /* override (utf-8 ok); from runtime config */ ++ char bar_empty[16]; /* override */ ++ char bar_style[32]; /* --bar STYLE */ ++ char title[256]; /* override; from runtime config */ ++ char notify_cmd[512]; /* override; from runtime config */ ++ char sound_cmd[512]; /* override; from runtime config */ ++ char msg[1024]; + } Config; + + static struct termios oldtios; +@@ -80,6 +94,13 @@ + interrupted = 1; + } + ++static int ++parse_bool_str(const char *v) ++{ ++ return !strcasecmp(v, "true") || !strcasecmp(v, "yes") || ++ !strcmp(v, "1") || !strcasecmp(v, "on"); ++} ++ + static void + restore_tty(void) + { +@@ -87,7 +108,7 @@ + tcsetattr(ttyfd, TCSANOW, &oldtios); + rawset = 0; + if (out_tty) +- fputs("\033[?7h\033[?25h", stdout); /* re-enable wrap, show cursor */ ++ fputs("\033[?5l\033[?7h\033[?25h", stdout); /* clear reverse, re-enable wrap, show cursor */ + fputc('\n', stdout); + fflush(stdout); + } +@@ -186,7 +207,8 @@ + /* ===== drawing ===== */ + + static void +-draw_countdown(long total, long elapsed_ms, int paused, int barw) ++draw_countdown(long total, long elapsed_ms, int paused, int barw, ++ const char *fill_glyph, const char *empty_glyph, int minimal) + { + if (total <= 0) return; + long total_ms = total * 1000L; +@@ -207,8 +229,15 @@ + const char *status = paused ? "PAUSED " : " "; + + printf("\r%sT-%s\033[0m \033[2m+%s\033[0m [", color, rem, el); +- for (int i = 0; i < fill; i++) fputs(BAR_FILL, stdout); +- for (int i = 0; i < empty; i++) fputs(BAR_EMPTY, stdout); ++ if (minimal) { ++ fputs("\033[7m", stdout); ++ for (int i = 0; i < fill; i++) fputc(' ', stdout); ++ fputs("\033[27m", stdout); ++ for (int i = 0; i < empty; i++) fputc(' ', stdout); ++ } else { ++ for (int i = 0; i < fill; i++) fputs(fill_glyph, stdout); ++ for (int i = 0; i < empty; i++) fputs(empty_glyph, stdout); ++ } + printf("] %3ld%% %s", pct, status); + fflush(stdout); + } +@@ -226,6 +255,740 @@ + fflush(stdout); + } + ++/* ===== alert loop ===== ++ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap. ++ */ ++ ++static int ++is_foreground(void) ++{ ++ if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) return 0; ++ pid_t pgrp = tcgetpgrp(STDIN_FILENO); ++ if (pgrp < 0) return 0; ++ return pgrp == getpgrp(); ++} ++ ++static void ++alert_loop(int flash) ++{ ++ fputs("\033[?25h\033[?7h", stdout); ++ int ticks = 0; ++ while (!interrupted) { ++ fputc('\a', stdout); ++ if (flash) fputs("\033[?5h", stdout); ++ fflush(stdout); ++ sleep_ms(150); ++ if (flash) fputs("\033[?5l", stdout); ++ fflush(stdout); ++ ++ int acked = 0; ++ for (int i = 0; i < 15 && !interrupted; i++) { ++ if (ttyfd < 0) { sleep_ms(100); continue; } ++ struct pollfd pfd = { .fd = ttyfd, .events = POLLIN }; ++ int pr = poll(&pfd, 1, 100); ++ if (pr > 0 && (pfd.revents & POLLIN)) { ++ char ch; ++ ssize_t rd = read(ttyfd, &ch, 1); ++ if (rd >= 0) { acked = 1; break; } ++ } else if (pr < 0) { ++ sleep_ms(100); ++ } ++ } ++ if (acked) break; ++ if (++ticks > 160) break; ++ } ++ fflush(stdout); ++} ++ ++/* ===== subprocess + placeholder expansion ===== ++ * Tokens split on whitespace; {title} and {msg} expanded within each token. ++ * No shell involved — argv stays argv. ++ */ ++ ++static int ++build_argv(const char *cmd, const Config *c, char ***out) ++{ ++ char buf[2048]; ++ snprintf(buf, sizeof(buf), "%s", cmd); ++ char **argv = calloc(64, sizeof(char *)); ++ if (!argv) return -1; ++ int argc = 0; ++ char *p = buf; ++ while (*p && argc < 63) { ++ while (*p && isspace((unsigned char)*p)) p++; ++ if (!*p) break; ++ char *start = p; ++ while (*p && !isspace((unsigned char)*p)) p++; ++ if (*p) *p++ = '\0'; ++ char tok[2048]; size_t oi = 0; ++ for (char *q = start; *q && oi < sizeof(tok) - 1; ) { ++ const char *sub = NULL; size_t skip = 0; ++ if (!strncmp(q, "{msg}", 5)) { sub = c->msg; skip = 5; } ++ else if (!strncmp(q, "{title}", 7)) { sub = c->title; skip = 7; } ++ if (sub) { ++ size_t sl = strlen(sub); ++ if (oi + sl >= sizeof(tok)) sl = sizeof(tok) - 1 - oi; ++ memcpy(tok + oi, sub, sl); ++ oi += sl; q += skip; ++ } else { tok[oi++] = *q++; } ++ } ++ tok[oi] = '\0'; ++ argv[argc++] = strdup(tok); ++ } ++ argv[argc] = NULL; ++ *out = argv; ++ return argc; ++} ++ ++static void ++free_argv(char **argv) ++{ ++ if (!argv) return; ++ for (int i = 0; argv[i]; i++) free(argv[i]); ++ free(argv); ++} ++ ++static void ++spawn_bg(char *const argv[]) ++{ ++ pid_t pid = fork(); ++ if (pid == 0) { ++ setsid(); ++ int devnull = open("/dev/null", O_RDWR); ++ if (devnull >= 0) { ++ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2); ++ if (devnull > 2) close(devnull); ++ } ++ execvp(argv[0], argv); ++ _exit(127); ++ } ++ (void)pid; ++} ++ ++static int ++command_exists(const char *name) ++{ ++ const char *path = getenv("PATH"); ++ if (!path) return 0; ++ char buf[512]; ++ const char *p = path; ++ while (*p) { ++ const char *e = strchr(p, ':'); ++ size_t len = e ? (size_t)(e - p) : strlen(p); ++ if (len > 0 && len + strlen(name) + 2 < sizeof(buf)) { ++ snprintf(buf, sizeof(buf), "%.*s/%s", (int)len, p, name); ++ if (access(buf, X_OK) == 0) return 1; ++ } ++ if (!e) break; ++ p = e + 1; ++ } ++ return 0; ++} ++ ++static const char * ++detect_sound_player(void) ++{ ++ static const char *cands[] = { "paplay", "aplay", "mpv", "ffplay", NULL }; ++ for (int i = 0; cands[i]; i++) ++ if (command_exists(cands[i])) return cands[i]; ++ return NULL; ++} ++ ++static const char * ++default_sound_path(void) ++{ ++ static const char *paths[] = { ++ "/usr/share/sounds/freedesktop/stereo/complete.oga", ++ "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", ++ "/usr/share/sounds/alsa/Front_Center.wav", ++ NULL ++ }; ++ for (int i = 0; paths[i]; i++) ++ if (access(paths[i], R_OK) == 0) return paths[i]; ++ return NULL; ++} ++ ++static void ++notify_and_sound(const Config *c) ++{ ++ if (c->notify_cmd[0] && command_exists("notify-send")) { ++ char **a; ++ if (build_argv(c->notify_cmd, c, &a) > 0) { ++ spawn_bg(a); ++ free_argv(a); ++ } ++ } ++ if (!c->quiet) { ++ if (c->sound_cmd[0]) { ++ char **a; ++ if (build_argv(c->sound_cmd, c, &a) > 0) { ++ spawn_bg(a); ++ free_argv(a); ++ } ++ } else { ++ const char *pl = detect_sound_player(); ++ const char *sp = default_sound_path(); ++ if (pl && sp) { ++ char *a[] = { (char *)pl, (char *)sp, NULL }; ++ spawn_bg(a); ++ } ++ } ++ } ++} ++ ++/* ===== log (TSV: ts<TAB>seconds<TAB>comment) ===== */ ++ ++static int ++build_config_path(char *buf, size_t bufsz, const char *suffix) ++{ ++ const char *home = getenv("HOME"); ++ const char *xdg = getenv("XDG_CONFIG_HOME"); ++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, suffix); ++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, suffix); ++ else return -1; ++ return 0; ++} ++ ++static void ++migrate_legacy_dir(void) ++{ ++ const char *home = getenv("HOME"); ++ const char *xdg = getenv("XDG_CONFIG_HOME"); ++ char base[512]; ++ if (xdg && *xdg) snprintf(base, sizeof(base), "%s", xdg); ++ else if (home && *home) snprintf(base, sizeof(base), "%s/.config", home); ++ else return; ++ char old_dir[600], new_dir[600]; ++ snprintf(old_dir, sizeof(old_dir), "%s/timer", base); ++ snprintf(new_dir, sizeof(new_dir), "%s/ttym", base); ++ struct stat sb; ++ if (stat(new_dir, &sb) == 0) return; ++ if (stat(old_dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) return; ++ if (rename(old_dir, new_dir) != 0) return; ++ char old_log[700], new_log[700]; ++ snprintf(old_log, sizeof(old_log), "%s/timer.log", new_dir); ++ snprintf(new_log, sizeof(new_log), "%s/ttym.log", new_dir); ++ if (stat(old_log, &sb) == 0) rename(old_log, new_log); ++} ++ ++static int ++mkdir_p(const char *path, mode_t mode) ++{ ++ char buf[1024]; ++ snprintf(buf, sizeof(buf), "%s", path); ++ for (char *p = buf + 1; *p; p++) { ++ if (*p == '/') { ++ *p = '\0'; ++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1; ++ *p = '/'; ++ } ++ } ++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1; ++ return 0; ++} ++ ++static void ++iso_local(char *buf, size_t bufsz) ++{ ++ time_t t = time(NULL); ++ struct tm tm; ++ localtime_r(&t, &tm); ++ strftime(buf, bufsz, "%Y-%m-%d %H:%M:%S", &tm); ++} ++ ++static void ++sanitize_field(char *s) ++{ ++ for (; *s; s++) ++ if (*s == '\t' || *s == '\n' || *s == '\r') *s = ' '; ++} ++ ++static int ++is_old_format(const char *line) ++{ ++ return strlen(line) >= 11 && line[10] == 'T'; ++} ++ ++static void ++migrate_line(const char *line, char *out, size_t outsz) ++{ ++ if (strlen(line) < 19) { snprintf(out, outsz, "%s", line); return; } ++ char date[11], tpart[9]; ++ memcpy(date, line, 10); date[10] = '\0'; ++ memcpy(tpart, line + 11, 8); tpart[8] = '\0'; ++ const char *tab = strchr(line, '\t'); ++ if (!tab) { snprintf(out, outsz, "%s %s\n", date, tpart); return; } ++ snprintf(out, outsz, "%s %s%s", date, tpart, tab); ++} ++ ++static void ++migrate_log_if_needed(const char *path) ++{ ++ FILE *f = fopen(path, "r"); ++ if (!f) return; ++ char probe[2048]; ++ int needs = 0; ++ while (fgets(probe, sizeof(probe), f)) ++ if (is_old_format(probe)) { needs = 1; break; } ++ if (!needs) { fclose(f); return; } ++ rewind(f); ++ char tmp[1024]; ++ snprintf(tmp, sizeof(tmp), "%s.tmp", path); ++ FILE *out = fopen(tmp, "w"); ++ if (!out) { fclose(f); return; } ++ char line[2048]; ++ while (fgets(line, sizeof(line), f)) { ++ if (is_old_format(line)) { ++ char conv[2048]; ++ migrate_line(line, conv, sizeof(conv)); ++ fputs(conv, out); ++ } else fputs(line, out); ++ } ++ fclose(f); ++ fclose(out); ++ rename(tmp, path); ++} ++ ++static void ++write_log(long duration_s, const char *comment) ++{ ++ char path[512]; ++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return; ++ char dir[512]; ++ snprintf(dir, sizeof(dir), "%s", path); ++ char *slash = strrchr(dir, '/'); ++ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); } ++ migrate_log_if_needed(path); ++ int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600); ++ if (fd < 0) return; ++ char ts[32]; iso_local(ts, sizeof(ts)); ++ char safe[512]; snprintf(safe, sizeof(safe), "%s", comment ? comment : ""); ++ sanitize_field(safe); ++ char line[1024]; ++ int n = snprintf(line, sizeof(line), "%s\t%ld\t%s\n", ts, duration_s, safe); ++ if (n > 0) { ssize_t w = write(fd, line, (size_t)n); (void)w; } ++ close(fd); ++} ++ ++static int ++read_log_lines(char ***out_lines, size_t *out_n) ++{ ++ char path[512]; ++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return -1; ++ migrate_log_if_needed(path); ++ FILE *f = fopen(path, "r"); ++ if (!f) { *out_lines = NULL; *out_n = 0; return 0; } ++ size_t cap = 64, n = 0; ++ char **lines = malloc(cap * sizeof(char *)); ++ if (!lines) { fclose(f); return -1; } ++ char buf[2048]; ++ while (fgets(buf, sizeof(buf), f)) { ++ size_t l = strlen(buf); ++ if (l && buf[l-1] == '\n') buf[l-1] = '\0'; ++ if (!*buf) continue; ++ if (n >= cap) { ++ cap *= 2; ++ char **nl = realloc(lines, cap * sizeof(char *)); ++ if (!nl) break; ++ lines = nl; ++ } ++ lines[n++] = strdup(buf); ++ } ++ fclose(f); ++ *out_lines = lines; ++ *out_n = n; ++ return 0; ++} ++ ++static void ++free_lines(char **lines, size_t n) ++{ ++ if (!lines) return; ++ for (size_t i = 0; i < n; i++) free(lines[i]); ++ free(lines); ++} ++ ++static int ++split_log_line(char *line, char **ts, long *secs, char **comment) ++{ ++ char *t1 = strchr(line, '\t'); if (!t1) return -1; ++ *t1 = '\0'; ++ char *t2 = strchr(t1 + 1, '\t'); if (!t2) return -1; ++ *t2 = '\0'; ++ char *end; ++ long v = strtol(t1 + 1, &end, 10); ++ if (end == t1 + 1) return -1; ++ *ts = line; *secs = v; *comment = t2 + 1; ++ return 0; ++} ++ ++static void ++fmt_long(long s, char *buf, size_t bufsz) ++{ ++ if (s < 0) s = 0; ++ long d = s / 86400; s %= 86400; ++ long h = s / 3600; s %= 3600; ++ long m = s / 60; ++ long sec = s % 60; ++ snprintf(buf, bufsz, "%02ld %02ld:%02ld:%02ld", d, h, m, sec); ++} ++ ++static int ++cmd_read(int argc, char **argv) ++{ ++ int n_tail = 0, raw = 0; ++ for (int i = 0; i < argc; i++) { ++ if (!strcmp(argv[i], "-n") && i + 1 < argc) { ++ n_tail = atoi(argv[++i]); ++ if (n_tail < 0) n_tail = 0; ++ } else if (!strcmp(argv[i], "--raw")) { ++ raw = 1; ++ } else { ++ fprintf(stderr, "timer -r: unknown arg: %s\n", argv[i]); ++ return 1; ++ } ++ } ++ char **lines = NULL; size_t n = 0; ++ if (read_log_lines(&lines, &n) < 0) return 1; ++ if (n == 0) { free_lines(lines, n); return 0; } ++ size_t start = 0; ++ if (n_tail > 0 && (size_t)n_tail < n) start = n - n_tail; ++ if (raw) { ++ for (size_t i = start; i < n; i++) puts(lines[i]); ++ } else { ++ printf("%-19s %-11s %s\n", "TIMESTAMP", "DURATION", "COMMENT"); ++ for (size_t i = start; i < n; i++) { ++ char *copy = strdup(lines[i]); ++ char *ts, *cmt; long secs; ++ if (split_log_line(copy, &ts, &secs, &cmt) == 0) { ++ char dur[32]; ++ fmt_long(secs, dur, sizeof(dur)); ++ printf("%-19s %-11s %s\n", ts, dur, cmt); ++ } else { ++ puts(lines[i]); ++ } ++ free(copy); ++ } ++ } ++ free_lines(lines, n); ++ return 0; ++} ++ ++static int ++parse_log_ts(const char *ts, struct tm *out) ++{ ++ memset(out, 0, sizeof(*out)); ++ if (sscanf(ts, "%d-%d-%d %d:%d:%d", ++ &out->tm_year, &out->tm_mon, &out->tm_mday, ++ &out->tm_hour, &out->tm_min, &out->tm_sec) != 6) return -1; ++ out->tm_year -= 1900; ++ out->tm_mon -= 1; ++ out->tm_isdst = -1; ++ return 0; ++} ++ ++typedef struct TagAcc { ++ char tag[128]; ++ long secs; ++ long count; ++ struct TagAcc *next; ++} TagAcc; ++ ++static void ++tag_add(TagAcc **head, const char *tag, long secs) ++{ ++ for (TagAcc *a = *head; a; a = a->next) ++ if (!strcmp(a->tag, tag)) { a->secs += secs; a->count++; return; } ++ TagAcc *n = calloc(1, sizeof(*n)); ++ if (!n) return; ++ snprintf(n->tag, sizeof(n->tag), "%s", tag); ++ n->secs = secs; ++ n->count = 1; ++ n->next = *head; ++ *head = n; ++} ++ ++static void ++extract_tags(const char *comment, long secs, TagAcc **head) ++{ ++ const char *p = comment; ++ int found = 0; ++ while (*p) { ++ int at_boundary = (p == comment) || isspace((unsigned char)*(p-1)); ++ if (*p == '+' && at_boundary) { ++ const char *s = p + 1, *e = s; ++ while (*e && !isspace((unsigned char)*e)) e++; ++ if (e > s) { ++ char buf[128]; ++ size_t len = (size_t)(e - s); ++ if (len >= sizeof(buf)) len = sizeof(buf) - 1; ++ memcpy(buf, s, len); buf[len] = '\0'; ++ tag_add(head, buf, secs); ++ found = 1; ++ } ++ p = e; ++ } else p++; ++ } ++ if (!found) tag_add(head, "(untagged)", secs); ++} ++ ++static int ++cmd_stats(int argc, char **argv) ++{ ++ const char *period = (argc > 0) ? argv[0] : "today"; ++ time_t now = time(NULL); ++ struct tm now_tm; localtime_r(&now, &now_tm); ++ time_t cutoff = 0; ++ if (!strcmp(period, "all")) cutoff = 0; ++ else if (!strcmp(period, "today")) { ++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; ++ cutoff = mktime(&t); ++ } else if (!strcmp(period, "week")) { ++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; ++ int back = (t.tm_wday == 0) ? 6 : t.tm_wday - 1; ++ t.tm_mday -= back; ++ cutoff = mktime(&t); ++ } else if (!strcmp(period, "month")) { ++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; t.tm_mday = 1; ++ cutoff = mktime(&t); ++ } else { ++ fprintf(stderr, "stats: unknown period '%s' (today|week|month|all)\n", period); ++ return 1; ++ } ++ char **lines = NULL; size_t n = 0; ++ if (read_log_lines(&lines, &n) < 0) return 1; ++ long total = 0, count = 0; ++ TagAcc *tags = NULL; ++ for (size_t i = 0; i < n; i++) { ++ char *copy = strdup(lines[i]); ++ char *ts, *cmt; long secs; ++ if (split_log_line(copy, &ts, &secs, &cmt) != 0) { free(copy); continue; } ++ struct tm tm; ++ if (parse_log_ts(ts, &tm) != 0) { free(copy); continue; } ++ time_t entry = mktime(&tm); ++ if (cutoff && entry < cutoff) { free(copy); continue; } ++ total += secs; count++; ++ extract_tags(cmt, secs, &tags); ++ free(copy); ++ } ++ free_lines(lines, n); ++ char tot[32]; fmt_long(total, tot, sizeof(tot)); ++ printf("\033[1m== timer stats: %s ==\033[0m\n", period); ++ printf("entries: %ld total: %s\n\n", count, tot); ++ int tag_n = 0; ++ for (TagAcc *a = tags; a; a = a->next) tag_n++; ++ if (tag_n > 0) { ++ TagAcc **arr = malloc(tag_n * sizeof(*arr)); ++ int i = 0; ++ for (TagAcc *a = tags; a; a = a->next) arr[i++] = a; ++ for (int x = 0; x < tag_n - 1; x++) ++ for (int y = x + 1; y < tag_n; y++) ++ if (arr[y]->secs > arr[x]->secs) { ++ TagAcc *t = arr[x]; arr[x] = arr[y]; arr[y] = t; ++ } ++ printf("%-24s %-11s %5s\n", "TAG", "DURATION", "COUNT"); ++ for (int z = 0; z < tag_n; z++) { ++ char d[32]; fmt_long(arr[z]->secs, d, sizeof(d)); ++ printf("%-24s %-11s %5ld\n", arr[z]->tag, d, arr[z]->count); ++ } ++ free(arr); ++ } ++ while (tags) { TagAcc *next = tags->next; free(tags); tags = next; } ++ return 0; ++} ++ ++/* ===== hooks ===== */ ++ ++static int ++build_hook_path(char *buf, size_t bufsz, const char *name) ++{ ++ const char *home = getenv("HOME"); ++ const char *xdg = getenv("XDG_CONFIG_HOME"); ++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/hooks/%s", xdg, name); ++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/hooks/%s", home, name); ++ else return -1; ++ return 0; ++} ++ ++static void ++run_hook(const char *name, const char *mode, long duration_s, const char *comment) ++{ ++ char path[512]; ++ if (build_hook_path(path, sizeof(path), name) < 0) return; ++ if (access(path, X_OK) != 0) return; ++ char dur[32]; ++ snprintf(dur, sizeof(dur), "%ld", duration_s); ++ pid_t pid = fork(); ++ if (pid == 0) { ++ int devnull = open("/dev/null", O_RDWR); ++ if (devnull >= 0) { ++ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2); ++ if (devnull > 2) close(devnull); ++ } ++ char *argv[] = { path, (char *)mode, dur, ++ (char *)(comment ? comment : ""), NULL }; ++ execvp(argv[0], argv); ++ _exit(127); ++ } else if (pid > 0) { ++ waitpid(pid, NULL, 0); ++ } ++} ++ ++/* ===== runtime config file (~/.config/ttym/config) ===== ++ * key = value lines; '#' comments. Unknown keys ignored. ++ */ ++ ++static void ++trim(char *s) ++{ ++ char *p = s; ++ while (*p && isspace((unsigned char)*p)) p++; ++ if (p != s) memmove(s, p, strlen(p) + 1); ++ size_t n = strlen(s); ++ while (n > 0 && isspace((unsigned char)s[n-1])) s[--n] = '\0'; ++} ++ ++static void ++ensure_default_config_file(const char *path) ++{ ++ if (access(path, F_OK) == 0) return; ++ char dir[512]; ++ snprintf(dir, sizeof(dir), "%s", path); ++ char *slash = strrchr(dir, '/'); ++ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); } ++ FILE *f = fopen(path, "w"); ++ if (!f) return; ++ fputs( ++"# ttym configuration file\n" ++"#\n" ++"# Location : ~/.config/ttym/config (/etc/ttym.conf is read first, if present)\n" ++"# Syntax : key = value -- one entry per line; '#' begins a comment.\n" ++"# A commented-out or absent key keeps its built-in default.\n" ++"# Command-line flags override anything set in this file.\n" ++"#\n" ++"# Every key below is recognised by this build. Uncomment and edit to taste.\n" ++"# ---------------------------------------------------------------------------\n" ++"\n" ++"# Progress-bar glyphs. Set BOTH or NEITHER -- mismatched display widths\n" ++"# break bar alignment. Multi-byte UTF-8 is fine. These override bar_style.\n" ++"# bar_fill = >\n" ++"# bar_empty = -\n" ++"\n" ++"# Named progress-bar style. One of:\n" ++"# unicode ascii hash dots line block arrow minimal\n" ++"# Leave unset to auto-detect (unicode on a UTF-8 locale, otherwise ascii).\n" ++"# bar_style = unicode\n" ++"\n" ++"# Reverse-video flash during the completion alert. on | off\n" ++"# flash = on\n" ++"\n" ++"# Persistent alert: keep ringing the bell until a key is pressed\n" ++"# (foreground runs only). on | off\n" ++"# alert_persist = on\n" ++"\n" ++"# Desktop-notification title.\n" ++"# title = Timer\n" ++"\n" ++"# Desktop-notification command run when a countdown finishes. Tokens are\n" ++"# split on whitespace; {title}/{msg} placeholders expand. Empty disables.\n" ++"# notify_cmd = notify-send -u critical {title} {msg}\n" ++"\n" ++"# Sound played when a countdown finishes. Leave unset to auto-detect a\n" ++"# player (paplay/aplay/mpv/ffplay) and play a default system sound.\n" ++"# Tokens are split on whitespace; {title}/{msg} placeholders expand.\n" ++"# sound_cmd = paplay /usr/share/sounds/freedesktop/stereo/complete.oga\n", ++ f); ++ fclose(f); ++ chmod(path, 0600); ++} ++ ++static void ++load_config_file(Config *c, const char *path) ++{ ++ FILE *f = fopen(path, "r"); ++ if (!f) return; ++ char line[1024]; ++ while (fgets(line, sizeof(line), f)) { ++ char *hash = strchr(line, '#'); ++ if (hash) *hash = '\0'; ++ char *eq = strchr(line, '='); ++ if (!eq) continue; ++ *eq = '\0'; ++ char *k = line, *v = eq + 1; ++ trim(k); trim(v); ++ if (!*k) continue; ++ if (!strcmp(k, "bar_fill")) snprintf(c->bar_fill, sizeof(c->bar_fill), "%s", v); ++ else if (!strcmp(k, "bar_empty")) snprintf(c->bar_empty, sizeof(c->bar_empty), "%s", v); ++ else if (!strcmp(k, "bar_style")) snprintf(c->bar_style, sizeof(c->bar_style), "%s", v); ++ else if (!strcmp(k, "flash")) c->flash = parse_bool_str(v); ++ else if (!strcmp(k, "alert_persist")) c->alert_persist = parse_bool_str(v); ++ else if (!strcmp(k, "title")) snprintf(c->title, sizeof(c->title), "%s", v); ++ else if (!strcmp(k, "notify_cmd")) snprintf(c->notify_cmd, sizeof(c->notify_cmd), "%s", v); ++ else if (!strcmp(k, "sound_cmd")) snprintf(c->sound_cmd, sizeof(c->sound_cmd), "%s", v); ++ } ++ fclose(f); ++} ++ ++static void ++load_config(Config *c) ++{ ++ load_config_file(c, "/etc/ttym.conf"); ++ char path[512]; ++ if (build_config_path(path, sizeof(path), "config") == 0) { ++ ensure_default_config_file(path); ++ load_config_file(c, path); ++ } ++} ++ ++/* ===== bar styles ===== */ ++ ++typedef struct { const char *name, *fill, *empty; } BarStyle; ++ ++static const BarStyle bar_styles[] = { ++ { "unicode", "\xe2\x96\x88", "\xe2\x96\x91" }, /* U+2588 U+2591 */ ++ { "ascii", "=", "-" }, ++ { "hash", "#", "." }, ++ { "dots", "\xe2\x80\xa2", "\xc2\xb7" }, /* U+2022 U+00B7 */ ++ { "line", "\xe2\x94\x81", "\xe2\x94\x80" }, /* U+2501 U+2500 */ ++ { "block", "\xe2\x96\x93", "\xe2\x96\x91" }, /* U+2593 U+2591 */ ++ { "arrow", "\xe2\x96\xb6", "\xe2\x96\xb7" }, /* U+25B6 U+25B7 */ ++ { "minimal", "", "" }, ++ { NULL, NULL, NULL } ++}; ++ ++static const BarStyle * ++find_bar_style(const char *name) ++{ ++ if (!name || !*name) return NULL; ++ for (int i = 0; bar_styles[i].name; i++) ++ if (!strcmp(bar_styles[i].name, name)) return &bar_styles[i]; ++ return NULL; ++} ++ ++static int ++is_utf8(void) ++{ ++ const char *l = setlocale(LC_CTYPE, ""); ++ if (!l) return 0; ++ return strstr(l, "UTF-8") || strstr(l, "utf8") || strstr(l, "utf-8"); ++} ++ ++static void ++resolve_bar(const Config *c, const char **fill, const char **empty, int *minimal) ++{ ++ *minimal = 0; ++ const char *name = (c->bar_style[0]) ? c->bar_style : BAR_STYLE; ++ const BarStyle *s = find_bar_style(name); ++ if (!s) s = find_bar_style(is_utf8() ? "unicode" : "ascii"); ++ if (s && !strcmp(s->name, "minimal")) { ++ *minimal = 1; ++ *fill = " "; *empty = " "; ++ return; ++ } ++ *fill = s ? s->fill : BAR_FILL; ++ *empty = s ? s->empty : BAR_EMPTY; ++} ++ + /* ===== usage ===== + * Patches MUST update this in lockstep with new flags so -h always + * reflects the build. +@@ -236,17 +999,25 @@ + { + fprintf(f, + "usage:\n" +-" timer [FLAGS] DURATION countdown\n" +-" timer [FLAGS] stopwatch\n" ++" timer [FLAGS] DURATION [MESSAGE...] countdown\n" ++" timer [FLAGS] stopwatch\n" ++" timer -r [-n N] [--raw] read log\n" ++" timer --stats [today|week|month|all] stats (default: today)\n" + "\n" + "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" + "FLAGS:\n" ++" -c TEXT comment for log (+tag tokens are extracted)\n" + " -q no sound\n" + " -s silent\n" ++" --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n" ++" --no-persist skip alert-until-keypress loop\n" ++" --flash on|off terminal flash during alert loop\n" + " -- end of options\n" + " -h help\n" + "\n" +-"controls: [space] pause/resume [q | Ctrl+C] quit\n"); ++"controls: [space] pause/resume [q | Ctrl+C] quit\n" ++"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n" ++"config: ~/.config/ttym/config (auto-generated on first run)\n"); + } + + /* ===== run loop ===== */ +@@ -259,6 +1030,14 @@ + int barw = cols - 40; + if (barw < 10) barw = 10; + ++ const char *fill_glyph, *empty_glyph; ++ int minimal = 0; ++ resolve_bar(cfg, &fill_glyph, &empty_glyph, &minimal); ++ /* explicit per-glyph overrides from the runtime config file ++ * (bar_fill / bar_empty) win over the named --bar style. */ ++ if (cfg->bar_fill[0]) fill_glyph = cfg->bar_fill; ++ if (cfg->bar_empty[0]) empty_glyph = cfg->bar_empty; ++ + ttyfd = open("/dev/tty", O_RDONLY); + if (ttyfd >= 0 && isatty(ttyfd)) { + if (tcgetattr(ttyfd, &oldtios) == 0) { +@@ -280,6 +1059,9 @@ + if (out_tty) + fputs("\033[?25l\033[?7l", stdout); + ++ run_hook("on_start", stopwatch ? "stopwatch" : "countdown", ++ stopwatch ? 0 : total, cfg->comment); ++ + long start_ms = now_ms(); + long pause_accum = 0; + long pause_start = 0; +@@ -300,7 +1082,8 @@ + draw_stopwatch(elapsed_ms, paused); + } else { + if (out_tty) +- draw_countdown(total, elapsed_ms, paused, barw); ++ draw_countdown(total, elapsed_ms, paused, barw, ++ fill_glyph, empty_glyph, minimal); + if (!paused && elapsed_ms >= total * 1000L) break; + } + +@@ -356,6 +1139,7 @@ + printf("\r\033[32mSTOP %s%*s\033[0m\n", el, 30, ""); + else + printf("STOP %s\n", el); ++ run_hook("on_quit", "stopwatch", elapsed_s, cfg->comment); + } else if (user_quit) { + char el[24]; + fmt_clock(elapsed_s, el, sizeof(el)); +@@ -363,6 +1147,7 @@ + printf("\r\033[33mQUIT at %s%*s\033[0m\n", el, 30, ""); + else + printf("QUIT at %s\n", el); ++ run_hook("on_quit", "countdown", elapsed_s, cfg->comment); + } else { + char tb[24]; + fmt_clock(total, tb, sizeof(tb)); +@@ -370,14 +1155,21 @@ + printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, ""); + else + printf("DONE %s 100%%\n", tb); ++ run_hook("on_done", "countdown", total, cfg->comment); ++ if (!cfg->silent) notify_and_sound(cfg); + if (!cfg->silent && !cfg->quiet && out_tty) { + fputc('\a', stdout); + fflush(stdout); + sleep_ms(50); + } ++ if (!cfg->silent && cfg->alert_persist && is_foreground()) { ++ interrupted = 0; ++ alert_loop(cfg->flash); ++ } + } + + (void)cfg; ++ write_log(elapsed_s, cfg->comment); + return user_quit ? 130 : 0; + } + +@@ -386,7 +1178,21 @@ + int + main(int argc, char **argv) + { ++ migrate_legacy_dir(); ++ ++ if (argc >= 2 && !strcmp(argv[1], "-r")) ++ return cmd_read(argc - 2, argv + 2); ++ if (argc >= 2 && !strcmp(argv[1], "--stats")) ++ return cmd_stats(argc - 2, argv + 2); ++ + Config cfg = {0}; ++ cfg.alert_persist = ALERT_PERSIST; ++ cfg.flash = FLASH; ++ snprintf(cfg.title, sizeof(cfg.title), "%s", TITLE ? TITLE : ""); ++ snprintf(cfg.notify_cmd, sizeof(cfg.notify_cmd), "%s", NOTIFY_CMD ? NOTIFY_CMD : ""); ++ snprintf(cfg.sound_cmd, sizeof(cfg.sound_cmd), "%s", SOUND_CMD ? SOUND_CMD : ""); ++ snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up"); ++ load_config(&cfg); + + /* Pre-strip long-form flags before getopt. Stop at "--". */ + int new_argc = 0; +@@ -399,15 +1205,32 @@ + new_argv[new_argc++] = argv[i]; + continue; + } ++ if (!past_dd && !strcmp(argv[i], "--no-persist")) { ++ cfg.alert_persist = 0; ++ continue; ++ } ++ if (!past_dd && !strncmp(argv[i], "--flash=", 8)) { ++ cfg.flash = parse_bool_str(argv[i] + 8); ++ continue; ++ } ++ if (!past_dd && !strcmp(argv[i], "--flash") && i + 1 < argc) { ++ cfg.flash = parse_bool_str(argv[++i]); ++ continue; ++ } ++ if (!past_dd && !strcmp(argv[i], "--bar") && i + 1 < argc) { ++ snprintf(cfg.bar_style, sizeof(cfg.bar_style), "%s", argv[++i]); ++ continue; ++ } + new_argv[new_argc++] = argv[i]; + } + new_argv[new_argc] = NULL; + + int opt; +- while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) { ++ while ((opt = getopt(new_argc, new_argv, "qsc:h")) != -1) { + switch (opt) { + case 'q': cfg.quiet = 1; break; + case 's': cfg.silent = 1; break; ++ case 'c': snprintf(cfg.comment, sizeof(cfg.comment), "%s", optarg); break; + case 'h': usage(stdout); free(new_argv); return 0; + default: usage(stderr); free(new_argv); return 1; + } +@@ -424,6 +1247,16 @@ + free(new_argv); + return 1; + } ++ if (rem_argc > 1) { ++ size_t off = 0; ++ cfg.msg[0] = '\0'; ++ for (int i = 1; i < rem_argc && off < sizeof(cfg.msg) - 2; i++) { ++ int n = snprintf(cfg.msg + off, sizeof(cfg.msg) - off, ++ "%s%s", (i > 1 ? " " : ""), rem_argv[i]); ++ if (n < 0) break; ++ off += (size_t)n; ++ } ++ } + } + + int rc = run(total, stopwatch, &cfg); diff --git a/patches/ttym-hooks-2.0.diff b/patches/ttym-hooks-2.0.diff new file mode 100644 index 0000000..dbb4f6a --- /dev/null +++ b/patches/ttym-hooks-2.0.diff @@ -0,0 +1,127 @@ +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 <stdlib.h> + #include <string.h> + #include <sys/ioctl.h> ++#include <sys/stat.h> ++#include <sys/wait.h> + #include <termios.h> + #include <time.h> + #include <unistd.h> +@@ -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-logging-2.0.diff b/patches/ttym-logging-2.0.diff new file mode 100644 index 0000000..d654d50 --- /dev/null +++ b/patches/ttym-logging-2.0.diff @@ -0,0 +1,447 @@ +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 <stdlib.h> + #include <string.h> + #include <sys/ioctl.h> ++#include <sys/stat.h> + #include <termios.h> + #include <time.h> + #include <unistd.h> +@@ -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: ts<TAB>seconds<TAB>comment) ===== */ ++ ++static int ++build_config_path(char *buf, size_t bufsz, const char *suffix) ++{ ++ const char *home = getenv("HOME"); ++ const char *xdg = getenv("XDG_CONFIG_HOME"); ++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, suffix); ++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, suffix); ++ else return -1; ++ return 0; ++} ++ ++static void ++migrate_legacy_dir(void) ++{ ++ const char *home = getenv("HOME"); ++ const char *xdg = getenv("XDG_CONFIG_HOME"); ++ char base[512]; ++ if (xdg && *xdg) snprintf(base, sizeof(base), "%s", xdg); ++ else if (home && *home) snprintf(base, sizeof(base), "%s/.config", home); ++ else return; ++ char old_dir[600], new_dir[600]; ++ snprintf(old_dir, sizeof(old_dir), "%s/timer", base); ++ snprintf(new_dir, sizeof(new_dir), "%s/ttym", base); ++ struct stat sb; ++ if (stat(new_dir, &sb) == 0) return; ++ if (stat(old_dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) return; ++ if (rename(old_dir, new_dir) != 0) return; ++ char old_log[700], new_log[700]; ++ snprintf(old_log, sizeof(old_log), "%s/timer.log", new_dir); ++ snprintf(new_log, sizeof(new_log), "%s/ttym.log", new_dir); ++ if (stat(old_log, &sb) == 0) rename(old_log, new_log); ++} ++ ++static int ++mkdir_p(const char *path, mode_t mode) ++{ ++ char buf[1024]; ++ snprintf(buf, sizeof(buf), "%s", path); ++ for (char *p = buf + 1; *p; p++) { ++ if (*p == '/') { ++ *p = '\0'; ++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1; ++ *p = '/'; ++ } ++ } ++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1; ++ return 0; ++} ++ ++static void ++iso_local(char *buf, size_t bufsz) ++{ ++ time_t t = time(NULL); ++ struct tm tm; ++ localtime_r(&t, &tm); ++ strftime(buf, bufsz, "%Y-%m-%d %H:%M:%S", &tm); ++} ++ ++static void ++sanitize_field(char *s) ++{ ++ for (; *s; s++) ++ if (*s == '\t' || *s == '\n' || *s == '\r') *s = ' '; ++} ++ ++static int ++is_old_format(const char *line) ++{ ++ return strlen(line) >= 11 && line[10] == 'T'; ++} ++ ++static void ++migrate_line(const char *line, char *out, size_t outsz) ++{ ++ if (strlen(line) < 19) { snprintf(out, outsz, "%s", line); return; } ++ char date[11], tpart[9]; ++ memcpy(date, line, 10); date[10] = '\0'; ++ memcpy(tpart, line + 11, 8); tpart[8] = '\0'; ++ const char *tab = strchr(line, '\t'); ++ if (!tab) { snprintf(out, outsz, "%s %s\n", date, tpart); return; } ++ snprintf(out, outsz, "%s %s%s", date, tpart, tab); ++} ++ ++static void ++migrate_log_if_needed(const char *path) ++{ ++ FILE *f = fopen(path, "r"); ++ if (!f) return; ++ char probe[2048]; ++ int needs = 0; ++ while (fgets(probe, sizeof(probe), f)) ++ if (is_old_format(probe)) { needs = 1; break; } ++ if (!needs) { fclose(f); return; } ++ rewind(f); ++ char tmp[1024]; ++ snprintf(tmp, sizeof(tmp), "%s.tmp", path); ++ FILE *out = fopen(tmp, "w"); ++ if (!out) { fclose(f); return; } ++ char line[2048]; ++ while (fgets(line, sizeof(line), f)) { ++ if (is_old_format(line)) { ++ char conv[2048]; ++ migrate_line(line, conv, sizeof(conv)); ++ fputs(conv, out); ++ } else fputs(line, out); ++ } ++ fclose(f); ++ fclose(out); ++ rename(tmp, path); ++} ++ ++static void ++write_log(long duration_s, const char *comment) ++{ ++ char path[512]; ++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return; ++ char dir[512]; ++ snprintf(dir, sizeof(dir), "%s", path); ++ char *slash = strrchr(dir, '/'); ++ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); } ++ migrate_log_if_needed(path); ++ int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600); ++ if (fd < 0) return; ++ char ts[32]; iso_local(ts, sizeof(ts)); ++ char safe[512]; snprintf(safe, sizeof(safe), "%s", comment ? comment : ""); ++ sanitize_field(safe); ++ char line[1024]; ++ int n = snprintf(line, sizeof(line), "%s\t%ld\t%s\n", ts, duration_s, safe); ++ if (n > 0) { ssize_t w = write(fd, line, (size_t)n); (void)w; } ++ close(fd); ++} ++ ++static int ++read_log_lines(char ***out_lines, size_t *out_n) ++{ ++ char path[512]; ++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return -1; ++ migrate_log_if_needed(path); ++ FILE *f = fopen(path, "r"); ++ if (!f) { *out_lines = NULL; *out_n = 0; return 0; } ++ size_t cap = 64, n = 0; ++ char **lines = malloc(cap * sizeof(char *)); ++ if (!lines) { fclose(f); return -1; } ++ char buf[2048]; ++ while (fgets(buf, sizeof(buf), f)) { ++ size_t l = strlen(buf); ++ if (l && buf[l-1] == '\n') buf[l-1] = '\0'; ++ if (!*buf) continue; ++ if (n >= cap) { ++ cap *= 2; ++ char **nl = realloc(lines, cap * sizeof(char *)); ++ if (!nl) break; ++ lines = nl; ++ } ++ lines[n++] = strdup(buf); ++ } ++ fclose(f); ++ *out_lines = lines; ++ *out_n = n; ++ return 0; ++} ++ ++static void ++free_lines(char **lines, size_t n) ++{ ++ if (!lines) return; ++ for (size_t i = 0; i < n; i++) free(lines[i]); ++ free(lines); ++} ++ ++static int ++split_log_line(char *line, char **ts, long *secs, char **comment) ++{ ++ char *t1 = strchr(line, '\t'); if (!t1) return -1; ++ *t1 = '\0'; ++ char *t2 = strchr(t1 + 1, '\t'); if (!t2) return -1; ++ *t2 = '\0'; ++ char *end; ++ long v = strtol(t1 + 1, &end, 10); ++ if (end == t1 + 1) return -1; ++ *ts = line; *secs = v; *comment = t2 + 1; ++ return 0; ++} ++ ++static void ++fmt_long(long s, char *buf, size_t bufsz) ++{ ++ if (s < 0) s = 0; ++ long d = s / 86400; s %= 86400; ++ long h = s / 3600; s %= 3600; ++ long m = s / 60; ++ long sec = s % 60; ++ snprintf(buf, bufsz, "%02ld %02ld:%02ld:%02ld", d, h, m, sec); ++} ++ ++static int ++cmd_read(int argc, char **argv) ++{ ++ int n_tail = 0, raw = 0; ++ for (int i = 0; i < argc; i++) { ++ if (!strcmp(argv[i], "-n") && i + 1 < argc) { ++ n_tail = atoi(argv[++i]); ++ if (n_tail < 0) n_tail = 0; ++ } else if (!strcmp(argv[i], "--raw")) { ++ raw = 1; ++ } else { ++ fprintf(stderr, "timer -r: unknown arg: %s\n", argv[i]); ++ return 1; ++ } ++ } ++ char **lines = NULL; size_t n = 0; ++ if (read_log_lines(&lines, &n) < 0) return 1; ++ if (n == 0) { free_lines(lines, n); return 0; } ++ size_t start = 0; ++ if (n_tail > 0 && (size_t)n_tail < n) start = n - n_tail; ++ if (raw) { ++ for (size_t i = start; i < n; i++) puts(lines[i]); ++ } else { ++ printf("%-19s %-11s %s\n", "TIMESTAMP", "DURATION", "COMMENT"); ++ for (size_t i = start; i < n; i++) { ++ char *copy = strdup(lines[i]); ++ char *ts, *cmt; long secs; ++ if (split_log_line(copy, &ts, &secs, &cmt) == 0) { ++ char dur[32]; ++ fmt_long(secs, dur, sizeof(dur)); ++ printf("%-19s %-11s %s\n", ts, dur, cmt); ++ } else { ++ puts(lines[i]); ++ } ++ free(copy); ++ } ++ } ++ free_lines(lines, n); ++ return 0; ++} ++ ++static int ++parse_log_ts(const char *ts, struct tm *out) ++{ ++ memset(out, 0, sizeof(*out)); ++ if (sscanf(ts, "%d-%d-%d %d:%d:%d", ++ &out->tm_year, &out->tm_mon, &out->tm_mday, ++ &out->tm_hour, &out->tm_min, &out->tm_sec) != 6) return -1; ++ out->tm_year -= 1900; ++ out->tm_mon -= 1; ++ out->tm_isdst = -1; ++ return 0; ++} ++ ++typedef struct TagAcc { ++ char tag[128]; ++ long secs; ++ long count; ++ struct TagAcc *next; ++} TagAcc; ++ ++static void ++tag_add(TagAcc **head, const char *tag, long secs) ++{ ++ for (TagAcc *a = *head; a; a = a->next) ++ if (!strcmp(a->tag, tag)) { a->secs += secs; a->count++; return; } ++ TagAcc *n = calloc(1, sizeof(*n)); ++ if (!n) return; ++ snprintf(n->tag, sizeof(n->tag), "%s", tag); ++ n->secs = secs; ++ n->count = 1; ++ n->next = *head; ++ *head = n; ++} ++ ++static void ++extract_tags(const char *comment, long secs, TagAcc **head) ++{ ++ const char *p = comment; ++ int found = 0; ++ while (*p) { ++ int at_boundary = (p == comment) || isspace((unsigned char)*(p-1)); ++ if (*p == '+' && at_boundary) { ++ const char *s = p + 1, *e = s; ++ while (*e && !isspace((unsigned char)*e)) e++; ++ if (e > s) { ++ char buf[128]; ++ size_t len = (size_t)(e - s); ++ if (len >= sizeof(buf)) len = sizeof(buf) - 1; ++ memcpy(buf, s, len); buf[len] = '\0'; ++ tag_add(head, buf, secs); ++ found = 1; ++ } ++ p = e; ++ } else p++; ++ } ++ if (!found) tag_add(head, "(untagged)", secs); ++} ++ ++static int ++cmd_stats(int argc, char **argv) ++{ ++ const char *period = (argc > 0) ? argv[0] : "today"; ++ time_t now = time(NULL); ++ struct tm now_tm; localtime_r(&now, &now_tm); ++ time_t cutoff = 0; ++ if (!strcmp(period, "all")) cutoff = 0; ++ else if (!strcmp(period, "today")) { ++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; ++ cutoff = mktime(&t); ++ } else if (!strcmp(period, "week")) { ++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; ++ int back = (t.tm_wday == 0) ? 6 : t.tm_wday - 1; ++ t.tm_mday -= back; ++ cutoff = mktime(&t); ++ } else if (!strcmp(period, "month")) { ++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; t.tm_mday = 1; ++ cutoff = mktime(&t); ++ } else { ++ fprintf(stderr, "stats: unknown period '%s' (today|week|month|all)\n", period); ++ return 1; ++ } ++ char **lines = NULL; size_t n = 0; ++ if (read_log_lines(&lines, &n) < 0) return 1; ++ long total = 0, count = 0; ++ TagAcc *tags = NULL; ++ for (size_t i = 0; i < n; i++) { ++ char *copy = strdup(lines[i]); ++ char *ts, *cmt; long secs; ++ if (split_log_line(copy, &ts, &secs, &cmt) != 0) { free(copy); continue; } ++ struct tm tm; ++ if (parse_log_ts(ts, &tm) != 0) { free(copy); continue; } ++ time_t entry = mktime(&tm); ++ if (cutoff && entry < cutoff) { free(copy); continue; } ++ total += secs; count++; ++ extract_tags(cmt, secs, &tags); ++ free(copy); ++ } ++ free_lines(lines, n); ++ char tot[32]; fmt_long(total, tot, sizeof(tot)); ++ printf("\033[1m== timer stats: %s ==\033[0m\n", period); ++ printf("entries: %ld total: %s\n\n", count, tot); ++ int tag_n = 0; ++ for (TagAcc *a = tags; a; a = a->next) tag_n++; ++ if (tag_n > 0) { ++ TagAcc **arr = malloc(tag_n * sizeof(*arr)); ++ int i = 0; ++ for (TagAcc *a = tags; a; a = a->next) arr[i++] = a; ++ for (int x = 0; x < tag_n - 1; x++) ++ for (int y = x + 1; y < tag_n; y++) ++ if (arr[y]->secs > arr[x]->secs) { ++ TagAcc *t = arr[x]; arr[x] = arr[y]; arr[y] = t; ++ } ++ printf("%-24s %-11s %5s\n", "TAG", "DURATION", "COUNT"); ++ for (int z = 0; z < tag_n; z++) { ++ char d[32]; fmt_long(arr[z]->secs, d, sizeof(d)); ++ printf("%-24s %-11s %5ld\n", arr[z]->tag, d, arr[z]->count); ++ } ++ free(arr); ++ } ++ while (tags) { TagAcc *next = tags->next; free(tags); tags = next; } ++ return 0; ++} ++ + /* ===== 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-notify-2.0.diff b/patches/ttym-notify-2.0.diff new file mode 100644 index 0000000..a80eef3 --- /dev/null +++ b/patches/ttym-notify-2.0.diff @@ -0,0 +1,232 @@ +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 <stdlib.h> + #include <string.h> + #include <sys/ioctl.h> ++#include <sys/wait.h> + #include <termios.h> + #include <time.h> + #include <unistd.h> +@@ -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-persist-alert-2.0.diff b/patches/ttym-persist-alert-2.0.diff new file mode 100644 index 0000000..7e9d64b --- /dev/null +++ b/patches/ttym-persist-alert-2.0.diff @@ -0,0 +1,111 @@ +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; @@ -0,0 +1,338 @@ +.TH TIMER 1 "ttym 2.0" +.SH NAME +timer \- terminal countdown and stopwatch +.SH SYNOPSIS +.B timer +.RB [ \-qs ] +.RB [ \-c +.IR text ] +.RB [ \-\-no\-persist ] +.RB [ \-\-flash +.BR on | off ] +.RB [ \-\-bar +.IR style ] +.RB [ \-\- ] +.RI [ duration ] +.RI [ message +.IR ... ] +.PP +.B timer \-r +.RB [ \-n +.IR N ] +.RB [ \-\-raw ] +.PP +.B timer \-\-stats +.RB [ today | week | month | all ] +.SH DESCRIPTION +.B timer +is a small terminal countdown timer and stopwatch inspired by suckless philosophy. +A single argument is parsed as a duration and the program counts down. +With no duration argument it runs as a stopwatch. +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 +.IR patches/ . +Each section below describing a non\-base feature is annotated with +.RI [patch: name ]; +those options and behaviours are only present if the corresponding +patch has been applied at build time. +.B timer \-h +always reflects the actual build. +.SH OPTIONS +.SS Base +.TP +.B \-q +Suppress the one\-shot completion bell. +Persistent alert loops, if enabled by a patch, still ring the bell. +.TP +.B \-s +Silent. No bell, no notification, no alert loop. +Equivalent to combining every silencing flag. +.TP +.B \-h +Print usage and exit. Reflects the actual build. +.TP +.B \-\- +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. +.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. +.TP +.BR \-\-bar \ \fIstyle\fR +.RI [patch: bar\-styles] +Progress bar style. One of +.BR unicode , +.BR ascii , +.BR hash , +.BR dots , +.BR line , +.BR block , +.BR arrow , +.BR minimal . +With no patch applied the bar uses the compile\-time +.B BAR_FILL +and +.B BAR_EMPTY +glyphs from +.IR config.h . +.SH SUBCOMMANDS +.SS timer \-r [\-n N] [\-\-raw] +.RI [patch: logging] +Read the session log. +.B \-n N +limits output to the last +.I N +entries. +.B \-\-raw +prints the underlying TSV instead of the formatted table, suitable for +piping through +.BR awk (1), +.BR grep (1) +or +.BR sort (1). +.SS timer \-\-stats [\fIperiod\fR] +.RI [patch: logging] +Print aggregate statistics. Period is one of +.BR today +(default), +.BR week , +.BR month , +.BR all . +Entries are summed in total and broken down by +.BI + tag +tokens extracted from comments. Untagged sessions are reported as +.BR (untagged) . +.SH DURATION FORMAT +A duration is either a colon notation or a unit notation. Examples: +.PP +.RS +.nf +25m twenty\-five minutes +90s ninety seconds +2h two hours +1h30m one and a half hours +01:30:00 one and a half hours +25:00 twenty\-five minutes +.fi +.RE +.PP +Colon notation expects +.IR H:M:S +or +.IR M:S . +Unit notation is a concatenation of one or more +.IR Nh , +.IR Nm , +.IR Ns +groups in any order. +.SH MESSAGE +.RI [patch: notify] +Any positional arguments after +.I duration +are joined into the message used for the one\-shot completion +notification. The message is expanded into the +.B notify_cmd +template via the +.B {msg} +placeholder. +.SH KEYS +While the timer is running: +.TP +.B space +Pause or resume the countdown or stopwatch. +.TP +.B q +Quit immediately. Logs the partial session if logging is enabled. +.TP +.B Ctrl+C +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. +.PP +The loop runs only when +.B timer +is in the foreground. +Background jobs +.RB ( timer +.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" . +.SH HOOKS +.RI [patch: hooks] +On state transitions +.B timer +invokes optional executables under +.IR ~/.config/ttym/hooks/ : +.TP +.B on_start +Called when a session begins. +.TP +.B on_done +Called when a countdown completes normally. +.TP +.B on_quit +Called when a session is interrupted with +.BR q +or +.BR Ctrl+C , +or when a stopwatch is stopped. +.PP +Each hook is executed synchronously with three arguments: +.IR mode , +.IR duration_seconds , +.IR comment . +The first is +.BR countdown +or +.BR stopwatch ; +the others come from the running session. Hooks must be marked +executable. Standard streams are redirected to +.IR /dev/null . +.SH FILES +.TP +.I ~/.config/ttym/config +.RI [patch: config\-file] +Runtime configuration. Auto\-generated on first run with all keys +commented out. +.TP +.I ~/.config/ttym/ttym.log +.RI [patch: logging] +Session log. TSV with three fields per line: +.RS +.PP +.RS +.nf +YYYY\-MM\-DD HH:MM:SS<TAB>SECONDS<TAB>COMMENT +.fi +.RE +.PP +Designed to be processed with +.BR awk (1) +and +.BR grep (1) +rather than re\-rendered by the binary. +.RE +.TP +.I ~/.config/ttym/hooks/ +.RI [patch: hooks] +Directory containing the +.BR on_start , +.BR on_done , +.B on_quit +executables described in +.BR HOOKS . +.SH EXAMPLES +A twenty\-five minute work session, tagged for later stats: +.PP +.RS +.nf +timer \-c "+work coding" 25m +.fi +.RE +.PP +A countdown that pops a notification with custom text: +.PP +.RS +.nf +timer 10m Lunch +.fi +.RE +.PP +Total hours logged this week: +.PP +.RS +.nf +timer \-r \-\-raw | awk \-F'\\t' '{s+=$2} END {printf "%dh\\n", s/3600}' +.fi +.RE +.PP +Count +.BR +work +sessions ever: +.PP +.RS +.nf +timer \-r \-\-raw | grep '+work' | wc \-l +.fi +.RE +.PP +Stopwatch with no notifications: +.PP +.RS +.nf +timer \-s +.fi +.RE +.SH DESIGN +The binary does one thing: time intervals and, optionally, log them. +Reports +.RB ( "timer \-r" , +.BR "timer \-\-stats" ) +are read\-only views over the same TSV log. For any other view, pipe +.B "timer \-r \-\-raw" +through +.BR awk (1), +.BR grep (1) +or +.BR sort (1). +No daemon, no IPC, no plugin system, no embedded TUI. Hooks are +git\-style: executables you write yourself. +.PP +Compile\-time options live in +.IR config.h ; +runtime overrides (when the +.B config\-file +patch is applied) live in +.IR ~/.config/ttym/config . +.SH AUTHORS +.B timer +is maintained by \(/Lukasz Kasprzak. +Distributed under the MIT license; see +.B LICENSE +in the source tree. +.SH SEE ALSO +.BR awk (1), +.BR grep (1), +.BR notify\-send (1), +.BR paplay (1), +.BR patch (1) @@ -0,0 +1,432 @@ +/* ttym - countdown / stopwatch in a single C file. + * See LICENSE file for copyright and license details. + */ +#define _POSIX_C_SOURCE 200809L +#include <ctype.h> +#include <errno.h> +#include <fcntl.h> +#include <poll.h> +#include <signal.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <termios.h> +#include <time.h> +#include <unistd.h> + +#include "config.h" + +/* ===== types & globals ===== */ + +typedef struct { + int quiet; /* -q: suppress completion bell */ + int silent; /* -s: no bell, no extras */ +} Config; + +static struct termios oldtios; +static int ttyfd = -1; +static int rawset = 0; +static volatile sig_atomic_t interrupted = 0; + +/* exposed for patches that read the final elapsed seconds at exit */ +static long elapsed_s = 0; + +/* nonzero once stdout is confirmed a tty; gates cursor/redraw escapes */ +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) +{ + struct timespec ts = { ms / 1000, (long)(ms % 1000) * 1000000L }; + nanosleep(&ts, NULL); +} + +static long +now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1000L + ts.tv_nsec / 1000000L; +} + +static int +term_cols(void) +{ + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) + return ws.ws_col; + return 80; +} + +static void +on_signal(int sig) +{ + (void)sig; + interrupted = 1; +} + +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); +} + +/* ===== duration parsing ===== + * accepts: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00 + */ + +static int +parse_uint(const char *s, size_t n, long *out) +{ + long v = 0; + if (n == 0) return -1; + for (size_t i = 0; i < n; i++) { + if (!isdigit((unsigned char)s[i])) return -1; + v = v * 10 + (s[i] - '0'); + if (v > 1000000) return -1; + } + *out = v; + return 0; +} + +static long +parse_duration(const char *s) +{ + size_t len = strlen(s); + if (len == 0) return -1; + + if (strchr(s, ':')) { + /* Split on ':' into 2 or 3 fields, then parse each with + * parse_uint. That rejects empty fields, signs and + * whitespace, and bounds every component -- so unlike a + * raw sscanf("%ld") this cannot overflow on huge input. + */ + const char *fld[3]; + size_t flen[3]; + int nf = 0; + size_t fstart = 0; + for (size_t i = 0; i <= len; i++) { + if (i != len && s[i] != ':') continue; + if (nf == 3) return -1; + fld[nf] = s + fstart; + flen[nf] = i - fstart; + nf++; + fstart = i + 1; + } + long v[3] = { 0, 0, 0 }; + for (int k = 0; k < nf; k++) + if (parse_uint(fld[k], flen[k], &v[k]) < 0) + return -1; + long total; + 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) { + 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; + long v; + if (parse_uint(s + start, i - start, &v) < 0) return -1; + switch (s[i]) { + case 'h': total += v * 3600; break; + case 'm': total += v * 60; break; + case 's': total += v; break; + } + have_any = 1; + i++; + start = i; + } + if (!have_any || start != len) return -1; + return total > 0 ? total : -1; +} + +/* ===== formatting ===== */ + +static void +fmt_clock(long s, char *buf, size_t bufsz) +{ + long h = s / 3600; s %= 3600; + long m = s / 60; long sec = s % 60; + if (h > 0) snprintf(buf, bufsz, "%02ld:%02ld:%02ld", h, m, sec); + else snprintf(buf, bufsz, "%02ld:%02ld", m, sec); +} + +/* ===== drawing ===== */ + +static void +draw_countdown(long total, long elapsed_ms, int paused, int barw) +{ + if (total <= 0) return; + long total_ms = total * 1000L; + if (elapsed_ms < 0) elapsed_ms = 0; + if (elapsed_ms > total_ms) elapsed_ms = total_ms; + long es = elapsed_ms / 1000; + long remaining = total - es; + long pct = (elapsed_ms * 100) / total_ms; + int fill = (int)((long)barw * elapsed_ms / total_ms); + if (fill > barw) fill = barw; + int empty = barw - fill; + + char rem[24], el[24]; + fmt_clock(remaining, rem, sizeof(rem)); + fmt_clock(es, el, sizeof(el)); + + const char *color = paused ? "\033[36m" : "\033[33m"; + 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); + printf("] %3ld%% %s", pct, status); + fflush(stdout); +} + +static void +draw_stopwatch(long elapsed_ms, int paused) +{ + long es = elapsed_ms / 1000; + char el[24]; + 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); + fflush(stdout); +} + +/* ===== usage ===== + * Patches MUST update this in lockstep with new flags so -h always + * reflects the build. + */ + +static void +usage(FILE *f) +{ + fprintf(f, +"usage:\n" +" timer [FLAGS] DURATION countdown\n" +" timer [FLAGS] stopwatch\n" +"\n" +"DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" +"FLAGS:\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"); +} + +/* ===== run loop ===== */ + +static int +run(long total, int stopwatch, const Config *cfg) +{ + out_tty = isatty(STDOUT_FILENO); + int cols = term_cols(); + int barw = cols - 40; + if (barw < 10) barw = 10; + + ttyfd = open("/dev/tty", O_RDONLY); + if (ttyfd >= 0 && isatty(ttyfd)) { + if (tcgetattr(ttyfd, &oldtios) == 0) { + struct termios raw = oldtios; + raw.c_lflag &= ~(ICANON | ECHO); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + if (tcsetattr(ttyfd, TCSANOW, &raw) == 0) + rawset = 1; + } + } + + atexit(restore_tty); + struct sigaction sa = {0}; + sa.sa_handler = on_signal; + sigaction(SIGINT, &sa, NULL); + sigaction(SIGTERM, &sa, NULL); + + if (out_tty) + fputs("\033[?25l\033[?7l", stdout); + + long start_ms = now_ms(); + long pause_accum = 0; + long pause_start = 0; + int paused = 0; + int user_quit = 0; + + 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; + + if (stopwatch) { + if (out_tty) + draw_stopwatch(elapsed_ms, paused); + } else { + if (out_tty) + draw_countdown(total, elapsed_ms, paused, barw); + if (!paused && elapsed_ms >= total * 1000L) break; + } + + long frame_end = now_ms() + 100; + for (;;) { + if (interrupted) break; + long rem_frame = frame_end - now_ms(); + if (rem_frame <= 0) break; + if (ttyfd < 0) { sleep_ms((int)rem_frame); break; } + + struct pollfd pfd = { .fd = ttyfd, .events = POLLIN }; + int pr = poll(&pfd, 1, (int)rem_frame); + if (pr > 0 && (pfd.revents & POLLIN)) { + char c; + 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; + } + } else if (c == 'q' || c == 'Q') { + user_quit = 1; + goto done; + } + } + } else if (pr == 0) { + break; + } else { + sleep_ms((int)rem_frame); + break; + } + } + } +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; + } + + 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, ""); + 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, ""); + 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, ""); + else + printf("DONE %s 100%%\n", tb); + if (!cfg->silent && !cfg->quiet && out_tty) { + fputc('\a', stdout); + fflush(stdout); + sleep_ms(50); + } + } + + (void)cfg; + return user_quit ? 130 : 0; +} + +/* ===== main ===== */ + +int +main(int argc, char **argv) +{ + Config cfg = {0}; + + /* 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; + for (int i = 0; i < argc; i++) { + if (!past_dd && !strcmp(argv[i], "--")) { + past_dd = 1; + new_argv[new_argc++] = 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) { + 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; + } + } + int rem_argc = new_argc - optind; + char **rem_argv = new_argv + optind; + + int stopwatch = (rem_argc < 1); + long total = 0; + if (!stopwatch) { + 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; +} |
