use std::time::Instant;
use ratatui::layout::Rect;
use crate::body::BodyEditor;
use crate::due;
use crate::duepicker::DuePicker;
use crate::image::{GifLoad, GifPlayback};
use crate::model::{Block, Category, MAX_TITLE_LEN, Task};
use crate::text_input::TextInput;
use crate::undo::{EditKind, History};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
Title,
Category,
Due,
Importance,
Body,
}
impl Field {
pub fn next(self) -> Self {
match self {
Self::Title => Self::Category,
Self::Category => Self::Due,
Self::Due => Self::Importance,
Self::Importance => Self::Body,
Self::Body => Self::Title,
}
}
pub fn prev(self) -> Self {
match self {
Self::Title => Self::Body,
Self::Category => Self::Title,
Self::Due => Self::Category,
Self::Importance => Self::Due,
Self::Body => Self::Importance,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CategorySnap {
name: TextInput,
description: BodyEditor,
on_description: bool,
}
pub struct CategoryForm {
pub name: TextInput,
pub description: BodyEditor,
pub on_description: bool,
pub error: Option<String>,
pub editing: Option<String>,
pub name_area: Rect,
pub description_area: Rect,
history: History<CategorySnap>,
initial_name: String,
initial_description: String,
}
impl CategoryForm {
pub fn new() -> Self {
Self {
name: TextInput::new("", crate::model::MAX_CATEGORY_NAME_LEN),
description: BodyEditor::plain(""),
on_description: false,
error: None,
editing: None,
name_area: Rect::ZERO,
description_area: Rect::ZERO,
history: History::new(),
initial_name: String::new(),
initial_description: String::new(),
}
}
pub fn edit(category: &crate::model::Category) -> Self {
Self {
name: TextInput::new(&category.name, crate::model::MAX_CATEGORY_NAME_LEN),
description: BodyEditor::plain(&category.description),
editing: Some(category.id.clone()),
initial_name: category.name.clone(),
initial_description: category.description.clone(),
..Self::new()
}
}
pub fn title_text(&self) -> &'static str {
if self.editing.is_some() {
"Edit category"
} else {
"New category"
}
}
pub fn toggle_field(&mut self) {
self.set_description_focus(!self.on_description);
}
pub fn set_description_focus(&mut self, description: bool) {
if self.on_description != description {
self.history.break_coalesce();
self.on_description = description;
}
}
pub fn is_dirty(&self) -> bool {
self.name.value() != self.initial_name
|| self.description.plain_value() != self.initial_description
}
fn snap(&self) -> CategorySnap {
CategorySnap {
name: self.name.clone(),
description: self.description.clone(),
on_description: self.on_description,
}
}
fn restore(&mut self, s: CategorySnap) {
self.name = s.name;
self.description = s.description;
self.on_description = s.on_description;
self.error = None;
}
pub fn before_edit(&mut self, kind: EditKind) {
let Self {
name,
description,
on_description,
history,
..
} = self;
history.before_edit_with(kind, || CategorySnap {
name: name.clone(),
description: description.clone(),
on_description: *on_description,
});
}
pub fn break_coalesce(&mut self) {
self.history.break_coalesce();
}
pub fn undo(&mut self) -> bool {
let Some(prev) = self.history.undo(self.snap()) else {
return false;
};
self.restore(prev);
true
}
pub fn redo(&mut self) -> bool {
let Some(next) = self.history.redo(self.snap()) else {
return false;
};
self.restore(next);
true
}
pub fn submit(&mut self) -> Option<(String, String)> {
self.submit_with(|_, _| Ok(()))
}
pub fn submit_with<F>(&mut self, validate_name: F) -> Option<(String, String)>
where
F: FnOnce(&str, Option<&str>) -> Result<(), String>,
{
let name = self.name.value().trim().to_string();
if name.is_empty() {
self.error = Some("A name is required".to_string());
self.on_description = false;
return None;
}
if let Err(error) = validate_name(&name, self.editing.as_deref()) {
self.error = Some(error);
self.on_description = false;
return None;
}
self.error = None;
Some((name, self.description.plain_value()))
}
}
impl Default for CategoryForm {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct FieldAreas {
pub title: Rect,
pub category: Rect,
pub due: Rect,
pub importance: Rect,
pub body: Rect,
}
impl FieldAreas {
pub fn field_at(&self, x: u16, y: u16) -> Option<Field> {
let pos = ratatui::layout::Position { x, y };
if self.title.contains(pos) {
Some(Field::Title)
} else if self.category.contains(pos) {
Some(Field::Category)
} else if self.due.contains(pos) {
Some(Field::Due)
} else if self.importance.contains(pos) {
Some(Field::Importance)
} else if self.body.contains(pos) {
Some(Field::Body)
} else {
None
}
}
pub fn rect(&self, field: Field) -> Rect {
match field {
Field::Title => self.title,
Field::Category => self.category,
Field::Due => self.due,
Field::Importance => self.importance,
Field::Body => self.body,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TaskDraft {
pub title: String,
pub category_id: Option<String>,
pub due: String,
pub importance: u8,
pub body: Vec<Block>,
}
impl TaskDraft {
pub fn new(title: &str) -> Self {
Self {
title: title.to_string(),
..Self::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TaskSnap {
title: TextInput,
category_id: Option<String>,
due: TextInput,
importance: u8,
body: BodyEditor,
field: Field,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CategoryChoice {
id: Option<String>,
name: String,
}
impl CategoryChoice {
fn uncategorized() -> Self {
Self {
id: None,
name: "Uncategorized".to_string(),
}
}
}
pub struct TaskForm {
pub title: TextInput,
category_id: Option<String>,
category_choices: Vec<CategoryChoice>,
pub due: TextInput,
pub importance: u8,
pub body: BodyEditor,
pub field: Field,
pub error: Option<String>,
pub editing: Option<String>,
pub areas: FieldAreas,
pub preview: bool,
pub gif: Option<(std::path::PathBuf, GifPlayback)>,
pub gif_pending: Option<GifLoad>,
pub picker: Option<DuePicker>,
pub last_body_click: Option<(Instant, usize)>,
pub menu_was_open: bool,
pub body_scroll: usize,
pub body_menu_area: Option<Rect>,
pub image_hits: Vec<(usize, Rect)>,
history: History<TaskSnap>,
initial: TaskDraft,
}
impl TaskForm {
pub fn new() -> Self {
Self {
title: TextInput::new("", MAX_TITLE_LEN),
category_id: None,
category_choices: vec![CategoryChoice::uncategorized()],
due: TextInput::new("", 32),
importance: 0,
body: BodyEditor::new(&[]),
field: Field::Title,
error: None,
editing: None,
areas: FieldAreas::default(),
preview: false,
gif: None,
gif_pending: None,
picker: None,
last_body_click: None,
menu_was_open: false,
body_scroll: 0,
body_menu_area: None,
image_hits: Vec::new(),
history: History::new(),
initial: TaskDraft::default(),
}
}
pub fn edit(task: &Task) -> Self {
Self {
title: TextInput::new(&task.title, MAX_TITLE_LEN),
category_id: task.category_id.clone(),
due: TextInput::new(&task.due, 32),
importance: task.importance,
body: BodyEditor::new(&task.body),
editing: Some(task.id.clone()),
initial: TaskDraft {
title: task.title.clone(),
category_id: task.category_id.clone(),
due: task.due.clone(),
importance: task.importance,
body: task.body.clone(),
},
..Self::new()
}
}
pub fn image_hit_at(&self, line: usize, x: u16, y: u16) -> bool {
use ratatui::layout::Position;
self.image_hits
.iter()
.any(|(i, r)| *i == line && r.contains(Position { x, y }))
}
pub fn set_image_root(&mut self, image_root: std::path::PathBuf) {
self.body.set_image_root(image_root);
}
pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
self.body.set_attachments(attachments);
}
pub fn set_categories(&mut self, categories: &[Category], selected_id: Option<&str>) {
self.category_choices.clear();
self.category_choices.push(CategoryChoice::uncategorized());
self.category_choices
.extend(
categories
.iter()
.filter(|category| !category.is_all())
.map(|category| CategoryChoice {
id: Some(category.id.clone()),
name: category.name.clone(),
}),
);
self.category_id = selected_id
.filter(|id| {
self.category_choices
.iter()
.any(|choice| choice.id.as_deref() == Some(*id))
})
.map(str::to_string);
self.initial.category_id = self.category_id.clone();
}
pub fn category_id(&self) -> Option<&str> {
self.category_id.as_deref()
}
pub fn category_label(&self) -> &str {
self.category_choices
.iter()
.find(|choice| choice.id.as_deref() == self.category_id.as_deref())
.map(|choice| choice.name.as_str())
.unwrap_or("Uncategorized")
}
pub fn cycle_category(&mut self, delta: i32) {
let len = self.category_choices.len();
if len <= 1 {
return;
}
let current = self
.category_choices
.iter()
.position(|choice| choice.id.as_deref() == self.category_id.as_deref())
.unwrap_or_default();
let next = (current as i32 + delta).rem_euclid(len as i32) as usize;
if next == current {
return;
}
self.before_edit(EditKind::Atomic);
self.category_id = self.category_choices[next].id.clone();
}
pub fn clear_category(&mut self) {
if self.category_id.is_none() {
return;
}
self.before_edit(EditKind::Atomic);
self.category_id = None;
}
pub fn open_image_preview(&mut self) -> Option<String> {
let Some(path) = self.body.selected_image() else {
self.preview = false;
return Some("No image to preview".into());
};
self.preview = true;
if crate::image::is_gif(&path) {
if matches!(&self.gif, Some((p, _)) if p == &path) {
return None;
}
if self
.gif_pending
.as_ref()
.is_none_or(|pending| pending.path() != path)
{
self.gif = None;
self.gif_pending = Some(GifLoad::start(path));
}
None
} else {
if !matches!(&self.gif, Some((p, _)) if p == &path) {
self.gif = None;
}
self.gif_pending = None;
None
}
}
pub fn close_image_preview(&mut self) {
self.preview = false;
}
pub fn gif_playing(&self) -> bool {
self.preview
&& (self.gif_pending.is_some()
|| (!crate::theme::reduced_motion()
&& self
.gif
.as_ref()
.is_some_and(|(_, g)| g.is_animated() && !g.is_paused())))
}
pub fn tick_gif(&mut self) -> bool {
if let Some(result) = self.gif_pending.as_ref().and_then(GifLoad::poll) {
let path = self
.gif_pending
.take()
.map(|pending| pending.path().to_path_buf());
match (path, result) {
(Some(path), Ok(gif)) => self.gif = Some((path, gif)),
(_, Err(error)) => {
self.gif = None;
self.error = Some(error);
}
(None, Ok(_)) => {}
}
return true;
}
if crate::theme::reduced_motion() {
return false;
}
if let Some((_, gif)) = &mut self.gif {
return gif.tick();
}
false
}
pub fn preview_click(&mut self) {
if crate::theme::reduced_motion() {
return;
}
if let Some((_, gif)) = &mut self.gif {
gif.toggle_pause();
}
}
pub fn is_edit(&self) -> bool {
self.editing.is_some()
}
pub fn is_dirty(&self) -> bool {
self.content() != self.initial
}
fn content(&self) -> TaskDraft {
TaskDraft {
title: self.title.value(),
category_id: self.category_id.clone(),
due: self.due.value(),
importance: self.importance,
body: self.body.value(),
}
}
pub fn title_text(&self) -> &'static str {
if self.is_edit() {
"Edit task"
} else {
"New task"
}
}
fn snap(&self) -> TaskSnap {
TaskSnap {
title: self.title.clone(),
category_id: self.category_id.clone(),
due: self.due.clone(),
importance: self.importance,
body: self.body.clone(),
field: self.field,
}
}
fn restore(&mut self, s: TaskSnap) {
self.title = s.title;
self.category_id = s.category_id;
self.due = s.due;
self.importance = s.importance;
self.body = s.body;
self.field = s.field;
self.error = None;
self.picker = None;
self.preview = false;
self.gif_pending = None;
self.body.close_menu();
}
pub fn before_edit(&mut self, kind: EditKind) {
let Self {
title,
category_id,
due,
importance,
body,
field,
history,
..
} = self;
history.before_edit_with(kind, || TaskSnap {
title: title.clone(),
category_id: category_id.clone(),
due: due.clone(),
importance: *importance,
body: body.clone(),
field: *field,
});
}
pub fn break_coalesce(&mut self) {
self.history.break_coalesce();
}
pub fn undo(&mut self) -> bool {
let Some(prev) = self.history.undo(self.snap()) else {
return false;
};
self.restore(prev);
true
}
pub fn redo(&mut self) -> bool {
let Some(next) = self.history.redo(self.snap()) else {
return false;
};
self.restore(next);
true
}
pub fn open_due_picker(&mut self) {
self.history.break_coalesce();
self.field = Field::Due;
self.picker = Some(DuePicker::new(self.due.value().trim()));
}
pub fn take_due_picker(&mut self) {
if self.picker.is_some() {
self.before_edit(EditKind::Atomic);
if let Some(picker) = self.picker.take() {
self.due = TextInput::new(&picker.value(), 32);
}
}
}
pub fn cycle_importance(&mut self) {
let next = (self.importance + 1) % (crate::model::MAX_IMPORTANCE + 1);
self.set_importance(next);
}
pub fn set_importance(&mut self, importance: u8) {
let next = importance.min(crate::model::MAX_IMPORTANCE);
if next == self.importance {
return;
}
self.before_edit(EditKind::Atomic);
self.importance = next;
}
pub fn clear_due(&mut self) {
if self.due.is_empty() && self.picker.is_none() {
return;
}
self.before_edit(EditKind::Atomic);
self.picker = None;
self.due = TextInput::new("", 32);
}
pub fn focus_next(&mut self) {
self.history.break_coalesce();
self.picker = None;
self.body.close_menu();
self.field = self.field.next();
}
pub fn focus_prev(&mut self) {
self.history.break_coalesce();
self.picker = None;
self.body.close_menu();
self.field = self.field.prev();
}
pub fn set_field(&mut self, field: Field) {
self.history.break_coalesce();
if field != Field::Due {
self.picker = None;
}
if field != Field::Body {
self.body.close_menu();
}
self.field = field;
}
pub fn submit(&mut self) -> Option<TaskDraft> {
self.error = None;
let title = self.title.value().trim().to_string();
if title.is_empty() {
self.error = Some("A title is required".to_string());
self.field = Field::Title;
return None;
}
let due_text = self.due.value().trim().to_string();
if !due::is_valid(&due_text) {
self.error = Some(format!("'{due_text}' is not a date mach understands"));
self.field = Field::Due;
return None;
}
let (inline_due, title) = due::parse(&title);
if title.is_empty() {
self.error = Some("A title is required".to_string());
self.field = Field::Title;
return None;
}
if !due::is_valid(&inline_due) {
self.error = Some(format!("'{inline_due}' is not a date mach understands"));
self.field = Field::Title;
return None;
}
Some(TaskDraft {
title,
category_id: self.category_id.clone(),
due: if due_text.is_empty() {
inline_due
} else {
due_text
},
importance: self.importance,
body: self.body.value(),
})
}
}
impl Default for TaskForm {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::undo::EditKind;
#[test]
fn requires_a_title() {
let mut form = TaskForm::new();
assert!(form.submit().is_none());
assert_eq!(form.field, Field::Title);
assert!(form.error.is_some());
}
#[test]
fn rejects_an_unparsable_date() {
let mut form = TaskForm::new();
form.title = TextInput::new("something", MAX_TITLE_LEN);
form.due = TextInput::new("next tuesday", 32);
assert!(form.submit().is_none());
assert_eq!(form.field, Field::Due);
}
#[test]
fn takes_a_date_typed_into_the_title() {
let mut form = TaskForm::new();
form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
let draft = form.submit().expect("valid");
assert_eq!(draft.title, "pay rent");
assert_eq!(draft.due, "2030-01-02");
}
#[test]
fn an_explicit_date_wins_over_the_inline_one() {
let mut form = TaskForm::new();
form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
form.due = TextInput::new("09:00", 32);
let draft = form.submit().expect("valid");
assert_eq!(draft.due, "09:00");
}
#[test]
fn undo_restores_title_and_body() {
let mut form = TaskForm::new();
form.before_edit(EditKind::Typing);
form.title = TextInput::new("hello", MAX_TITLE_LEN);
form.before_edit(EditKind::Atomic);
form.body.insert_str("note");
assert!(form.undo());
assert!(form.body.is_empty() || form.body.plain_value().is_empty());
assert_eq!(form.title.value(), "hello");
assert!(form.undo());
assert_eq!(form.title.value(), "");
assert!(form.redo());
assert_eq!(form.title.value(), "hello");
}
#[test]
fn undo_restores_importance() {
let mut form = TaskForm::new();
form.set_importance(2);
assert_eq!(form.importance, 2);
assert!(form.undo());
assert_eq!(form.importance, 0);
assert!(form.redo());
assert_eq!(form.importance, 2);
}
#[test]
fn dirty_state_tracks_content_not_focus() {
let mut task = TaskForm::new();
task.focus_next();
assert!(!task.is_dirty());
task.title.insert('x');
assert!(task.is_dirty());
let mut category = CategoryForm::new();
category.set_description_focus(true);
assert!(!category.is_dirty());
category.name.insert('x');
assert!(category.is_dirty());
}
#[test]
fn category_submit_exposes_a_shared_name_policy_hook() {
let mut form = CategoryForm::new();
form.name.insert_str("Work");
assert!(
form.submit_with(|name, _| {
(name != "Work")
.then_some(())
.ok_or_else(|| "A category with that name already exists".to_string())
})
.is_none()
);
assert_eq!(
form.error.as_deref(),
Some("A category with that name already exists")
);
}
#[test]
fn opening_a_gif_never_decodes_on_the_input_path() {
let path = std::env::temp_dir().join(format!("mach-async-{}.gif", std::process::id()));
std::fs::write(&path, b"not a real gif").unwrap();
let mut task = Task::new("gif", 0, None, "");
task.body = vec![Block::Image {
attachment_id: path.display().to_string(),
}];
let mut form = TaskForm::edit(&task);
assert!(form.open_image_preview().is_none());
assert!(form.gif.is_none());
assert!(form.gif_pending.is_some());
}
#[test]
fn category_selector_includes_uncategorized_and_is_part_of_the_draft() {
let categories = [
crate::model::Category::all_tasks(),
crate::model::Category {
id: "work".into(),
name: "Work".into(),
description: String::new(),
},
];
let mut form = TaskForm::new();
form.set_categories(&categories, Some("work"));
assert_eq!(form.category_id(), Some("work"));
assert_eq!(form.category_label(), "Work");
assert!(!form.is_dirty());
form.cycle_category(1);
assert_eq!(form.category_id(), None);
assert_eq!(form.category_label(), "Uncategorized");
assert!(form.is_dirty());
form.title.insert_str("portable task");
assert_eq!(form.submit().expect("valid draft").category_id, None);
assert!(form.undo());
assert_eq!(form.category_id(), Some("work"));
}
#[test]
fn editing_a_task_keeps_its_category_in_the_dirty_baseline() {
let mut task = Task::new("move me", 0, Some("work".into()), "");
task.id = "task".into();
let categories = [crate::model::Category {
id: "work".into(),
name: "Work".into(),
description: String::new(),
}];
let mut form = TaskForm::edit(&task);
form.set_categories(&categories, task.category_id.as_deref());
assert_eq!(form.category_id(), Some("work"));
assert!(!form.is_dirty());
assert_eq!(
form.submit().expect("valid draft").category_id.as_deref(),
Some("work")
);
}
}