aboutsummaryrefslogtreecommitdiff
path: root/ttym.c
diff options
context:
space:
mode:
Diffstat (limited to 'ttym.c')
-rw-r--r--ttym.c432
1 files changed, 432 insertions, 0 deletions
diff --git a/ttym.c b/ttym.c
new file mode 100644
index 0000000..fa46132
--- /dev/null
+++ b/ttym.c
@@ -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;
+}