aboutsummaryrefslogtreecommitdiff
path: root/colitur-x.c
blob: 03e27936261ed9891943c03503b4d4509feddbe9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
#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 <X11/Xlib.h>
#include <X11/Xft/Xft.h>
#include <X11/keysym.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

#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 Pixmap buf;              /* draw off-screen, then blit */
static GC gc;
static int bufw, bufh;
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;
}

/* The header line is DETECTED, not assumed. Skipping the first line
   unconditionally punishes the obvious way to look at one month --
   `... | grep "^2027-04" | colitur-x` -- by silently eating the 1st,
   which is a filter this program should compose with rather than fight.
   A header is exactly the line beginning "date,"; anything else is a day. */
static int is_header(const char *line)
{
	return strncmp(line, "date,", 5) == 0;
}

static void load(void)
{
	char buf[4096];
	int first = 1;
	while (nrows < MAXDAYS && fgets(buf, sizeof buf, stdin)) {
		buf[strcspn(buf, "\n")] = 0;
		if (first && is_header(buf)) { first = 0; continue; }
		first = 0;
		if (buf[0] == 0) continue;
		char *dup = strdup(buf);
		if (!dup) break;
		if (split(dup, rows[nrows].f) >= 12) nrows++; else free(dup);
	}
}

/* Start on TODAY if the input covers it. Reading the clock is a
   presentation choice and belongs here: colitur's own kernel is pure and
   deliberately never asks what day it is, which is what makes it testable
   to the year 9999. A viewer has no such duty. */
static void select_today(void)
{
	time_t t = time(NULL);
	struct tm tm;
	if (!localtime_r(&t, &tm)) return;
	char iso[11];
	strftime(iso, sizeof iso, "%Y-%m-%d", &tm);
	for (int i = 0; i < nrows; i++)
		if (!strcmp(rows[i].f[0], iso)) { cur = i; return; }
}

/* --- 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);
}

/* An n-pixel ring just inside (x,y,w,h). */
static void ring(XftColor *c, int x, int y, int w, int h, int n)
{
	for (int k = 0; k < n; k++) {
		XftDrawRect(xd, c, x + k,         y + k,         w - 2*k, 1);
		XftDrawRect(xd, c, x + k,         y + h - 1 - k, w - 2*k, 1);
		XftDrawRect(xd, c, x + k,         y + k,         1,       h - 2*k);
		XftDrawRect(xd, c, x + w - 1 - k, y + k,         1,       h - 2*k);
	}
}

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;
}

/* CX_DUMP=path writes one frame as a PPM and exits. It reads back the
   off-screen PIXMAP, not the window: a window's contents are not retained
   while it is obscured or on an unfocused tag, so screenshotting one can
   return solid black while the program draws perfectly. */
static void dump_and_exit(const char *path, int w, int h)
{
	XImage *im = XGetImage(dpy, buf, 0, 0, w, h, AllPlanes, ZPixmap);
	FILE *f = im ? fopen(path, "wb") : NULL;
	if (!f) { fprintf(stderr, "CX_DUMP: cannot write %s\n", path); exit(1); }
	fprintf(f, "P6\n%d %d\n255\n", w, h);
	for (int y = 0; y < h; y++)
		for (int x = 0; x < w; x++) {
			unsigned long px = XGetPixel(im, x, y);
			unsigned char rgb[3] = { (unsigned char)((px >> 16) & 0xff),
			                         (unsigned char)((px >> 8) & 0xff),
			                         (unsigned char)(px & 0xff) };
			fwrite(rgb, 1, 3, f);
		}
	fclose(f);
	XDestroyImage(im);
	fprintf(stderr, "CX_DUMP: wrote %s (%dx%d)\n", path, w, h);
	exit(0);
}

static void redraw(void)
{
	XWindowAttributes wa;
	XGetWindowAttributes(dpy, win, &wa);
	if (!buf || wa.width != bufw || wa.height != bufh) {
		if (xd)  { XftDrawDestroy(xd); xd = NULL; }
		if (buf) { XFreePixmap(dpy, buf); }
		buf = XCreatePixmap(dpy, win, wa.width, wa.height,
		                    DefaultDepth(dpy, DefaultScreen(dpy)));
		xd = XftDrawCreate(dpy, buf, vis, cmap);
		bufw = wa.width; bufh = wa.height;
	}
	fill(col_bg, 0, 0, wa.width, wa.height);

	XftColor head, detail, sel_dark, sel_light, grid;
	alloc(col_head, &head); alloc(col_detail, &detail);
	alloc(col_sel_dark, &sel_dark); alloc(col_sel_light, &sel_light);
	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) {
			/* dark ring outside, bright ring inside: readable on a white
			   cell and on a red one without knowing which it is */
			ring(&sel_dark,  x, y, CELL_W - 2, CELL_H - 2, SEL_RING);
			ring(&sel_light, x + SEL_RING, y + SEL_RING,
			     CELL_W - 2 - 2*SEL_RING, CELL_H - 2 - 2*SEL_RING, SEL_RING);
		}

		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")) {
			/* badge: filled with the cell's FOREGROUND, glyph in its
			   BACKGROUND, so it inverts against any liturgical colour */
			int gw = 0, bw2, bx, by = y + 5, bh2 = 17;
			XGlyphInfo gi;
			XftTextExtentsUtf8(dpy, fnt, (FcChar8 *)rank1_glyph,
			                   strlen(rank1_glyph), &gi);
			gw = gi.xOff;
			bw2 = gw + 10;
			bx = x + CELL_W - 4 - bw2;
			XftDrawRect(xd, &fg, bx, by, bw2, bh2);
			XftDrawStringUtf8(xd, &bg, fnt, bx + 5, by + 13,
			                  (FcChar8 *)rank1_glyph, strlen(rank1_glyph));
		}

		/* 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);

	XCopyArea(dpy, buf, win, gc, 0, 0, wa.width, wa.height, 0, 0);
	{
		const char *p = getenv("CX_DUMP");
		if (p) dump_and_exit(p, wa.width, wa.height);
	}
}

int main(void)
{
	load();
	if (!nrows) { fputs("colitur-x: no rows on stdin\n", stderr); return 2; }
	select_today();

	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);
	gc      = XCreateGC(dpy, win, 0, NULL);
	/* xd is created against the off-screen pixmap in redraw() */

	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;
}