/* cbink.c -- -lncursesw * simple C client for bink * https://git.tilde.town/nebula/bink */ /* TODO: * * - read ~user/.pronouns and put next to name * - add keybind to view info about a user, like .plan, .project etc * - 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 #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_PFP_MARGIN 1 #define GFX_TEXT_MARGIN_X 1 #define GFX_TEXT_MARGIN_Y 0 #define GFX_POST_SPACING 1 #define GFX_PRONOUNS_MARGIN 1 #define GFX_PRONOUNS_FMT "<%S>" #define GFX_PFP_MIN_LINES 3 #define GFX_PFP_MIN_COLS 7 #define GFX_PFP_MAX_LINES 8 #define GFX_PFP_MAX_COLS 16 #define MIN(x,y) ((x)<(y)?(x):(y)) #define MAX(x,y) ((x)>(y)?(x):(y)) /* colors */ #define CPAIR_INIT_X(cp, fg, bg) init_pair(cp, fg, bg); #define CPAIR_ENUM_X(cp, fg, bg) cp, #define CPAIR_LIST(X)\ X(CPAIR_TEXT , COLOR_WHITE , COLOR_BLACK)\ X(CPAIR_MENTION , COLOR_BLACK , COLOR_WHITE)\ X(CPAIR_USER , COLOR_YELLOW , COLOR_BLACK)\ X(CPAIR_PRONOUNS , COLOR_CYAN , COLOR_BLACK)\ X(CPAIR_TIME , COLOR_BLUE , COLOR_BLACK)\ X(CPAIR_PFP , COLOR_BLUE , COLOR_BLACK)\ X(CPAIR_PFP_SELF , COLOR_YELLOW , COLOR_BLACK)\ X(CPAIR_BANNER , COLOR_WHITE , 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) typedef enum { CPAIR_LIST(CPAIR_ENUM_X) } ColorPair; /* 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) /* utf8 */ int fast_utf8_width(Str s) { int n = 0; for (int i = 0; i < s.n; i++) { if (s.s[i] & 0x80) { i += stdc_leading_ones((unsigned char)s.s[i]) - 1; } n++; } return n; } int utf8_cp_to_byte(Str s, int dest) { int i, n; for (i = 0, n = 0; i < s.n && n < dest; i++) { if (s.s[i] & 0x80) { i += stdc_leading_ones((unsigned char)s.s[i]) - 1; } n++; } return i; } /* options */ typedef struct { struct { int pfp, pronouns; } see; struct { int left, right, top, bottom; } margin; } Options; /* globals */ regex_t re_mention; Options opt = { .see = { .pfp = 1, .pronouns = 1 }, .margin = { .left = 0, .right = 0 } }; /* 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; } /* users */ typedef struct { int lines, cols; Str *line; } Pic; typedef struct { Str name; Str pronouns; Pic pfp, banner; unsigned post_count; } User; void pic_load(Pic *pfp, Str src, int min_lines, int min_cols, Arena *a) { int lines = 0, cols = 0; for (Str s = src, l = {0}; next_line(&s, &l);) { int w = fast_utf8_width(l); if (w > cols) cols = w; lines++; } pfp->lines = MAX(lines, min_lines); pfp->cols = MAX(cols, min_cols); pfp->line = new_arr(a, Str, pfp->lines); lines = 0; for (Str s = src, l = {0}; next_line(&s, &l);) { pfp->line[lines++] = l; } while (lines < pfp->lines) { pfp->line[lines++] = S(""); } } void pic_load_path(Pic *dest, const char *path, Str or_src, int min_lines, int min_cols, Arena *a) { FILE *f = fopen(path, "r/o"); if (f) { Str pic_src = {0}; read_all(f, &pic_src, a); fclose(f); pic_load(dest, pic_src, min_lines, min_cols, a); } else { pic_load(dest, or_src, min_lines, min_cols, a); } } User *user_load(Str name, Str homedir, Str binkdir, Arena *a) { (void)binkdir; User *u = new(a, User); u->name = str_dup(name, a); pic_load_path(&u->pfp, cstr_fmt(a, "%S/.binkpfp", homedir), S( "bi __ \n" "nk_(''<\n" " (____)\n" ), GFX_PFP_MIN_LINES, GFX_PFP_MIN_COLS, a); pic_load_path(&u->banner, cstr_fmt(a, "%S/.binkbanner", homedir), S(""), 0, 0, a); FILE *f = fopen(cstr_fmt(a, "%S/.pronouns", homedir), "r/o"); if (f) { Str buf = { 0 }; read_all(f, &buf, a); fclose(f); buf = str_trim(buf); next_line(&buf, &u->pronouns); } return u; } /* posts */ typedef struct Post { struct timespec timestamp; const char *path; User *user; Str 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, Str homedir, Arena *a) { const char *path = cstr_fmt(a, "%S/.bink", homedir); DIR *d = opendir(path); if (!d) return; post_stats.user_count++; Str binkdir = str_from_cstr(path); User *user = user_load(username, homedir, binkdir, a); 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 = user; p.path = cstr_fmt(a, "%S/%s", binkdir, de->d_name); DA_PUSH(posts, p); user->post_count++; } 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), str_fmt(a, "/home/%s", de->d_name), a); } closedir(d); posts_gather_from(posts, S("our"), S("/town/our/data"), 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); } /* post utility functions */ int is_post_mine(Post *post) { return str_eql(post->user->name, str_from_cstr(getlogin())); } /* 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 post_left_margin(Post *post) { int lm = opt.margin.left; if (opt.see.pfp && post->user->pfp.cols >= lm) lm = post->user->pfp.cols + GFX_PFP_MARGIN; return lm; } int gfx_post_width(Post *post) { return getmaxx(stdscr) - GFX_MARGIN_X * 2 - post_left_margin(post) - opt.margin.right; } int gfx_wrap_width(Post *post) { return gfx_post_width(post) - 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) { 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], gfx_wrap_width(&posts->data[i]), 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_text_height(GfxPost *post) { return post->lines + 2 + GFX_TEXT_MARGIN_Y * 2; } int gfx_post_height(GfxPost *post) { int base = gfx_post_text_height(post); int l = MIN(post->src->user->pfp.lines, GFX_PFP_MAX_LINES); if (opt.see.pfp && l > base) return l; return base; } void gfx_draw_box(int y, int x, int height, int width) { color_set(CPAIR_BORDER, 0); int right = x + width - 1, bottom = y + height - 1; gfx_hline(y, x, right - x, ACS_HLINE); gfx_vline(y, x, height - 1, ACS_VLINE); gfx_vline(y, right, height - 1, ACS_VLINE); gfx_hline(bottom, x, right - x, ACS_HLINE); mvaddch(bottom, right, ACS_LRCORNER); mvaddch(bottom, x, ACS_LLCORNER); mvaddch(y, x, ACS_ULCORNER); mvaddch(y, right, ACS_URCORNER); } void gfx_cleared_box(int y, int x, int height, int width) { for (int i = y; i < y + height; i++) { for (int j = x; j < x + width; j++) { mvaddch(i, j, ' '); } } gfx_draw_box(y, x, height, width); } typedef enum { PIC_NONE, PIC_CTR_Y = 1, PIC_CTR_X = 2, PIC_LEFT = 4, PIC_TOP = 8, PIC_BORDER = 16, } PicDrawFlags; void gfx_draw_pic(Pic *pfp, int y, int x, int max_lines, int max_cols, PicDrawFlags f) { int w = 0; int h = MIN(pfp->lines, max_lines); for (int i = 0; i < h; i++) { w = MAX(w, MIN(fast_utf8_width(pfp->line[i]), max_cols)); } if (f & PIC_CTR_X) x -= w >> 1; if (f & PIC_CTR_Y) y -= h >> 1; if (f & PIC_LEFT) x -= w; if (f & PIC_TOP) y -= h; for (int i = 0; i < h; i++) { int n = utf8_cp_to_byte(pfp->line[i], max_cols); mvaddnstr(y + i, x, pfp->line[i].s, n); } if (f & PIC_BORDER) gfx_draw_box(y - 1, x - 1, h + 2, w + 2); } void gfx_draw_post(GfxPost *post, int y, int x, int width, Arena *scratch) { int height = gfx_post_text_height(post); int total_height = gfx_post_height(post); if (!post->drawn) { gfx_predraw_post(post); post->drawn = 1; } int left = x + post_left_margin(post->src), top = y + opt.margin.top; int right = left + width - 1; int self = is_post_mine(post->src); User *u = post->src->user; if (total_height > height) { int ydiff = (total_height - height) >> 1; top += ydiff; } gfx_draw_box(top, left, height, width); color_set(CPAIR_TIME, 0); Str s = str_trim(str_from_cstr(ctime(&(time_t){post->src->timestamp.tv_sec}))); mvaddnstr(top, right - s.n, s.s, s.n); if (opt.see.pfp) { color_set(self ? CPAIR_PFP_SELF : CPAIR_PFP, 0); Pic *pfp = &u->pfp; int pfpy = y; int h = MIN(pfp->lines, GFX_PFP_MAX_LINES); if (height > h) pfpy += (height - h) >> 1; gfx_draw_pic(pfp, pfpy, left - GFX_PFP_MARGIN, GFX_PFP_MAX_LINES, GFX_PFP_MAX_COLS, PIC_LEFT); } color_set(CPAIR_USER, 0); mvaddnstr(top, left + 2, u->name.s, u->name.n); if (opt.see.pronouns && u->pronouns.n) { color_set(CPAIR_PRONOUNS, 0); mvaddstr(top, left + 2 + fast_utf8_width(u->name) + GFX_PRONOUNS_MARGIN, cstr_fmt(scratch, GFX_PRONOUNS_FMT, u->pronouns)); } 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, Arena *scratch) { 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, gfx_post_width(gfx->posts[i].src), scratch); y += gfx_post_height(&gfx->posts[i]) + GFX_POST_SPACING; } } /* maybe should write an ui layer to not have to deal with all the manual layout stuff */ /* * ui_begin(&gfx, .align = UI_CTR_X | UI_CTR_Y); * ui_begin_row(&gfx); * ui_begin_col(&gfx, .align = UI_CTR_X | UI_CTR_Y); * ui_pic(&gfx, &user->banner); * ui_end_col(&gfx); * ui_end_row(&gfx); * ui_begin_row(&gfx); * ui_begin_col(&gfx, .align = UI_CTR_Y); * ui_pic(&gfx, &user->pfp); * ui_end_col(&gfx); * ui_begin_col(&gfx, .border = 1); * ui_text_row(&gfx, user->name); * ui_text_row(&gfx, user->pronouns); * ui_text_row(&gfx, str_fmt(scratch, "%u binks", user->post_count)); * ui_end_col(&gfx); * ui_end_row(&gfx); * ui_end(&gfx); * */ /* * ui_begin(&gfx, .align = UI_CTR_X | UI_CTR_Y); * UI_ROW(&gfx, { * ui_begin_col(&gfx, .align = UI_CTR_X | UI_CTR_Y); * ui_pic(&gfx, &user->banner); * ui_end_col(&gfx); * }); * UI_ROW(&gfx, { * UI_COL(&gfx, ui_pic(&gfx, &user->pfp)); * UI_COL(&gfx, { * ui_text_row(&gfx, user->name); * ui_text_row(&gfx, user->pronouns); * ui_text_row(&gfx, str_fmt(scratch, "%u binks", user->post_count)); * }, .border = 1); * }); * ui_end(&gfx); * */ void gfx_draw_user(User *user, Arena *scratch) { int cy = getmaxy(stdscr) >> 1, cx = getmaxx(stdscr) >> 1; const char *binks = cstr_fmt(scratch, "%u binks", user->post_count); int box_height = 3 + GFX_TEXT_MARGIN_Y; int box_width = MAX((int)strlen(binks), MAX(fast_utf8_width(user->name), fast_utf8_width(user->pronouns))); int bh = MIN(user->banner.lines, getmaxy(stdscr) - 3 - GFX_TEXT_MARGIN_Y * 2 - 4); int bw = MIN(user->banner.cols, getmaxx(stdscr) - GFX_TEXT_MARGIN_X * 2 - 4); int total_height = (bh ? bh + 1 : 0) + MAX(box_height, user->pfp.lines) + GFX_TEXT_MARGIN_Y * 2; int total_width = MAX(bw, user->pfp.cols + 2 + box_width) + GFX_TEXT_MARGIN_X * 2; gfx_cleared_box(cy - (total_height >> 1) - 2 - GFX_TEXT_MARGIN_Y, cx - (total_width >> 1) - 2 - GFX_TEXT_MARGIN_X, total_height + 4, total_width + 4); cy -= (total_height >> 1); if (bh > 0) { color_set(CPAIR_BANNER, 0); gfx_draw_pic(&user->banner, cy, cx, bh, bw, PIC_CTR_X); cy += bh + 1; } int left = cx - (total_width >> 1); if (bw > box_width + user->pfp.cols) left += (bw - box_width - user->pfp.cols) >> 1; color_set(CPAIR_PFP, 0); int pfpy = cy; if (box_height > user->pfp.lines) { pfpy += (box_height - user->pfp.lines) >> 1; } gfx_draw_pic(&user->pfp, pfpy, left, GFX_PFP_MAX_LINES, GFX_PFP_MAX_COLS, 0); color_set(CPAIR_USER, 0); left += user->pfp.cols + 2; if (user->pfp.lines > box_height) { cy += (user->pfp.lines - box_height) >> 1; } int y = cy; mvaddnstr(y++, left + GFX_TEXT_MARGIN_X, user->name.s, user->name.n); if (user->pronouns.n > 0) { color_set(CPAIR_PRONOUNS, 0); mvaddnstr(y++, left + GFX_TEXT_MARGIN_X, user->pronouns.s, user->pronouns.n); } color_set(CPAIR_TEXT, 0); mvaddstr(y++, left + GFX_TEXT_MARGIN_X, binks); } /* 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; } void init_curses(void); void fini_curses(void); int edit_file(const char *path, Arena *temp) { fini_curses(); int r = system(cstr_fmt(temp, "%s %s", get_editor(), path)); init_curses(); return r; } void edit_post(Post *post, Arena *temp) { edit_file(post->path, temp); } 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 (edit_file(tmpf, temp)) 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); CPAIR_LIST(CPAIR_INIT_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, &temp_arena); 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])) { edit_post(&posts.data[cur], &temp_arena); goto refresh; } else { log_warn("not your post to edit!"); break; } case 'p': edit_file(cstr_fmt(&temp_arena, "/home/%s/.binkpfp", getlogin()), &temp_arena); goto refresh; case 'c': new_post(&temp_arena); /* 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); goto resize; case '\t': opt.see.pfp = !opt.see.pfp; /* fallthrough */ case KEY_RESIZE: 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; case 'i': gfx_draw_user(posts.data[cur].user, &temp_arena); (void)getch(); break; } } free(posts.data); regfree(&re_mention); arena_free(&post_arena); arena_free(&gfx_arena); arena_free(&temp_arena); fini_curses(); return 0; }