aboutsummaryrefslogtreecommitdiff
path: root/bin/pretty.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-28 10:03:37 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-28 10:03:37 +0200
commitd0807440309e2357d51cd56c72a443072b0ba57f (patch)
tree919c412b4814261f15c21f57f34d3fa8e7b92a72 /bin/pretty.ml
parent4b3c18d61a243f1ec9c39e6279f7c687402b8dda (diff)
parent8ba98d24499bedbc2491bd56268ea4f69aaddb2f (diff)
downloadcolitur-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.ml151
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)