From 2fe328e64067f77070de3ba51f5cff3643afc47f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 23:00:32 +0200 Subject: colitur-x: raw Xlib month grid for colitur Reads colitur emit --format csv on stdin and draws the year as a month grid coloured by liturgical colour, with the selected day's readings below. A separate program on purpose: colitur's dependencies are frozen and its kernel is pure, so a front-end composes with it rather than living in it. Configuration is compile-time, the same model clectio uses. --- Makefile | 5 ++ README.md | 37 +++++++++ colitur-x.c | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ config.h | 23 ++++++ 4 files changed, 321 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 colitur-x.c create mode 100644 config.h diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c74426a --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +CFLAGS = -std=c99 -Os -Wall -Wextra $(shell pkg-config --cflags xft x11) +LDLIBS = $(shell pkg-config --libs xft x11) +colitur-x: colitur-x.c config.h + $(CC) $(CFLAGS) -o $@ colitur-x.c $(LDLIBS) +clean:; rm -f colitur-x diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7b8c6e --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# colitur-x + +A raw Xlib front-end for [colitur](../colitur): reads `colitur emit --format +csv` on stdin, draws the year as a month grid coloured by each day's +liturgical colour, and shows the selected day's readings underneath. + + colitur emit --format csv --from 2027 --to 2027 | colitur-x + +Keys: arrows or `hjkl` move, `n`/`p` change month, `q` quits. + +## Why it exists + +colitur is a Unix tool: it computes and prints. This is a separate program +that composes with it, so colitur's own dependencies stay frozen +(`dune alcotest qcheck qcheck-alcotest sexplib ppx_sexp_conv`) and its +kernel stays pure. + +## Build + + make + +`-std=c99 -Os -Wall -Wextra`, linking libX11 and libXft. ~17 KB. + +Configuration is compile-time, suckless style: edit `config.h` and rebuild. +There is deliberately no runtime config file — colitur itself owns the +calendar's configuration (language, sigla, overlays); this program owns only +how it is painted. + +## Limits, stated rather than discovered + +- X11 only. Runs under XWayland but is not a Wayland client. +- Read-only. It renders what colitur emits; it edits nothing. +- Xft is used for antialiasing and UTF-8. Dropping it for core X11 fonts + would reduce the dependency list to libX11 and libc alone, at the cost of + correct rendering for `æ`/`œ` and Polish diacritics. Not worth it. +- A tiling window manager will ignore the size hints and give the grid its + own tile; the layout handles that, the empty space is the WM's choice. diff --git a/colitur-x.c b/colitur-x.c new file mode 100644 index 0000000..def42fa --- /dev/null +++ b/colitur-x.c @@ -0,0 +1,256 @@ +#define _POSIX_C_SOURCE 200809L +/* colitur-x -- a raw Xlib front-end for colitur. + * + * Reads `colitur emit --format csv` on stdin, draws the year as a month + * grid coloured by the day's liturgical colour, and shows the selected + * day's readings underneath. Arrow keys / hjkl move, n and p change month, + * q quits. + * + * Links libX11 and libXft. No toolkit, no runtime config: edit config.h. + * SPDX-License-Identifier: GPL-3.0-or-later + */ +#include +#include +#include +#include +#include +#include + +#include "config.h" + +#define MAXDAYS 400 +#define F 16 /* CSV columns, see colitur emit --format csv */ + +typedef struct { char *f[F]; } Row; + +static Row rows[MAXDAYS]; +static int nrows; +static int cur; /* selected row */ + +static Display *dpy; +static Window win; +static int scr; +static XftFont *fnt, *fnt_big; +static XftDraw *xd; +static Visual *vis; +static Colormap cmap; + +/* --- tiny CSV reader: no quoting in colitur's output except commas in + citations, which are quoted. Handle both. ------------------------------ */ +static int split(char *line, char **out) +{ + int n = 0; char *p = line; + while (n < F) { + if (*p == '"') { + out[n++] = ++p; + while (*p && *p != '"') p++; + if (*p) *p++ = 0; + if (*p == ',') p++; + } else { + out[n++] = p; + while (*p && *p != ',') p++; + if (*p) *p++ = 0; else break; + } + } + return n; +} + +static void load(void) +{ + char buf[4096]; + if (!fgets(buf, sizeof buf, stdin)) return; /* header */ + while (nrows < MAXDAYS && fgets(buf, sizeof buf, stdin)) { + buf[strcspn(buf, "\n")] = 0; + char *dup = strdup(buf); + if (!dup) break; + if (split(dup, rows[nrows].f) >= 12) nrows++; + } +} + +/* --- colour helpers ----------------------------------------------------- */ +static void alloc(const char *spec, XftColor *c) +{ + XftColorAllocName(dpy, vis, cmap, spec, c); +} + +static void colours_for(const char *name, XftColor *bg, XftColor *fg) +{ + for (size_t i = 0; i < sizeof palette / sizeof *palette; i++) + if (!strcmp(palette[i].name, name)) { + alloc(palette[i].bg, bg); + alloc(palette[i].fg, fg); + return; + } + alloc("#808080", bg); alloc("#ffffff", fg); +} + +static void text(XftColor *c, XftFont *f, int x, int y, const char *s, int maxw) +{ + XGlyphInfo gi; + int len = strlen(s); + while (len > 0) { + XftTextExtentsUtf8(dpy, f, (FcChar8 *)s, len, &gi); + if (gi.xOff <= maxw) break; + len--; + } + XftDrawStringUtf8(xd, c, f, x, y, (FcChar8 *)s, len); +} + +static void fill(const char *spec, int x, int y, int w, int h) +{ + XftColor c; alloc(spec, &c); + XRenderColor rc = c.color; + XftDrawRect(xd, &c, x, y, w, h); + (void)rc; +} + +/* --- drawing ------------------------------------------------------------ */ +static int month_of(int i) { return atoi(rows[i].f[0] + 5); } +static int dom_of(int i) { return atoi(rows[i].f[0] + 8); } + +static int dow_of(int i) +{ + /* Sakamoto, on the ISO date in column 0. */ + static const int t[] = {0,3,2,5,0,3,5,1,4,6,2,4}; + int y = atoi(rows[i].f[0]), m = month_of(i), d = dom_of(i); + if (m < 3) y--; + return (y + y/4 - y/100 + y/400 + t[m-1] + d) % 7; +} + +static void redraw(void) +{ + XWindowAttributes wa; + XGetWindowAttributes(dpy, win, &wa); + fill(col_bg, 0, 0, wa.width, wa.height); + + XftColor head, detail, rank1, grid; + alloc(col_head, &head); alloc(col_detail, &detail); + alloc(col_rank1, &rank1); alloc(col_grid, &grid); + + int mon = month_of(cur); + char title[128]; + snprintf(title, sizeof title, "%.4s %02d %s", + rows[cur].f[0], mon, rows[cur].f[3]); + text(&head, fnt_big, PAD, 26, title, wa.width - 2*PAD); + + static const char *dows[] = {"Dom","Fer II","Fer III","Fer IV","Fer V","Fer VI","Sab"}; + for (int c = 0; c < 7; c++) + text(&head, fnt, PAD + c*CELL_W + 4, HEAD_H - 6, dows[c], CELL_W - 8); + + /* first row index of this month, and where it starts in the week */ + int first = 0; + while (first < nrows && month_of(first) != mon) first++; + int slot = dow_of(first); + + for (int i = first; i < nrows && month_of(i) == mon; i++, slot++) { + int r = slot / 7, c = slot % 7; + int x = PAD + c*CELL_W, y = HEAD_H + r*CELL_H; + + XftColor bg, fg; + colours_for(rows[i].f[10], &bg, &fg); + XftDrawRect(xd, &bg, x, y, CELL_W - 2, CELL_H - 2); + if (i == cur) + for (int k = 0; k < 3; k++) + XftDrawRect(xd, &rank1, x+k, y+k, CELL_W-2-2*k, 1), + XftDrawRect(xd, &rank1, x+k, y+CELL_H-3-k, CELL_W-2-2*k, 1), + XftDrawRect(xd, &rank1, x+k, y+k, 1, CELL_H-2-2*k), + XftDrawRect(xd, &rank1, x+CELL_W-3-k, y+k, 1, CELL_H-2-2*k); + + char d[8]; snprintf(d, sizeof d, "%d", dom_of(i)); + text(&fg, fnt_big, x + 6, y + 20, d, 40); + if (!strcmp(rows[i].f[8], "class-1")) + text(&rank1, fnt, x + CELL_W - 26, y + 18, "I", 20); + + /* Feast name over up to three lines, breaking on spaces that fit + the cell. Measured with XftTextExtents rather than guessed at a + character count, so it is right for any font in config.h. */ + const char *nm = rows[i].f[6]; + int avail = CELL_W - 12, ty = y + 36; + const char *p = nm; + for (int ln = 0; ln < 3 && *p; ln++) { + size_t take = strlen(p), best = 0; + XGlyphInfo gi; + for (size_t k = 1; k <= take; k++) { + XftTextExtentsUtf8(dpy, fnt, (FcChar8 *)p, k, &gi); + if (gi.xOff > avail) break; + if (p[k] == ' ' || p[k] == 0) best = k; + } + if (!best) { /* one long word: hard cut */ + for (size_t k = 1; k <= take; k++) { + XftTextExtentsUtf8(dpy, fnt, (FcChar8 *)p, k, &gi); + if (gi.xOff > avail) { best = k ? k - 1 : 1; break; } + best = k; + } + } + XftDrawStringUtf8(xd, &fg, fnt, x + 6, ty, (FcChar8 *)p, best); + ty += 13; + p += best; + while (*p == ' ') p++; + } + } + + int dy = wa.height - DETAIL_H; + XftDrawRect(xd, &grid, 0, dy - 1, wa.width, 1); + char line[512]; + snprintf(line, sizeof line, "%s %s", rows[cur].f[0], rows[cur].f[6]); + text(&detail, fnt_big, PAD, dy + 22, line, wa.width - 2*PAD); + snprintf(line, sizeof line, "%s · %s", rows[cur].f[9], rows[cur].f[11]); + text(&head, fnt, PAD, dy + 42, line, wa.width - 2*PAD); + snprintf(line, sizeof line, "Epistola %s", rows[cur].f[13]); + text(&detail, fnt, PAD, dy + 62, line, wa.width - 2*PAD); + snprintf(line, sizeof line, "Evangelium %s", rows[cur].f[14]); + text(&detail, fnt, PAD, dy + 80, line, wa.width - 2*PAD); +} + +int main(void) +{ + load(); + if (!nrows) { fputs("colitur-x: no rows on stdin\n", stderr); return 2; } + + if (!(dpy = XOpenDisplay(NULL))) { fputs("cannot open display\n", stderr); return 1; } + scr = DefaultScreen(dpy); + vis = DefaultVisual(dpy, scr); + cmap = DefaultColormap(dpy, scr); + + int w = PAD*2 + 7*CELL_W, h = HEAD_H + 6*CELL_H + DETAIL_H; + win = XCreateSimpleWindow(dpy, RootWindow(dpy, scr), 0, 0, w, h, 0, + BlackPixel(dpy, scr), BlackPixel(dpy, scr)); + XStoreName(dpy, win, "colitur"); + { /* Ask the window manager for exactly the grid we drew. Without + this a tiling or maximising WM picks its own size and the grid + floats in a field of background. */ + XSizeHints *sh = XAllocSizeHints(); + sh->flags = PSize | PMinSize; + sh->width = w; sh->height = h; + sh->min_width = w; sh->min_height = h; + XSetWMNormalHints(dpy, win, sh); + XFree(sh); + } + XSelectInput(dpy, win, ExposureMask | KeyPressMask | StructureNotifyMask); + XMapWindow(dpy, win); + + fnt = XftFontOpenName(dpy, scr, font); + fnt_big = XftFontOpenName(dpy, scr, font_big); + xd = XftDrawCreate(dpy, win, vis, cmap); + + for (XEvent ev;;) { + XNextEvent(dpy, &ev); + if (ev.type == Expose || ev.type == ConfigureNotify) redraw(); + else if (ev.type == KeyPress) { + KeySym k = XLookupKeysym(&ev.xkey, 0); + int mon = month_of(cur); + if (k == XK_q) break; + else if (k == XK_Right || k == XK_l) cur = (cur + 1) % nrows; + else if (k == XK_Left || k == XK_h) cur = (cur + nrows - 1) % nrows; + else if (k == XK_Down || k == XK_j) cur = (cur + 7) % nrows; + else if (k == XK_Up || k == XK_k) cur = (cur + nrows - 7) % nrows; + else if (k == XK_n) { while (cur < nrows-1 && month_of(cur) == mon) cur++; } + else if (k == XK_p) { while (cur > 0 && month_of(cur) == mon) cur--; + mon = month_of(cur); + while (cur > 0 && month_of(cur-1) == mon) cur--; } + redraw(); + } + } + XCloseDisplay(dpy); + return 0; +} diff --git a/config.h b/config.h new file mode 100644 index 0000000..5b242b4 --- /dev/null +++ b/config.h @@ -0,0 +1,23 @@ +/* colitur-x -- compile-time configuration, suckless style. + * Edit, `make`, done. There is no runtime config file by design. */ + +static const char *font = "monospace:size=10"; +static const char *font_big = "monospace:size=14:bold"; + +/* Liturgical colours, as the engine names them -> what to paint. */ +static const struct { const char *name, *bg, *fg; } palette[] = { + { "white", "#f7f5ef", "#1a1a1a" }, + { "red", "#b03030", "#ffffff" }, + { "violet", "#5a3a78", "#ffffff" }, + { "green", "#2f6b3a", "#ffffff" }, + { "black", "#202020", "#e0e0e0" }, + { "rose", "#d9a0b0", "#1a1a1a" }, +}; + +static const char *col_bg = "#141414"; /* window background */ +static const char *col_grid = "#3a3a3a"; /* cell borders */ +static const char *col_head = "#8a8a8a"; /* weekday headings */ +static const char *col_detail = "#e8e8e8"; /* detail pane text */ +static const char *col_rank1 = "#ffcc55"; /* I-class marker */ + +enum { CELL_W = 122, CELL_H = 74, PAD = 10, HEAD_H = 46, DETAIL_H = 92 }; -- cgit v1.3