mossaic 0.1.1

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
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
//! Rendering. The whole screen is one paragraph of pre-built lines, so the chart
//! never needs layout maths beyond choosing a cell style that fits — and when the
//! terminal draws pixels, the seven weekday rows are left blank for
//! [`crate::graphics`] to paint into, which is why [`Layout`] travels back out to
//! the app: the painter and the mouse both need to know where the grid landed.

use chrono::{Datelike, Local, NaiveDate};
use ratatui::layout::Rect;
use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use ratatui::Frame;

use crate::app::{App, CellStyle, Load, Mode};
use crate::calendar::{Calendar, Day};
use crate::graphics::COLUMNS_PER_DAY;
use crate::primer::Palette;
use crate::thousands;

/// Width of the weekday label column, e.g. "Mon ".
const GUTTER: usize = 4;
/// Only alternating rows are labelled, the way GitHub does it.
const WEEKDAYS: [&str; 7] = ["", "Mon", "", "Wed", "", "Fri", ""];
const SPINNER: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
/// Fills a day that has happened.
const FILL: &str = "";
/// Marks the cursor. A partial shade lets the day's own level show through.
const CURSOR: &str = "";
/// A half-height cell. A character is about twice as tall as it is wide, so half of
/// one is square, and the unpainted lower half becomes the gap below it.
const SQUARE: &str = "";
/// A rounded square, two characters wide. These are block sextants: each character
/// is a 2×3 grid of sub-blocks, so the pair is 4×3 and can have all four corners
/// shaved — the finest rounding a character grid allows.
///
/// ```text
///   U+1FB2B  U+1FB1B      .## .            .##.
///     .#       #.          ###  #   =>      ####
///     ##       ##          .## .            .##.
///     .#       #.
/// ```
const ROUND: &str = "\u{1FB2B}\u{1FB1B}";
/// Rows the chart needs besides the cells: header, blank, months, blank, detail,
/// legend, summary, blank, footer.
const CHROME_ROWS: usize = 9;

/// A concrete cell layout, resolved from a [`CellStyle`] and the space available.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cells {
    /// Actual pixels, drawn by the terminal's graphics protocol: rounded exactly,
    /// square whatever the font, and in Primer's own greens. Two columns and one
    /// row per day, the same stride as [`Cells::Squares`], so everything around it
    /// stays text.
    Pixels,
    /// Small filled squares separated in both directions and drawn with no outline
    /// at all — how github.com actually looks. Each day is half a character tall,
    /// which is square, followed by a blank column.
    Squares,
    /// Box-bordered cells, `fill` columns of content each. A terminal character is
    /// about twice as tall as it is wide, so `fill: 2` reads as a square and
    /// `fill: 1` as a tall rectangle.
    Grid {
        /// Columns of content per cell; 2 reads as a square.
        fill: usize,
    },
    /// Genuinely rounded squares: a sextant pair per day, so the corners are
    /// shaved at sub-character resolution. The closest match to github.com short of
    /// pixels, but it needs the terminal to draw U+1FB00 block sextants.
    /// Without the `gap` column the cells butt together and their middle rows join
    /// into a continuous bar — rounded, but reading as a chain rather than as
    /// separate squares. It buys back a third of the width.
    Rounded {
        /// Blank columns after each cell. Zero makes them touch.
        gap: usize,
    },
    /// Bare cells: `fill` coloured columns then `gap` blank ones.
    Solid {
        /// Coloured columns per cell.
        fill: usize,
        /// Blank columns after them.
        gap: usize,
    },
}

impl Cells {
    /// Columns consumed per week, which is also the month-label stride.
    pub const fn stride(&self) -> usize {
        match self {
            Self::Pixels => COLUMNS_PER_DAY as usize,
            // One content column plus the gap beside it.
            Self::Squares => 2,
            // Two characters of rounded cell, plus whatever gap follows it.
            Self::Rounded { gap } => 2 + *gap,
            // One shared border column per cell.
            Self::Grid { fill } => *fill + 1,
            Self::Solid { fill, gap } => *fill + *gap,
        }
    }

    /// Columns before the first cell, past the gutter. The grid has a left border.
    const fn offset(&self) -> usize {
        match self {
            Self::Grid { .. } => 1,
            Self::Pixels | Self::Rounded { .. } | Self::Squares | Self::Solid { .. } => 0,
        }
    }

