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
|
(* pretty -- terminal presentation for the row commands.
*
* This module is PRESENTATION ONLY. It never decides what a day is, only how
* it is shown, and nothing else in colitur reads it. That separation matters
* here more than usual: every other output format this program has is a
* contract something parses, and a change to one is a breaking change. This
* one is for a person reading a terminal, so it is free to change.
*
* The default output is deliberately unaffected. `--pretty` is opt-in, and a
* command that does not take it refuses rather than ignoring it, like every
* other flag here. *)
(* ------------------------------------------------------------ colour *)
(* Colour is emitted only when stdout is a terminal AND NO_COLOR is unset.
*
* The TTY test is what keeps `colitur day --pretty 2026 | less` and
* `> file` free of escape sequences: alignment survives the pipe, colour does
* not, which is the behaviour a person actually wants from both.
*
* NO_COLOR (https://no-color.org) is honoured on PRESENCE, whatever its
* value -- that is the convention's own rule, and reading it as a boolean
* ("NO_COLOR=0 means colour") is the usual way tools get it wrong. *)
let use_colour =
lazy
(match Sys.getenv_opt "NO_COLOR" with
| Some _ -> false
| None -> ( try Unix.isatty Unix.stdout with Unix.Unix_error _ -> false))
(* The six liturgical colours the engine can emit, as the nearest sensible
ANSI. Rose is the one that needs a note: it is a distinct liturgical colour
(Gaudete, Laetare), not a shade of red, so it gets bright magenta rather
than being folded into red -- collapsing them would lose a distinction the
calendar deliberately makes. Black is rendered bright-black (grey) because
true black is invisible on a dark terminal, which is where this is mostly
read. *)
let ansi_of_colour = function
| "white" -> "\027[97m"
| "red" -> "\027[31m"
| "green" -> "\027[32m"
| "violet" -> "\027[35m"
| "rose" -> "\027[95m"
| "black" -> "\027[90m"
| _ -> "\027[37m"
let reset = "\027[0m"
(* A filled circle in the day's colour. Chosen over tinting the whole row:
violet and black text are hard to read on a dark background, and a fully
coloured line reads as a status indicator (red = error) rather than as
liturgical information. *)
let swatch colour =
if Lazy.force use_colour then ansi_of_colour colour ^ "\xe2\x97\x8f" ^ reset
else
(* Without colour the swatch would be six identical dots, carrying nothing.
Print the colour's own initial instead, so the information survives a
pipe rather than silently vanishing with the escapes. *)
(match colour with
| "white" -> "w" | "red" -> "r" | "green" -> "g"
| "violet" -> "v" | "rose" -> "o" | "black" -> "k" | _ -> "?")
let dim s = if Lazy.force use_colour then "\027[2m" ^ s ^ reset else s
(* ------------------------------------------------------------ columns *)
(* Pad to a display width. Counts UTF-8 CODE POINTS rather than bytes: the
Latin names carry ae/oe ligatures and accents ("Sanctae Familiae", "Fremiot"
in some langs), and padding those by byte length under-pads the column by
one per multi-byte character, which shears the whole table. Not a full
grapheme or East-Asian-width implementation -- colitur's own languages are
Latin-script, and pretending otherwise would be more code claiming more
correctness than it has. *)
let utf8_len s =
let n = ref 0 in
String.iter (fun c -> if Char.code c land 0xC0 <> 0x80 then incr n) s;
!n
(* Pads to [w], and ALWAYS leaves at least one trailing space. The second
half matters: the Latin season names are long ("Tempus per annum ante
Septuagesimam" is 35 characters against a 22-wide column), and a pad that
returns an over-long value unchanged lets the next field butt straight
against it -- which is how "...Septuagesimam 1S. Hilarii" happened, the
week number and the name fused into one token. An over-wide row is untidy;
an ambiguous one is wrong. *)
let pad w s =
let l = utf8_len s in
if l >= w then s ^ " " else s ^ String.make (w - l) ' '
(* Column widths, fixed rather than measured over the year. Measuring would
align more tightly but needs the whole year buffered before the first line
prints, which loses streaming -- and `colitur day --pretty 9999 | head` is
a reasonable thing to do. These are sized from the longest real values in
the shipped data. *)
let w_date = 10
let w_dow = 4
let w_rank = 18
let w_season = 34
|