diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-28 10:03:37 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-28 10:03:37 +0200 |
| commit | d0807440309e2357d51cd56c72a443072b0ba57f (patch) | |
| tree | 919c412b4814261f15c21f57f34d3fa8e7b92a72 /bin/pretty.ml | |
| parent | 4b3c18d61a243f1ec9c39e6279f7c687402b8dda (diff) | |
| parent | 8ba98d24499bedbc2491bd56268ea4f69aaddb2f (diff) | |
| download | colitur-d0807440309e2357d51cd56c72a443072b0ba57f.tar.gz colitur-d0807440309e2357d51cd56c72a443072b0ba57f.zip | |
Merge branch 'cli-pretty'v1.2.0
--pretty, and the --month/--date/--today flags that make it usable: a box
spans seven lines, so the flag that improved reading had removed grepping.
Diffstat (limited to 'bin/pretty.ml')
| -rw-r--r-- | bin/pretty.ml | 151 |
1 files changed, 151 insertions, 0 deletions
diff --git a/bin/pretty.ml b/bin/pretty.ml new file mode 100644 index 0000000..1b875d4 --- /dev/null +++ b/bin/pretty.ml @@ -0,0 +1,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) |