    /// Columns the whole chart needs, gutter included.
    pub const fn width(&self, weeks: usize) -> usize {
        match self {
            // Borders are shared between neighbours, plus one to close the right edge.
            Self::Grid { .. } => GUTTER + weeks * self.stride() + 1,
            Self::Pixels | Self::Rounded { .. } | Self::Squares | Self::Solid { .. } => {
                GUTTER + weeks * self.stride()
            }
        }
    }

    /// Rows the cells themselves occupy: seven weekdays, plus rules for the grid.
    pub const fn height(&self) -> usize {
        match self {
            Self::Grid { .. } => 7 + 6 + 2,
            Self::Pixels | Self::Rounded { .. } | Self::Squares | Self::Solid { .. } => 7,
        }
    }

    /// The legend chip: one cell drawn exactly as the chart draws it. In pixel mode
    /// the swatches are an image too, so the text only reserves the room.
    fn swatch(&self) -> String {
        match self {
            Self::Pixels => " ".repeat(COLUMNS_PER_DAY as usize),
            Self::Rounded { .. } => ROUND.to_string(),
            Self::Squares => SQUARE.to_string(),
            Self::Grid { fill } | Self::Solid { fill, .. } => FILL.repeat(*fill),
        }
    }

    /// Whether the legend puts a space between swatches. Pixel swatches carry their
    /// own gap, exactly as the chart's cells do.
    const fn swatch_gap(&self) -> usize {
        match self {
            Self::Pixels => 0,
            _ => 1,
        }
    }

    /// The name shown beside the legend.
    pub const fn name(&self) -> &'static str {
        match self {
            Self::Pixels => "pixel",
            Self::Rounded { gap: 0 } => "snug",
            Self::Rounded { .. } => "rounded",
            Self::Squares => "squares",
            Self::Grid { fill: 2 } => "grid",
            Self::Grid { .. } => "slim",
            Self::Solid { fill: 2, gap: 1 } => "spaced",
            Self::Solid { fill: 2, .. } => "blocks",
            Self::Solid { .. } => "compact",
        }
    }

    const fn fits(&self, weeks: usize, width: usize, height: usize) -> bool {
        self.width(weeks) <= width && self.height() + CHROME_ROWS <= height
    }
}

/// Where the grid ended up on screen, so the painter can put pixels on it and the
/// mouse can tell which day it is over. Screen coordinates, not frame-relative.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Layout {
    /// Column of the first cell, and row of the first cell row.
    pub x: u16,
    /// Row of the first cell row.
    pub y: u16,
    /// Columns in the grid.
    pub weeks: u16,
    /// The style the chart resolved to.
    pub cells: Cells,
    /// Where the legend swatches start, when the legend is on screen.
    pub legend: Option<(u16, u16)>,
    /// The row past the bottom of the drawable area. An image is placed at the
    /// cursor and drawn downwards from there, so one that would reach past the last
    /// row scrolls the screen out from under everything else — worth one comparison
    /// to rule out, since `d` can ask for cells the terminal has no room for.
    pub bottom: u16,
}

impl Layout {
    /// Whether the cells fit on the screen from where they start.
    pub fn has_room(&self) -> bool {
        self.y + self.cells.height() as u16 <= self.bottom
    }

    /// Which day a character cell belongs to. Deliberately forgiving: a click on the
    /// gap beside a cell, or on a grid rule, snaps to the day it borders rather than
    /// falling through, because a two-column target is small enough already.
    pub fn hit(&self, column: u16, row: u16) -> Option<(usize, usize)> {
        let dx = usize::from(column.checked_sub(self.x)?);
        let dy = usize::from(row.checked_sub(self.y)?);
        let week = dx / self.cells.stride();
        // The bordered styles spend a row on a rule between every two weekdays.
        let weekday = match self.cells {
            Cells::Grid { .. } => dy.saturating_sub(1) / 2,
            _ => dy,
        };
        (week < usize::from(self.weeks) && weekday < 7 && dy < self.cells.height())
            .then_some((week, weekday))
    }
}

