Skip to main content

mossaic/
lib.rs

1//! GitHub's contribution chart, in the terminal.
2//!
3//! `mossaic` draws the year the way github.com draws it: [Primer]'s own
4//! colours, read out of the stylesheets GitHub serves; cells at its own geometry
5//! — an 11px square on a 14px pitch, rounded by 2px; and, on a terminal that
6//! draws pixels, actual anti-aliased rounded squares rather than characters
7//! shaped like them.
8//!
9//! This is the library the three binaries share:
10//!
11//! | binary | what it is |
12//! | --- | --- |
13//! | `mossaic` | the chart |
14//! | `mossaic-art` | writes text into a contribution graph by dating commits |
15//! | `mossaic-glyphs` | shows the fallback cells, to check what a terminal renders |
16//!
17//! # The chart, as pixels
18//!
19//! [`graphics`] rasterises a year and hands it to whichever protocol the
20//! terminal speaks — [`graphics::kitty`] for RGBA over the kitty graphics
21//! protocol, [`graphics::sixel`] for a palette and six pixels to a byte. The
22//! same image goes to [`png`] for a terminal that draws neither.
23//!
24//! ```
25//! use mossaic::graphics;
26//! use mossaic::primer::{Appearance, Palette, Season};
27//!
28//! // `levels[week][weekday]`: GitHub's shade, or None for a day it does not draw.
29//! let mut levels = vec![[None; 7]; 53];
30//! levels[20][3] = Some(4);
31//!
32//! let palette = Palette::new(Appearance::Dark, Season::Default, true);
33//! assert_eq!(palette.levels[4], mossaic::primer::Rgb::hex(0x56d364));
34//!
35//! // One character cell is 10x20 pixels here; a day gets two columns and one row.
36//! let image = graphics::grid(&levels, &palette, (10, 20));
37//! assert_eq!((image.width, image.height), (53 * 2 * 10, 7 * 20));
38//!
39//! let escape = graphics::sixel(&image, palette.canvas);
40//! assert!(escape.starts_with("\x1bP0;1;0q"));
41//! ```
42//!
43//! # Writing text into a year
44//!
45//! [`art`] draws characters as pixels on the calendar and maps every lit one to
46//! a date. Letters are five columns wide with one between, and five rows tall on
47//! Mon–Fri, so **eight characters** is what a year holds: nine need all 53
48//! columns, and the first and last are partial weeks.
49//!
50//! ```
51//! use mossaic::art::{self, Grid, Ink, Shades};
52//!
53//! # fn main() -> Result<(), String> {
54//! let grid = Grid::new(2027).unwrap();
55//! let columns = art::bitmap("VYNCINT")?;
56//! assert_eq!(columns.len(), 41); // 6N - 1
57//!
58//! // Letters against an empty graph: the classic look.
59//! let placed = art::place(&columns, &grid, 1, None, Ink { lit: 4, field: 0 })?;
60//! assert_eq!(placed.skipped, 0, "centred, so nothing falls outside the year");
61//! assert_eq!(placed.lit.len(), 75);
62//! assert!(placed.field.is_empty());
63//! # Ok(())
64//! # }
65//! ```
66//!
67//! Leaving the background empty means *not contributing* on the other three
68//! hundred days. [`art::Shades`] draws it as a colour instead, so the letters
69//! are the difference between two greens and the year stays busy:
70//!
71//! ```
72//! use mossaic::art::{self, Grid, Shades};
73//! use mossaic::primer::Legibility;
74//!
75//! # fn main() -> Result<(), String> {
76//! let grid = Grid::new(2027).unwrap();
77//! let columns = art::bitmap("VYNCINT")?;
78//!
79//! let shades = Shades { ink: 4, field: 1 };
80//! shades.check()?;
81//! // Level 1 under level 4 is plain in every palette GitHub ships.
82//! assert_eq!(shades.worst().0, Legibility::Clear);
83//!
84//! // Four commits on a letter day makes four the year'"'"'s peak; one commit is
85//! // then level 1, and the background costs a commit a day.
86//! let ink = shades.commits(4);
87//! assert_eq!((ink.lit, ink.field), (4, 1));
88//!
89//! let placed = art::place(&columns, &grid, 1, None, ink)?;
90//! assert_eq!(placed.lit.len(), 75);
91//! assert_eq!(placed.field.len(), 365 - 75, "every other day of the year");
92//! # Ok(())
93//! # }
94//! ```
95//!
96//! # Asking the terminal
97//!
98//! [`term::probe`] asks rather than guesses: one write, four questions, one round
99//! trip, and a deadline for the terminals that stay silent. What comes back
100//! decides the protocol, the image scale, and whether the palette is the light
101//! one or the dark one.
102//!
103//! [Primer]: https://primer.style
104
105/// When to colour output.
106///
107/// `Auto` is what everything defaults to: colour when stdout is a terminal and
108/// [`NO_COLOR`](https://no-color.org) is unset. The other two are for pipes
109/// that want it anyway and terminals that do not.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub enum Colour {
112    /// Colour when stdout is a terminal and `NO_COLOR` is unset.
113    #[default]
114    Auto,
115    /// Always, even into a pipe.
116    Always,
117    /// Never.
118    Never,
119}
120
121impl Colour {
122    /// From `--color`'s value, if it is one of the three.
123    pub fn parse(value: &str) -> Option<Self> {
124        match value {
125            "auto" => Some(Self::Auto),
126            "always" | "yes" | "force" => Some(Self::Always),
127            "never" | "no" | "none" => Some(Self::Never),
128            _ => None,
129        }
130    }
131
132    /// Whether to actually emit colour, asked once per run.
133    pub fn enabled(self) -> bool {
134        use std::io::IsTerminal;
135        match self {
136            Self::Always => true,
137            Self::Never => false,
138            Self::Auto => {
139                std::env::var_os("NO_COLOR").is_none_or(|value| value.is_empty())
140                    && std::io::stdout().is_terminal()
141            }
142        }
143    }
144}
145
146/// Text with its control characters removed, for anything that reaches a
147/// terminal.
148///
149/// A contribution calendar is data from elsewhere — the API, or a file someone
150/// sent you — and a terminal executes what it is written. An `ESC` in a login
151/// or an error message is a title change, a cursor-position report typed back
152/// into the application, or an `OSC 52` clipboard write. The renderer drops
153/// control characters when it fills a cell, but the paths that print straight
154/// to stdout (`--png`, `--track`, error messages) do not, so untrusted text is
155/// cleaned where it enters rather than at each of them.
156pub fn printable(text: &str) -> String {
157    text.chars().filter(|c| !c.is_control()).collect()
158}
159
160/// A count with thousands separators, the way github.com writes one.
161///
162/// Here rather than in three modules: the chart, the tracker and the reports all
163/// print counts, and they should all print them the same way.
164pub fn thousands(n: u32) -> String {
165    let digits = n.to_string();
166    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
167    for (index, digit) in digits.chars().enumerate() {
168        if index > 0 && (digits.len() - index).is_multiple_of(3) {
169            out.push(',');
170        }
171        out.push(digit);
172    }
173    out
174}
175
176pub mod app;
177pub mod art;
178pub mod calendar;
179pub mod cli;
180pub mod github;
181pub mod graphics;
182pub mod plan;
183pub mod png;
184pub mod primer;
185pub mod term;
186pub mod ui;
187
188#[cfg(test)]
189mod render_tests;