/* cbink.c -- -lncursesw * simple C client for bink * https://git.tilde.town/nebula/bink */ /* TODO: * * - make it so that page up/page down act based on number of lines * traversed, not abstract number of posts skipped * * - check bink.py to see if it gets the filename timestamp * (a) at the time you start editing; or * (b) at the time you actually post * **/ #define _POSIX_C_SOURCE 202506L #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 10 #define GFX_MARGIN_X 2 #define GFX_MARGIN_Y 1 #define GFX_TEXT_MARGIN_X 1 #define GFX_TEXT_MARGIN_Y 0 /* colors */ #define CPAIR_TEXT 0 #define CPAIR_USER 1 #define CPAIR_TIME 2 #define CPAIR_MENTION 3 #define CPAIR_BORDER 4 /* 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; /* logging */ /* TODO: change to put in a proper log file or something */ void log_warn(const char *fmt, ...) { va_list ap; va_start(ap, fmt); vfprintf(stderr, fmt, ap); fprintf(stderr, "\n"); va_end(ap); } /* 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; } /* formatting */ /* 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; /* 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) { const Post *pa = (const Post *)a; const Post *pb = (const Post *)b; return -ts_cmp(&pa->timestamp, &pb->timestamp); } /* @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; 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) { 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_warn("couldn't open %s", p->path); continue; } if (read_all(f, &p->text, a)) { log_warn("couldn't read %s", p->path); fclose(f); continue; } fclose(f); } } void posts_refresh(PostList *posts, Arena *a) { posts->len = 0; posts_gather(posts, a); posts_load(posts, a); } /* 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; 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; } int gfx_post_width(void) { return getmaxx(stdscr) - GFX_MARGIN_X * 2 - 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, bottom = y + height - 1; color_set(CPAIR_BORDER, 0); gfx_hline(top, left, width, ACS_HLINE); gfx_vline(top, left, height - 1, ACS_VLINE); gfx_vline(top, right, height - 1, ACS_VLINE); gfx_hline(bottom, left, width, 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, x + width - 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]) + 1; } } /* main */ 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); init_pair(CPAIR_BORDER, COLOR_BLUE, COLOR_BLACK); init_pair(CPAIR_USER, COLOR_YELLOW, COLOR_BLACK); init_pair(CPAIR_TIME, COLOR_BLUE, COLOR_BLACK); init_pair(CPAIR_TEXT, COLOR_WHITE, COLOR_BLACK); init_pair(CPAIR_MENTION, COLOR_BLACK, COLOR_WHITE); } void fini_curses(void) { curs_set(1); endwin(); } char *get_editor(void) { char *editor = getenv("EDITOR"); if (!editor) editor = "nano"; return editor; } int my_post(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; if (clock_gettime(CLOCK_REALTIME, &ts)) { err(1, "clock_gettime failure"); } Str t = str_fmt(temp, "%U%09U", (uint64_t)ts.tv_sec, (uint64_t)ts.tv_nsec); const char *tmpf = cstr_fmt(temp, "/tmp/cbink_%s_%S.txt", getlogin(), t); const char *outf = cstr_fmt(temp, "/home/%s/.bink/%S", getlogin(), t); 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_warn("couldn't read %s", tmpf); return; } fclose(f); if (remove(tmpf)) log_warn("failed to remove %s", tmpf); body = str_trim(body); if (body.n < 1) return; f = fopen(outf, "w/o"); if (!f) { log_warn("failed to open %s", outf); return; } if (fwrite(body.s, 1, body.n, f) != (size_t)body.n) { err(1, "write error in %s", outf); fclose(f); return; } fclose(f); } /* this will probably crash if there are zero posts */ 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); 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 'd': case KEY_NPAGE: case 0x04 /* ^D */: cur += PAGE_LEN; if (cur > posts.len - 1) cur = posts.len - 1; break; case 'b': case 'u': case KEY_PPAGE: case 0x15 /* ^U */: cur -= PAGE_LEN; if (cur < 0) cur = 0; break; case 'e': if (my_post(&posts.data[cur])) { fini_curses(); edit_post(&posts.data[cur], &temp_arena); init_curses(); } else { beep(); flash(); } goto refresh; case 'c': fini_curses(); new_post(&temp_arena); init_curses(); /* fallthrough */ case 'r': refresh: arena_reset(&post_arena); posts_refresh(&posts, &post_arena); /* 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; }