//! Application state management - R-STATE-SEPARATION pattern //! //! This module implements the R-STATE-SEPARATION design pattern: //! - Application state is kept completely separate from rendering logic //! - All state mutations happen through explicit methods //! - Dirty flag marks when state has changed and redraw is needed //! - No rendering logic here - that's in ui.rs use crate::api::ApiClient; use crate::types::{ChecklistItemDto, ProjectDto, TaskPriority}; use anyhow::Result; use ratatui::widgets::ListState; use tui_textarea::TextArea; /// Application screens - Navigation state /// /// # R-STATE-SEPARATION /// Screens are part of state management, not rendering. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Screen { /// Projects list screen ProjectsList, /// Project detail screen ProjectDetail, /// Security assessment screen Security, /// Create project screen CreateProject, /// Tasks/checklist screen Tasks, /// Create/edit task screen CreateTask, /// Edit existing task screen EditTask, /// Task filter and sort screen TasksFilter, /// Delete confirmation modal DeleteConfirm, /// Keyboard shortcuts help screen Help, /// Theme selector screen ThemeSelector, } /// Sort options for tasks #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum TaskSortOption { /// Newest first (by creation date) #[default] CreatedNewest, /// Oldest first (by creation date) CreatedOldest, /// Highest priority first (Critical → High → Medium → Low) PriorityHighest, /// Lowest priority first (Low → Medium → High → Critical) PriorityLowest, /// Completed tasks first CompletedFirst, /// Pending tasks first PendingFirst, } impl TaskSortOption { /// Get all available sort options pub(crate) fn all() -> &'static [Self] { &[ Self::CreatedNewest, Self::CreatedOldest, Self::PriorityHighest, Self::PriorityLowest, Self::CompletedFirst, Self::PendingFirst, ] } /// Get display label for this sort option pub(crate) fn label(&self) -> &'static str { match self { Self::CreatedNewest => "Newest First", Self::CreatedOldest => "Oldest First", Self::PriorityHighest => "Highest Priority", Self::PriorityLowest => "Lowest Priority", Self::CompletedFirst => "Completed First", Self::PendingFirst => "Pending First", } } } /// Delete confirmation mode #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DeleteMode { /// Show modal dialog for confirmation Modal, /// Show inline message, require second press Inline, /// Show prompt in status bar Prompt, } impl DeleteMode { /// Parse from string pub(crate) fn from_str(s: &str) -> Self { match s.to_lowercase().as_str() { "modal" => Self::Modal, "inline" => Self::Inline, "prompt" => Self::Prompt, _ => Self::Modal, // Default to modal } } } /// Application state - Complete immutable snapshot /// /// # R-STATE-SEPARATION Pattern /// /// This struct contains ALL mutable state. All mutations go through explicit methods /// (see `handle_*` methods below). The rendering layer (ui.rs) reads this state /// but NEVER modifies it - rendering functions take `&App`, never `&mut App`. /// /// ## Dirty Flag Optimization /// The `dirty` flag tracks whether state has changed since last render. /// Only redraw when `dirty` is true, then set to false. pub(crate) struct App { // === Rendering state === /// Current screen (navigation state) pub screen: Screen, /// Dirty flag: true if state changed, needs redraw pub dirty: bool, // === Projects data === /// Loaded projects list pub projects: Vec, /// Projects list state (R-LIST-STATE pattern) pub projects_list_state: ListState, /// Detailed view of selected project pub current_project: Option, // === Tasks/Checklist data === /// Loaded checklist items for current project pub tasks: Vec, /// Tasks list state (R-LIST-STATE pattern) pub tasks_list_state: ListState, // === Loading/Error state === /// Whether currently loading from API pub is_loading: bool, /// Error message to display (None = no error) pub error: Option, // === Form input state === /// Project name input field (create form) pub project_name_input: String, /// Project description input field (create form) pub project_desc_input: String, // === Task creation/editing state === /// Task description textarea (R-TEXTAREA-BASIC pattern) pub task_textarea: TextArea<'static>, /// Task creation mode: true for new, false for editing pub task_creation_mode: bool, /// Form error message during task creation pub task_form_error: Option, /// ID of task being edited (None = creating new) pub editing_task_id: Option, // === Task filtering/search state === /// Search query for task description/ID pub task_search_query: String, /// Filter by completion status: None = all, Some(true) = completed, Some(false) = pending pub task_filter_completed: Option, /// Current sort option for tasks pub task_sort: TaskSortOption, /// Currently selected sort option index (for navigation in filter menu) pub filter_sort_selected: usize, // === Pagination state === /// Current page for task pagination pub current_page: usize, /// Total tasks count (from server) pub total_tasks: usize, /// Tasks per page pub tasks_per_page: usize, // === Delete confirmation state === /// Delete confirmation mode (modal, inline, prompt) pub delete_confirmation_mode: DeleteMode, /// Whether delete action is pending confirmation (inline mode) pub delete_confirm_pending: bool, /// Whether awaiting delete confirmation input (prompt mode) pub awaiting_delete_confirm: bool, // === Theme and layout state === /// Current theme name pub current_theme: String, /// Selected theme index in theme selector pub theme_selector_index: usize, /// Task detail width percentage pub task_detail_width_percent: u16, // === Help screen state === /// Help screen scroll offset pub help_scroll_offset: usize, // === Application lifecycle === /// Should quit application (Ctrl+C or 'q') pub should_quit: bool, // === External dependencies === /// API client for data fetching pub api: ApiClient, } impl App { // === Lifecycle Methods === /// Create a new app instance with default state /// /// # Dirty Flag /// Starts with `dirty: true` to force initial render pub(crate) fn new() -> Self { let mut projects_list_state = ListState::default(); projects_list_state.select(Some(0)); let mut tasks_list_state = ListState::default(); tasks_list_state.select(Some(0)); Self { screen: Screen::ProjectsList, dirty: true, // Initial render needed projects: Vec::new(), projects_list_state, current_project: None, tasks: Vec::new(), tasks_list_state, is_loading: true, error: None, api: ApiClient::new(), project_name_input: String::new(), project_desc_input: String::new(), task_textarea: TextArea::default(), task_creation_mode: true, task_form_error: None, editing_task_id: None, task_search_query: String::new(), task_filter_completed: None, task_sort: TaskSortOption::default(), filter_sort_selected: 0, current_page: 0, total_tasks: 0, tasks_per_page: 50, delete_confirmation_mode: DeleteMode::Modal, delete_confirm_pending: false, awaiting_delete_confirm: false, current_theme: "default".to_string(), theme_selector_index: 0, task_detail_width_percent: 50, help_scroll_offset: 0, should_quit: false, } } // === Data Loading Methods === /// Load projects from the API /// /// # Side Effects /// - Sets `is_loading` true before fetch /// - Sets `dirty = true` after fetch completes /// - Clears any previous error pub(crate) async fn load_projects(&mut self) -> Result<()> { self.is_loading = true; self.error = None; self.dirty = true; match self.api.list_projects().await { Ok(projects) => { self.projects = projects; self.is_loading = false; self.dirty = true; // Data changed, redraw needed Ok(()) } Err(e) => { self.error = Some(format!("Failed to load projects: {}", e)); self.is_loading = false; self.dirty = true; // Error state changed, redraw needed Err(e) } } } /// Load project detail from the API /// /// # Side Effects /// - Sets `is_loading` true before fetch /// - Sets `dirty = true` after fetch completes #[allow(dead_code)] pub(crate) async fn load_project_detail(&mut self, id: &str) -> Result<()> { self.is_loading = true; self.error = None; self.dirty = true; match self.api.get_project(id).await { Ok(project) => { self.current_project = Some(project); self.is_loading = false; self.dirty = true; Ok(()) } Err(e) => { self.error = Some(format!("Failed to load project: {}", e)); self.is_loading = false; self.dirty = true; Err(e) } } } // === Navigation Methods === /// Select currently highlighted project and show detail view /// /// # Dirty Flag /// Sets `dirty = true` when screen changes /// # R-LIST-STATE Pattern /// Uses ListState for proper list selection management pub(crate) fn select_project(&mut self) { if let Some(selected) = self.projects_list_state.selected() { if let Some(project) = self.projects.get(selected) { self.current_project = Some(project.clone()); self.screen = Screen::ProjectDetail; self.dirty = true; } } } /// Move selection down one project /// /// # Behavior /// - Wraps around to top when at bottom /// - Only works if projects list is not empty /// - Sets `dirty = true` /// # R-LIST-STATE Pattern /// Uses ListState for proper selection pub(crate) fn nav_down(&mut self) { if !self.projects.is_empty() { if let Some(selected) = self.projects_list_state.selected() { let next = if selected >= self.projects.len().saturating_sub(1) { 0 // Wrap to top } else { selected + 1 }; self.projects_list_state.select(Some(next)); } self.dirty = true; } } /// Move selection up one project /// /// # Behavior /// - Wraps around to bottom when at top /// - Only works if projects list is not empty /// - Sets `dirty = true` /// # R-LIST-STATE Pattern /// Uses ListState for proper selection pub(crate) fn nav_up(&mut self) { if !self.projects.is_empty() { if let Some(selected) = self.projects_list_state.selected() { let prev = if selected == 0 { self.projects.len().saturating_sub(1) // Wrap to bottom } else { selected - 1 }; self.projects_list_state.select(Some(prev)); } self.dirty = true; } } /// Go back from detail view to projects list /// /// # Dirty Flag /// Sets `dirty = true` when screen changes pub(crate) fn back_to_projects(&mut self) { self.screen = Screen::ProjectsList; self.current_project = None; self.dirty = true; } /// Navigate to security assessment screen pub(crate) fn view_security(&mut self) { self.screen = Screen::Security; self.dirty = true; } /// Navigate to create project screen pub(crate) fn create_project_screen(&mut self) { self.screen = Screen::CreateProject; self.reset_create_project_form(); self.dirty = true; } /// Load tasks from the API for the current project /// /// # Side Effects /// - Sets `is_loading` true before fetch /// - Sets `dirty = true` after fetch completes /// - Clears any previous error pub(crate) async fn load_tasks(&mut self) -> Result<()> { if let Some(project) = &self.current_project { self.is_loading = true; self.error = None; self.dirty = true; self.tasks_list_state.select(Some(0)); // Reset task selection match self.api.get_checklists(&project.id).await { Ok(tasks) => { self.tasks = tasks; self.is_loading = false; self.dirty = true; Ok(()) } Err(e) => { self.error = Some(format!("Failed to load tasks: {}", e)); self.is_loading = false; self.dirty = true; Err(e) } } } else { Err(anyhow::anyhow!("No project selected")) } } /// Navigate to tasks screen for the currently selected project /// /// # Dirty Flag /// Sets `dirty = true` when screen changes pub(crate) fn view_tasks(&mut self) { if self.current_project.is_some() { self.screen = Screen::Tasks; self.tasks_list_state.select(Some(0)); self.dirty = true; } } /// Move task selection down one position /// /// # Behavior /// - Wraps around to top when at bottom /// - Only works if tasks list is not empty /// - Sets `dirty = true` /// # R-LIST-STATE Pattern /// Uses ListState for proper selection pub(crate) fn task_nav_down(&mut self) { if !self.tasks.is_empty() { if let Some(selected) = self.tasks_list_state.selected() { let next = if selected >= self.tasks.len().saturating_sub(1) { 0 // Wrap to top } else { selected + 1 }; self.tasks_list_state.select(Some(next)); } self.dirty = true; } } /// Move task selection up one position /// /// # Behavior /// - Wraps around to bottom when at top /// - Only works if tasks list is not empty /// - Sets `dirty = true` /// # R-LIST-STATE Pattern /// Uses ListState for proper selection pub(crate) fn task_nav_up(&mut self) { if !self.tasks.is_empty() { if let Some(selected) = self.tasks_list_state.selected() { let prev = if selected == 0 { self.tasks.len().saturating_sub(1) // Wrap to bottom } else { selected - 1 }; self.tasks_list_state.select(Some(prev)); } self.dirty = true; } } /// Toggle the completion status of the selected task /// /// # Behavior /// - Toggles `completed` status of the selected task /// - Marks `dirty = true` to trigger redraw /// - Does nothing if no task is selected /// # R-LIST-STATE Pattern /// Uses ListState for proper selection pub(crate) fn toggle_task_completion(&mut self) { if let Some(selected) = self.tasks_list_state.selected() { if let Some(task) = self.tasks.get_mut(selected) { task.completed = !task.completed; self.dirty = true; // Note: In a full implementation, this would also call the API to persist the change } } } /// Go back from tasks screen to project detail /// /// # Dirty Flag /// Sets `dirty = true` when screen changes pub(crate) fn back_to_project_detail(&mut self) { self.screen = Screen::ProjectDetail; self.tasks.clear(); self.tasks_list_state.select(Some(0)); self.dirty = true; } /// Navigate to task creation screen /// /// # Dirty Flag /// Sets `dirty = true` when screen changes pub(crate) fn create_task_screen(&mut self) { self.screen = Screen::CreateTask; self.task_textarea = TextArea::default(); self.task_creation_mode = true; self.task_form_error = None; self.dirty = true; } /// Validate task description with comprehensive checks /// /// # Validation Rules /// - Content cannot be empty or whitespace only /// - Minimum 3 characters (trimmed) /// - Maximum 500 characters (including newlines) /// - First line cannot be empty or whitespace only /// /// # Errors /// Returns specific error messages for each validation failure pub(crate) fn validate_task_description(&self) -> Result<(), String> { let content = self.task_textarea.lines().join("\n"); let trimmed = content.trim(); // Check for empty input if trimmed.is_empty() { return Err("Please enter a task description (at least 3 characters)".to_string()); } // Check for minimum length (based on trimmed content) if trimmed.len() < 3 { return Err(format!( "Task description must be at least 3 characters (currently {} chars)", trimmed.len() )); } // Check for maximum length (allow more for multi-line descriptions) if content.len() > 500 { return Err(format!( "Task description is too long ({} chars, maximum is 500)", content.len() )); } // Check that first line is not just whitespace if let Some(first_line) = self.task_textarea.lines().first() { if first_line.trim().is_empty() { return Err("First line of task cannot be empty".to_string()); } } Ok(()) } /// Go back from task creation to tasks screen /// /// # Dirty Flag /// Sets `dirty = true` when screen changes pub(crate) fn back_to_tasks(&mut self) { self.screen = Screen::Tasks; self.task_textarea = TextArea::default(); self.task_form_error = None; self.dirty = true; } // === Task Filtering/Search Methods === /// Get filtered and sorted tasks /// /// Applies search query, completion filter, and sort order to the task list. /// This is a read-only method that doesn't modify state. pub(crate) fn filtered_tasks(&self) -> Vec { let mut filtered: Vec<_> = self .tasks .iter() .filter(|task| { // Apply completion filter match self.task_filter_completed { Some(true) => task.completed, Some(false) => !task.completed, None => true, // Show all } }) .filter(|task| { // Apply search query if self.task_search_query.is_empty() { return true; } let query = self.task_search_query.to_lowercase(); task.description.to_lowercase().contains(&query) || task.id.to_lowercase().contains(&query) }) .cloned() .collect(); // Apply sorting match self.task_sort { TaskSortOption::CreatedNewest => { filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at)); } TaskSortOption::CreatedOldest => { filtered.sort_by(|a, b| a.created_at.cmp(&b.created_at)); } TaskSortOption::PriorityHighest => { filtered.sort_by(|a, b| { let priority_order = |p: &TaskPriority| match p { TaskPriority::Critical => 0, TaskPriority::High => 1, TaskPriority::Medium => 2, TaskPriority::Low => 3, }; priority_order(&a.priority).cmp(&priority_order(&b.priority)) }); } TaskSortOption::PriorityLowest => { filtered.sort_by(|a, b| { let priority_order = |p: &TaskPriority| match p { TaskPriority::Critical => 0, TaskPriority::High => 1, TaskPriority::Medium => 2, TaskPriority::Low => 3, }; priority_order(&b.priority).cmp(&priority_order(&a.priority)) }); } TaskSortOption::CompletedFirst => { filtered.sort_by(|a, b| b.completed.cmp(&a.completed)); } TaskSortOption::PendingFirst => { filtered.sort_by(|a, b| a.completed.cmp(&b.completed)); } } filtered } /// Calculate task statistics from all tasks /// /// Returns (total, completed, pending, completion_percentage) pub(crate) fn calculate_statistics(&self) -> (usize, usize, usize, f64) { let total = self.tasks.len(); let completed = self.tasks.iter().filter(|t| t.completed).count(); let pending = total - completed; let completion_percentage = if total > 0 { (completed as f64 / total as f64) * 100.0 } else { 0.0 }; (total, completed, pending, completion_percentage) } /// Toggle filter view to filter/sort screen /// /// # Dirty Flag /// Sets `dirty = true` when screen changes pub(crate) fn show_filters(&mut self) { self.screen = Screen::TasksFilter; self.dirty = true; } /// Return from filter screen to tasks screen /// /// # Dirty Flag /// Sets `dirty = true` when screen changes pub(crate) fn back_to_task_view(&mut self) { self.screen = Screen::Tasks; self.dirty = true; } /// Toggle completion filter (all/completed/pending) pub(crate) fn toggle_completion_filter(&mut self) { self.task_filter_completed = match self.task_filter_completed { None => Some(true), // all → completed Some(true) => Some(false), // completed → pending Some(false) => None, // pending → all }; self.dirty = true; } /// Clear all filters (show all tasks in default sort order) pub(crate) fn clear_all_filters(&mut self) { self.task_search_query.clear(); self.task_filter_completed = None; self.task_sort = TaskSortOption::default(); self.dirty = true; } /// Add character to search query pub(crate) fn add_search_char(&mut self, c: char) { self.task_search_query.push(c); self.dirty = true; } /// Remove last character from search query pub(crate) fn backspace_search(&mut self) { self.task_search_query.pop(); self.dirty = true; } /// Navigate to next sort option pub(crate) fn next_sort_option(&mut self) { let max = TaskSortOption::all().len(); self.filter_sort_selected = (self.filter_sort_selected + 1) % max; self.dirty = true; } /// Navigate to previous sort option pub(crate) fn prev_sort_option(&mut self) { let max = TaskSortOption::all().len(); self.filter_sort_selected = if self.filter_sort_selected == 0 { max - 1 } else { self.filter_sort_selected - 1 }; self.dirty = true; } /// Apply selected sort option pub(crate) fn apply_sort_option(&mut self) { if let Some(&sort) = TaskSortOption::all().get(self.filter_sort_selected) { self.task_sort = sort; self.dirty = true; } } // === Error Handling === /// Clear the current error message /// /// # Dirty Flag /// Sets `dirty = true` if error was present #[allow(dead_code)] pub(crate) fn clear_error(&mut self) { if self.error.is_some() { self.error = None; self.dirty = true; } } // === Form Management === /// Reset create project form to empty state pub(crate) fn reset_create_project_form(&mut self) { self.project_name_input.clear(); self.project_desc_input.clear(); } /// Update project name input #[allow(dead_code)] pub(crate) fn set_project_name(&mut self, name: String) { self.project_name_input = name; self.dirty = true; } /// Update project description input #[allow(dead_code)] pub(crate) fn set_project_description(&mut self, desc: String) { self.project_desc_input = desc; self.dirty = true; } // === Application Lifecycle === /// Request application quit pub(crate) fn quit(&mut self) { self.should_quit = true; } /// Mark state as rendered (clear dirty flag) /// /// # Usage /// Call this after successfully rendering, in main event loop: /// ```no_run /// if app.dirty { /// terminal.draw(|f| ui::render(f, &app))?; /// app.mark_rendered(); // Clear dirty flag /// } /// ``` pub(crate) fn mark_rendered(&mut self) { self.dirty = false; } // === Task Editing Methods === /// Get the currently selected task pub(crate) fn selected_task(&self) -> Option<&ChecklistItemDto> { if let Some(selected) = self.tasks_list_state.selected() { self.tasks.get(selected) } else { None } } /// Start editing the currently selected task pub(crate) fn start_edit_task(&mut self) { if let Some(task) = self.selected_task() { let description = task.description.clone(); let task_id = task.id.clone(); self.task_textarea = TextArea::from(vec![description.as_str()]); self.editing_task_id = Some(task_id); self.task_creation_mode = false; self.task_form_error = None; self.screen = Screen::EditTask; self.dirty = true; } } /// Submit task edit to API pub(crate) async fn submit_task_edit(&mut self) -> Result<()> { if let Some(task_id) = self.editing_task_id.take() { let description = self.task_textarea.lines().join("\n"); // Validate description if let Err(e) = self.validate_task_description() { self.task_form_error = Some(e); self.editing_task_id = Some(task_id); // Restore ID self.dirty = true; return Err(anyhow::anyhow!("Validation failed")); } // Update via API (placeholder - implement when API method exists) // self.api.update_task(&task_id, &description).await?; // Update local state if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) { task.description = description; } self.screen = Screen::Tasks; self.task_form_error = None; self.dirty = true; Ok(()) } else { Err(anyhow::anyhow!("No task being edited")) } } /// Cancel task editing pub(crate) fn cancel_edit_task(&mut self) { self.screen = Screen::Tasks; self.editing_task_id = None; self.task_textarea = TextArea::default(); self.task_form_error = None; self.dirty = true; } // === Task Deletion Methods === /// Start task deletion flow (mode-dependent) pub(crate) fn start_delete_task(&mut self) { if self.selected_task().is_none() { return; // No task selected } match self.delete_confirmation_mode { DeleteMode::Modal => { self.screen = Screen::DeleteConfirm; self.dirty = true; } DeleteMode::Inline => { if self.delete_confirm_pending { // Second press - actually delete // This will be handled by async operation self.delete_confirm_pending = false; } else { // First press - mark pending self.delete_confirm_pending = true; self.dirty = true; } } DeleteMode::Prompt => { self.awaiting_delete_confirm = true; self.dirty = true; } } } /// Confirm and execute task deletion pub(crate) async fn confirm_delete_task(&mut self) -> Result<()> { if let Some(task) = self.selected_task() { let task_id = task.id.clone(); // Delete via API (placeholder - implement when API method exists) // self.api.delete_task(&task_id).await?; // Remove from local state self.tasks.retain(|t| t.id != task_id); // Reset delete state self.delete_confirm_pending = false; self.awaiting_delete_confirm = false; // Return to tasks screen if self.screen == Screen::DeleteConfirm { self.screen = Screen::Tasks; } self.dirty = true; Ok(()) } else { Err(anyhow::anyhow!("No task selected")) } } /// Cancel task deletion pub(crate) fn cancel_delete_task(&mut self) { self.delete_confirm_pending = false; self.awaiting_delete_confirm = false; if self.screen == Screen::DeleteConfirm { self.screen = Screen::Tasks; } self.dirty = true; } // === Help Screen Methods === /// Show help screen pub(crate) fn show_help(&mut self) { self.screen = Screen::Help; self.help_scroll_offset = 0; self.dirty = true; } /// Close help screen pub(crate) fn close_help(&mut self) { self.screen = Screen::Tasks; // Or previous screen self.dirty = true; } /// Scroll help screen down pub(crate) fn help_scroll_down(&mut self) { self.help_scroll_offset = self.help_scroll_offset.saturating_add(1); self.dirty = true; } /// Scroll help screen up pub(crate) fn help_scroll_up(&mut self) { self.help_scroll_offset = self.help_scroll_offset.saturating_sub(1); self.dirty = true; } // === Theme Selector Methods === /// Show theme selector pub(crate) fn show_theme_selector(&mut self) { self.screen = Screen::ThemeSelector; self.dirty = true; } /// Close theme selector pub(crate) fn close_theme_selector(&mut self) { self.screen = Screen::Tasks; // Or previous screen self.dirty = true; } /// Navigate theme selector down pub(crate) fn theme_nav_down(&mut self) { let max_themes = 5; // default, dark, light, minimal, professional self.theme_selector_index = (self.theme_selector_index + 1) % max_themes; self.dirty = true; } /// Navigate theme selector up pub(crate) fn theme_nav_up(&mut self) { let max_themes = 5; self.theme_selector_index = if self.theme_selector_index == 0 { max_themes - 1 } else { self.theme_selector_index - 1 }; self.dirty = true; } /// Apply selected theme pub(crate) fn apply_selected_theme(&mut self) -> Result<()> { let themes = ["default", "dark", "light", "minimal", "professional"]; if let Some(&theme_name) = themes.get(self.theme_selector_index) { self.current_theme = theme_name.to_string(); self.close_theme_selector(); // TODO: Save to config file Ok(()) } else { Err(anyhow::anyhow!("Invalid theme index")) } } /// Toggle dark mode (quick switch) #[allow(dead_code)] pub(crate) fn toggle_dark_mode(&mut self) { self.current_theme = if self.current_theme == "dark" { "default".to_string() } else { "dark".to_string() }; self.dirty = true; } // === Layout Methods === /// Save current layout to file #[allow(dead_code)] pub(crate) fn save_layout(&self) -> Result<()> { use crate::config::LayoutConfig; let layout = LayoutConfig { task_detail_width_percent: self.task_detail_width_percent, window_width: 0, // Will be set by terminal size window_height: 0, restore_on_startup: true, }; layout .save() .map_err(|e| anyhow::anyhow!("Failed to save layout: {}", e)) } /// Restore layout from file pub(crate) fn restore_layout(&mut self) -> Result<()> { use crate::config::LayoutConfig; match LayoutConfig::load() { Ok(layout) => { self.task_detail_width_percent = layout.task_detail_width_percent; self.dirty = true; Ok(()) } Err(e) => { tracing::warn!("Failed to restore layout: {}", e); Ok(()) // Don't fail, just use defaults } } } /// Reset layout to defaults #[allow(dead_code)] pub(crate) fn reset_layout(&mut self) { self.task_detail_width_percent = 50; self.dirty = true; } // === Pagination Methods === /// Load more tasks (next page) pub(crate) async fn load_more_tasks(&mut self) -> Result<()> { if self.current_page * self.tasks_per_page < self.total_tasks { self.current_page += 1; // TODO: Implement server-side pagination // For now, just mark as loading self.is_loading = true; self.dirty = true; } Ok(()) } /// Get pagination info string #[allow(dead_code)] pub(crate) fn pagination_info(&self) -> String { let start = self.current_page * self.tasks_per_page + 1; let end = ((self.current_page + 1) * self.tasks_per_page).min(self.total_tasks); format!("Showing {}-{} of {} tasks", start, end, self.total_tasks) } // === Configuration Methods === /// Load configuration from app config pub(crate) fn load_from_config(&mut self, config: &crate::config::TuiConfig) { use crate::app::DeleteMode; self.delete_confirmation_mode = DeleteMode::from_str(&config.ui.delete_confirmation_mode); self.current_theme = config.app.theme.clone(); // Restore layout if configured if let Err(e) = self.restore_layout() { tracing::warn!("Failed to restore layout: {}", e); } self.dirty = true; } } impl Default for App { fn default() -> Self { Self::new() } } // ============================================================================ // TESTS - R-STATE-SEPARATION pattern validation // ============================================================================ #[cfg(test)] mod tests { use super::*; /// Helper function to create a test ProjectDto fn create_test_project(id: &str, name: &str, phase: &str) -> ProjectDto { ProjectDto { id: id.to_string(), name: name.to_string(), current_phase: phase.to_string(), version: "1.0".to_string(), project_type: "Web".to_string(), description: "Test project".to_string(), created_at: "2024-01-01".to_string(), updated_at: "2024-01-01".to_string(), } } // === APP INITIALIZATION TESTS === #[test] fn test_app_new_default_state() { let app = App::new(); assert_eq!(app.screen, Screen::ProjectsList); assert!(app.dirty); // Should start dirty to force initial render assert!(app.projects.is_empty()); assert_eq!(app.projects_list_state.selected(), Some(0)); // ListState starts with first item selected assert!(app.current_project.is_none()); assert!(!app.should_quit); assert!(app.error.is_none()); } #[test] fn test_app_default_trait() { let app = App::default(); let new_app = App::new(); assert_eq!(app.screen, new_app.screen); assert_eq!(app.dirty, new_app.dirty); assert_eq!(app.should_quit, new_app.should_quit); } // === NAVIGATION TESTS === #[test] fn test_nav_down_single_item() { let mut app = App::new(); app.projects = vec![create_test_project("1", "Project 1", "Phase 1")]; app.dirty = false; app.nav_down(); // Should wrap around when at single item assert_eq!(app.projects_list_state.selected(), Some(0)); assert!(app.dirty); // Dirty flag should be set } #[test] fn test_nav_down_multiple_items() { let mut app = App::new(); app.projects = vec![ create_test_project("1", "Project 1", "Phase 1"), create_test_project("2", "Project 2", "Phase 2"), create_test_project("3", "Project 3", "Phase 3"), ]; app.dirty = false; app.projects_list_state.select(Some(0)); app.nav_down(); assert_eq!(app.projects_list_state.selected(), Some(1)); assert!(app.dirty); app.dirty = false; app.nav_down(); assert_eq!(app.projects_list_state.selected(), Some(2)); assert!(app.dirty); // Test wrapping app.dirty = false; app.nav_down(); assert_eq!(app.projects_list_state.selected(), Some(0)); // Should wrap to start assert!(app.dirty); } #[test] fn test_nav_up_wrapping() { let mut app = App::new(); app.projects = vec![ create_test_project("1", "P1", "Phase"), create_test_project("2", "P2", "Phase"), ]; app.projects_list_state.select(Some(0)); app.dirty = false; app.nav_up(); // Should wrap to end when at start assert_eq!(app.projects_list_state.selected(), Some(1)); assert!(app.dirty); } // === SCREEN NAVIGATION TESTS === #[test] fn test_select_project() { let mut app = App::new(); app.projects = vec![create_test_project("1", "Project 1", "Phase 1")]; app.projects_list_state.select(Some(0)); app.dirty = false; app.select_project(); assert_eq!(app.screen, Screen::ProjectDetail); assert!(app.current_project.is_some()); assert_eq!(app.current_project.as_ref().unwrap().id, "1"); assert!(app.dirty); } #[test] fn test_back_to_projects() { let mut app = App::new(); app.screen = Screen::ProjectDetail; app.current_project = Some(create_test_project("1", "Project 1", "Phase 1")); app.dirty = false; app.back_to_projects(); assert_eq!(app.screen, Screen::ProjectsList); assert!(app.current_project.is_none()); assert!(app.dirty); } #[test] fn test_create_project_screen() { let mut app = App::new(); app.project_name_input = "Old Name".to_string(); app.project_desc_input = "Old Desc".to_string(); app.dirty = false; app.create_project_screen(); assert_eq!(app.screen, Screen::CreateProject); assert!(app.project_name_input.is_empty()); // Form should be reset assert!(app.project_desc_input.is_empty()); assert!(app.dirty); } #[test] fn test_view_security() { let mut app = App::new(); app.dirty = false; app.view_security(); assert_eq!(app.screen, Screen::Security); assert!(app.dirty); } // === ERROR HANDLING TESTS === #[test] fn test_clear_error_when_present() { let mut app = App::new(); app.error = Some("Error message".to_string()); app.dirty = false; app.clear_error(); assert!(app.error.is_none()); assert!(app.dirty); } #[test] fn test_clear_error_when_not_present() { let mut app = App::new(); app.error = None; app.dirty = false; app.clear_error(); assert!(app.error.is_none()); // Dirty should remain false if no error was present assert!(!app.dirty); } // === FORM INPUT TESTS === #[test] fn test_set_project_name() { let mut app = App::new(); app.dirty = false; app.set_project_name("New Project".to_string()); assert_eq!(app.project_name_input, "New Project"); assert!(app.dirty); } #[test] fn test_set_project_description() { let mut app = App::new(); app.dirty = false; app.set_project_description("New Description".to_string()); assert_eq!(app.project_desc_input, "New Description"); assert!(app.dirty); } #[test] fn test_reset_create_project_form() { let mut app = App::new(); app.project_name_input = "Name".to_string(); app.project_desc_input = "Desc".to_string(); app.reset_create_project_form(); assert!(app.project_name_input.is_empty()); assert!(app.project_desc_input.is_empty()); } // === DIRTY FLAG TESTS === #[test] fn test_mark_rendered_clears_dirty() { let mut app = App::new(); assert!(app.dirty); // Starts dirty app.mark_rendered(); assert!(!app.dirty); } #[test] fn test_dirty_flag_set_on_mutations() { let mut app = App::new(); app.projects = vec![create_test_project("1", "P1", "Phase")]; app.dirty = false; // Each mutation should set dirty app.nav_down(); assert!(app.dirty); app.dirty = false; app.nav_up(); assert!(app.dirty); app.dirty = false; app.create_project_screen(); assert!(app.dirty); app.dirty = false; app.view_security(); assert!(app.dirty); } // === APPLICATION LIFECYCLE TESTS === #[test] fn test_quit() { let mut app = App::new(); assert!(!app.should_quit); app.quit(); assert!(app.should_quit); } // === EDGE CASES === #[test] fn test_nav_empty_projects() { let mut app = App::new(); assert!(app.projects.is_empty()); app.dirty = false; app.nav_down(); // Should not panic, dirty should remain false assert!(!app.dirty); assert_eq!(app.projects_list_state.selected(), Some(0)); } #[test] fn test_select_project_empty_list() { let mut app = App::new(); assert!(app.projects.is_empty()); app.dirty = false; app.select_project(); // Should not panic, screen should not change assert_eq!(app.screen, Screen::ProjectsList); assert!(app.current_project.is_none()); assert!(!app.dirty); } #[test] fn test_multiple_state_changes() { let mut app = App::new(); app.projects = vec![create_test_project("1", "P1", "Phase")]; // Simulate user interaction sequence app.nav_down(); assert!(app.dirty); app.mark_rendered(); app.select_project(); assert!(app.dirty); assert_eq!(app.screen, Screen::ProjectDetail); app.mark_rendered(); app.back_to_projects(); assert!(app.dirty); assert_eq!(app.screen, Screen::ProjectsList); } }