summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitconfig2
-rw-r--r--Makefile38
-rw-r--r--README.md24
-rw-r--r--arena.h149
-rw-r--r--main.c518
-rw-r--r--str.h152
-rw-r--r--strio.h217
7 files changed, 1100 insertions, 0 deletions
diff --git a/.gitconfig b/.gitconfig
new file mode 100644
index 0000000..ef0b399
--- /dev/null
+++ b/.gitconfig
@@ -0,0 +1,2 @@
+*.o
+cbink
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..47dcbdc
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,38 @@
+EXE = cbink
+RUNARGS =
+
+PREFIX ?= ${HOME}/.local
+BINDIR = ${PREFIX}/bin
+
+CFLAGS = -std=c17 -Wall -Wextra -Wpedantic -Os ${CFLAGS_${DEBUG}}
+LDFLAGS = -flto ${LDFLAGS_${DEBUG}}
+LDLIBS = -lncursesw
+
+DEBUG = 0
+GDB != { [ "$DISPLAY" ] && which gf2 2> /dev/null; } || which gdb
+
+CFLAGS_1 = -g -fsanitize=undefined
+LDFLAGS_1 = -g -fsanitize=undefined
+LDFLAGS_0 = -s
+
+OBJ != find -type f -name '*.c' | sed 's/\.c$$/.o/'
+
+.PHONY: run all clean
+
+all: ${EXE}
+run: ${EXE}
+	./${EXE} ${RUNARGS}
+debug: ${EXE}
+	${GDB} -ex start --args ./${EXE} ${RUNARGS}
+
+clean:
+	rm -fv ${EXE} ${OBJ}
+
+${EXE}: ${OBJ}
+	${CC} ${LDFLAGS} ${OBJ} -o ${EXE} ${LDLIBS}
+
+install: ${EXE}
+	cp ${EXE} ${BINDIR}/${EXE}
+
+uninstall:
+	rm ${BINDIR}/${EXE}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..15c4a0b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,24 @@
+# cbink
+
+a simple c client for the [bink](https://git.tilde.town/nebula/bink)
+microblogging platform.  there is no central server, only files in
+`/home/*/.bink`, named after the number of nanoseconds since the unix
+epoch at the time of their creation.
+
+## dependencies
+
+* a c17-compatible c compiler
+* libncursesw (with unicode support)
+
+## keyboard shortcuts
+
+* `k` / `Up` --- up
+* `j` / `Down` -- down
+* `d` / `Space` / `PageDown` / `Ctrl-D` --- page down
+* `b` / `u` / `PageUp` / `Ctrl-U` --- page up
+* `g` / `Home` -- top of feed
+* `G` / `End` -- bottom of feed
+* `r` -- refresh feed
+* `c` -- create new post
+* `v` -- view post at top of window in `less(1)`
+* `e` -- edit post at top of window (must be yours)
diff --git a/arena.h b/arena.h
new file mode 100644
index 0000000..045cde0
--- /dev/null
+++ b/arena.h
@@ -0,0 +1,149 @@
+#ifndef ARENA_H
+#define ARENA_H
+
+#include <stdint.h>
+#include <stddef.h>
+#include <string.h>
+
+typedef struct ArenaPg {
+	struct ArenaPg *prev, *next;
+	char *beg, *end;
+	char data[];
+} ArenaPg;
+
+typedef struct {
+	ArenaPg *pg;
+	char *beg;
+} ArenaMark;
+
+typedef struct {
+	ArenaPg *cur, *tail;
+} Arena;
+
+#define new(a, t)\
+	(t*)arena_zeroed(arena_alloc(a, sizeof(t), _Alignof(t)), sizeof(t))
+
+#define new_arr(a, t, n)\
+	arena_alloc(a, sizeof(t) * n, _Alignof(t))
+
+#define resize(a, p, old, new)\
+	arena_realloc(a, p, (old) * sizeof(*(p)), (new) * sizeof(*(p)),\
+			_Alignof(__typeof__(*(p))))
+
+void arena_free(Arena *a);
+
+void arena_save(Arena *a, ArenaMark *m);
+void arena_load(Arena *a, ArenaMark *m);
+
+void arena_reset(Arena *a);
+void arena_reserve(Arena *a, ptrdiff_t n);
+
+void *arena_alloc(Arena *a, ptrdiff_t n, ptrdiff_t align);
+void *arena_realloc(Arena *a, void *ptr, ptrdiff_t old, ptrdiff_t new, ptrdiff_t align);
+void *arena_zeroed(void *p, size_t n);
+
+#define ARENA_BACKEND_MALLOC 0
+#define ARENA_BACKEND_MMAP 1
+
+#ifndef ARENA_BACKEND
+#if defined(__linux__)
+#	define ARENA_BACKEND ARENA_BACKEND_MMAP
+#else
+#	define ARENA_BACKEND ARENA_BACKEND_MALLOC
+#endif
+#endif
+
+#ifdef ARENA_IMPL
+
+#include <stdio.h>
+#include <stdlib.h>
+static void arena_pg_alloc_fail(void) {
+	fprintf(stderr, "failed to allocate arena page\n");
+	abort();
+}
+
+#if ARENA_BACKEND == ARENA_BACKEND_MMAP
+#include <sys/mman.h>
+#include <unistd.h>
+#define ARENA_PG_SIZE sysconf(_SC_PAGESIZE)
+static inline void *arena_pg_alloc(ptrdiff_t n) {
+	void *p = mmap(NULL, n, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+	return p == MAP_FAILED ? NULL : p;
+}
+static inline void arena_pg_free(void *ptr, ptrdiff_t n) { munmap(ptr, n); }
+#elif ARENA_BACKEND == ARENA_BACKEND_MALLOC
+#define ARENA_PG_SIZE 8192
+static inline void *arena_pg_alloc(ptrdiff_t n) { return malloc(n); }
+static inline void arena_pg_free(void *ptr, ptrdiff_t n) { free(ptr); (void)n; }
+#endif
+
+void arena_free(Arena *a) {
+	while (a->tail) {
+		a->cur = a->tail->prev;
+		arena_pg_free(a->tail, (uintptr_t)(a->tail->end - (char*)a->tail));
+		a->tail = a->cur;
+	}
+}
+
+void arena_reserve(Arena *a, ptrdiff_t n) {
+	while (a->cur && a->cur->beg + n >= a->cur->end) a->cur = a->cur->next;
+	if (a->cur) return;
+	ptrdiff_t cap = n + sizeof(ArenaPg);
+	cap += (uintptr_t)-cap & (ARENA_PG_SIZE - 1);
+	ArenaPg *p = arena_pg_alloc(cap);
+	if (!p) arena_pg_alloc_fail();
+	p->next = NULL;
+	p->prev = a->tail;
+	p->beg = p->data;
+	p->end = (char*)p + cap;
+	if (a->tail) a->tail->next = p;
+	a->cur = (a->tail = p);
+}
+
+void *arena_alloc(Arena *a, ptrdiff_t n, ptrdiff_t align) {
+	arena_reserve(a, n + (align - 1));
+	char *ptr = a->cur->beg + (-(uintptr_t)a->cur->beg & (align - 1));
+	a->cur->beg = ptr + n;
+	return ptr;
+}
+
+void *arena_realloc(Arena *a, void *ptr, ptrdiff_t old, ptrdiff_t new, ptrdiff_t align) {
+	if (a->cur && ptr == a->cur->beg - old && (char*)ptr + new < a->cur->end) {
+		a->cur->beg += new - old;
+		return ptr;
+	} else {
+		void *p = arena_alloc(a, new, align);
+		if (ptr) memcpy(p, ptr, old);
+		return p;
+	}
+}
+
+void *arena_zeroed(void *p, size_t n) {
+	memset(p, 0, n);
+	return p;
+}
+
+void arena_reset(Arena *a) {
+	if (!a->cur) return;
+	while (a->cur->prev) {
+		a->cur->beg = a->cur->data;
+		a->cur = a->cur->prev;
+	}
+	a->cur->beg = a->cur->data;
+}
+
+void arena_save(Arena *a, ArenaMark *m) {
+	m->pg = a->cur;
+	if (a->cur) m->beg = a->cur->beg;
+}
+
+void arena_load(Arena *a, ArenaMark *m) {
+	while (a->cur && a->cur != m->pg) {
+		a->cur->beg = a->cur->data;
+		a->cur = a->cur->prev;
+	}
+	if (a->cur) a->cur->beg = m->beg;
+}
+
+#endif
+#endif
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;
+}
diff --git a/str.h b/str.h
new file mode 100644
index 0000000..b10bc64
--- /dev/null
+++ b/str.h
@@ -0,0 +1,152 @@
+#ifndef STR_H
+#define STR_H
+
+#include <string.h>
+#include <stddef.h>
+
+#include "arena.h"
+
+typedef struct {
+	char *s;
+	ptrdiff_t n;
+} Str;
+
+typedef struct {
+	Str head, tail;
+} Cut;
+
+#define S(s) (Str){s,sizeof(s)-1}
+
+char *str_to_cstr(Str s, Arena *a);
+Str str_from_cstr(const char *s);
+int str_eql(Str a, Str b);
+int str_starts(Str a, Str b);
+int str_ends(Str a, Str b);
+void str_catc(Str *a, char b, Arena *m);
+Str str_skip(Str a, ptrdiff_t n);
+int is_space(char c);
+Str str_trim_left(Str a);
+Str str_trim_right(Str a);
+Str str_trim(Str a);
+Cut str_cut(Str s, char c);
+Str str_findc(Str s, char c);
+Str str_find(Str haystack, Str needle);
+int str_contains(Str a, Str b);
+Str str_dup(Str a, Arena *m);
+void str_cat(Str *a, Str b, Arena *m);
+Str str_replace_end(Str s, Str a, Str b, Arena *m);
+
+#ifdef STR_IMPL
+
+/* conversions */
+
+char *str_to_cstr(Str s, Arena *a) {
+	char *r = new_arr(a, char, s.n + 1);
+	memcpy(r, s.s, s.n);
+	r[s.n] = 0;
+	return r;
+}
+
+Str str_from_cstr(const char *s) {
+	return (Str) { (char*)s, strlen(s) };
+}
+
+/* pure functions */
+
+int str_eql(Str a, Str b) {
+	return a.n == b.n && !memcmp(a.s, b.s, b.n);
+}
+
+int str_starts(Str a, Str b) {
+	return a.n >= b.n && !memcmp(a.s, b.s, b.n);
+}
+
+int str_ends(Str a, Str b) {
+	return a.n >= b.n && !memcmp(&a.s[a.n - b.n], b.s, b.n);
+}
+
+void str_catc(Str *a, char b, Arena *m) {
+	a->s = resize(m, a->s, a->n, a->n + 1);
+	a->s[a->n++] = b;
+}
+
+Str str_skip(Str a, ptrdiff_t n) {
+	return (Str) { a.s + n, a.n - n };
+}
+
+int is_space(char c) {
+	return c == ' ' || c == '\t' || c == '\n' || c == '\r';
+}
+
+Str str_trim_left(Str a) {
+	while (a.n > 0 && is_space(a.s[0])) a.s++, a.n--;
+	return a;
+}
+
+Str str_trim_right(Str a) {
+	while (a.n > 0 && is_space(a.s[a.n - 1])) a.n--;
+	return a;
+}
+
+Str str_trim(Str a) {
+	return str_trim_left(str_trim_right(a));
+}
+
+/* splitting, searching */
+
+Cut str_cut(Str s, char c) {
+	char *p = memchr(s.s, c, s.n);
+	if (!p) {
+		return (Cut) { s, { &s.s[s.n], 0 } };
+	} else {
+		return (Cut) {
+			{ s.s, p - s.s },
+			{ p + 1, &s.s[s.n] - (p + 1) }
+		};
+	}
+}
+
+Str str_findc(Str s, char c) {
+	char *p = memchr(s.s, c, s.n);
+	return p ? (Str) { p, s.n - (p - s.s) } : (Str) { &s.s[s.n], 0 };
+}
+
+Str str_find(Str haystack, Str needle) {
+	if (needle.n < 1) return haystack;
+	while (haystack.n > 0) {
+		haystack = str_findc(haystack, needle.s[0]);
+		if (str_starts(haystack, needle)) break;
+		if (haystack.n > 0) haystack = str_skip(haystack, 1);
+	}
+	return haystack;
+}
+
+int str_contains(Str a, Str b) {
+	return str_find(a, b).n > 0;
+}
+
+/* allocating */
+
+Str str_dup(Str a, Arena *m) {
+	char *s = new_arr(m, char, a.n);
+	memcpy(s, a.s, a.n);
+	a.s = s;
+	return a;
+}
+
+void str_cat(Str *a, Str b, Arena *m) {
+	a->s = resize(m, a->s, a->n, a->n + b.n);
+	memcpy(&a->s[a->n], b.s, b.n);
+	a->n += b.n;
+}
+
+Str str_replace_end(Str s, Str a, Str b, Arena *m) {
+	if (!str_ends(s, a)) return s;
+	char *p = new_arr(m, char, s.n + b.n - a.n);
+	memcpy(p, s.s, s.n - a.n);
+	memcpy(p + s.n - a.n, b.s, b.n);
+	return (Str) { p, s.n + b.n - a.n };
+}
+
+#endif
+#endif
diff --git a/strio.h b/strio.h
new file mode 100644
index 0000000..c8bb21f
--- /dev/null
+++ b/strio.h
@@ -0,0 +1,217 @@
+#ifndef STRIO_H
+#define STRIO_H
+
+#include "str.h"
+#include "arena.h"
+
+int read_all(FILE *f, Str *buf, Arena *a);
+int next_line(Str *src, Str *line);
+void str_putf(Str s, FILE *f);
+void str_put(Str s);
+
+int str_to_u64(Str s, uint64_t *out);
+void str_cat_i64(Str *out, int64_t c, char pad_char, int min_width, Arena *a);
+void str_cat_u64(Str *out, uint64_t c, char pad_char, int min_width, Arena *a);
+void str_cat_fmtv(Str *out, Arena *arena, const char *fmt, va_list ap);
+void str_cat_fmt(Str *out, Arena *arena, const char *fmt, ...);
+Str str_fmtv(Arena *arena, const char *fmt, va_list ap);
+Str str_fmt(Arena *arena, const char *fmt, ...);
+const char *cstr_fmt(Arena *arena, const char *fmt, ...);
+
+#ifdef STRIO_IMPL
+
+#include <stdio.h>
+#include <stdint.h>
+
+static inline long read_all_file_size(FILE *f) {
+	fseek(f, 0, SEEK_END);
+	long t = ftell(f);
+	fseek(f, 0, SEEK_SET);
+	return t > 0 ? t : -1;
+}
+
+int read_all(FILE *f, Str *buf, Arena *a) {
+	if (!f) return -1;
+	long sz = read_all_file_size(f);
+	if (sz < 1) {
+		ptrdiff_t cap = 4096;
+		buf->s = new_arr(a, char, cap);
+		buf->n = 0;
+		while (!feof(f)) {
+			size_t n = fread(&buf->s[buf->n], 1, cap - buf->n, f);
+			if (n < 1) break;
+			buf->n += n;
+			if (buf->n >= cap) {
+				size_t c = cap;
+				while (buf->n >= cap) cap <<= 1;
+				buf->s = resize(a, buf->s, c, cap);
+			}
+		}
+	} else {
+		buf->n = sz;
+		buf->s = new_arr(a, char, sz);
+		size_t sz = fread(buf->s, 1, buf->n, f);
+		if (sz < (size_t)buf->n) return -1;
+	}
+	return ferror(f) ? -1 : 0;
+}
+
+int next_line(Str *src, Str *line) {
+	if (src->n < 1) return 0;
+	line->s = src->s;
+	char *newln = memchr(src->s, '\n', src->n);
+	line->n = newln ? newln - src->s : src->n;
+	src->s += line->n + 1;
+	src->n -= line->n + 1;
+	if (line->n > 0 && line->s[line->n-1] == '\r') line->n--;
+	return 1;
+}
+
+void str_putf(Str s, FILE *f) {
+	fwrite(s.s, 1, s.n, f);
+}
+
+void str_put(Str s) {
+	str_putf(s, stdout);
+}
+
+/* formatted conversion */
+
+int str_to_u64(Str s, uint64_t *out) {
+	if (s.n < 1) return -1;
+	uint64_t acc = 0;
+	for (int i = 0; i < s.n; i++) {
+		char c = s.s[i];
+		if (!(c >= '0' && c <= '9')) return -1;
+		acc = (acc * 10) + (c - '0');
+	}
+	*out = acc;
+	return 0;
+}
+
+static void str_cat_u64_(char buf[32], int *n, uint64_t c) {
+	int i = 0;
+	buf[31] = '\0';
+	do {
+		buf[32 - ++i] = (c % 10) + '0';
+		c /= 10;
+	} while (c);
+	*n = i;
+}
+
+void str_cat_u64(Str *out, uint64_t c, char pad_char, int min_width, Arena *a) {
+	int n;
+	/* more than enough for the largest 64-bit number
+	 * log_10(1 << 64) ~= 19.3 digits max */
+	char buf[32];
+	str_cat_u64_(buf, &n, c);
+	while (n < min_width && ++n < 32) buf[32-n] = pad_char;
+	str_cat(out, (Str) { &buf[sizeof(buf) - n], n }, a);
+}
+
+void str_cat_i64(Str *out, int64_t c, char pad_char, int min_width, Arena *a) {
+	/* more than enough for the largest 64-bit number
+	 * log_10(1 << 64) ~= 19.3 digits max */
+	int n, neg = 0;
+	char buf[32];
+	if (c < 0) neg = 1, c = -c;
+	str_cat_u64_(buf, &n, c);
+	if (neg) buf[sizeof(buf) - ++n] = '-';
+	while (n < min_width && ++n < 32) buf[32-n] = pad_char;
+	str_cat(out, (Str) { &buf[sizeof(buf) - n], n }, a);
+}
+
+/* IMPORTANT: this is not and will not be printf() compatible
+ *
+ * %s - c string
+ * %S - Str
+ * %i - int32
+ * %I - int64
+ * %u - uint32
+ * %U - uin64
+ *
+ **/
+void str_cat_fmtv(Str *out, Arena *arena, const char *fmt, va_list ap) {
+	size_t n = strlen(fmt);
+	for (size_t i = 0; i < n; i++) {
+		const char *mch = memchr(&fmt[i], '%', n - i);
+		if (!mch) {
+			str_cat(out, (Str) { (char*)&fmt[i], n - i }, arena);
+			break;
+		}
+		size_t skip = mch - &fmt[i];
+		if (mch != &fmt[i]) {
+			str_cat(out, (Str) { (char*)&fmt[i], skip }, arena);
+			i += skip;
+		}
+		if (i + 1 < n) {
+			int zero_pad = 0, min_width = 0;
+			i++;
+			if (fmt[i] == '0') {
+				zero_pad = 1;
+				i++;
+			}
+			while (i < n && fmt[i] >= '0' && fmt[i] <= '9') {
+				min_width = min_width * 10 + (fmt[i] - '0');
+				i++;
+			}
+			if (i >= n) break;
+			switch (fmt[i]) {
+			case 's':
+				str_cat(out, str_from_cstr(va_arg(ap, const char *)), arena);
+				break;
+			case 'S':
+				str_cat(out, va_arg(ap, Str), arena);
+				break;
+			case 'i':
+				str_cat_i64(out, va_arg(ap, int32_t), zero_pad?'0':' ', min_width, arena);
+				break;
+			case 'I':
+				str_cat_i64(out, va_arg(ap, int64_t), zero_pad?'0':' ', min_width, arena);
+				break;
+			case 'u':
+				str_cat_u64(out, va_arg(ap, uint32_t), zero_pad?'0':' ', min_width, arena);
+				break;
+			case 'U':
+				str_cat_u64(out, va_arg(ap, uint64_t), zero_pad?'0':' ', min_width, arena);
+				break;
+			default:
+				str_catc(out, fmt[i], arena);
+				break;
+			}
+		}
+	}
+}
+
+void str_cat_fmt(Str *out, Arena *arena, const char *fmt, ...) {
+	va_list ap;
+	va_start(ap, fmt);
+	str_cat_fmtv(out, arena, fmt, ap);
+	va_end(ap);
+}
+
+Str str_fmtv(Arena *arena, const char *fmt, va_list ap) {
+	Str s = { 0 };
+	str_cat_fmtv(&s, arena, fmt, ap);
+	return s;
+}
+
+Str str_fmt(Arena *arena, const char *fmt, ...) {
+	va_list ap;
+	va_start(ap, fmt);
+	Str r = str_fmtv(arena, fmt, ap);
+	va_end(ap);
+	return r;
+}
+
+const char *cstr_fmt(Arena *arena, const char *fmt, ...) {
+	va_list ap;
+	va_start(ap, fmt);
+	Str r = str_fmtv(arena, fmt, ap);
+	str_catc(&r, '\0', arena);
+	va_end(ap);
+	return r.s;
+}
+
+#endif
+#endif