use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use super::{Action, Context, Result, View, new_task::NewTaskView};
use crate::agent;
use crate::domain::{Todo, TodoScope};
use crate::tmux::Tmux;
pub struct TodosView {
task_id: Option<i64>,
scope: TodoScope,
todos: Vec<Todo>,
list: ListState,
adding: Option<String>,
loaded: bool,
}
impl TodosView {
pub fn for_task(task_id: i64) -> Self {
Self::with(Some(task_id), TodoScope::Task(task_id))
}
pub fn global() -> Self {
Self::with(None, TodoScope::Global)
}
fn with(task_id: Option<i64>, scope: TodoScope) -> Self {
Self {
task_id,
scope,
todos: Vec::new(),
list: ListState::default().with_selected(Some(0)),
adding: None,
loaded: false,
}
}
fn reload(&mut self, ctx: &mut Context) -> Result<()> {
self.todos = ctx.store.list_todos(self.scope)?;
self.loaded = true;
let last = self.todos.len().saturating_sub(1);
if self.list.selected().unwrap_or(0) > last {
self.list.select(Some(last));
}
Ok(())
}
fn selected(&self) -> Option<&Todo> {
self.list.selected().and_then(|i| self.todos.get(i))
}
fn move_by(&mut self, delta: isize) {
if self.todos.is_empty() {
return;
}
let current = self.list.selected().unwrap_or(0) as isize;
let last = self.todos.len() as isize - 1;
self.list
.select(Some(current.saturating_add(delta).clamp(0, last) as usize));
}
fn switch_scope(&mut self, ctx: &mut Context) -> Result<()> {
let Some(task_id) = self.task_id else {
ctx.say("no task selected, so these are the global todos");
return Ok(());
};
self.scope = match self.scope {
TodoScope::Global => TodoScope::Task(task_id),
TodoScope::Task(_) => TodoScope::Global,
};
self.list.select(Some(0));
self.reload(ctx)
}
fn use_selected(&mut self, ctx: &mut Context) -> Result<Action> {
let Some(todo) = self.selected().cloned() else {
return Ok(Action::None);
};
match self.scope {
TodoScope::Global => Ok(Action::Push(Box::new(NewTaskView::from_todo(&todo)))),
TodoScope::Task(task_id) => {
let task = ctx.store.get_task(task_id)?;
match agent::say(&Tmux::new(), &task, &todo.text) {
Ok(()) => {
ctx.store.set_todo_done(todo.id, true)?;
ctx.say(format!("sent to task {task_id}"));
self.reload(ctx)?;
}
Err(err) => ctx.say(err.to_string()),
}
Ok(Action::None)
}
}
}
fn handle_adding(&mut self, key: KeyEvent, ctx: &mut Context) -> Result<Action> {
let Some(text) = self.adding.as_mut() else {
return Ok(Action::None);
};
match key.code {
KeyCode::Esc => self.adding = None,
KeyCode::Enter => {
let text = self.adding.take().unwrap_or_default();
if text.trim().is_empty() {
ctx.say("nothing to add");
} else {
ctx.store.add_todo(self.scope, &text, chrono::Utc::now())?;
self.reload(ctx)?;
self.list.select(Some(self.todos.len().saturating_sub(1)));
}
}
KeyCode::Backspace => {
text.pop();
}
KeyCode::Char(c) => text.push(c),
_ => {}
}
Ok(Action::None)
}
fn scope_label(&self) -> String {
match self.scope {
TodoScope::Global => "global".to_string(),
TodoScope::Task(id) => format!("task {id}"),
}
}
}
impl View for TodosView {
fn title(&self) -> String {
format!("Todos — {}", self.scope_label())
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &mut Context) {
if !self.loaded {
let _ = self.reload(ctx);
}
let [list_area, field_area] = Layout::vertical([
Constraint::Min(3),
Constraint::Length(if self.adding.is_some() { 3 } else { 0 }),
])
.areas(area);
let items: Vec<ListItem> = if self.todos.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
match self.scope {
TodoScope::Global => "nothing noted down — a to add",
TodoScope::Task(_) => "nothing for this task — a to add",
},
Style::default().add_modifier(Modifier::DIM),
)))]
} else {
self.todos
.iter()
.map(|todo| {
let (mark, style) = if todo.done {
("✓ ", Style::default().add_modifier(Modifier::DIM))
} else {
("· ", Style::default())
};
ListItem::new(Line::from(vec![
Span::styled(mark, style),
Span::styled(todo.text.clone(), style),
]))
})
.collect()
};
let hint = match self.scope {
TodoScope::Global => "↵ starts a task from one",
TodoScope::Task(_) => "↵ sends one to the agent",
};
frame.render_stateful_widget(
List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {} — {hint} ", self.scope_label())),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED)),
list_area,
&mut self.list,
);
if let Some(text) = &self.adding {
frame.render_widget(
Paragraph::new(format!("{text}▏")).block(
Block::default()
.borders(Borders::ALL)
.title(" new todo — ↵ to add, esc to drop "),
),
field_area,
);
}
}
fn handle_key(&mut self, key: KeyEvent, ctx: &mut Context) -> Result<Action> {
if self.adding.is_some() {
return self.handle_adding(key, ctx);
}
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return Ok(match key.code {
KeyCode::Char('c') | KeyCode::Char('q') => Action::Pop,
_ => Action::None,
});
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => return Ok(Action::Pop),
KeyCode::Char('j') | KeyCode::Down => self.move_by(1),
KeyCode::Char('k') | KeyCode::Up => self.move_by(-1),
KeyCode::Tab => self.switch_scope(ctx)?,
KeyCode::Char('a') => self.adding = Some(String::new()),
KeyCode::Char(' ') => {
if let Some(todo) = self.selected() {
let (id, done) = (todo.id, todo.done);
ctx.store.set_todo_done(id, !done)?;
self.reload(ctx)?;
}
}
KeyCode::Char('x') => {
if let Some(todo) = self.selected() {
ctx.store.delete_todo(todo.id)?;
self.reload(ctx)?;
}
}
KeyCode::Enter => return self.use_selected(ctx),
_ => {}
}
Ok(Action::None)
}
fn tick(&mut self, ctx: &mut Context) -> Result<()> {
self.reload(ctx)
}
fn captures_input(&self) -> bool {
self.adding.is_some()
}
fn keys(&self) -> Vec<(&'static str, &'static str)> {
if self.adding.is_some() {
return vec![("↵", "add"), ("esc", "cancel")];
}
vec![
("a", "add"),
(
"↵",
match self.scope {
TodoScope::Global => "start a task",
TodoScope::Task(_) => "send to agent",
},
),
("space", "done"),
("x", "delete"),
("tab", "scope"),
("esc", "back"),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::tui::testing::{press, render_view};
use chrono::{DateTime, Utc};
use std::path::Path;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn typed(view: &mut TodosView, store: &mut Store, text: &str) {
for ch in text.chars() {
press(view, store, key(KeyCode::Char(ch)));
}
}
fn store_with_task() -> (Store, i64) {
let mut store = Store::open_in_memory().unwrap();
let id = store
.create_task("a task", "do it", Path::new("/tmp/tasks"), &[], at(0))
.unwrap()
.id;
(store, id)
}
#[test]
fn adding_writes_a_todo_in_the_current_scope() {
let (mut store, task_id) = store_with_task();
let mut view = TodosView::for_task(task_id);
render_view(&mut view, &mut store, 60, 12);
press(&mut view, &mut store, key(KeyCode::Char('a')));
typed(&mut view, &mut store, "handle nulls");
press(&mut view, &mut store, key(KeyCode::Enter));
let todos = store.list_todos(TodoScope::Task(task_id)).unwrap();
assert_eq!(todos.len(), 1);
assert_eq!(todos[0].text, "handle nulls");
assert!(
store.list_todos(TodoScope::Global).unwrap().is_empty(),
"it belongs to the task that was open"
);
}
#[test]
fn the_field_swallows_the_keys_that_are_bindings_outside_it() {
let (mut store, task_id) = store_with_task();
let mut view = TodosView::for_task(task_id);
press(&mut view, &mut store, key(KeyCode::Char('a')));
assert!(view.captures_input(), "the field must take every key");
typed(&mut view, &mut store, "fax the axe");
press(&mut view, &mut store, key(KeyCode::Enter));
assert_eq!(
store.list_todos(TodoScope::Task(task_id)).unwrap()[0].text,
"fax the axe"
);
assert!(!view.captures_input(), "and give them back afterwards");
}
#[test]
fn escape_drops_what_was_being_typed() {
let (mut store, task_id) = store_with_task();
let mut view = TodosView::for_task(task_id);
press(&mut view, &mut store, key(KeyCode::Char('a')));
typed(&mut view, &mut store, "never mind");
let action = press(&mut view, &mut store, key(KeyCode::Esc));
assert!(
matches!(action, Action::None),
"esc closes the field, not the screen"
);
assert!(
store
.list_todos(TodoScope::Task(task_id))
.unwrap()
.is_empty()
);
}
#[test]
fn space_ticks_off_and_x_removes() {
let (mut store, task_id) = store_with_task();
store
.add_todo(TodoScope::Task(task_id), "one", at(1))
.unwrap();
let mut view = TodosView::for_task(task_id);
render_view(&mut view, &mut store, 60, 12);
press(&mut view, &mut store, key(KeyCode::Char(' ')));
assert!(
store.list_todos(TodoScope::Task(task_id)).unwrap()[0].done,
"space marks it done and leaves it there"
);
press(&mut view, &mut store, key(KeyCode::Char('x')));
assert!(
store
.list_todos(TodoScope::Task(task_id))
.unwrap()
.is_empty()
);
}
#[test]
fn tab_moves_between_a_tasks_todos_and_the_global_ones() {
let (mut store, task_id) = store_with_task();
store
.add_todo(TodoScope::Task(task_id), "for the agent", at(1))
.unwrap();
store
.add_todo(TodoScope::Global, "for later", at(2))
.unwrap();
let mut view = TodosView::for_task(task_id);
render_view(&mut view, &mut store, 60, 12);
assert!(view.title().contains(&format!("task {task_id}")));
press(&mut view, &mut store, key(KeyCode::Tab));
assert!(view.title().contains("global"), "{}", view.title());
let shown = render_view(&mut view, &mut store, 60, 12).join("\n");
assert!(shown.contains("for later"), "{shown}");
assert!(!shown.contains("for the agent"), "{shown}");
}
#[test]
fn a_screen_opened_with_no_task_stays_global() {
let mut store = Store::open_in_memory().unwrap();
let mut view = TodosView::global();
render_view(&mut view, &mut store, 60, 12);
press(&mut view, &mut store, key(KeyCode::Tab));
assert!(view.title().contains("global"));
}
#[test]
fn enter_on_a_global_todo_opens_a_task_prefilled_with_it() {
let mut store = Store::open_in_memory().unwrap();
store
.add_todo(TodoScope::Global, "upgrade ratatui to 0.30", at(1))
.unwrap();
let mut view = TodosView::global();
render_view(&mut view, &mut store, 60, 12);
let action = press(&mut view, &mut store, key(KeyCode::Enter));
assert!(
matches!(action, Action::Push(_)),
"a global todo becomes a task"
);
assert!(
!store.list_todos(TodoScope::Global).unwrap()[0].done,
"not ticked off until the task is actually queued"
);
}
#[test]
fn a_todo_that_could_not_be_sent_is_left_undone() {
let (mut store, task_id) = store_with_task();
store
.add_todo(TodoScope::Task(task_id), "tell the agent", at(1))
.unwrap();
let mut view = TodosView::for_task(task_id);
render_view(&mut view, &mut store, 60, 12);
let action = press(&mut view, &mut store, key(KeyCode::Enter));
assert!(matches!(action, Action::None), "it stays on this screen");
let todos = store.list_todos(TodoScope::Task(task_id)).unwrap();
assert_eq!(todos.len(), 1);
assert!(!todos[0].done, "undelivered is not done");
}
#[test]
fn an_empty_list_says_what_to_press() {
let (mut store, task_id) = store_with_task();
let mut view = TodosView::for_task(task_id);
let shown = render_view(&mut view, &mut store, 60, 12).join("\n");
assert!(shown.contains("a to add"), "{shown}");
}
}