summary refs log tree commit diff
path: root/main.c
diff options
context:
space:
mode:
authorwrmr2025-06-23 00:24:47 -0400
committerwrmr2025-06-23 00:24:47 -0400
commitcf8d26862e654924a1742f0e67ea91fb900a70a2 (patch)
tree841aebda260cc876609332b58faf80f09e2f2c61 /main.c
initial commit
Diffstat (limited to 'main.c')
-rw-r--r--main.c518
1 files changed, 518 insertions, 0 deletions
diff --git a/main.c b/main.c
new file mode 100644
index 0000000..38ada6e
--- /dev/null
+++ b/main.c
@@ -0,0 +1,518 @@
+/* cbink.c -- -lncursesw
+ * simple C client for bink
+ * https://git.tilde.town/nebula/bink
+ */
+
+#define _POSIX_C_SOURCE 202506L
+
+#include <stdio.h>
+#include <locale.h>
+#include <stdlib.h>
+#include <dirent.h>
+#include <curses.h>
+#include <stdarg.h>
+#include <linux/limits.h> /* PATH_MAX */
+#include <time.h>
+#include <regex.h>
+#include <err.h>
+
+#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);
+}
+
+void posts_gather_from(PostList *posts, Str username, const char *path, Arena *a) {
+	if (chdir(path)) return;
+
+	DIR *bink = opendir(".bink");
+	if (!bink) return;
+
+	if (chdir(".bink")) {
+		log_warn("couldn't cd %s/.bink", path);
+		return;
+	}
+
+	char cwd[PATH_MAX];
+	getcwd(cwd, PATH_MAX);
+
+	struct dirent *de_bink;
+	while ((de_bink = readdir(bink))) {
+		if (!strcmp(de_bink->d_name, ".") || !strcmp(de_bink->d_name, "..")) continue;
+
+		Post p = { 0 };
+
+		if (str_to_timespec(str_from_cstr(de_bink->d_name), &p.timestamp)) continue;
+		if (timestamp_invalid(&p.timestamp)) continue;
+
+		p.user = str_dup(username, a);
+		p.path = cstr_fmt(a, "%s/%s", cwd, de_bink->d_name);
+
+		DA_PUSH(posts, p);
+	}
+	closedir(bink);
+}
+
+void posts_gather(PostList *posts, Arena *a) {
+	struct dirent *de_homedir;
+	DIR *home = opendir("/home/");
+	if (!home) err(1, "couldn't open /home/");
+	while ((de_homedir = readdir(home))) {
+		if (!strcmp(de_homedir->d_name, ".") || !strcmp(de_homedir->d_name, "..")) continue;
+		if (chdir("/home/")) err(1, "failed to change directory");
+		posts_gather_from(posts, str_from_cstr(de_homedir->d_name), de_homedir->d_name, a);
+	}
+	closedir(home);
+	posts_gather_from(posts, S("our"), "/town/our/data", 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_wrap_width(void) {
+	return getmaxx(stdscr) - 2 - GFX_MARGIN_X * 2 - 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 - 2, 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);
+	char *tstamp = ctime(&(time_t){post->src->timestamp.tv_sec});
+	mvaddnstr(y, x + width - strlen(tstamp) - 1, tstamp, strlen(tstamp) - 1);
+
+	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 wrap_width = gfx_wrap_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, wrap_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 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_curses();
+
+	/* 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);
+
+	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);
+
+	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;
+}