use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
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;
const UNDO_DEPTH: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Idle,
Save,
Quit,
}
#[derive(Debug, Clone)]
pub struct Editor {
pub canvas: Canvas,
pub grid: Grid,
pub start: usize,
pub cursor: (usize, usize),
pub brush: u8,
pub painting: bool,
undo: Vec<Canvas>,
pub dirty: bool,
pub output: PathBuf,
pub status: String,
pub help: bool,
pub commits: u32,
}
impl Editor {
#[must_use]
pub fn new(canvas: Canvas, grid: Grid, start: usize, output: PathBuf, commits: u32) -> Self {
Self {
canvas,
grid,
start,
cursor: (0, 0),
brush: 4,
painting: false,
undo: Vec::new(),
dirty: false,
output,
status: String::new(),
help: false,
commits: commits.max(1),
}
}
#[must_use]
pub fn date_at(&self, week: usize, row: usize) -> Option<NaiveDate> {
let column = self.start.checked_add(week)?;
if column >= self.grid.weeks || row >= CANVAS_ROWS {
return None;
}
let date = self.grid.date_at(column, row);
self.grid.holds(date).then_some(date)
}
#[must_use]
pub fn cursor_date(&self) -> Option<NaiveDate> {
self.date_at(self.cursor.0, self.cursor.1)
}
fn remember(&mut self) {
if self.undo.len() == UNDO_DEPTH {
self.undo.remove(0);
}
self.undo.push(self.canvas.clone());
}
pub fn paint(&mut self, level: u8) {
let (week, row) = self.cursor;
if self.canvas.at(week, row) == level.min(4) {
return;
}
self.remember();
self.canvas.set(week, row, level);
self.dirty = true;
}
pub fn move_by(&mut self, dx: isize, dy: isize) {
let width = self.canvas.width().max(1);
let (week, row) = self.cursor;
let next_week = (week as isize + dx).clamp(0, width as isize - 1) as usize;
let next_row = (row as isize + dy).clamp(0, CANVAS_ROWS as isize - 1) as usize;
self.cursor = (next_week, next_row);
}
pub fn fill(&mut self, level: u8) {
self.remember();
for week in 0..self.canvas.width() {
for row in 0..CANVAS_ROWS {
self.canvas.set(week, row, level);
}
}
self.dirty = true;
self.status = format!("filled with level {}", level.min(4));
}
pub fn invert(&mut self) {
self.remember();
for week in 0..self.canvas.width() {
for row in 0..CANVAS_ROWS {
let level = self.canvas.at(week, row);
self.canvas.set(week, row, 4u8.saturating_sub(level));
}
}
self.dirty = true;
self.status = "inverted".to_string();
}
pub fn undo(&mut self) -> bool {
match self.undo.pop() {
Some(previous) => {
self.canvas = previous;
self.dirty = true;
let left = self.undo.len();
self.status = format!("undone — {left} {} left", plural(left, "step", "steps"));
true
}
None => {
self.status = "nothing to undo".to_string();
false
}
}
}
#[must_use]
pub fn undo_depth(&self) -> usize {
self.undo.len()
}
#[must_use]
pub fn peak(&self) -> u32 {
self.commits.max(self.canvas.min_peak())
}
#[must_use]
pub fn estimate(&self) -> u32 {
let peak = self.peak();
(0..self.canvas.width())
.flat_map(|week| (0..CANVAS_ROWS).map(move |row| (week, row)))
.filter(|(week, row)| self.date_at(*week, *row).is_some())
.map(|(week, row)| self.canvas.at(week, row))
.filter(|level| *level > 0)
.fold(0u32, |sum, level| {
sum.saturating_add(art::commits_to_reach(level, peak))
})
}
#[must_use]
pub fn legibility(&self) -> Option<(u8, u8, Legibility, f32)> {
self.canvas.closest_pair()
}
pub fn on_key(&mut self, code: ratatui::crossterm::event::KeyCode, ctrl: bool) -> Outcome {
use ratatui::crossterm::event::KeyCode::*;
if self.help && !matches!(code, Char('?') | Esc | Char('q')) {
self.help = false;
}
match code {
Char('q') | Esc if self.help => {
self.help = false;
Outcome::Idle
}
Char('?') => {
self.help = !self.help;
Outcome::Idle
}
Char('q') | Esc => Outcome::Quit,
Char('c') if ctrl => Outcome::Quit,
Char('z') if ctrl => {
self.undo();
Outcome::Idle
}
Left | Char('h') => {
self.move_by(-1, 0);
Outcome::Idle
}
Right | Char('l') => {
self.move_by(1, 0);
Outcome::Idle
}
Up | Char('k') => {
self.move_by(0, -1);
Outcome::Idle
}
Down | Char('j') => {
self.move_by(0, 1);
Outcome::Idle
}
Home => {
self.cursor.0 = 0;
Outcome::Idle
}
End => {
self.cursor.0 = self.canvas.width().saturating_sub(1);
Outcome::Idle
}
Char(digit @ '0'..='4') => {
let level = digit as u8 - b'0';
self.brush = level;
self.paint(level);
Outcome::Idle
}
Char(' ') | Enter => {
let (week, row) = self.cursor;
let next = (self.canvas.at(week, row) + 1) % 5;
self.brush = next;
self.paint(next);
Outcome::Idle
}
Char('c') => {
self.fill(0);
Outcome::Idle
}
Char('i') => {
self.invert();
Outcome::Idle
}
Char('u') => {
self.undo();
Outcome::Idle
}
Char('s') => Outcome::Save,
_ => Outcome::Idle,
}
}
pub fn on_mouse(&mut self, event: ratatui::crossterm::event::MouseEvent, origin: Rect) {
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
let inside = |column: u16, row: u16| -> Option<(usize, usize)> {
let week = column.checked_sub(origin.x)? as usize;
let cell_row = row.checked_sub(origin.y)? as usize;
(week < self.canvas.width() && cell_row < CANVAS_ROWS).then_some((week, cell_row))
};
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some(cell) = inside(event.column, event.row) {
self.cursor = cell;
self.painting = true;
self.paint(self.brush);
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if self.painting {
if let Some(cell) = inside(event.column, event.row) {
self.cursor = cell;
self.paint(self.brush);
}
}
}
MouseEventKind::Up(_) => self.painting = false,
MouseEventKind::Moved => {
if let Some(cell) = inside(event.column, event.row) {
self.cursor = cell;
}
}
MouseEventKind::Down(MouseButton::Right) => {
if let Some(cell) = inside(event.column, event.row) {
self.cursor = cell;
self.paint(0);
}
}
_ => {}
}
}
pub fn save(&mut self) -> Result<(), String> {
let body = self.canvas.to_art();
if let Some(parent) = self
.output
.parent()
.filter(|path| !path.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.map_err(|error| format!("could not make {}: {error}", parent.display()))?;
}
std::fs::write(&self.output, body)
.map_err(|error| format!("could not write {}: {error}", self.output.display()))?;
self.dirty = false;
self.status = format!("saved {}", self.output.display());
Ok(())
}
}
const GUTTER: u16 = 5;
#[must_use]
pub fn grid_origin(area: Rect) -> Rect {
Rect {
x: area.x + GUTTER,
y: area.y + 1,
width: area.width.saturating_sub(GUTTER),
height: CANVAS_ROWS as u16,
}
}
pub fn render(frame: &mut Frame<'_>, editor: &Editor, palette: &Palette) {
let area = frame.area();
let [top, body, hud, keys] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(CANVAS_ROWS as u16 + 2),
Constraint::Min(6),
Constraint::Length(1),
])
.areas(area);
let name = editor
.canvas
.meta()
.name
.clone()
.unwrap_or_else(|| "untitled".to_string());
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!(" {name} "),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(
"{}x{} · {} · {}{}",
editor.canvas.width(),
CANVAS_ROWS,
editor.grid.year,
editor.output.display(),
if editor.dirty { " *" } else { "" }
)),
])),
top,
);
const NAMES: [&str; CANVAS_ROWS] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
let mut lines: Vec<Line<'_>> = Vec::with_capacity(CANVAS_ROWS + 2);
let mut ruler = String::from(" ");
let mut last_month = 0;
for week in 0..editor.canvas.width() {
let label = match editor.date_at(week, 3) {
Some(date) => {
use chrono::Datelike;
if date.month() != last_month {
last_month = date.month();
const MONTHS: [&str; 12] =
["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
MONTHS[(date.month() - 1) as usize]
} else {
" "
}
}
None => " ",
};
ruler.push_str(label);
}
lines.push(Line::from(Span::styled(
ruler,
Style::default().fg(Color::DarkGray),
)));
for (row, name) in NAMES.iter().enumerate() {
let mut spans = vec![Span::styled(
format!("{name:<4} "),
Style::default().fg(Color::DarkGray),
)];
for week in 0..editor.canvas.width() {
let level = editor.canvas.at(week, row);
let rgb = palette.levels[usize::from(level).min(4)];
let outside = editor.date_at(week, row).is_none();
let here = editor.cursor == (week, row);
let glyph = if outside { '·' } else { '█' };
let mut style = Style::default().fg(Color::Rgb(rgb.0, rgb.1, rgb.2));
if outside {
style = Style::default().fg(Color::DarkGray);
}
if here {
style = style.add_modifier(Modifier::REVERSED);
}
spans.push(Span::styled(glyph.to_string(), style));
}
lines.push(Line::from(spans));
}
frame.render_widget(Paragraph::new(lines), body);
let histogram = editor.canvas.histogram();
let peak = editor.peak();
let mut stats: Vec<Line<'_>> = Vec::new();
stats.push(Line::from(match editor.cursor_date() {
Some(date) => format!(
" {} · level {} · brush {}",
date.format("%a %-d %b %Y"),
editor.canvas.at(editor.cursor.0, editor.cursor.1),
editor.brush
),
None => format!(
" outside {} — the first and last columns are partial weeks · brush {}",
editor.grid.year, editor.brush
),
}));
stats.push(Line::from(""));
for level in (0..=4u8).rev() {
let count = histogram[usize::from(level)];
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} {:<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()
} else {
format!(
" {} commits each",
thousands(art::commits_to_reach(level, peak))
)
}),
]));
}
stats.push(Line::from(""));
stats.push(Line::from(format!(
" {} commits in total, at --commits {}",
thousands(editor.estimate()),
editor.commits
)));
match editor.legibility() {
Some((low, high, legibility, delta)) => stats.push(Line::from(format!(
" closest shades {low} and {high} read as {legibility} — ΔE {delta:.0} at worst"
))),
None => stats.push(Line::from(
" one shade only — nothing to tell apart yet".to_string(),
)),
}
if !editor.status.is_empty() {
stats.push(Line::from(""));
stats.push(Line::from(Span::styled(
format!(" {}", editor.status),
Style::default().fg(Color::Yellow),
)));
}
frame.render_widget(
Paragraph::new(stats).block(Block::default().borders(Borders::TOP)),
hud,
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
" arrows/hjkl move · 0-4 paint · space cycle · c clear · i invert · u undo · s save · ? help · q quit",
Style::default().fg(Color::DarkGray),
))),
keys,
);
if editor.help {
render_help(frame, area);
}
}
fn render_help(frame: &mut Frame<'_>, area: Rect) {
let lines = vec![
Line::from(Span::styled(
" Drawing on the year ",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(" arrows, or h j k l move the cursor"),
Line::from(" Home / End first and last column"),
Line::from(" 0 1 2 3 4 paint that shade, and take it as the brush"),
Line::from(" space, enter cycle this cell 0 to 4 and round again"),
Line::from(" click, drag paint with the brush; right-click clears"),
Line::from(" move the pointer read a day without painting it"),
Line::from(""),
Line::from(" c clear the whole canvas"),
Line::from(" i invert every shade"),
Line::from(" u, ctrl-z undo"),
Line::from(" s save to the output file"),
Line::from(""),
Line::from(" ? close this"),
Line::from(" q, esc leave"),
Line::from(""),
Line::from(Span::styled(
" Level 0 is a day that must stay dark. The commit counts on the",
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
" left are what --write would actually make, not an estimate.",
Style::default().fg(Color::DarkGray),
)),
];
let height = lines.len() as u16 + 2;
let width = 70.min(area.width);
let box_area = Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height: height.min(area.height),
};
frame.render_widget(ratatui::widgets::Clear, box_area);
frame.render_widget(
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" help ")),
box_area,
);
}
#[must_use]
pub fn palette() -> Palette {
Palette::new(Appearance::Dark, Season::Default, true)
}
#[must_use]
pub fn default_output(name: Option<&str>) -> PathBuf {
Path::new(&format!("{}.art", name.unwrap_or("drawing"))).to_path_buf()
}