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;
const GUTTER: usize = 4;
const WEEKDAYS: [&str; 7] = ["", "Mon", "", "Wed", "", "Fri", ""];
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const FILL: &str = "█";
const CURSOR: &str = "▓";
const SQUARE: &str = "▀";
const ROUND: &str = "\u{1FB2B}\u{1FB1B}";
pub(crate) const CHROME_ROWS: usize = 10;
pub(crate) const BORDER: usize = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cells {
Pixels,
Squares,
Grid {
fill: usize,
},
Rounded {
gap: usize,
},
Solid {
fill: usize,
gap: usize,
},
}
impl Cells {
pub const fn stride(&self) -> usize {
match self {
Self::Pixels => COLUMNS_PER_DAY as usize,
Self::Squares => 2,
Self::Rounded { gap } => 2 + *gap,
Self::Grid { fill } => *fill + 1,
Self::Solid { fill, gap } => *fill + *gap,
}
}
const fn offset(&self) -> usize {
match self {
Self::Grid { .. } => 1,
Self::Pixels | Self::Rounded { .. } | Self::Squares | Self::Solid { .. } => 0,
}
}
pub const fn width(&self, weeks: usize) -> usize {
match self {
Self::Grid { .. } => GUTTER + weeks * self.stride() + 1,
Self::Pixels | Self::Rounded { .. } | Self::Squares | Self::Solid { .. } => {
GUTTER + weeks * self.stride()
}
}
}
pub const fn height(&self) -> usize {
match self {
Self::Grid { .. } => 7 + 6 + 2,
Self::Pixels | Self::Rounded { .. } | Self::Squares | Self::Solid { .. } => 7,
}
}
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),
}
}
const fn swatch_gap(&self) -> usize {
match self {
Self::Pixels => 0,
_ => 1,
}
}
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
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Layout {
pub x: u16,
pub y: u16,
pub weeks: u16,
pub cells: Cells,
pub legend: Option<(u16, u16)>,
pub bottom: u16,
pub right: u16,
}
impl Layout {
pub fn has_room(&self) -> bool {
self.y + self.cells.height() as u16 <= self.bottom
&& self.x + self.weeks * COLUMNS_PER_DAY <= self.right
}
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();
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))
}
}
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 {
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,
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),
}
}
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);
app.footer_width = inner.width;
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);
}
}
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 cells = app.layout.map_or("—", |layout| layout.cells.name());
let yes_no = |flag: bool| if flag { "yes" } else { "no" };
const SPACER: u8 = 0;
const HINT: u8 = 1;
const HEADING: u8 = 2;
const KEY: u8 = 3;
const FACT: u8 = 4;
const DECISION: u8 = 5;
const WAY_OUT: u8 = 6;
let heading = |title: &'static str| (HEADING, Line::styled(title, muted));
let row = |keys: &'static str, what: &'static str| {
(
KEY,
Line::from(vec![
Span::styled(format!(" {keys:<16}"), key),
Span::styled(what, text),
]),
)
};
let fact = |rank: u8, name: &'static str, value: String| {
(
rank,
Line::from(vec![
Span::styled(format!(" {name:<16}"), muted),
Span::styled(value, text),
]),
)
};
let blank = || (SPACER, Line::raw(""));
let mut lines = vec![
heading(" Moving"),
row("← → / h l", "previous / next week"),
row("↑ ↓ / k j", "previous / next day"),
row("[ ] · PgUp PgDn", "previous / next year"),
row("t · Home End", "today · first / last day"),
blank(),
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"),
blank(),
heading(" Chart"),
row("d", "cycle cell style"),
row("u · r", "another user · reload"),
row("q · Esc", "quit"),
blank(),
heading(" This terminal"),
fact(FACT, "kitty graphics", yes_no(app.caps.kitty).to_string()),
fact(FACT, "sixel", yes_no(app.caps.sixel).to_string()),
fact(
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(
DECISION,
"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(blank());
lines.push((
HINT,
Line::styled(
" kitty, Ghostty, WezTerm, foot, Konsole, iTerm2 and",
muted,
),
));
lines.push((
HINT,
Line::styled(" xterm -ti vt340 draw one protocol or the other.", muted),
));
}
lines.push(blank());
lines.push((WAY_OUT, Line::styled(" any key closes this", muted)));
while lines.len() as u16 + 3 > inner.height && lines.len() > 1 {
let Some(cheapest) = lines
.iter()
.enumerate()
.min_by_key(|(index, (rank, _))| (*rank, std::cmp::Reverse(*index)))
.map(|(index, _)| index)
else {
break;
};
lines.remove(cheapest);
}
let lines: Vec<Line<'static>> = lines.into_iter().map(|(_, line)| line).collect();
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) => {
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));
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 {
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(),
right: inner.right(),
};
(lines, Some(layout))
}
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 let Some(protocol) = app.graphics_refused() {
return Some(Line::styled(
format!("({protocol} was asked for, but no cell size is known — pass --cell WxH)"),
muted,
));
}
if !cells.fits(weeks, width, height) {
let smallest = Cells::Solid { fill: 1, gap: 0 };
let short = smallest.height() + CHROME_ROWS > height;
let narrow = smallest.width(weeks) > width;
return Some(Line::styled(
match (narrow, short) {
(true, _) => format!(
"(a year needs {} columns even at its narrowest — this window has {})",
smallest.width(weeks) + BORDER,
width + BORDER
),
(false, true) => format!(
"(the chart needs {} rows — this window has {})",
smallest.height() + CHROME_ROWS + BORDER,
height + BORDER
),
_ => "(too small for these cells — press d for a smaller style)".to_string(),
},
muted,
));
}
if !matches!(app.cells, CellStyle::Auto) {
return None;
}
let (wanted, need) = match (app.pixels_available(), cells) {
(true, Cells::Pixels) => return None,
(true, _) => ("pixel cells", Cells::Pixels.width(weeks) + BORDER),
(false, Cells::Rounded { .. }) => return None,
(false, _) => (
"rounded corners",
Cells::Rounded { gap: 1 }.width(weeks) + BORDER,
),
};
Some(Line::styled(
format!("{wanted} need {need} columns — d forces them, clipped"),
muted,
))
}
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();
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)))
}
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
}
fn filled_rows(
app: &App,
calendar: &Calendar,
glyph: &'static str,
gap: usize,
) -> Vec<Line<'static>> {
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] {
None => spans.push(Span::raw(blank.clone())),
Some(day) if day.future => match mark(app, day.date) {
Some(color) => {
spans.push(Span::styled(glyph, Style::new().fg(color)));
if gap > 0 {
spans.push(Span::raw(" ".repeat(gap)));
}
}
None => spans.push(Span::raw(blank.clone())),
},
Some(day) => {
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)),
)
}
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))
}
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))),
])
}
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())));
}
}
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));
}
let chosen = match matches!(app.cells, CellStyle::Auto) {
true => format!("auto: {}", cells.name()),
false => cells.name().to_string(),
};
spans.push(Span::styled(
match cells {
Cells::Pixels => format!(" · {chosen} cells ({})", app.protocol_name()),
_ => format!(" · {chosen} cells"),
},
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(app.today());
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)
}
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 {
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;
}
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);
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,
),
);
}
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");
if let Some(label) = app.source_label() {
keys.push(label);
}
const JOIN: &str = " · ";
const JOIN_COLUMNS: usize = 5;
let width = usize::from(app.footer_width);
let fits = |items: &[&str]| {
items.iter().map(|item| item.chars().count()).sum::<usize>()
+ JOIN_COLUMNS * items.len().saturating_sub(1)
<= width
};
while !fits(&keys) && keys.len() > 2 {
keys.remove(0);
}
Line::styled(keys.join(JOIN), muted)
}
}
}