aboutsummaryrefslogtreecommitdiff
path: root/bin/pretty.ml
blob: 53f8e8a7a8f7ddaa12139a3e32dbbe4bfa15d863 (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
(* 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