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
|
(* 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
(* The colour NAME, tinted in that colour on a terminal and left as plain text
everywhere else. The word carries the information either way -- this is
what keeps `--pretty | tee ordo.txt` meaningful rather than a box with a
missing field. *)
let tint colour s =
if Lazy.force use_colour then ansi_of_colour colour ^ s ^ reset else s
(* ------------------------------------------------------------- boxes *)
(* One box per day, drawn in PURE ASCII -- '+', '-' and '|' only.
*
* No Unicode box-drawing characters, deliberately. The whole point of this
* format is that it can be pasted or piped into a document, a mail, a commit
* message or a plain-text ordo, and U+2500 and friends survive that journey
* only when every stage of it agrees about encoding and font. '+---+' has
* never once failed to render anywhere. *)
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
(* Inner width. 72 leaves the whole box at 74 columns, inside an 80-column
terminal and inside the 80-ish column a plain-text document usually wants,
with room for a quote marker or a couple of levels of indent. *)
let width = 72
let rule () = "+" ^ String.make (width + 2) '-' ^ "+"
(* A divider INSIDE the box. Corners are '+' rather than '|' for the same
reason the outer rule uses them: '+' at every junction is the shape every
ASCII table has had since forever, and a '|' there reads as a broken edge. *)
let divider () = "+" ^ String.make (width + 2) '-' ^ "+"
(* Capitalise a lowercase weekday/season word for display. The engine emits
these lowercase because they are DATA there; a box is prose. *)
let cap s =
if s = "" then s
else String.make 1 (Char.uppercase_ascii s.[0]) ^ String.sub s 1 (String.length s - 1)
let line s =
let l = utf8_len s in
let s = if l > width then
(* Truncated rather than overflowing: a box whose right edge does
not line up is worse than a clipped name, and the full value is
always available in the default output. *)
(let b = Buffer.create width in
let n = ref 0 in
String.iter (fun c ->
if Char.code c land 0xC0 <> 0x80 then incr n;
if !n <= width - 1 then Buffer.add_char b c) s;
Buffer.contents b ^ "~")
else s in
let l = utf8_len s in
"| " ^ s ^ String.make (width - l) ' ' ^ " |"
(* A heading row: left text, right text, flush to the two edges. Used for the
date and the day's colour, which are the two things you scan for. *)
let line_lr left right =
let ll = utf8_len left and rl = utf8_len right in
if ll + rl + 2 > width then line (left ^ " " ^ right)
else "| " ^ left ^ String.make (width - ll - rl) ' ' ^ right ^ " |"
(* Wrap on spaces to the inner width, so a long Latin title becomes two body
lines rather than being clipped. Falls back to a hard break for a single
token longer than the box, which no real celebration name is. *)
let wrap s =
if utf8_len s <= width then [ s ]
else begin
let words = String.split_on_char ' ' s in
let out = ref [] and cur = Buffer.create width in
let flush () =
if Buffer.length cur > 0 then (out := Buffer.contents cur :: !out; Buffer.clear cur)
in
List.iter (fun w ->
let cand = if Buffer.length cur = 0 then w else Buffer.contents cur ^ " " ^ w in
if utf8_len cand <= width then (Buffer.clear cur; Buffer.add_string cur cand)
else (flush (); Buffer.add_string cur w)) words;
flush ();
List.rev !out
end
(* A label/value body row, label column fixed so the values align down the box. *)
let line_kv label value =
let lw = 9 in
let l = utf8_len label in
let label = if l >= lw then label else label ^ String.make (lw - l) ' ' in
line (dim label ^ value)
|