diff --git a/CHANGELOG.md b/CHANGELOG.md index 507f88a..c1edd75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,63 @@ listed under a **Changed** or **Removed** heading. ## [Unreleased] +## [0.6.3] - 2026-08-24 + ### Added +- **Three more templates**, so the catalogue is a catalogue: `wave` (one clean + sine across the year), `pulse` (a heartbeat trace, a spike every few weeks) + and `invader` (a space invader, centred). Each is 51 columns, which keeps + every lit day clear of the two partial weeks at the ends of a year, and each + is drawn in `{0, 2, 4}` — the only three shades no reader has to squint at. + +- **Two gates on template art**, which matter now that + [#57](https://github.com/vyncint/mossaic/issues/57) invites contributions. + `every_template_reads_clearly` measures the closest pair of shades in every + template and fails anything that is not *clear*: a picture using `2` and `3` + looks two-toned in the `.art` file, because the digits differ, and reads as + one flat colour on github.com. `no_template_overhangs_the_year` fails a + template that loses a lit day off either end of any year. Both were checked + against art planted to break them. + +### Changed + +- **`mossaic-art --help` is grouped rather than a list of thirty flags.** The + options now sit under *what to draw*, *where it goes*, *tracking*, *making + the commits* and *output*, with the usage and seven examples first. The + colour-theory paragraph that lived inside `--background` moved to + [docs/ART.md](docs/ART.md), where there is room to say it properly. Shorter + overall (93 lines against 113) and no flag was dropped. + +- **Counts read as English.** Every user-facing count said `1 template(s)`, + `6 cell(s)`, `59 day(s)` — twenty-nine of them, on the first lines a new + user reads and in the markdown the GitHub Action posts. They now agree with + their number, verb included: *1 day is short*, *2 days are short*. + +- **The description says what mossaic is.** It never mentioned the terminal. + +### Fixed + +- **The one shipped template no longer warns on every single year.** Drawing + `--template dragon` printed `note: 6 cell(s) fell outside 2027 and were + dropped` for 2025 through 2029 — and all six were *blank* margin, so nothing + was lost. `Canvas::place` counted every cell that fell outside the year, + lit or not. It now counts only the cells that would have been drawn, so the + note appears when a shade is genuinely lost and stays quiet otherwise. + + Found by running the flagship path and reading what it printed. + +- **A missing `--file` in a script names the file, not the terminal.** The + chart claims the terminal before it reads the calendar, so + `mossaic --file typo.json` in CI reported `needs an interactive terminal` — + true, and no help at all in finding the typo. With a terminal the behaviour + is unchanged: the error is shown *in* the chart with `r` to retry, which is + better than exiting. + +- **`--font` no longer tells its readers to edit `src/art.rs`.** It printed a + note meant for contributors to everyone who ran it. + + - **`brew install vyncint/tap/mossaic`.** The formula is generated by the release itself from the checksums the archives produced, so it cannot describe an archive that was never built, and a hand-edit in the tap is @@ -861,7 +916,8 @@ there was none. [termlens]: https://github.com/vyncint/termlens -[Unreleased]: https://github.com/vyncint/mossaic/compare/v0.6.2...HEAD +[Unreleased]: https://github.com/vyncint/mossaic/compare/v0.6.3...HEAD +[0.6.3]: https://github.com/vyncint/mossaic/compare/v0.6.2...v0.6.3 [0.6.2]: https://github.com/vyncint/mossaic/compare/v0.6.1...v0.6.2 [0.6.1]: https://github.com/vyncint/mossaic/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/vyncint/mossaic/compare/v0.5.0...v0.6.0 diff --git a/Cargo.lock b/Cargo.lock index 4a317ba..dffea9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "mossaic" -version = "0.6.2" +version = "0.6.3" dependencies = [ "chrono", "libc", diff --git a/Cargo.toml b/Cargo.toml index 9d32ce3..61e0218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mossaic" -version = "0.6.2" +version = "0.6.3" edition = "2021" # Minimum supported Rust version. Verified by the `msrv` CI job, which reads this # field; bumping it is a minor (not patch) change. @@ -8,8 +8,11 @@ edition = "2021" # 1.88 is dictated by ratatui 0.30. Everything else in the tree needs less: the # graphics and colour code is plain std. rust-version = "1.88" -description = "Plan and track GitHub contribution art — what today owes to draw your name by December, and whether the year can still be drawn at all" -keywords = ["github", "contributions", "contribution-art", "sixel", "kitty"] +description = "Draw pixel art in your GitHub contribution graph — a terminal chart, an editor, and a planner that says what today owes" +# `tui` earns its place over `contributions`, which `contribution-art` already +# covers: the chart, the designer and the glyph viewer are all terminal UIs, and +# that is how someone looking for one searches. +keywords = ["github", "contribution-art", "tui", "sixel", "kitty"] categories = ["command-line-utilities", "visualization"] license = "MIT OR Apache-2.0" repository = "https://github.com/vyncint/mossaic" diff --git a/README.md b/README.md index 8d5419e..0c59936 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,9 @@ That is `--template dragon` on 2027 — 146 days, 442 commits — drawn by the s rasteriser that draws the chart, so it is what the graph will actually look like rather than an impression of it. +Four templates ship with it — `dragon`, `wave`, `pulse` and `invader` — and +each is a seven-line text file you can copy and edit. + ```sh mossaic-art --list-templates # what there is, with thumbnails mossaic-art --template dragon --year 2027 # draw one, and see what it costs diff --git a/action/README.md b/action/README.md index 3d8619f..77f2f50 100644 --- a/action/README.md +++ b/action/README.md @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - id: art - uses: vyncint/mossaic/action@v0.6.0 + uses: vyncint/mossaic/action@v0.6.3 with: text: VYNCINT year: "2027" @@ -84,7 +84,7 @@ drawn. The rest are the same either way. ```yaml - id: art - uses: vyncint/mossaic/action@v0.6.0 + uses: vyncint/mossaic/action@v0.6.3 with: template: dragon year: "2027" @@ -216,7 +216,7 @@ on 290 days of the year is a job nobody reads. ## Notes -- **Two knobs, two jobs.** The ref you pin (`@v0.6.0`, `@main`) chooses the +- **Two knobs, two jobs.** The ref you pin (`@v0.6.3`, `@main`) chooses the *action's steps* — the glue that runs the tracker and shapes the outputs. The `version` input chooses the *tracker itself*, straight from crates.io. The default, `latest`, is fine for a daily report; pin a number when you diff --git a/action/action.yml b/action/action.yml index b30ecbd..6ff2555 100644 --- a/action/action.yml +++ b/action/action.yml @@ -118,7 +118,7 @@ inputs: default: "true" version: description: >- - Which mossaic release runs the tracking, e.g. "0.6.0". The default, + Which mossaic release runs the tracking, e.g. "0.6.3". The default, `latest`, installs the newest release on crates.io each run — fine for tracking, since the report format is versioned with the crate. Pin a number if you want the tracker to change only when you say so. diff --git a/action/track.example.yml b/action/track.example.yml index e08cadc..1bc06b2 100644 --- a/action/track.example.yml +++ b/action/track.example.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - id: art - uses: vyncint/mossaic/action@v0.6.0 + uses: vyncint/mossaic/action@v0.6.3 with: text: VYNCINT year: "2027" @@ -39,7 +39,7 @@ jobs: # the failure notification you already get does the reminding. fail-on: never # Which mossaic release does the tracking. The default, latest, - # follows crates.io; pin a number ("0.6.0") to freeze it. + # follows crates.io; pin a number ("0.6.3") to freeze it. # version: latest # ----------------------------------------------------------------- issue diff --git a/art/templates/invader.art b/art/templates/invader.art new file mode 100644 index 0000000..b12fbfb --- /dev/null +++ b/art/templates/invader.art @@ -0,0 +1,11 @@ +# name: Invader +# author: @vyncint +# description: A space invader, centred on the year + +000000000000000000000040000400000000000000000000000 +000000000000000000000004444000000000000000000000000 +000000000000000000000044044400000000000000000000000 +000000000000000000004444444444000000000000000000000 +000000000000000000004044444404000000000000000000000 +000000000000000000004040000404000000000000000000000 +000000000000000000000004004000000000000000000000000 diff --git a/art/templates/pulse.art b/art/templates/pulse.art new file mode 100644 index 0000000..809042b --- /dev/null +++ b/art/templates/pulse.art @@ -0,0 +1,11 @@ +# name: Pulse +# author: @vyncint +# description: A heartbeat trace across the year: a quiet line, a spike every few weeks + +000000004400000000000000044000000000000000440000000 +000000004400000000000000044000000000000000440000000 +000004204404200000000042044042000000000420440420000 +222224244424222222222242444242222222222424442422222 +000000044420000000000000444200000000000004442000000 +000000000420000000000000004200000000000000042000000 +000000000000000000000000000000000000000000000000000 diff --git a/art/templates/wave.art b/art/templates/wave.art new file mode 100644 index 0000000..0f86def --- /dev/null +++ b/art/templates/wave.art @@ -0,0 +1,11 @@ +# name: Wave +# author: @vyncint +# description: One clean sine wave rolling across the whole year + +000000000444444444000000000000000000000000000000000 +000004444400000004444400000000000000000000000000000 +004444000000000000000444400000000000000000000000000 +444000000000000000000000444400000000000000000000044 +000000000000000000000000000444400000000000000044440 +000000000000000000000000000000444440000000444440000 +000000000000000000000000000000000044444444400000000 diff --git a/docs/ART.md b/docs/ART.md index 1a01890..6c33a02 100644 --- a/docs/ART.md +++ b/docs/ART.md @@ -211,6 +211,19 @@ mossaic-art --list-templates # what there is, with thumbnails mossaic-art --template dragon --year 2027 # draw one ``` +Four ship with it, and every one of them is a file in +[`art/templates/`](../art/templates): + +| name | what it is | +|---|---| +| `dragon` | a serpentine dragon coiling across the whole year | +| `wave` | one clean sine rolling across the year | +| `pulse` | a heartbeat trace: a quiet line, a spike every few weeks | +| `invader` | a space invader, centred | + +They are worth reading as much as drawing: each is seven lines of digits, and +copying one into your own file is the quickest way to start. + ``` Dragon · 2027 · 53 of 53 columns · 146 days · 442 commits @@ -494,7 +507,7 @@ arrive rather than be asked for: ```yaml - id: art - uses: vyncint/mossaic/action@v0.6.0 + uses: vyncint/mossaic/action@v0.6.3 with: text: VYNCINT year: "2027" diff --git a/src/art.rs b/src/art.rs index a725b7f..339ed95 100644 --- a/src/art.rs +++ b/src/art.rs @@ -916,10 +916,15 @@ impl Canvas { /// Lay the canvas on a year and report the shade each day should end at. /// /// `start` is the calendar column the canvas's first column lands on. - /// Columns that fall outside the year are counted in the returned total - /// rather than silently dropped — the first and last calendar columns are - /// partial weeks, so a full-width picture always loses a few days at the - /// ends and the caller should be able to say so. + /// The returned total counts the cells that fell outside the year and + /// **would have been drawn** — the first and last calendar columns are + /// partial weeks, so a full-width picture overhangs them. + /// + /// Blank cells are not counted, because losing one costs the picture + /// nothing. Counting them meant a 53-column template warned about six + /// dropped cells on every year, all six of them empty margin: a `note:` + /// on the path everybody takes, which teaches people to stop reading + /// notes. What is worth interrupting for is a *shade* that will not fit. /// /// Returns the level for **every** day it covers, including the level-0 /// ones. That is the difference between a canvas and text: a dark day @@ -930,19 +935,19 @@ impl Canvas { let mut levels = BTreeMap::new(); let mut skipped = 0; for (offset, column) in self.columns.iter().enumerate() { - let Some(week) = start.checked_add(offset) else { - skipped += CANVAS_ROWS; - continue; + let week = match start.checked_add(offset) { + Some(week) if week < grid.weeks => week, + // Past the end of the year: nothing in this column lands. + _ => { + skipped += column.iter().filter(|level| **level > 0).count(); + continue; + } }; - if week >= grid.weeks { - skipped += CANVAS_ROWS; - continue; - } for (row, level) in column.iter().enumerate() { let date = grid.date_at(week, row); if grid.holds(date) { levels.insert(date, *level); - } else { + } else if *level > 0 { skipped += 1; } } diff --git a/src/bin/mossaic-art.rs b/src/bin/mossaic-art.rs index f2946a0..a89775c 100644 --- a/src/bin/mossaic-art.rs +++ b/src/bin/mossaic-art.rs @@ -16,121 +16,101 @@ use chrono::{Datelike, Local, NaiveDate}; use mossaic::art::{self, Grid}; use mossaic::cli::{Args, YEARS}; use mossaic::primer::{Appearance, Palette, Season}; -use mossaic::{github, graphics, plan, png, primer, templates, thousands, Colour}; +use mossaic::{github, graphics, plan, plural, png, primer, templates, thousands, Colour}; -const HELP: &str = "\ -mossaic-art — write text into a GitHub contribution graph by dating commits +const HELP: &str = r#"mossaic-art — draw on a GitHub contribution graph by dating commits usage: - mossaic-art TEXT [options] - mossaic-art --template NAME [options] - mossaic-art --matrix FILE [options] + mossaic-art TEXT [options] write words through a 5x5 font + mossaic-art --template NAME draw a picture from the catalogue + mossaic-art --matrix FILE draw a picture of your own + mossaic-art --draw draw one by hand, in an editor - TEXT what to draw, e.g. VYNCINT. Letters, digits, punctuation - and shapes: `I :heart: RUST`. A shape is written between - colons, or pasted as the symbol or emoji it depicts. - --font lists everything - - Text is drawn through a 5x5 font on Mon-Fri. The three flags below draw a - *picture* instead: seven rows by up to 53 columns, any of GitHub's five - shades per day, using the whole week and the whole year. - - --template NAME draw a template from the catalogue. --list-templates - shows what there is +examples: + mossaic-art --draw a blank year, by hand + mossaic-art --list-templates the pictures on offer + mossaic-art --template dragon --year 2027 a picture across next year + mossaic-art VYNCINT --year 2027 what it looks like, and costs + mossaic-art VYNCINT --year 2027 --save remember it + mossaic-art --track am I on pace? + mossaic-art --backfill --repo ../art --write commit what the plan is short + +WHAT TO DRAW + TEXT letters, digits, punctuation and shapes: `I :heart: RUST`. + Drawn Mon-Fri through a 5x5 font; --font lists every glyph + --template NAME a picture from the catalogue --list-templates list the templates, with a thumbnail of each, and exit - --matrix FILE (--file) draw a .art file: 7 rows of the shades 0-4, or - of the blocks ░ ▒ ▓ █ with a space for 0. - Lines starting with # are comments, and `# name:`, - `# author:` and `# description:` are read as the - picture's own. With --commits N a level-4 day costs N - and every darker level is priced against it - --image FILE turn a PNG into a picture: shrunk to fit the calendar, - keeping its aspect ratio, and quantised to five shades. + --matrix FILE (--file) a .art file: 7 rows of the shades 0-4, or of the + blocks ░ ▒ ▓ █ with a space for 0. `#` starts a comment + --image FILE a PNG, shrunk to fit the year and quantised to five shades. A dark pixel is a busy day, the way ink reads on paper - --invert with --image, map bright pixels to busy days instead - --dither with --image, spread the rounding error into - neighbouring days (Floyd-Steinberg), so a gradient - reads as one rather than as four bands - --draw open the editor and draw on the year by hand: arrows or - hjkl to move, 0-4 to paint a shade, space to cycle one, - the mouse to paint directly, u to undo and s to save. - With --template or --matrix it opens that picture; on - its own it starts a blank year - --output FILE (-o) where --draw saves, as a .art file - (default: the picture's name, or drawing.art) + --invert with --image, bright pixels become the busy days instead + --dither with --image, spread the rounding error into neighbouring + days (Floyd-Steinberg), so a gradient reads as one + --draw open the editor: arrows or hjkl to move, 0-4 to paint a + shade, space to cycle one, the mouse to paint, u to undo, + s to save. With --template or --matrix it opens that picture + --output FILE (-o) where --draw saves (default: the picture's name) + + A picture is seven rows by up to 53 columns, any of GitHub's five shades per + day, using the whole week and the whole year. Text uses Mon-Fri and one shade. + +WHERE IT GOES --year YEAR which year's calendar (default: this one) - --commits N commits per lit day (default 4); keep it uniform for one - flat shade - --background LEVEL (--bg) draw the background as a shade instead of leaving - it empty, 0-3 (default 0). The letters stay at level 4, so - --background 1 draws them on a light green field rather - than on nothing — which is how you draw art without - going dark for most of the year. Levels two or more - apart are legible in every palette GitHub ships; one - apart is not - --top ROW first calendar row used, 0 = Sunday (default 1, so Mon-Fri) - --start-week N left edge in weeks (default: centred) - --merge PATH a saved `gh api graphql` calendar to draw on top of, so the - preview accounts for contributions already there - --snapshot PATH write a calendar file for `mossaic --file PATH` - --repo DIR where --write puts the commits - --write actually create the commits in --repo (local only) - --backfill commit what each day already past is still short of the - plan, measured against the real calendar — the flag for - catching up. Unlike a plain --write it never adds to a day - that is already bright, which over an active year is what - stops the art raising the very peak it is measured - against, and it leaves days still to come alone. --today - is what \"past\" means. Needs --repo, and --write to commit - --login NAME name shown in the snapshot (default: preview) + --start-week N left edge, in weeks (default: centred) + --commits N commits per lit day (default 4) + --top ROW first calendar row, 0 = Sunday (default 1, so Mon-Fri). + Text only; a picture uses all seven rows + --background LEVEL (--bg) draw the background as a shade 0-3 rather than + leaving it empty, so the whole year stays green. Text + only. Keep it two levels clear of the letters, or the + two greens are too close to tell apart + +TRACKING + --track [USER] how far along, what today owes, and whether the picture can + still be drawn at all. Reads --merge if given, otherwise + asks gh for USER (default: whoever gh is) + --today DATE what counts as today, as YYYY-MM-DD (default: the clock), + so a report is repeatable and a future day can be asked + --merge PATH a saved calendar to draw on top of, so the preview accounts + for contributions already there + --plan PATH where the plan lives (default: mossaic-plan.json) + --save write the plan, so later runs need no flags at all + +MAKING THE COMMITS + --repo DIR the repository --write commits into (local only) + --write actually create the commits + --backfill commit only what each past day is still short of the plan, + never adding to a day that is already bright, and leaving + days still to come alone. Needs --repo, and --write to commit --name NAME commit author name (default: git config) --email ADDRESS commit author email (default: git config) + +OUTPUT + --snapshot PATH write a calendar file for `mossaic --file PATH` + --login NAME the name that snapshot carries (default: preview) + --format F text (default), json or markdown — what --track prints. + The GitHub Action reads json and markdown --color WHEN (--colour) auto (default), always or never. auto means colour when stdout is a terminal and NO_COLOR is unset --no-color (--no-colour) the same as --color never - --format F text (default), json or markdown — what --track prints. - json and markdown are what the GitHub Action reads - --plan PATH where the plan is read from and written to - (default: mossaic-plan.json) - --save write the plan, so later runs need no flags at all --font print every glyph the font has, and exit - --png PATH with --font, write the glyphs to a PNG instead of the - terminal — the sheet the README shows - --track [USER] compare the plan with what has actually been contributed: - how far along, what today owes, and whether the text can - still be drawn at all. Reads --merge if given, otherwise - asks gh for USER (default: whoever gh is) - --today DATE what \"today\" means, as YYYY-MM-DD (default: the clock). - A report is then reproducible, and a day that has not - arrived can be asked what it will owe + --png PATH with --font, write the glyph sheet to a PNG instead + -V, --version print the version -h, --help show this help -examples: - mossaic-art --draw draw a year by hand - mossaic-art --template dragon --draw -o mine.art start from one, save as mine - mossaic-art --list-templates see the pictures on offer - mossaic-art --template dragon --year 2027 draw one across the year - mossaic-art --matrix mine.art --year 2027 --save draw your own, and keep it - mossaic-art VYNCINT --year 2027 what it would look like, and cost - mossaic-art VYNCINT --year 2027 --background 1 letters on a field, not on nothing - mossaic-art VYNCINT --year 2027 --track am I getting there, and what today owes - mossaic-art VYNCINT --year 2027 --snapshot a.json then: mossaic --file a.json - mossaic-art VYNCINT --year 2027 --repo ../art --write local commits, never pushed - mossaic-art --backfill --repo ../art --write commit just what the plan is short - mossaic-art --track --today 2027-06-01 what that day will owe - mossaic-art \"I :heart: RUST\" --year 2027 a shape among the letters - mossaic-art --font every glyph, side by side - mossaic-art --font --png art/font.png the same sheet, as an image - Save the plan once and later runs need no flags at all: mossaic-art VYNCINT --year 2027 --start-week 6 --save mossaic-art --track # reads mossaic-plan.json +Pictures, templates and the .art format: docs/ART.md + https://github.com/vyncint/mossaic/blob/main/docs/ART.md + also installed: mossaic the chart itself - mossaic-glyphs what this terminal makes of the fallback cells"; + mossaic-glyphs what this terminal makes of the fallback cells"#; fn main() { let Some(options) = parse_args() else { @@ -223,11 +203,13 @@ fn main() { .unwrap_or_else(|error| fail(&error)); if placed.skipped > 0 { eprintln!( - "note: {} pixel(s) fell outside {} and were dropped — the first and \ + "note: {} lit {} fell outside {} and {} dropped — the first and \ last calendar columns are partial weeks, so {} of {} columns hold a \ whole letter", placed.skipped, + plural(placed.skipped, "pixel", "pixels"), grid.year, + plural(placed.skipped, "was", "were"), grid.usable_weeks(), grid.weeks ); @@ -301,11 +283,12 @@ fn main() { if shades.field > 0 { let (legibility, delta) = drawn.worst(); println!( - "background level {} under letters at level {} · {} background day(s), \ + "background level {} under letters at level {} · {} background {}, \ {} each · ΔE {delta:.0} at worst, {legibility}", drawn.field, drawn.ink, thousands(placed.field.len() as u32), + plural(placed.field.len(), "day", "days"), ink.field, ); match legibility { @@ -338,13 +321,15 @@ fn main() { }) .count(); println!( - "drawing over {} day(s) already active (busiest {})", + "drawing over {} {} already active (busiest {})", existing.len(), + plural(existing.len(), "day", "days"), existing.values().copied().max().unwrap_or(0) ); println!( "peak after merge {peak} · letters land at level {art_level}/4 · \ - {rivals} existing day(s) as bright or brighter" + {rivals} existing {} as bright or brighter", + plural(rivals, "day", "days") ); if art_level < 4 { let days: Vec = placed.lit.keys().copied().collect(); @@ -527,7 +512,11 @@ fn show_templates(colour: bool) { .max() .unwrap_or(0); - println!("{} template(s):\n", catalogue.len()); + println!( + "{} {}:\n", + catalogue.len(), + plural(catalogue.len(), "template", "templates") + ); for template in &catalogue { let author = template .author() @@ -598,8 +587,10 @@ fn run_canvas(options: &Options, grid: &Grid, name: &str, canvas: &art::Canvas) } if skipped > 0 { eprintln!( - "note: {skipped} cell(s) fell outside {} and were dropped — the first \ - and last calendar columns are partial weeks", + "note: {skipped} {} of the picture fell outside {} — the first and \ + last calendar columns are partial weeks, so a full-width picture \ + overhangs them. Try --start-week, or a narrower picture.", + plural(skipped, "shade", "shades"), grid.year ); } @@ -799,12 +790,14 @@ fn track_canvas( match plan.verdict() { plan::Verdict::Done => println!(" Drawn. Every day is the shade the picture asks for."), plan::Verdict::Reachable => println!( - " On track — {drawn} of {wanted} shaded day(s) are right, \ - {owing_days} to go." + " On track — {drawn} of {wanted} shaded {} right, \ + {owing_days} to go.", + plural(wanted, "day is", "days are") ), plan::Verdict::Holed { holes } => println!( - " Cannot be drawn cleanly — {holes} day(s) are brighter than the \ - picture wants,\n and nothing takes a contribution away." + " Cannot be drawn cleanly — {holes} {} brighter than the \ + picture wants,\n and nothing takes a contribution away.", + plural(holes, "day is", "days are") ), } @@ -845,9 +838,11 @@ fn track_canvas( ); println!( - "\n still owing {} day(s) · {} contributions", + "\n still owing {} {} · {} {}", thousands(owing_days as u32), - thousands(owing_commits) + plural(owing_days, "day", "days"), + thousands(owing_commits), + plural(owing_commits, "contribution", "contributions") ); // What a day is owed turns on `want`, not on whether `need` is zero. Both @@ -895,7 +890,11 @@ fn track_canvas( let holes = plan.holes(); if !holes.is_empty() { - println!("\n {} day(s) already too bright:", holes.len()); + println!( + "\n {} {} already too bright:", + holes.len(), + plural(holes.len(), "day", "days") + ); for day in holes.iter().take(8) { println!( " {} has {}, wants at most {}", @@ -931,8 +930,9 @@ fn backfill_canvas(options: &Options, grid: &Grid, levels: &BTreeMap 0 { let (field_days, field_commits) = plan.field_owing(); println!( - " background {field_days} day(s) short, {} commits", + " background {field_days} {} short, {} commits", + plural(field_days, "day", "days"), thousands(field_commits) ); } @@ -1067,8 +1069,10 @@ fn backfill( println!(" reaching days before {today}, which are the ones only back-dating reaches"); if ahead > 0 { println!( - " {ahead} day(s) from {today} on are short too, and left alone — \ - contribute on those as they come" + " {ahead} {} from {today} on {} short too, and left alone — \ + contribute on those as they come", + plural(ahead, "day", "days"), + plural(ahead, "is", "are") ); } } @@ -1077,11 +1081,13 @@ fn backfill( // commits changes that. if let plan::Verdict::Holed { holes } = plan.verdict() { println!( - "\n warning: {} cannot be drawn cleanly in {} — {holes} day(s) inside the\n \ - letters are already lit, and nothing takes those away. Backfilling will\n \ + "\n warning: {} cannot be drawn cleanly in {} — {holes} {} inside the\n \ + letters already lit, and nothing takes those away. Backfilling will\n \ brighten the letters, and the text will still read with holes in it.\n \ `mossaic-art --track` sweeps --start-week for a placement with fewer.", - plan.text, plan.year + plan.text, + plan.year, + plural(holes, "day", "days") ); } @@ -1095,9 +1101,11 @@ fn backfill( return; } println!( - "\n {} commit(s) across {} day(s), earliest {}, latest {}", + "\n {} {} across {} {}, earliest {}, latest {}", thousands(total), + plural(total, "commit", "commits"), owed.len(), + plural(owed.len(), "day", "days"), owed.keys().next().expect("not empty"), owed.keys().next_back().expect("not empty"), ); @@ -1273,7 +1281,8 @@ fn track_progress( ); if owing_days > 0 { println!( - " owing {owing_days} day(s) short, {} contributions between them", + " owing {owing_days} {} short, {} contributions between them", + plural(owing_days, "day", "days"), thousands(owing_commits) ); } @@ -1295,7 +1304,8 @@ fn track_progress( let (field_owing_days, field_owing_commits) = plan.field_owing(); if field_owing_days > 0 { println!( - " owing {field_owing_days} background day(s) short, {} contributions", + " owing {field_owing_days} background {} short, {} contributions", + plural(field_owing_days, "day", "days"), thousands(field_owing_commits) ); } @@ -1305,8 +1315,9 @@ fn track_progress( " holes {}", paint( &format!( - "{} day(s) inside the letters are lit and cannot be unlit", - holes.len() + "{} {} lit inside the letters and cannot be unlit", + holes.len(), + plural(holes.len(), "day is", "days are") ), danger() ) @@ -1314,8 +1325,9 @@ fn track_progress( } if plan.around() > 0 { println!( - " around {} day(s) outside the text have contributions", - plan.around() + " around {} {} outside the text with contributions", + plan.around(), + plural(plan.around(), "day", "days") ); } @@ -1346,8 +1358,9 @@ fn track_progress( ) ); println!( - " {holes} day(s) inside the letters already have contributions, and\n \ - nothing takes those away — the text would read with holes in it." + " {holes} {} inside the letters already have contributions, and\n \ + nothing takes those away — the text would read with holes in it.", + plural(holes, "day", "days") ); match plan::best_start_week( grid, @@ -1487,7 +1500,8 @@ fn track_progress( } println!("\n the rest of the year"); println!( - " {future} letter day(s) still to come, {} contributions", + " {future} letter {} still to come, {} contributions", + plural(future, "day", "days"), thousands(future_commits) ); if past > 0 { @@ -1497,8 +1511,9 @@ fn track_progress( // would not even read the plan, so the placement could differ from the // one just reported on. println!( - " {past} letter day(s) already past, {} contributions — only back-dated\n \ + " {past} letter {} already past, {} contributions — only back-dated\n \ commits reach those:\n\n mossaic-art {}--backfill --repo ../art --write\n", + plural(past, "day", "days"), thousands(past_commits), match options.plan_loaded { true => String::new(), @@ -1548,7 +1563,7 @@ fn show_font(colour: bool) { let characters: Vec = art::alphabet().collect(); println!( - "{} glyphs, {}x{} each — add one to FONT in src/art.rs\n", + "{} glyphs, {}x{} each — write any of them with mossaic-art TEXT\n", characters.len(), art::GLYPH_COLS, art::GLYPH_ROWS diff --git a/src/draw.rs b/src/draw.rs index 8bce818..ec39c1c 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -20,6 +20,7 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::art::{self, Canvas, Grid, CANVAS_ROWS}; +use crate::plural; use crate::primer::{Appearance, Legibility, Palette, Season}; use crate::thousands; @@ -181,7 +182,8 @@ impl Editor { Some(previous) => { self.canvas = previous; self.dirty = true; - self.status = format!("undone — {} step(s) left", self.undo.len()); + let left = self.undo.len(); + self.status = format!("undone — {left} {} left", plural(left, "step", "steps")); true } None => { @@ -515,7 +517,10 @@ pub fn render(frame: &mut Frame<'_>, editor: &Editor, palette: &Palette) { let rgb = palette.levels[usize::from(level)]; let bar = "█".repeat(count * 30 / (editor.canvas.width() * CANVAS_ROWS).max(1)); stats.push(Line::from(vec![ - Span::raw(format!(" level {level} {count:>4} day(s) ")), + Span::raw(format!( + " level {level} {count:>4} {:<6} ", + plural(count, "day", "days") + )), Span::styled(bar, Style::default().fg(Color::Rgb(rgb.0, rgb.1, rgb.2))), Span::raw(if level == 0 { " (must stay dark)".to_string() diff --git a/src/lib.rs b/src/lib.rs index baa4661..d3abfd3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -173,6 +173,22 @@ pub fn thousands(n: u32) -> String { out } +/// The singular or the plural of a word, chosen by a count. +/// +/// Here because the alternative is what mossaic used to print: `1 template(s)`, +/// `6 cell(s)`, `59 day(s)` — on the first lines a new user reads. Taking both +/// forms rather than appending an `s` keeps the irregular cases honest, and +/// leaves the number's own formatting (widths, thousands separators) to the +/// call site, which is where it belongs. +#[must_use] +pub fn plural<'a, N: PartialEq + From>(n: N, one: &'a str, many: &'a str) -> &'a str { + if n == N::from(1) { + one + } else { + many + } +} + pub mod app; pub mod art; pub mod calendar; diff --git a/src/main.rs b/src/main.rs index 3113a8a..8506b5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ //! library beside it. use std::io; +use std::path::Path; use std::time::Duration; use chrono::{Datelike, Local, NaiveDate}; @@ -104,8 +105,24 @@ fn main() { // try_init rather than init/run so a non-tty (a pipe, CI) gets a readable // message instead of a panic. It still installs the terminal-restoring hook. - let mut terminal = ratatui::try_init() - .unwrap_or_else(|error| fail(&format!("needs an interactive terminal ({error})"))); + let mut terminal = ratatui::try_init().unwrap_or_else(|error| { + // With a terminal, a `--file` that cannot be read is shown *in* the + // chart, with `r` to retry — better than exiting, because the fix is + // usually to write the file in another pane. Without one there is no + // chart to show it in, and reporting only the missing terminal names + // the wrong problem: a typo'd path in a script used to come back as + // "needs an interactive terminal", which is true and useless. + if let Source::File(path) = &source { + if !Path::new(path).is_file() { + fail(&format!( + "no calendar file at {path}\n\n \ + mossaic-art TEXT --snapshot {path} write one\n \ + mossaic --demo a sample year instead" + )); + } + } + fail(&format!("needs an interactive terminal ({error})")) + }); // Ask now: raw mode is on, so the replies come back unbuffered and unechoed, // and anything a terminal prints instead of answering lands on the alternate diff --git a/src/plan.rs b/src/plan.rs index ade64c0..c3de168 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -21,7 +21,7 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::{Datelike, NaiveDate}; use crate::art::{self, Grid, GLYPH_ROWS}; -use crate::thousands; +use crate::{plural, thousands}; /// What the art wants of a day. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -913,8 +913,13 @@ impl Report { match self.verdict { "drawn" => format!("{} · {} — drawn", self.text, self.year), "holed" => format!( - "{} · {} — {} of {} bright, {} hole(s) that cannot be unlit", - self.text, self.year, self.bright, self.letters, self.holes + "{} · {} — {} of {} bright, {} {} that cannot be unlit", + self.text, + self.year, + self.bright, + self.letters, + self.holes, + plural(self.holes, "hole", "holes") ), // The background is work too, and saying "0 to go" while three // hundred field days are bare is the kind of confidently wrong a @@ -941,13 +946,15 @@ impl Report { out.push_str(&match self.verdict { "drawn" => format!("**{} is drawn.**\n\n", self.text), "holed" => format!( - "**Cannot be drawn cleanly** — {} day(s) inside the letters are already \ + "**Cannot be drawn cleanly** — {} {} inside the letters already \ lit, and nothing takes those away.\n\n", - self.holes + self.holes, + plural(self.holes, "day is", "days are") ), _ => format!( - "**On track** — {} contribution(s) to go{}.\n\n", + "**On track** — {} {} to go{}.\n\n", thousands(self.owing_commits), + plural(self.owing_commits, "contribution", "contributions"), match self.field_owing_commits { 0 => String::new(), owed => format!(", and {} for the background", thousands(owed)), @@ -962,8 +969,9 @@ impl Report { )); if self.owing_days > 0 { out.push_str(&format!( - "| still owing | {} day(s) · {} contributions |\n", + "| still owing | {} {} · {} contributions |\n", self.owing_days, + plural(self.owing_days, "day", "days"), thousands(self.owing_commits) )); } @@ -978,8 +986,9 @@ impl Report { )); if self.field_owing_days > 0 { out.push_str(&format!( - "| background owing | {} day(s) · {} contributions |\n", + "| background owing | {} {} · {} contributions |\n", self.field_owing_days, + plural(self.field_owing_days, "day", "days"), thousands(self.field_owing_commits) )); } @@ -1032,8 +1041,9 @@ impl Report { } if self.overdue_days > 0 { out.push_str(&format!( - "| already past | {} day(s) · {} contributions, back-dating only |\n", + "| already past | {} {} · {} contributions, back-dating only |\n", self.overdue_days, + plural(self.overdue_days, "day", "days"), thousands(self.overdue_commits) )); } @@ -1041,7 +1051,8 @@ impl Report { if let (Some(week), Some(holes)) = (self.suggested_start_week, self.suggested_holes) { if holes < self.holes { out.push_str(&format!( - "\n`--start-week {week}` would leave {holes} hole(s) instead of {}.\n", + "\n`--start-week {week}` would leave {holes} {} instead of {}.\n", + plural(holes, "hole", "holes"), self.holes )); } diff --git a/src/render_tests.rs b/src/render_tests.rs index 5311cf4..cdca357 100644 --- a/src/render_tests.rs +++ b/src/render_tests.rs @@ -3916,17 +3916,54 @@ fn every_template_file_is_a_valid_canvas() { #[test] fn the_reference_template_reads_clearly() { let dragon = crate::templates::find("dragon").expect("the reference template"); - let (low, high, legibility, delta) = dragon.canvas.closest_pair().expect("more than one shade"); - assert_eq!( - legibility, - crate::primer::Legibility::Clear, - "the reference template's closest pair is {low} and {high} at ΔE {delta:.0}, \ - which is not something a reader can rely on seeing" - ); assert_eq!(dragon.canvas.width(), crate::art::CANVAS_COLS); assert_eq!(dragon.author(), Some("@vyncint")); } +/// Every template in the catalogue, not only the reference one. +/// +/// This is the gate on contributed art, and it cannot be replaced by reading +/// the file: a picture whose two closest shades are nine ΔE apart looks +/// obviously two-toned in the `.art` source, because `2` and `3` are different +/// characters — and reads as one flat colour on github.com. Only measuring +/// catches it. +#[test] +fn every_template_reads_clearly() { + for (name, source) in crate::templates::builtin_sources() { + let canvas = + crate::art::Canvas::parse(source).unwrap_or_else(|error| panic!("{name}: {error}")); + // One shade has nothing to tell apart from anything. + let Some((low, high, legibility, delta)) = canvas.closest_pair() else { + continue; + }; + assert_eq!( + legibility, + crate::primer::Legibility::Clear, + "{name}: its closest pair is {low} and {high} at ΔE {delta:.0}, \ + which is not something a reader can rely on seeing" + ); + } +} + +/// No template loses a shade off the end of the year it is drawn across. +/// +/// The `note:` about a picture overhanging the calendar is for pictures that +/// genuinely do not fit. Shipping one that trips it on every year — as the +/// reference template did, on six blank cells — trains people to ignore the +/// note, which costs more than the note is worth. +#[test] +fn no_template_overhangs_the_year() { + for year in [2026, 2027, 2028, 2029] { + let grid = crate::art::Grid::new(year).expect("a real year"); + for (name, source) in crate::templates::builtin_sources() { + let canvas = + crate::art::Canvas::parse(source).unwrap_or_else(|error| panic!("{name}: {error}")); + let (_, skipped) = canvas.place(&grid, canvas.centred(&grid)); + assert_eq!(skipped, 0, "{name} loses {skipped} shades off {year}"); + } + } +} + /// The fact the rule above rests on, checked rather than asserted in prose: /// three shades is the most that can be drawn with nothing faint between any /// two of them, and `{0, 2, 4}` is the only such set. diff --git a/tests/art_cli.rs b/tests/art_cli.rs index 1f64562..b0e7268 100644 --- a/tests/art_cli.rs +++ b/tests/art_cli.rs @@ -45,7 +45,10 @@ fn the_font_can_be_looked_at() { .unwrap_or_else(|| panic!("no glyph count in {text}")); assert!(count >= 39, "{count} glyphs: {text}"); assert!(text.contains("glyphs, 5x5 each"), "{text}"); - assert!(text.contains("add one to FONT in src/art.rs"), "{text}"); + assert!( + text.contains("write any of them with mossaic-art TEXT"), + "{text}" + ); // Every character it claims, drawn. for character in ['A', 'Z', '0', '9', '-', '.'] { assert!( @@ -903,7 +906,7 @@ fn a_background_is_drawn_as_a_shade_and_priced_as_one() { text.contains("background level 1 under letters at level 4"), "{text}" ); - assert!(text.contains("290 background day(s), 1 each"), "{text}"); + assert!(text.contains("290 background days, 1 each"), "{text}"); assert!(text.contains("clear"), "level 1 under 4 is clear: {text}"); assert!(!text.contains("faint"), "{text}"); @@ -1678,19 +1681,19 @@ fn backfill_reaches_only_the_days_that_have_gone() { let mid = stdout(&art(&args("2026-08-19"))); // The same 34 days and 3,464 commits `--track` calls "already past": both // answers come from one plan and one date, so they agree by construction. - assert!(mid.contains("3,464 commit(s) across 34 day(s)"), "{mid}"); + assert!(mid.contains("3,464 commits across 34 days"), "{mid}"); assert!( mid.contains("latest 2026-08-14"), "nothing after today: {mid}" ); assert!( - mid.contains("23 day(s) from 2026-08-19 on are short too, and left alone"), + mid.contains("23 days from 2026-08-19 on are short too, and left alone"), "and it says what it is leaving: {mid}" ); // Later in the year, more of it is reachable. let end = stdout(&art(&args("2026-12-31"))); - assert!(end.contains("5,994 commit(s) across 57 day(s)"), "{end}"); + assert!(end.contains("5,994 commits across 57 days"), "{end}"); // Before the year starts, none of it is — and it says why rather than // claiming the plan is finished. @@ -1702,7 +1705,7 @@ fn backfill_reaches_only_the_days_that_have_gone() { // A year that cannot be drawn cleanly says so before an irreversible write. assert!( - mid.contains("cannot be drawn cleanly in 2026") && mid.contains("61 day(s) inside the"), + mid.contains("cannot be drawn cleanly in 2026") && mid.contains("61 days inside the"), "a warning belongs where the commits do: {mid}" ); } diff --git a/tests/canvas_pty.rs b/tests/canvas_pty.rs index 2a25b94..c01ee4f 100644 --- a/tests/canvas_pty.rs +++ b/tests/canvas_pty.rs @@ -53,26 +53,37 @@ fn list_templates_shows_a_picture_and_not_just_a_name() -> termlens::Result<()> // satisfied by a screen the rest of it has not reached yet. Every // assertion below is then a race, and it loses on a loaded runner. terminal.wait_exit()?; - let screen = terminal.screen(); - - assert!(screen.contains("dragon"), "the name:\n{screen}"); - assert!(screen.contains("Dragon"), "the title:\n{screen}"); - assert!(screen.contains("@vyncint"), "the author:\n{screen}"); - assert!(screen.contains("53 columns"), "the size:\n{screen}"); - assert!(screen.contains("built in"), "where it came from:\n{screen}"); + // History *and* screen. The catalogue outgrew one screenful when it stopped + // being a catalogue of one, so the first entry has scrolled off by the time + // the process exits — and "did this reach the terminal" is the question the + // test is actually asking, not "is it still visible". + let text = terminal.screen().full_text(); + + assert!(text.contains("dragon"), "the name:\n{text}"); + assert!(text.contains("Dragon"), "the title:\n{text}"); + assert!(text.contains("@vyncint"), "the author:\n{text}"); + assert!(text.contains("53 columns"), "the size:\n{text}"); + assert!(text.contains("built in"), "where it came from:\n{text}"); + + // Every template in the catalogue, not just the first: a listing that + // silently stopped after one would satisfy the assertions above. + for name in ["dragon", "invader", "pulse", "wave"] { + assert!( + text.contains(name), + "{name} is missing from the catalogue:\n{text}" + ); + } // The thumbnail is the reason to run this rather than read a directory // listing, and a name with no picture beside it would satisfy every // assertion above. - let blocks = (0..screen.rows()) - .filter(|row| { - let text = screen.row_text(*row); - text.contains('\u{2588}') || text.contains('\u{2593}') - }) + let blocks = text + .lines() + .filter(|line| line.contains('\u{2588}') || line.contains('\u{2593}')) .count(); assert!( blocks >= 5, - "expected a seven-row thumbnail, found {blocks} row(s) of blocks:\n{screen}" + "expected a seven-row thumbnail, found {blocks} rows of blocks:\n{text}" ); Ok(()) } @@ -181,7 +192,7 @@ fn painting_shows_up_on_screen_and_in_the_numbers() -> termlens::Result<()> { terminal.send(Key::Char('4'))?; terminal.send(Key::Char('l'))?; } - let screen = terminal.wait_frame(|screen| screen.contains("level 4 4 day(s)"))?; + let screen = terminal.wait_frame(|screen| screen.contains("level 4 4 days"))?; // Four days at level 4 cost four commits each at the default --commits. assert!( @@ -203,10 +214,10 @@ fn undo_takes_the_drawing_back_and_says_when_there_is_no_more() -> termlens::Res terminal.wait_until(ready)?; terminal.send(Key::Char('3'))?; - terminal.wait_until(|screen| screen.contains("level 3 1 day(s)"))?; + terminal.wait_until(|screen| screen.contains("level 3 1 day "))?; terminal.send(Key::Char('u'))?; let screen = terminal.wait_frame(|screen| screen.contains("undone"))?; - assert!(screen.contains("level 3 0 day(s)"), "{screen}"); + assert!(screen.contains("level 3 0 days"), "{screen}"); terminal.send(Key::Char('u'))?; terminal.wait_until(|screen| screen.contains("nothing to undo"))?; @@ -279,7 +290,7 @@ fn an_unsaved_drawing_is_not_lost_quietly() -> termlens::Result<()> { let mut terminal = spawn(&["--draw", "--year", "2027", "--plan", "/dev/null"])?; terminal.wait_until(ready)?; terminal.send(Key::Char('4'))?; - terminal.wait_until(|screen| screen.contains("level 4 1 day(s)"))?; + terminal.wait_until(|screen| screen.contains("level 4 1 day "))?; terminal.send(Key::Char('q'))?; terminal.wait_exit()?; diff --git a/tests/chart_cli.rs b/tests/chart_cli.rs new file mode 100644 index 0000000..d8ee8fa --- /dev/null +++ b/tests/chart_cli.rs @@ -0,0 +1,68 @@ +//! The `mossaic` chart binary run *without* a terminal — a script, a pipe, CI. +//! +//! No PTY here on purpose: this file is about what the chart says when it +//! cannot open one at all. `smoke.rs` covers everything it does once it can. + +use std::process::{Command, Output}; + +fn chart(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_mossaic")) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args(args) + .stdin(std::process::Stdio::null()) + .output() + .expect("the chart binary runs") +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// The chart shows a missing `--file` *inside* the chart, with `r` to retry +/// (`smoke.rs::a_missing_file_is_reported_not_swallowed`). Without a terminal +/// there is no chart to show it in, and it used to report only the missing +/// terminal — true, and no help at all to someone with a typo in a script. +#[test] +fn without_a_terminal_a_missing_file_is_named_rather_than_the_terminal() { + let out = chart(&["--file", "/no/such/calendar.json"]); + let text = stderr(&out); + assert!( + text.contains("no calendar file at /no/such/calendar.json"), + "the file is the problem, and it should say so:\n{text}" + ); + assert!( + !text.contains("interactive terminal"), + "and it should not blame the terminal:\n{text}" + ); + assert!( + text.contains("--snapshot") && text.contains("--demo"), + "and it should say how to get one:\n{text}" + ); +} + +/// The other half, which keeps the message above specific rather than blanket: +/// a file that *is* there leaves the terminal as the only thing wrong. +#[test] +fn without_a_terminal_an_existing_file_still_reports_the_terminal() { + let out = chart(&["--file", "Cargo.toml"]); + let text = stderr(&out); + assert!( + text.contains("interactive terminal"), + "nothing is wrong with the path, so the terminal is the problem:\n{text}" + ); + assert!( + !text.contains("no calendar file"), + "and the file must not be blamed:\n{text}" + ); +} + +/// `--png` is the documented way to get a chart out of a machine with no +/// terminal, so it must not need one. +#[test] +fn png_needs_no_terminal() { + let path = std::env::temp_dir().join(format!("mossaic-{}-headless.png", std::process::id())); + let out = chart(&["--demo", "--png", path.to_str().expect("utf-8 temp path")]); + assert!(out.status.success(), "{}", stderr(&out)); + assert!(path.is_file(), "it wrote {}", path.display()); + let _ = std::fs::remove_file(&path); +}