aboutsummaryrefslogtreecommitdiff
path: root/ttym.c
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 /ttym.c
parent2f6b29abd0d766ef477eb29c5a7bf6c1e88e9630 (diff)
downloadttym-8f99f1650e4a7363af1d83190b4082fb72a9f73f.tar.gz
ttym-8f99f1650e4a7363af1d83190b4082fb72a9f73f.zip
ttym 3.0: fix terminal and config bugs, fold terminal-only patches into base
Correctness fixes: - restore the terminal on SIGQUIT and SIGHUP. atexit never runs on an uncaught fatal signal, so Ctrl+\ left the cursor hidden and autowrap disabled. - drop the unconditional newline in restore_tty. Every run emitted a stray blank line, including piped output. - treat EINTR as an interruption rather than a poll() failure, which delayed Ctrl+C by up to one 100ms frame. config-file patch: - make comment stripping quote-aware so a value may contain '#', and strip one matched pair of quotes. bar_fill = "#" was silently dropped, despite '#' being the glyph of the hash style this build ships. - resolve bar_fill/bar_empty inside resolve_bar and clear them on --bar, so command-line flags beat the config file as documented. Previously config glyphs silently overrode --bar. - report unknown keys, unparseable booleans and malformed lines on stderr. flash = enabled silently meant off. - never write to disk. --dump-config prints a commented template on stdout instead of the binary creating ~/.config/ttym/config on first run. Patch set: - fold persist-alert, flash and bar-styles into the base. The base is now terminal-only: it opens /dev/tty, draws and exits, with no file writes and no subprocesses. Everything still in patches/ changes what the program touches. - seven patches become four; config-file's prerequisites drop from six patches to one (notify), so it is regenerable mechanically. - regenerate every patch. All apply with zero fuzz and compile. Source: - rewrite argv in place rather than into a malloc'd copy (C99 5.1.2.2.1), removing die(), four free() calls and <stdarg.h>. - fold the duplicated elapsed-time expression into elapsed(). - drop dead code: have_any, (void)cfg, and an unreachable branch in parse_duration. - replace hand-counted padding with \033[K. Docs and build: - timer.1: -c is the third hook argument, not the fourth. Add EXIT STATUS (a stopwatch always exits 130 -- it has no natural end) and ENVIRONMENT. Document config precedence and quoting. - wire VERSION into a dist target; it was defined and never used. - track .gitignore, which previously ignored itself, so a fresh clone had no ignore rules. Claude-Session: https://claude.ai/code/session_01APLBs8RB1FcUaC4viVkzbP
Diffstat (limited to 'ttym.c')
-rw-r--r--ttym.c253
1 files changed, 183 insertions, 70 deletions
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);
}