use crate::context::AppContext;
use crate::model::{CalendarListEntry, Task};
use crate::store::{FilterOptions, TaskListItem, TaskStore};
use crate::system::SystemEvent;
use crate::tui::action::SidebarMode;
use ratatui::widgets::ListState;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::mpsc;
#[derive(PartialEq, Clone, Copy)]
pub enum Focus {
Sidebar,
Main,
}
#[derive(PartialEq, Clone, Copy)]
pub enum InputMode {
Normal,
Creating,
Searching,
Editing,
EditingDescription,
Moving,
SelectingExportSource,
Exporting,
Snoozing,
RelationshipBrowsing,
AddingSession,
ManagingSessions,
ActionMenu,
Help(crate::help::HelpTab),
}
pub struct AppState {
pub ctx: Arc<dyn AppContext>,
pub store: TaskStore,
pub tasks: Vec<TaskListItem>,
pub calendars: Vec<CalendarListEntry>,
pub list_state: ListState,
pub cal_state: ListState,
pub active_focus: Focus,
pub mode: InputMode,
pub message: String,
pub loading: bool,
pub sidebar_mode: SidebarMode,
pub active_cal_href: Option<String>,
pub hidden_calendars: HashSet<String>,
pub disabled_calendars: HashSet<String>,
pub local_mode_enabled: bool,
pub selected_categories: HashSet<String>,
pub selected_locations: HashSet<String>, pub match_all_categories: bool,
pub hide_completed: bool,
pub hide_fully_completed_tags: bool,
pub hide_aliases_in_sidebar: bool,
pub strikethrough_completed: bool,
pub show_priority_numbers: bool,
pub sort_cutoff_months: Option<u32>,
pub sort_standard_by_priority: bool,
pub theme: crate::config::AppTheme,
pub quick_filter_term: String,
pub quick_filter_icon: String,
pub show_quick_filter: bool,
pub cached_categories: Vec<crate::store::AggregateItem>,
pub cached_locations: Vec<crate::store::AggregateItem>,
pub urgent_days: u32,
pub urgent_prio: u8,
pub default_priority: u8,
pub start_grace_period_days: u32,
pub snooze_short_mins: u32,
pub snooze_long_mins: u32,
pub input_buffer: String,
pub active_search_query: String, pub cursor_position: usize,
pub edit_scroll_offset: u16,
pub edit_scroll_x: u16,
pub editing_uid: Option<String>,
pub move_selection_state: ListState,
pub move_targets: Vec<CalendarListEntry>,
pub export_source_selection_state: ListState,
pub export_source_calendars: Vec<CalendarListEntry>,
pub export_selection_state: ListState,
pub export_targets: Vec<CalendarListEntry>,
pub yanked_uid: Option<String>,
pub yank_lock_active: bool,
pub creating_child_of: Option<String>,
pub creating_with_desc: bool,
pub new_task_title: String,
pub tag_aliases: HashMap<String, Vec<String>>,
pub relationship_items: Vec<(String, String, String)>, pub relationship_selection_state: ListState,
pub session_items: Vec<(usize, String)>,
pub session_selection_state: ListState,
pub available_actions: Vec<crate::config::TaskAction>,
pub action_menu_items: Vec<crate::config::TaskAction>,
pub action_selection_state: ListState,
pub action_filter: String,
pub unsynced_changes: bool,
pub alarm_actor_tx: Option<mpsc::Sender<SystemEvent>>,
pub active_alarm: Option<(Task, String)>,
pub expanded_done_groups: HashSet<String>,
pub expanded_tags: HashSet<String>,
pub expanded_locations: HashSet<String>,
pub needs_redraw: bool,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
let ctx = Arc::new(crate::context::StandardContext::new(None));
Self::new_with_ctx(ctx)
}
pub fn new_with_ctx(ctx: Arc<dyn AppContext>) -> Self {
let mut l_state = ListState::default();
l_state.select(Some(0));
let mut c_state = ListState::default();
c_state.select(Some(0));
Self {
ctx: ctx.clone(),
store: TaskStore::new(ctx.clone()),
tasks: vec![],
calendars: vec![],
list_state: l_state,
cal_state: c_state,
active_focus: Focus::Main,
mode: InputMode::Normal,
message: "Loading...".to_string(),
loading: true,
sidebar_mode: SidebarMode::Calendars,
active_cal_href: None,
hidden_calendars: HashSet::new(),
disabled_calendars: HashSet::new(),
local_mode_enabled: true,
selected_categories: HashSet::new(),
selected_locations: HashSet::new(), match_all_categories: true,
hide_completed: false,
strikethrough_completed: false,
hide_fully_completed_tags: false,
hide_aliases_in_sidebar: true,
show_priority_numbers: true,
quick_filter_term: "is:ready".to_string(),
quick_filter_icon: "f0fa9".to_string(),
show_quick_filter: true,
sort_cutoff_months: Some(2),
sort_standard_by_priority: false,
theme: crate::config::AppTheme::default(),
cached_categories: Vec::new(),
cached_locations: Vec::new(),
urgent_days: 1,
urgent_prio: 1,
default_priority: 5,
start_grace_period_days: 1,
snooze_short_mins: 60,
snooze_long_mins: 1440,
input_buffer: String::new(),
active_search_query: String::new(),
cursor_position: 0,
edit_scroll_offset: 0,
edit_scroll_x: 0,
editing_uid: None,
move_selection_state: ListState::default(),
move_targets: Vec::new(),
yanked_uid: None,
yank_lock_active: false,
creating_child_of: None,
creating_with_desc: false,
new_task_title: String::new(),
tag_aliases: HashMap::new(),
export_source_selection_state: ListState::default(),
export_source_calendars: Vec::new(),
export_selection_state: ListState::default(),
export_targets: Vec::new(),
relationship_items: Vec::new(),
relationship_selection_state: ListState::default(),
session_items: Vec::new(),
session_selection_state: ListState::default(),
available_actions: Vec::new(),
action_menu_items: Vec::new(),
action_selection_state: ListState::default(),
action_filter: String::new(),
unsynced_changes: false, alarm_actor_tx: None,
active_alarm: None,
expanded_done_groups: HashSet::new(),
expanded_tags: HashSet::new(),
expanded_locations: HashSet::new(),
needs_redraw: false,
}
}
pub fn get_filtered_calendars(&self) -> Vec<&CalendarListEntry> {
self.calendars
.iter()
.filter(|c| self.local_mode_enabled || !c.href.starts_with("local://"))
.filter(|c| !self.disabled_calendars.contains(&c.href))
.filter(|c| {
if c.href == crate::storage::LOCAL_TRASH_HREF || c.href == "local://recovery" {
self.store
.calendars
.get(&c.href)
.is_some_and(|map| !map.is_empty())
} else {
true
}
})
.collect()
}
pub fn refresh_filtered_view(&mut self) {
let search_term = if self.mode == InputMode::Searching {
&self.input_buffer
} else {
&self.active_search_query
};
let cutoff_date = if let Some(months) = self.sort_cutoff_months {
let now = chrono::Utc::now();
let days = months as i64 * 30;
Some(now + chrono::Duration::days(days))
} else {
None
};
let mut effective_hidden = self.hidden_calendars.clone();
effective_hidden.extend(self.disabled_calendars.clone());
if !self.local_mode_enabled {
for href in self.store.calendars.keys() {
if href.starts_with("local://") {
effective_hidden.insert(href.clone());
}
}
}
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let filter_res = self.store.filter(FilterOptions {
active_cal_href: None, hidden_calendars: &effective_hidden,
selected_categories: &self.selected_categories,
selected_locations: &self.selected_locations,
match_all_categories: self.match_all_categories,
search_term,
hide_completed_global: self.hide_completed,
hide_fully_completed_tags: self.hide_fully_completed_tags,
hide_aliases_in_sidebar: self.hide_aliases_in_sidebar,
cutoff_date,
min_duration: None,
max_duration: None,
include_unset_duration: true,
urgent_days: self.urgent_days,
urgent_prio: self.urgent_prio,
default_priority: self.default_priority,
start_grace_period_days: self.start_grace_period_days,
sort_standard_by_priority: self.sort_standard_by_priority,
sort_preset: config.sort_preset,
expanded_done_groups: &self.expanded_done_groups,
expanded_tags: &self.expanded_tags,
expanded_locations: &self.expanded_locations,
max_done_roots: config.max_done_roots,
max_done_subtasks: config.max_done_subtasks,
tag_aliases: &config.tag_aliases,
});
self.tasks = filter_res.items;
self.cached_categories = filter_res.categories;
self.cached_locations = filter_res.locations;
let len = self.tasks.len();
if len == 0 {
self.list_state.select(None);
} else {
let current = self.list_state.selected().unwrap_or(0);
if current >= len {
self.list_state.select(Some(len - 1)); } else {
self.list_state.select(Some(current));
}
}
}
pub fn get_selected_task(&self) -> Option<&Task> {
if let Some(idx) = self.list_state.selected() {
match &self.tasks.get(idx) {
Some(TaskListItem::Task(task)) => Some(task),
_ => None,
}
} else {
None
}
}
pub fn get_task_at_index(&self, idx: usize) -> Option<&Task> {
match &self.tasks.get(idx) {
Some(TaskListItem::Task(task)) => Some(task),
_ => None,
}
}
pub fn find_task_index_by_uid(&self, uid: &str) -> Option<usize> {
self.tasks.iter().position(|item| {
if let TaskListItem::Task(task) = item {
task.uid == uid
} else {
false
}
})
}
pub fn get_all_real_tasks(&self) -> Vec<&Task> {
self.tasks
.iter()
.filter_map(|item| {
if let TaskListItem::Task(task) = item {
Some(task.as_ref())
} else {
None
}
})
.collect()
}
pub fn move_cursor_left(&mut self) {
let cursor_moved_left = self.cursor_position.saturating_sub(1);
self.cursor_position = self.clamp_cursor(cursor_moved_left);
}
pub fn move_cursor_right(&mut self) {
let cursor_moved_right = self.cursor_position.saturating_add(1);
self.cursor_position = self.clamp_cursor(cursor_moved_right);
}
pub fn enter_char(&mut self, new_char: char) {
let byte_index = self
.input_buffer
.char_indices()
.map(|(i, _)| i)
.nth(self.cursor_position)
.unwrap_or(self.input_buffer.len());
self.input_buffer.insert(byte_index, new_char);
self.move_cursor_right();
}
pub fn delete_char(&mut self) {
if self.cursor_position != 0 {
let current_index = self.cursor_position;
let before = self.input_buffer.chars().take(current_index - 1);
let after = self.input_buffer.chars().skip(current_index);
self.input_buffer = before.chain(after).collect();
self.move_cursor_left();
}
}
pub fn reset_input(&mut self) {
self.input_buffer.clear();
self.cursor_position = 0;
}
fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
new_cursor_pos.clamp(0, self.input_buffer.chars().count())
}
fn get_sidebar_len(&self) -> usize {
match self.sidebar_mode {
SidebarMode::Calendars => self.get_filtered_calendars().len(),
SidebarMode::Categories => self.cached_categories.len(),
SidebarMode::Locations => self.cached_locations.len(),
}
}
pub fn next(&mut self) {
match self.active_focus {
Focus::Main => {
if self.tasks.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i >= self.tasks.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
Focus::Sidebar => {
let len = self.get_sidebar_len();
if len == 0 {
return;
}
let i = match self.cal_state.selected() {
Some(i) => {
if i >= len - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.cal_state.select(Some(i));
}
}
}
pub fn previous(&mut self) {
match self.active_focus {
Focus::Main => {
if self.tasks.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i == 0 {
self.tasks.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
Focus::Sidebar => {
let len = self.get_sidebar_len();
if len == 0 {
return;
}
let i = match self.cal_state.selected() {
Some(i) => {
if i == 0 {
len - 1
} else {
i - 1
}
}
None => 0,
};
self.cal_state.select(Some(i));
}
}
}
pub fn jump_forward(&mut self, step: usize) {
match self.active_focus {
Focus::Main => {
if !self.tasks.is_empty() {
let current = self.list_state.selected().unwrap_or(0);
self.list_state
.select(Some((current + step).min(self.tasks.len() - 1)));
}
}
Focus::Sidebar => {
let len = self.get_sidebar_len();
if len > 0 {
let current = self.cal_state.selected().unwrap_or(0);
self.cal_state.select(Some((current + step).min(len - 1)));
}
}
}
}
pub fn jump_backward(&mut self, step: usize) {
match self.active_focus {
Focus::Main => {
if !self.tasks.is_empty() {
let current = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some(current.saturating_sub(step)));
}
}
Focus::Sidebar => {
let len = self.get_sidebar_len();
if len > 0 {
let current = self.cal_state.selected().unwrap_or(0);
self.cal_state.select(Some(current.saturating_sub(step)));
}
}
}
}
pub fn toggle_focus(&mut self) {
self.active_focus = match self.active_focus {
Focus::Main => Focus::Sidebar,
Focus::Sidebar => Focus::Main,
}
}
pub fn next_move_target(&mut self) {
if self.move_targets.is_empty() {
return;
}
let i = match self.move_selection_state.selected() {
Some(i) => {
if i >= self.move_targets.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.move_selection_state.select(Some(i));
}
pub fn previous_move_target(&mut self) {
if self.move_targets.is_empty() {
return;
}
let i = match self.move_selection_state.selected() {
Some(i) => {
if i == 0 {
self.move_targets.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.move_selection_state.select(Some(i));
}
pub fn next_export_source(&mut self) {
if self.export_source_calendars.is_empty() {
return;
}
let i = match self.export_source_selection_state.selected() {
Some(i) => {
if i >= self.export_source_calendars.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.export_source_selection_state.select(Some(i));
}
pub fn previous_export_source(&mut self) {
if self.export_source_calendars.is_empty() {
return;
}
let i = match self.export_source_selection_state.selected() {
Some(i) => {
if i == 0 {
self.export_source_calendars.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.export_source_selection_state.select(Some(i));
}
pub fn next_export_target(&mut self) {
if self.export_targets.is_empty() {
return;
}
let i = match self.export_selection_state.selected() {
Some(i) => {
if i >= self.export_targets.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.export_selection_state.select(Some(i));
}
pub fn previous_export_target(&mut self) {
if self.export_targets.is_empty() {
return;
}
let i = match self.export_selection_state.selected() {
Some(i) => {
if i == 0 {
self.export_targets.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.export_selection_state.select(Some(i));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn dummy_task() -> Task {
Task::new("test", &HashMap::new(), None)
}
#[test]
fn test_navigation_next_wraps() {
let mut state = AppState::new();
state.tasks = vec![
TaskListItem::Task(Box::new(dummy_task())),
TaskListItem::Task(Box::new(dummy_task())),
TaskListItem::Task(Box::new(dummy_task())),
];
state.list_state.select(Some(0));
state.next(); assert_eq!(state.list_state.selected(), Some(1));
state.next(); assert_eq!(state.list_state.selected(), Some(2));
state.next(); assert_eq!(state.list_state.selected(), Some(0));
}
#[test]
fn test_navigation_previous_wraps() {
let mut state = AppState::new();
state.tasks = vec![
TaskListItem::Task(Box::new(dummy_task())),
TaskListItem::Task(Box::new(dummy_task())),
TaskListItem::Task(Box::new(dummy_task())),
];
state.list_state.select(Some(0));
state.previous(); assert_eq!(state.list_state.selected(), Some(2));
state.previous(); assert_eq!(state.list_state.selected(), Some(1));
}
#[test]
fn test_navigation_empty_list_safety() {
let mut state = AppState::new();
state.tasks = vec![];
state.next();
state.previous();
}
#[test]
fn test_cursor_clamping() {
let mut state = AppState::new();
state.input_buffer = "abc".to_string(); state.cursor_position = 0;
state.move_cursor_right(); state.move_cursor_right(); state.move_cursor_right(); state.move_cursor_right();
assert_eq!(state.cursor_position, 3);
state.move_cursor_left(); state.move_cursor_left(); state.move_cursor_left(); state.move_cursor_left();
assert_eq!(state.cursor_position, 0);
}
}