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
//! Application state, key and mouse handling, and the background fetch plumbing.
use std::io::{self, Write};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use chrono::{Duration, Local, NaiveDate};
use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use crate::calendar::Calendar;
use crate::github;
use crate::graphics::{Mark, Painter, Protocol, Ring, Scene, COLUMNS_PER_DAY};
use crate::primer::{Appearance, Palette, Season};
use crate::term::{self, Caps};
use crate::ui::{Cells, Layout};
/// Where the calendar comes from.
#[derive(Debug, Clone)]
pub enum Source {
/// The GitHub API, through `gh`.
GitHub,
/// A saved calendar file, for previewing offline.
File(String),
/// A sample year, for trying the chart with no account and no network.
Demo,
}
/// Where the current year's fetch has got to.
#[derive(Debug)]
pub enum Load {
/// A fetch is in flight.
Loading,
/// A year, ready to draw.
Ready(Box<Calendar>),
/// The fetch failed, with what to tell the user.
Failed(String),
}
/// Which graphics protocol to use, when the choice is not left to the terminal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Graphics {
/// Whatever the terminal answers to.
#[default]
Auto,
/// The kitty graphics protocol, asked for regardless of the answer.
Kitty,
/// Sixel, asked for regardless of the answer.
Sixel,
/// Characters only, however capable the terminal turns out to be.
Text,
}
/// The command line, as far as presentation is concerned.
#[derive(Debug, Clone, Copy)]
pub struct Options {
/// Which graphics protocol to use, if any.
pub graphics: Graphics,
/// `None` follows the terminal's own background colour.
pub appearance: Option<Appearance>,
/// `None` follows the calendar, as github.com does.
pub season: Option<Season>,
/// Whether to turn on mouse reporting at all.
pub mouse: bool,
/// One character cell in pixels, when the terminal will not say. Without it
/// an image cannot be lined up with the labels around it, so a terminal that
/// answers nothing gets text cells however capable it is.
pub cell: Option<(u16, u16)>,
/// Which day to read the year as of. `None` is the clock — and, for a saved
/// calendar, means every day in it counts as elapsed.
pub today: Option<NaiveDate>,
}
impl Default for Options {
fn default() -> Self {
Self {
graphics: Graphics::Auto,
appearance: None,
season: None,
mouse: true,
cell: None,
today: None,
}
}
}
/// How each day cell is drawn. `Auto` picks the most faithful that fits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellStyle {
/// The most faithful style that fits.
Auto,
/// Real pixels through the terminal's graphics protocol: github.com's geometry
/// exactly, at whatever resolution a character cell turns out to be.
Pixels,
/// Rounded squares, corners shaved at sub-character resolution with block
/// sextants. The closest match to github.com without pixels.
Rounded,
/// Rounded cells with no gap between them: two thirds of the width, but the
/// cells touch and read as a chain rather than as separate squares.
Snug,
/// The same shape with sharp corners, in less width.
Squares,
/// A box border around every square cell: easiest to read a single day out
/// of, and the largest at two columns per day plus shared borders.
Grid,
/// Two-column cells with a blank column between weeks.
Spaced,
/// Two-column cells, touching.
Blocks,
/// Bordered but only one column per day, so cells are tall rectangles. Keeps
/// the borders when a terminal is too narrow for square ones.
Slim,
/// One column per day, no border: fits a year in the least space.
Compact,
}
impl CellStyle {
/// The next style in the cycle. Pixels are skipped where the terminal cannot
/// draw them, since asking for them there resolves to rounded and pressing `d`
/// would look like it had done nothing.
fn next(self, pixels: bool) -> Self {
let next = match self {
Self::Auto => Self::Pixels,
Self::Pixels => Self::Rounded,
Self::Rounded => Self::Snug,
Self::Snug => Self::Squares,
Self::Squares => Self::Grid,
Self::Grid => Self::Spaced,
Self::Spaced => Self::Blocks,
Self::Blocks => Self::Slim,
Self::Slim => Self::Compact,
Self::Compact => Self::Auto,
};
match next {
Self::Pixels if !pixels => next.next(pixels),
next => next,
}
}
}
/// What the keyboard is doing.
#[derive(Debug)]
pub enum Mode {
/// Moving around the chart.
Normal,
/// Typing a username into the footer.
Input(String),
}
/// Result of one background fetch. `seq` lets stale replies be dropped when the
/// user flips through years faster than the requests come back.
struct Fetched {
seq: u64,
result: Result<Calendar, String>,
}
/// The whole application state: what is being shown, what the terminal can do,
/// and where the last frame put things.
#[derive(Debug)]
pub struct App {
/// The GitHub login being shown.
pub login: String,
/// The year being shown.
pub year: i32,
/// The fetch for that year.
pub load: Load,
/// The keyboard cursor's day.
pub cursor: NaiveDate,
/// The day under the mouse, if the terminal reports motion and the pointer is
/// over one. Separate from the cursor: a pointer and a caret are different
/// things, and github.com's tooltip follows the pointer.
pub hover: Option<NaiveDate>,
/// Years with contributions, ascending. Empty until the first fetch lands.
pub years: Vec<i32>,
/// Normal, or typing a username.
pub mode: Mode,
/// The cell style asked for, which `Auto` leaves to the renderer.
pub cells: CellStyle,
/// The colours in use.
pub palette: Palette,
/// What the terminal answered at startup.
pub caps: Caps,
/// Present when the terminal draws pixels and we know how big a cell is.
pub gfx: Option<Painter>,
/// Where the last frame put the grid. Set by the renderer, read by the mouse
/// and the painter.
pub layout: Option<Layout>,
/// Columns the footer may use, set by the renderer each frame so it can drop
/// what will not fit rather than being cut mid-word.
pub footer_width: u16,
/// Whether mouse reporting is on; `m` toggles it.
pub mouse: bool,
/// Whether the help overlay is up. `?` opens it, any key closes it.
pub help: bool,
/// Ask for a full clear before the next frame — anything that leaves stale
/// pixels behind sets it.
pub redraw: bool,
/// Where calendars come from.
pub source: Source,
/// Frames drawn, which drives the loading spinner.
pub tick: u64,
/// Set when the event loop should stop.
pub quit: bool,
/// Which day to read the year as of, when the clock is not the answer.
today: Option<NaiveDate>,
/// The protocol that was asked for, so the chart can say when it could not
/// be used rather than quietly drawing characters.
wanted: Graphics,
/// `--cell`, which outranks anything the terminal reports and survives a
/// re-measure.
cell_override: Option<(u16, u16)>,
seq: u64,
tx: Sender<Fetched>,
rx: Receiver<Fetched>,
}
impl App {
/// A fresh app, before the terminal has been asked anything.
pub fn new(login: String, year: i32, source: Source) -> Self {
let (tx, rx) = mpsc::channel();
Self {
login,
year,
load: Load::Loading,
cursor: Local::now().date_naive(),
hover: None,
years: Vec::new(),
mode: Mode::Normal,
cells: CellStyle::Auto,
palette: Palette::new(
Appearance::Dark,
Season::on(Local::now().date_naive()),
Palette::truecolor_env(),
),
caps: Caps::default(),
gfx: None,
layout: None,
footer_width: 0,
mouse: false,
help: false,
redraw: false,
source,
tick: 0,
quit: false,
today: None,
wanted: Graphics::Auto,
cell_override: None,
seq: 0,
tx,
rx,
}
}
/// Settle everything that depends on the terminal: which theme, which protocol,
/// how big a character cell is. Called once, after the terminal is set up and
/// has been asked what it can do.
pub fn configure(&mut self, caps: Caps, options: Options) {
self.caps = caps;
self.mouse = options.mouse;
self.today = options.today;
self.wanted = options.graphics;
self.cell_override = options.cell;
let appearance = options
.appearance
.or_else(|| caps.background.map(Appearance::from_background))
.unwrap_or(Appearance::Dark);
let season = options
.season
.unwrap_or_else(|| Season::on(Local::now().date_naive()));
self.palette = Palette::new(appearance, season, Palette::truecolor_env());
self.gfx = painter(&caps, &options, &self.palette);
}
/// Which day the chart is read as of: `--today` when it was given, otherwise
/// the clock. An input rather than an ambient fact, the way the tracker's has
/// been since 0.3.0.
pub fn today(&self) -> NaiveDate {
self.today.unwrap_or_else(|| Local::now().date_naive())
}
/// What a saved calendar should be read as of: nothing, unless a date was
/// given. A snapshot of a year that has not happened is the point of `--file`,
/// and it only works while every day in it counts as elapsed.
fn file_now(&self) -> Option<NaiveDate> {
self.today
}
/// Whether this terminal can draw pixel cells.
pub fn pixels_available(&self) -> bool {
self.gfx.is_some()
}
/// Whether a protocol was asked for and could not be used. The chart says so
/// under the legend: asking for pixels and silently getting characters is the
/// one outcome `--capabilities` explains and the chart did not.
pub fn graphics_refused(&self) -> Option<&'static str> {
match (self.wanted, self.gfx.is_some()) {
(Graphics::Kitty, false) => Some("kitty"),
(Graphics::Sixel, false) => Some("sixel"),
_ => None,
}
}
/// Ask the terminal how big a character cell is again.
///
/// The size was read once at startup, so changing the font left the painter
/// scaling a stale geometry — and `docs/DESIGN.md` promises the ratios are
/// "scaled to whatever a character cell measures". `TIOCGWINSZ` answers this
/// without another probe, so a resize can afford to ask.
pub fn remeasure(&mut self) {
let Some(painter) = self.gfx.as_mut() else {
return;
};
if let Some(cell) = self.cell_override.or_else(|| term::cell_size(&self.caps)) {
if cell != painter.cell {
painter.cell = cell;
painter.invalidate();
}
}
}
/// The protocol in use, or `text` where there is none.
pub fn protocol_name(&self) -> &'static str {
self.gfx.as_ref().map_or("text", |gfx| gfx.protocol.name())
}
/// Kick off a fetch for the current login and year, superseding any in flight.
pub fn request(&mut self) {
self.seq += 1;
let (seq, tx) = (self.seq, self.tx.clone());
let (login, year) = (self.login.clone(), self.year);
let source = self.source.clone();
let (today, file_now) = (self.today(), self.file_now());
self.load = Load::Loading;
self.hover = None;
// The chart is about to be replaced by a spinner, and the rows it occupied
// are blank as far as the text layer is concerned — so nothing would write
// over the pixels still sitting in them.
self.redraw = true;
thread::spawn(move || {
let result = match source {
Source::GitHub => github::fetch(&login, year, today),
Source::File(path) => github::from_file(&path, file_now),
Source::Demo => Ok(crate::calendar::demo(year)),
};
let _ = tx.send(Fetched { seq, result });
});
}
/// Apply any completed fetches. Non-blocking; call once per frame.
pub fn drain(&mut self) {
while let Ok(fetched) = self.rx.try_recv() {
if fetched.seq != self.seq {
continue;
}
match fetched.result {
Ok(calendar) => {
if !calendar.years.is_empty() {
self.years = calendar.years.clone();
}
self.login = calendar.login.clone();
self.year = calendar.year;
// Start on today when it is in range, otherwise the newest day shown.
let today = self.today();
self.cursor = if calendar.day(today).is_some() {
today
} else {
calendar.last_date().unwrap_or(self.cursor)
};
self.load = Load::Ready(Box::new(calendar));
}
Err(message) => self.load = Load::Failed(message),
}
}
}
/// A file or the demo has no other years or users to move between.
pub fn previewing(&self) -> bool {
matches!(self.source, Source::File(_) | Source::Demo)
}
/// What the footer calls this, when it is not a real account.
pub fn source_label(&self) -> Option<&'static str> {
match self.source {
Source::GitHub => None,
Source::File(_) => Some("preview"),
Source::Demo => Some("demo — `mossaic <user>` for a real one"),
}
}
/// Handle a key press.
pub fn on_key(&mut self, code: KeyCode, mods: KeyModifiers) {
if mods.contains(KeyModifiers::CONTROL) && matches!(code, KeyCode::Char('c' | 'd')) {
self.quit = true;
return;
}
if matches!(self.mode, Mode::Input(_)) {
self.on_key_input(code);
} else {
self.on_key_normal(code);
}
}
fn on_key_normal(&mut self, code: KeyCode) {
// The help is modal and forgiving: whatever you press to get out of it,
// gets you out of it. Closing needs a redraw because the overlay is text
// written over the image, and a sixel does not survive that.
if self.help {
self.help = false;
self.redraw = true;
return;
}
match code {
KeyCode::Char('q') | KeyCode::Esc => self.quit = true,
KeyCode::Left | KeyCode::Char('h') => self.move_cursor(-7),
KeyCode::Right | KeyCode::Char('l') => self.move_cursor(7),
KeyCode::Up | KeyCode::Char('k') => self.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_cursor(1),
// `y` and `Y` used to do this too, undocumented. In a chart that
// binds `h j k l`, `y` is a key a vim-handed user presses meaning
// *yank* — and having it silently change the year was the one
// binding here with a real chance of surprising someone. `PageUp`
// and `PageDown` say what they do and are documented instead.
KeyCode::Char('[') | KeyCode::PageUp => self.change_year(-1),
KeyCode::Char(']') | KeyCode::PageDown => self.change_year(1),
KeyCode::Home => self.jump(Edge::First),
KeyCode::End => self.jump(Edge::Last),
KeyCode::Char('t') => self.jump(Edge::Today),
// A style change moves or removes the image, so the screen has to go
// with it: pixels the text layer never wrote are pixels it cannot clear.
KeyCode::Char('d') => {
self.cells = self.cells.next(self.pixels_available());
self.redraw = true;
}
KeyCode::Char('?') | KeyCode::F(1) => {
self.help = true;
self.redraw = true;
}
KeyCode::Char('m') => {
self.mouse = !self.mouse;
self.hover = None;
}
KeyCode::Char('r') => self.request(),
KeyCode::Char('u') if !self.previewing() => {
self.mode = Mode::Input(self.login.clone());
self.hover = None;
}
_ => {}
}
}
fn on_key_input(&mut self, code: KeyCode) {
let Mode::Input(buffer) = &mut self.mode else {
return;
};
match code {
KeyCode::Backspace => {
buffer.pop();
}
KeyCode::Char(c) if !c.is_whitespace() => buffer.push(c),
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Enter => {
let login = buffer.trim().to_string();
self.mode = Mode::Normal;
if !login.is_empty() && login != self.login {
self.login = login;
self.years.clear();
self.request();
}
}
_ => {}
}
}
/// Motion, drag and press all hover, because not every terminal reports all
/// three: `1003` motion tracking is the one that makes hovering work, and where
/// it is missing — Terminal.app, some multiplexers — a click still lands.
pub fn on_mouse(&mut self, event: MouseEvent) {
// The overlay says "any key closes this", and a wheel notch is not a key.
// Acting behind it changed the year and started a fetch for a year the
// reader could not see.
if matches!(self.mode, Mode::Input(_)) || self.help {
return;
}
match event.kind {
MouseEventKind::Moved | MouseEventKind::Drag(MouseButton::Left) => {
self.hover = self.day_at(event.column, event.row);
}
MouseEventKind::Down(MouseButton::Left) => match self.day_at(event.column, event.row) {
Some(date) => {
self.cursor = date;
self.hover = Some(date);
}
// A click that misses a day used to leave the last tooltip on
// screen, still pointing at an unrelated date. On a terminal that
// reports clicks but not motion — which is the case this arm
// exists for — no later event corrects it.
None => self.hover = None,
},
MouseEventKind::ScrollUp => self.change_year(-1),
MouseEventKind::ScrollDown => self.change_year(1),
_ => {}
}
}
/// Which day is under a character cell. Days still to come are not days: GitHub
/// draws nothing there and has nothing to say about them.
fn day_at(&self, column: u16, row: u16) -> Option<NaiveDate> {
let Load::Ready(calendar) = &self.load else {
return None;
};
let (week, weekday) = self.layout?.hit(column, row)?;
let day = calendar.weeks.get(week)?.days[weekday]?;
(!day.future).then_some(day.date)
}
/// Move the cursor by whole days (+-1) or weeks (+-7), clamped to the visible range.
/// Every date between the first and last day exists, so clamping is enough.
fn move_cursor(&mut self, days: i64) {
let Load::Ready(calendar) = &self.load else {
return;
};
let (Some(first), Some(last)) = (calendar.first_date(), calendar.last_date()) else {
return;
};
// Checked, and the clamp cannot do it: the addition happens first, so a
// cursor within a week of the last date a `NaiveDate` can hold used to
// panic on the next arrow key. Running off the calendar is what clamping
// is for, so an overflow lands on the edge it was heading for.
let moved = self
.cursor
.checked_add_signed(Duration::days(days))
.unwrap_or(if days < 0 { first } else { last });
self.cursor = moved.clamp(first, last);
}
fn jump(&mut self, edge: Edge) {
let Load::Ready(calendar) = &self.load else {
return;
};
let target = match edge {
Edge::First => calendar.first_date(),
Edge::Last => calendar.last_date(),
Edge::Today => {
let today = self.today();
calendar.day(today).map(|_| today)
}
};
if let Some(date) = target {
self.cursor = date;
}
}
/// Step through the years GitHub reports contributions for, so empty years are skipped.
fn change_year(&mut self, delta: i32) {
if self.previewing() {
return;
}
let target = match self.years.binary_search(&self.year) {
Ok(index) => {
let next = index as i32 + delta;
if next < 0 || next as usize >= self.years.len() {
return;
}
self.years[next as usize]
}
// Still loading, or a year outside the contribution set (--year 2010):
// fall back to stepping by one so those years stay reachable — but
// only as far as `--year` itself would accept. Walking past it was a
// `gh` subprocess per keypress for a year the CLI refuses to be given.
Err(_) => match self.year.checked_add(delta) {
Some(year) if crate::cli::YEARS.contains(&year) => year,
_ => return,
},
};
if target != self.year {
self.year = target;
self.request();
}
}
/// Put the pixels on the screen, after the text has been drawn. Nothing here
/// touches ratatui's buffer: the renderer leaves the grid blank and the painter
/// writes over it, which is what keeps the diff from erasing the image.
pub fn paint(&mut self, out: &mut impl Write) -> io::Result<()> {
// Split borrows: the scene reads the loaded year and the palette, the
// painter is mutated. Disjoint fields, so both can be held at once.
// Nothing while the overlay is up. A kitty image sits at `z=-2`, which
// draws *over* a cell background rather than under it, and the help panel
// sets one — so the chart showed through its text.
let scene = match self.help {
true => None,
false => scene(
&self.load,
self.layout,
&self.palette,
self.cursor,
self.hover,
),
};
let width = self.layout.map_or(u16::MAX, |layout| layout.right);
let Some(painter) = self.gfx.as_mut() else {
return Ok(());
};
painter.width = width;
match scene {
Some(scene) => painter.paint(out, &scene),
None => painter.clear(out),
}
}
/// Take the chart down before the terminal goes back to the shell.
pub fn stop(&mut self, out: &mut impl Write) -> io::Result<()> {
match self.gfx.as_mut() {
Some(painter) => painter.clear(out),
None => Ok(()),
}
}
}
fn painter(caps: &Caps, options: &Options, palette: &Palette) -> Option<Painter> {
let protocol = match options.graphics {
Graphics::Text => return None,
Graphics::Kitty => Protocol::Kitty,
Graphics::Sixel => Protocol::Sixel,
Graphics::Auto if caps.kitty => Protocol::Kitty,
Graphics::Auto if caps.sixel => Protocol::Sixel,
Graphics::Auto => return None,
};
// Without the size of a character cell an image cannot be lined up with the
// labels around it, and a chart half a column out is worse than no chart.
let cell = options
.cell
.or_else(|| term::cell_size(caps))
.filter(|(w, h)| *w >= 2 && *h >= 2)?;
Some(Painter::new(
protocol,
cell,
caps.background.unwrap_or(palette.canvas),
))
}
/// What the painter should be showing, given what the renderer laid out.
fn scene<'a>(
load: &'a Load,
layout: Option<Layout>,
palette: &'a Palette,
cursor: NaiveDate,
hover: Option<NaiveDate>,
) -> Option<Scene<'a>> {
let layout = layout.filter(|layout| layout.cells == Cells::Pixels && layout.has_room())?;
// The legend swatches are an image too, and `has_room` only ever covered the
// grid — so they were placed off-screen and the terminal clamped them onto
// whatever row was last, leaving a hole in the frame.
let legend = layout
.legend
.filter(|(x, y)| *y < layout.bottom && x + 5 * COLUMNS_PER_DAY <= layout.right);
let Load::Ready(calendar) = load else {
return None;
};
let levels: Vec<[Option<u8>; 7]> = calendar
.weeks
.iter()
.map(|week| {
let mut column = [None; 7];
for (weekday, day) in week.days.iter().enumerate() {
// A day still to come is drawn as nothing at all, the way an
// unwritten day is on github.com.
column[weekday] = day.filter(|day| !day.future).map(|day| day.level);
}
column
})
.collect();
let mark = |date: Option<NaiveDate>, ring: Ring| -> Option<Mark> {
let date = date?;
let (week, weekday) = calendar.position(date)?;
Some(Mark {
week: week as u16,
weekday: weekday as u16,
level: calendar
.day(date)
.filter(|day| !day.future)
.map(|day| day.level),
ring,
})
};
// The pointer wins where both land on the same day: two rings on one cell would
// draw over each other, and erasing one would take the other with it.
let hovered = mark(hover, Ring::Hover);
let cursored = mark(
Some(cursor).filter(|date| hover != Some(*date)),
Ring::Cursor,
);
Some(Scene {
key: key(&calendar.login, calendar.year, palette, &levels),
palette,
grid: (layout.x, layout.y),
legend,
levels,
marks: [cursored, hovered],
})
}
/// Identity of the base image: anything that would change a pixel of it. Rings are
/// not in it, because they are painted a cell at a time.
fn key(login: &str, year: i32, palette: &Palette, levels: &[[Option<u8>; 7]]) -> u64 {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hasher = DefaultHasher::new();
(login, year).hash(&mut hasher);
(palette.appearance as u8, palette.season as u8).hash(&mut hasher);
for level in palette.levels {
(level.0, level.1, level.2).hash(&mut hasher);
}
levels.hash(&mut hasher);
hasher.finish()
}
enum Edge {
First,
Last,
Today,
}