diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-05-20 14:23:09 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-05-20 14:23:09 +0200 |
| commit | 9e3fcd953e7136b900d6aabafe1c64c083a927be (patch) | |
| tree | 87d504609b5518e47a515b8d269143831b206e39 /ttym.c | |
| download | ttym-9e3fcd953e7136b900d6aabafe1c64c083a927be.tar.gz ttym-9e3fcd953e7136b900d6aabafe1c64c083a927be.zip | |
initial commit
Diffstat (limited to 'ttym.c')
| -rw-r--r-- | ttym.c | 432 |
1 files changed, 432 insertions, 0 deletions
@@ -0,0 +1,432 @@ +/* ttym - countdown / stopwatch in a single C file. + * See LICENSE file for copyright and license details. + */ +#define _POSIX_C_SOURCE 200809L +#include <ctype.h> +#include <errno.h> +#include <fcntl.h> +#include <poll.h> +#include <signal.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/ioctl.h> +#include <termios.h> +#include <time.h> +#include <unistd.h> + +#include "config.h" + +/* ===== types & globals ===== */ + +typedef struct { + int quiet; /* -q: suppress completion bell */ + int silent; /* -s: no bell, no extras */ +} Config; + +static struct termios oldtios; +static int ttyfd = -1; +static int rawset = 0; +static volatile sig_atomic_t interrupted = 0; + +/* exposed for patches that read the final elapsed seconds at exit */ +static long elapsed_s = 0; + +/* nonzero once stdout is confirmed a tty; gates cursor/redraw escapes */ +static int out_tty = 0; + +/* ===== helpers ===== */ + +static void +die(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + fputc('\n', stderr); + exit(1); +} + +static void +sleep_ms(int ms) +{ + struct timespec ts = { ms / 1000, (long)(ms % 1000) * 1000000L }; + nanosleep(&ts, NULL); +} + +static long +now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1000L + ts.tv_nsec / 1000000L; +} + +static int +term_cols(void) +{ + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) + return ws.ws_col; + return 80; +} + +static void +on_signal(int sig) +{ + (void)sig; + interrupted = 1; +} + +static void +restore_tty(void) +{ + if (rawset && ttyfd >= 0) + tcsetattr(ttyfd, TCSANOW, &oldtios); + rawset = 0; + if (out_tty) + fputs("\033[?7h\033[?25h", stdout); /* re-enable wrap, show cursor */ + fputc('\n', stdout); + fflush(stdout); +} + +/* ===== duration parsing ===== + * accepts: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00 + */ + +static int +parse_uint(const char *s, size_t n, long *out) +{ + long v = 0; + if (n == 0) return -1; + for (size_t i = 0; i < n; i++) { + if (!isdigit((unsigned char)s[i])) return -1; + v = v * 10 + (s[i] - '0'); + if (v > 1000000) return -1; + } + *out = v; + return 0; +} + +static long +parse_duration(const char *s) +{ + size_t len = strlen(s); + if (len == 0) return -1; + + if (strchr(s, ':')) { + /* Split on ':' into 2 or 3 fields, then parse each with + * parse_uint. That rejects empty fields, signs and + * whitespace, and bounds every component -- so unlike a + * raw sscanf("%ld") this cannot overflow on huge input. + */ + const char *fld[3]; + size_t flen[3]; + int nf = 0; + size_t fstart = 0; + for (size_t i = 0; i <= len; i++) { + if (i != len && s[i] != ':') continue; + if (nf == 3) return -1; + fld[nf] = s + fstart; + flen[nf] = i - fstart; + nf++; + fstart = i + 1; + } + long v[3] = { 0, 0, 0 }; + for (int k = 0; k < nf; k++) + if (parse_uint(fld[k], flen[k], &v[k]) < 0) + return -1; + long total; + if (nf == 3) { + if (v[1] >= 60 || v[2] >= 60) return -1; + total = v[0] * 3600 + v[1] * 60 + v[2]; + } else if (nf == 2) { + if (v[1] >= 60) return -1; + total = v[0] * 60 + v[1]; + } else { + return -1; + } + return total > 0 ? total : -1; + } + + long total = 0; + size_t i = 0, start = 0; + int have_any = 0; + while (i < len) { + if (isdigit((unsigned char)s[i])) { i++; continue; } + if (s[i] != 'h' && s[i] != 'm' && s[i] != 's') return -1; + long v; + if (parse_uint(s + start, i - start, &v) < 0) return -1; + switch (s[i]) { + case 'h': total += v * 3600; break; + case 'm': total += v * 60; break; + case 's': total += v; break; + } + have_any = 1; + i++; + start = i; + } + if (!have_any || start != len) return -1; + return total > 0 ? total : -1; +} + +/* ===== formatting ===== */ + +static void +fmt_clock(long s, char *buf, size_t bufsz) +{ + long h = s / 3600; s %= 3600; + long m = s / 60; long sec = s % 60; + if (h > 0) snprintf(buf, bufsz, "%02ld:%02ld:%02ld", h, m, sec); + else snprintf(buf, bufsz, "%02ld:%02ld", m, sec); +} + +/* ===== drawing ===== */ + +static void +draw_countdown(long total, long elapsed_ms, int paused, int barw) +{ + if (total <= 0) return; + long total_ms = total * 1000L; + if (elapsed_ms < 0) elapsed_ms = 0; + if (elapsed_ms > total_ms) elapsed_ms = total_ms; + long es = elapsed_ms / 1000; + long remaining = total - es; + long pct = (elapsed_ms * 100) / total_ms; + int fill = (int)((long)barw * elapsed_ms / total_ms); + if (fill > barw) fill = barw; + int empty = barw - fill; + + char rem[24], el[24]; + fmt_clock(remaining, rem, sizeof(rem)); + fmt_clock(es, el, sizeof(el)); + + const char *color = paused ? "\033[36m" : "\033[33m"; + const char *status = paused ? "PAUSED " : " "; + + printf("\r%sT-%s\033[0m \033[2m+%s\033[0m [", color, rem, el); + for (int i = 0; i < fill; i++) fputs(BAR_FILL, stdout); + for (int i = 0; i < empty; i++) fputs(BAR_EMPTY, stdout); + printf("] %3ld%% %s", pct, status); + fflush(stdout); +} + +static void +draw_stopwatch(long elapsed_ms, int paused) +{ + long es = elapsed_ms / 1000; + char el[24]; + fmt_clock(es, el, sizeof(el)); + const char *color = paused ? "\033[36m" : "\033[32m"; + const char *status = paused ? "PAUSED " : " "; + printf("\r%sT+%s\033[0m %s ", + color, el, status); + fflush(stdout); +} + +/* ===== usage ===== + * Patches MUST update this in lockstep with new flags so -h always + * reflects the build. + */ + +static void +usage(FILE *f) +{ + fprintf(f, +"usage:\n" +" timer [FLAGS] DURATION countdown\n" +" timer [FLAGS] stopwatch\n" +"\n" +"DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" +"FLAGS:\n" +" -q no sound\n" +" -s silent\n" +" -- end of options\n" +" -h help\n" +"\n" +"controls: [space] pause/resume [q | Ctrl+C] quit\n"); +} + +/* ===== run loop ===== */ + +static int +run(long total, int stopwatch, const Config *cfg) +{ + out_tty = isatty(STDOUT_FILENO); + int cols = term_cols(); + int barw = cols - 40; + if (barw < 10) barw = 10; + + ttyfd = open("/dev/tty", O_RDONLY); + if (ttyfd >= 0 && isatty(ttyfd)) { + if (tcgetattr(ttyfd, &oldtios) == 0) { + struct termios raw = oldtios; + raw.c_lflag &= ~(ICANON | ECHO); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + if (tcsetattr(ttyfd, TCSANOW, &raw) == 0) + rawset = 1; + } + } + + atexit(restore_tty); + struct sigaction sa = {0}; + sa.sa_handler = on_signal; + sigaction(SIGINT, &sa, NULL); + sigaction(SIGTERM, &sa, NULL); + + if (out_tty) + fputs("\033[?25l\033[?7l", stdout); + + long start_ms = now_ms(); + long pause_accum = 0; + long pause_start = 0; + int paused = 0; + int user_quit = 0; + + for (;;) { + if (interrupted) { user_quit = 1; break; } + + long now = now_ms(); + long elapsed_ms = paused + ? pause_start - start_ms - pause_accum + : now - start_ms - pause_accum; + if (elapsed_ms < 0) elapsed_ms = 0; + + if (stopwatch) { + if (out_tty) + draw_stopwatch(elapsed_ms, paused); + } else { + if (out_tty) + draw_countdown(total, elapsed_ms, paused, barw); + if (!paused && elapsed_ms >= total * 1000L) break; + } + + long frame_end = now_ms() + 100; + for (;;) { + if (interrupted) break; + long rem_frame = frame_end - now_ms(); + if (rem_frame <= 0) break; + if (ttyfd < 0) { sleep_ms((int)rem_frame); break; } + + struct pollfd pfd = { .fd = ttyfd, .events = POLLIN }; + int pr = poll(&pfd, 1, (int)rem_frame); + if (pr > 0 && (pfd.revents & POLLIN)) { + char c; + ssize_t n = read(ttyfd, &c, 1); + if (n == 1) { + if (c == ' ') { + if (paused) { + pause_accum += now_ms() - pause_start; + paused = 0; + } else { + pause_start = now_ms(); + paused = 1; + } + } else if (c == 'q' || c == 'Q') { + user_quit = 1; + goto done; + } + } + } else if (pr == 0) { + break; + } else { + sleep_ms((int)rem_frame); + break; + } + } + } +done: + + { + long final_now = now_ms(); + long final_ms = paused + ? pause_start - start_ms - pause_accum + : final_now - start_ms - pause_accum; + if (final_ms < 0) final_ms = 0; + elapsed_s = final_ms / 1000; + } + + if (stopwatch) { + char el[24]; + fmt_clock(elapsed_s, el, sizeof(el)); + if (out_tty) + printf("\r\033[32mSTOP %s%*s\033[0m\n", el, 30, ""); + else + printf("STOP %s\n", el); + } else if (user_quit) { + char el[24]; + fmt_clock(elapsed_s, el, sizeof(el)); + if (out_tty) + printf("\r\033[33mQUIT at %s%*s\033[0m\n", el, 30, ""); + else + printf("QUIT at %s\n", el); + } else { + char tb[24]; + fmt_clock(total, tb, sizeof(tb)); + if (out_tty) + printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, ""); + else + printf("DONE %s 100%%\n", tb); + if (!cfg->silent && !cfg->quiet && out_tty) { + fputc('\a', stdout); + fflush(stdout); + sleep_ms(50); + } + } + + (void)cfg; + return user_quit ? 130 : 0; +} + +/* ===== main ===== */ + +int +main(int argc, char **argv) +{ + Config cfg = {0}; + + /* Pre-strip long-form flags before getopt. Stop at "--". */ + int new_argc = 0; + char **new_argv = malloc(sizeof(char *) * (argc + 1)); + if (!new_argv) die("oom"); + int past_dd = 0; + for (int i = 0; i < argc; i++) { + if (!past_dd && !strcmp(argv[i], "--")) { + past_dd = 1; + new_argv[new_argc++] = argv[i]; + continue; + } + new_argv[new_argc++] = argv[i]; + } + new_argv[new_argc] = NULL; + + int opt; + while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) { + switch (opt) { + case 'q': cfg.quiet = 1; break; + case 's': cfg.silent = 1; break; + case 'h': usage(stdout); free(new_argv); return 0; + default: usage(stderr); free(new_argv); return 1; + } + } + int rem_argc = new_argc - optind; + char **rem_argv = new_argv + optind; + + int stopwatch = (rem_argc < 1); + long total = 0; + if (!stopwatch) { + total = parse_duration(rem_argv[0]); + if (total < 0) { + fprintf(stderr, "bad duration: %s\n", rem_argv[0]); + free(new_argv); + return 1; + } + } + + int rc = run(total, stopwatch, &cfg); + free(new_argv); + return rc; +} |