/// Honour an explicit style even when it will clip; for `Auto`, take the most
/// faithful one the terminal can show in full. `pixels` is whether the terminal
/// answered yes to a graphics protocol.
pub fn resolve(style: CellStyle, weeks: usize, width: usize, height: usize, pixels: bool) -> Cells {
    const SQUARES: Cells = Cells::Squares;
    const ROUNDED: Cells = Cells::Rounded { gap: 1 };
    const SNUG: Cells = Cells::Rounded { gap: 0 };
    const GRID: Cells = Cells::Grid { fill: 2 };
    const SLIM: Cells = Cells::Grid { fill: 1 };
    const SPACED: Cells = Cells::Solid { fill: 2, gap: 1 };
    const BLOCKS: Cells = Cells::Solid { fill: 2, gap: 0 };
    const COMPACT: Cells = Cells::Solid { fill: 1, gap: 0 };

    match style {
        // Asking for pixels on a terminal that cannot draw them falls to the closest
        // thing that is still rounded, rather than to nothing.
        CellStyle::Pixels if pixels => Cells::Pixels,
        CellStyle::Pixels => ROUNDED,
        CellStyle::Squares => SQUARES,
        CellStyle::Rounded => ROUNDED,
        CellStyle::Snug => SNUG,
        CellStyle::Grid => GRID,
        CellStyle::Slim => SLIM,
        CellStyle::Spaced => SPACED,
        CellStyle::Blocks => BLOCKS,
        CellStyle::Compact => COMPACT,
        // Pixels first when the terminal has them: they are the only style that is
        // rounded, square and gapped at once. Then rounded, which gives up the gap
        // between rows; then squares, which gives up the rounding but needs less
        // width. Only fall to the narrow ones when nothing wider fits, since a
        // one-column cell reads as a rectangle.
        CellStyle::Auto => [
            Cells::Pixels,
            ROUNDED,
            SQUARES,
            GRID,
            SPACED,
            BLOCKS,
            SLIM,
            COMPACT,
        ]
        .into_iter()
        .filter(|cells| pixels || *cells != Cells::Pixels)
        .find(|cells| cells.fits(weeks, width, height))
        .unwrap_or(COMPACT),
    }
}

/// Draw the whole chart, and record where the grid landed so the painter and
/// the mouse can find it.
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
    let area = frame.area();
    let block = Block::bordered()
        .border_style(Style::new().fg(app.palette.ansi(app.palette.rule)))
        .title(Line::from(" mossaic ".bold()));
    let inner = block.inner(area);
    frame.render_widget(block, area);

    let (lines, layout) = body(app, inner);
    frame.render_widget(Paragraph::new(lines), inner);
    app.layout = layout;

    if let Some(layout) = layout {
        tooltip(frame, app, &layout, inner);
    }
    if app.help {
        help(frame, app, inner);
    }
}

