use crate::store::LocalStore;
use anyhow::Result;
use chrono::{DateTime, Local, NaiveDate};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use note_to_self_lib::{Entry, EntryKind, Priority};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
use ratatui::{Frame, Terminal};
use std::cmp::Reverse;
use std::io::{self, Stdout};
use std::time::{Duration as StdDuration, Instant};
use uuid::Uuid;
pub fn run(
store: &mut LocalStore,
config: &crate::config::Config,
keys: ¬e_to_self_lib::DerivedKeys,
) -> Result<bool> {
let mut app = TodoApp::new(
store.journal().to_string(),
todo_items(store.entries.values()),
);
let mut term = TerminalGuard::enter()?;
let mut mutated = false;
let mut last_mutation: Option<Instant> = None;
loop {
term.draw(|frame| app.render(frame))?;
if !event::poll(StdDuration::from_millis(50))? {
if let Some(t) = last_mutation {
if t.elapsed() >= StdDuration::from_secs(2) {
if try_sync(store, config, keys) {
sync_refresh(&mut app, store);
}
last_mutation = None;
}
}
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
match app.handle_key(key) {
Action::Continue => {}
Action::Quit => break,
Action::CreateTodo(body) => {
let id = store.add_todo(body, &[], Priority::Medium, None)?;
refresh(&mut app, store, Some(id));
app.message = Some(format!("created {}", short_id(id)));
mutated = true;
last_mutation = Some(Instant::now());
}
Action::UpdateBody { id, body } => {
let resolved = store.edit_body(&id, body)?;
refresh(&mut app, store, Some(resolved));
app.message = Some(format!("updated {}", short_id(resolved)));
mutated = true;
last_mutation = Some(Instant::now());
}
Action::ToggleDone => {
if let Some(item) = app.selected_item().cloned() {
let next = !item.completed;
let id = store.set_todo_completed(&item.id, next)?;
refresh(&mut app, store, Some(id));
app.message = Some(if next {
format!("completed {}", short_id(id))
} else {
format!("reopened {}", short_id(id))
});
mutated = true;
last_mutation = Some(Instant::now());
}
}
Action::CyclePriority(dir) => {
if let Some(item) = app.selected_item().cloned() {
let priority = cycle_priority(item.priority, dir);
let id = store.set_todo_priority(&item.id, priority)?;
refresh(&mut app, store, Some(id));
app.message = Some(format!("priority \u{2192} {priority}"));
mutated = true;
last_mutation = Some(Instant::now());
}
}
Action::SetDue { id, due } => {
let resolved = store.set_todo_due(&id, due)?;
refresh(&mut app, store, Some(resolved));
app.message = Some(match due {
Some(d) => format!("due \u{2192} {d}"),
None => "due date cleared".to_string(),
});
mutated = true;
last_mutation = Some(Instant::now());
}
Action::ClearDue => {
if let Some(item) = app.selected_item().cloned() {
let id = store.set_todo_due(&item.id, None)?;
refresh(&mut app, store, Some(id));
app.message = Some("due date cleared".to_string());
mutated = true;
last_mutation = Some(Instant::now());
}
}
}
}
Ok(mutated && last_mutation.is_some())
}
struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
fn enter() -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
Ok(Self {
terminal: Terminal::new(CrosstermBackend::new(stdout))?,
})
}
fn draw<F: FnOnce(&mut Frame)>(&mut self, f: F) -> Result<()> {
self.terminal.draw(f)?;
Ok(())
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
fn refresh(app: &mut TodoApp, store: &LocalStore, selected: Option<Uuid>) {
app.replace_items(todo_items(store.entries.values()));
if let Some(id) = selected {
app.select_by_id(&id.to_string());
}
}
fn try_sync(
store: &mut LocalStore,
config: &crate::config::Config,
keys: ¬e_to_self_lib::DerivedKeys,
) -> bool {
if config.sync.is_none() {
return false;
}
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(crate::sync_client::sync_once(store, config, keys))
.is_ok()
})
}
fn sync_refresh(app: &mut TodoApp, store: &LocalStore) {
let selected_id = app.selected_item().map(|i| i.id.clone());
app.replace_items(todo_items(store.entries.values()));
if let Some(id) = selected_id {
app.select_by_id(&id);
}
}
#[derive(Debug, Clone)]
struct TodoItem {
id: String,
created_at: DateTime<Local>,
body: String,
tags: Vec<String>,
completed: bool,
priority: Priority,
due: Option<NaiveDate>,
}
impl TodoItem {
fn short_id(&self) -> String {
self.id.chars().take(8).collect()
}
}
#[derive(Clone)]
struct InputState {
text: String,
cursor: usize,
purpose: InputPurpose,
}
#[derive(Clone)]
enum InputPurpose {
NewTodo,
EditTodo { id: String },
DueDate { id: String },
}
impl InputState {
fn new(purpose: InputPurpose, initial: &str) -> Self {
Self {
cursor: initial.len(),
text: initial.to_string(),
purpose,
}
}
fn insert(&mut self, c: char) {
self.text.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
fn backspace(&mut self) {
if self.cursor > 0 {
let prev = self.text[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.text.drain(prev..self.cursor);
self.cursor = prev;
}
}
fn delete_char(&mut self) {
if self.cursor < self.text.len() {
let next = self.text[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.text.len());
self.text.drain(self.cursor..next);
}
}
fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor = self.text[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
}
fn move_right(&mut self) {
if self.cursor < self.text.len() {
self.cursor = self.text[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.text.len());
}
}
fn clear_to_start(&mut self) {
self.text.drain(..self.cursor);
self.cursor = 0;
}
fn delete_word(&mut self) {
if self.cursor == 0 {
return;
}
let before = &self.text[..self.cursor];
let start = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
self.text.drain(start..self.cursor);
self.cursor = start;
}
fn cursor_x(&self) -> u16 {
self.text[..self.cursor].chars().count() as u16
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Normal,
Help,
Input,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Action {
Continue,
Quit,
CreateTodo(String),
UpdateBody { id: String, body: String },
ToggleDone,
CyclePriority(i8),
SetDue { id: String, due: Option<NaiveDate> },
ClearDue,
}
struct TodoApp {
journal: String,
items: Vec<TodoItem>,
selected: usize,
show_done: bool,
mode: Mode,
input: Option<InputState>,
message: Option<String>,
}
impl TodoApp {
fn new(journal: String, items: Vec<TodoItem>) -> Self {
Self {
journal,
items,
selected: 0,
show_done: true,
mode: Mode::Normal,
input: None,
message: None,
}
}
fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
let content = centered_content(area);
let visible_count = self.visible_len();
let max_list = content.height.saturating_sub(8);
let list_h = (visible_count as u16).max(1).min(max_list.max(3)).min(20);
let chunks = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(3),
Constraint::Length(list_h),
Constraint::Length(2),
Constraint::Fill(3),
])
.split(content);
self.render_title(frame, chunks[1]);
self.render_list(frame, chunks[2]);
self.render_footer(frame, chunks[3]);
if self.mode == Mode::Help {
self.render_help(frame, area);
}
}
fn render_title(&self, frame: &mut Frame, area: Rect) {
let open = self.items.iter().filter(|i| !i.completed).count();
let done = self.items.len().saturating_sub(open);
let filter = if self.show_done {
""
} else {
" \u{00b7} open only"
};
let title = Line::styled(
format!("{} \u{00b7} todos", self.journal),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let stats = Line::styled(
format!("{open} open \u{00b7} {done} done{filter}"),
Style::default().fg(Color::DarkGray),
);
let text = Text::from(vec![title, stats]);
frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
}
fn render_list(&mut self, frame: &mut Frame, area: Rect) {
let len = self.visible_len();
if len == 0 {
self.selected = 0;
} else {
self.selected = self.selected.min(len.saturating_sub(1));
}
let visible = self.visible_items();
let body_max = area.width.saturating_sub(16) as usize;
let items: Vec<ListItem> = if visible.is_empty() {
let msg = if self.items.is_empty() {
"press n to create a todo"
} else {
"no open todos \u{2014} press o to show completed"
};
vec![ListItem::new(Line::styled(
msg,
Style::default().fg(Color::DarkGray),
))]
} else {
visible
.iter()
.map(|item| {
let check = if item.completed { "[x]" } else { "[ ]" };
let pri = priority_symbol(item.priority);
let body = one_line(&item.body, body_max);
let due_text = item
.due
.map(|d| format!(" {}", d.format("%b %-d")))
.unwrap_or_default();
let tag_text = if item.tags.is_empty() {
String::new()
} else {
format!(
" {}",
item.tags
.iter()
.map(|t| format!("#{t}"))
.collect::<Vec<_>>()
.join(" ")
)
};
if item.completed {
let dim = Style::default().fg(Color::DarkGray);
ListItem::new(Line::from(vec![
Span::styled(check, Style::default().fg(Color::Green)),
Span::styled(format!(" {pri}{body}{due_text}{tag_text}"), dim),
]))
} else {
ListItem::new(Line::from(vec![
Span::styled(check, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(pri, priority_style(item.priority)),
Span::raw(body),
Span::styled(due_text, due_style(item.due)),
Span::styled(tag_text, Style::default().fg(Color::Yellow)),
]))
}
})
.collect()
};
let mut state = ListState::default();
if !visible.is_empty() {
state.select(Some(self.selected));
}
let list = List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
frame.render_stateful_widget(list, area, &mut state);
}
fn render_footer(&self, frame: &mut Frame, area: Rect) {
if area.height < 2 {
return;
}
let (line1, hints_str, cursor) = if let Some(input) = &self.input {
let (prefix, hints) = match &input.purpose {
InputPurpose::DueDate { .. } => (
"due: ",
"esc cancel \u{00b7} enter set \u{00b7} empty clears",
),
_ => ("> ", "esc cancel \u{00b7} enter save"),
};
let cursor_x = area.x + prefix.len() as u16 + input.cursor_x();
let cursor_y = area.y;
let line = Line::from(vec![
Span::styled(prefix, Style::default().fg(Color::DarkGray)),
Span::raw(input.text.clone()),
]);
(line, hints, Some((cursor_x, cursor_y)))
} else {
let line = if let Some(msg) = &self.message {
Line::styled(msg.clone(), Style::default().fg(Color::Yellow))
} else {
self.selected_info_line()
};
(line, "? help \u{00b7} q quit", None)
};
let line2 = Line::styled(hints_str, Style::default().fg(Color::DarkGray));
let text = Text::from(vec![line1, line2]);
frame.render_widget(Paragraph::new(text), area);
if let Some((x, y)) = cursor {
frame.set_cursor_position((x, y));
}
}
fn selected_info_line(&self) -> Line<'static> {
let Some(item) = self.selected_item() else {
return Line::raw("");
};
let mut parts = vec![item.short_id()];
parts.push(item.created_at.format("%b %-d").to_string());
if !item.tags.is_empty() {
parts.push(
item.tags
.iter()
.map(|t| format!("#{t}"))
.collect::<Vec<_>>()
.join(" "),
);
}
Line::styled(
parts.join(" \u{00b7} "),
Style::default().fg(Color::DarkGray),
)
}
fn render_help(&self, frame: &mut Frame, area: Rect) {
let popup = centered_popup(area, 44, 18);
frame.render_widget(Clear, popup);
let lines = vec![
Line::styled(
" keybindings",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Line::raw(""),
Line::raw(" j/k move up/down"),
Line::raw(" g/G first/last"),
Line::raw(" n new todo"),
Line::raw(" e/enter edit todo"),
Line::raw(" x/space toggle done"),
Line::raw(" p/P cycle priority"),
Line::raw(" d set due date"),
Line::raw(" D clear due date"),
Line::raw(" o show/hide done"),
Line::raw(" ? close help"),
Line::raw(" q/esc quit"),
Line::raw(""),
Line::styled(
" press ? or esc to close",
Style::default().fg(Color::DarkGray),
),
];
let help = Paragraph::new(Text::from(lines)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
frame.render_widget(help, popup);
}
fn handle_key(&mut self, key: KeyEvent) -> Action {
match self.mode {
Mode::Normal => self.handle_normal_key(key),
Mode::Help => self.handle_help_key(key),
Mode::Input => self.handle_input_key(key),
}
}
fn handle_normal_key(&mut self, key: KeyEvent) -> Action {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => Action::Quit,
KeyCode::Char('n') => {
self.input = Some(InputState::new(InputPurpose::NewTodo, ""));
self.mode = Mode::Input;
self.message = None;
Action::Continue
}
KeyCode::Char('e') | KeyCode::Enter => {
if let Some(item) = self.selected_item() {
let id = item.id.clone();
let body = item.body.clone();
self.input = Some(InputState::new(InputPurpose::EditTodo { id }, &body));
self.mode = Mode::Input;
self.message = None;
}
Action::Continue
}
KeyCode::Char('x') | KeyCode::Char(' ') => {
if self.selected_item().is_some() {
Action::ToggleDone
} else {
Action::Continue
}
}
KeyCode::Char('p') => {
if self.selected_item().is_some() {
Action::CyclePriority(1)
} else {
Action::Continue
}
}
KeyCode::Char('P') => {
if self.selected_item().is_some() {
Action::CyclePriority(-1)
} else {
Action::Continue
}
}
KeyCode::Char('d') => {
if let Some(item) = self.selected_item() {
let id = item.id.clone();
let initial = item
.due
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default();
self.input = Some(InputState::new(InputPurpose::DueDate { id }, &initial));
self.mode = Mode::Input;
self.message = None;
}
Action::Continue
}
KeyCode::Char('D') => {
if self.selected_item().is_some() {
Action::ClearDue
} else {
Action::Continue
}
}
KeyCode::Char('j') | KeyCode::Down => {
self.move_selection(1);
Action::Continue
}
KeyCode::Char('k') | KeyCode::Up => {
self.move_selection(-1);
Action::Continue
}
KeyCode::Char('g') => {
self.move_selection_to(0);
Action::Continue
}
KeyCode::Char('G') => {
self.move_selection_to(usize::MAX);
Action::Continue
}
KeyCode::Char('o') => {
self.show_done = !self.show_done;
self.move_selection_to(self.selected);
self.message = Some(if self.show_done {
"showing completed".to_string()
} else {
"hiding completed".to_string()
});
Action::Continue
}
KeyCode::Char('?') => {
self.mode = Mode::Help;
self.message = None;
Action::Continue
}
_ => Action::Continue,
}
}
fn handle_help_key(&mut self, key: KeyEvent) -> Action {
match key.code {
KeyCode::Char('q') => Action::Quit,
KeyCode::Esc | KeyCode::Char('?') => {
self.mode = Mode::Normal;
Action::Continue
}
_ => Action::Continue,
}
}
fn handle_input_key(&mut self, key: KeyEvent) -> Action {
let input = match self.input.as_mut() {
Some(input) => input,
None => {
self.mode = Mode::Normal;
return Action::Continue;
}
};
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('a') | KeyCode::Home => {
input.cursor = 0;
return Action::Continue;
}
KeyCode::Char('e') | KeyCode::End => {
input.cursor = input.text.len();
return Action::Continue;
}
KeyCode::Char('u') => {
input.clear_to_start();
return Action::Continue;
}
KeyCode::Char('w') => {
input.delete_word();
return Action::Continue;
}
_ => return Action::Continue,
}
}
match key.code {
KeyCode::Esc => {
self.input = None;
self.mode = Mode::Normal;
Action::Continue
}
KeyCode::Enter => {
let text = input.text.clone();
let purpose = input.purpose.clone();
self.submit_input(text, purpose)
}
KeyCode::Char(c) => {
input.insert(c);
Action::Continue
}
KeyCode::Backspace => {
input.backspace();
Action::Continue
}
KeyCode::Delete => {
input.delete_char();
Action::Continue
}
KeyCode::Left => {
input.move_left();
Action::Continue
}
KeyCode::Right => {
input.move_right();
Action::Continue
}
KeyCode::Home => {
input.cursor = 0;
Action::Continue
}
KeyCode::End => {
input.cursor = input.text.len();
Action::Continue
}
_ => Action::Continue,
}
}
fn submit_input(&mut self, text: String, purpose: InputPurpose) -> Action {
match purpose {
InputPurpose::NewTodo => {
self.input = None;
self.mode = Mode::Normal;
if text.trim().is_empty() {
self.message = Some("cancelled".to_string());
Action::Continue
} else {
Action::CreateTodo(text)
}
}
InputPurpose::EditTodo { id } => {
self.input = None;
self.mode = Mode::Normal;
if text.trim().is_empty() {
self.message = Some("cancelled".to_string());
Action::Continue
} else {
Action::UpdateBody { id, body: text }
}
}
InputPurpose::DueDate { id } => {
if text.trim().is_empty() {
self.input = None;
self.mode = Mode::Normal;
Action::SetDue { id, due: None }
} else {
match NaiveDate::parse_from_str(text.trim(), "%Y-%m-%d") {
Ok(date) => {
self.input = None;
self.mode = Mode::Normal;
Action::SetDue {
id,
due: Some(date),
}
}
Err(_) => {
self.message = Some("invalid date; use YYYY-MM-DD".to_string());
Action::Continue
}
}
}
}
}
}
fn visible_items(&self) -> Vec<&TodoItem> {
self.items
.iter()
.filter(|item| self.show_done || !item.completed)
.collect()
}
fn visible_len(&self) -> usize {
self.items
.iter()
.filter(|item| self.show_done || !item.completed)
.count()
}
fn selected_item(&self) -> Option<&TodoItem> {
self.visible_items().into_iter().nth(self.selected)
}
fn replace_items(&mut self, items: Vec<TodoItem>) {
self.items = items;
self.selected = self.selected.min(self.visible_len().saturating_sub(1));
}
fn select_by_id(&mut self, id: &str) {
if let Some(pos) = self
.visible_items()
.iter()
.position(|item| item.id == id || item.short_id() == id)
{
self.selected = pos;
}
}
fn move_selection(&mut self, delta: isize) {
let len = self.visible_len();
if len == 0 {
self.selected = 0;
return;
}
self.selected =
(self.selected as isize + delta).clamp(0, len.saturating_sub(1) as isize) as usize;
self.message = None;
}
fn move_selection_to(&mut self, target: usize) {
let len = self.visible_len();
self.selected = if len == 0 {
0
} else {
target.min(len.saturating_sub(1))
};
self.message = None;
}
}
fn todo_items<'a>(entries: impl Iterator<Item = &'a Entry>) -> Vec<TodoItem> {
let mut items: Vec<TodoItem> = entries
.filter_map(|entry| {
if entry.deleted {
return None;
}
let EntryKind::Todo(meta) = &entry.kind else {
return None;
};
Some(TodoItem {
id: entry.id.to_string(),
created_at: entry.created_at.with_timezone(&Local),
body: entry.body.clone(),
tags: entry.tags.clone(),
completed: meta.completed,
priority: meta.priority,
due: meta.due,
})
})
.collect();
items.sort_by_key(todo_sort_key);
items
}
fn todo_sort_key(
item: &TodoItem,
) -> (
bool,
bool,
Option<NaiveDate>,
Reverse<Priority>,
DateTime<Local>,
) {
(
item.completed,
item.due.is_none(),
item.due,
Reverse(item.priority),
item.created_at,
)
}
fn cycle_priority(priority: Priority, direction: i8) -> Priority {
let values = [
Priority::Low,
Priority::Medium,
Priority::High,
Priority::Urgent,
];
let current = values.iter().position(|v| *v == priority).unwrap_or(1) as isize;
let next = (current + direction as isize).rem_euclid(values.len() as isize);
values[next as usize]
}
fn priority_symbol(priority: Priority) -> &'static str {
match priority {
Priority::Low => " ",
Priority::Medium => "! ",
Priority::High => "!! ",
Priority::Urgent => "!!! ",
}
}
fn priority_style(priority: Priority) -> Style {
match priority {
Priority::Low => Style::default().fg(Color::DarkGray),
Priority::Medium => Style::default().fg(Color::Yellow),
Priority::High => Style::default().fg(Color::LightRed),
Priority::Urgent => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
}
}
fn due_style(due: Option<NaiveDate>) -> Style {
match due {
Some(d) if d < Local::now().date_naive() => Style::default().fg(Color::Red),
Some(_) => Style::default().fg(Color::Cyan),
None => Style::default().fg(Color::DarkGray),
}
}
fn one_line(body: &str, max_chars: usize) -> String {
let line = body.lines().next().unwrap_or_default().trim();
if line.chars().count() > max_chars {
format!(
"{}...",
line.chars()
.take(max_chars.saturating_sub(3))
.collect::<String>()
)
} else {
line.to_string()
}
}
fn centered_content(area: Rect) -> Rect {
let width = 76u16.min(area.width.saturating_sub(2));
Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y,
width,
height: area.height,
}
}
fn centered_popup(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width,
height,
}
}
fn short_id(id: Uuid) -> String {
id.to_string().chars().take(8).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use note_to_self_lib::Hlc;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn test_item(
body: &str,
priority: Priority,
completed: bool,
due: Option<NaiveDate>,
) -> TodoItem {
let entry = Entry::new_todo(body.to_string(), &[], priority, due, Hlc::new(1));
TodoItem {
id: entry.id.to_string(),
created_at: entry.created_at.with_timezone(&Local),
body: body.to_string(),
tags: vec![],
completed,
priority,
due,
}
}
#[test]
fn priority_cycles_in_both_directions() {
assert_eq!(cycle_priority(Priority::Low, 1), Priority::Medium);
assert_eq!(cycle_priority(Priority::Urgent, 1), Priority::Low);
assert_eq!(cycle_priority(Priority::Low, -1), Priority::Urgent);
}
#[test]
fn n_enters_input_mode() {
let item = test_item("task", Priority::Medium, false, None);
let mut app = TodoApp::new("work".to_string(), vec![item]);
assert_eq!(app.handle_key(key(KeyCode::Char('n'))), Action::Continue);
assert_eq!(app.mode, Mode::Input);
assert!(app.input.is_some());
}
#[test]
fn x_toggles_done_when_selected() {
let item = test_item("task", Priority::Medium, false, None);
let mut app = TodoApp::new("work".to_string(), vec![item]);
assert_eq!(app.handle_key(key(KeyCode::Char('x'))), Action::ToggleDone);
}
#[test]
fn x_is_noop_when_empty() {
let mut app = TodoApp::new("work".to_string(), vec![]);
assert_eq!(app.handle_key(key(KeyCode::Char('x'))), Action::Continue);
}
#[test]
fn p_cycles_priority() {
let item = test_item("task", Priority::Medium, false, None);
let mut app = TodoApp::new("work".to_string(), vec![item]);
assert_eq!(
app.handle_key(key(KeyCode::Char('p'))),
Action::CyclePriority(1)
);
}
#[test]
fn input_submit_returns_create_action() {
let mut app = TodoApp::new("work".to_string(), vec![]);
app.handle_key(key(KeyCode::Char('n')));
for c in "buy milk".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let action = app.handle_key(key(KeyCode::Enter));
assert_eq!(action, Action::CreateTodo("buy milk".to_string()));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn input_esc_cancels() {
let mut app = TodoApp::new("work".to_string(), vec![]);
app.handle_key(key(KeyCode::Char('n')));
app.handle_key(key(KeyCode::Char('h')));
let action = app.handle_key(key(KeyCode::Esc));
assert_eq!(action, Action::Continue);
assert_eq!(app.mode, Mode::Normal);
assert!(app.input.is_none());
}
#[test]
fn empty_input_cancels_create() {
let mut app = TodoApp::new("work".to_string(), vec![]);
app.handle_key(key(KeyCode::Char('n')));
let action = app.handle_key(key(KeyCode::Enter));
assert_eq!(action, Action::Continue);
assert!(app.message.as_deref() == Some("cancelled"));
}
#[test]
fn due_date_input_validates() {
let item = test_item("task", Priority::Medium, false, None);
let mut app = TodoApp::new("work".to_string(), vec![item]);
app.handle_key(key(KeyCode::Char('d')));
assert_eq!(app.mode, Mode::Input);
for c in "not-a-date".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let action = app.handle_key(key(KeyCode::Enter));
assert_eq!(action, Action::Continue);
assert_eq!(app.mode, Mode::Input);
}
#[test]
fn todo_items_sort_open_due_and_priority_first() {
let mut low = Entry::new_todo(
"low".to_string(),
&[],
Priority::Low,
Some(NaiveDate::from_ymd_opt(2026, 5, 15).unwrap()),
Hlc::new(1),
);
low.created_at = Utc.with_ymd_and_hms(2026, 5, 14, 10, 0, 0).unwrap();
let mut urgent = Entry::new_todo(
"urgent".to_string(),
&[],
Priority::Urgent,
Some(NaiveDate::from_ymd_opt(2026, 5, 15).unwrap()),
Hlc::new(1),
);
urgent.created_at = Utc.with_ymd_and_hms(2026, 5, 14, 11, 0, 0).unwrap();
let mut done =
Entry::new_todo("done".to_string(), &[], Priority::Urgent, None, Hlc::new(1));
done.created_at = Utc.with_ymd_and_hms(2026, 5, 14, 9, 0, 0).unwrap();
if let EntryKind::Todo(meta) = &mut done.kind {
meta.completed = true;
}
let entries = vec![done, low, urgent];
let items = todo_items(entries.iter());
assert_eq!(items[0].body, "urgent");
assert_eq!(items[1].body, "low");
assert_eq!(items[2].body, "done");
}
}