use std::time::Instant;
use ratatui::layout::Rect;
use crate::description::DescriptionEditor;
use crate::due;
use crate::duepicker::DuePicker;
use crate::image::{GifLoad, GifPlayback, TemporaryImage};
use crate::model::{Block, Category, Label, LabelColor, MAX_LABELS_PER_TASK, 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,
Labels,
Due,
Importance,
Description,
}
impl Field {
pub fn next(self) -> Self {
match self {
Self::Title => Self::Category,
Self::Category => Self::Labels,
Self::Labels => Self::Due,
Self::Due => Self::Importance,
Self::Importance => Self::Description,
Self::Description => Self::Title,
}
}
pub fn prev(self) -> Self {
match self {
Self::Title => Self::Description,
Self::Category => Self::Title,
Self::Labels => Self::Category,
Self::Due => Self::Labels,
Self::Importance => Self::Due,
Self::Description => Self::Importance,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CategorySnap {
name: TextInput,
description: DescriptionEditor,
on_description: bool,
}
pub struct CategoryForm {
pub name: TextInput,
pub description: DescriptionEditor,
pub on_description: bool,
pub error: Option<String>,
pub editing: Option<String>,
pub form_area: Rect,
pub name_area: Rect,
pub description_area: Rect,
pub description_menu_area: Option<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: DescriptionEditor::plain(""),
on_description: false,
error: None,
editing: None,
form_area: Rect::ZERO,
name_area: Rect::ZERO,
description_area: Rect::ZERO,
description_menu_area: None,
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: DescriptionEditor::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 labels: Rect,
pub due: Rect,
pub importance: Rect,
pub description: 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.labels.contains(pos) {
Some(Field::Labels)
} else if self.due.contains(pos) {
Some(Field::Due)
} else if self.importance.contains(pos) {
Some(Field::Importance)
} else if self.description.contains(pos) {
Some(Field::Description)
} else {
None
}
}
pub fn rect(&self, field: Field) -> Rect {
match field {
Field::Title => self.title,
Field::Category => self.category,
Field::Labels => self.labels,
Field::Due => self.due,
Field::Importance => self.importance,
Field::Description => self.description,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TaskDraft {
pub title: String,
pub category_id: Option<String>,
pub label_ids: Vec<String>,
pub due: String,
pub importance: u8,
pub description: Vec<Block>,
}
impl TaskDraft {
pub fn new(title: &str) -> Self {
Self {
title: title.to_string(),
..Self::default()
}
}
pub(crate) fn resolved_title_and_due(&self) -> (String, String) {
let (inline_due, title) = due::parse(self.title.trim());
let due = if self.due.is_empty() {
inline_due
} else {
self.due.clone()
};
(title, due)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TaskSnap {
title: TextInput,
category_id: Option<String>,
label_ids: Vec<String>,
due: TextInput,
importance: u8,
description: DescriptionEditor,
field: Field,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CategoryChoice {
id: Option<String>,
name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LabelChoice {
id: String,
name: String,
color: LabelColor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LabelPicker {
pub index: usize,
area: Rect,
start: usize,
}
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>,
label_ids: Vec<String>,
label_choices: Vec<LabelChoice>,
pub due: TextInput,
pub importance: u8,
pub description: DescriptionEditor,
pub field: Field,
pub error: Option<String>,
pub editing: Option<String>,
pub areas: FieldAreas,
pub form_area: Rect,
pub preview: bool,
pub gif: Option<(std::path::PathBuf, GifPlayback)>,
pub gif_pending: Option<GifLoad>,
pub picker: Option<DuePicker>,
pub label_picker: Option<LabelPicker>,
pub last_description_click: Option<(Instant, usize)>,
pub description_scroll: usize,
pub description_menu_area: Option<Rect>,
pub image_hits: Vec<(usize, Rect)>,
pub(crate) image_occlusions: Vec<Rect>,
pub(crate) image_layout: Vec<(std::path::PathBuf, u16, u16)>,
history: History<TaskSnap>,
initial: TaskDraft,
temporary_images: Vec<TemporaryImage>,
}
impl TaskForm {
pub fn new() -> Self {
Self::new_with_images(crate::image::default_images_root(), &[])
}
pub fn new_with_images(
image_root: std::path::PathBuf,
attachments: &[crate::store::Attachment],
) -> Self {
Self::with_description(DescriptionEditor::new_with_images(
&[],
image_root,
attachments,
))
}
fn with_description(description: DescriptionEditor) -> Self {
Self {
title: TextInput::new("", MAX_TITLE_LEN),
category_id: None,
category_choices: vec![CategoryChoice::uncategorized()],
label_ids: Vec::new(),
label_choices: Vec::new(),
due: TextInput::new("", 32),
importance: 0,
description,
field: Field::Title,
error: None,
editing: None,
areas: FieldAreas::default(),
form_area: Rect::ZERO,
preview: false,
gif: None,
gif_pending: None,
picker: None,
label_picker: None,
last_description_click: None,
description_scroll: 0,
description_menu_area: None,
image_hits: Vec::new(),
image_occlusions: Vec::new(),
image_layout: Vec::new(),
history: History::new(),
initial: TaskDraft::default(),
temporary_images: Vec::new(),
}
}
pub fn edit(task: &Task) -> Self {
Self::edit_with_images(task, crate::image::default_images_root(), &[])
}
pub fn edit_with_images(
task: &Task,
image_root: std::path::PathBuf,
attachments: &[crate::store::Attachment],
) -> Self {
let description =
DescriptionEditor::new_with_images(&task.description, image_root, attachments);
let initial_description = description.value();
let mut form = Self::with_description(description);
form.title = TextInput::new(&task.title, MAX_TITLE_LEN);
form.category_id = task.category_id.clone();
form.label_ids = task.label_ids.clone();
form.due = TextInput::new(&task.due, 32);
form.importance = task.importance;
form.editing = Some(task.id.clone());
form.initial = TaskDraft {
title: task.title.clone(),
category_id: task.category_id.clone(),
label_ids: task.label_ids.clone(),
due: task.due.clone(),
importance: task.importance,
description: initial_description,
};
form
}
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.description.set_image_root(image_root);
}
pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
self.description.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 set_labels(&mut self, labels: &[Label], selected_ids: &[String]) {
self.label_choices = labels
.iter()
.map(|label| LabelChoice {
id: label.id.clone(),
name: label.name.clone(),
color: label.color,
})
.collect();
self.label_ids = self
.label_choices
.iter()
.filter(|choice| selected_ids.contains(&choice.id))
.map(|choice| choice.id.clone())
.collect();
self.initial.label_ids = self.label_ids.clone();
}
pub fn refresh_labels(&mut self, labels: &[Label]) {
self.label_choices = labels
.iter()
.map(|label| LabelChoice {
id: label.id.clone(),
name: label.name.clone(),
color: label.color,
})
.collect();
let order = self
.label_choices
.iter()
.map(|choice| choice.id.clone())
.collect::<Vec<_>>();
let canonicalize = |ids: &mut Vec<String>| {
*ids = order
.iter()
.filter(|id| ids.contains(id))
.cloned()
.collect();
};
canonicalize(&mut self.label_ids);
canonicalize(&mut self.initial.label_ids);
self.history
.for_each_mut(|snapshot| canonicalize(&mut snapshot.label_ids));
}
pub fn label_ids(&self) -> &[String] {
&self.label_ids
}
pub fn selected_labels(&self) -> Vec<(&str, LabelColor)> {
self.label_choices
.iter()
.filter(|choice| self.label_ids.contains(&choice.id))
.map(|choice| (choice.name.as_str(), choice.color))
.collect()
}
pub fn label_choices(&self) -> impl Iterator<Item = (&str, &str, LabelColor, bool)> {
self.label_choices.iter().map(|choice| {
(
choice.id.as_str(),
choice.name.as_str(),
choice.color,
self.label_ids.contains(&choice.id),
)
})
}
pub fn label_picker_open(&self) -> bool {
self.label_picker.is_some()
}
pub fn open_label_picker(&mut self) {
self.history.break_coalesce();
self.field = Field::Labels;
self.picker = None;
self.description.close_menu();
let index = self
.label_choices
.iter()
.position(|choice| self.label_ids.contains(&choice.id))
.unwrap_or_default();
self.label_picker = Some(LabelPicker {
index,
area: Rect::default(),
start: 0,
});
}
pub fn close_label_picker(&mut self) {
self.label_picker = None;
}
pub(crate) fn set_label_picker_layout(&mut self, area: Rect, start: usize) {
if let Some(picker) = &mut self.label_picker {
picker.area = area;
picker.start = start;
}
}
pub fn label_picker_area(&self) -> Option<Rect> {
self.label_picker.as_ref().map(|picker| picker.area)
}
pub(crate) fn label_picker_row_at(&self, x: u16, y: u16) -> Option<usize> {
let picker = self.label_picker.as_ref()?;
if !picker.area.contains(ratatui::layout::Position { x, y })
|| y <= picker.area.y
|| y >= picker.area.bottom().saturating_sub(1)
{
return None;
}
let index = picker.start + usize::from(y - picker.area.y - 1);
(index <= self.label_choices.len()).then_some(index)
}
pub(crate) fn select_label_picker(&mut self, index: usize) {
if let Some(picker) = &mut self.label_picker
&& index <= self.label_choices.len()
{
picker.index = index;
}
}
pub fn label_picker_manage_selected(&self) -> bool {
self.label_picker
.as_ref()
.is_some_and(|picker| picker.index == self.label_choices.len())
}
pub fn move_label_picker(&mut self, delta: isize) {
let count = self.label_choices.len().saturating_add(1);
let Some(picker) = &mut self.label_picker else {
return;
};
if count > 0 {
picker.index = (picker.index as isize + delta).clamp(0, count as isize - 1) as usize;
}
}
pub fn select_first_label(&mut self) {
if let Some(picker) = &mut self.label_picker {
picker.index = 0;
}
}
pub fn select_last_label(&mut self) {
if let Some(picker) = &mut self.label_picker {
picker.index = self.label_choices.len();
}
}
pub fn toggle_current_label(&mut self) -> Result<(), &'static str> {
let Some(index) = self.label_picker.as_ref().map(|picker| picker.index) else {
return Ok(());
};
let Some(id) = self
.label_choices
.get(index)
.map(|choice| choice.id.clone())
else {
return Ok(());
};
self.toggle_label(&id)
}
pub fn toggle_label(&mut self, id: &str) -> Result<(), &'static str> {
if self.label_ids.iter().any(|selected| selected == id) {
self.before_edit(EditKind::Atomic);
self.label_ids.retain(|selected| selected != id);
return Ok(());
}
if self.label_ids.len() >= MAX_LABELS_PER_TASK {
return Err("This task already has the maximum number of labels");
}
if !self.label_choices.iter().any(|choice| choice.id == id) {
return Ok(());
}
self.before_edit(EditKind::Atomic);
self.label_ids.push(id.to_string());
self.canonicalize_label_ids();
Ok(())
}
pub fn clear_labels(&mut self) {
if self.label_ids.is_empty() {
return;
}
self.before_edit(EditKind::Atomic);
self.label_ids.clear();
}
fn canonicalize_label_ids(&mut self) {
self.label_ids = self
.label_choices
.iter()
.filter(|choice| self.label_ids.contains(&choice.id))
.map(|choice| choice.id.clone())
.collect();
}
pub fn open_image_preview(&mut self) -> Option<String> {
let Some(path) = self.description.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));
}
} 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()
.expect("a polled GIF load is still pending")
.path()
.to_path_buf();
match result {
Ok(gif) => self.gif = Some((path, gif)),
Err(error) => {
self.gif = None;
self.error = Some(error);
}
}
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(),
label_ids: self.label_ids.clone(),
due: self.due.value(),
importance: self.importance,
description: self.description.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(),
label_ids: self.label_ids.clone(),
due: self.due.clone(),
importance: self.importance,
description: self.description.clone(),
field: self.field,
}
}
fn restore(&mut self, s: TaskSnap) {
self.title = s.title;
self.category_id = s.category_id;
self.label_ids = s.label_ids;
self.due = s.due;
self.importance = s.importance;
self.description = s.description;
self.field = s.field;
self.error = None;
self.picker = None;
self.label_picker = None;
self.preview = false;
self.gif_pending = None;
self.description.close_menu();
}
pub fn before_edit(&mut self, kind: EditKind) {
let Self {
title,
category_id,
label_ids,
due,
importance,
description,
field,
history,
..
} = self;
history.before_edit_with(kind, || TaskSnap {
title: title.clone(),
category_id: category_id.clone(),
label_ids: label_ids.clone(),
due: due.clone(),
importance: *importance,
description: description.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(crate) fn insert_temporary_image(&mut self, image: TemporaryImage) -> bool {
let reference = image.path().to_string_lossy().into_owned();
if !self.description.insert_block(Block::image(&reference)) {
return false;
}
self.temporary_images.push(image);
true
}
pub fn open_due_picker(&mut self) {
self.history.break_coalesce();
self.field = Field::Due;
self.label_picker = None;
self.picker = Some(DuePicker::new(self.due.value().trim()));
}
pub fn take_due_picker(&mut self) {
let Some(picker) = self.picker.take() else {
return;
};
self.before_edit(EditKind::Atomic);
self.due = TextInput::new(&picker.value(), 32);
}
pub fn cycle_importance(&mut self) {
let next = crate::model::next_importance(self.importance);
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.label_picker = None;
self.due = TextInput::new("", 32);
}
pub fn focus_next(&mut self) {
self.history.break_coalesce();
self.picker = None;
self.label_picker = None;
self.description.close_menu();
self.field = self.field.next();
}
pub fn focus_prev(&mut self) {
self.history.break_coalesce();
self.picker = None;
self.label_picker = None;
self.description.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::Labels {
self.label_picker = None;
}
if field != Field::Description {
self.description.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(),
label_ids: self.label_ids.clone(),
due: if due_text.is_empty() {
inline_due
} else {
due_text
},
importance: self.importance,
description: self.description.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_description() {
let mut form = TaskForm::new();
form.before_edit(EditKind::Typing);
form.title = TextInput::new("hello", MAX_TITLE_LEN);
form.before_edit(EditKind::Atomic);
form.description.insert_str("note");
assert!(form.undo());
assert!(form.description.is_empty() || form.description.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.description = 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")
);
}
}