/// Everything a first run needs to know, including the one thing only the
/// program can answer: what this terminal turned out to be able to draw.
fn help(frame: &mut Frame<'_>, app: &App, inner: Rect) {
    let palette = &app.palette;
    let key = Style::new().fg(palette.ansi(palette.fg)).bold();
    let text = Style::new().fg(palette.ansi(palette.fg));
    let muted = Style::new().fg(palette.ansi(palette.muted));

    let heading = |title: &'static str| Line::styled(title, muted);
    let row = |keys: &'static str, what: &'static str| {
        Line::from(vec![
            Span::styled(format!("  {keys:<16}"), key),
            Span::styled(what, text),
        ])
    };
    let fact = |name: &'static str, value: String| {
        Line::from(vec![
            Span::styled(format!("  {name:<16}"), muted),
            Span::styled(value, text),
        ])
    };

    let cells = app.layout.map_or("", |layout| layout.cells.name());
    let yes_no = |flag: bool| if flag { "yes" } else { "no" };
    let mut lines = vec![
        heading(" Moving"),
        row("← → / h l", "previous / next week"),
        row("↑ ↓ / k j", "previous / next day"),
        row("[ ]", "previous / next year"),
        row("t · Home End", "today · first / last day"),
        Line::raw(""),
        heading(" Mouse"),
        row("hover a day", "its tooltip, as github.com writes it"),
        row("click", "move the cursor there"),
        row("wheel", "previous / next year"),
        row("m", "mouse reporting off / on"),
        Line::raw(""),
        heading(" Chart"),
        row("d", "cycle cell style"),
        row("u · r · q", "another user · reload · quit"),
        Line::raw(""),
        heading(" This terminal"),
        fact("kitty graphics", yes_no(app.caps.kitty).to_string()),
        fact("sixel", yes_no(app.caps.sixel).to_string()),
        fact(
            "character cell",
            app.gfx.as_ref().map_or_else(
                || "not reported".to_string(),
                |gfx| format!("{}x{} px", gfx.cell.0, gfx.cell.1),
            ),
        ),
        fact(
            "drawing with",
            match app.pixels_available() {
                true => format!("{cells} cells ({})", app.protocol_name()),
                false => format!("{cells} cells — no pixels here"),
            },
        ),
    ];
    if !app.pixels_available() && app.caps.answered {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "  kitty, Ghostty, WezTerm, foot, Konsole, iTerm2 and",
            muted,
        ));
        lines.push(Line::styled(
            "  xterm -ti vt340 draw one protocol or the other.",
            muted,
        ));
    }
    lines.push(Line::raw(""));
    lines.push(Line::styled("  any key closes this", muted));

    // Centred, and never larger than the frame it floats over.
    // Wide enough for the longest line at a 16-column key gutter, and never
    // wider than what it floats over.
    let width = 58.min(inner.width);
    let height = (lines.len() as u16 + 2).min(inner.height);
    let area = Rect::new(
        inner.x + (inner.width.saturating_sub(width)) / 2,
        inner.y + (inner.height.saturating_sub(height)) / 2,
        width,
        height,
    );
    let block = Block::bordered()
        .border_type(ratatui::widgets::BorderType::Rounded)
        .border_style(Style::new().fg(palette.ansi(palette.rule)))
        .style(Style::new().bg(palette.ansi(palette.canvas)))
        .title(Line::styled(" help ", key));
    frame.render_widget(Clear, area);
    frame.render_widget(Paragraph::new(lines).block(block), area);
}

fn body(app: &App, inner: Rect) -> (Vec<Line<'static>>, Option<Layout>) {
    let mut lines = vec![header(app), Line::raw("")];
    let mut layout = None;
    match &app.load {
        Load::Loading => lines.push(loading(app)),
        Load::Failed(message) => lines.extend(failure(app, message)),
        Load::Ready(calendar) => {
            // The chart starts two lines down, which is what turns a line index into
            // a screen row.
            let origin = (inner.x, inner.y + lines.len() as u16);
            let (chart, resolved) = chart(app, calendar, inner, origin);
            lines.extend(chart);
            layout = resolved;
        }
    }
    lines.push(Line::raw(""));
    lines.push(footer(app));
    (lines, layout)
}

fn header(app: &App) -> Line<'static> {
    let palette = &app.palette;
    let mut spans = vec![
        Span::styled(
            app.login.clone(),
            Style::new().fg(palette.ansi(palette.fg)).bold(),
        ),
        separator(palette),
        Span::styled(
            app.year.to_string(),
            Style::new().fg(palette.ansi(palette.fg)),
        ),
    ];
    if let Load::Ready(calendar) = &app.load {
        spans.push(separator(palette));
        // github.com's own wording, under its own chart.
        spans.push(Span::styled(
            format!(
                "{} contributions in {}",
                thousands(calendar.total),
                app.year
            ),
            Style::new().fg(palette.ansi(palette.fg)),
        ));
    }
    Line::from(spans)
}

fn separator(palette: &Palette) -> Span<'static> {
    Span::styled("  ·  ", Style::new().fg(palette.ansi(palette.muted)))
}

