#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 #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(col_unknown_bg, bg); alloc(col_unknown_fg, 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); /* Cells are sized from the window, not from a constant, so the grid fills its tile. Six rows is the most a month can need: 31 days starting on a Saturday spans six Sunday-started weeks. */ int CELL_W = (wa.width - 2 * PAD) / 7; int CELL_H = (wa.height - HEAD_H - DETAIL_H) / 6; if (CELL_W < CELL_W_MIN) CELL_W = CELL_W_MIN; if (CELL_H < CELL_H_MIN) CELL_H = CELL_H_MIN; 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); for (int c = 0; c < 7; c++) text(&head, fnt, PAD + c*CELL_W + 4, HEAD_H - 6, dow_headings[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); } int lh = fnt->ascent + fnt->descent; /* body line height */ int bigh = fnt_big->ascent + fnt_big->descent; char d[8]; snprintf(d, sizeof d, "%d", dom_of(i)); text(&fg, fnt_big, x + 6, y + fnt_big->ascent + 4, d, CELL_W / 3); if (!strcmp(rows[i].f[8], badge_rank)) { /* 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 + 4, bh2 = lh + 2; XGlyphInfo gi; XftTextExtentsUtf8(dpy, fnt, (FcChar8 *)badge_glyph, strlen(badge_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 + 1 + fnt->ascent, (FcChar8 *)badge_glyph, strlen(badge_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 + bigh + 8 + fnt->ascent; int room = (CELL_H - 2 - (bigh + 8)) / lh; /* lines that fit */ if (room > NAME_LINES_MAX) room = NAME_LINES_MAX; if (room < 1) room = 1; const char *p = nm; for (int ln = 0; ln < room && *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 += lh; 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, "%-10s %s", label_epistle, rows[cur].f[13]); text(&detail, fnt, PAD, dy + 62, line, wa.width - 2*PAD); snprintf(line, sizeof line, "%-10s %s", label_gospel, 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); } } static void usage(void) { fputs("usage: colitur-x [-g WxH] < calendar.csv\n", stderr); exit(2); } int main(int argc, char **argv) { int w = WIN_W, h = WIN_H; for (int i = 1; i < argc; i++) { if (!strcmp(argv[i], "-g") && i + 1 < argc) { if (sscanf(argv[++i], "%dx%d", &w, &h) != 2) usage(); } else usage(); } 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); win = XCreateSimpleWindow(dpy, RootWindow(dpy, scr), 0, 0, w, h, 0, BlackPixel(dpy, scr), BlackPixel(dpy, scr)); XStoreName(dpy, win, window_title); { /* A PREFERRED size and a MINIMUM, never a fixed one. Pinning the size was the earlier mistake: it fights a tiling window manager, which ignores it anyway, and it stops the grid making use of a larger tile. The layout reflows, so any size above the minimum is legitimate. */ XSizeHints *sh = XAllocSizeHints(); sh->flags = PSize | PMinSize; sh->width = w; sh->height = h; sh->min_width = PAD*2 + 7*CELL_W_MIN; sh->min_height = HEAD_H + 6*CELL_H_MIN + DETAIL_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; }