(* 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)