fn chart(
    app: &App,
    calendar: &Calendar,
    inner: Rect,
    origin: (u16, u16),
) -> (Vec<Line<'static>>, Option<Layout>) {
    if calendar.weeks.is_empty() {
        return (empty(app, calendar), None);
    }

    let weeks = calendar.weeks.len();
    let (width, height) = (inner.width as usize, inner.height as usize);
    let cells = resolve(app.cells, weeks, width, height, app.pixels_available());

    let mut lines = Vec::with_capacity(cells.height() + 5);
    lines.push(months(app, calendar, &cells));
    let grid_row = origin.1 + lines.len() as u16;
    match cells {
        // Nothing but the gutter: the seven rows below belong to the painter, and
        // staying blank is what keeps ratatui's diff from writing over the image.
        Cells::Pixels => lines.extend((0..7).map(|row| Line::from(weekday(app, row)))),
        Cells::Rounded { gap } => lines.extend(filled_rows(app, calendar, ROUND, gap)),
        Cells::Squares => lines.extend(filled_rows(app, calendar, SQUARE, 1)),
        Cells::Grid { fill } => lines.extend(grid_rows(app, calendar, fill)),
        Cells::Solid { fill, gap } => lines.extend(solid_rows(app, calendar, fill, gap)),
    }
    lines.push(Line::raw(""));
    lines.push(detail(app, calendar));

    let legend_row = origin.1 + lines.len() as u16;
    let (legend, legend_at) = legend(app, calendar, &cells, inner.x, legend_row);
    lines.push(legend);
    lines.push(summary(app, calendar));
    if let Some(note) = note(app, &cells, weeks, width, height) {
        lines.push(note);
    }

    let layout = Layout {
        x: origin.0 + (GUTTER + cells.offset()) as u16,
        y: grid_row,
        weeks: weeks as u16,
        cells,
        legend: legend_at,
        bottom: inner.bottom(),
    };
    (lines, Some(layout))
}

/// The line under the chart that explains why it does not look its best.
fn note(
    app: &App,
    cells: &Cells,
    weeks: usize,
    width: usize,
    height: usize,
) -> Option<Line<'static>> {
    let muted = Style::new().fg(app.palette.ansi(app.palette.muted));
    if !cells.fits(weeks, width, height) {
        return Some(Line::styled(
            "(too small for these cells — press d for a smaller style)",
            muted,
        ));
    }
    if !matches!(app.cells, CellStyle::Auto) {
        return None;
    }
    // Every style Auto can choose is seven rows tall, so width is the only thing
    // that rules the better ones out — if height were short, nothing would fit and
    // the branch above would have fired. Say what it wants, because silently
    // sharpening the corners reads as rounding not working.
    let (wanted, need) = match (app.pixels_available(), cells) {
        (true, Cells::Pixels) => return None,
        (true, _) => ("pixel cells", Cells::Pixels.width(weeks) + 2),
        (false, Cells::Rounded { .. }) => return None,
        (false, _) => (
            "rounded corners",
            Cells::Rounded { gap: 1 }.width(weeks) + 2,
        ),
    };
    Some(Line::styled(
        format!("{wanted} need {need} columns — d forces them, clipped"),
        muted,
    ))
}

/// Nothing to draw at all. Only reachable if GitHub returns an empty range, since
/// a real year always yields its days, future ones included.
fn empty(app: &App, calendar: &Calendar) -> Vec<Line<'static>> {
    let year = calendar.year;
    let muted = Style::new().fg(app.palette.ansi(app.palette.muted));
    let reason = if calendar.starts_after(Local::now().date_naive()) {
        format!("{year} hasn't started yet — nothing to draw until Jan 1 {year}")
    } else {
        format!("no contribution data for {year}")
    };
    vec![
        Line::styled(reason, muted),
        Line::raw(""),
        Line::styled("[ previous year  ·  u change user", muted),
    ]
}

fn months(app: &App, calendar: &Calendar, cells: &Cells) -> Line<'static> {
    let start = GUTTER + cells.offset();
    let mut label = " ".repeat(start);
    for (week, name) in calendar.month_labels() {
        let column = start + week * cells.stride();
        // Skip a label that the previous one has already run into.
        if column < label.len() {
            continue;
        }
        label.push_str(&" ".repeat(column - label.len()));
        label.push_str(name);
    }
    Line::styled(label, Style::new().fg(app.palette.ansi(app.palette.fg)))
}

