use crate::calendar::Calendar; use crate::config; use crate::config::Config; use crate::context::Context; use crate::help::Help; use crate::keyconfig::KeyConfig; use crate::table::{Row, Table, TableMode, TableState}; use crate::task_report::TaskReportTable; use crate::util::Key; use crate::util::{Event, Events}; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::convert::TryInto; use std::fs; use std::path::Path; use std::process::Command; use std::time::SystemTime; use task_hookrs::date::Date; use task_hookrs::import::import; use task_hookrs::status::TaskStatus; use task_hookrs::task::Task; use uuid::Uuid; use unicode_segmentation::Graphemes; use unicode_segmentation::UnicodeSegmentation; use chrono::{Datelike, Local, NaiveDate, NaiveDateTime, TimeZone, Timelike}; use anyhow::Result; use async_std::prelude::*; use async_std::stream::StreamExt; use async_std::task; use futures::future::join_all; use futures::join; use futures::stream::FuturesOrdered; use std::sync::{Arc, Mutex}; use std::time::Duration; use tui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, terminal::Frame, text::{Span, Spans, Text}, widgets::{Block, BorderType, Borders, Clear, Paragraph}, }; use rustyline::error::ReadlineError; use rustyline::history::Direction as HistoryDirection; use rustyline::history::History; use rustyline::line_buffer::LineBuffer; use rustyline::At; use rustyline::Editor; use rustyline::Word; use std::io; use tui::{backend::CrosstermBackend, Terminal}; use regex::Regex; const MAX_LINE: usize = 4096; pub fn cmp(t1: &Task, t2: &Task) -> Ordering { let urgency1 = match t1.urgency() { Some(f) => *f, None => 0.0, }; let urgency2 = match t2.urgency() { Some(f) => *f, None => 0.0, }; urgency2.partial_cmp(&urgency1).unwrap_or(Ordering::Less) } #[derive(Debug)] pub enum DateState { BeforeToday, EarlierToday, LaterToday, AfterToday, NotDue, } pub fn get_date_state(reference: &Date, due: usize) -> DateState { let now = Local::now(); let reference = TimeZone::from_utc_datetime(now.offset(), reference); let now = TimeZone::from_utc_datetime(now.offset(), &now.naive_utc()); if reference.date() < now.date() { return DateState::BeforeToday; } if reference.date() == now.date() { if reference.time() < now.time() { return DateState::EarlierToday; } else { return DateState::LaterToday; } } if reference <= now + chrono::Duration::days(7) { DateState::AfterToday } else { DateState::NotDue } } fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2), ] .as_ref(), ) .split(r); Layout::default() .direction(Direction::Horizontal) .constraints( [ Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2), ] .as_ref(), ) .split(popup_layout[1])[1] } #[derive(PartialEq)] pub enum AppMode { TaskReport, TaskFilter, TaskAdd, TaskAnnotate, TaskSubprocess, TaskLog, TaskModify, TaskHelpPopup, TaskError, TaskContextMenu, Calendar, } pub struct HistoryContext { history: History, history_index: usize, } impl HistoryContext { pub fn new() -> Self { let history = History::new(); Self { history, history_index: 0, } } pub fn history(&self) -> &History { &self.history } pub fn history_index(&self) -> usize { self.history_index } pub fn history_search(&mut self, buf: &str, dir: HistoryDirection) -> Option { if self.history.is_empty() { return None; } if self.history_index == self.history.len().saturating_sub(1) && dir == HistoryDirection::Forward || self.history_index == 0 && dir == HistoryDirection::Reverse { return Some(self.history.get(self.history_index).unwrap().clone()); } let history_index = match dir { HistoryDirection::Reverse => self.history_index - 1, HistoryDirection::Forward => self.history_index + 1, }; if let Some(history_index) = self.history.starts_with(buf, history_index, dir) { self.history_index = history_index; Some(self.history.get(history_index).unwrap().clone()) } else if buf.is_empty() { self.history_index = history_index; Some(self.history.get(history_index).unwrap().clone()) } else { None } } pub fn add(&mut self, buf: &str) { if self.history.add(buf) { self.history_index = self.history.len() - 1; } } pub fn last(&mut self) { self.history_index = self.history.len().saturating_sub(1); } } pub struct TaskwarriorTuiApp { pub should_quit: bool, pub dirty: bool, pub task_table_state: TableState, pub context_table_state: TableState, pub current_context_filter: String, pub current_context: String, pub command: LineBuffer, pub filter: LineBuffer, pub modify: LineBuffer, pub error: String, pub tasks: Vec, pub task_details: HashMap, pub marked: HashSet, // stores index of current task that is highlighted pub current_selection: usize, pub task_report_table: TaskReportTable, pub calendar_year: i32, pub mode: AppMode, pub config: Config, pub task_report_show_info: bool, pub task_report_height: u16, pub task_details_scroll: u16, pub help_popup: Help, pub contexts: Vec, pub last_export: Option, pub keyconfig: KeyConfig, pub terminal_width: u16, pub terminal_height: u16, pub filter_history_context: HistoryContext, pub command_history_context: HistoryContext, } impl TaskwarriorTuiApp { pub fn new() -> Result { let c = Config::default()?; let mut kc = KeyConfig::default(); kc.update()?; let (w, h) = crossterm::terminal::size()?; let mut app = Self { should_quit: false, dirty: true, task_table_state: TableState::default(), context_table_state: TableState::default(), tasks: vec![], task_details: HashMap::new(), marked: HashSet::new(), current_selection: 0, current_context_filter: "".to_string(), current_context: "".to_string(), command: LineBuffer::with_capacity(MAX_LINE), filter: LineBuffer::with_capacity(MAX_LINE), modify: LineBuffer::with_capacity(MAX_LINE), error: "".to_string(), mode: AppMode::TaskReport, task_report_height: 0, task_details_scroll: 0, task_report_show_info: c.uda_task_report_show_info, config: c, task_report_table: TaskReportTable::new()?, calendar_year: Local::today().year(), help_popup: Help::new(), contexts: vec![], last_export: None, keyconfig: kc, terminal_width: w, terminal_height: h, filter_history_context: HistoryContext::new(), command_history_context: HistoryContext::new(), }; for c in app.config.filter.chars() { app.filter.insert(c, 1); } app.get_context()?; app.update(true)?; app.filter_history_context.add(app.filter.as_str()); Ok(app) } pub fn get_context(&mut self) -> Result<()> { let output = Command::new("task").arg("_get").arg("rc.context").output()?; self.current_context = String::from_utf8_lossy(&output.stdout).to_string(); self.current_context = self.current_context.strip_suffix('\n').unwrap_or("").to_string(); let output = Command::new("task") .arg("_get") .arg(format!("rc.context.{}", self.current_context)) .output()?; self.current_context_filter = String::from_utf8_lossy(&output.stdout).to_string(); self.current_context_filter = self.current_context_filter.strip_suffix('\n').unwrap_or("").to_string(); Ok(()) } pub fn render(&mut self, terminal: &mut Terminal) -> Result<()> where B: Backend, { terminal.draw(|f| self.draw(f))?; Ok(()) } pub fn draw(&mut self, f: &mut Frame) { let rect = f.size(); self.terminal_width = rect.width; self.terminal_height = rect.height; match self.mode { AppMode::TaskReport | AppMode::TaskFilter | AppMode::TaskAdd | AppMode::TaskAnnotate | AppMode::TaskContextMenu | AppMode::TaskError | AppMode::TaskHelpPopup | AppMode::TaskSubprocess | AppMode::TaskLog | AppMode::TaskModify => self.draw_task(f), AppMode::Calendar => self.draw_calendar(f), } } pub fn draw_debug(&mut self, f: &mut Frame) { let area = centered_rect(50, 50, f.size()); f.render_widget(Clear, area); let t = format!( "{}\n{}\n{:?}", self.command.pos(), self.command_history_context.history_index, self.command_history_context.history.iter().collect::>() ); let p = Paragraph::new(Text::from(t)) .block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded)); f.render_widget(p, area); } pub fn draw_calendar(&mut self, f: &mut Frame) { let dates_with_styles = self.get_dates_with_styles(); let rects = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0)].as_ref()) .split(f.size()); let today = Local::today(); let mut c = Calendar::default() .block( Block::default() .title(Spans::from(vec![ Span::styled("Task", Style::default().add_modifier(Modifier::DIM)), Span::from("|"), Span::styled("Calendar", Style::default().add_modifier(Modifier::BOLD)), ])) .borders(Borders::ALL) .border_type(BorderType::Rounded), ) .year(self.calendar_year) .date_style(dates_with_styles) .months_per_row(self.config.uda_calendar_months_per_row); c.title_background_color = self.config.uda_style_calendar_title.bg.unwrap_or(Color::Black); f.render_widget(c, rects[0]); } pub fn get_dates_with_styles(&self) -> Vec<(NaiveDate, Style)> { let mut tasks_with_styles = vec![]; if !self.tasks.is_empty() { let tasks = &self.tasks; let tasks_with_due_dates = tasks.iter().filter(|t| t.due().is_some()); tasks_with_styles .extend(tasks_with_due_dates.map(|t| (t.due().unwrap().clone().date(), self.style_for_task(t)))) } tasks_with_styles } pub fn get_position(&self, lb: &LineBuffer) -> usize { let mut position = lb.as_str().graphemes(true).count(); for (i, (_i, g)) in lb.as_str().grapheme_indices(true).enumerate() { if _i == lb.pos() { position = i; break; } } position } pub fn draw_task(&mut self, f: &mut Frame) { let rects = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref()) .split(f.size()); if !self.task_report_show_info { let full_table_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(100)].as_ref()) .split(rects[0]); self.task_report_height = full_table_layout[0].height; self.draw_task_report(f, full_table_layout[0]); } else { let split_task_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) .split(rects[0]); self.task_report_height = split_task_layout[0].height; self.draw_task_report(f, split_task_layout[0]); self.draw_task_details(f, split_task_layout[1]); } let selected = self.current_selection; let task_ids = if self.tasks.is_empty() { vec!["0".to_string()] } else { match self.task_table_state.mode() { TableMode::SingleSelection => vec![self.tasks[selected].id().unwrap_or_default().to_string()], TableMode::MultipleSelection => { let mut tids = vec![]; for uuid in self.marked.iter() { if let Some(t) = self.task_by_uuid(*uuid) { tids.push(t.id().unwrap_or_default().to_string()); } } tids } } }; match self.mode { AppMode::TaskReport => self.draw_command( f, rects[1], self.filter.as_str(), "Filter Tasks", self.get_position(&self.filter), false, ), AppMode::TaskFilter => { let position = self.get_position(&self.filter); self.draw_command( f, rects[1], self.filter.as_str(), Span::styled("Filter Tasks", Style::default().add_modifier(Modifier::BOLD)), position, true, ); } AppMode::TaskLog => { let position = self.get_position(&self.command); self.draw_command( f, rects[1], self.command.as_str(), Span::styled("Log Tasks", Style::default().add_modifier(Modifier::BOLD)), position, true, ); } AppMode::TaskSubprocess => { let position = self.get_position(&self.command); self.draw_command( f, rects[1], self.command.as_str(), Span::styled("Shell Command", Style::default().add_modifier(Modifier::BOLD)), position, true, ); } AppMode::TaskModify => { let position = self.get_position(&self.modify); let label = if task_ids.len() > 1 { format!("Modify Tasks {}", task_ids.join(",")) } else { format!("Modify Task {}", task_ids.join(",")) }; self.draw_command( f, rects[1], self.modify.as_str(), Span::styled(label, Style::default().add_modifier(Modifier::BOLD)), position, true, ); } AppMode::TaskAnnotate => { let position = self.get_position(&self.command); let label = if task_ids.len() > 1 { format!("Annotate Tasks {}", task_ids.join(",")) } else { format!("Annotate Task {}", task_ids.join(",")) }; self.draw_command( f, rects[1], self.command.as_str(), Span::styled(label, Style::default().add_modifier(Modifier::BOLD)), position, true, ); } AppMode::TaskAdd => { let position = self.get_position(&self.command); self.draw_command( f, rects[1], self.command.as_str(), Span::styled("Add Task", Style::default().add_modifier(Modifier::BOLD)), position, true, ); } AppMode::TaskError => { self.draw_command( f, rects[1], self.error.as_str(), Span::styled("Error", Style::default().add_modifier(Modifier::BOLD)), 0, false, ); } AppMode::TaskHelpPopup => { self.draw_command( f, rects[1], self.filter.as_str(), "Filter Tasks", self.get_position(&self.filter), false, ); self.draw_help_popup(f, 80, 90); } AppMode::TaskContextMenu => { self.draw_command( f, rects[1], self.filter.as_str(), "Filter Tasks", self.get_position(&self.filter), false, ); self.draw_context_menu(f, 80, 50); } _ => { panic!("Reached unreachable code. Something went wrong"); } } } fn draw_help_popup(&mut self, f: &mut Frame, percent_x: u16, percent_y: u16) { let area = centered_rect(percent_x, percent_y, f.size()); f.render_widget(Clear, area); self.help_popup.scroll = std::cmp::min( self.help_popup.scroll, (self.help_popup.text_height as u16).saturating_sub(area.height), ); f.render_widget(&self.help_popup, area); } fn draw_context_menu(&mut self, f: &mut Frame, percent_x: u16, percent_y: u16) { let rects = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0)].as_ref()) .split(f.size()); let area = centered_rect(percent_x, percent_y, f.size()); f.render_widget( Clear, area.inner(&Margin { vertical: 0, horizontal: 0, }), ); let (contexts, headers) = self.get_all_contexts(); let maximum_column_width = area.width; let widths = self.calculate_widths(&contexts, &headers, maximum_column_width); let selected = self.context_table_state.current_selection().unwrap_or_default(); let header = headers.iter(); let mut rows = vec![]; let mut highlight_style = Style::default(); for (i, context) in contexts.iter().enumerate() { let mut style = Style::default(); if &self.contexts[i].active == "yes" { style = self.config.uda_style_context_active; } rows.push(Row::StyledData(context.iter(), style)); if i == self.context_table_state.current_selection().unwrap_or_default() { highlight_style = style; } } let constraints: Vec = widths .iter() .map(|i| Constraint::Length((*i).try_into().unwrap_or(maximum_column_width as u16))) .collect(); let highlight_style = highlight_style.add_modifier(Modifier::BOLD); let t = Table::new(header, rows.into_iter()) .block( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .title(Spans::from(vec![Span::styled( "Context", Style::default().add_modifier(Modifier::BOLD), )])), ) .header_style( self.config .color .get("color.label") .cloned() .unwrap_or_default() .add_modifier(Modifier::UNDERLINED), ) .highlight_style(highlight_style) .highlight_symbol(&self.config.uda_selection_indicator) .widths(&constraints); f.render_stateful_widget(t, area, &mut self.context_table_state); } fn draw_command<'a, T>( &self, f: &mut Frame, rect: Rect, text: &str, title: T, position: usize, cursor: bool, ) where T: Into>, { f.render_widget(Clear, rect); if cursor { f.set_cursor( std::cmp::min(rect.x + position as u16 + 1, rect.x + rect.width.saturating_sub(2)), rect.y + 1, ); } let p = Paragraph::new(Text::from(text)) .block( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .title(title.into()), ) .scroll((0, ((position + 3) as u16).saturating_sub(rect.width))); f.render_widget(p, rect); } fn draw_task_details(&mut self, f: &mut Frame, rect: Rect) { if self.tasks.is_empty() { f.render_widget( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .title("Task not found"), rect, ); return; } let selected = self.current_selection; let task_id = self.tasks[selected].id().unwrap_or_default(); let task_uuid = *self.tasks[selected].uuid(); let data = match self.task_details.get(&task_uuid) { Some(s) => s.clone(), None => "Loading task details ...".to_string(), }; self.task_details_scroll = std::cmp::min( (data.lines().count() as u16) .saturating_sub(rect.height) .saturating_add(2), self.task_details_scroll, ); let p = Paragraph::new(Text::from(&data[..])) .block( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .title(format!("Task {}", task_id)), ) .scroll((self.task_details_scroll, 0)); f.render_widget(p, rect); } fn task_details_scroll_up(&mut self) { self.task_details_scroll = self.task_details_scroll.saturating_sub(1); } fn task_details_scroll_down(&mut self) { self.task_details_scroll = self.task_details_scroll.saturating_add(1); } fn task_by_index(&self, i: usize) -> Option { let tasks = &self.tasks; if i >= tasks.len() { None } else { Some(tasks[i].clone()) } } fn task_by_uuid(&self, uuid: Uuid) -> Option { let tasks = &self.tasks; let m = tasks.iter().find(|t| *t.uuid() == uuid); m.cloned() } fn task_by_id(&self, id: u64) -> Option { let tasks = &self.tasks; let m = tasks.iter().find(|t| t.id().unwrap() == id); m.cloned() } fn task_index_by_uuid(&self, uuid: Uuid) -> Option { let tasks = &self.tasks; let m = tasks.iter().position(|t| *t.uuid() == uuid); m } fn style_for_task(&self, task: &Task) -> Style { let virtual_tag_names_in_precedence = &self.config.rule_precedence_color; let mut style = Style::default(); for tag_name in virtual_tag_names_in_precedence.iter().rev() { if tag_name == "uda." || tag_name == "priority" { if let Some(p) = task.priority() { let s = self .config .color .get(&format!("color.uda.priority.{}", p)) .cloned() .unwrap_or_default(); style = style.patch(s); } } else if tag_name == "tag." { if let Some(tags) = task.tags() { for t in tags { let color_tag_name = format!("color.tag.{}", t); let s = self.config.color.get(&color_tag_name).cloned().unwrap_or_default(); style = style.patch(s); } } } else if tag_name == "project." { if let Some(p) = task.project() { let s = self .config .color .get(&format!("color.project.{}", p)) .cloned() .unwrap_or_default(); style = style.patch(s); } } else if task .tags() .unwrap_or(&vec![]) .contains(&tag_name.to_string().replace(".", "").to_uppercase()) { let color_tag_name = format!("color.{}", tag_name); let s = self.config.color.get(&color_tag_name).cloned().unwrap_or_default(); style = style.patch(s); } } style } pub fn calculate_widths(&self, tasks: &[Vec], headers: &[String], maximum_column_width: u16) -> Vec { // naive implementation of calculate widths let mut widths = headers.iter().map(|s| s.len()).collect::>(); for row in tasks.iter() { for (i, cell) in row.iter().enumerate() { widths[i] = std::cmp::max(cell.len(), widths[i]); } } for (i, header) in headers.iter().enumerate() { if header == "Description" || header == "Definition" { // always give description or definition the most room to breath widths[i] = maximum_column_width as usize; break; } } for (i, header) in headers.iter().enumerate() { if header == "ID" || header == "Name" { // always give ID a couple of extra for indicator widths[i] += self.config.uda_selection_indicator.as_str().graphemes(true).count(); // if let TableMode::MultipleSelection = self.task_table_state.mode() { // widths[i] += 2 // }; } } // now start trimming while (widths.iter().sum::() as u16) >= maximum_column_width - (headers.len()) as u16 { let index = widths.iter().position(|i| i == widths.iter().max().unwrap()).unwrap(); if widths[index] == 1 { break; } widths[index] -= 1; } widths } fn draw_task_report(&mut self, f: &mut Frame, rect: Rect) { let (tasks, headers) = self.get_task_report(); if tasks.is_empty() { let mut style = Style::default(); match self.mode { AppMode::TaskReport => style = style.add_modifier(Modifier::BOLD), _ => style = style.add_modifier(Modifier::DIM), } f.render_widget( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .title(Spans::from(vec![ Span::styled("Task", style), Span::from("|"), Span::styled("Calendar", Style::default().add_modifier(Modifier::DIM)), ])), rect, ); return; } let maximum_column_width = rect.width; let widths = self.calculate_widths(&tasks, &headers, maximum_column_width); for (i, header) in headers.iter().enumerate() { if header == "Description" || header == "Definition" { self.task_report_table.description_width = widths[i] - 1; break; } } let selected = self.current_selection; let header = headers.iter(); let mut rows = vec![]; let mut highlight_style = Style::default(); for (i, task) in tasks.iter().enumerate() { let style = self.style_for_task(&self.tasks[i]); if i == selected { highlight_style = style; if self.config.uda_selection_bold { highlight_style = highlight_style.add_modifier(Modifier::BOLD); } if self.config.uda_selection_italic { highlight_style = highlight_style.add_modifier(Modifier::ITALIC); } if self.config.uda_selection_dim { highlight_style = highlight_style.add_modifier(Modifier::DIM); } if self.config.uda_selection_blink { highlight_style = highlight_style.add_modifier(Modifier::SLOW_BLINK); } } rows.push(Row::StyledData(task.iter(), style)); } let constraints: Vec = widths .iter() .map(|i| Constraint::Length((*i).try_into().unwrap_or(maximum_column_width as u16))) .collect(); let mut style = Style::default(); match self.mode { AppMode::TaskReport => style = style.add_modifier(Modifier::BOLD), _ => style = style.add_modifier(Modifier::DIM), } let t = Table::new(header, rows.into_iter()) .block( Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .title(Spans::from(vec![ Span::styled("Task", style), Span::from("|"), Span::styled("Calendar", Style::default().add_modifier(Modifier::DIM)), ])), ) .header_style( self.config .color .get("color.label") .cloned() .unwrap_or_default() .add_modifier(Modifier::UNDERLINED), ) .highlight_style(highlight_style) .highlight_symbol(&self.config.uda_selection_indicator) .mark_symbol(&self.config.uda_mark_indicator) .unmark_symbol(&self.config.uda_unmark_indicator) .widths(&constraints); f.render_stateful_widget(t, rect, &mut self.task_table_state); } pub fn get_all_contexts(&self) -> (Vec>, Vec) { let contexts = self .contexts .iter() .map(|c| vec![c.name.clone(), c.description.clone(), c.active.clone()]) .collect(); let headers = vec!["Name".to_string(), "Description".to_string(), "Active".to_string()]; (contexts, headers) } pub fn get_task_report(&mut self) -> (Vec>, Vec) { self.task_report_table.generate_table(&self.tasks); let (tasks, headers) = self.task_report_table.simplify_table(); (tasks, headers) } pub fn update(&mut self, force: bool) -> Result<()> { if force || self.dirty || self.tasks_changed_since(self.last_export)? { self.last_export = Some(std::time::SystemTime::now()); self.task_report_table.export_headers()?; let _ = self.export_tasks(); self.export_contexts()?; self.update_tags(); self.task_details.clear(); self.dirty = false; self.cursor_fix(); } if self.task_report_show_info { task::block_on(self.update_task_details())?; } Ok(()) } pub fn cursor_fix(&mut self) { while !self.tasks.is_empty() && self.current_selection >= self.tasks.len() { self.task_report_previous(); } } pub async fn update_task_details(&mut self) -> Result<()> { if self.tasks.is_empty() { return Ok(()); } // remove task_details of tasks not in task report let mut to_delete = vec![]; for k in self.task_details.keys() { if !self.tasks.iter().map(|t| t.uuid()).any(|x| x == k) { to_delete.push(*k); } } for k in to_delete { self.task_details.remove(&k); } let selected = self.current_selection; if selected >= self.tasks.len() { return Ok(()); } let current_task_uuid = *self.tasks[selected].uuid(); let mut l = vec![selected]; for s in 1..=self.config.uda_task_detail_prefetch { l.insert(0, std::cmp::min(selected.saturating_sub(s), self.tasks.len() - 1)); l.push(std::cmp::min(selected + s, self.tasks.len() - 1)) } l.dedup(); let mut output_futs = FuturesOrdered::new(); for s in l.iter() { if self.tasks.is_empty() { return Ok(()); } if s >= &self.tasks.len() { break; } let task_uuid = *self.tasks[*s].uuid(); if !self.task_details.contains_key(&task_uuid) || task_uuid == current_task_uuid { let output_fut = async_std::process::Command::new("task") .arg("rc.color=off") .arg(format!("rc.defaultwidth={}", self.terminal_width - 2)) .arg(format!("{}", task_uuid)) .output(); output_futs.push(output_fut); } } for s in l.iter() { if s >= &self.tasks.len() { break; } let task_id = self.tasks[*s].id().unwrap_or_default(); let task_uuid = *self.tasks[*s].uuid(); if !self.task_details.contains_key(&task_uuid) || task_uuid == current_task_uuid { if let Some(Ok(output)) = output_futs.next().await { let data = String::from_utf8_lossy(&output.stdout).to_string(); self.task_details.insert(task_uuid, data); } } } Ok(()) } pub fn update_task_table_state(&mut self) { self.task_table_state.select(Some(self.current_selection)); for uuid in self.marked.clone() { if self.task_by_uuid(uuid).is_none() { self.marked.remove(&uuid); } } if self.marked.is_empty() { self.task_table_state.single_selection(); } self.task_table_state.clear(); for uuid in &self.marked { self.task_table_state.mark(self.task_index_by_uuid(*uuid)) } } pub fn context_next(&mut self) { let i = match self.context_table_state.current_selection() { Some(i) => { if i >= self.contexts.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.context_table_state.select(Some(i)); } pub fn context_previous(&mut self) { let i = match self.context_table_state.current_selection() { Some(i) => { if i == 0 { self.contexts.len() - 1 } else { i - 1 } } None => 0, }; self.context_table_state.select(Some(i)); } pub fn context_select(&mut self) -> Result<(), String> { let i = self.context_table_state.current_selection().unwrap(); let mut command = Command::new("task"); command.arg("context").arg(&self.contexts[i].name); let output = command.output(); Ok(()) } pub fn task_report_top(&mut self) { if self.tasks.is_empty() { return; } self.current_selection = 0; } pub fn task_report_bottom(&mut self) { if self.tasks.is_empty() { return; } self.current_selection = self.tasks.len() - 1; } pub fn task_report_next(&mut self) { if self.tasks.is_empty() { return; } let i = { if self.current_selection >= self.tasks.len() - 1 { if self.config.uda_task_report_looping { 0 } else { self.current_selection } } else { self.current_selection + 1 } }; self.current_selection = i; } pub fn task_report_previous(&mut self) { if self.tasks.is_empty() { return; } let i = { if self.current_selection == 0 { if self.config.uda_task_report_looping { self.tasks.len() - 1 } else { 0 } } else { self.current_selection - 1 } }; self.current_selection = i; } pub fn task_report_next_page(&mut self) { if self.tasks.is_empty() { return; } let i = { if self.current_selection >= self.tasks.len() - 1 { if self.config.uda_task_report_looping { 0 } else { self.current_selection } } else { self.current_selection .checked_add(self.task_report_height as usize) .unwrap_or_else(|| self.tasks.len()) } }; self.current_selection = i; } pub fn task_report_previous_page(&mut self) { if self.tasks.is_empty() { return; } let i = { if self.current_selection == 0 { if self.config.uda_task_report_looping { self.tasks.len() - 1 } else { 0 } } else { self.current_selection.saturating_sub(self.task_report_height as usize) } }; self.current_selection = i; } pub fn export_contexts(&mut self) -> Result<()> { let output = Command::new("task").arg("context").output()?; let data = String::from_utf8_lossy(&output.stdout); self.contexts = vec![]; for (i, line) in data.trim().split('\n').enumerate() { let line = line.trim(); if line.is_empty() || line == "Use 'task context none' to unset the current context." { continue; } let mut s = line.split(' '); let name = s.next().unwrap_or_default(); let active = s.last().unwrap_or_default(); let definition = line.replacen(name, "", 1); let definition = definition.strip_suffix(active).unwrap(); if i == 0 || i == 1 { continue; } else { let context = Context::new(name.to_string(), definition.trim().to_string(), active.to_string()); self.contexts.push(context); } } if self.contexts.iter().any(|r| r.active != "no") { self.contexts .insert(0, Context::new("none".to_string(), "".to_string(), "no".to_string())) } else { self.contexts .insert(0, Context::new("none".to_string(), "".to_string(), "yes".to_string())) } Ok(()) } fn get_task_files_max_mtime(&self) -> Result { let data_dir = shellexpand::tilde(&self.config.data_location).into_owned(); let mut mtimes = Vec::new(); for fname in &["backlog.data", "completed.data", "pending.data"] { let pending_fp = Path::new(&data_dir).join(fname); let mtime = fs::metadata(pending_fp)?.modified()?; mtimes.push(mtime); } Ok(*mtimes.iter().max().unwrap()) } pub fn tasks_changed_since(&mut self, prev: Option) -> Result { if let Some(prev) = prev { match self.get_task_files_max_mtime() { Ok(mtime) => { if mtime > prev { Ok(true) } else { // Unfortunately, we can not use std::time::Instant which is guaranteed to be monotonic, // because we need to compare it to a file mtime as SystemTime, so as a safety for unexpected // time shifts, cap maximum wait to 1 min let now = SystemTime::now(); let max_delta = Duration::from_secs(60); Ok(now.duration_since(prev)? > max_delta) } } Err(_) => Ok(true), } } else { Ok(true) } } pub fn export_tasks(&mut self) -> Result<()> { let mut task = Command::new("task"); task.arg("rc.json.array=on"); task.arg("rc.confirmation=off"); task.arg("export"); let filter = if !self.current_context_filter.is_empty() { let t = format!("{} '\\({}\\)'", self.filter.as_str(), self.current_context_filter); t } else { self.filter.as_str().into() }; match shlex::split(&filter) { Some(cmd) => { for s in cmd { task.arg(&s); } } None => { task.arg(""); } } let output = task.output()?; let data = String::from_utf8_lossy(&output.stdout); let error = String::from_utf8_lossy(&output.stderr); if !error.contains("The expression could not be evaluated.") { if let Ok(imported) = import(data.as_bytes()) { self.tasks = imported; self.tasks.sort_by(cmp); } } Ok(()) } pub fn selected_task_uuids(&self) -> Vec { let selected = match self.task_table_state.mode() { TableMode::SingleSelection => vec![self.current_selection], TableMode::MultipleSelection => self.task_table_state.marked().cloned().collect::>(), }; let mut task_uuids = vec![]; for s in selected { let task_id = self.tasks[s].id().unwrap_or_default(); let task_uuid = *self.tasks[s].uuid(); task_uuids.push(task_uuid); } task_uuids } pub fn task_subprocess(&mut self) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let shell = self.command.as_str(); match shlex::split(&shell) { Some(cmd) => { // first argument must be a binary let mut command = Command::new(&cmd[0]); // remaining arguments are args for (i, s) in cmd.iter().enumerate() { if i == 0 { continue; } command.arg(&s); } let output = command.output(); match output { Ok(_) => Ok(()), Err(_) => Err(format!("Shell command `{}` exited with non-zero output", shell,)), } } None => Err(format!("Cannot run subprocess. Unable to shlex split `{}`", shell)), } } pub fn task_log(&mut self) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let mut command = Command::new("task"); command.arg("log"); let shell = self.command.as_str(); match shlex::split(&shell) { Some(cmd) => { for s in cmd { command.arg(&s); } let output = command.output(); match output { Ok(_) => Ok(()), Err(_) => Err(format!( "Cannot run `task log {}`. Check documentation for more information", shell )), } } None => Err(format!("Unable to run `task log`. Cannot shlex split `{}`", shell)), } } pub fn task_shortcut(&mut self, s: usize) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let task_uuids = self.selected_task_uuids(); let shell = &self.config.uda_shortcuts[s]; if shell.is_empty() { return Err("Trying to run empty shortcut.".to_string()); } let shell = format!( "{} {}", shell, task_uuids .iter() .map(|u| u.to_string()) .collect::>() .join(" ") ); let shell = shellexpand::tilde(&shell).into_owned(); match shlex::split(&shell) { Some(cmd) => { let mut command = Command::new(&cmd[0]); for s in cmd.iter().skip(1) { command.arg(&s); } let output = command.output(); match output { Ok(o) => { if o.status.success() { Ok(()) } else { Err(format!( "Unable to run shortcut {}. Failed with status code {}", s, o.status.code().unwrap() )) } } Err(s) => Err(format!("`{}` failed: {}", shell, s,)), } } None => Err(format!("Unable to run `{}`. Cannot shlex split `{}`", shell, shell)), } } pub fn task_modify(&mut self) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let task_uuids = self.selected_task_uuids(); let mut command = Command::new("task"); command.arg("rc.bulk=0"); command.arg("rc.confirmation=off"); command.arg("rc.dependency.confirmation=off"); command.arg("rc.recurrence.confirmation=off"); for task_uuid in &task_uuids { command.arg(task_uuid.to_string()); } command.arg("modify"); let shell = self.modify.as_str(); match shlex::split(&shell) { Some(cmd) => { for s in cmd { command.arg(&s); } let output = command.output(); match output { Ok(o) => { if o.status.success() { Ok(()) } else { Err(format!("Modify failed. {}", String::from_utf8_lossy(&o.stdout),)) } } Err(_) => Err(format!( "Cannot run `task {:?} modify {}`. Check documentation for more information", task_uuids, shell, )), } } None => Err(format!("Cannot shlex split `{}`", shell,)), } } pub fn task_annotate(&mut self) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let task_uuids = self.selected_task_uuids(); let mut command = Command::new("task"); command.arg("rc.bulk=0"); command.arg("rc.confirmation=off"); command.arg("rc.dependency.confirmation=off"); command.arg("rc.recurrence.confirmation=off"); for task_uuid in &task_uuids { command.arg(task_uuid.to_string()); } command.arg("annotate"); let shell = self.command.as_str(); match shlex::split(&shell) { Some(cmd) => { for s in cmd { command.arg(&s); } let output = command.output(); match output { Ok(o) => { if o.status.success() { Ok(()) } else { Err(format!("Annotate failed. {}", String::from_utf8_lossy(&o.stdout),)) } } Err(_) => Err(format!( "Cannot run `task {} annotate {}`. Check documentation for more information", task_uuids .iter() .map(|u| u.to_string()) .collect::>() .join(" "), shell )), } } None => Err(format!("Cannot shlex split `{}`", shell)), } } pub fn task_add(&mut self) -> Result<(), String> { let mut command = Command::new("task"); command.arg("add"); let shell = self.command.as_str(); match shlex::split(&shell) { Some(cmd) => { for s in cmd { command.arg(&s); } let output = command.output(); match output { Ok(_) => Ok(()), Err(_) => Err(format!( "Cannot run `task add {}`. Check documentation for more information", shell )), } } None => Err(format!("Unable to run `task add`. Cannot shlex split `{}`", shell)), } } pub fn task_virtual_tags(task_uuid: Uuid) -> Result { let output = Command::new("task").arg(format!("{}", task_uuid)).output(); match output { Ok(output) => { let data = String::from_utf8_lossy(&output.stdout); for line in data.split('\n') { for prefix in &["Virtual tags", "Virtual"] { if line.starts_with(prefix) { let line = line.to_string(); let line = line.replace(prefix, ""); return Ok(line); } } } Err(format!( "Cannot find any tags for `task {}`. Check documentation for more information", task_uuid )) } Err(_) => Err(format!( "Cannot run `task {}`. Check documentation for more information", task_uuid )), } } pub fn task_start_stop(&mut self) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let selected = self.current_selection; let task_id = self.tasks[selected].id().unwrap_or_default(); let task_uuid = *self.tasks[selected].uuid(); let task_uuids = self.selected_task_uuids(); for task_uuid in &task_uuids { let mut command = "start"; for tag in TaskwarriorTuiApp::task_virtual_tags(*task_uuid).unwrap().split(' ') { if tag == "ACTIVE" { command = "stop" } } let output = Command::new("task").arg(task_uuid.to_string()).arg(command).output(); if output.is_err() { return Err(format!("Error running `task {}` for task `{}`.", command, task_uuid,)); } } Ok(()) } pub fn task_delete(&self) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let task_uuids = self.selected_task_uuids(); let mut cmd = Command::new("task"); cmd.arg("rc.bulk=0") .arg("rc.confirmation=off") .arg("rc.dependency.confirmation=off") .arg("rc.recurrence.confirmation=off"); for task_uuid in &task_uuids { cmd.arg(task_uuid.to_string()); } cmd.arg("delete"); let output = cmd.output(); match output { Ok(_) => Ok(()), Err(_) => Err(format!( "Cannot run `task delete` for tasks `{}`. Check documentation for more information", task_uuids .iter() .map(|u| u.to_string()) .collect::>() .join(" ") )), } } pub fn task_done(&mut self) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let task_uuids = self.selected_task_uuids(); let mut cmd = Command::new("task"); cmd.arg("rc.bulk=0") .arg("rc.confirmation=off") .arg("rc.dependency.confirmation=off") .arg("rc.recurrence.confirmation=off"); for task_uuid in &task_uuids { cmd.arg(task_uuid.to_string()); } cmd.arg("done"); let output = cmd.output(); match output { Ok(_) => Ok(()), Err(_) => Err(format!( "Cannot run `task done` for task `{}`. Check documentation for more information", task_uuids .iter() .map(|u| u.to_string()) .collect::>() .join(" ") )), } } pub fn task_undo(&self) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let output = Command::new("task").arg("rc.confirmation=off").arg("undo").output(); match output { Ok(_) => Ok(()), Err(_) => Err("Cannot run `task undo`. Check documentation for more information".to_string()), } } pub fn task_edit(&self) -> Result<(), String> { if self.tasks.is_empty() { return Ok(()); } let selected = self.task_table_state.current_selection().unwrap_or_default(); let task_id = self.tasks[selected].id().unwrap_or_default(); let task_uuid = *self.tasks[selected].uuid(); let r = Command::new("task").arg(format!("{}", task_uuid)).arg("edit").spawn(); match r { Ok(child) => { let output = child.wait_with_output(); match output { Ok(output) => { if !output.status.success() { Err(format!( "`task edit` for task `{}` failed. {}{}", task_uuid, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), )) } else { String::from_utf8_lossy(&output.stdout); String::from_utf8_lossy(&output.stderr); Ok(()) } } Err(err) => Err(format!("Cannot run `task edit` for task `{}`. {}", task_uuid, err)), } } _ => Err(format!( "Cannot start `task edit` for task `{}`. Check documentation for more information", task_uuid )), } } pub fn task_current(&self) -> Option { if self.tasks.is_empty() { return None; } let selected = self.current_selection; Some(self.tasks[selected].clone()) } pub fn update_tags(&mut self) { let tasks = &mut self.tasks; // dependency scan for l_i in 0..tasks.len() { let default_deps = vec![]; let deps = tasks[l_i].depends().unwrap_or(&default_deps).clone(); add_tag(&mut tasks[l_i], "UNBLOCKED".to_string()); for dep in deps { for r_i in 0..tasks.len() { if tasks[r_i].uuid() == &dep { let lstatus = tasks[l_i].status(); let rstatus = tasks[r_i].status(); if lstatus != &TaskStatus::Completed && lstatus != &TaskStatus::Deleted && rstatus != &TaskStatus::Completed && rstatus != &TaskStatus::Deleted { remove_tag(&mut tasks[l_i], "UNBLOCKED".to_string()); add_tag(&mut tasks[l_i], "BLOCKED".to_string()); add_tag(&mut tasks[r_i], "BLOCKING".to_string()); } break; } } } } // other virtual tags // TODO: support all virtual tags that taskwarrior supports for mut task in tasks.iter_mut() { match task.status() { TaskStatus::Waiting => add_tag(&mut task, "WAITING".to_string()), TaskStatus::Completed => add_tag(&mut task, "COMPLETED".to_string()), TaskStatus::Pending => add_tag(&mut task, "PENDING".to_string()), TaskStatus::Deleted => add_tag(&mut task, "DELETED".to_string()), TaskStatus::Recurring => (), } if task.start().is_some() { add_tag(&mut task, "ACTIVE".to_string()); } if task.scheduled().is_some() { add_tag(&mut task, "SCHEDULED".to_string()); } if task.parent().is_some() { add_tag(&mut task, "INSTANCE".to_string()); } if task.until().is_some() { add_tag(&mut task, "UNTIL".to_string()); } if task.annotations().is_some() { add_tag(&mut task, "ANNOTATED".to_string()); } let virtual_tags = self.task_report_table.virtual_tags.clone(); if task.tags().is_some() && task.tags().unwrap().iter().any(|s| !virtual_tags.contains(s)) { add_tag(&mut task, "TAGGED".to_string()); } if !task.uda().is_empty() { add_tag(&mut task, "UDA".to_string()); } if task.mask().is_some() { add_tag(&mut task, "TEMPLATE".to_string()); } if task.project().is_some() { add_tag(&mut task, "PROJECT".to_string()); } if task.priority().is_some() { add_tag(&mut task, "PRIORITY".to_string()); } if task.recur().is_some() { add_tag(&mut task, "RECURRING".to_string()); let r = task.recur().unwrap(); } if let Some(d) = task.due() { let status = task.status(); // due today if status != &TaskStatus::Completed && status != &TaskStatus::Deleted { let now = Local::now(); let reference = TimeZone::from_utc_datetime(now.offset(), d); let now = TimeZone::from_utc_datetime(now.offset(), &now.naive_utc()); let d = d.clone(); if (reference - chrono::Duration::nanoseconds(1)).month() == now.month() { add_tag(&mut task, "MONTH".to_string()); } if (reference - chrono::Duration::nanoseconds(1)).month() % 4 == now.month() % 4 { add_tag(&mut task, "QUARTER".to_string()); } if reference.year() == now.year() { add_tag(&mut task, "YEAR".to_string()); } match get_date_state(&d, self.config.due) { DateState::EarlierToday | DateState::LaterToday => { add_tag(&mut task, "DUE".to_string()); add_tag(&mut task, "TODAY".to_string()); add_tag(&mut task, "DUETODAY".to_string()); } DateState::AfterToday => { add_tag(&mut task, "DUE".to_string()); if reference.date() == (now + chrono::Duration::days(1)).date() { add_tag(&mut task, "TOMORROW".to_string()); } } _ => (), } } } if let Some(d) = task.due() { let status = task.status(); // overdue if status != &TaskStatus::Completed && status != &TaskStatus::Deleted && status != &TaskStatus::Recurring { let now = Local::now().naive_utc(); let d = NaiveDateTime::new(d.date(), d.time()); if d < now { add_tag(&mut task, "OVERDUE".to_string()); } } } } } pub fn toggle_mark(&mut self) { let selected = self.current_selection; let task_id = self.tasks[selected].id().unwrap_or_default(); let task_uuid = *self.tasks[selected].uuid(); if !self.marked.insert(task_uuid) { self.marked.remove(&task_uuid); } } pub fn toggle_mark_all(&mut self) { for task in &self.tasks { if !self.marked.insert(*task.uuid()) { self.marked.remove(task.uuid()); } } } pub fn handle_input(&mut self, input: Key) -> Result<()> { match self.mode { AppMode::TaskReport => { if input == Key::Esc { self.marked.clear(); } else if input == self.keyconfig.quit || input == Key::Ctrl('c') { self.should_quit = true; } else if input == self.keyconfig.select { self.task_table_state.multiple_selection(); self.toggle_mark(); } else if input == self.keyconfig.select_all { self.task_table_state.multiple_selection(); self.toggle_mark_all(); } else if input == self.keyconfig.refresh { self.update(true)?; } else if input == self.keyconfig.go_to_bottom || input == Key::End { self.task_report_bottom(); } else if input == self.keyconfig.go_to_top || input == Key::Home { self.task_report_top(); } else if input == Key::Down || input == self.keyconfig.down { self.task_report_next(); } else if input == Key::Up || input == self.keyconfig.up { self.task_report_previous(); } else if input == Key::PageDown || input == self.keyconfig.page_down { self.task_report_next_page(); } else if input == Key::PageUp || input == self.keyconfig.page_up { self.task_report_previous_page(); } else if input == Key::Ctrl('e') { self.task_details_scroll_down(); } else if input == Key::Ctrl('y') { self.task_details_scroll_up(); } else if input == self.keyconfig.done { match self.task_done() { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.delete { match self.task_delete() { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.start_stop { match self.task_start_stop() { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.undo { match self.task_undo() { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.edit { let r = self.task_edit(); match r { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.modify { self.mode = AppMode::TaskModify; match self.task_table_state.mode() { TableMode::SingleSelection => match self.task_current() { Some(t) => { let mut s = format!("{} ", t.description().replace("'", "\\'")); if self.config.uda_prefill_task_metadata { if t.tags().is_some() { let virtual_tags = self.task_report_table.virtual_tags.clone(); for tag in t.tags().unwrap() { if !virtual_tags.contains(tag) { s = format!("{}+{} ", s, tag); } } } if t.project().is_some() { s = format!("{}project:{} ", s, t.project().unwrap()); } if t.priority().is_some() { s = format!("{}priority:{} ", s, t.priority().unwrap()); } if t.due().is_some() { let date = t.due().unwrap(); let now = Local::now(); let date = TimeZone::from_utc_datetime(now.offset(), date); s = format!( "{}due:'{:04}-{:02}-{:02}T{:02}:{:02}:{:02}' ", s, date.year(), date.month(), date.day(), date.hour(), date.minute(), date.second(), ) } } self.modify.update(&s, s.as_str().len()) } None => self.modify.update("", 0), }, TableMode::MultipleSelection => self.modify.update("", 0), } } else if input == self.keyconfig.shell { self.mode = AppMode::TaskSubprocess; } else if input == self.keyconfig.log { self.mode = AppMode::TaskLog; } else if input == self.keyconfig.add { self.mode = AppMode::TaskAdd; } else if input == self.keyconfig.annotate { self.mode = AppMode::TaskAnnotate; } else if input == self.keyconfig.help { self.mode = AppMode::TaskHelpPopup; } else if input == self.keyconfig.filter { self.mode = AppMode::TaskFilter; } else if input == self.keyconfig.shortcut1 { match self.task_shortcut(1) { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.shortcut2 { match self.task_shortcut(2) { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.shortcut3 { match self.task_shortcut(3) { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.shortcut4 { match self.task_shortcut(4) { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.shortcut5 { match self.task_shortcut(5) { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.shortcut6 { match self.task_shortcut(6) { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.shortcut7 { match self.task_shortcut(7) { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.shortcut8 { match self.task_shortcut(8) { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.shortcut9 { match self.task_shortcut(9) { Ok(_) => self.update(true)?, Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } else if input == self.keyconfig.zoom { self.task_report_show_info = !self.task_report_show_info; } else if input == self.keyconfig.context_menu { self.mode = AppMode::TaskContextMenu; } else if input == self.keyconfig.next_tab { self.mode = AppMode::Calendar; } } AppMode::TaskContextMenu => { if input == self.keyconfig.quit || input == Key::Esc { self.mode = AppMode::TaskReport; } else if input == Key::Down || input == self.keyconfig.down { self.context_next(); } else if input == Key::Up || input == self.keyconfig.up { self.context_previous(); } else if input == Key::Char('\n') { match self.context_select() { Ok(_) => { self.get_context()?; self.update(true)?; } Err(e) => { self.mode = AppMode::TaskError; self.error = e; } } } } AppMode::TaskHelpPopup => { if input == self.keyconfig.quit || input == Key::Esc { self.mode = AppMode::TaskReport; } else if input == self.keyconfig.down { self.help_popup.scroll = self.help_popup.scroll.checked_add(1).unwrap_or(0); let th = (self.help_popup.text_height as u16).saturating_sub(1); if self.help_popup.scroll > th { self.help_popup.scroll = th } } else if input == self.keyconfig.up { self.help_popup.scroll = self.help_popup.scroll.saturating_sub(1); } } AppMode::TaskModify => match input { Key::Char('\n') => match self.task_modify() { Ok(_) => { self.mode = AppMode::TaskReport; self.command_history_context.add(self.modify.as_str()); self.modify.update("", 0); self.update(true)?; } Err(e) => { self.mode = AppMode::TaskError; self.error = e; } }, Key::Up => { if let Some(s) = self .command_history_context .history_search(&self.modify.as_str()[..self.modify.pos()], HistoryDirection::Reverse) { let p = self.modify.pos(); self.modify.update("", 0); self.modify.update(&s, p); } } Key::Down => { if let Some(s) = self .command_history_context .history_search(&self.modify.as_str()[..self.modify.pos()], HistoryDirection::Forward) { let p = self.modify.pos(); self.modify.update("", 0); self.modify.update(&s, p); } } Key::Esc => { self.modify.update("", 0); self.mode = AppMode::TaskReport; } _ => { self.command_history_context.last(); handle_movement(&mut self.modify, input); } }, AppMode::TaskSubprocess => match input { Key::Char('\n') => match self.task_subprocess() { Ok(_) => { self.mode = AppMode::TaskReport; self.command.update("", 0); self.update(true)?; } Err(e) => { self.mode = AppMode::TaskError; self.error = e; } }, Key::Esc => { self.command.update("", 0); self.mode = AppMode::TaskReport; } _ => handle_movement(&mut self.command, input), }, AppMode::TaskLog => match input { Key::Char('\n') => match self.task_log() { Ok(_) => { self.mode = AppMode::TaskReport; self.command_history_context.add(self.command.as_str()); self.command.update("", 0); self.update(true)?; } Err(e) => { self.mode = AppMode::TaskError; self.error = e; } }, Key::Up => { if let Some(s) = self .command_history_context .history_search(&self.command.as_str()[..self.command.pos()], HistoryDirection::Reverse) { let p = self.command.pos(); self.command.update("", 0); self.command.update(&s, p); } } Key::Down => { if let Some(s) = self .command_history_context .history_search(&self.command.as_str()[..self.command.pos()], HistoryDirection::Forward) { let p = self.command.pos(); self.command.update("", 0); self.command.update(&s, p); } } Key::Esc => { self.command.update("", 0); self.mode = AppMode::TaskReport; } _ => { self.command_history_context.last(); handle_movement(&mut self.command, input); } }, AppMode::TaskAnnotate => match input { Key::Char('\n') => match self.task_annotate() { Ok(_) => { self.mode = AppMode::TaskReport; self.command.update("", 0); self.update(true)?; } Err(e) => { self.mode = AppMode::TaskError; self.error = e; } }, Key::Esc => { self.command.update("", 0); self.mode = AppMode::TaskReport; } _ => handle_movement(&mut self.command, input), }, AppMode::TaskAdd => match input { Key::Char('\n') => match self.task_add() { Ok(_) => { self.mode = AppMode::TaskReport; self.command_history_context.add(self.command.as_str()); self.command.update("", 0); self.update(true)?; } Err(e) => { self.mode = AppMode::TaskError; self.error = e; } }, Key::Up => { if let Some(s) = self .command_history_context .history_search(&self.command.as_str()[..self.command.pos()], HistoryDirection::Reverse) { let p = self.command.pos(); self.command.update("", 0); self.command.update(&s, p); } } Key::Down => { if let Some(s) = self .command_history_context .history_search(&self.command.as_str()[..self.command.pos()], HistoryDirection::Forward) { let p = self.command.pos(); self.command.update("", 0); self.command.update(&s, p); } } Key::Esc => { self.command.update("", 0); self.mode = AppMode::TaskReport; } _ => { self.command_history_context.last(); handle_movement(&mut self.command, input); } }, AppMode::TaskFilter => match input { Key::Char('\n') | Key::Esc => { self.mode = AppMode::TaskReport; self.filter_history_context.add(self.filter.as_str()); self.update(true)?; } Key::Up => { if let Some(s) = self .filter_history_context .history_search(&self.filter.as_str()[..self.filter.pos()], HistoryDirection::Reverse) { let p = self.filter.pos(); self.filter.update("", 0); self.filter.update(&s, p); self.dirty = true; } } Key::Down => { if let Some(s) = self .filter_history_context .history_search(&self.filter.as_str()[..self.filter.pos()], HistoryDirection::Forward) { let p = self.filter.pos(); self.filter.update("", 0); self.filter.update(&s, p); self.dirty = true; } } _ => { handle_movement(&mut self.filter, input); self.dirty = true; } }, AppMode::TaskError => self.mode = AppMode::TaskReport, AppMode::Calendar => { if input == self.keyconfig.quit || input == Key::Ctrl('c') { self.should_quit = true; } else if input == self.keyconfig.previous_tab { self.mode = AppMode::TaskReport; } else if input == Key::Up || input == self.keyconfig.up { if self.calendar_year > 0 { self.calendar_year -= 1; } } else if input == Key::Down || input == self.keyconfig.down { self.calendar_year += 1; } else if input == Key::PageUp || input == self.keyconfig.page_up { if self.calendar_year > 0 { self.calendar_year -= 10 } } else if input == Key::PageDown || input == self.keyconfig.page_down { self.calendar_year += 10 } } } self.update_task_table_state(); Ok(()) } } pub fn handle_movement(linebuffer: &mut LineBuffer, input: Key) { match input { Key::Ctrl('f') | Key::Right => { linebuffer.move_forward(1); } Key::Ctrl('b') | Key::Left => { linebuffer.move_backward(1); } Key::Char(c) => { linebuffer.insert(c, 1); } Key::Ctrl('h') | Key::Backspace => { linebuffer.backspace(1); } Key::Ctrl('d') | Key::Delete => { linebuffer.delete(1); } Key::Ctrl('a') | Key::Home => { linebuffer.move_home(); } Key::Ctrl('e') | Key::End => { linebuffer.move_end(); } Key::Ctrl('k') => { linebuffer.kill_line(); } Key::Ctrl('u') => { linebuffer.discard_line(); } Key::Ctrl('w') => { linebuffer.delete_prev_word(Word::Emacs, 1); } Key::Alt('d') => { linebuffer.delete_word(At::AfterEnd, Word::Emacs, 1); } Key::Alt('f') => { linebuffer.move_to_next_word(At::AfterEnd, Word::Emacs, 1); } Key::Alt('b') => { linebuffer.move_to_prev_word(Word::Emacs, 1); } Key::Alt('t') => { linebuffer.transpose_words(1); } _ => {} } } pub fn add_tag(task: &mut Task, tag: String) { match task.tags_mut() { Some(t) => t.push(tag), None => task.set_tags(Some(vec![tag])), } } pub fn remove_tag(task: &mut Task, tag: String) { if let Some(t) = task.tags_mut() { if let Some(index) = t.iter().position(|x| *x == tag) { t.remove(index); } } } #[cfg(test)] mod tests { use super::*; use std::ffi::OsStr; use std::fs::File; use std::path::Path; use tui::backend::TestBackend; use tui::buffer::Buffer; #[test] fn test_centered_rect() { assert_eq!( centered_rect(50, 50, Rect::new(0, 0, 100, 100)), Rect::new(25, 25, 50, 50) ); } fn setup() { use std::io::Read; use std::io::Write; use std::process::Stdio; let mut f = File::open(Path::new(env!("TASKDATA")).parent().unwrap().join("export.json")).unwrap(); let mut s = String::new(); f.read_to_string(&mut s).unwrap(); let tasks = task_hookrs::import::import(s.as_bytes()).unwrap(); // tasks.iter_mut().find(| t | t.id().unwrap() == 1).unwrap().priority_mut().replace(&mut "H".to_string()); // tasks.iter_mut().find(| t | t.id().unwrap() == 2).unwrap().priority_mut().replace(&mut "H".to_string()); // tasks.iter_mut().find(| t | t.id().unwrap() == 4).unwrap().tags_mut().replace(&mut vec!["test".to_string(), "another tag".to_string()]); assert!(task_hookrs::tw::save(&tasks).is_ok()); } fn teardown() { let cd = Path::new(env!("TASKDATA")); std::fs::remove_dir_all(cd).unwrap(); } #[test] fn test_taskwarrior_tui() { let app = TaskwarriorTuiApp::new(); if app.is_err() { return; } let app = app.unwrap(); assert!(app.task_by_index(0).is_none()); let app = TaskwarriorTuiApp::new().unwrap(); assert!(app .task_by_uuid(Uuid::parse_str("3f43831b-88dc-45e2-bf0d-4aea6db634cc").unwrap()) .is_none()); test_draw_empty_task_report(); test_draw_calendar(); test_draw_help_popup(); setup(); let app = TaskwarriorTuiApp::new().unwrap(); assert!(app.task_by_index(0).is_some()); let app = TaskwarriorTuiApp::new().unwrap(); assert!(app .task_by_uuid(Uuid::parse_str("3f43831b-88dc-45e2-bf0d-4aea6db634cc").unwrap()) .is_some()); test_draw_task_report_with_extended_modify_command(); test_draw_task_report(); test_task_tags(); test_task_style(); test_task_context(); test_task_tomorrow(); test_task_earlier_today(); test_task_later_today(); teardown(); } fn test_task_tags() { // testing tags let app = TaskwarriorTuiApp::new().unwrap(); let task = app.task_by_id(1).unwrap(); let tags = vec!["PENDING".to_string(), "PRIORITY".to_string()]; for tag in tags { assert!(task.tags().unwrap().contains(&tag)); } let app = TaskwarriorTuiApp::new().unwrap(); let task = app.task_by_id(11).unwrap(); let tags = vec!["finance", "UNBLOCKED", "PENDING", "TAGGED", "UDA"] .iter() .map(|s| s.to_string()) .collect::>(); for tag in tags { assert!(task.tags().unwrap().contains(&tag)); } } fn test_task_style() { let app = TaskwarriorTuiApp::new().unwrap(); let task = app.task_by_id(1).unwrap(); for r in vec![ "active", "blocked", "blocking", "completed", "deleted", "due", "due.today", "keyword.", "overdue", "project.", "recurring", "scheduled", "tag.", "tagged", "uda.", ] { assert!(app.config.rule_precedence_color.contains(&r.to_string())); } let style = app.style_for_task(&task); assert_eq!(style, Style::default().fg(Color::Indexed(2))); let task = app.task_by_id(11).unwrap(); let style = app.style_for_task(&task); } fn test_task_context() { let mut app = TaskwarriorTuiApp::new().unwrap(); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); app.context_select().unwrap(); assert_eq!(app.tasks.len(), 26); assert_eq!(app.current_context_filter, ""); assert_eq!(app.context_table_state.current_selection(), Some(0)); app.context_next(); app.context_select().unwrap(); assert_eq!(app.context_table_state.current_selection(), Some(1)); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), 1); assert_eq!(app.current_context_filter, "+finance -private"); assert_eq!(app.context_table_state.current_selection(), Some(1)); app.context_previous(); app.context_select().unwrap(); assert_eq!(app.context_table_state.current_selection(), Some(0)); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), 26); assert_eq!(app.current_context_filter, ""); } fn test_task_tomorrow() { let total_tasks: u64 = 26; let mut app = TaskwarriorTuiApp::new().unwrap(); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), total_tasks as usize); assert_eq!(app.current_context_filter, ""); let now = Local::now(); let now = TimeZone::from_utc_datetime(now.offset(), &now.naive_utc()); let mut command = Command::new("task"); command.arg("add"); let tomorrow = now + chrono::Duration::days(1); let message = format!( "'new task for testing tomorrow' due:{:04}-{:02}-{:02}", tomorrow.year(), tomorrow.month(), tomorrow.day(), ); let shell = message.as_str().replace("'", "\\'"); let cmd = shlex::split(&shell).unwrap(); for s in cmd { command.arg(&s); } let output = command.output().unwrap(); let s = String::from_utf8_lossy(&output.stdout); let re = Regex::new(r"^Created task (?P\d+).\n$").unwrap(); let caps = re.captures(&s); if caps.is_none() { let s = String::from_utf8_lossy(&output.stderr); assert!(false); } let caps = re.captures(&s).unwrap(); let task_id = caps["task_id"].parse::().unwrap(); assert_eq!(task_id, total_tasks + 1); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), (total_tasks + 1) as usize); assert_eq!(app.current_context_filter, ""); let task = app.task_by_id(task_id).unwrap(); for s in &[ "DUE", "MONTH", "PENDING", "QUARTER", "TOMORROW", "UDA", "UNBLOCKED", "YEAR", ] { assert!(task.tags().unwrap().contains(&s.to_string())); } let output = Command::new("task") .arg("rc.confirmation=off") .arg("undo") .output() .unwrap(); let mut app = TaskwarriorTuiApp::new().unwrap(); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), total_tasks as usize); assert_eq!(app.current_context_filter, ""); } fn test_task_earlier_today() { let total_tasks: u64 = 26; let mut app = TaskwarriorTuiApp::new().unwrap(); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), total_tasks as usize); assert_eq!(app.current_context_filter, ""); let now = Local::now(); let now = TimeZone::from_utc_datetime(now.offset(), &now.naive_utc()); let mut command = Command::new("task"); command.arg("add"); let message = "'new task for testing earlier today' due:now"; let shell = message.replace("'", "\\'"); let cmd = shlex::split(&shell).unwrap(); for s in cmd { command.arg(&s); } let output = command.output().unwrap(); let s = String::from_utf8_lossy(&output.stdout); let re = Regex::new(r"^Created task (?P\d+).\n$").unwrap(); let caps = re.captures(&s).unwrap(); let task_id = caps["task_id"].parse::().unwrap(); assert_eq!(task_id, total_tasks + 1); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), (total_tasks + 1) as usize); assert_eq!(app.current_context_filter, ""); let task = app.task_by_id(task_id).unwrap(); for s in &[ "DUE", "DUETODAY", "MONTH", "OVERDUE", "PENDING", "QUARTER", "TODAY", "UDA", "UNBLOCKED", "YEAR", ] { assert!(task.tags().unwrap().contains(&s.to_string())); } let output = Command::new("task") .arg("rc.confirmation=off") .arg("undo") .output() .unwrap(); let mut app = TaskwarriorTuiApp::new().unwrap(); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), total_tasks as usize); assert_eq!(app.current_context_filter, ""); } fn test_task_later_today() { let total_tasks: u64 = 26; let mut app = TaskwarriorTuiApp::new().unwrap(); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), total_tasks as usize); assert_eq!(app.current_context_filter, ""); let now = Local::now(); let now = TimeZone::from_utc_datetime(now.offset(), &now.naive_utc()); let mut command = Command::new("task"); command.arg("add"); let message = format!( "'new task for testing later today' due:'{:04}-{:02}-{:02}T{:02}:{:02}:{:02}'", now.year(), now.month(), now.day(), now.hour(), now.minute() + 1, now.second(), ); let shell = message.as_str().replace("'", "\\'"); let cmd = shlex::split(&shell).unwrap(); for s in cmd { command.arg(&s); } let output = command.output().unwrap(); let s = String::from_utf8_lossy(&output.stdout); let re = Regex::new(r"^Created task (?P\d+).\n$").unwrap(); let caps = re.captures(&s).unwrap(); let task_id = caps["task_id"].parse::().unwrap(); assert_eq!(task_id, total_tasks + 1); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), (total_tasks + 1) as usize); assert_eq!(app.current_context_filter, ""); let task = app.task_by_id(task_id).unwrap(); for s in &[ "DUE", "DUETODAY", "MONTH", "PENDING", "QUARTER", "TODAY", "UDA", "UNBLOCKED", "YEAR", ] { assert!(task.tags().unwrap().contains(&s.to_string())); } let output = Command::new("task") .arg("rc.confirmation=off") .arg("undo") .output() .unwrap(); let mut app = TaskwarriorTuiApp::new().unwrap(); assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), total_tasks as usize); assert_eq!(app.current_context_filter, ""); } fn test_draw_empty_task_report() { let test_case = |expected: &Buffer| { let mut app = TaskwarriorTuiApp::new().unwrap(); app.task_report_next(); app.context_next(); let total_tasks: u64 = 0; assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), total_tasks as usize); assert_eq!(app.current_context_filter, ""); let now = Local::now(); let now = TimeZone::from_utc_datetime(now.offset(), &now.naive_utc()); app.update(true).unwrap(); let backend = TestBackend::new(50, 15); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| { app.draw(f); app.draw(f); }) .unwrap(); assert_eq!(terminal.backend().size().unwrap(), expected.area); terminal.backend().assert_buffer(expected); }; let mut expected = Buffer::with_lines(vec![ "╭Task|Calendar───────────────────────────────────╮", "│ │", "│ │", "│ │", "│ │", "╰────────────────────────────────────────────────╯", "╭Task not found──────────────────────────────────╮", "│ │", "│ │", "│ │", "│ │", "╰────────────────────────────────────────────────╯", "╭Filter Tasks────────────────────────────────────╮", "│status:pending -private │", "╰────────────────────────────────────────────────╯", ]); for i in 1..=4 { // Task expected .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::BOLD)); } for i in 6..=13 { // Calendar expected .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::DIM)); } test_case(&expected); } fn test_draw_task_report_with_extended_modify_command() { let test_case = |expected1: &Buffer, expected2: &Buffer| { let mut app = TaskwarriorTuiApp::new().unwrap(); let total_tasks: u64 = 26; assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), total_tasks as usize); assert_eq!(app.current_context_filter, ""); let now = Local::now(); let now = TimeZone::from_utc_datetime(now.offset(), &now.naive_utc()); app.mode = AppMode::TaskModify; match app.task_table_state.mode() { TableMode::SingleSelection => match app.task_current() { Some(t) => { let s = format!("{} ", t.description()); app.modify.update(&s, s.as_str().len()) } None => app.modify.update("", 0), }, TableMode::MultipleSelection => app.modify.update("", 0), } app.update(true).unwrap(); let backend = TestBackend::new(25, 3); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| { let rects = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref()) .split(f.size()); let position = app.get_position(&app.modify); f.set_cursor( std::cmp::min( rects[1].x + position as u16 + 1, rects[1].x + rects[1].width.saturating_sub(2), ), rects[1].y + 1, ); f.render_widget(Clear, rects[1]); let selected = app.current_selection; let task_ids = if app.tasks.is_empty() { vec!["0".to_string()] } else { match app.task_table_state.mode() { TableMode::SingleSelection => { vec![app.tasks[selected].id().unwrap_or_default().to_string()] } TableMode::MultipleSelection => { let mut tids = vec![]; for uuid in app.marked.iter() { if let Some(t) = app.task_by_uuid(*uuid) { tids.push(t.id().unwrap_or_default().to_string()); } } tids } } }; let label = if task_ids.len() > 1 { format!("Modify Tasks {}", task_ids.join(",")) } else { format!("Modify Task {}", task_ids.join(",")) }; app.draw_command( f, rects[1], app.modify.as_str(), Span::styled(label, Style::default().add_modifier(Modifier::BOLD)), position, true, ); }) .unwrap(); assert_eq!(terminal.backend().size().unwrap(), expected1.area); terminal.backend().assert_buffer(expected1); app.modify.move_home(); terminal .draw(|f| { let rects = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref()) .split(f.size()); let position = app.get_position(&app.modify); f.set_cursor( std::cmp::min( rects[1].x + position as u16 + 1, rects[1].x + rects[1].width.saturating_sub(2), ), rects[1].y + 1, ); f.render_widget(Clear, rects[1]); let selected = app.current_selection; let task_ids = if app.tasks.is_empty() { vec!["0".to_string()] } else { match app.task_table_state.mode() { TableMode::SingleSelection => { vec![app.tasks[selected].id().unwrap_or_default().to_string()] } TableMode::MultipleSelection => { let mut tids = vec![]; for uuid in app.marked.iter() { if let Some(t) = app.task_by_uuid(*uuid) { tids.push(t.id().unwrap_or_default().to_string()); } } tids } } }; let label = if task_ids.len() > 1 { format!("Modify Tasks {}", task_ids.join(",")) } else { format!("Modify Task {}", task_ids.join(",")) }; app.draw_command( f, rects[1], app.modify.as_str(), Span::styled(label, Style::default().add_modifier(Modifier::BOLD)), position, true, ); }) .unwrap(); assert_eq!(terminal.backend().size().unwrap(), expected2.area); terminal.backend().assert_buffer(expected2); }; let mut expected1 = Buffer::with_lines(vec![ "╭Modify Task 10─────────╮", "│based on your .taskrc │", "╰───────────────────────╯", ]); let mut expected2 = Buffer::with_lines(vec![ "╭Modify Task 10─────────╮", "│Support color for tasks│", "╰───────────────────────╯", ]); for i in 1..=14 { // Task expected1 .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::BOLD)); expected2 .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::BOLD)); } test_case(&expected1, &expected2); } fn test_draw_task_report() { let test_case = |expected: &Buffer| { let mut app = TaskwarriorTuiApp::new().unwrap(); app.task_report_next(); app.context_next(); let total_tasks: u64 = 26; assert!(app.get_context().is_ok()); assert!(app.update(true).is_ok()); assert_eq!(app.tasks.len(), total_tasks as usize); assert_eq!(app.current_context_filter, ""); let now = Local::now(); let now = TimeZone::from_utc_datetime(now.offset(), &now.naive_utc()); let mut command = Command::new("task"); command.arg("add"); let message = "'new task 1 for testing draw' priority:U"; let shell = message.replace("'", "\\'"); let cmd = shlex::split(&shell).unwrap(); for s in cmd { command.arg(&s); } let output = command.output().unwrap(); let s = String::from_utf8_lossy(&output.stdout); let re = Regex::new(r"^Created task (?P\d+).\n$").unwrap(); let caps = re.captures(&s).unwrap(); let task_id = caps["task_id"].parse::().unwrap(); assert_eq!(task_id, total_tasks + 1); let mut command = Command::new("task"); command.arg("add"); let message = "'new task 2 for testing draw' priority:U +none"; let shell = message.replace("'", "\\'"); let cmd = shlex::split(&shell).unwrap(); for s in cmd { command.arg(&s); } let output = command.output().unwrap(); let s = String::from_utf8_lossy(&output.stdout); let re = Regex::new(r"^Created task (?P\d+).\n$").unwrap(); let caps = re.captures(&s).unwrap(); let task_id = caps["task_id"].parse::().unwrap(); assert_eq!(task_id, total_tasks + 2); app.task_report_next(); app.task_report_previous(); app.task_report_next_page(); app.task_report_previous_page(); app.task_report_bottom(); app.task_report_top(); app.update(true).unwrap(); let backend = TestBackend::new(50, 15); let mut terminal = Terminal::new(backend).unwrap(); app.task_report_show_info = !app.task_report_show_info; terminal .draw(|f| { app.draw(f); app.draw(f); }) .unwrap(); app.task_report_show_info = !app.task_report_show_info; terminal .draw(|f| { app.draw(f); app.draw(f); }) .unwrap(); let output = Command::new("task") .arg("rc.confirmation=off") .arg("undo") .output() .unwrap(); let output = Command::new("task") .arg("rc.confirmation=off") .arg("undo") .output() .unwrap(); assert_eq!(terminal.backend().size().unwrap(), expected.area); terminal.backend().assert_buffer(expected); }; let mut expected = Buffer::with_lines(vec![ "╭Task|Calendar───────────────────────────────────╮", "│ ID Age Deps P Projec Tag Due Descrip Urg │", "│ │", "│• 27 0s U new ta… 15.00│", "│ 28 0s U none new ta… 15.00│", "╰────────────────────────────────────────────────╯", "╭Task 27─────────────────────────────────────────╮", "│ │", "│Name Value │", "│------------- ----------------------------------│", "│ID 27 │", "╰────────────────────────────────────────────────╯", "╭Filter Tasks────────────────────────────────────╮", "│status:pending -private │", "╰────────────────────────────────────────────────╯", ]); for i in 1..=4 { // Task expected .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::BOLD)); } for i in 6..=13 { // Calendar expected .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::DIM)); } for r in &[ 1..=4, // ID 6..=8, // Age 10..=13, // Deps 15..=15, // P 17..=22, // Projec 24..=30, // Tag 32..=34, // Due 36..=42, // Descr 44..=48, // Urg ] { for i in r.clone().into_iter() { expected .get_mut(i, 1) .set_style(Style::default().add_modifier(Modifier::UNDERLINED)); } } for i in 1..expected.area().width - 1 { expected.get_mut(i, 3).set_style( Style::default() .fg(Color::Indexed(1)) .bg(Color::Reset) .add_modifier(Modifier::BOLD), ); } for i in 1..expected.area().width - 1 { expected .get_mut(i, 4) .set_style(Style::default().fg(Color::Indexed(1)).bg(Color::Indexed(4))); } test_case(&expected); } fn test_draw_calendar() { let test_case = |expected: &Buffer| { let mut app = TaskwarriorTuiApp::new().unwrap(); app.task_report_next(); app.context_next(); app.update(true).unwrap(); app.calendar_year = 2020; app.mode = AppMode::Calendar; let backend = TestBackend::new(50, 15); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| { app.draw(f); app.draw(f); }) .unwrap(); assert_eq!(terminal.backend().size().unwrap(), expected.area); terminal.backend().assert_buffer(expected); }; let mut expected = Buffer::with_lines(vec![ "╭Task|Calendar───────────────────────────────────╮", "│ │", "│ 2020 │", "│ │", "│ January February │", "│ Su Mo Tu We Th Fr Sa Su Mo Tu We Th Fr Sa │", "│ 1 2 3 4 1 │", "│ 5 6 7 8 9 10 11 2 3 4 5 6 7 8 │", "│ 12 13 14 15 16 17 18 9 10 11 12 13 14 15 │", "│ 19 20 21 22 23 24 25 16 17 18 19 20 21 22 │", "│ 26 27 28 29 30 31 23 24 25 26 27 28 29 │", "│ │", "│ │", "│ │", "╰────────────────────────────────────────────────╯", ]); for i in 1..=4 { // Task expected .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::DIM)); } for i in 6..=13 { // Calendar expected .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::BOLD)); } for i in 1..=48 { expected .get_mut(i, 2) .set_style(Style::default().add_modifier(Modifier::UNDERLINED)); } for i in 3..=22 { expected.get_mut(i, 4).set_style(Style::default().bg(Color::Black)); } for i in 25..=44 { expected.get_mut(i, 4).set_style(Style::default().bg(Color::Black)); } for i in 3..=22 { expected .get_mut(i, 5) .set_style(Style::default().bg(Color::Black).add_modifier(Modifier::UNDERLINED)); } for i in 25..=44 { expected .get_mut(i, 5) .set_style(Style::default().bg(Color::Black).add_modifier(Modifier::UNDERLINED)); } test_case(&expected); } fn test_draw_help_popup() { let test_case = |expected: &Buffer| { let mut app = TaskwarriorTuiApp::new().unwrap(); app.mode = AppMode::TaskHelpPopup; app.task_report_next(); app.context_next(); app.update(true).unwrap(); let backend = TestBackend::new(40, 12); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| { app.draw_help_popup(f, 100, 100); }) .unwrap(); assert_eq!(terminal.backend().size().unwrap(), expected.area); terminal.backend().assert_buffer(expected); }; let mut expected = Buffer::with_lines(vec![ "╭Help──────────────────────────────────╮", "│# Default Keybindings │", "│ │", "│Keybindings: │", "│ │", "│ Esc: │", "│ │", "│ ]: Next view │", "│ │", "│ [: Previous view │", "│ │", "╰──────────────────────────────────────╯", ]); for i in 1..=4 { // Calendar expected .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::BOLD)); } test_case(&expected); } fn test_draw_context_menu() { let test_case = |expected: &Buffer| { let mut app = TaskwarriorTuiApp::new().unwrap(); app.mode = AppMode::TaskContextMenu; app.task_report_next(); app.context_next(); app.update(true).unwrap(); let backend = TestBackend::new(80, 10); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| { app.draw_context_menu(f, 100, 100); app.draw_context_menu(f, 100, 100); }) .unwrap(); assert_eq!(terminal.backend().size().unwrap(), expected.area); terminal.backend().assert_buffer(expected); }; let mut expected = Buffer::with_lines(vec![ "╭Context───────────────────────────────────────────────────────────────────────╮", "│Name Description Active│", "│ │", "│• none yes │", "│ finance +finance -private no │", "│ personal +personal -private no │", "│ work -personal -private no │", "│ │", "│ │", "╰──────────────────────────────────────────────────────────────────────────────╯", ]); for i in 1..=7 { // Task expected .get_mut(i, 0) .set_style(Style::default().add_modifier(Modifier::BOLD)); } for i in 1..=10 { // Task expected .get_mut(i, 1) .set_style(Style::default().add_modifier(Modifier::UNDERLINED)); } for i in 12..=71 { // Task expected .get_mut(i, 1) .set_style(Style::default().add_modifier(Modifier::UNDERLINED)); } for i in 73..=78 { // Task expected .get_mut(i, 1) .set_style(Style::default().add_modifier(Modifier::UNDERLINED)); } for i in 1..=78 { // Task expected .get_mut(i, 3) .set_style(Style::default().add_modifier(Modifier::BOLD)); } test_case(&expected); } }