/* cbink.c -- -lncursesw * simple C client for bink * https://git.tilde.town/nebula/bink */ /* TODO: * * - maybe remove ncurses dependency * - consider setting up a proper log file for log_warn / log_err stuff * **/ #define _POSIX_C_SOURCE 202506L /* includes */ #include #include #include #include #include #include #include /* PATH_MAX */ #include /* mkdir(2) */ #include #include #include #include #define STR_IMPL #define STRIO_IMPL #define ARENA_IMPL #include "str.h" #include "strio.h" #include "arena.h" #define POST_LIMIT 200 /* see bink.py */ #define PAGE_LEN getmaxy(stdscr) #define HALF_PAGE_LEN ((getmaxy(stdscr) * 75) / 100) #define GFX_MARGIN_X 2 #define GFX_MARGIN_Y 1 #define GFX_TEXT_MARGIN_X 1 #define GFX_TEXT_MARGIN_Y 0 #define GFX_POST_SPACING 1 /* colors */ typedef enum { CPAIR_TEXT, CPAIR_MENTION, CPAIR_USER, CPAIR_TIME, CPAIR_BORDER, CPAIR_TAGLINE_OK, CPAIR_TAGLINE_WARN, CPAIR_TAGLINE_ERR } ColorPair; #define CPAIR_INIT_X(cp, fg, bg) init_pair(cp, fg, bg); #define CPAIR_LIST\ X(CPAIR_TEXT , COLOR_WHITE , COLOR_BLACK)\ X(CPAIR_MENTION , COLOR_BLACK , COLOR_WHITE)\ X(CPAIR_USER , COLOR_YELLOW , COLOR_BLACK)\ X(CPAIR_TIME , COLOR_BLUE , COLOR_BLACK)\ X(CPAIR_BORDER , COLOR_BLUE , COLOR_BLACK)\ X(CPAIR_TAGLINE_OK , COLOR_GREEN , COLOR_BLACK)\ X(CPAIR_TAGLINE_WARN , COLOR_YELLOW , COLOR_BLACK)\ X(CPAIR_TAGLINE_ERR , COLOR_RED , COLOR_BLACK) /* dynamic arrays */ #define DYNARR(T) struct { ptrdiff_t len, cap; T *data; } #define DA_FIT(da, n) do {\ if (n >= (da)->cap) {\ (da)->cap += !(da)->cap;\ while (n >= (da)->cap) (da)->cap <<= 1;\ void *da_fit_ptr = realloc((da)->data, (da)->cap * sizeof((da)->data[0]));\ if (!da_fit_ptr) { fprintf(stderr, "dynamic array reallocation failed\n"); abort(); }\ (da)->data = da_fit_ptr;\ }\ } while(0) #define DA_PUSH(da, ...) do {\ DA_FIT(da, (da)->len + 1);\ (da)->data[(da)->len++] = (__VA_ARGS__);\ } while(0) #define DA_PUSH_MULT(da, buf, n) do {\ DA_FIT(da, (da)->len + n);\ memcpy(&(da)->data[(da)->len], buf, n * sizeof((da)->data[0]));\ (da)->len += n;\ } while(0) /* globals */ regex_t re_mention; /* tagline & logging */ #define TAGLINE_MAX 1024 char tagline[TAGLINE_MAX] = { 0 }; typedef enum { TAGLINE_OK, TAGLINE_WARN, TAGLINE_ERR } TaglineStatus; TaglineStatus tagline_status = TAGLINE_OK; void tagline_set(const char *fmt, ...) { va_list ap; va_start(ap, fmt); vsnprintf(tagline, TAGLINE_MAX - 1, fmt, ap); va_end(ap); tagline_status = TAGLINE_OK; } void log_warn(const char *fmt, ...) { va_list ap; va_start(ap, fmt); beep(); flash(); vsnprintf(tagline, TAGLINE_MAX - 1, fmt, ap); va_end(ap); tagline_status = TAGLINE_WARN; } void log_err(const char *fmt, ...) { beep(); flash(); va_list ap; va_start(ap, fmt); vsnprintf(tagline, TAGLINE_MAX - 1, fmt, ap); size_t n = strlen(tagline); snprintf(tagline + n, TAGLINE_MAX - n - 1, ": %s", strerror(errno)); va_end(ap); tagline_status = TAGLINE_ERR; } /* string conversion */ int str_to_timespec(Str s, struct timespec *ts) { uint64_t sec, nsec; /* timestamps are in nanoseconds-since-epoch */ /* timespec is tv_sec and tv_nsec, the latter ranging from 0 - 999'999'999 */ /* reject any timestamps that wouldn't reach one second */ if (s.n < 9) return -1; /* no negative timestamps */ if (s.s[0] == '-') return -1; /* both must be valid numbers */ if (str_to_u64((Str) { s.s, s.n - 9 }, &sec) || str_to_u64(str_skip(s, s.n - 9), &nsec)) return -1; ts->tv_sec = sec; ts->tv_nsec = nsec; return 0; } int timestamp_invalid(struct timespec *ts) { /* reject posts from more than a minute into the future */ if (ts->tv_sec > time(NULL) + 60) return 1; return 0; } /* posts */ typedef struct Post { struct timespec timestamp; const char *path; Str user, text; } Post; typedef struct { ArenaMark mark; Post *data; ptrdiff_t len, cap; } PostList; typedef struct { size_t user_count, post_count, gather_ns; struct timespec time_start, time_end; int err; } PostStats; /* sorting */ int ts_cmp(const struct timespec *a, const struct timespec *b) { if (a->tv_sec < b->tv_sec) return -1; if (a->tv_sec > b->tv_sec) return 1; if (a->tv_nsec < b->tv_nsec) return -1; if (a->tv_nsec > b->tv_nsec) return 1; return 0; } int post_cmp(const void *a, const void *b) { return -ts_cmp(&((const Post *)a)->timestamp, &((const Post *)b)->timestamp); } /* post indexing */ PostStats post_stats = { 0 }; void post_stats_init(PostStats *ps) { memset(ps, 0, sizeof(PostStats)); if (clock_gettime(CLOCK_REALTIME, &ps->time_start)) { log_warn("clock error!"); ps->err = 1; } } void post_stats_fini(PostStats *ps) { if (clock_gettime(CLOCK_REALTIME, &ps->time_end)) { log_warn("clock error!"); ps->err = 1; } int64_t diff_sec = ps->time_end.tv_sec - ps->time_start.tv_sec; int64_t diff_nsec = ps->time_end.tv_nsec - ps->time_start.tv_nsec; ps->gather_ns = (diff_sec * 1000000000) + diff_nsec; } /* @path must be both * (a) absolute; and * (b) allocated either statically or within arena @a */ void posts_gather_from(PostList *posts, Str username, const char *path, Arena *a) { DIR *d = opendir(path); if (!d) return; post_stats.user_count++; for (struct dirent *de; (de = readdir(d)); ) { if (*de->d_name == '.') continue; Post p = { 0 }; if (str_to_timespec(str_from_cstr(de->d_name), &p.timestamp)) continue; if (timestamp_invalid(&p.timestamp)) continue; p.user = str_dup(username, a); p.path = cstr_fmt(a, "%s/%s", path, de->d_name); DA_PUSH(posts, p); } closedir(d); } void posts_gather(PostList *posts, Arena *a) { DIR *d = opendir("/home/"); if (!d) return; for (struct dirent *de; (de = readdir(d)); ) { if (*de->d_name == '.') continue; posts_gather_from(posts, str_from_cstr(de->d_name), cstr_fmt(a, "/home/%s/.bink", de->d_name), a); } closedir(d); posts_gather_from(posts, S("our"), "/town/our/data/.bink", a); } void posts_load(PostList *posts, Arena *a) { post_stats.post_count = posts->len; qsort(posts->data, posts->len, sizeof(Post), post_cmp); if (posts->len > POST_LIMIT) posts->len = POST_LIMIT; for (int i = 0; i < posts->len; i++) { Post *p = &posts->data[i]; FILE *f = fopen(p->path, "r/o"); if (!f) { log_err("couldn't open %s", p->path); continue; } if (read_all(f, &p->text, a)) { log_err("couldn't read %s", p->path); fclose(f); continue; } fclose(f); } } void posts_refresh(PostList *posts, Arena *a) { posts->len = 0; post_stats_init(&post_stats); posts_gather(posts, a); posts_load(posts, a); post_stats_fini(&post_stats); } /* word wrapping */ static inline int ch_space(char c) { return c <= 0x20; } int str_cat_wrap(Str *out, Str s, int width, Arena *a) { int lines = 1; int x = 1; for (int i = 0; i < s.n; i++) { if (ch_space(s.s[i])) { int w = 0; for (int j = i + 1; j < s.n && !ch_space(s.s[j]); j++) w++; if (x + w >= width || s.s[i] == '\n') { x = 1; str_catc(out, '\n', a); lines++; } else if (x < width) { str_catc(out, s.s[i], a); x++; } } else { str_catc(out, s.s[i], a); x++; } } if (x == 1) lines--; return lines; } /* display */ typedef struct { Post *src; Str text; int lines; struct { unsigned drawn : 1; unsigned has_mention : 1; }; } GfxPost; typedef struct { int len; GfxPost *posts; } Gfx; int gfx_post_width(void) { return getmaxx(stdscr) - GFX_MARGIN_X * 2; } int gfx_wrap_width(void) { return gfx_post_width() - GFX_TEXT_MARGIN_X * 2; } void gfx_load_post(GfxPost *post, Post *src, int width, Arena *a) { memset(post, 0, sizeof(GfxPost)); post->src = src; /* subtract two to make room for border */ post->lines = str_cat_wrap(&post->text, src->text, width, a); str_catc(&post->text, '\0', a); post->text.n--; } void gfx_load(Gfx *gfx, PostList *posts, Arena *a) { int width = gfx_wrap_width(); gfx->len = posts->len; gfx->posts = new_arr(a, GfxPost, gfx->len); for (int i = 0; i < gfx->len; i++) { gfx_load_post(&gfx->posts[i], &posts->data[i], width, a); } } void gfx_vline(int y, int x, int len, int c) { for (int i = y; i < y + len; i++) mvaddch(i, x, c); } void gfx_hline(int y, int x, int len, int c) { for (int i = x; i < x + len; i++) mvaddch(y, i, c); } void gfx_predraw_post(GfxPost *post) { post->has_mention = !regexec(&re_mention, post->text.s, 0, NULL, 0); } int gfx_post_height(GfxPost *post) { return post->lines + 2 + GFX_TEXT_MARGIN_Y * 2; } void gfx_draw_post(GfxPost *post, int y, int x, int width) { int height = gfx_post_height(post); if (!post->drawn) { gfx_predraw_post(post); post->drawn = 1; } int left = x, top = y, right = x + width - 1, bottom = y + height - 1; color_set(CPAIR_BORDER, 0); gfx_hline(top, left, right - left, ACS_HLINE); gfx_vline(top, left, height - 1, ACS_VLINE); gfx_vline(top, right, height - 1, ACS_VLINE); gfx_hline(bottom, left, right - left, ACS_HLINE); mvaddch(bottom, right, ACS_LRCORNER); mvaddch(bottom, left, ACS_LLCORNER); mvaddch(top, left, ACS_ULCORNER); mvaddch(top, right, ACS_URCORNER); color_set(CPAIR_TIME, 0); Str s = str_trim(str_from_cstr(ctime(&(time_t){post->src->timestamp.tv_sec}))); mvaddnstr(y, right - s.n, s.s, s.n); color_set(CPAIR_USER, 0); mvaddnstr(y, x + 2, post->src->user.s, post->src->user.n); color_set(post->has_mention ? CPAIR_MENTION : CPAIR_TEXT, 0); Str txt = post->text; for (int i = 0; i < post->lines; i++) { Cut c = str_cut(txt, '\n'); mvaddnstr(top + i + 1 + GFX_TEXT_MARGIN_Y, left + 1 + GFX_TEXT_MARGIN_X, c.head.s, c.head.n); txt = c.tail; } } void gfx_draw(Gfx *gfx, int cur) { int width = gfx_post_width(); erase(); for (int i = cur, y = GFX_MARGIN_Y; i < gfx->len && y < getmaxy(stdscr) - GFX_MARGIN_Y; i++) { gfx_draw_post(&gfx->posts[i], y, GFX_MARGIN_X, width); y += gfx_post_height(&gfx->posts[i]) + GFX_POST_SPACING; } } /* will skip at least one post, so not suitable for just scrolling one line at * a time like bink.py does */ int gfx_line_skip(Gfx *gfx, int cur, int amt) { int sign = amt < 0 ? -1 : 1; amt = abs(amt); while (amt > 0 && (sign < 0 ? cur > 0 : cur + 1 < gfx->len)) { amt -= gfx_post_height(&gfx->posts[cur]) + 1; cur += sign; } if (amt < 0) { if (sign < 0 && cur + 1 < gfx->len) cur++; else if (sign > 0 && cur > 0) cur--; } return cur; } /* post creation & editing */ char *get_editor(void) { char *editor = getenv("EDITOR"); if (!editor) editor = "nano"; return editor; } int is_post_mine(Post *post) { return str_eql(post->user, str_from_cstr(getlogin())); } void edit_post(Post *post, Arena *temp) { system(cstr_fmt(temp, "%s %s", get_editor(), post->path)); } void ensure_dotbink_exists(Arena *temp) { const char *binkdir = cstr_fmt(temp, "/home/%s/.bink", getlogin()); if (mkdir(binkdir, 0775) && errno != EEXIST) { err(1, "couldn't create %s", binkdir); } } void new_post(Arena *temp) { struct timespec ts; /* create and edit post in a temp file */ if (clock_gettime(CLOCK_REALTIME, &ts)) { log_err("clock failure"); return; } const char *tmpf = cstr_fmt(temp, "/tmp/cbink_%s_%U%09u.txt", getlogin(), (uint64_t)ts.tv_sec, (uint32_t)ts.tv_nsec); if (system(cstr_fmt(temp, "%s %s", get_editor(), tmpf))) return; Str body = { 0 }; FILE *f = fopen(tmpf, "r/o"); if (!f) return; if (read_all(f, &body, temp)) { fclose(f); log_err("couldn't read %s", tmpf); return; } fclose(f); if (remove(tmpf)) log_err("failed to remove %s", tmpf); body = str_trim(body); if (body.n < 1) return; /* write it to .bink (with updated timestamp) */ if (clock_gettime(CLOCK_REALTIME, &ts)) { log_err("clock failure"); return; } const char *outf = cstr_fmt(temp, "/home/%s/.bink/%U%09u", getlogin(), (uint64_t)ts.tv_sec, (uint32_t)ts.tv_nsec); f = fopen(outf, "w/o"); if (!f) { log_err("failed to open %s", outf); return; } if (fwrite(body.s, 1, body.n, f) != (size_t)body.n) { log_err("write error in %s", outf); fclose(f); return; } fclose(f); } /* curses setup/cleanup */ void init_curses(void) { setlocale(LC_ALL, ""); initscr(); start_color(); init_color(COLOR_BLACK, 0, 0, 0); cbreak(); noecho(); intrflush(stdscr, FALSE); keypad(stdscr, TRUE); curs_set(0); #define X CPAIR_INIT_X CPAIR_LIST #undef X } void fini_curses(void) { curs_set(1); endwin(); } /* main */ int main(void) { /* init */ Arena post_arena = { 0 }; Arena gfx_arena = { 0 }; Arena temp_arena = { 0 }; arena_reserve(&post_arena, 128 << 10L); arena_reserve(&gfx_arena, 128 << 10L); ensure_dotbink_exists(&temp_arena); PostList posts = { 0 }; Gfx gfx = { 0 }; regcomp(&re_mention, cstr_fmt(&temp_arena, "([ \t\n]+|^)[@~]?%s([:;,.!? \t\n]|$)", getlogin()), REG_EXTENDED | REG_ICASE | REG_NOSUB); init_curses(); posts_refresh(&posts, &post_arena); gfx_load(&gfx, &posts, &gfx_arena); int cur = 0; for (;;) { if (cur > posts.len - 1) cur = posts.len - 1; if (cur < 0) cur = 0; arena_reset(&temp_arena); gfx_draw(&gfx, cur); switch (tagline_status) { case TAGLINE_OK: color_set(CPAIR_TAGLINE_OK, 0); break; case TAGLINE_WARN: color_set(CPAIR_TAGLINE_WARN, 0); break; case TAGLINE_ERR: color_set(CPAIR_TAGLINE_ERR, 0); break; } mvaddstr(getmaxy(stdscr) - 1, getmaxx(stdscr) - strlen(tagline), tagline); tagline[0] = '\0'; int ch = getch(); if (ch == 'q') break; switch (ch) { case KEY_UP: case 'k': if (cur > 0) cur--; break; case KEY_DOWN: case 'j': if (cur + 1 < posts.len) cur++; break; case 'g': case KEY_HOME: cur = 0; break; case 'G': case KEY_END: cur = posts.len - 1; break; case ' ': case KEY_NPAGE: cur = gfx_line_skip(&gfx, cur, PAGE_LEN); break; case 'd': case 0x04 /* ^D */: cur = gfx_line_skip(&gfx, cur, HALF_PAGE_LEN); break; case 'b': case KEY_PPAGE: cur = gfx_line_skip(&gfx, cur, -PAGE_LEN); break; case 'u': case 0x15 /* ^U */: cur = gfx_line_skip(&gfx, cur, -HALF_PAGE_LEN); break; case 'e': if (is_post_mine(&posts.data[cur])) { fini_curses(); edit_post(&posts.data[cur], &temp_arena); init_curses(); goto refresh; } else { log_warn("not your post to edit!"); break; } case 'c': fini_curses(); new_post(&temp_arena); init_curses(); /* fallthrough */ case 'r': refresh: arena_reset(&post_arena); posts_refresh(&posts, &post_arena); tagline_set("%zu users, %zu posts, %zums", post_stats.user_count, post_stats.post_count, post_stats.gather_ns / 1000000); /* fallthrough */ case KEY_RESIZE: arena_reset(&gfx_arena); gfx_load(&gfx, &posts, &gfx_arena); break; case 'v': fini_curses(); system(cstr_fmt(&temp_arena, "less %s", posts.data[cur].path)); init_curses(); break; } } free(posts.data); regfree(&re_mention); arena_free(&post_arena); arena_free(&gfx_arena); arena_free(&temp_arena); fini_curses(); return 0; }