/// Box-bordered cells. Neighbours share a border, so each week costs two columns
/// and each weekday two rows.
fn grid_rows(app: &App, calendar: &Calendar, fill: usize) -> Vec<Line<'static>> {
    let weeks = calendar.weeks.len();
    let border = Style::new().fg(app.palette.ansi(app.palette.rule));
    let gutter = || Span::raw(" ".repeat(GUTTER));

    let rule = |left: char, joint: char, right: char| {
        let mut drawn = String::with_capacity(weeks * (fill + 1) + 2);
        drawn.push(left);
        for week in 0..weeks {
            if week > 0 {
                drawn.push(joint);
            }
            for _ in 0..fill {
                drawn.push('');
            }
        }
        drawn.push(right);
        Line::from(vec![gutter(), Span::styled(drawn, border)])
    };

    let mut lines = Vec::with_capacity(Cells::Grid { fill }.height());
    lines.push(rule('', '', ''));
    for row in 0..7 {
        if row > 0 {
            lines.push(rule('', '', ''));
        }
        let mut spans = Vec::with_capacity(weeks * 2 + 2);
        spans.push(weekday(app, row));
        spans.push(Span::styled("", border));
        for week in &calendar.weeks {
            spans.push(cell(app, week.days[row], fill));
            spans.push(Span::styled("", border));
        }
        lines.push(Line::from(spans));
    }
    lines.push(rule('', '', ''));
    lines
}

/// github.com's shape: a filled cell per day with a gap column and no outline at
/// all. `glyph` decides the corners — [`SQUARE`] leaves them sharp, [`ROUND`] shaves
/// them. Neither paints a background, so the space around a cell stays the gap.
fn filled_rows(
    app: &App,
    calendar: &Calendar,
    glyph: &'static str,
    gap: usize,
) -> Vec<Line<'static>> {
    // The gap counts: a skipped day has to be as wide as a drawn one plus its gap,
    // otherwise the partial first week shifts the rows above it out of column.
    let blank = " ".repeat(glyph.chars().count() + gap);
    (0..7)
        .map(|row| {
            let mut spans = Vec::with_capacity(calendar.weeks.len() * 2 + 1);
            spans.push(weekday(app, row));
            for week in &calendar.weeks {
                match week.days[row] {
                    // Outside the year, or not yet happened: nothing to draw.
                    None => spans.push(Span::raw(blank.clone())),
                    Some(day) if day.future => spans.push(Span::raw(blank.clone())),
                    Some(day) => {
                        // A background would fill the gap, so a marked day shows as a
                        // colour rather than the shaded-over cell the grid uses.
                        let color = match mark(app, day.date) {
                            Some(color) => color,
                            None => app.palette.level(day.level),
                        };
                        spans.push(Span::styled(glyph, Style::new().fg(color)));
                        if gap > 0 {
                            spans.push(Span::raw(" ".repeat(gap)));
                        }
                    }
                }
            }
            Line::from(spans)
        })
        .collect()
}

fn solid_rows(app: &App, calendar: &Calendar, fill: usize, gap: usize) -> Vec<Line<'static>> {
    (0..7)
        .map(|row| {
            let mut spans = Vec::with_capacity(calendar.weeks.len() * 2 + 1);
            spans.push(weekday(app, row));
            for week in &calendar.weeks {
                spans.push(cell(app, week.days[row], fill));
                if gap > 0 {
                    spans.push(Span::raw(" ".repeat(gap)));
                }
            }
            Line::from(spans)
        })
        .collect()
}

fn weekday(app: &App, row: usize) -> Span<'static> {
    Span::styled(
        format!("{:<width$}", WEEKDAYS[row], width = GUTTER),
        Style::new().fg(app.palette.ansi(app.palette.fg)),
    )
}

/// The colour a day is marked with, if it is: the mouse wins over the cursor, the
/// same way a browser shows the tooltip for what you are pointing at.
fn mark(app: &App, date: NaiveDate) -> Option<ratatui::style::Color> {
    if app.hover == Some(date) {
        return Some(app.palette.ansi(app.palette.accent));
    }
    (app.cursor == date).then(|| app.palette.ansi(app.palette.fg))
}

/// One day. Days outside the year and days still to come both draw blank, so the
/// year is always a complete rectangle with the unwritten part left empty.
fn cell(app: &App, day: Option<Day>, fill: usize) -> Span<'static> {
    let Some(day) = day else {
        return Span::raw(" ".repeat(fill));
    };
    let marked = mark(app, day.date);
    if day.future {
        return match marked {
            Some(color) => Span::styled(CURSOR.repeat(fill), Style::new().fg(color)),
            None => Span::raw(" ".repeat(fill)),
        };
    }
    match marked {
        Some(color) => Span::styled(
            CURSOR.repeat(fill),
            Style::new().fg(color).bg(app.palette.level(day.level)),
        ),
        None => Span::styled(
            FILL.repeat(fill),
            Style::new().fg(app.palette.level(day.level)),
        ),
    }
}

