aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 19:02:35 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 19:02:35 +0200
commit8f99f1650e4a7363af1d83190b4082fb72a9f73f (patch)
tree86ed3b714c376e95f98d33d7572e76d6b43f06d3
parent2f6b29abd0d766ef477eb29c5a7bf6c1e88e9630 (diff)
downloadttym-8f99f1650e4a7363af1d83190b4082fb72a9f73f.tar.gz
ttym-8f99f1650e4a7363af1d83190b4082fb72a9f73f.zip
ttym 3.0: fix terminal and config bugs, fold terminal-only patches into base
Correctness fixes: - restore the terminal on SIGQUIT and SIGHUP. atexit never runs on an uncaught fatal signal, so Ctrl+\ left the cursor hidden and autowrap disabled. - drop the unconditional newline in restore_tty. Every run emitted a stray blank line, including piped output. - treat EINTR as an interruption rather than a poll() failure, which delayed Ctrl+C by up to one 100ms frame. config-file patch: - make comment stripping quote-aware so a value may contain '#', and strip one matched pair of quotes. bar_fill = "#" was silently dropped, despite '#' being the glyph of the hash style this build ships. - resolve bar_fill/bar_empty inside resolve_bar and clear them on --bar, so command-line flags beat the config file as documented. Previously config glyphs silently overrode --bar. - report unknown keys, unparseable booleans and malformed lines on stderr. flash = enabled silently meant off. - never write to disk. --dump-config prints a commented template on stdout instead of the binary creating ~/.config/ttym/config on first run. Patch set: - fold persist-alert, flash and bar-styles into the base. The base is now terminal-only: it opens /dev/tty, draws and exits, with no file writes and no subprocesses. Everything still in patches/ changes what the program touches. - seven patches become four; config-file's prerequisites drop from six patches to one (notify), so it is regenerable mechanically. - regenerate every patch. All apply with zero fuzz and compile. Source: - rewrite argv in place rather than into a malloc'd copy (C99 5.1.2.2.1), removing die(), four free() calls and <stdarg.h>. - fold the duplicated elapsed-time expression into elapsed(). - drop dead code: have_any, (void)cfg, and an unreachable branch in parse_duration. - replace hand-counted padding with \033[K. Docs and build: - timer.1: -c is the third hook argument, not the fourth. Add EXIT STATUS (a stopwatch always exits 130 -- it has no natural end) and ENVIRONMENT. Document config precedence and quoting. - wire VERSION into a dist target; it was defined and never used. - track .gitignore, which previously ignored itself, so a fresh clone had no ignore rules. Claude-Session: https://claude.ai/code/session_01APLBs8RB1FcUaC4viVkzbP
-rw-r--r--.gitignore9
-rw-r--r--Makefile12
-rw-r--r--README.md19
-rw-r--r--config.def.h16
-rw-r--r--config.mk2
-rw-r--r--patches/README109
-rw-r--r--patches/ttym-bar-styles-2.0.diff155
-rw-r--r--patches/ttym-config-file-2.0.diff237
-rw-r--r--patches/ttym-config-file-3.0.diff293
-rw-r--r--patches/ttym-flash-2.0.diff113
-rw-r--r--patches/ttym-full-3.0.diff (renamed from patches/ttym-full-2.0.diff)560
-rw-r--r--patches/ttym-hooks-3.0.diff (renamed from patches/ttym-hooks-2.0.diff)55
-rw-r--r--patches/ttym-logging-3.0.diff (renamed from patches/ttym-logging-2.0.diff)77
-rw-r--r--patches/ttym-notify-3.0.diff (renamed from patches/ttym-notify-2.0.diff)77
-rw-r--r--patches/ttym-persist-alert-2.0.diff111
-rw-r--r--timer.1153
-rw-r--r--ttym.c253
17 files changed, 1056 insertions, 1195 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..81b9c00
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+# build artifacts -- regenerate with `make`
+config.h
+*.o
+timer
+ttym-*.tar.gz
+
+# local Claude Code settings
+CLAUDE.md
+.claude/settings.local.json
diff --git a/Makefile b/Makefile
index fd103de..72b5053 100644
--- a/Makefile
+++ b/Makefile
@@ -23,11 +23,19 @@ $(BIN): $(OBJ)
$(CC) -o $@ $(OBJ) $(LDFLAGS)
clean:
- rm -f $(BIN) $(OBJ)
+ rm -f $(BIN) $(OBJ) ttym-$(VERSION).tar.gz
distclean: clean
rm -f config.h
+dist: clean
+ mkdir -p ttym-$(VERSION)
+ cp -R LICENSE Makefile README.md config.def.h config.mk timer.1 ttym.c \
+ patches ttym-$(VERSION)
+ tar -cf ttym-$(VERSION).tar ttym-$(VERSION)
+ gzip ttym-$(VERSION).tar
+ rm -rf ttym-$(VERSION)
+
install: all
mkdir -p $(DESTDIR)$(PREFIX)/bin
cp -f $(BIN) $(DESTDIR)$(PREFIX)/bin/$(BIN)
@@ -40,4 +48,4 @@ uninstall:
rm -f $(DESTDIR)$(PREFIX)/bin/$(BIN)
rm -f $(DESTDIR)$(MANPREFIX)/man1/timer.1
-.PHONY: all clean distclean install uninstall
+.PHONY: all clean distclean dist install uninstall
diff --git a/README.md b/README.md
index a8ebce5..5c0e3ea 100644
--- a/README.md
+++ b/README.md
@@ -2,17 +2,21 @@ ttym - terminal countdown / stopwatch
=====================================
A small countdown timer and stopwatch in C. Single binary, libc-only,
-no daemon. Default build is intentionally minimal:
+no daemon. The base build is terminal-only -- it writes no files and
+spawns no processes:
- T-24:13 +00:47 [>>>>>>>>>>------------------------------] 25%
+ T-24:13 +00:47 [████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 25%
- countdown when given a duration; stopwatch when not
- pause/resume with [space], quit with [q] or Ctrl+C
- - ASCII progress bar
- - one-shot terminal bell on completion
+ - progress bar with eight styles (`--bar`, auto-detected by locale)
+ - alert until keypress on completion (`--no-persist` to disable),
+ with an optional reverse-video flash (`--flash on|off`)
-Anything beyond that is an optional patch under patches/. See
-patches/README for the available set and apply order.
+Every feature that touches the filesystem, runs a subprocess or needs
+an external binary is an optional patch under patches/ -- desktop
+notifications, session logging, hooks and a runtime config file. See
+patches/README for the set and apply order.
Requirements
@@ -54,6 +58,9 @@ Usage
FLAGS:
-q no sound
-s silent
+ --no-persist skip alert-until-keypress loop
+ --flash on|off terminal flash during alert loop
+ --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal
-- end of options
-h help
diff --git a/config.def.h b/config.def.h
index af19705..2038fc9 100644
--- a/config.def.h
+++ b/config.def.h
@@ -3,3 +3,19 @@
/* progress bar glyphs (ASCII default) */
static const char *const BAR_FILL = ">";
static const char *const BAR_EMPTY = "-";
+
+/* persistent alert: keep ringing the bell until any key is pressed.
+ * Foreground-only; backgrounded runs always fall through.
+ * Override per-run with --no-persist.
+ */
+static const int ALERT_PERSIST = 1;
+
+/* reverse-video flash during the alert loop.
+ * Override per-run with --flash on|off.
+ */
+static const int FLASH = 1;
+
+/* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii).
+ * Available: unicode | ascii | hash | dots | line | block | arrow | minimal.
+ */
+static const char *const BAR_STYLE = "";
diff --git a/config.mk b/config.mk
index f07e004..53a270d 100644
--- a/config.mk
+++ b/config.mk
@@ -1,5 +1,5 @@
# ttym version
-VERSION = 2.0
+VERSION = 3.0
# paths
PREFIX = /usr/local
diff --git a/patches/README b/patches/README
index cd4e12a..51595b9 100644
--- a/patches/README
+++ b/patches/README
@@ -4,31 +4,48 @@ ttym patches
Each patch is a unified diff against the pristine base. Apply with:
cd ttym
- patch -p1 < patches/ttym-<name>-2.0.diff
+ patch -p1 < patches/ttym-<name>-3.0.diff
Then rebuild:
rm -f config.h ; make
To get every feature at once, skip the individual patches entirely and
-apply ttym-full-2.0.diff -- one combined diff, no conflicts to resolve.
+apply ttym-full-3.0.diff -- one combined diff, no conflicts to resolve.
See "The full build" below.
+What is in the base
+-------------------
+
+The base build is terminal-only: it opens /dev/tty, draws, and exits.
+It does not write files, spawn processes or depend on anything outside
+libc. That includes the progress-bar styles, the alert loop and the
+terminal flash, which were separate patches through 2.0:
+
+ - countdown and stopwatch, pause/resume, quit
+ - progress bar with eight built-in styles (--bar, BAR_STYLE)
+ - alert-until-keypress on completion (--no-persist, ALERT_PERSIST)
+ - reverse-video flash during that alert (--flash, FLASH)
+
+Every remaining patch changes what the program *touches* -- the
+filesystem, subprocesses, or external binaries. That is the line the
+split is drawn on.
+
+
The full build
--------------
- ttym-full-2.0.diff
- Every feature in a single patch: persist-alert, flash, notify,
- logging, hooks, bar-styles and config-file, with all inter-patch
- conflicts already resolved. Apply it to the pristine base and
- rebuild:
+ ttym-full-3.0.diff
+ Every feature in a single patch: notify, logging, hooks and
+ config-file, with all inter-patch conflicts already resolved.
+ Apply it to the pristine base and rebuild:
- patch -p1 < patches/ttym-full-2.0.diff
+ patch -p1 < patches/ttym-full-3.0.diff
rm -f config.h ; make
This is the recommended route to the full-featured build. It is
- equivalent to stacking all seven feature patches below and hand-
+ equivalent to stacking the four feature patches below and hand-
resolving every collision, but deterministic and conflict-free.
Use the individual patches only when you want a specific subset.
@@ -36,86 +53,68 @@ The full build
Available patches
-----------------
- ttym-persist-alert-2.0.diff
- After a countdown completes, keep ringing the bell every ~1.5s
- until any key is pressed. Foreground-only; backgrounded runs
- fall through. Adds: --no-persist flag, ALERT_PERSIST knob.
-
- ttym-flash-2.0.diff [requires persist-alert]
- Add reverse-video terminal flash to the persistent alert loop.
- Adds: --flash on|off flag, FLASH knob.
-
- ttym-notify-2.0.diff
+ ttym-notify-3.0.diff
One-shot desktop notification and sound on countdown completion,
plus a MESSAGE positional argument piped through {title}/{msg}
- placeholders. Adds: TITLE, NOTIFY_CMD, SOUND_CMD knobs.
+ placeholders. Spawns notify-send and a sound player.
+ Adds: TITLE, NOTIFY_CMD, SOUND_CMD knobs.
- ttym-logging-2.0.diff
+ ttym-logging-3.0.diff
TSV log at ~/.config/ttym/ttym.log, +tag extraction, the -r
reader (-n, --raw), and --stats (today|week|month|all). Adds
one-shot directory migration from ~/.config/timer/.
Adds: -c TEXT flag.
- ttym-hooks-2.0.diff
+ ttym-hooks-3.0.diff
Optional executables at ~/.config/ttym/hooks/{on_start,on_done,
on_quit} invoked with: mode duration_seconds comment.
Adds: -c TEXT flag (passed as 3rd hook arg).
- ttym-bar-styles-2.0.diff
- Eight built-in bar styles (unicode, ascii, hash, dots, line,
- block, arrow, minimal) with locale-aware auto-detect.
- Adds: --bar STYLE flag, BAR_STYLE knob.
-
- ttym-config-file-2.0.diff [capstone -- see Dependencies]
- Runtime config at ~/.config/ttym/config (key = value), auto-
- generated and self-documenting on first run. Exposes eight keys:
+ ttym-config-file-3.0.diff [requires notify]
+ Runtime config read from /etc/ttym.conf, then
+ ~/.config/ttym/config (key = value). Exposes eight keys:
bar_fill, bar_empty, bar_style, flash, alert_persist, title,
- notify_cmd, sound_cmd. A system-wide /etc/ttym.conf is read
- first. Because it wires up knobs owned by other patches, it must
- be applied LAST.
+ notify_cmd, sound_cmd. Unknown keys and unparseable values are
+ reported on stderr. Nothing is written to disk: --dump-config
+ prints a commented template on stdout, so a user does
+ timer --dump-config > ~/.config/ttym/config
-Dependencies
-------------
+ It needs notify only for the title / notify_cmd / sound_cmd
+ keys; the bar and alert keys it exposes now live in the base.
-flash depends on persist-alert.
-config-file is the capstone: it reads knobs introduced by persist-
-alert, flash, notify, logging and bar-styles, and extends a usage()
-line added by hooks -- so all six must be applied before it. Apply
-config-file last.
+Dependencies
+------------
-All other patches are independent of each other and of the base.
+config-file requires notify. Everything else is independent of the
+base and of each other.
Stacking
--------
-The simplest route to the full build is ttym-full-2.0.diff (see "The
+The simplest route to the full build is ttym-full-3.0.diff (see "The
full build" above). The notes here apply only when stacking the
feature patches by hand to assemble a subset.
Every patch in this directory applies and compiles cleanly against the
-pristine base -- except flash, which builds on persist-alert, and
-config-file, the capstone, which builds on six other patches (see
-Dependencies). That is the property the verification step guarantees.
+pristine base -- except config-file, which builds on notify.
Combining multiple optional patches will produce conflicts at shared
regions: the Config struct, usage(), the getopt() option string, and
in some cases #include lines. This is normal suckless territory --
-resolve by hand, or apply ttym-full-2.0.diff instead. The base is laid
-out so most patches add their new code in fresh sections (before
-usage(), before main()) where they do not collide; the inevitable
-collisions are in points where every patch must add a line or field.
+resolve by hand, or apply ttym-full-3.0.diff instead. Two known
+collisions when stacking logging with hooks: both own the -c flag, so
+keep one Config member and one usage() line for it.
Recommended order when stacking:
- persist-alert -> flash -> notify -> logging -> hooks ->
- bar-styles -> config-file
+ notify -> logging -> hooks -> config-file
-config-file must come last -- after bar-styles in particular, since it
-overrides the resolved bar glyphs. Apply, resolve any .rej files,
-rebuild.
+config-file must come last -- it overrides the resolved bar glyphs and
+extends usage() with lines the other patches also touch. Apply,
+resolve any .rej files, rebuild.
Authoring new patches
@@ -129,5 +128,5 @@ Match the discipline used here:
- Verify with `patch -p1` against a fresh copy of the base -- or,
for a patch with prerequisites, the base plus those -- and confirm
the result compiles before publishing.
- - When the patch set changes, regenerate ttym-full-2.0.diff so the
+ - When the patch set changes, regenerate ttym-full-3.0.diff so the
one-shot full build stays in sync.
diff --git a/patches/ttym-bar-styles-2.0.diff b/patches/ttym-bar-styles-2.0.diff
deleted file mode 100644
index 2653cf5..0000000
--- a/patches/ttym-bar-styles-2.0.diff
+++ /dev/null
@@ -1,155 +0,0 @@
-diff -ruN a/config.def.h b/config.def.h
---- a/config.def.h 2026-05-20 12:05:11.699957443 +0200
-+++ b/config.def.h 2026-05-20 12:05:11.703957526 +0200
-@@ -3,3 +3,8 @@
- /* progress bar glyphs (ASCII default) */
- static const char *const BAR_FILL = ">";
- static const char *const BAR_EMPTY = "-";
-+
-+/* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii).
-+ * Available: unicode | ascii | hash | dots | line | block | arrow | minimal.
-+ */
-+static const char *const BAR_STYLE = "";
-diff -ruN a/ttym.c b/ttym.c
---- a/ttym.c 2026-05-20 12:05:11.699957443 +0200
-+++ b/ttym.c 2026-05-20 12:06:26.161496315 +0200
-@@ -5,6 +5,7 @@
- #include <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
deleted file mode 100644
index 422d724..0000000
--- a/patches/ttym-config-file-2.0.diff
+++ /dev/null
@@ -1,237 +0,0 @@
-diff -ruN a/config.def.h b/config.def.h
---- a/config.def.h 2026-05-20 13:18:06.149571116 +0200
-+++ b/config.def.h 2026-05-20 13:19:12.046956614 +0200
-@@ -1,23 +1,29 @@
- /* See LICENSE file for copyright and license details. */
-
--/* progress bar glyphs (ASCII default) */
-+/* progress bar glyphs (ASCII default).
-+ * Runtime config file ~/.config/ttym/config can override these via
-+ * bar_fill / bar_empty keys.
-+ */
- static const char *const BAR_FILL = ">";
- static const char *const BAR_EMPTY = "-";
-
- /* persistent alert: keep ringing the bell until any key is pressed.
- * Foreground-only; backgrounded runs always fall through.
-- * Override per-run with --no-persist.
-+ * Override per-run with --no-persist, or via the alert_persist key
-+ * in the runtime config file (~/.config/ttym/config).
- */
- static const int ALERT_PERSIST = 1;
-
- /* reverse-video flash during the alert loop.
-- * Override per-run with --flash on|off.
-+ * Override per-run with --flash on|off, or via the flash key in the
-+ * runtime config file (~/.config/ttym/config).
- */
- static const int FLASH = 1;
-
- /* desktop notification on countdown completion.
- * Tokens are split on whitespace; placeholders {title} and {msg} are expanded.
- * Set NULL or "" to disable.
-+ * The runtime config file can override these via title / notify_cmd keys.
- */
- static const char *const TITLE = "Timer";
- static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}";
-@@ -25,6 +31,7 @@
- /* sound on countdown completion. If SOUND_CMD is "" the binary autodetects
- * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound.
- * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD.
-+ * The runtime config file can override this via the sound_cmd key.
- */
- static const char *const SOUND_CMD = "";
-
-@@ -35,5 +42,6 @@
-
- /* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii).
- * Available: unicode | ascii | hash | dots | line | block | arrow | minimal.
-+ * The runtime config file can override this via the bar_style key.
- */
- static const char *const BAR_STYLE = "";
-diff -ruN a/ttym.c b/ttym.c
---- a/ttym.c 2026-05-20 13:18:06.149571116 +0200
-+++ b/ttym.c 2026-05-20 13:19:12.046956614 +0200
-@@ -30,10 +30,12 @@
- int alert_persist; /* alert loop until keypress; --no-persist disables */
- int flash; /* reverse-video flash during alert loop */
- char comment[512]; /* -c TEXT: logged with each entry */
-+ char bar_fill[16]; /* override (utf-8 ok); from runtime config */
-+ char bar_empty[16]; /* override */
- char bar_style[32]; /* --bar STYLE */
-- const char *title;
-- const char *notify_cmd;
-- const char *sound_cmd;
-+ char title[256]; /* override; from runtime config */
-+ char notify_cmd[512]; /* override; from runtime config */
-+ char sound_cmd[512]; /* override; from runtime config */
- char msg[1024];
- } Config;
-
-@@ -409,7 +411,7 @@
- static void
- notify_and_sound(const Config *c)
- {
-- if (c->notify_cmd && c->notify_cmd[0] && command_exists("notify-send")) {
-+ if (c->notify_cmd[0] && command_exists("notify-send")) {
- char **a;
- if (build_argv(c->notify_cmd, c, &a) > 0) {
- spawn_bg(a);
-@@ -417,7 +419,7 @@
- }
- }
- if (!c->quiet) {
-- if (c->sound_cmd && c->sound_cmd[0]) {
-+ if (c->sound_cmd[0]) {
- char **a;
- if (build_argv(c->sound_cmd, c, &a) > 0) {
- spawn_bg(a);
-@@ -832,6 +834,112 @@
- }
- }
-
-+/* ===== runtime config file (~/.config/ttym/config) =====
-+ * key = value lines; '#' comments. Unknown keys ignored.
-+ */
-+
-+static void
-+trim(char *s)
-+{
-+ char *p = s;
-+ while (*p && isspace((unsigned char)*p)) p++;
-+ if (p != s) memmove(s, p, strlen(p) + 1);
-+ size_t n = strlen(s);
-+ while (n > 0 && isspace((unsigned char)s[n-1])) s[--n] = '\0';
-+}
-+
-+static void
-+ensure_default_config_file(const char *path)
-+{
-+ if (access(path, F_OK) == 0) return;
-+ char dir[512];
-+ snprintf(dir, sizeof(dir), "%s", path);
-+ char *slash = strrchr(dir, '/');
-+ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); }
-+ FILE *f = fopen(path, "w");
-+ if (!f) return;
-+ fputs(
-+"# ttym configuration file\n"
-+"#\n"
-+"# Location : ~/.config/ttym/config (/etc/ttym.conf is read first, if present)\n"
-+"# Syntax : key = value -- one entry per line; '#' begins a comment.\n"
-+"# A commented-out or absent key keeps its built-in default.\n"
-+"# Command-line flags override anything set in this file.\n"
-+"#\n"
-+"# Every key below is recognised by this build. Uncomment and edit to taste.\n"
-+"# ---------------------------------------------------------------------------\n"
-+"\n"
-+"# Progress-bar glyphs. Set BOTH or NEITHER -- mismatched display widths\n"
-+"# break bar alignment. Multi-byte UTF-8 is fine. These override bar_style.\n"
-+"# bar_fill = >\n"
-+"# bar_empty = -\n"
-+"\n"
-+"# Named progress-bar style. One of:\n"
-+"# unicode ascii hash dots line block arrow minimal\n"
-+"# Leave unset to auto-detect (unicode on a UTF-8 locale, otherwise ascii).\n"
-+"# bar_style = unicode\n"
-+"\n"
-+"# Reverse-video flash during the completion alert. on | off\n"
-+"# flash = on\n"
-+"\n"
-+"# Persistent alert: keep ringing the bell until a key is pressed\n"
-+"# (foreground runs only). on | off\n"
-+"# alert_persist = on\n"
-+"\n"
-+"# Desktop-notification title.\n"
-+"# title = Timer\n"
-+"\n"
-+"# Desktop-notification command run when a countdown finishes. Tokens are\n"
-+"# split on whitespace; {title}/{msg} placeholders expand. Empty disables.\n"
-+"# notify_cmd = notify-send -u critical {title} {msg}\n"
-+"\n"
-+"# Sound played when a countdown finishes. Leave unset to auto-detect a\n"
-+"# player (paplay/aplay/mpv/ffplay) and play a default system sound.\n"
-+"# Tokens are split on whitespace; {title}/{msg} placeholders expand.\n"
-+"# sound_cmd = paplay /usr/share/sounds/freedesktop/stereo/complete.oga\n",
-+ f);
-+ fclose(f);
-+ chmod(path, 0600);
-+}
-+
-+static void
-+load_config_file(Config *c, const char *path)
-+{
-+ FILE *f = fopen(path, "r");
-+ if (!f) return;
-+ char line[1024];
-+ while (fgets(line, sizeof(line), f)) {
-+ char *hash = strchr(line, '#');
-+ if (hash) *hash = '\0';
-+ char *eq = strchr(line, '=');
-+ if (!eq) continue;
-+ *eq = '\0';
-+ char *k = line, *v = eq + 1;
-+ trim(k); trim(v);
-+ if (!*k) continue;
-+ if (!strcmp(k, "bar_fill")) snprintf(c->bar_fill, sizeof(c->bar_fill), "%s", v);
-+ else if (!strcmp(k, "bar_empty")) snprintf(c->bar_empty, sizeof(c->bar_empty), "%s", v);
-+ else if (!strcmp(k, "bar_style")) snprintf(c->bar_style, sizeof(c->bar_style), "%s", v);
-+ else if (!strcmp(k, "flash")) c->flash = parse_bool_str(v);
-+ else if (!strcmp(k, "alert_persist")) c->alert_persist = parse_bool_str(v);
-+ else if (!strcmp(k, "title")) snprintf(c->title, sizeof(c->title), "%s", v);
-+ else if (!strcmp(k, "notify_cmd")) snprintf(c->notify_cmd, sizeof(c->notify_cmd), "%s", v);
-+ else if (!strcmp(k, "sound_cmd")) snprintf(c->sound_cmd, sizeof(c->sound_cmd), "%s", v);
-+ }
-+ fclose(f);
-+}
-+
-+static void
-+load_config(Config *c)
-+{
-+ load_config_file(c, "/etc/ttym.conf");
-+ char path[512];
-+ if (build_config_path(path, sizeof(path), "config") == 0) {
-+ ensure_default_config_file(path);
-+ load_config_file(c, path);
-+ }
-+}
-+
- /* ===== bar styles ===== */
-
- typedef struct { const char *name, *fill, *empty; } BarStyle;
-@@ -908,7 +1016,8 @@
- " -h help\n"
- "\n"
- "controls: [space] pause/resume [q | Ctrl+C] quit\n"
--"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n");
-+"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n"
-+"config: ~/.config/ttym/config (auto-generated on first run)\n");
- }
-
- /* ===== run loop ===== */
-@@ -924,6 +1033,10 @@
- const char *fill_glyph, *empty_glyph;
- int minimal = 0;
- resolve_bar(cfg, &fill_glyph, &empty_glyph, &minimal);
-+ /* explicit per-glyph overrides from the runtime config file
-+ * (bar_fill / bar_empty) win over the named --bar style. */
-+ if (cfg->bar_fill[0]) fill_glyph = cfg->bar_fill;
-+ if (cfg->bar_empty[0]) empty_glyph = cfg->bar_empty;
-
- ttyfd = open("/dev/tty", O_RDONLY);
- if (ttyfd >= 0 && isatty(ttyfd)) {
-@@ -1075,10 +1188,11 @@
- Config cfg = {0};
- cfg.alert_persist = ALERT_PERSIST;
- cfg.flash = FLASH;
-- cfg.title = TITLE;
-- cfg.notify_cmd = NOTIFY_CMD;
-- cfg.sound_cmd = SOUND_CMD;
-+ snprintf(cfg.title, sizeof(cfg.title), "%s", TITLE ? TITLE : "");
-+ snprintf(cfg.notify_cmd, sizeof(cfg.notify_cmd), "%s", NOTIFY_CMD ? NOTIFY_CMD : "");
-+ snprintf(cfg.sound_cmd, sizeof(cfg.sound_cmd), "%s", SOUND_CMD ? SOUND_CMD : "");
- snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up");
-+ load_config(&cfg);
-
- /* Pre-strip long-form flags before getopt. Stop at "--". */
- int new_argc = 0;
diff --git a/patches/ttym-config-file-3.0.diff b/patches/ttym-config-file-3.0.diff
new file mode 100644
index 0000000..031f105
--- /dev/null
+++ b/patches/ttym-config-file-3.0.diff
@@ -0,0 +1,293 @@
+diff -ruN a/config.def.h b/config.def.h
+--- a/config.def.h 2026-08-25 17:18:15.738123728 +0200
++++ b/config.def.h 2026-08-25 17:18:15.738123728 +0200
+@@ -1,5 +1,10 @@
+ /* See LICENSE file for copyright and license details. */
+
++/* Defaults below are compile-time. Many can be overridden at runtime by
++ * /etc/ttym.conf and ~/.config/ttym/config; command-line flags override
++ * both. Run `timer --dump-config` for a commented template. See timer(1).
++ */
++
+ /* progress bar glyphs (ASCII default) */
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
+diff -ruN a/config.h b/config.h
+--- a/config.h 2026-08-25 17:18:15.738123728 +0200
++++ b/config.h 2026-08-25 17:18:15.738123728 +0200
+@@ -1,5 +1,10 @@
+ /* See LICENSE file for copyright and license details. */
+
++/* Defaults below are compile-time. Many can be overridden at runtime by
++ * /etc/ttym.conf and ~/.config/ttym/config; command-line flags override
++ * both. Run `timer --dump-config` for a commented template. See timer(1).
++ */
++
+ /* progress bar glyphs (ASCII default) */
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
+Binary files a/timer and b/timer differ
+diff -ruN a/ttym.c b/ttym.c
+--- a/ttym.c 2026-08-25 17:18:15.738123728 +0200
++++ b/ttym.c 2026-08-25 17:18:15.738123728 +0200
+@@ -25,13 +25,15 @@
+ typedef struct {
+ int quiet; /* -q: suppress completion bell */
+ int silent; /* -s: no bell, no extras */
+- const char *title;
+- const char *notify_cmd;
+- const char *sound_cmd;
++ char title[256]; /* runtime config may override */
++ char notify_cmd[512]; /* runtime config may override */
++ char sound_cmd[512]; /* runtime config may override */
+ char msg[1024];
+ int alert_persist; /* alert loop until keypress; --no-persist disables */
+ int flash; /* reverse-video flash during alert loop */
+ char bar_style[32]; /* --bar STYLE */
++ char bar_fill[16]; /* runtime config: explicit glyph, beats bar_style */
++ char bar_empty[16]; /* runtime config: explicit glyph, beats bar_style */
+ } Config;
+
+ static struct termios oldtios;
+@@ -352,7 +354,7 @@
+ static void
+ notify_and_sound(const Config *c)
+ {
+- if (c->notify_cmd && c->notify_cmd[0] && command_exists("notify-send")) {
++ if (c->notify_cmd[0] && command_exists("notify-send")) {
+ char **a;
+ if (build_argv(c->notify_cmd, c, &a) > 0) {
+ spawn_bg(a);
+@@ -360,7 +362,7 @@
+ }
+ }
+ if (!c->quiet) {
+- if (c->sound_cmd && c->sound_cmd[0]) {
++ if (c->sound_cmd[0]) {
+ char **a;
+ if (build_argv(c->sound_cmd, c, &a) > 0) {
+ spawn_bg(a);
+@@ -469,6 +471,178 @@
+ }
+ *fill = s ? s->fill : BAR_FILL;
+ *empty = s ? s->empty : BAR_EMPTY;
++ /* explicit glyphs from the config file beat the named style; --bar
++ * clears them so a flag always wins over the file. */
++ if (c->bar_fill[0]) *fill = c->bar_fill;
++ if (c->bar_empty[0]) *empty = c->bar_empty;
++}
++
++/* ===== runtime config file =====
++ * key = value lines. '#' starts a comment only at the start of a line or
++ * after whitespace, and never inside quotes. Quote a value to protect
++ * surrounding space or a leading '#'; one matched pair is stripped. Unknown keys and
++ * unparseable values are reported on stderr rather than silently dropped.
++ * Precedence: command-line flags > ~/.config/ttym/config > /etc/ttym.conf
++ * > the compile-time defaults in config.h.
++ */
++
++static int
++build_cfg_path(char *buf, size_t bufsz, const char *name)
++{
++ const char *xdg = getenv("XDG_CONFIG_HOME");
++ const char *home = getenv("HOME");
++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, name);
++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, name);
++ else return -1;
++ return 0;
++}
++
++static void
++trim(char *s)
++{
++ char *p = s;
++ while (*p && isspace((unsigned char)*p)) p++;
++ if (p != s) memmove(s, p, strlen(p) + 1);
++ size_t n = strlen(s);
++ while (n > 0 && isspace((unsigned char)s[n - 1])) s[--n] = '\0';
++}
++
++static void
++strip_comment(char *s)
++{
++ int q = 0;
++ for (char *p = s; *p; p++) {
++ if (q) {
++ if (*p == q) q = 0;
++ } else if (*p == '"' || *p == '\'') {
++ q = *p;
++ } else if (*p == '#' && (p == s || isspace((unsigned char)p[-1]))) {
++ *p = '\0';
++ return;
++ }
++ }
++}
++
++static void
++unquote(char *s)
++{
++ size_t n = strlen(s);
++ if (n >= 2 && (s[0] == '"' || s[0] == '\'') && s[n - 1] == s[0]) {
++ memmove(s, s + 1, n - 2);
++ s[n - 2] = '\0';
++ }
++}
++
++static int
++cfg_bool(const char *path, int ln, const char *k, const char *v, int cur)
++{
++ if (!strcasecmp(v, "true") || !strcasecmp(v, "yes") ||
++ !strcasecmp(v, "on") || !strcmp(v, "1")) return 1;
++ if (!strcasecmp(v, "false") || !strcasecmp(v, "no") ||
++ !strcasecmp(v, "off") || !strcmp(v, "0")) return 0;
++ fprintf(stderr, "%s:%d: %s: expected on or off, got: %s\n", path, ln, k, v);
++ return cur;
++}
++
++static void
++load_config_file(Config *c, const char *path)
++{
++ FILE *f = fopen(path, "r");
++ if (!f) return;
++ char line[1024];
++ int ln = 0;
++ while (fgets(line, sizeof(line), f)) {
++ ln++;
++ strip_comment(line);
++ char *eq = strchr(line, '=');
++ if (!eq) {
++ trim(line);
++ if (*line)
++ fprintf(stderr, "%s:%d: not key = value: %s\n", path, ln, line);
++ continue;
++ }
++ *eq = '\0';
++ char *k = line, *v = eq + 1;
++ trim(k); trim(v); unquote(v);
++ if (!*k) {
++ fprintf(stderr, "%s:%d: missing key\n", path, ln);
++ continue;
++ }
++ if (!strcmp(k, "bar_fill")) snprintf(c->bar_fill, sizeof(c->bar_fill), "%s", v);
++ else if (!strcmp(k, "bar_empty")) snprintf(c->bar_empty, sizeof(c->bar_empty), "%s", v);
++ else if (!strcmp(k, "title")) snprintf(c->title, sizeof(c->title), "%s", v);
++ else if (!strcmp(k, "notify_cmd")) snprintf(c->notify_cmd, sizeof(c->notify_cmd), "%s", v);
++ else if (!strcmp(k, "sound_cmd")) snprintf(c->sound_cmd, sizeof(c->sound_cmd), "%s", v);
++ else if (!strcmp(k, "flash")) c->flash = cfg_bool(path, ln, k, v, c->flash);
++ else if (!strcmp(k, "alert_persist")) c->alert_persist = cfg_bool(path, ln, k, v, c->alert_persist);
++ else if (!strcmp(k, "bar_style")) {
++ if (find_bar_style(v))
++ snprintf(c->bar_style, sizeof(c->bar_style), "%s", v);
++ else
++ fprintf(stderr, "%s:%d: unknown bar style: %s\n", path, ln, v);
++ }
++ else fprintf(stderr, "%s:%d: unknown key: %s\n", path, ln, k);
++ }
++ fclose(f);
++}
++
++static void
++load_config(Config *c)
++{
++ load_config_file(c, "/etc/ttym.conf");
++ char path[512];
++ if (build_cfg_path(path, sizeof(path), "config") == 0)
++ load_config_file(c, path);
++}
++
++/* Written to stdout by --dump-config; ttym never creates this file itself.
++ * timer --dump-config > ~/.config/ttym/config
++ */
++static void
++dump_config(FILE *f)
++{
++ fputs(
++"# ttym configuration\n"
++"#\n"
++"# Read from /etc/ttym.conf first, then ~/.config/ttym/config\n"
++"# (honours $XDG_CONFIG_HOME). Command-line flags override both; an absent\n"
++"# or commented-out key keeps the compile-time default from config.h.\n"
++"#\n"
++"# Syntax: key = value. '#' begins a comment at the start of a line or after\n"
++"# whitespace. Quote a value to keep surrounding spaces or a leading '#';\n"
++"# one matched pair of quotes is stripped.\n"
++"# ---------------------------------------------------------------------------\n"
++"\n"
++"# Progress-bar glyphs. Set BOTH or NEITHER -- mismatched display widths\n"
++"# break bar alignment. Multi-byte UTF-8 is fine. These beat bar_style,\n"
++"# but --bar on the command line beats them.\n"
++"# bar_fill = > # a '#' glyph must be quoted: bar_fill = \"#\"\n"
++"# bar_empty = -\n"
++"\n"
++"# Named progress-bar style. One of:\n"
++"# unicode ascii hash dots line block arrow minimal\n"
++"# Leave unset to auto-detect (unicode on a UTF-8 locale, otherwise ascii).\n"
++"# bar_style = unicode\n"
++"\n"
++"# Reverse-video flash during the completion alert. on | off\n"
++"# flash = on\n"
++"\n"
++"# Persistent alert: keep ringing the bell until a key is pressed\n"
++"# (foreground runs only). on | off\n"
++"# alert_persist = on\n"
++"\n"
++"# Desktop-notification title.\n"
++"# title = Timer\n"
++"\n"
++"# Desktop-notification command run when a countdown finishes. Tokens are\n"
++"# split on whitespace; {title}/{msg} placeholders expand. Empty disables.\n"
++"# notify_cmd = notify-send -u critical {title} {msg}\n"
++"\n"
++"# Sound played when a countdown finishes. Leave unset to auto-detect a\n"
++"# player (paplay/aplay/mpv/ffplay) and play a default system sound.\n"
++"# Tokens are split on whitespace; {title}/{msg} placeholders expand.\n"
++"# sound_cmd = paplay /usr/share/sounds/freedesktop/stereo/complete.oga\n",
++ f);
+ }
+
+ /* ===== usage =====
+@@ -491,10 +665,12 @@
+ " --no-persist skip alert-until-keypress loop\n"
+ " --flash on|off terminal flash during alert loop\n"
+ " --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n"
++" --dump-config print a commented config template on stdout and exit\n"
+ " -- end of options\n"
+ " -h help\n"
+ "\n"
+-"controls: [space] pause/resume [q | Ctrl+C] quit\n");
++"controls: [space] pause/resume [q | Ctrl+C] quit\n"
++"config: /etc/ttym.conf, then ~/.config/ttym/config (--dump-config)\n");
+ }
+
+ /* ===== run loop ===== */
+@@ -633,12 +809,13 @@
+ main(int argc, char **argv)
+ {
+ Config cfg = {0};
+- cfg.title = TITLE;
+- cfg.notify_cmd = NOTIFY_CMD;
+- cfg.sound_cmd = SOUND_CMD;
++ snprintf(cfg.title, sizeof(cfg.title), "%s", TITLE ? TITLE : "");
++ snprintf(cfg.notify_cmd, sizeof(cfg.notify_cmd), "%s", NOTIFY_CMD ? NOTIFY_CMD : "");
++ snprintf(cfg.sound_cmd, sizeof(cfg.sound_cmd), "%s", SOUND_CMD ? SOUND_CMD : "");
+ snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up");
+ cfg.alert_persist = ALERT_PERSIST;
+ cfg.flash = FLASH;
++ load_config(&cfg);
+
+ /* Pre-strip long-form flags before getopt. Stop at "--". */
+ int nargc = 0, past_dd = 0;
+@@ -659,8 +836,13 @@
+ }
+ if (!past_dd && !strcmp(argv[i], "--bar") && i + 1 < argc) {
+ snprintf(cfg.bar_style, sizeof(cfg.bar_style), "%s", argv[++i]);
++ cfg.bar_fill[0] = cfg.bar_empty[0] = '\0';
+ continue;
+ }
++ if (!past_dd && !strcmp(argv[i], "--dump-config")) {
++ dump_config(stdout);
++ return 0;
++ }
+ argv[nargc++] = argv[i];
+ }
+ argv[nargc] = NULL;
diff --git a/patches/ttym-flash-2.0.diff b/patches/ttym-flash-2.0.diff
deleted file mode 100644
index 0ee30b5..0000000
--- a/patches/ttym-flash-2.0.diff
+++ /dev/null
@@ -1,113 +0,0 @@
-diff -ruN a/config.def.h b/config.def.h
---- a/config.def.h 2026-05-20 12:14:52.639963552 +0200
-+++ b/config.def.h 2026-05-20 12:14:52.639963552 +0200
-@@ -9,3 +9,8 @@
- * Override per-run with --no-persist.
- */
- static const int ALERT_PERSIST = 1;
-+
-+/* reverse-video flash during the alert loop.
-+ * Override per-run with --flash on|off.
-+ */
-+static const int FLASH = 1;
-diff -ruN a/ttym.c b/ttym.c
---- a/ttym.c 2026-05-20 12:14:52.639963552 +0200
-+++ b/ttym.c 2026-05-20 12:17:37.203364538 +0200
-@@ -11,6 +11,7 @@
- #include <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-3.0.diff
index a344275..166c869 100644
--- a/patches/ttym-full-2.0.diff
+++ b/patches/ttym-full-3.0.diff
@@ -1,34 +1,57 @@
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 @@
+--- a/config.def.h 2026-08-25 17:18:15.750123947 +0200
++++ b/config.def.h 2026-08-25 17:18:15.750123947 +0200
+@@ -1,9 +1,32 @@
/* See LICENSE file for copyright and license details. */
--/* 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.
++/* Defaults below are compile-time. Many can be overridden at runtime by
++ * /etc/ttym.conf and ~/.config/ttym/config; command-line flags override
++ * both. Run `timer --dump-config` for a commented template. See timer(1).
+ */
++
+ /* progress bar glyphs (ASCII default) */
static const char *const BAR_FILL = ">";
static const char *const BAR_EMPTY = "-";
+
++/* desktop notification on countdown completion.
++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded.
++ * Set NULL or "" to disable.
++ */
++static const char *const TITLE = "Timer";
++static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}";
++
++/* sound on countdown completion. If SOUND_CMD is "" the binary autodetects
++ * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound.
++ * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD.
++ */
++static const char *const SOUND_CMD = "";
+
-+/* 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).
++/* 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.
+ */
-+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).
+ /* persistent alert: keep ringing the bell until any key is pressed.
+ * Foreground-only; backgrounded runs always fall through.
+ * Override per-run with --no-persist.
+diff -ruN a/config.h b/config.h
+--- a/config.h 2026-08-25 17:18:15.750123947 +0200
++++ b/config.h 2026-08-25 17:18:15.750123947 +0200
+@@ -1,9 +1,32 @@
+ /* See LICENSE file for copyright and license details. */
+
++/* Defaults below are compile-time. Many can be overridden at runtime by
++ * /etc/ttym.conf and ~/.config/ttym/config; command-line flags override
++ * both. Run `timer --dump-config` for a commented template. See timer(1).
+ */
-+static const int FLASH = 1;
+
+ /* progress bar glyphs (ASCII default) */
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
+
+/* desktop notification on countdown completion.
+ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded.
+ * Set NULL or "" to disable.
-+ * 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}";
@@ -36,7 +59,6 @@ diff -ruN a/config.def.h b/config.def.h
+/* 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 = "";
+
@@ -45,149 +67,45 @@ diff -ruN a/config.def.h b/config.def.h
+ * 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 = "";
+ /* persistent alert: keep ringing the bell until any key is pressed.
+ * Foreground-only; backgrounded runs always fall through.
+ * Override per-run with --no-persist.
+Binary files a/timer and b/timer differ
diff -ruN a/ttym.c b/ttym.c
---- a/ttym.c 2026-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>
+--- a/ttym.c 2026-08-25 17:18:15.750123947 +0200
++++ b/ttym.c 2026-08-25 17:18:15.750123947 +0200
+@@ -13,6 +13,10 @@
#include <string.h>
-+#include <strings.h>
+ #include <strings.h>
#include <sys/ioctl.h>
++#include <sys/wait.h>
++#include <sys/stat.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
-@@ -23,6 +27,16 @@
+@@ -24,9 +28,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 title[256]; /* runtime config may override */
++ char notify_cmd[512]; /* runtime config may override */
++ char sound_cmd[512]; /* runtime config may override */
+ char msg[1024];
++ char comment[512]; /* -c TEXT: logged, and passed as 3rd arg to hooks */
+ int alert_persist; /* alert loop until keypress; --no-persist disables */
+ int flash; /* reverse-video flash during alert loop */
+ char bar_style[32]; /* --bar STYLE */
++ char bar_fill[16]; /* runtime config: explicit glyph, beats bar_style */
++ char bar_empty[16]; /* runtime config: explicit glyph, beats bar_style */
} Config;
static struct termios oldtios;
-@@ -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);
+@@ -236,6 +247,540 @@
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.
@@ -722,10 +640,39 @@ diff -ruN a/ttym.c b/ttym.c
+ }
+}
+
-+/* ===== runtime config file (~/.config/ttym/config) =====
-+ * key = value lines; '#' comments. Unknown keys ignored.
+ /* ===== alert loop =====
+ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap.
+ */
+@@ -328,6 +873,178 @@
+ }
+ *fill = s ? s->fill : BAR_FILL;
+ *empty = s ? s->empty : BAR_EMPTY;
++ /* explicit glyphs from the config file beat the named style; --bar
++ * clears them so a flag always wins over the file. */
++ if (c->bar_fill[0]) *fill = c->bar_fill;
++ if (c->bar_empty[0]) *empty = c->bar_empty;
++}
++
++/* ===== runtime config file =====
++ * key = value lines. '#' starts a comment only at the start of a line or
++ * after whitespace, and never inside quotes. Quote a value to protect
++ * surrounding space or a leading '#'; one matched pair is stripped. Unknown keys and
++ * unparseable values are reported on stderr rather than silently dropped.
++ * Precedence: command-line flags > ~/.config/ttym/config > /etc/ttym.conf
++ * > the compile-time defaults in config.h.
+ */
+
++static int
++build_cfg_path(char *buf, size_t bufsz, const char *name)
++{
++ const char *xdg = getenv("XDG_CONFIG_HOME");
++ const char *home = getenv("HOME");
++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, name);
++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, name);
++ else return -1;
++ return 0;
++}
++
+static void
+trim(char *s)
+{
@@ -733,33 +680,119 @@ diff -ruN a/ttym.c b/ttym.c
+ 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';
++ while (n > 0 && isspace((unsigned char)s[n - 1])) s[--n] = '\0';
+}
+
+static void
-+ensure_default_config_file(const char *path)
++strip_comment(char *s)
+{
-+ 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");
++ int q = 0;
++ for (char *p = s; *p; p++) {
++ if (q) {
++ if (*p == q) q = 0;
++ } else if (*p == '"' || *p == '\'') {
++ q = *p;
++ } else if (*p == '#' && (p == s || isspace((unsigned char)p[-1]))) {
++ *p = '\0';
++ return;
++ }
++ }
++}
++
++static void
++unquote(char *s)
++{
++ size_t n = strlen(s);
++ if (n >= 2 && (s[0] == '"' || s[0] == '\'') && s[n - 1] == s[0]) {
++ memmove(s, s + 1, n - 2);
++ s[n - 2] = '\0';
++ }
++}
++
++static int
++cfg_bool(const char *path, int ln, const char *k, const char *v, int cur)
++{
++ if (!strcasecmp(v, "true") || !strcasecmp(v, "yes") ||
++ !strcasecmp(v, "on") || !strcmp(v, "1")) return 1;
++ if (!strcasecmp(v, "false") || !strcasecmp(v, "no") ||
++ !strcasecmp(v, "off") || !strcmp(v, "0")) return 0;
++ fprintf(stderr, "%s:%d: %s: expected on or off, got: %s\n", path, ln, k, v);
++ return cur;
++}
++
++static void
++load_config_file(Config *c, const char *path)
++{
++ FILE *f = fopen(path, "r");
+ if (!f) return;
++ char line[1024];
++ int ln = 0;
++ while (fgets(line, sizeof(line), f)) {
++ ln++;
++ strip_comment(line);
++ char *eq = strchr(line, '=');
++ if (!eq) {
++ trim(line);
++ if (*line)
++ fprintf(stderr, "%s:%d: not key = value: %s\n", path, ln, line);
++ continue;
++ }
++ *eq = '\0';
++ char *k = line, *v = eq + 1;
++ trim(k); trim(v); unquote(v);
++ if (!*k) {
++ fprintf(stderr, "%s:%d: missing key\n", path, ln);
++ continue;
++ }
++ if (!strcmp(k, "bar_fill")) snprintf(c->bar_fill, sizeof(c->bar_fill), "%s", v);
++ else if (!strcmp(k, "bar_empty")) snprintf(c->bar_empty, sizeof(c->bar_empty), "%s", v);
++ else if (!strcmp(k, "title")) snprintf(c->title, sizeof(c->title), "%s", v);
++ else if (!strcmp(k, "notify_cmd")) snprintf(c->notify_cmd, sizeof(c->notify_cmd), "%s", v);
++ else if (!strcmp(k, "sound_cmd")) snprintf(c->sound_cmd, sizeof(c->sound_cmd), "%s", v);
++ else if (!strcmp(k, "flash")) c->flash = cfg_bool(path, ln, k, v, c->flash);
++ else if (!strcmp(k, "alert_persist")) c->alert_persist = cfg_bool(path, ln, k, v, c->alert_persist);
++ else if (!strcmp(k, "bar_style")) {
++ if (find_bar_style(v))
++ snprintf(c->bar_style, sizeof(c->bar_style), "%s", v);
++ else
++ fprintf(stderr, "%s:%d: unknown bar style: %s\n", path, ln, v);
++ }
++ else fprintf(stderr, "%s:%d: unknown key: %s\n", path, ln, k);
++ }
++ fclose(f);
++}
++
++static void
++load_config(Config *c)
++{
++ load_config_file(c, "/etc/ttym.conf");
++ char path[512];
++ if (build_cfg_path(path, sizeof(path), "config") == 0)
++ load_config_file(c, path);
++}
++
++/* Written to stdout by --dump-config; ttym never creates this file itself.
++ * timer --dump-config > ~/.config/ttym/config
++ */
++static void
++dump_config(FILE *f)
++{
+ fputs(
-+"# ttym configuration file\n"
++"# ttym configuration\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"
++"# Read from /etc/ttym.conf first, then ~/.config/ttym/config\n"
++"# (honours $XDG_CONFIG_HOME). Command-line flags override both; an absent\n"
++"# or commented-out key keeps the compile-time default from config.h.\n"
+"#\n"
-+"# Every key below is recognised by this build. Uncomment and edit to taste.\n"
++"# Syntax: key = value. '#' begins a comment at the start of a line or after\n"
++"# whitespace. Quote a value to keep surrounding spaces or a leading '#';\n"
++"# one matched pair of quotes is stripped.\n"
+"# ---------------------------------------------------------------------------\n"
+"\n"
+"# Progress-bar glyphs. Set BOTH or NEITHER -- mismatched display widths\n"
-+"# break bar alignment. Multi-byte UTF-8 is fine. These override bar_style.\n"
-+"# bar_fill = >\n"
++"# break bar alignment. Multi-byte UTF-8 is fine. These beat bar_style,\n"
++"# but --bar on the command line beats them.\n"
++"# bar_fill = > # a '#' glyph must be quoted: bar_fill = \"#\"\n"
+"# bar_empty = -\n"
+"\n"
+"# Named progress-bar style. One of:\n"
@@ -786,101 +819,10 @@ diff -ruN a/ttym.c b/ttym.c
+"# 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 @@
+@@ -340,20 +1057,26 @@
{
fprintf(f,
"usage:\n"
@@ -896,35 +838,21 @@ diff -ruN a/ttym.c b/ttym.c
+" -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"
+ " --no-persist skip alert-until-keypress loop\n"
+ " --flash on|off terminal flash during alert loop\n"
+ " --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n"
++" --dump-config print a commented config template on stdout and exit\n"
" -- end of options\n"
" -h help\n"
"\n"
-"controls: [space] pause/resume [q | Ctrl+C] quit\n");
+"controls: [space] pause/resume [q | Ctrl+C] quit\n"
+"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n"
-+"config: ~/.config/ttym/config (auto-generated on first run)\n");
++"config: /etc/ttym.conf, then ~/.config/ttym/config (--dump-config)\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 @@
+@@ -393,6 +1116,9 @@
if (out_tty)
fputs("\033[?25l\033[?7l", stdout);
@@ -934,55 +862,40 @@ diff -ruN a/ttym.c b/ttym.c
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, "");
+@@ -457,6 +1183,7 @@
+ printf("\r\033[32mSTOP %s\033[0m\033[K\n", el);
else
printf("STOP %s\n", el);
+ run_hook("on_quit", "stopwatch", elapsed_s, cfg->comment);
} else if (user_quit) {
char el[24];
fmt_clock(elapsed_s, el, sizeof(el));
-@@ -363,6 +1147,7 @@
- printf("\r\033[33mQUIT at %s%*s\033[0m\n", el, 30, "");
+@@ -464,6 +1191,7 @@
+ printf("\r\033[33mQUIT at %s\033[0m\033[K\n", el);
else
printf("QUIT at %s\n", el);
+ run_hook("on_quit", "countdown", elapsed_s, cfg->comment);
} else {
char tb[24];
fmt_clock(total, tb, sizeof(tb));
-@@ -370,14 +1155,21 @@
- printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, "");
+@@ -471,6 +1199,8 @@
+ printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb);
else
printf("DONE %s 100%%\n", tb);
-+ run_hook("on_done", "countdown", total, cfg->comment);
+ if (!cfg->silent) notify_and_sound(cfg);
++ run_hook("on_done", "countdown", total, cfg->comment);
if (!cfg->silent && !cfg->quiet && out_tty) {
fputc('\a', stdout);
fflush(stdout);
- sleep_ms(50);
+@@ -482,6 +1212,7 @@
}
-+ 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 @@
+@@ -490,9 +1221,21 @@
int
main(int argc, char **argv)
{
@@ -994,52 +907,43 @@ diff -ruN a/ttym.c b/ttym.c
+ 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");
+ cfg.alert_persist = ALERT_PERSIST;
+ cfg.flash = FLASH;
+ 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];
+ int nargc = 0, past_dd = 0;
+@@ -513,17 +1256,23 @@
+ }
+ if (!past_dd && !strcmp(argv[i], "--bar") && i + 1 < argc) {
+ snprintf(cfg.bar_style, sizeof(cfg.bar_style), "%s", argv[++i]);
++ cfg.bar_fill[0] = cfg.bar_empty[0] = '\0';
continue;
}
-+ if (!past_dd && !strcmp(argv[i], "--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;
++ if (!past_dd && !strcmp(argv[i], "--dump-config")) {
++ dump_config(stdout);
++ return 0;
+ }
- new_argv[new_argc++] = argv[i];
+ argv[nargc++] = argv[i];
}
- new_argv[new_argc] = NULL;
+ argv[nargc] = NULL;
int opt;
-- while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) {
-+ while ((opt = getopt(new_argc, new_argv, "qsc:h")) != -1) {
+- while ((opt = getopt(nargc, argv, "qsh")) != -1) {
++ while ((opt = getopt(nargc, argv, "qsc:h")) != -1) {
switch (opt) {
case 'q': cfg.quiet = 1; break;
case 's': cfg.silent = 1; break;
+ case 'c': snprintf(cfg.comment, sizeof(cfg.comment), "%s", optarg); break;
- case 'h': usage(stdout); free(new_argv); return 0;
- default: usage(stderr); free(new_argv); return 1;
+ case 'h': usage(stdout); return 0;
+ default: usage(stderr); return 1;
}
-@@ -424,6 +1247,16 @@
- free(new_argv);
+@@ -539,6 +1288,16 @@
+ fprintf(stderr, "bad duration: %s\n", rem_argv[0]);
return 1;
}
+ if (rem_argc > 1) {
@@ -1054,4 +958,4 @@ diff -ruN a/ttym.c b/ttym.c
+ }
}
- int rc = run(total, stopwatch, &cfg);
+ return run(total, stopwatch, &cfg);
diff --git a/patches/ttym-hooks-2.0.diff b/patches/ttym-hooks-3.0.diff
index dbb4f6a..fd505c9 100644
--- a/patches/ttym-hooks-2.0.diff
+++ b/patches/ttym-hooks-3.0.diff
@@ -1,24 +1,25 @@
+Binary files a/timer and b/timer differ
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>
+--- a/ttym.c 2026-08-25 17:18:15.722123437 +0200
++++ b/ttym.c 2026-08-25 17:18:15.726123510 +0200
+@@ -13,6 +13,8 @@
#include <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 +25,7 @@
+@@ -24,6 +26,7 @@
typedef struct {
int quiet; /* -q: suppress completion bell */
int silent; /* -s: no bell, no extras */
+ char comment[512]; /* passed as 3rd arg to hooks */
- } Config;
-
- static struct termios oldtios;
-@@ -226,6 +229,43 @@
+ int alert_persist; /* alert loop until keypress; --no-persist disables */
+ int flash; /* reverse-video flash during alert loop */
+ char bar_style[32]; /* --bar STYLE */
+@@ -236,6 +239,43 @@
fflush(stdout);
}
@@ -59,16 +60,18 @@ diff -ruN a/ttym.c b/ttym.c
+ }
+}
+
- /* ===== usage =====
- * Patches MUST update this in lockstep with new flags so -h always
- * reflects the build.
-@@ -241,12 +281,14 @@
+ /* ===== alert loop =====
+ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap.
+ */
+@@ -345,6 +385,7 @@
"\n"
"DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n"
"FLAGS:\n"
+" -c TEXT comment, passed as 3rd arg to hooks\n"
" -q no sound\n"
" -s silent\n"
+ " --no-persist skip alert-until-keypress loop\n"
+@@ -353,7 +394,8 @@
" -- end of options\n"
" -h help\n"
"\n"
@@ -78,7 +81,7 @@ diff -ruN a/ttym.c b/ttym.c
}
/* ===== run loop ===== */
-@@ -280,6 +322,9 @@
+@@ -393,6 +435,9 @@
if (out_tty)
fputs("\033[?25l\033[?7l", stdout);
@@ -88,40 +91,40 @@ diff -ruN a/ttym.c b/ttym.c
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, "");
+@@ -457,6 +502,7 @@
+ printf("\r\033[32mSTOP %s\033[0m\033[K\n", el);
else
printf("STOP %s\n", el);
+ run_hook("on_quit", "stopwatch", elapsed_s, cfg->comment);
} else if (user_quit) {
char el[24];
fmt_clock(elapsed_s, el, sizeof(el));
-@@ -363,6 +409,7 @@
- printf("\r\033[33mQUIT at %s%*s\033[0m\n", el, 30, "");
+@@ -464,6 +510,7 @@
+ printf("\r\033[33mQUIT at %s\033[0m\033[K\n", el);
else
printf("QUIT at %s\n", el);
+ run_hook("on_quit", "countdown", elapsed_s, cfg->comment);
} else {
char tb[24];
fmt_clock(total, tb, sizeof(tb));
-@@ -370,6 +417,7 @@
- printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, "");
+@@ -471,6 +518,7 @@
+ printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb);
else
printf("DONE %s 100%%\n", tb);
+ run_hook("on_done", "countdown", total, cfg->comment);
if (!cfg->silent && !cfg->quiet && out_tty) {
fputc('\a', stdout);
fflush(stdout);
-@@ -404,10 +452,11 @@
- new_argv[new_argc] = NULL;
+@@ -520,10 +568,11 @@
+ argv[nargc] = NULL;
int opt;
-- while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) {
-+ while ((opt = getopt(new_argc, new_argv, "qsc:h")) != -1) {
+- while ((opt = getopt(nargc, argv, "qsh")) != -1) {
++ while ((opt = getopt(nargc, argv, "qsc:h")) != -1) {
switch (opt) {
case 'q': cfg.quiet = 1; break;
case 's': cfg.silent = 1; break;
+ case 'c': snprintf(cfg.comment, sizeof(cfg.comment), "%s", optarg); break;
- case 'h': usage(stdout); free(new_argv); return 0;
- default: usage(stderr); free(new_argv); return 1;
+ case 'h': usage(stdout); return 0;
+ default: usage(stderr); return 1;
}
diff --git a/patches/ttym-logging-2.0.diff b/patches/ttym-logging-3.0.diff
index d654d50..0b16351 100644
--- a/patches/ttym-logging-2.0.diff
+++ b/patches/ttym-logging-3.0.diff
@@ -1,35 +1,54 @@
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) */
+--- a/config.def.h 2026-08-25 17:18:15.710123219 +0200
++++ b/config.def.h 2026-08-25 17:18:15.714123291 +0200
+@@ -4,6 +4,11 @@
static const char *const BAR_FILL = ">";
static const char *const BAR_EMPTY = "-";
+
++/* log location is ~/.config/ttym/ttym.log (honours $XDG_CONFIG_HOME).
++ * One-shot migration: ~/.config/timer/ -> ~/.config/ttym/ if the new
++ * directory doesn't already exist; legacy log file inside is renamed.
++ */
+
+ /* persistent alert: keep ringing the bell until any key is pressed.
+ * Foreground-only; backgrounded runs always fall through.
+ * Override per-run with --no-persist.
+diff -ruN a/config.h b/config.h
+--- a/config.h 2026-08-25 17:18:15.710123219 +0200
++++ b/config.h 2026-08-25 17:18:15.714123291 +0200
+@@ -4,6 +4,11 @@
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
+
+/* log location is ~/.config/ttym/ttym.log (honours $XDG_CONFIG_HOME).
+ * One-shot migration: ~/.config/timer/ -> ~/.config/ttym/ if the new
+ * directory doesn't already exist; legacy log file inside is renamed.
+ */
++
+ /* persistent alert: keep ringing the bell until any key is pressed.
+ * Foreground-only; backgrounded runs always fall through.
+ * Override per-run with --no-persist.
+Binary files a/timer and b/timer differ
diff -ruN a/ttym.c b/ttym.c
---- a/ttym.c 2026-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>
+--- a/ttym.c 2026-08-25 17:18:15.710123219 +0200
++++ b/ttym.c 2026-08-25 17:18:15.714123291 +0200
+@@ -13,6 +13,7 @@
#include <string.h>
+ #include <strings.h>
#include <sys/ioctl.h>
+#include <sys/stat.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
-@@ -23,6 +24,7 @@
+@@ -24,6 +25,7 @@
typedef struct {
int quiet; /* -q: suppress completion bell */
int silent; /* -s: no bell, no extras */
+ char comment[512]; /* -c TEXT: logged with each entry */
- } Config;
-
- static struct termios oldtios;
-@@ -226,6 +228,367 @@
+ int alert_persist; /* alert loop until keypress; --no-persist disables */
+ int flash; /* reverse-video flash during alert loop */
+ char bar_style[32]; /* --bar STYLE */
+@@ -236,6 +238,367 @@
fflush(stdout);
}
@@ -394,10 +413,10 @@ diff -ruN a/ttym.c b/ttym.c
+ return 0;
+}
+
- /* ===== usage =====
- * Patches MUST update this in lockstep with new flags so -h always
- * reflects the build.
-@@ -238,9 +601,12 @@
+ /* ===== alert loop =====
+ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap.
+ */
+@@ -342,9 +705,12 @@
"usage:\n"
" timer [FLAGS] DURATION countdown\n"
" timer [FLAGS] stopwatch\n"
@@ -409,16 +428,16 @@ diff -ruN a/ttym.c b/ttym.c
+" -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 @@
+ " --no-persist skip alert-until-keypress loop\n"
+@@ -482,6 +848,7 @@
+ }
}
- (void)cfg;
+ write_log(elapsed_s, cfg->comment);
return user_quit ? 130 : 0;
}
-@@ -386,6 +753,13 @@
+@@ -490,6 +857,13 @@
int
main(int argc, char **argv)
{
@@ -430,18 +449,18 @@ diff -ruN a/ttym.c b/ttym.c
+ 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;
+ cfg.alert_persist = ALERT_PERSIST;
+ cfg.flash = FLASH;
+@@ -520,10 +894,11 @@
+ argv[nargc] = NULL;
int opt;
-- while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) {
-+ while ((opt = getopt(new_argc, new_argv, "qsc:h")) != -1) {
+- while ((opt = getopt(nargc, argv, "qsh")) != -1) {
++ while ((opt = getopt(nargc, argv, "qsc:h")) != -1) {
switch (opt) {
case 'q': cfg.quiet = 1; break;
case 's': cfg.silent = 1; break;
+ case 'c': snprintf(cfg.comment, sizeof(cfg.comment), "%s", optarg); break;
- case 'h': usage(stdout); free(new_argv); return 0;
- default: usage(stderr); free(new_argv); return 1;
+ case 'h': usage(stdout); return 0;
+ default: usage(stderr); return 1;
}
diff --git a/patches/ttym-notify-2.0.diff b/patches/ttym-notify-3.0.diff
index a80eef3..9ccfd5c 100644
--- a/patches/ttym-notify-2.0.diff
+++ b/patches/ttym-notify-3.0.diff
@@ -1,11 +1,33 @@
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) */
+--- a/config.def.h 2026-08-25 17:18:15.698123000 +0200
++++ b/config.def.h 2026-08-25 17:18:15.702123073 +0200
+@@ -4,6 +4,19 @@
static const char *const BAR_FILL = ">";
static const char *const BAR_EMPTY = "-";
+
++/* desktop notification on countdown completion.
++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded.
++ * Set NULL or "" to disable.
++ */
++static const char *const TITLE = "Timer";
++static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}";
++
++/* sound on countdown completion. If SOUND_CMD is "" the binary autodetects
++ * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound.
++ * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD.
++ */
++static const char *const SOUND_CMD = "";
+
+ /* persistent alert: keep ringing the bell until any key is pressed.
+ * Foreground-only; backgrounded runs always fall through.
+ * Override per-run with --no-persist.
+diff -ruN a/config.h b/config.h
+--- a/config.h 2026-08-25 17:18:15.698123000 +0200
++++ b/config.h 2026-08-25 17:18:15.702123073 +0200
+@@ -4,6 +4,19 @@
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
+
+/* desktop notification on countdown completion.
+ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded.
+ * Set NULL or "" to disable.
@@ -18,18 +40,23 @@ diff -ruN a/config.def.h b/config.def.h
+ * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD.
+ */
+static const char *const SOUND_CMD = "";
++
+ /* persistent alert: keep ringing the bell until any key is pressed.
+ * Foreground-only; backgrounded runs always fall through.
+ * Override per-run with --no-persist.
+Binary files a/timer and b/timer differ
diff -ruN a/ttym.c b/ttym.c
---- a/ttym.c 2026-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>
+--- a/ttym.c 2026-08-25 17:18:15.698123000 +0200
++++ b/ttym.c 2026-08-25 17:18:15.702123073 +0200
+@@ -13,6 +13,7 @@
#include <string.h>
+ #include <strings.h>
#include <sys/ioctl.h>
+#include <sys/wait.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
-@@ -23,6 +24,10 @@
+@@ -24,6 +25,10 @@
typedef struct {
int quiet; /* -q: suppress completion bell */
int silent; /* -s: no bell, no extras */
@@ -37,10 +64,10 @@ diff -ruN a/ttym.c b/ttym.c
+ const char *notify_cmd;
+ const char *sound_cmd;
+ char msg[1024];
- } Config;
-
- static struct termios oldtios;
-@@ -226,6 +231,142 @@
+ int alert_persist; /* alert loop until keypress; --no-persist disables */
+ int flash; /* reverse-video flash during alert loop */
+ char bar_style[32]; /* --bar STYLE */
+@@ -236,6 +241,142 @@
fflush(stdout);
}
@@ -180,10 +207,10 @@ diff -ruN a/ttym.c b/ttym.c
+ }
+}
+
- /* ===== usage =====
- * Patches MUST update this in lockstep with new flags so -h always
- * reflects the build.
-@@ -236,8 +377,8 @@
+ /* ===== alert loop =====
+ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap.
+ */
+@@ -340,8 +481,8 @@
{
fprintf(f,
"usage:\n"
@@ -194,15 +221,15 @@ diff -ruN a/ttym.c b/ttym.c
"\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, "");
+@@ -471,6 +612,7 @@
+ printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb);
else
printf("DONE %s 100%%\n", tb);
+ if (!cfg->silent) notify_and_sound(cfg);
if (!cfg->silent && !cfg->quiet && out_tty) {
fputc('\a', stdout);
fflush(stdout);
-@@ -387,6 +529,10 @@
+@@ -491,6 +633,10 @@
main(int argc, char **argv)
{
Config cfg = {0};
@@ -210,11 +237,11 @@ diff -ruN a/ttym.c b/ttym.c
+ cfg.notify_cmd = NOTIFY_CMD;
+ cfg.sound_cmd = SOUND_CMD;
+ snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up");
+ cfg.alert_persist = ALERT_PERSIST;
+ cfg.flash = FLASH;
- /* Pre-strip long-form flags before getopt. Stop at "--". */
- int new_argc = 0;
-@@ -424,6 +570,16 @@
- free(new_argv);
+@@ -539,6 +685,16 @@
+ fprintf(stderr, "bad duration: %s\n", rem_argv[0]);
return 1;
}
+ if (rem_argc > 1) {
@@ -229,4 +256,4 @@ diff -ruN a/ttym.c b/ttym.c
+ }
}
- int rc = run(total, stopwatch, &cfg);
+ return run(total, stopwatch, &cfg);
diff --git a/patches/ttym-persist-alert-2.0.diff b/patches/ttym-persist-alert-2.0.diff
deleted file mode 100644
index 7e9d64b..0000000
--- a/patches/ttym-persist-alert-2.0.diff
+++ /dev/null
@@ -1,111 +0,0 @@
-diff -ruN a/config.def.h b/config.def.h
---- a/config.def.h 2026-05-20 11:57:26.574344832 +0200
-+++ b/config.def.h 2026-05-20 11:57:26.578344915 +0200
-@@ -3,3 +3,9 @@
- /* progress bar glyphs (ASCII default) */
- static const char *const BAR_FILL = ">";
- static const char *const BAR_EMPTY = "-";
-+
-+/* persistent alert: keep ringing the bell until any key is pressed.
-+ * Foreground-only; backgrounded runs always fall through.
-+ * Override per-run with --no-persist.
-+ */
-+static const int ALERT_PERSIST = 1;
-diff -ruN a/ttym.c b/ttym.c
---- a/ttym.c 2026-05-20 11:57:26.574344832 +0200
-+++ b/ttym.c 2026-05-20 11:57:26.578344915 +0200
-@@ -23,6 +23,7 @@
- typedef struct {
- int quiet; /* -q: suppress completion bell */
- int silent; /* -s: no bell, no extras */
-+ int alert_persist; /* alert loop until keypress; --no-persist disables */
- } Config;
-
- static struct termios oldtios;
-@@ -226,6 +227,48 @@
- fflush(stdout);
- }
-
-+/* ===== alert loop =====
-+ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap.
-+ */
-+
-+static int
-+is_foreground(void)
-+{
-+ if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) return 0;
-+ pid_t pgrp = tcgetpgrp(STDIN_FILENO);
-+ if (pgrp < 0) return 0;
-+ return pgrp == getpgrp();
-+}
-+
-+static void
-+alert_loop(void)
-+{
-+ fputs("\033[?25h\033[?7h", stdout);
-+ int ticks = 0;
-+ while (!interrupted) {
-+ fputc('\a', stdout);
-+ fflush(stdout);
-+ sleep_ms(150);
-+
-+ int acked = 0;
-+ for (int i = 0; i < 15 && !interrupted; i++) {
-+ if (ttyfd < 0) { sleep_ms(100); continue; }
-+ struct pollfd pfd = { .fd = ttyfd, .events = POLLIN };
-+ int pr = poll(&pfd, 1, 100);
-+ if (pr > 0 && (pfd.revents & POLLIN)) {
-+ char ch;
-+ ssize_t rd = read(ttyfd, &ch, 1);
-+ if (rd >= 0) { acked = 1; break; }
-+ } else if (pr < 0) {
-+ sleep_ms(100);
-+ }
-+ }
-+ if (acked) break;
-+ if (++ticks > 160) break;
-+ }
-+ fflush(stdout);
-+}
-+
- /* ===== usage =====
- * Patches MUST update this in lockstep with new flags so -h always
- * reflects the build.
-@@ -243,6 +286,7 @@
- "FLAGS:\n"
- " -q no sound\n"
- " -s silent\n"
-+" --no-persist skip alert-until-keypress loop\n"
- " -- end of options\n"
- " -h help\n"
- "\n"
-@@ -375,6 +419,10 @@
- fflush(stdout);
- sleep_ms(50);
- }
-+ if (!cfg->silent && cfg->alert_persist && is_foreground()) {
-+ interrupted = 0;
-+ alert_loop();
-+ }
- }
-
- (void)cfg;
-@@ -387,6 +435,7 @@
- main(int argc, char **argv)
- {
- Config cfg = {0};
-+ cfg.alert_persist = ALERT_PERSIST;
-
- /* Pre-strip long-form flags before getopt. Stop at "--". */
- int new_argc = 0;
-@@ -399,6 +448,10 @@
- new_argv[new_argc++] = argv[i];
- continue;
- }
-+ if (!past_dd && !strcmp(argv[i], "--no-persist")) {
-+ cfg.alert_persist = 0;
-+ continue;
-+ }
- new_argv[new_argc++] = argv[i];
- }
- new_argv[new_argc] = NULL;
diff --git a/timer.1 b/timer.1
index 57f84bb..e72566f 100644
--- a/timer.1
+++ b/timer.1
@@ -1,9 +1,25 @@
-.TH TIMER 1 "ttym 2.0"
+.TH TIMER 1 "ttym 3.0"
.SH NAME
timer \- terminal countdown and stopwatch
.SH SYNOPSIS
.B timer
.RB [ \-qs ]
+.RB [ \-\-no\-persist ]
+.RB [ \-\-flash
+.BR on | off ]
+.RB [ \-\-bar
+.IR style ]
+.RB [ \-\- ]
+.RI [ duration ]
+.PP
+The remaining forms exist only when the corresponding patch is applied;
+each is annotated
+.RI [patch: name ]
+under
+.BR OPTIONS .
+.PP
+.B timer
+.RB [ \-qs ]
.RB [ \-c
.IR text ]
.RB [ \-\-no\-persist ]
@@ -11,6 +27,7 @@ timer \- terminal countdown and stopwatch
.BR on | off ]
.RB [ \-\-bar
.IR style ]
+.RB [ \-\-dump\-config ]
.RB [ \-\- ]
.RI [ duration ]
.RI [ message
@@ -32,9 +49,11 @@ The progress bar, current elapsed time and time remaining
are redrawn on the same line ten times per second.
.PP
The binary is intentionally small and modular.
-Beyond the base build (countdown, stopwatch, ASCII progress bar,
-pause/resume, one\-shot terminal bell on completion) every feature is
-an optional patch under
+The base build is terminal\-only: countdown, stopwatch, pause/resume,
+the progress bar and its eight styles, and the alert\-until\-keypress
+loop with its optional terminal flash. It writes no files and spawns no
+processes. Every feature that touches the filesystem, runs a subprocess
+or depends on an external binary is an optional patch under
.IR patches/ .
Each section below describing a non\-base feature is annotated with
.RI [patch: name ];
@@ -46,8 +65,11 @@ always reflects the actual build.
.SS Base
.TP
.B \-q
-Suppress the one\-shot completion bell.
-Persistent alert loops, if enabled by a patch, still ring the bell.
+Suppress the one\-shot completion bell. With the
+.B notify
+patch it also suppresses the completion sound, though not the desktop
+notification. Persistent alert loops, if enabled by a patch, still ring
+the bell.
.TP
.B \-s
Silent. No bell, no notification, no alert loop.
@@ -60,36 +82,22 @@ Print usage and exit. Reflects the actual build.
End of options. Subsequent arguments are treated as positional even if
they begin with
.BR \- .
-.SS Optional, added by patches
-.TP
-.BR \-c \ \fItext\fR
-.RI [patch: logging,
-.RI hooks]
-Comment associated with this session. With the
-.B logging
-patch, it is written as the third TSV field and any
-.BI + tag
-tokens within it are picked up by
-.BR "timer \-\-stats" .
-With the
-.B hooks
-patch, it is passed as the fourth argument to each hook script.
.TP
.B \-\-no\-persist
-.RI [patch: persist\-alert]
Skip the alert\-until\-keypress loop.
.B timer
-emits one bell and one notification, then exits.
+emits one bell, then exits.
.TP
.BR \-\-flash \ \fBon\fR | \fBoff\fR
-.RI [patch: flash]
Toggle the reverse\-video terminal flash that runs during the
persistent alert loop. Accepts
.BR on / off / true / false / 1 / 0 / yes / no .
-Default is build\-dependent.
+Default is
+.B FLASH
+in
+.IR config.h .
.TP
.BR \-\-bar \ \fIstyle\fR
-.RI [patch: bar\-styles]
Progress bar style. One of
.BR unicode ,
.BR ascii ,
@@ -99,12 +107,40 @@ Progress bar style. One of
.BR block ,
.BR arrow ,
.BR minimal .
-With no patch applied the bar uses the compile\-time
+Unset (the default) auto\-detects: unicode on a UTF\-8 locale,
+otherwise ascii. The
.B BAR_FILL
and
.B BAR_EMPTY
-glyphs from
-.IR config.h .
+glyphs in
+.I config.h
+are the fallback when no style matches.
+.SS Optional, added by patches
+.TP
+.BR \-c \ \fItext\fR
+.RI [patch: logging,
+.RI hooks]
+Comment associated with this session. With the
+.B logging
+patch, it is written as the third TSV field and any
+.BI + tag
+tokens within it are picked up by
+.BR "timer \-\-stats" .
+With the
+.B hooks
+patch, it is passed as the third argument to each hook script.
+.TP
+.B \-\-dump\-config
+.RI [patch: config\-file]
+Print a commented configuration template on stdout and exit.
+.B timer
+never creates the file itself:
+.PP
+.RS
+.nf
+timer \-\-dump\-config > ~/.config/ttym/config
+.fi
+.RE
.SH SUBCOMMANDS
.SS timer \-r [\-n N] [\-\-raw]
.RI [patch: logging]
@@ -178,7 +214,6 @@ Quit immediately. Logs the partial session if logging is enabled.
Same as
.BR q .
.SH ALERT LOOP
-.RI [patch: persist\-alert]
When a countdown reaches zero, instead of emitting one bell and
exiting, the program rings the bell every \(ti1.5 s and polls for a
keypress. Any key acknowledges and exits.
@@ -191,12 +226,14 @@ Background jobs
.IR ... \ &)
get one bell and exit normally, leaving the shell quiet.
.PP
-If the
-.B flash
-patch is also applied, each beat of the loop briefly inverts the
-terminal colours (reverse\-video). The flash can be suppressed without
-disabling the loop via
-.BR "\-\-flash off" .
+Each beat of the loop briefly inverts the terminal colours
+(reverse\-video). The flash can be suppressed without disabling the
+loop via
+.BR "\-\-flash off" ,
+or permanently via
+.B FLASH
+in
+.IR config.h .
.SH HOOKS
.RI [patch: hooks]
On state transitions
@@ -228,12 +265,54 @@ or
the others come from the running session. Hooks must be marked
executable. Standard streams are redirected to
.IR /dev/null .
+.SH EXIT STATUS
+.TP
+.B 0
+A countdown ran to completion.
+.TP
+.B 1
+Usage error: an unknown option, or a duration that could not be parsed.
+.TP
+.B 130
+The session was cut short, either by
+.B q
+or by
+.BR SIGINT ,
+.BR SIGTERM ,
+.BR SIGQUIT
+or
+.BR SIGHUP .
+The terminal is restored in every case.
+A stopwatch always exits this way \(em it has no natural end.
+.SH ENVIRONMENT
+.TP
+.B XDG_CONFIG_HOME
+Base directory for the files below. Defaults to
+.I ~/.config
+when unset or empty.
.SH FILES
.TP
+.I /etc/ttym.conf
+.RI [patch: config\-file]
+System\-wide runtime configuration, read before the per\-user file.
+.TP
.I ~/.config/ttym/config
.RI [patch: config\-file]
-Runtime configuration. Auto\-generated on first run with all keys
-commented out.
+Per\-user runtime configuration;
+.BR key " = " value
+lines. A
+.B #
+begins a comment at the start of a line or after whitespace, and never
+inside quotes; quote a value to protect surrounding spaces or a leading
+.BR # .
+Unknown keys and unparseable values are reported on stderr.
+Command\-line flags override this file, which overrides
+.IR /etc/ttym.conf ,
+which overrides the compile\-time defaults in
+.IR config.h .
+.B timer
+never creates this file \(em see
+.BR \-\-dump\-config .
.TP
.I ~/.config/ttym/ttym.log
.RI [patch: logging]
diff --git a/ttym.c b/ttym.c
index fa46132..fb33d1f 100644
--- a/ttym.c
+++ b/ttym.c
@@ -5,12 +5,13 @@
#include <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 <termios.h>
#include <time.h>
@@ -23,6 +24,9 @@
typedef struct {
int quiet; /* -q: suppress completion bell */
int silent; /* -s: no bell, no extras */
+ int alert_persist; /* alert loop until keypress; --no-persist disables */
+ int flash; /* reverse-video flash during alert loop */
+ char bar_style[32]; /* --bar STYLE */
} Config;
static struct termios oldtios;
@@ -39,17 +43,6 @@ static int out_tty = 0;
/* ===== helpers ===== */
static void
-die(const char *fmt, ...)
-{
- va_list ap;
- va_start(ap, fmt);
- vfprintf(stderr, fmt, ap);
- va_end(ap);
- fputc('\n', stderr);
- exit(1);
-}
-
-static void
sleep_ms(int ms)
{
struct timespec ts = { ms / 1000, (long)(ms % 1000) * 1000000L };
@@ -73,6 +66,13 @@ term_cols(void)
return 80;
}
+static long
+elapsed(long start_ms, long pause_accum, long pause_start, int paused)
+{
+ long ms = (paused ? pause_start : now_ms()) - start_ms - pause_accum;
+ return ms < 0 ? 0 : ms;
+}
+
static void
on_signal(int sig)
{
@@ -80,16 +80,23 @@ on_signal(int sig)
interrupted = 1;
}
+static int
+parse_bool_str(const char *v)
+{
+ return !strcasecmp(v, "true") || !strcasecmp(v, "yes") ||
+ !strcmp(v, "1") || !strcasecmp(v, "on");
+}
+
static void
restore_tty(void)
{
if (rawset && ttyfd >= 0)
tcsetattr(ttyfd, TCSANOW, &oldtios);
rawset = 0;
- if (out_tty)
- fputs("\033[?7h\033[?25h", stdout); /* re-enable wrap, show cursor */
- fputc('\n', stdout);
- fflush(stdout);
+ if (out_tty) {
+ fputs("\033[?5l\033[?7h\033[?25h", stdout); /* clear reverse, re-enable wrap, show cursor */
+ fflush(stdout);
+ }
}
/* ===== duration parsing =====
@@ -142,18 +149,15 @@ parse_duration(const char *s)
if (nf == 3) {
if (v[1] >= 60 || v[2] >= 60) return -1;
total = v[0] * 3600 + v[1] * 60 + v[2];
- } else if (nf == 2) {
+ } else {
if (v[1] >= 60) return -1;
total = v[0] * 60 + v[1];
- } else {
- return -1;
}
return total > 0 ? total : -1;
}
long total = 0;
size_t i = 0, start = 0;
- int have_any = 0;
while (i < len) {
if (isdigit((unsigned char)s[i])) { i++; continue; }
if (s[i] != 'h' && s[i] != 'm' && s[i] != 's') return -1;
@@ -164,11 +168,10 @@ parse_duration(const char *s)
case 'm': total += v * 60; break;
case 's': total += v; break;
}
- have_any = 1;
i++;
start = i;
}
- if (!have_any || start != len) return -1;
+ if (start != len) return -1;
return total > 0 ? total : -1;
}
@@ -186,7 +189,8 @@ fmt_clock(long s, char *buf, size_t bufsz)
/* ===== drawing ===== */
static void
-draw_countdown(long total, long elapsed_ms, int paused, int barw)
+draw_countdown(long total, long elapsed_ms, int paused, int barw,
+ const char *fill_glyph, const char *empty_glyph, int minimal)
{
if (total <= 0) return;
long total_ms = total * 1000L;
@@ -207,8 +211,15 @@ draw_countdown(long total, long elapsed_ms, int paused, int barw)
const char *status = paused ? "PAUSED " : " ";
printf("\r%sT-%s\033[0m \033[2m+%s\033[0m [", color, rem, el);
- for (int i = 0; i < fill; i++) fputs(BAR_FILL, stdout);
- for (int i = 0; i < empty; i++) fputs(BAR_EMPTY, stdout);
+ if (minimal) {
+ fputs("\033[7m", stdout);
+ for (int i = 0; i < fill; i++) fputc(' ', stdout);
+ fputs("\033[27m", stdout);
+ for (int i = 0; i < empty; i++) fputc(' ', stdout);
+ } else {
+ for (int i = 0; i < fill; i++) fputs(fill_glyph, stdout);
+ for (int i = 0; i < empty; i++) fputs(empty_glyph, stdout);
+ }
printf("] %3ld%% %s", pct, status);
fflush(stdout);
}
@@ -221,11 +232,104 @@ draw_stopwatch(long elapsed_ms, int paused)
fmt_clock(es, el, sizeof(el));
const char *color = paused ? "\033[36m" : "\033[32m";
const char *status = paused ? "PAUSED " : " ";
- printf("\r%sT+%s\033[0m %s ",
- color, el, status);
+ printf("\r%sT+%s\033[0m %s\033[K", color, el, status);
+ fflush(stdout);
+}
+
+/* ===== alert loop =====
+ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap.
+ */
+
+static int
+is_foreground(void)
+{
+ if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) return 0;
+ pid_t pgrp = tcgetpgrp(STDIN_FILENO);
+ if (pgrp < 0) return 0;
+ return pgrp == getpgrp();
+}
+
+static void
+alert_loop(int flash)
+{
+ fputs("\033[?25h\033[?7h", stdout);
+ int ticks = 0;
+ while (!interrupted) {
+ fputc('\a', stdout);
+ if (flash) fputs("\033[?5h", stdout);
+ fflush(stdout);
+ sleep_ms(150);
+ if (flash) fputs("\033[?5l", stdout);
+ fflush(stdout);
+
+ int acked = 0;
+ for (int i = 0; i < 15 && !interrupted; i++) {
+ if (ttyfd < 0) { sleep_ms(100); continue; }
+ struct pollfd pfd = { .fd = ttyfd, .events = POLLIN };
+ int pr = poll(&pfd, 1, 100);
+ if (pr > 0 && (pfd.revents & POLLIN)) {
+ char ch;
+ ssize_t rd = read(ttyfd, &ch, 1);
+ if (rd >= 0) { acked = 1; break; }
+ } else if (pr < 0) {
+ sleep_ms(100);
+ }
+ }
+ if (acked) break;
+ if (++ticks > 160) break;
+ }
fflush(stdout);
}
+/* ===== bar styles ===== */
+
+typedef struct { const char *name, *fill, *empty; } BarStyle;
+
+static const BarStyle bar_styles[] = {
+ { "unicode", "\xe2\x96\x88", "\xe2\x96\x91" }, /* U+2588 U+2591 */
+ { "ascii", "=", "-" },
+ { "hash", "#", "." },
+ { "dots", "\xe2\x80\xa2", "\xc2\xb7" }, /* U+2022 U+00B7 */
+ { "line", "\xe2\x94\x81", "\xe2\x94\x80" }, /* U+2501 U+2500 */
+ { "block", "\xe2\x96\x93", "\xe2\x96\x91" }, /* U+2593 U+2591 */
+ { "arrow", "\xe2\x96\xb6", "\xe2\x96\xb7" }, /* U+25B6 U+25B7 */
+ { "minimal", "", "" },
+ { NULL, NULL, NULL }
+};
+
+static const BarStyle *
+find_bar_style(const char *name)
+{
+ if (!name || !*name) return NULL;
+ for (int i = 0; bar_styles[i].name; i++)
+ if (!strcmp(bar_styles[i].name, name)) return &bar_styles[i];
+ return NULL;
+}
+
+static int
+is_utf8(void)
+{
+ const char *l = setlocale(LC_CTYPE, "");
+ if (!l) return 0;
+ return strstr(l, "UTF-8") || strstr(l, "utf8") || strstr(l, "utf-8");
+}
+
+static void
+resolve_bar(const Config *c, const char **fill, const char **empty, int *minimal)
+{
+ *minimal = 0;
+ const char *name = (c->bar_style[0]) ? c->bar_style : BAR_STYLE;
+ const BarStyle *s = find_bar_style(name);
+ if (!s) s = find_bar_style(is_utf8() ? "unicode" : "ascii");
+ if (s && !strcmp(s->name, "minimal")) {
+ *minimal = 1;
+ *fill = " "; *empty = " ";
+ return;
+ }
+ *fill = s ? s->fill : BAR_FILL;
+ *empty = s ? s->empty : BAR_EMPTY;
+}
+
/* ===== usage =====
* Patches MUST update this in lockstep with new flags so -h always
* reflects the build.
@@ -243,6 +347,9 @@ usage(FILE *f)
"FLAGS:\n"
" -q no sound\n"
" -s silent\n"
+" --no-persist skip alert-until-keypress loop\n"
+" --flash on|off terminal flash during alert loop\n"
+" --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n"
" -- end of options\n"
" -h help\n"
"\n"
@@ -259,6 +366,10 @@ run(long total, int stopwatch, const Config *cfg)
int barw = cols - 40;
if (barw < 10) barw = 10;
+ const char *fill_glyph, *empty_glyph;
+ int minimal = 0;
+ resolve_bar(cfg, &fill_glyph, &empty_glyph, &minimal);
+
ttyfd = open("/dev/tty", O_RDONLY);
if (ttyfd >= 0 && isatty(ttyfd)) {
if (tcgetattr(ttyfd, &oldtios) == 0) {
@@ -276,6 +387,8 @@ run(long total, int stopwatch, const Config *cfg)
sa.sa_handler = on_signal;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
+ sigaction(SIGQUIT, &sa, NULL);
+ sigaction(SIGHUP, &sa, NULL);
if (out_tty)
fputs("\033[?25l\033[?7l", stdout);
@@ -289,18 +402,15 @@ run(long total, int stopwatch, const Config *cfg)
for (;;) {
if (interrupted) { user_quit = 1; break; }
- long now = now_ms();
- long elapsed_ms = paused
- ? pause_start - start_ms - pause_accum
- : now - start_ms - pause_accum;
- if (elapsed_ms < 0) elapsed_ms = 0;
+ long elapsed_ms = elapsed(start_ms, pause_accum, pause_start, paused);
if (stopwatch) {
if (out_tty)
draw_stopwatch(elapsed_ms, paused);
} else {
if (out_tty)
- draw_countdown(total, elapsed_ms, paused, barw);
+ draw_countdown(total, elapsed_ms, paused, barw,
+ fill_glyph, empty_glyph, minimal);
if (!paused && elapsed_ms >= total * 1000L) break;
}
@@ -318,19 +428,17 @@ run(long total, int stopwatch, const Config *cfg)
ssize_t n = read(ttyfd, &c, 1);
if (n == 1) {
if (c == ' ') {
- if (paused) {
- pause_accum += now_ms() - pause_start;
- paused = 0;
- } else {
- pause_start = now_ms();
- paused = 1;
- }
+ long t = now_ms();
+ if (paused)
+ pause_accum += t - pause_start;
+ pause_start = t;
+ paused = !paused;
} else if (c == 'q' || c == 'Q') {
user_quit = 1;
goto done;
}
}
- } else if (pr == 0) {
+ } else if (pr == 0 || (pr < 0 && errno == EINTR)) {
break;
} else {
sleep_ms((int)rem_frame);
@@ -340,34 +448,27 @@ run(long total, int stopwatch, const Config *cfg)
}
done:
- {
- long final_now = now_ms();
- long final_ms = paused
- ? pause_start - start_ms - pause_accum
- : final_now - start_ms - pause_accum;
- if (final_ms < 0) final_ms = 0;
- elapsed_s = final_ms / 1000;
- }
+ elapsed_s = elapsed(start_ms, pause_accum, pause_start, paused) / 1000;
if (stopwatch) {
char el[24];
fmt_clock(elapsed_s, el, sizeof(el));
if (out_tty)
- printf("\r\033[32mSTOP %s%*s\033[0m\n", el, 30, "");
+ printf("\r\033[32mSTOP %s\033[0m\033[K\n", el);
else
printf("STOP %s\n", el);
} else if (user_quit) {
char el[24];
fmt_clock(elapsed_s, el, sizeof(el));
if (out_tty)
- printf("\r\033[33mQUIT at %s%*s\033[0m\n", el, 30, "");
+ printf("\r\033[33mQUIT at %s\033[0m\033[K\n", el);
else
printf("QUIT at %s\n", el);
} else {
char tb[24];
fmt_clock(total, tb, sizeof(tb));
if (out_tty)
- printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, "");
+ printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb);
else
printf("DONE %s 100%%\n", tb);
if (!cfg->silent && !cfg->quiet && out_tty) {
@@ -375,9 +476,12 @@ done:
fflush(stdout);
sleep_ms(50);
}
+ if (!cfg->silent && cfg->alert_persist && is_foreground()) {
+ interrupted = 0;
+ alert_loop(cfg->flash);
+ }
}
- (void)cfg;
return user_quit ? 130 : 0;
}
@@ -387,33 +491,45 @@ int
main(int argc, char **argv)
{
Config cfg = {0};
+ cfg.alert_persist = ALERT_PERSIST;
+ cfg.flash = FLASH;
/* Pre-strip long-form flags before getopt. Stop at "--". */
- int new_argc = 0;
- char **new_argv = malloc(sizeof(char *) * (argc + 1));
- if (!new_argv) die("oom");
- int past_dd = 0;
+ int nargc = 0, past_dd = 0;
for (int i = 0; i < argc; i++) {
- if (!past_dd && !strcmp(argv[i], "--")) {
+ if (!past_dd && !strcmp(argv[i], "--"))
past_dd = 1;
- new_argv[new_argc++] = argv[i];
+ if (!past_dd && !strcmp(argv[i], "--no-persist")) {
+ cfg.alert_persist = 0;
+ continue;
+ }
+ if (!past_dd && !strncmp(argv[i], "--flash=", 8)) {
+ cfg.flash = parse_bool_str(argv[i] + 8);
+ continue;
+ }
+ if (!past_dd && !strcmp(argv[i], "--flash") && i + 1 < argc) {
+ cfg.flash = parse_bool_str(argv[++i]);
+ continue;
+ }
+ if (!past_dd && !strcmp(argv[i], "--bar") && i + 1 < argc) {
+ snprintf(cfg.bar_style, sizeof(cfg.bar_style), "%s", argv[++i]);
continue;
}
- new_argv[new_argc++] = argv[i];
+ argv[nargc++] = argv[i];
}
- new_argv[new_argc] = NULL;
+ argv[nargc] = NULL;
int opt;
- while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) {
+ while ((opt = getopt(nargc, argv, "qsh")) != -1) {
switch (opt) {
case 'q': cfg.quiet = 1; break;
case 's': cfg.silent = 1; break;
- case 'h': usage(stdout); free(new_argv); return 0;
- default: usage(stderr); free(new_argv); return 1;
+ case 'h': usage(stdout); return 0;
+ default: usage(stderr); return 1;
}
}
- int rem_argc = new_argc - optind;
- char **rem_argv = new_argv + optind;
+ int rem_argc = nargc - optind;
+ char **rem_argv = argv + optind;
int stopwatch = (rem_argc < 1);
long total = 0;
@@ -421,12 +537,9 @@ main(int argc, char **argv)
total = parse_duration(rem_argv[0]);
if (total < 0) {
fprintf(stderr, "bad duration: %s\n", rem_argv[0]);
- free(new_argv);
return 1;
}
}
- int rc = run(total, stopwatch, &cfg);
- free(new_argv);
- return rc;
+ return run(total, stopwatch, &cfg);
}