diff options
| -rw-r--r-- | bin/dune | 1 | ||||
| -rw-r--r-- | bin/main.ml | 269 | ||||
| -rw-r--r-- | bin/pretty.ml | 97 | ||||
| -rw-r--r-- | man/colitur.1 | 34 | ||||
| -rw-r--r-- | test/cli.t | 43 |
5 files changed, 422 insertions, 22 deletions
@@ -1,5 +1,6 @@ (executable (name main) + (modules main pretty) (public_name colitur) (package colitur) ; [unix] ships with the OCaml compiler -- it is not a new entry in diff --git a/bin/main.ml b/bin/main.ml index 49f06e5..7c6427b 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -29,7 +29,7 @@ let easter_report y = same claim. [Colitur_kernel.Record.of_temporal] was already fully polymorphic over [('s, 'r)] (record.mli), so this is the same plumbing already done for `day`/`readings`/etc, not new library work. *) -let temporal_report ~rite y = +let temporal_report ~rite ~pretty y = let jan1 = match D.make ~year:y ~month:1 ~day:1 with | Ok t -> t | Error e -> failwith e @@ -54,10 +54,28 @@ let temporal_report ~rite y = let d = ref jan1 in while D.compare !d dec31 <= 0 do let r = record_of_day !d in - Printf.printf "%s %s %s %s %s %s %s\n" r.Colitur_kernel.Record.date - r.Colitur_kernel.Record.weekday r.Colitur_kernel.Record.season - (field r.Colitur_kernel.Record.week) r.Colitur_kernel.Record.slug - r.Colitur_kernel.Record.rank r.Colitur_kernel.Record.colour; + (if pretty then + (* The temporal cycle has no sanctoral, so there is never a + commemoration to indent -- same row shape as `day --pretty`, minus + that. Shown here from the flat Record rather than a Liturgical_day + because that is all this report ever had. *) + Printf.printf "%s %s %s%s %s%s\n" + (Pretty.pad Pretty.w_date r.Colitur_kernel.Record.date) + (Pretty.pad Pretty.w_dow + (let w = r.Colitur_kernel.Record.weekday in + String.sub w 0 (min 3 (String.length w)))) + (Pretty.swatch r.Colitur_kernel.Record.colour) + (Printf.sprintf " %s" (Pretty.pad Pretty.w_rank r.Colitur_kernel.Record.rank)) + (Pretty.pad Pretty.w_season + (match r.Colitur_kernel.Record.week with + | "" -> r.Colitur_kernel.Record.season + | w -> r.Colitur_kernel.Record.season ^ " " ^ w)) + r.Colitur_kernel.Record.slug + else + Printf.printf "%s %s %s %s %s %s %s\n" r.Colitur_kernel.Record.date + r.Colitur_kernel.Record.weekday r.Colitur_kernel.Record.season + (field r.Colitur_kernel.Record.week) r.Colitur_kernel.Record.slug + r.Colitur_kernel.Record.rank r.Colitur_kernel.Record.colour); d := D.add_days !d 1 done @@ -764,9 +782,114 @@ let resolved_year_days ~overlays y = let resolved_year_report ~line ~overlays y = List.iter line (resolved_year_days ~overlays y) -let day_report ~lang ~overlays y = resolved_year_report ~line:(day_line ~lang) ~overlays y -let readings_report ~lang ~sigla ~overlays y = - resolved_year_report ~line:(readings_line ~lang ~sigla) ~overlays y + +(* ---------------------------------------------------------------------- *) +(* --pretty: the same days, laid out for a person rather than for awk. + * + * These mirror the plain formatters above one for one and share their data; + * they do NOT recompute anything. The plain output stays byte-identical -- + * see bin/pretty.ml's own note on why this format is free to change while + * every other one is a contract. *) + +let pretty_day_generic ~date ~dow ~colour ~rank ~season ~week ~name ~comms = + let season_col = + match week with + | Some w -> Printf.sprintf "%s %s" season w + | None -> season + in + (* The prefix is built, then MEASURED, rather than its width being assumed + from the column constants. Those constants are a minimum, not a maximum + -- a season name longer than its column (the Latin "Tempus per annum ante + Septuagesimam" is 35 against 34) pushes everything after it right, and an + indent computed from the constants would then sit under the wrong column + on exactly the rows that have something to indent. *) + let prefix = + Printf.sprintf "%s %s " (Pretty.pad Pretty.w_date date) + (Pretty.pad Pretty.w_dow (String.sub dow 0 (min 3 (String.length dow)))) + in + let mid = + Printf.sprintf " %s %s" + (Pretty.pad Pretty.w_rank rank) + (Pretty.pad Pretty.w_season season_col) + in + Printf.printf "%s%s%s%s\n" prefix (Pretty.swatch colour) mid name; + (* Commemorations take their own indented line rather than a suffix: the EF + admits up to three, and appending them runs the row past any sensible + terminal width on precisely the days worth reading. The swatch counts as + one display column however it was rendered. *) + let indent = Pretty.utf8_len prefix + 1 + Pretty.utf8_len mid in + List.iter + (fun c -> Printf.printf "%s%s\n" (String.make indent ' ') (Pretty.dim ("+ " ^ c))) + comms + +let day_line_pretty ~lang (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = + let t = d.Colitur_kernel.Liturgical_day.temporal in + let cel = d.Colitur_kernel.Liturgical_day.observed in + let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in + let name = Colitur_naming.Lang.celebration lang slug_s in + pretty_day_generic + ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) + ~dow:(D.weekday_to_string t.Colitur_kernel.Temporal.weekday) + ~colour:(Colitur_kernel.Colour.to_string cel.Colitur_kernel.Celebration.colour) + ~rank:(Colitur_naming.Lang.rank lang (Rite_ef.Vocab_ef.rank_to_string cel.Colitur_kernel.Celebration.rank)) + ~season:(Colitur_naming.Lang.season lang (Rite_ef.Vocab_ef.season_to_string t.Colitur_kernel.Temporal.season)) + ~week:(match t.Colitur_kernel.Temporal.week with Some n -> Some (string_of_int n) | None -> None) + ~name:(if name = slug_s then slug_s else name) + ~comms:(List.map + (fun (c, _) -> + let cs = Colitur_kernel.Slug.to_string c.Colitur_kernel.Celebration.slug in + let cn = Colitur_naming.Lang.celebration lang cs in + if cn = cs then cs else cn) + d.Colitur_kernel.Liturgical_day.commemorations) + +let day_report ~lang ~pretty ~overlays y = + resolved_year_report + ~line:(if pretty then day_line_pretty ~lang else day_line ~lang) + ~overlays y +let readings_pretty_row ~date ~dow ~name ~first ~second ~gospel = + Printf.printf "%s %s %s\n" + (Pretty.pad Pretty.w_date date) + (Pretty.pad Pretty.w_dow (String.sub dow 0 (min 3 (String.length dow)))) + name; + let show label v = + if v <> "" && v <> "-" then + Printf.printf "%s%s %s\n" + (String.make (Pretty.w_date + Pretty.w_dow + 5) ' ') + (Pretty.dim (Pretty.pad 8 label)) v + in + (* Labelled and stacked rather than pipe-separated. A citation is what a + person came here to read, and "Isai 63:16b-17, 19b; 64:2-7" is hard to + find in a row of three when the separators are bars. The OF's second + reading simply does not print on the days that have none, which is most + of them. *) + show "First" first; + show "Second" second; + show "Gospel" gospel + +let readings_line_pretty ~lang ~sigla (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = + let cel = d.Colitur_kernel.Liturgical_day.observed in + let part_ref p = + match + List.find_opt (fun (c : Colitur_kernel.Citation.t) -> + c.Colitur_kernel.Citation.part = p) d.Colitur_kernel.Liturgical_day.citations + with + | Some c -> Colitur_citation.Sigla.format sigla c.Colitur_kernel.Citation.reference + | None -> "" + in + let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in + let n = Colitur_naming.Lang.celebration lang slug_s in + readings_pretty_row + ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) + ~dow:(D.weekday_to_string d.Colitur_kernel.Liturgical_day.temporal.Colitur_kernel.Temporal.weekday) + ~name:(if n = slug_s then slug_s else n) + ~first:(part_ref Colitur_kernel.Citation.First) + ~second:(part_ref Colitur_kernel.Citation.Second) + ~gospel:(part_ref Colitur_kernel.Citation.Gospel) + +let readings_report ~lang ~sigla ~pretty ~overlays y = + resolved_year_report + ~line:(if pretty then readings_line_pretty ~lang ~sigla else readings_line ~lang ~sigla) + ~overlays y (* Fix wave I1 (final-review.md, 2026-08-25-colitur-of-phases-3-5): unlike EF's [Celebration.names] (almost always empty -- lang/la.ini is EF's own @@ -866,19 +989,96 @@ let readings_line_of ~lang ~sigla let resolved_of_year_report ~line ~overlays y = List.iter line (resolved_of_year_days ~overlays y) -let day_report_of ~lang ~overlays y = resolved_of_year_report ~line:(day_line_of ~lang) ~overlays y -let readings_report_of ~lang ~sigla ~overlays y = - resolved_of_year_report ~line:(readings_line_of ~lang ~sigla) ~overlays y +let day_line_of_pretty ~lang (d : (Rite_of.Vocab_of.season, Rite_of.Vocab_of.rank) Colitur_kernel.Liturgical_day.t) = + let t = d.Colitur_kernel.Liturgical_day.temporal in + let cel = d.Colitur_kernel.Liturgical_day.observed in + let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in + (* Same preference [day_line_of] itself uses: the shipped OF data's own + verified per-slug name first, the generic lang table only as a fallback. + Reusing [observed_name_of] rather than restating it keeps the two row + shapes from drifting apart. *) + let name = observed_name_of ~lang cel slug_s in + pretty_day_generic + ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) + ~dow:(D.weekday_to_string t.Colitur_kernel.Temporal.weekday) + ~colour:(Colitur_kernel.Colour.to_string cel.Colitur_kernel.Celebration.colour) + ~rank:(Colitur_naming.Lang.rank lang (Rite_of.Vocab_of.rank_to_string cel.Colitur_kernel.Celebration.rank)) + ~season:(Colitur_naming.Lang.season lang (Rite_of.Vocab_of.season_to_string t.Colitur_kernel.Temporal.season)) + ~week:(match t.Colitur_kernel.Temporal.week with Some n -> Some (string_of_int n) | None -> None) + ~name ~comms:[] + +let day_report_of ~lang ~pretty ~overlays y = + resolved_of_year_report + ~line:(if pretty then day_line_of_pretty ~lang else day_line_of ~lang) + ~overlays y +let readings_line_of_pretty ~lang ~sigla (d : (Rite_of.Vocab_of.season, Rite_of.Vocab_of.rank) Colitur_kernel.Liturgical_day.t) = + let cel = d.Colitur_kernel.Liturgical_day.observed in + let part_ref p = + match + List.find_opt (fun (c : Colitur_kernel.Citation.t) -> + c.Colitur_kernel.Citation.part = p) d.Colitur_kernel.Liturgical_day.citations + with + | Some c -> Colitur_citation.Sigla.format sigla c.Colitur_kernel.Citation.reference + | None -> "" + in + let slug_s = Colitur_kernel.Slug.to_string cel.Colitur_kernel.Celebration.slug in + readings_pretty_row + ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) + ~dow:(D.weekday_to_string d.Colitur_kernel.Liturgical_day.temporal.Colitur_kernel.Temporal.weekday) + ~name:(observed_name_of ~lang cel slug_s) + ~first:(part_ref Colitur_kernel.Citation.First) + ~second:(part_ref Colitur_kernel.Citation.Second) + ~gospel:(part_ref Colitur_kernel.Citation.Gospel) + +let readings_report_of ~lang ~sigla ~pretty ~overlays y = + resolved_of_year_report + ~line:(if pretty then readings_line_of_pretty ~lang ~sigla else readings_line_of ~lang ~sigla) + ~overlays y (* Fix 1 (cli-flags-report, 2026-08-27): `rubrics` widened to `--rite of`. [rubrics_line] above needs no OF twin (it type-checks polymorphic over [('s, 'r)] already -- see its own header), so dispatching here is only ever about which resolver walks the year, never about which printer prints it. *) -let rubrics_report ~rite ~lang ~overlays y = + +(* --pretty for `readings` and `rubrics`. Both keep the same one-row-per-day + shape as `day --pretty`, so the four commands scan alike; only the columns + after the date differ, because what they are FOR differs. *) + +let rubrics_pretty_row ~date ~said ~via ~creed ~gloria ~preface = + let tick b = if b then "yes" else Pretty.dim "no" in + Printf.printf "%s %s %s %s %s %s\n" + (Pretty.pad Pretty.w_date date) + (Pretty.pad 34 said) + (Pretty.pad 10 via) + (Pretty.pad 8 ("Creed " ^ tick creed)) + (Pretty.pad 9 ("Gloria " ^ tick gloria)) + preface + + +let rubrics_line_pretty (d : (_, _) Colitur_kernel.Liturgical_day.t) = + let said, via = + match d.Colitur_kernel.Liturgical_day.formulary with + | Some f -> + ((match f.Colitur_kernel.Mass_formulary.said with + | Some sl -> Colitur_kernel.Slug.to_string sl + | None -> "-"), + Colitur_kernel.Mass_formulary.source_to_string f.Colitur_kernel.Mass_formulary.via) + | None -> ("-", "-") + in + rubrics_pretty_row + ~date:(D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) + ~said ~via + ~creed:d.Colitur_kernel.Liturgical_day.creed + ~gloria:d.Colitur_kernel.Liturgical_day.gloria + ~preface:(match d.Colitur_kernel.Liturgical_day.preface with + | Some pf -> Colitur_kernel.Preface.to_string pf + | None -> "-") + +let rubrics_report ~rite ~lang ~pretty ~overlays y = match rite with - | `Ef -> resolved_year_report ~line:(rubrics_line ~lang) ~overlays y - | `Of -> resolved_of_year_report ~line:(rubrics_line ~lang) ~overlays y + | `Ef -> resolved_year_report ~line:(if pretty then rubrics_line_pretty else rubrics_line ~lang) ~overlays y + | `Of -> resolved_of_year_report ~line:(if pretty then rubrics_line_pretty else rubrics_line ~lang) ~overlays y (* Fix 1 (cli-flags-report, 2026-08-27): shared by [table_report] and [publish_report], which each need only the rendered [Template.value] -- @@ -2185,6 +2385,7 @@ type parsed_args = { prune : bool; lang : string option; raw : bool; + pretty : bool; dump : string option; check : string option; list : bool; @@ -2222,6 +2423,7 @@ let parse_args argv = | "--lang" :: v :: rest -> go { acc with lang = Some v } rest | [ "--lang" ] -> Error "--lang needs a language code or file path" | "--raw" :: rest -> go { acc with raw = true } rest + | "--pretty" :: rest -> go { acc with pretty = true } rest | "--dump" :: v :: rest -> go { acc with dump = Some v } rest | [ "--dump" ] -> Error "--dump needs a language code" | "--check" :: v :: rest -> go { acc with check = Some v } rest @@ -2246,7 +2448,7 @@ let parse_args argv = in go { overlays = []; rite = None; format = None; from_y = None; to_y = None; dtstamp = None; year = None; - template = None; flavour = None; out = None; prune = false; lang = None; raw = false; + template = None; flavour = None; out = None; prune = false; lang = None; raw = false; pretty = false; dump = None; check = None; list = false; show = false; sigla_style = None; sigla_book = None; sigla_tradition = None; positional = [] } argv @@ -2382,6 +2584,20 @@ let reject_publish_flags_for cmd ~out ~prune = `check`/`convert`/`new-overlay` (operate on overlay files, not a rendered calendar), or `lang`/`config` themselves (which take their OWN flags, `--dump`/`--check`/`--list`/`--show`, disjoint from these). *) +(* Sibling to [reject_lang_for] and the rest: --pretty is terminal + presentation for the four commands that print one row per day. `emit`, + `table`/`render` and `publish` already choose their own shape through + --format and --template, so prettifying them would compete with the + template engine rather than complement it; `easter` prints six key/value + lines, not a day grid. Accepting the flag there and quietly doing nothing + is the failure mode this program refuses everywhere else. *) +let reject_pretty_for cmd ~pretty = + if pretty then begin + Printf.eprintf + "colitur: --pretty has no effect on `%s`; refusing rather than ignoring it\n" cmd; + exit 2 + end + let reject_lang_for cmd ~lang ~raw = if lang <> None || raw then begin Printf.eprintf "colitur: --lang/--raw have no effect on `%s`; refusing rather than ignoring them\n" cmd; @@ -2865,13 +3081,15 @@ let () = Printf.eprintf "colitur: %s\n" msg; usage () | Ok { overlays; rite; format; from_y; to_y; dtstamp; year; template; flavour; out; prune; lang; - raw; dump; check; list; show; sigla_style; sigla_book; sigla_tradition; positional } -> ( + raw; pretty; dump; check; list; show; sigla_style; sigla_book; sigla_tradition; + positional } -> ( let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in let reject_rite cmd = reject_rite_for cmd rite in let reject_table = reject_table_flags_for ~year ~template ~flavour in let reject_template_flavour cmd = reject_template_flavour_for cmd ~template ~flavour in let reject_publish = reject_publish_flags_for ~out ~prune in let reject_lang = reject_lang_for ~lang ~raw in + let reject_pretty = reject_pretty_for ~pretty in let reject_lang_sub = reject_lang_subcommand_flags_for ~dump ~check ~list ~show in let reject_sigla = reject_sigla_for ~sigla_style ~sigla_book ~sigla_tradition in (* Loaded once, unconditionally: a config file the user wrote and @@ -2948,6 +3166,7 @@ let () = reject_publish "easter"; reject_lang "easter"; reject_lang_sub "easter"; + reject_pretty "easter"; reject_sigla "easter"; with_year ys easter_report | [ "temporal"; ys ] -> @@ -2958,7 +3177,7 @@ let () = reject_lang "temporal"; reject_lang_sub "temporal"; reject_sigla "temporal"; - with_year ys (temporal_report ~rite:(resolve_rite rite)) + with_year ys (temporal_report ~rite:(resolve_rite rite) ~pretty) | "check" :: (_ :: _ as files) -> reject_overlays_for "check" overlays; reject_rite "check"; @@ -2967,6 +3186,7 @@ let () = reject_publish "check"; reject_lang "check"; reject_lang_sub "check"; + reject_pretty "check"; reject_sigla "check"; check_report files | [ "convert"; path ] -> @@ -2977,6 +3197,7 @@ let () = reject_publish "convert"; reject_lang "convert"; reject_lang_sub "convert"; + reject_pretty "convert"; reject_sigla "convert"; convert_report path | [ "new-overlay" ] -> @@ -2987,6 +3208,7 @@ let () = reject_publish "new-overlay"; reject_lang "new-overlay"; reject_lang_sub "new-overlay"; + reject_pretty "new-overlay"; reject_sigla "new-overlay"; print_string new_overlay_template; exit 0 @@ -3066,8 +3288,8 @@ let () = ~flag:year in (match resolve_rite rite with - | `Ef -> with_year ys (day_report ~lang:(resolved_lang ()) ~overlays:effective_overlays) - | `Of -> with_year ys (day_report_of ~lang:(resolved_lang ()) ~overlays:effective_overlays)) + | `Ef -> with_year ys (day_report ~lang:(resolved_lang ()) ~pretty ~overlays:effective_overlays) + | `Of -> with_year ys (day_report_of ~lang:(resolved_lang ()) ~pretty ~overlays:effective_overlays)) | "readings" :: rest when List.length rest <= 1 -> reject_emit "readings"; reject_template_flavour "readings"; @@ -3084,8 +3306,8 @@ let () = ~sigla_tradition_flag:sigla_tradition ~config in (match resolve_rite rite with - | `Ef -> with_year ys (readings_report ~lang:lang_t ~sigla ~overlays:effective_overlays) - | `Of -> with_year ys (readings_report_of ~lang:lang_t ~sigla ~overlays:effective_overlays)) + | `Ef -> with_year ys (readings_report ~lang:lang_t ~sigla ~pretty ~overlays:effective_overlays) + | `Of -> with_year ys (readings_report_of ~lang:lang_t ~sigla ~pretty ~overlays:effective_overlays)) | "rubrics" :: rest when List.length rest <= 1 -> (* --overlay accepted, same reasoning as `readings`: an overlay can change which celebration is observed, hence which Mass formulary @@ -3120,11 +3342,12 @@ let () = ~positional:(match rest with [ ys ] -> Some ys | _ -> None) ~flag:year in - with_year ys (rubrics_report ~rite:(resolve_rite rite) ~lang:(rubrics_lang ()) ~overlays:effective_overlays) + with_year ys (rubrics_report ~rite:(resolve_rite rite) ~lang:(rubrics_lang ()) ~pretty ~overlays:effective_overlays) | [ "emit" ] -> ( reject_table "emit"; reject_publish "emit"; reject_lang_sub "emit"; + reject_pretty "emit"; match (match format with Some f -> Some f | None -> Colitur_naming.Config.format config) with | None -> Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n"; @@ -3152,6 +3375,7 @@ let () = reject_emit cmd; reject_publish cmd; reject_lang_sub cmd; + reject_pretty cmd; let positional_year = match rest with [ ys ] -> Some ys | _ -> None in match (match template with Some t -> Some t | None -> Colitur_naming.Config.template config) with | None -> @@ -3180,6 +3404,7 @@ let () = reject_table "publish"; reject_format_for "publish" format; reject_lang_sub "publish"; + reject_pretty "publish"; match out with | None -> Printf.eprintf "colitur: publish requires --out DIR\n"; diff --git a/bin/pretty.ml b/bin/pretty.ml new file mode 100644 index 0000000..53f8e8a --- /dev/null +++ b/bin/pretty.ml @@ -0,0 +1,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 diff --git a/man/colitur.1 b/man/colitur.1 index 1a330d8..55383ec 100644 --- a/man/colitur.1 +++ b/man/colitur.1 @@ -476,6 +476,40 @@ Print a usage summary to standard output and exit 0. .TP .BR \-V ", " \-\-version Print the version and exit 0. +.SH PRETTY OUTPUT +.B \-\-pretty +lays the rows out for a terminal rather than for +.BR awk (1): +aligned columns, the day's liturgical colour as a filled circle, and +commemorations on their own indented line instead of lengthening the row. +.PP +Accepted by +.BR day ", " readings ", " rubrics " and " temporal . +Every other command refuses it rather than accepting it and doing nothing: +.BR emit ", " table ", " render " and " publish +already choose their shape through +.B \-\-format +or +.BR \-\-template , +and +.B easter +prints six key/value lines rather than a day grid. +.PP +Colour is written only when standard output is a terminal, so redirecting or +piping yields plain aligned text \-\- the alignment survives, the escape +sequences do not, and the colour column degrades to its initial +.RB ( w ", " r ", " g ", " v ", " o ", " k ) +so the information is not simply lost. The +.B NO_COLOR +environment variable is honoured on presence, whatever its value, per the +convention at https://no-color.org. +.PP +The default output is unchanged by this flag and remains the parseable one. +Nothing should be written to parse +.BR \-\-pretty : +its layout is free to change, which is precisely what the default format is +not. +.PP .SH OUTPUT FORMAT .SS day .RS @@ -1962,3 +1962,46 @@ the file is loaded once, unconditionally, before dispatch: $ XDG_CONFIG_HOME=xdg-bad colitur day 2027 --raw colitur: xdg-bad/colitur/config.ini: line 1: "[defaults" looks like a section header but does not end with ']' [2] + +`--pretty` lays the four row commands out for a person rather than for awk. +Opt-in: the default output above is untouched, byte for byte. Colour appears +only on a terminal, so this test -- which is a pipe -- sees the letter +fallback (w/r/g/v/o/k) rather than escape sequences: + + $ colitur day --pretty 2026 | head -1 + 2026-01-01 thu w I classis Tempus Nativitatis In Octava Nativitatis Domini + +A commemoration takes its own indented line rather than lengthening the row: + + $ colitur day --pretty 2026 | sed -n '/2026-01-14/,+1p' + 2026-01-14 wed w III classis Tempus per annum ante Septuagesimam 1 S. Hilarii Ep., Conf. et Eccl. Doct. + + S. Felicis Presbyt. et Mart. + +`readings --pretty` labels and stacks the citations, and prints the OF second +reading only on the days that have one: + + $ colitur readings --rite of --pretty 2026 | head -4 + 2026-01-01 thu of-mary-mother-of-god + First Num 6:22-27 + Second Gal 4:4-7 + Gospel Luc 2:16-21 + +NO_COLOR is honoured on presence, whatever its value -- that is the +convention's own rule, and reading it as a boolean is how tools get it wrong: + + $ NO_COLOR=0 colitur day --pretty 2026 | head -1 | cat -v | grep -c 'ESC' + 0 + [1] + +The commands that already choose their own shape refuse it, rather than +accepting it and quietly doing nothing: + + $ colitur emit --from 2026 --to 2026 --format csv --pretty + colitur: --pretty has no effect on `emit`; refusing rather than ignoring it + [2] + $ colitur table --year 2026 --template templates/ef/ordo.txt --pretty + colitur: --pretty has no effect on `table`; refusing rather than ignoring it + [2] + $ colitur easter 2026 --pretty + colitur: --pretty has no effect on `easter`; refusing rather than ignoring it + [2] |