fn detail(app: &App, calendar: &Calendar) -> Line<'static> {
    let palette = &app.palette;
    let Some(day) = calendar.day(app.cursor) else {
        return Line::raw("");
    };
    let date = Span::styled(
        app.cursor.format("%a, %b %-d %Y").to_string(),
        Style::new().fg(palette.ansi(palette.fg)).bold(),
    );
    if day.future {
        return Line::from(vec![
            date,
            separator(palette),
            Span::styled(
                "still to come",
                Style::new().fg(palette.ansi(palette.muted)),
            ),
        ]);
    }
    Line::from(vec![
        date,
        separator(palette),
        Span::styled(count(day.count), Style::new().fg(palette.ansi(palette.fg))),
    ])
}

/// Returns the line and, in pixel mode, where the swatch image goes.
fn legend(
    app: &App,
    calendar: &Calendar,
    cells: &Cells,
    x: u16,
    row: u16,
) -> (Line<'static>, Option<(u16, u16)>) {
    let palette = &app.palette;
    let muted = Style::new().fg(palette.ansi(palette.muted));
    let lead = "Less ";
    let mut spans = vec![Span::styled(lead, muted)];
    for level in 0..5u8 {
        spans.push(Span::styled(
            cells.swatch(),
            Style::new().fg(palette.level(level)),
        ));
        if cells.swatch_gap() > 0 {
            spans.push(Span::raw(" ".repeat(cells.swatch_gap())));
        }
    }
    // The gap after the last swatch is the one before "More"; pixel swatches carry
    // theirs inside the image, so that one has to be spelled out.
    spans.push(Span::styled(
        if cells.swatch_gap() > 0 {
            "More"
        } else {
            " More"
        },
        muted,
    ));
    if calendar.days().any(|day| day.future) {
        spans.push(Span::styled("   ·   blank = still to come", muted));
    }
    // Naming the protocol matters here: "pixel cells (sixel)" is the difference
    // between a chart that looks right and knowing why it looks right.
    spans.push(Span::styled(
        match cells {
            Cells::Pixels => format!("   ·   {} cells ({})", cells.name(), app.protocol_name()),
            _ => format!("   ·   {} cells", cells.name()),
        },
        muted,
    ));
    let at = matches!(cells, Cells::Pixels).then(|| (x + lead.len() as u16, row));
    (Line::from(spans), at)
}

fn summary(app: &App, calendar: &Calendar) -> Line<'static> {
    let muted = Style::new().fg(app.palette.ansi(app.palette.muted));
    if !calendar.has_elapsed_days() {
        return Line::styled(format!("{} hasn't started yet", calendar.year), muted);
    }
    let stats = calendar.stats();
    let mut parts = vec![format!("{} active days", thousands(stats.active_days))];
    if stats.current_streak > 0 {
        parts.push(format!("{}-day streak", stats.current_streak));
    }
    if stats.longest_streak > 0 {
        parts.push(format!("longest {}", stats.longest_streak));
    }
    if let Some((date, count)) = stats.best {
        parts.push(format!(
            "best {} ({})",
            date.format("%b %-d"),
            thousands(count)
        ));
    }
    Line::styled(parts.join("  ·  "), muted)
}

