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 @@ /* 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, 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, 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}"; + +/* 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 = ""; + +/* 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. + */ + +/* 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 14:08:08.221029371 +0200 +++ b/ttym.c 2026-05-20 14:08:08.221029371 +0200 @@ -5,13 +5,17 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include +#include +#include #include #include #include @@ -23,6 +27,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 msg[1024]; } 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); 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. + */ + +static int +build_argv(const char *cmd, const Config *c, char ***out) +{ + char buf[2048]; + snprintf(buf, sizeof(buf), "%s", cmd); + char **argv = calloc(64, sizeof(char *)); + if (!argv) return -1; + int argc = 0; + char *p = buf; + while (*p && argc < 63) { + while (*p && isspace((unsigned char)*p)) p++; + if (!*p) break; + char *start = p; + while (*p && !isspace((unsigned char)*p)) p++; + if (*p) *p++ = '\0'; + char tok[2048]; size_t oi = 0; + for (char *q = start; *q && oi < sizeof(tok) - 1; ) { + const char *sub = NULL; size_t skip = 0; + if (!strncmp(q, "{msg}", 5)) { sub = c->msg; skip = 5; } + else if (!strncmp(q, "{title}", 7)) { sub = c->title; skip = 7; } + if (sub) { + size_t sl = strlen(sub); + if (oi + sl >= sizeof(tok)) sl = sizeof(tok) - 1 - oi; + memcpy(tok + oi, sub, sl); + oi += sl; q += skip; + } else { tok[oi++] = *q++; } + } + tok[oi] = '\0'; + argv[argc++] = strdup(tok); + } + argv[argc] = NULL; + *out = argv; + return argc; +} + +static void +free_argv(char **argv) +{ + if (!argv) return; + for (int i = 0; argv[i]; i++) free(argv[i]); + free(argv); +} + +static void +spawn_bg(char *const argv[]) +{ + pid_t pid = fork(); + if (pid == 0) { + setsid(); + int devnull = open("/dev/null", O_RDWR); + if (devnull >= 0) { + dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2); + if (devnull > 2) close(devnull); + } + execvp(argv[0], argv); + _exit(127); + } + (void)pid; +} + +static int +command_exists(const char *name) +{ + const char *path = getenv("PATH"); + if (!path) return 0; + char buf[512]; + const char *p = path; + while (*p) { + const char *e = strchr(p, ':'); + size_t len = e ? (size_t)(e - p) : strlen(p); + if (len > 0 && len + strlen(name) + 2 < sizeof(buf)) { + snprintf(buf, sizeof(buf), "%.*s/%s", (int)len, p, name); + if (access(buf, X_OK) == 0) return 1; + } + if (!e) break; + p = e + 1; + } + return 0; +} + +static const char * +detect_sound_player(void) +{ + static const char *cands[] = { "paplay", "aplay", "mpv", "ffplay", NULL }; + for (int i = 0; cands[i]; i++) + if (command_exists(cands[i])) return cands[i]; + return NULL; +} + +static const char * +default_sound_path(void) +{ + static const char *paths[] = { + "/usr/share/sounds/freedesktop/stereo/complete.oga", + "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", + "/usr/share/sounds/alsa/Front_Center.wav", + NULL + }; + for (int i = 0; paths[i]; i++) + if (access(paths[i], R_OK) == 0) return paths[i]; + return NULL; +} + +static void +notify_and_sound(const Config *c) +{ + if (c->notify_cmd[0] && command_exists("notify-send")) { + char **a; + if (build_argv(c->notify_cmd, c, &a) > 0) { + spawn_bg(a); + free_argv(a); + } + } + if (!c->quiet) { + if (c->sound_cmd[0]) { + char **a; + if (build_argv(c->sound_cmd, c, &a) > 0) { + spawn_bg(a); + free_argv(a); + } + } else { + const char *pl = detect_sound_player(); + const char *sp = default_sound_path(); + if (pl && sp) { + char *a[] = { (char *)pl, (char *)sp, NULL }; + spawn_bg(a); + } + } + } +} + +/* ===== log (TSV: tssecondscomment) ===== */ + +static int +build_config_path(char *buf, size_t bufsz, const char *suffix) +{ + const char *home = getenv("HOME"); + const char *xdg = getenv("XDG_CONFIG_HOME"); + if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, suffix); + else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, suffix); + else return -1; + return 0; +} + +static void +migrate_legacy_dir(void) +{ + const char *home = getenv("HOME"); + const char *xdg = getenv("XDG_CONFIG_HOME"); + char base[512]; + if (xdg && *xdg) snprintf(base, sizeof(base), "%s", xdg); + else if (home && *home) snprintf(base, sizeof(base), "%s/.config", home); + else return; + char old_dir[600], new_dir[600]; + snprintf(old_dir, sizeof(old_dir), "%s/timer", base); + snprintf(new_dir, sizeof(new_dir), "%s/ttym", base); + struct stat sb; + if (stat(new_dir, &sb) == 0) return; + if (stat(old_dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) return; + if (rename(old_dir, new_dir) != 0) return; + char old_log[700], new_log[700]; + snprintf(old_log, sizeof(old_log), "%s/timer.log", new_dir); + snprintf(new_log, sizeof(new_log), "%s/ttym.log", new_dir); + if (stat(old_log, &sb) == 0) rename(old_log, new_log); +} + +static int +mkdir_p(const char *path, mode_t mode) +{ + char buf[1024]; + snprintf(buf, sizeof(buf), "%s", path); + for (char *p = buf + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1; + *p = '/'; + } + } + if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1; + return 0; +} + +static void +iso_local(char *buf, size_t bufsz) +{ + time_t t = time(NULL); + struct tm tm; + localtime_r(&t, &tm); + strftime(buf, bufsz, "%Y-%m-%d %H:%M:%S", &tm); +} + +static void +sanitize_field(char *s) +{ + for (; *s; s++) + if (*s == '\t' || *s == '\n' || *s == '\r') *s = ' '; +} + +static int +is_old_format(const char *line) +{ + return strlen(line) >= 11 && line[10] == 'T'; +} + +static void +migrate_line(const char *line, char *out, size_t outsz) +{ + if (strlen(line) < 19) { snprintf(out, outsz, "%s", line); return; } + char date[11], tpart[9]; + memcpy(date, line, 10); date[10] = '\0'; + memcpy(tpart, line + 11, 8); tpart[8] = '\0'; + const char *tab = strchr(line, '\t'); + if (!tab) { snprintf(out, outsz, "%s %s\n", date, tpart); return; } + snprintf(out, outsz, "%s %s%s", date, tpart, tab); +} + +static void +migrate_log_if_needed(const char *path) +{ + FILE *f = fopen(path, "r"); + if (!f) return; + char probe[2048]; + int needs = 0; + while (fgets(probe, sizeof(probe), f)) + if (is_old_format(probe)) { needs = 1; break; } + if (!needs) { fclose(f); return; } + rewind(f); + char tmp[1024]; + snprintf(tmp, sizeof(tmp), "%s.tmp", path); + FILE *out = fopen(tmp, "w"); + if (!out) { fclose(f); return; } + char line[2048]; + while (fgets(line, sizeof(line), f)) { + if (is_old_format(line)) { + char conv[2048]; + migrate_line(line, conv, sizeof(conv)); + fputs(conv, out); + } else fputs(line, out); + } + fclose(f); + fclose(out); + rename(tmp, path); +} + +static void +write_log(long duration_s, const char *comment) +{ + char path[512]; + if (build_config_path(path, sizeof(path), "ttym.log") < 0) return; + char dir[512]; + snprintf(dir, sizeof(dir), "%s", path); + char *slash = strrchr(dir, '/'); + if (slash) { *slash = '\0'; mkdir_p(dir, 0700); } + migrate_log_if_needed(path); + int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600); + if (fd < 0) return; + char ts[32]; iso_local(ts, sizeof(ts)); + char safe[512]; snprintf(safe, sizeof(safe), "%s", comment ? comment : ""); + sanitize_field(safe); + char line[1024]; + int n = snprintf(line, sizeof(line), "%s\t%ld\t%s\n", ts, duration_s, safe); + if (n > 0) { ssize_t w = write(fd, line, (size_t)n); (void)w; } + close(fd); +} + +static int +read_log_lines(char ***out_lines, size_t *out_n) +{ + char path[512]; + if (build_config_path(path, sizeof(path), "ttym.log") < 0) return -1; + migrate_log_if_needed(path); + FILE *f = fopen(path, "r"); + if (!f) { *out_lines = NULL; *out_n = 0; return 0; } + size_t cap = 64, n = 0; + char **lines = malloc(cap * sizeof(char *)); + if (!lines) { fclose(f); return -1; } + char buf[2048]; + while (fgets(buf, sizeof(buf), f)) { + size_t l = strlen(buf); + if (l && buf[l-1] == '\n') buf[l-1] = '\0'; + if (!*buf) continue; + if (n >= cap) { + cap *= 2; + char **nl = realloc(lines, cap * sizeof(char *)); + if (!nl) break; + lines = nl; + } + lines[n++] = strdup(buf); + } + fclose(f); + *out_lines = lines; + *out_n = n; + return 0; +} + +static void +free_lines(char **lines, size_t n) +{ + if (!lines) return; + for (size_t i = 0; i < n; i++) free(lines[i]); + free(lines); +} + +static int +split_log_line(char *line, char **ts, long *secs, char **comment) +{ + char *t1 = strchr(line, '\t'); if (!t1) return -1; + *t1 = '\0'; + char *t2 = strchr(t1 + 1, '\t'); if (!t2) return -1; + *t2 = '\0'; + char *end; + long v = strtol(t1 + 1, &end, 10); + if (end == t1 + 1) return -1; + *ts = line; *secs = v; *comment = t2 + 1; + return 0; +} + +static void +fmt_long(long s, char *buf, size_t bufsz) +{ + if (s < 0) s = 0; + long d = s / 86400; s %= 86400; + long h = s / 3600; s %= 3600; + long m = s / 60; + long sec = s % 60; + snprintf(buf, bufsz, "%02ld %02ld:%02ld:%02ld", d, h, m, sec); +} + +static int +cmd_read(int argc, char **argv) +{ + int n_tail = 0, raw = 0; + for (int i = 0; i < argc; i++) { + if (!strcmp(argv[i], "-n") && i + 1 < argc) { + n_tail = atoi(argv[++i]); + if (n_tail < 0) n_tail = 0; + } else if (!strcmp(argv[i], "--raw")) { + raw = 1; + } else { + fprintf(stderr, "timer -r: unknown arg: %s\n", argv[i]); + return 1; + } + } + char **lines = NULL; size_t n = 0; + if (read_log_lines(&lines, &n) < 0) return 1; + if (n == 0) { free_lines(lines, n); return 0; } + size_t start = 0; + if (n_tail > 0 && (size_t)n_tail < n) start = n - n_tail; + if (raw) { + for (size_t i = start; i < n; i++) puts(lines[i]); + } else { + printf("%-19s %-11s %s\n", "TIMESTAMP", "DURATION", "COMMENT"); + for (size_t i = start; i < n; i++) { + char *copy = strdup(lines[i]); + char *ts, *cmt; long secs; + if (split_log_line(copy, &ts, &secs, &cmt) == 0) { + char dur[32]; + fmt_long(secs, dur, sizeof(dur)); + printf("%-19s %-11s %s\n", ts, dur, cmt); + } else { + puts(lines[i]); + } + free(copy); + } + } + free_lines(lines, n); + return 0; +} + +static int +parse_log_ts(const char *ts, struct tm *out) +{ + memset(out, 0, sizeof(*out)); + if (sscanf(ts, "%d-%d-%d %d:%d:%d", + &out->tm_year, &out->tm_mon, &out->tm_mday, + &out->tm_hour, &out->tm_min, &out->tm_sec) != 6) return -1; + out->tm_year -= 1900; + out->tm_mon -= 1; + out->tm_isdst = -1; + return 0; +} + +typedef struct TagAcc { + char tag[128]; + long secs; + long count; + struct TagAcc *next; +} TagAcc; + +static void +tag_add(TagAcc **head, const char *tag, long secs) +{ + for (TagAcc *a = *head; a; a = a->next) + if (!strcmp(a->tag, tag)) { a->secs += secs; a->count++; return; } + TagAcc *n = calloc(1, sizeof(*n)); + if (!n) return; + snprintf(n->tag, sizeof(n->tag), "%s", tag); + n->secs = secs; + n->count = 1; + n->next = *head; + *head = n; +} + +static void +extract_tags(const char *comment, long secs, TagAcc **head) +{ + const char *p = comment; + int found = 0; + while (*p) { + int at_boundary = (p == comment) || isspace((unsigned char)*(p-1)); + if (*p == '+' && at_boundary) { + const char *s = p + 1, *e = s; + while (*e && !isspace((unsigned char)*e)) e++; + if (e > s) { + char buf[128]; + size_t len = (size_t)(e - s); + if (len >= sizeof(buf)) len = sizeof(buf) - 1; + memcpy(buf, s, len); buf[len] = '\0'; + tag_add(head, buf, secs); + found = 1; + } + p = e; + } else p++; + } + if (!found) tag_add(head, "(untagged)", secs); +} + +static int +cmd_stats(int argc, char **argv) +{ + const char *period = (argc > 0) ? argv[0] : "today"; + time_t now = time(NULL); + struct tm now_tm; localtime_r(&now, &now_tm); + time_t cutoff = 0; + if (!strcmp(period, "all")) cutoff = 0; + else if (!strcmp(period, "today")) { + struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; + cutoff = mktime(&t); + } else if (!strcmp(period, "week")) { + struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; + int back = (t.tm_wday == 0) ? 6 : t.tm_wday - 1; + t.tm_mday -= back; + cutoff = mktime(&t); + } else if (!strcmp(period, "month")) { + struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; t.tm_mday = 1; + cutoff = mktime(&t); + } else { + fprintf(stderr, "stats: unknown period '%s' (today|week|month|all)\n", period); + return 1; + } + char **lines = NULL; size_t n = 0; + if (read_log_lines(&lines, &n) < 0) return 1; + long total = 0, count = 0; + TagAcc *tags = NULL; + for (size_t i = 0; i < n; i++) { + char *copy = strdup(lines[i]); + char *ts, *cmt; long secs; + if (split_log_line(copy, &ts, &secs, &cmt) != 0) { free(copy); continue; } + struct tm tm; + if (parse_log_ts(ts, &tm) != 0) { free(copy); continue; } + time_t entry = mktime(&tm); + if (cutoff && entry < cutoff) { free(copy); continue; } + total += secs; count++; + extract_tags(cmt, secs, &tags); + free(copy); + } + free_lines(lines, n); + char tot[32]; fmt_long(total, tot, sizeof(tot)); + printf("\033[1m== timer stats: %s ==\033[0m\n", period); + printf("entries: %ld total: %s\n\n", count, tot); + int tag_n = 0; + for (TagAcc *a = tags; a; a = a->next) tag_n++; + if (tag_n > 0) { + TagAcc **arr = malloc(tag_n * sizeof(*arr)); + int i = 0; + for (TagAcc *a = tags; a; a = a->next) arr[i++] = a; + for (int x = 0; x < tag_n - 1; x++) + for (int y = x + 1; y < tag_n; y++) + if (arr[y]->secs > arr[x]->secs) { + TagAcc *t = arr[x]; arr[x] = arr[y]; arr[y] = t; + } + printf("%-24s %-11s %5s\n", "TAG", "DURATION", "COUNT"); + for (int z = 0; z < tag_n; z++) { + char d[32]; fmt_long(arr[z]->secs, d, sizeof(d)); + printf("%-24s %-11s %5ld\n", arr[z]->tag, d, arr[z]->count); + } + free(arr); + } + while (tags) { TagAcc *next = tags->next; free(tags); tags = next; } + return 0; +} + +/* ===== hooks ===== */ + +static int +build_hook_path(char *buf, size_t bufsz, const char *name) +{ + const char *home = getenv("HOME"); + const char *xdg = getenv("XDG_CONFIG_HOME"); + if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/hooks/%s", xdg, name); + else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/hooks/%s", home, name); + else return -1; + return 0; +} + +static void +run_hook(const char *name, const char *mode, long duration_s, const char *comment) +{ + char path[512]; + if (build_hook_path(path, sizeof(path), name) < 0) return; + if (access(path, X_OK) != 0) return; + char dur[32]; + snprintf(dur, sizeof(dur), "%ld", duration_s); + pid_t pid = fork(); + if (pid == 0) { + int devnull = open("/dev/null", O_RDWR); + if (devnull >= 0) { + dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2); + if (devnull > 2) close(devnull); + } + char *argv[] = { path, (char *)mode, dur, + (char *)(comment ? comment : ""), NULL }; + execvp(argv[0], argv); + _exit(127); + } else if (pid > 0) { + waitpid(pid, NULL, 0); + } +} + +/* ===== 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; + +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 @@ { fprintf(f, "usage:\n" -" timer [FLAGS] DURATION countdown\n" -" timer [FLAGS] stopwatch\n" +" timer [FLAGS] DURATION [MESSAGE...] countdown\n" +" timer [FLAGS] stopwatch\n" +" timer -r [-n N] [--raw] read log\n" +" timer --stats [today|week|month|all] stats (default: today)\n" "\n" "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" "FLAGS:\n" +" -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" " -- 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"); } /* ===== 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 @@ if (out_tty) fputs("\033[?25l\033[?7l", stdout); + run_hook("on_start", stopwatch ? "stopwatch" : "countdown", + stopwatch ? 0 : total, cfg->comment); + 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, ""); 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, ""); 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, ""); else printf("DONE %s 100%%\n", tb); + run_hook("on_done", "countdown", total, cfg->comment); + if (!cfg->silent) notify_and_sound(cfg); 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); + } } (void)cfg; + write_log(elapsed_s, cfg->comment); return user_quit ? 130 : 0; } @@ -386,7 +1178,21 @@ int main(int argc, char **argv) { + migrate_legacy_dir(); + + if (argc >= 2 && !strcmp(argv[1], "-r")) + return cmd_read(argc - 2, argv + 2); + if (argc >= 2 && !strcmp(argv[1], "--stats")) + 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"); + 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]; 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; + } new_argv[new_argc++] = argv[i]; } new_argv[new_argc] = NULL; int opt; - while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) { + while ((opt = getopt(new_argc, new_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; } @@ -424,6 +1247,16 @@ free(new_argv); return 1; } + if (rem_argc > 1) { + size_t off = 0; + cfg.msg[0] = '\0'; + for (int i = 1; i < rem_argc && off < sizeof(cfg.msg) - 2; i++) { + int n = snprintf(cfg.msg + off, sizeof(cfg.msg) - off, + "%s%s", (i > 1 ? " " : ""), rem_argv[i]); + if (n < 0) break; + off += (size_t)n; + } + } } int rc = run(total, stopwatch, &cfg);