/* ttym - countdown / stopwatch in a single C file. * See LICENSE file for copyright and license details. */ #define _POSIX_C_SOURCE 200809L #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "config.h" /* ===== types & globals ===== */ 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; static int ttyfd = -1; static int rawset = 0; static volatile sig_atomic_t interrupted = 0; /* exposed for patches that read the final elapsed seconds at exit */ static long elapsed_s = 0; /* nonzero once stdout is confirmed a tty; gates cursor/redraw escapes */ static int out_tty = 0; /* ===== helpers ===== */ static void sleep_ms(int ms) { struct timespec ts = { ms / 1000, (long)(ms % 1000) * 1000000L }; nanosleep(&ts, NULL); } static long now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec * 1000L + ts.tv_nsec / 1000000L; } static int term_cols(void) { struct winsize ws; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) return ws.ws_col; return 80; } static 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) { (void)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[?5l\033[?7h\033[?25h", stdout); /* clear reverse, re-enable wrap, show cursor */ fflush(stdout); } } /* ===== duration parsing ===== * accepts: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00 */ static int parse_uint(const char *s, size_t n, long *out) { long v = 0; if (n == 0) return -1; for (size_t i = 0; i < n; i++) { if (!isdigit((unsigned char)s[i])) return -1; v = v * 10 + (s[i] - '0'); if (v > 1000000) return -1; } *out = v; return 0; } static long parse_duration(const char *s) { size_t len = strlen(s); if (len == 0) return -1; if (strchr(s, ':')) { /* Split on ':' into 2 or 3 fields, then parse each with * parse_uint. That rejects empty fields, signs and * whitespace, and bounds every component -- so unlike a * raw sscanf("%ld") this cannot overflow on huge input. */ const char *fld[3]; size_t flen[3]; int nf = 0; size_t fstart = 0; for (size_t i = 0; i <= len; i++) { if (i != len && s[i] != ':') continue; if (nf == 3) return -1; fld[nf] = s + fstart; flen[nf] = i - fstart; nf++; fstart = i + 1; } long v[3] = { 0, 0, 0 }; for (int k = 0; k < nf; k++) if (parse_uint(fld[k], flen[k], &v[k]) < 0) return -1; long total; if (nf == 3) { if (v[1] >= 60 || v[2] >= 60) return -1; total = v[0] * 3600 + v[1] * 60 + v[2]; } else { if (v[1] >= 60) return -1; total = v[0] * 60 + v[1]; } return total > 0 ? total : -1; } long total = 0; size_t i = 0, start = 0; while (i < len) { if (isdigit((unsigned char)s[i])) { i++; continue; } if (s[i] != 'h' && s[i] != 'm' && s[i] != 's') return -1; long v; if (parse_uint(s + start, i - start, &v) < 0) return -1; switch (s[i]) { case 'h': total += v * 3600; break; case 'm': total += v * 60; break; case 's': total += v; break; } i++; start = i; } if (start != len) return -1; return total > 0 ? total : -1; } /* ===== formatting ===== */ static void fmt_clock(long s, char *buf, size_t bufsz) { long h = s / 3600; s %= 3600; long m = s / 60; long sec = s % 60; if (h > 0) snprintf(buf, bufsz, "%02ld:%02ld:%02ld", h, m, sec); else snprintf(buf, bufsz, "%02ld:%02ld", m, sec); } /* ===== drawing ===== */ static void draw_countdown(long total, long elapsed_ms, int paused, int barw, const char *fill_glyph, const char *empty_glyph, int minimal) { if (total <= 0) return; long total_ms = total * 1000L; if (elapsed_ms < 0) elapsed_ms = 0; if (elapsed_ms > total_ms) elapsed_ms = total_ms; long es = elapsed_ms / 1000; long remaining = total - es; long pct = (elapsed_ms * 100) / total_ms; int fill = (int)((long)barw * elapsed_ms / total_ms); if (fill > barw) fill = barw; int empty = barw - fill; char rem[24], el[24]; fmt_clock(remaining, rem, sizeof(rem)); fmt_clock(es, el, sizeof(el)); const char *color = paused ? "\033[36m" : "\033[33m"; const char *status = paused ? "PAUSED " : " "; printf("\r%sT-%s\033[0m \033[2m+%s\033[0m [", color, rem, el); 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); } static void draw_stopwatch(long elapsed_ms, int paused) { long es = elapsed_ms / 1000; char el[24]; fmt_clock(es, el, sizeof(el)); const char *color = paused ? "\033[36m" : "\033[32m"; const char *status = paused ? "PAUSED " : " "; printf("\r%sT+%s\033[0m %s\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. */ static void usage(FILE *f) { fprintf(f, "usage:\n" " timer [FLAGS] DURATION countdown\n" " timer [FLAGS] stopwatch\n" "\n" "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" "FLAGS:\n" " -q no sound\n" " -s silent\n" " --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" "controls: [space] pause/resume [q | Ctrl+C] quit\n"); } /* ===== run loop ===== */ static int run(long total, int stopwatch, const Config *cfg) { out_tty = isatty(STDOUT_FILENO); int cols = term_cols(); int barw = cols - 40; if (barw < 10) barw = 10; 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) { struct termios raw = oldtios; raw.c_lflag &= ~(ICANON | ECHO); raw.c_cc[VMIN] = 0; raw.c_cc[VTIME] = 0; if (tcsetattr(ttyfd, TCSANOW, &raw) == 0) rawset = 1; } } atexit(restore_tty); struct sigaction sa = {0}; sa.sa_handler = on_signal; sigaction(SIGINT, &sa, NULL); sigaction(SIGTERM, &sa, NULL); sigaction(SIGQUIT, &sa, NULL); sigaction(SIGHUP, &sa, NULL); if (out_tty) fputs("\033[?25l\033[?7l", stdout); long start_ms = now_ms(); long pause_accum = 0; long pause_start = 0; int paused = 0; int user_quit = 0; for (;;) { if (interrupted) { user_quit = 1; break; } long 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, fill_glyph, empty_glyph, minimal); if (!paused && elapsed_ms >= total * 1000L) break; } long frame_end = now_ms() + 100; for (;;) { if (interrupted) break; long rem_frame = frame_end - now_ms(); if (rem_frame <= 0) break; if (ttyfd < 0) { sleep_ms((int)rem_frame); break; } struct pollfd pfd = { .fd = ttyfd, .events = POLLIN }; int pr = poll(&pfd, 1, (int)rem_frame); if (pr > 0 && (pfd.revents & POLLIN)) { char c; ssize_t n = read(ttyfd, &c, 1); if (n == 1) { if (c == ' ') { 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 || (pr < 0 && errno == EINTR)) { break; } else { sleep_ms((int)rem_frame); break; } } } done: 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\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\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%%\033[0m\033[K\n", tb); else printf("DONE %s 100%%\n", tb); if (!cfg->silent && !cfg->quiet && out_tty) { fputc('\a', stdout); fflush(stdout); sleep_ms(50); } if (!cfg->silent && cfg->alert_persist && is_foreground()) { interrupted = 0; alert_loop(cfg->flash); } } return user_quit ? 130 : 0; } /* ===== main ===== */ 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 nargc = 0, past_dd = 0; for (int i = 0; i < argc; i++) { if (!past_dd && !strcmp(argv[i], "--")) past_dd = 1; 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; } argv[nargc++] = argv[i]; } argv[nargc] = NULL; int opt; 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); return 0; default: usage(stderr); return 1; } } int rem_argc = nargc - optind; char **rem_argv = argv + optind; int stopwatch = (rem_argc < 1); long total = 0; if (!stopwatch) { total = parse_duration(rem_argv[0]); if (total < 0) { fprintf(stderr, "bad duration: %s\n", rem_argv[0]); return 1; } } return run(total, stopwatch, &cfg); }