/// The floating box github.com shows above the day under the pointer, in the same
/// `--bgColor-emphasis` and with the same wording. It sits in the two rows above the
/// grid rather than over it: in pixel mode those rows belong to the image, and text
/// written over a sixel erases it for good.
fn tooltip(frame: &mut Frame<'_>, app: &App, layout: &Layout, inner: Rect) {
    let Load::Ready(calendar) = &app.load else {
        return;
    };
    let (Some(date), Some(day)) = (app.hover, app.hover.and_then(|date| calendar.day(date))) else {
        return;
    };
    let (Some((week, _)), Some(text_row)) = (calendar.position(date), layout.y.checked_sub(2))
    else {
        return;
    };
    if day.future {
        // github.com does not draw a day that has not happened, so it has nothing to
        // say about one either. Reachable only if the pointer is left on a cell that
        // a reload turns into the future.
        return;
    }

    let palette = &app.palette;
    let text = format!(
        "{} on {} {}.",
        count(day.count),
        date.format("%B"),
        ordinal(date.day())
    );
    let width = text.chars().count() as u16 + 4;
    if width > inner.width {
        return;
    }
    // Point at the middle of the day's cell, and keep the box on screen.
    let point =
        layout.x + week as u16 * layout.cells.stride() as u16 + layout.cells.stride() as u16 / 2;
    let x = point
        .saturating_sub(width / 2)
        .clamp(inner.x, inner.right().saturating_sub(width));

    let background = palette.ansi(palette.tooltip_bg);
    // Half blocks close the ends, so the box reads as a rounded pill rather than as
    // a rectangle of colour bleeding into the cell beside it.
    let pill = Line::from(vec![
        Span::styled("", Style::new().fg(background)),
        Span::styled(
            format!(" {text} "),
            Style::new()
                .bg(background)
                .fg(palette.ansi(palette.tooltip_fg)),
        ),
        Span::styled("", Style::new().fg(background)),
    ]);
    frame.render_widget(Paragraph::new(pill), Rect::new(x, text_row, width, 1));
    frame.render_widget(
        Paragraph::new(Line::from(Span::styled("", Style::new().fg(background)))),
        Rect::new(
            point.min(inner.right().saturating_sub(1)),
            text_row + 1,
            1,
            1,
        ),
    );
}

/// "No contributions" / "1 contribution" / "97 contributions", as github.com writes it.
fn count(count: u32) -> String {
    match count {
        0 => "No contributions".to_string(),
        1 => "1 contribution".to_string(),
        n => format!("{} contributions", thousands(n)),
    }
}

fn ordinal(day: u32) -> String {
    let suffix = match (day % 10, day % 100) {
        (_, 11..=13) => "th",
        (1, _) => "st",
        (2, _) => "nd",
        (3, _) => "rd",
        _ => "th",
    };
    format!("{day}{suffix}")
}

fn loading(app: &App) -> Line<'static> {
    let frame = SPINNER[(app.tick / 2) as usize % SPINNER.len()];
    Line::from(vec![
        Span::styled(frame, Style::new().fg(app.palette.level(4))),
        Span::styled(
            format!(" loading {} {}", app.login, app.year),
            Style::new().fg(app.palette.ansi(app.palette.muted)),
        ),
    ])
}

fn failure(app: &App, message: &str) -> Vec<Line<'static>> {
    let palette = &app.palette;
    vec![
        Line::from(vec![
            Span::styled("!", Style::new().fg(ratatui::style::Color::Red).bold()),
            Span::styled(
                format!(" {message}"),
                Style::new().fg(palette.ansi(palette.fg)),
            ),
        ]),
        Line::raw(""),
        Line::styled(
            "r retry  ·  u change user",
            Style::new().fg(palette.ansi(palette.muted)),
        ),
    ]
}

fn footer(app: &App) -> Line<'static> {
    let palette = &app.palette;
    let muted = Style::new().fg(palette.ansi(palette.muted));
    match &app.mode {
        Mode::Input(buffer) => Line::from(vec![
            Span::styled("user ", muted),
            Span::styled(
                buffer.clone(),
                Style::new().fg(palette.ansi(palette.fg)).bold(),
            ),
            Span::styled("", Style::new().fg(palette.ansi(palette.fg))),
            Span::styled("   enter load  ·  esc cancel", muted),
        ]),
        Mode::Normal => {
            let mut keys = vec!["←→↑↓ day/week"];
            if !app.previewing() {
                keys.push("[ ] year");
            }
            keys.push("t today");
            if !app.previewing() {
                keys.push("u user");
            }
            keys.push("d cells");
            keys.push(if app.mouse {
                "m mouse off"
            } else {
                "m mouse on"
            });
            keys.push("r reload");
            keys.push("q quit");
            keys.push("? help");
            let mut line = keys.join("  ·  ");
            if let Some(label) = app.source_label() {
                line.push_str("  ·  ");
                line.push_str(label);
            }
            Line::styled(line, muted)
        }
    }
}