use std::collections::HashSet;
use std::time::Instant;
use ratatui::layout::Rect;
use thiserror::Error;
use tui_logger::TuiWidgetState;
use crate::models::{Filter, Priority, Project, SortOrder, Task, TaskStatus};
use crate::storage::{Database, StorageError, Tag};
use crate::ui::calendar::CalendarState;
use crate::ui::dialogs::Dialog;
use crate::ui::effects::AnimationState;
use crate::ui::search::SearchResult;
const STATUS_MESSAGE_TIMEOUT_SECS: u64 = 3;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Storage error: {0}")]
Storage(#[from] StorageError),
}
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum View {
#[default]
Splash,
Main,
TaskDetail,
Calendar,
Search,
Help,
DebugLogs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputMode {
#[default]
Normal,
Editing,
Search,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FocusPanel {
Sidebar,
#[default]
TaskList,
}
pub struct App {
pub db: Database,
pub tasks: Vec<Task>,
pub projects: Vec<Project>,
pub tags: Vec<Tag>,
pub current_view: View,
pub input_mode: InputMode,
pub focus: FocusPanel,
pub selected_task_index: Option<usize>,
pub selected_project_index: usize,
pub filter: Filter,
pub sort: SortOrder,
pub input_buffer: String,
pub input_cursor: usize,
pub log_state: TuiWidgetState,
pub should_quit: bool,
pub status_message: Option<String>,
status_message_set_at: Option<Instant>,
pub editing_task: Option<Task>,
pub dialog: Option<Dialog>,
pub search_results: Vec<SearchResult>,
pub selected_search_index: usize,
pub calendar_state: CalendarState,
pub animation: AnimationState,
pub splash_started: bool,
pub dissolving_tasks: HashSet<String>,
pub last_task_list_area: Option<Rect>,
pub last_list_scroll_offset: usize,
pub pending_new_task_animation: Option<String>,
pub pending_complete_animation: Option<String>,
pub pending_priority_animation: Option<(String, ratatui::style::Color)>,
}
impl App {
pub async fn new(db: Database) -> Result<Self> {
let mut app = Self {
db,
tasks: Vec::new(),
projects: Vec::new(),
tags: Vec::new(),
current_view: View::Splash,
input_mode: InputMode::Normal,
focus: FocusPanel::TaskList,
selected_task_index: None,
selected_project_index: 0,
filter: Filter::Pending,
sort: SortOrder::DueDateAsc,
input_buffer: String::new(),
input_cursor: 0,
log_state: TuiWidgetState::default(),
should_quit: false,
status_message: None,
status_message_set_at: None,
editing_task: None,
dialog: None,
search_results: Vec::new(),
selected_search_index: 0,
calendar_state: CalendarState::new(),
animation: AnimationState::new(),
splash_started: false,
dissolving_tasks: HashSet::new(),
last_task_list_area: None,
last_list_scroll_offset: 0,
pending_new_task_animation: None,
pending_complete_animation: None,
pending_priority_animation: None,
};
if std::env::var("RATADO_NO_ANIMATIONS").is_ok() {
app.animation.enabled = false;
app.current_view = View::Main;
}
app.load_data().await?;
Ok(app)
}
pub async fn load_data(&mut self) -> Result<()> {
self.tasks = self.db.get_all_tasks().await?;
self.projects = self.db.get_all_projects().await?;
self.tags = self.db.get_all_tags().await?;
self.adjust_task_selection();
Ok(())
}
pub async fn refresh(&mut self) -> Result<()> {
self.load_data().await
}
pub fn adjust_task_selection(&mut self) {
let visible_count = self.visible_tasks().len();
if visible_count == 0 {
self.selected_task_index = None;
} else if let Some(idx) = self.selected_task_index {
if idx >= visible_count {
self.selected_task_index = Some(visible_count.saturating_sub(1));
}
} else {
self.selected_task_index = Some(0);
}
}
pub fn update_task_in_place(&mut self, task: Task) {
if let Some(existing) = self.tasks.iter_mut().find(|t| t.id == task.id) {
*existing = task;
}
self.adjust_task_selection();
}
pub fn remove_task_in_place(&mut self, task_id: &str) {
self.tasks.retain(|t| t.id != task_id);
self.adjust_task_selection();
}
pub fn add_task_in_place(&mut self, task: Task) {
self.tasks.insert(0, task);
self.adjust_task_selection();
}
pub async fn refresh_tags(&mut self) -> Result<()> {
self.tags = self.db.get_all_tags().await?;
Ok(())
}
pub fn visible_tasks(&self) -> Vec<&Task> {
let project_filter = if self.selected_project_index == 0 {
None } else {
self.projects
.get(self.selected_project_index - 1)
.map(|p| p.id.clone())
};
let mut tasks: Vec<&Task> = self
.tasks
.iter()
.filter(|t| {
if let Some(ref proj_id) = project_filter
&& t.project_id.as_ref() != Some(proj_id)
{
return false;
}
self.filter.matches(t)
})
.collect();
self.sort.apply(&mut tasks);
tasks
}
pub fn selected_task(&self) -> Option<&Task> {
let tasks = self.visible_tasks();
self.selected_task_index.and_then(|idx| tasks.get(idx).copied())
}
pub fn project_tasks(&self) -> Vec<&Task> {
if self.selected_project_index == 0 {
self.tasks.iter().collect()
} else if let Some(project) = self.projects.get(self.selected_project_index - 1) {
self.tasks
.iter()
.filter(|t| t.project_id.as_ref() == Some(&project.id))
.collect()
} else {
self.tasks.iter().collect()
}
}
pub fn selected_project_name(&self) -> &str {
if self.selected_project_index == 0 {
"All Tasks"
} else if let Some(project) = self.projects.get(self.selected_project_index - 1) {
&project.name
} else {
"All Tasks"
}
}
pub fn selected_project(&self) -> Option<&Project> {
if self.selected_project_index == 0 {
None
} else {
self.projects.get(self.selected_project_index - 1)
}
}
pub fn task_count_for_project(&self, project_id: &str) -> usize {
self.tasks
.iter()
.filter(|t| t.project_id.as_ref().map(|id| id == project_id).unwrap_or(false))
.filter(|t| t.status != TaskStatus::Archived)
.count()
}
pub fn total_task_count(&self) -> usize {
self.tasks
.iter()
.filter(|t| t.status != TaskStatus::Archived)
.count()
}
pub fn overdue_count(&self) -> usize {
self.tasks.iter().filter(|t| t.is_overdue()).count()
}
pub fn due_today_count(&self) -> usize {
self.tasks
.iter()
.filter(|t| t.is_due_today() && t.status != TaskStatus::Completed)
.count()
}
pub fn in_progress_count(&self) -> usize {
self.tasks
.iter()
.filter(|t| t.status == TaskStatus::InProgress)
.count()
}
pub fn completed_count(&self) -> usize {
self.tasks
.iter()
.filter(|t| t.status == TaskStatus::Completed)
.count()
}
pub fn task_count_for_tag(&self, tag_name: &str) -> usize {
self.tasks
.iter()
.filter(|t| t.tags.contains(&tag_name.to_string()))
.filter(|t| t.status != TaskStatus::Archived)
.count()
}
pub fn select_previous_task(&mut self) {
let count = self.visible_tasks().len();
if count == 0 {
self.selected_task_index = None;
return;
}
self.selected_task_index = Some(match self.selected_task_index {
Some(0) => count - 1, Some(i) => i - 1,
None => 0,
});
}
pub fn select_next_task(&mut self) {
let count = self.visible_tasks().len();
if count == 0 {
self.selected_task_index = None;
return;
}
self.selected_task_index = Some(match self.selected_task_index {
Some(i) if i >= count - 1 => 0, Some(i) => i + 1,
None => 0,
});
}
pub fn select_previous_project(&mut self) {
let count = self.projects.len() + 1; if self.selected_project_index == 0 {
self.selected_project_index = count - 1;
} else {
self.selected_project_index -= 1;
}
self.update_task_selection();
}
pub fn select_next_project(&mut self) {
let count = self.projects.len() + 1; self.selected_project_index = (self.selected_project_index + 1) % count;
self.update_task_selection();
}
fn update_task_selection(&mut self) {
let count = self.visible_tasks().len();
self.selected_task_index = if count > 0 { Some(0) } else { None };
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
FocusPanel::Sidebar => FocusPanel::TaskList,
FocusPanel::TaskList => FocusPanel::Sidebar,
};
}
pub fn set_status(&mut self, message: impl Into<String>) {
self.status_message = Some(message.into());
self.status_message_set_at = Some(Instant::now());
}
pub fn clear_status(&mut self) {
self.status_message = None;
self.status_message_set_at = None;
}
pub fn start_closing_dialog(&mut self, _dialog: Dialog) {
self.animation.start_dialog_close();
self.animation.clear_targeted_effects();
}
pub fn on_tick(&mut self) {
if let Some(set_at) = self.status_message_set_at
&& set_at.elapsed().as_secs() >= STATUS_MESSAGE_TIMEOUT_SECS {
self.clear_status();
}
if self.current_view == View::Splash
&& self.splash_started
&& !self.animation.has_active_effects()
{
self.current_view = View::Main;
}
if !self.dissolving_tasks.is_empty() && !self.animation.has_active_effects() {
let ids: Vec<String> = self.dissolving_tasks.drain().collect();
for id in ids {
self.remove_task_in_place(&id);
}
}
}
pub fn cycle_filter(&mut self) {
self.filter = match self.filter {
Filter::All => Filter::Pending,
Filter::Pending => Filter::Completed,
Filter::Completed => Filter::DueToday,
Filter::DueToday => Filter::Overdue,
Filter::Overdue => Filter::All,
_ => Filter::All,
};
let count = self.visible_tasks().len();
self.selected_task_index = if count > 0 { Some(0) } else { None };
}
pub fn cycle_sort(&mut self) {
self.sort = match self.sort {
SortOrder::DueDateAsc => SortOrder::PriorityDesc,
SortOrder::PriorityDesc => SortOrder::CreatedDesc,
SortOrder::CreatedDesc => SortOrder::Alphabetical,
SortOrder::Alphabetical => SortOrder::DueDateAsc,
_ => SortOrder::DueDateAsc,
};
}
pub fn filter_name(&self) -> &'static str {
match self.filter {
Filter::All => "All",
Filter::Pending => "Pending",
Filter::InProgress => "In Progress",
Filter::Completed => "Completed",
Filter::Archived => "Archived",
Filter::DueToday => "Due Today",
Filter::DueThisWeek => "This Week",
Filter::Overdue => "Overdue",
Filter::ByProject(_) => "Project",
Filter::ByTag(_) => "Tag",
Filter::ByPriority(Priority::Low) => "Low Priority",
Filter::ByPriority(Priority::Medium) => "Medium Priority",
Filter::ByPriority(Priority::High) => "High Priority",
Filter::ByPriority(Priority::Urgent) => "Urgent",
}
}
pub fn sort_name(&self) -> &'static str {
match self.sort {
SortOrder::DueDateAsc => "Due Date ↑",
SortOrder::DueDateDesc => "Due Date ↓",
SortOrder::PriorityDesc => "Priority ↓",
SortOrder::PriorityAsc => "Priority ↑",
SortOrder::CreatedDesc => "Newest",
SortOrder::CreatedAsc => "Oldest",
SortOrder::Alphabetical => "A-Z",
}
}
}
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
.field("tasks", &self.tasks.len())
.field("projects", &self.projects.len())
.field("current_view", &self.current_view)
.field("input_mode", &self.input_mode)
.field("focus", &self.focus)
.field("should_quit", &self.should_quit)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::run_migrations;
async fn setup_app() -> App {
let db = Database::open_in_memory().await.unwrap();
run_migrations(&db).await.unwrap();
App::new(db).await.unwrap()
}
#[tokio::test]
async fn test_app_new() {
let app = setup_app().await;
assert!(!app.should_quit);
assert_eq!(app.current_view, View::Splash);
assert_eq!(app.input_mode, InputMode::Normal);
assert_eq!(app.focus, FocusPanel::TaskList);
}
#[tokio::test]
async fn test_visible_tasks_empty() {
let app = setup_app().await;
let visible = app.visible_tasks();
assert!(visible.is_empty());
}
#[tokio::test]
async fn test_visible_tasks_with_data() {
let mut app = setup_app().await;
let task1 = Task::new("Task 1");
let task2 = Task::new("Task 2");
app.db.insert_task(&task1).await.unwrap();
app.db.insert_task(&task2).await.unwrap();
app.load_data().await.unwrap();
let visible = app.visible_tasks();
assert_eq!(visible.len(), 2);
}
#[tokio::test]
async fn test_select_next_previous_task() {
let mut app = setup_app().await;
for i in 0..3 {
let task = Task::new(&format!("Task {}", i));
app.db.insert_task(&task).await.unwrap();
}
app.load_data().await.unwrap();
assert_eq!(app.selected_task_index, Some(0));
app.select_next_task();
assert_eq!(app.selected_task_index, Some(1));
app.select_next_task();
assert_eq!(app.selected_task_index, Some(2));
app.select_next_task(); assert_eq!(app.selected_task_index, Some(0));
app.select_previous_task(); assert_eq!(app.selected_task_index, Some(2));
}
#[tokio::test]
async fn test_toggle_focus() {
let mut app = setup_app().await;
assert_eq!(app.focus, FocusPanel::TaskList);
app.toggle_focus();
assert_eq!(app.focus, FocusPanel::Sidebar);
app.toggle_focus();
assert_eq!(app.focus, FocusPanel::TaskList);
}
#[tokio::test]
async fn test_cycle_filter() {
let mut app = setup_app().await;
assert_eq!(app.filter, Filter::Pending);
app.cycle_filter();
assert_eq!(app.filter, Filter::Completed);
app.cycle_filter();
assert_eq!(app.filter, Filter::DueToday);
}
#[tokio::test]
async fn test_project_selection() {
let mut app = setup_app().await;
assert!(!app.projects.is_empty());
assert_eq!(app.selected_project_index, 0);
app.select_next_project();
assert_eq!(app.selected_project_index, 1);
app.select_previous_project();
assert_eq!(app.selected_project_index, 0); }
#[tokio::test]
async fn test_update_task_in_place() {
let mut app = setup_app().await;
let task = Task::new("Original");
app.db.insert_task(&task).await.unwrap();
app.load_data().await.unwrap();
let mut updated = app.tasks[0].clone();
updated.title = "Updated".to_string();
app.update_task_in_place(updated);
assert_eq!(app.tasks[0].title, "Updated");
}
#[tokio::test]
async fn test_remove_task_in_place() {
let mut app = setup_app().await;
let task = Task::new("To remove");
app.db.insert_task(&task).await.unwrap();
app.load_data().await.unwrap();
assert_eq!(app.tasks.len(), 1);
app.remove_task_in_place(&task.id);
assert!(app.tasks.is_empty());
assert_eq!(app.selected_task_index, None);
}
#[tokio::test]
async fn test_add_task_in_place() {
let mut app = setup_app().await;
assert!(app.tasks.is_empty());
let task = Task::new("New task");
app.add_task_in_place(task.clone());
assert_eq!(app.tasks.len(), 1);
assert_eq!(app.tasks[0].title, "New task");
assert_eq!(app.selected_task_index, Some(0));
}
#[tokio::test]
async fn test_adjust_task_selection_clamps() {
let mut app = setup_app().await;
let task = Task::new("Only task");
app.db.insert_task(&task).await.unwrap();
app.load_data().await.unwrap();
app.selected_task_index = Some(5);
app.adjust_task_selection();
assert_eq!(app.selected_task_index, Some(0));
}
#[tokio::test]
async fn test_task_counts() {
let mut app = setup_app().await;
let mut task1 = Task::new("Task 1");
task1.project_id = Some("inbox".to_string());
app.db.insert_task(&task1).await.unwrap();
let task2 = Task::new("Task 2");
app.db.insert_task(&task2).await.unwrap();
app.load_data().await.unwrap();
assert_eq!(app.total_task_count(), 2);
assert_eq!(app.task_count_for_project("inbox"), 1);
}
}