#![forbid(unsafe_code)]
use std::fs;
use std::io::{self, Stdout, stdout};
use std::path::Path;
use chrono::{Local, NaiveDate};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout},
prelude::Stylize,
symbols::border,
text::Line,
widgets::{Block, Borders, Padding, Paragraph, TableState},
};
use thiserror::Error;
use tui_dialog::Dialog;
pub mod config;
pub mod count;
pub mod files;
pub mod modes;
pub mod priority;
pub mod tasks;
#[cfg(test)]
mod test_helpers;
use config::{Config, ConfigError};
use count::TaskCount;
use files::{FileStatus, FileWithTasks, edit};
use modes::{
Mode,
config_mode::{self, ConfigMode, ConfigSetting},
evergreen_mode::{self, EvergreenMode},
files_mode::{self, DueFilter, FilesMode},
help_mode::{
self, CHANGELOG, EXAMPLE1, EXAMPLE2, EXAMPLE3, EXAMPLE4, EXAMPLE5, EXAMPLE6, Examples,
HelpMode, USAGE,
},
log_mode::{self, GraphKind, LogMode, LogSubMode},
tasks_mode::{self, RecurringStatus, TasksMode},
};
use priority::Priority;
use tasks::{CompletionStatus, RichTask};
pub const SIDEBAR_SIZE: u16 = 38;
pub const TASK_IDENTIFIERS: &[&str] = &[
"[ ]", "- [ ]", "[]", "- []", "[x]", "- [x]", "[X]", "- [X]", "[/]", "- [/]", "[\\]", "- [\\]",
];
#[derive(Error, Debug)]
pub enum TfError {
#[error("IoError: {0}")]
Io(#[from] io::Error),
#[error("System time error")]
SystemTime(#[from] std::time::SystemTimeError),
#[error("Parsing error")]
ParseInt(#[from] std::num::ParseIntError),
#[error("No matching priority.")]
NoMatchingPriority,
#[error("Path does not exist.")]
NoSuchPath,
#[error("{0}")]
ConfigError(#[from] config::ConfigError),
#[error("Error exporting upcoming tasks: {0}")]
ExportError(String),
#[error("Error copying upcoming tasks to clipboard.")]
ArboardError(#[from] arboard::Error),
}
pub struct App {
pub action: Action,
pub mode: Mode,
pub config: Config,
pub filemode: FilesMode,
pub logmode: LogMode,
pub configmode: ConfigMode,
pub helpmode: HelpMode,
pub evergreenmode: EvergreenMode,
pub taskmode: TasksMode,
pub current_date: NaiveDate,
pub message_to_user: Option<String>,
pub exit: bool,
}
impl App {
pub fn mode_init(&mut self, mode: Mode) -> Result<(), TfError> {
self.mode = mode;
match self.mode {
Mode::Files => {
files_mode::refine_files(self)?;
self.filemode.line_offset = 0;
}
Mode::Tasks => {
self.filemode.files = FileWithTasks::collect(&self.config)?;
self.taskmode.data = RichTask::collect(self)?;
self.taskmode.dates = tasks_mode::Dates::default();
if self.taskmode.data.is_empty() {
self.taskmode.table.select(None);
} else if self.taskmode.table.selected() == Some(self.taskmode.data.len()) {
self.taskmode
.table
.select(Some(self.taskmode.data.len() - 1));
}
}
Mode::Log => {
self.logmode.submode = LogSubMode::Table;
if let Some(v) = self.logmode.data.first_mut() {
let active_count =
TaskCount::extract(&FileWithTasks::collect(&self.config)?, &self.mode);
*v = active_count;
}
}
Mode::Help => {
self.helpmode.line_offset = 0;
}
Mode::Evergreen => {
if self.config.evergreen_file != Path::new("").to_path_buf() {
self.evergreenmode.line_offset = 0;
}
}
Mode::Config => {
self.configmode.table.select(Some(0));
self.configmode.setting = ConfigSetting::get(0);
self.filemode.line_offset = 0;
}
};
Ok(())
}
}
#[non_exhaustive]
#[derive(Clone, PartialEq)]
pub enum Action {
Wait,
SwitchMode(Mode),
EditFile,
EditEvergreenFile,
EditConfigOption,
ResetConfigOption,
ExportUpcomingTasks,
FilterPriority(Priority),
FilterComplete,
FilterArchived,
FilterStale,
FilterRecurring,
FilterTag,
FilterTerm,
FilterDueDate,
FilterOverdue,
UseFileModeTagDialog(KeyCode),
UseFileModeSearchDialog(KeyCode),
UseTaskModeTagDialog(KeyCode),
UseTaskModeSearchDialog(KeyCode),
UseConfigModeDialog(KeyCode),
ClearFilters,
ToggleGraph,
ToggleGraphByCompletion,
ToggleGraphByRecurring,
ToggleVersionAndUsage,
ToggleFileExamples(Examples),
ScrollUp,
ScrollDown,
PageUp,
PageDown,
NextRow,
PrevRow,
NextFile,
PrevFile,
GoToTop,
Exit,
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if let Some(v) = args.get(1) {
if ["help", "--help", "-h"].contains(&v.as_str()) {
println!("tf {} ", std::env!("CARGO_PKG_VERSION"));
println!(
"Run taskfinder without any arguments (`tf`) and then press `h` within the TUI to view help."
);
return;
} else if ["version", "--version", "-V", "-v"].contains(&v.as_str()) {
println!("tf {} ", std::env!("CARGO_PKG_VERSION"));
return;
}
}
let config = match Config::create_or_get() {
Ok(v) => v,
Err(e) => {
eprintln!("Error configuring app: {e}");
return;
}
};
if config.priority_markers.len() != 5 {
eprintln!(
"{} Please fix before continuing.",
ConfigError::IncorrectNumberOfPriorityMarkers
);
return;
}
let current_active_count = match &FileWithTasks::collect(&config) {
Ok(v) => TaskCount::extract(v, &Mode::Log),
Err(e) => {
eprintln!("Error getting current active task count from log: {e}");
return;
}
};
if let Err(e) = TaskCount::log(&config, current_active_count.clone()) {
eprintln!("Error adding current task count to log: {e}");
return;
}
let num_tasks_log = match fs::read_to_string(config.num_tasks_log.clone()) {
Ok(v) => v,
Err(e) => {
eprintln!("Error reading task log: {e}");
return;
}
};
let mut task_counts = num_tasks_log
.lines()
.map(TaskCount::new)
.collect::<Vec<TaskCount>>();
task_counts.push(current_active_count);
task_counts.reverse();
let mut app = App {
action: Action::Wait,
mode: config.start_mode,
config: config.clone(),
filemode: FilesMode {
current_file: 0,
files: vec![],
completed: config.include_completed,
due: DueFilter::Any,
file_status: FileStatus::Active,
priority: None,
tag_dialog: Dialog::default(),
search_dialog: Dialog::default(),
line_offset: 0,
},
logmode: LogMode {
submode: LogSubMode::Table,
data: task_counts,
table: TableState::default().with_selected(0),
},
configmode: ConfigMode {
table: TableState::default().with_selected(0),
setting: None,
dialog: Dialog::default(),
},
helpmode: HelpMode {
line_offset: 0,
help_text: USAGE.to_string(),
},
evergreenmode: EvergreenMode { line_offset: 0 },
taskmode: TasksMode {
data: vec![],
table: TableState::default().with_selected(0),
file_status: FileStatus::Active,
completion_status: CompletionStatus::Incomplete,
recurring_status: RecurringStatus::All,
tag_dialog: Dialog::default(),
search_dialog: Dialog::default(),
dates: tasks_mode::Dates::default(),
},
current_date: Local::now().date_naive(),
message_to_user: None,
exit: false,
};
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
Ok(v) => v,
Err(e) => {
eprintln!("Error creating new terminal interface: {e}");
return;
}
};
if let Err(e) = execute!(terminal.backend_mut(), EnterAlternateScreen) {
eprintln!("Errow switching to alternate screen: {e}");
return;
}
if let Err(e) = enable_raw_mode() {
eprintln!("Error entering raw mode: {e}");
return;
}
if let Err(e) = run(&mut app, &mut terminal) {
eprintln!("Error running app: {e}");
return;
}
if let Err(e) = disable_raw_mode() {
eprintln!("Error disabling raw mode: {e}");
return;
}
if let Err(e) = execute!(terminal.backend_mut(), LeaveAlternateScreen) {
eprintln!("Error leaving alternate screen: {e}");
}
}
fn run(app: &mut App, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), TfError> {
app.mode_init(app.mode)?;
while !app.exit {
terminal.draw(|frame| render(frame, app))?;
let now_date = Local::now().date_naive();
if now_date > app.current_date {
let files = &FileWithTasks::collect(&app.config)?;
let active_count = TaskCount::extract(files, &Mode::Log);
TaskCount::log(&app.config, active_count.clone())?;
app.logmode.data.remove(0);
app.current_date = now_date;
app.logmode.data.push(active_count.clone());
app.logmode.data.push(active_count);
app.logmode.data.sort_by_key(|t| t.date);
app.logmode.data.reverse();
}
app.action = match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
app.message_to_user = None;
if app.filemode.tag_dialog.open {
Action::UseFileModeTagDialog(key_event.code)
} else if app.filemode.search_dialog.open {
Action::UseFileModeSearchDialog(key_event.code)
} else if app.taskmode.tag_dialog.open {
Action::UseTaskModeTagDialog(key_event.code)
} else if app.taskmode.search_dialog.open {
Action::UseTaskModeSearchDialog(key_event.code)
} else if app.configmode.dialog.open {
Action::UseConfigModeDialog(key_event.code)
} else {
match key_event.code {
KeyCode::Char('q') => Action::Exit,
KeyCode::Char('f') => Action::SwitchMode(Mode::Files),
KeyCode::Char('t') => Action::SwitchMode(Mode::Tasks),
KeyCode::Char('l') => Action::SwitchMode(Mode::Log),
KeyCode::Char('x') => Action::SwitchMode(Mode::Config),
KeyCode::Char('h') => Action::SwitchMode(Mode::Help),
KeyCode::Char('e') => Action::SwitchMode(Mode::Evergreen),
_ => match app.mode {
Mode::Files => match key_event.code {
KeyCode::Char('j') | KeyCode::Down => Action::ScrollDown,
KeyCode::Char('k') | KeyCode::Up => Action::ScrollUp,
KeyCode::PageDown => Action::PageDown,
KeyCode::Char('d')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
Action::PageDown
}
KeyCode::PageUp => Action::PageUp,
KeyCode::Char('u')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
Action::PageUp
}
KeyCode::Char('n') | KeyCode::Right => Action::NextFile,
KeyCode::Char('p') | KeyCode::Left => Action::PrevFile,
KeyCode::Enter => Action::EditFile,
KeyCode::Char('1') => Action::FilterPriority(Priority::One),
KeyCode::Char('2') => Action::FilterPriority(Priority::Two),
KeyCode::Char('3') => Action::FilterPriority(Priority::Three),
KeyCode::Char('4') => Action::FilterPriority(Priority::Four),
KeyCode::Char('5') => Action::FilterPriority(Priority::Five),
KeyCode::Char('#') => Action::FilterTag,
KeyCode::Char('/') => Action::FilterTerm,
KeyCode::Char('c') => Action::FilterComplete,
KeyCode::Char('s') => Action::FilterStale,
KeyCode::Char('a') => Action::FilterArchived,
KeyCode::Char('d') => Action::FilterDueDate,
KeyCode::Char('o') => Action::FilterOverdue,
KeyCode::Esc => Action::ClearFilters,
_ => Action::Wait,
},
Mode::Tasks => match key_event.code {
KeyCode::Home => Action::GoToTop,
KeyCode::Char('j') | KeyCode::Down => Action::NextRow,
KeyCode::Char('k') | KeyCode::Up => Action::PrevRow,
KeyCode::Enter => Action::EditFile,
KeyCode::Char('c') => Action::FilterComplete,
KeyCode::Char('a') => Action::FilterArchived,
KeyCode::Char('s') => Action::FilterStale,
KeyCode::Char('#') => Action::FilterTag,
KeyCode::Char('/') => Action::FilterTerm,
KeyCode::Char('r') => Action::FilterRecurring,
KeyCode::Esc => Action::ClearFilters,
KeyCode::Char('X') => Action::ExportUpcomingTasks,
_ => Action::Wait,
},
Mode::Log => match &app.logmode.submode {
LogSubMode::Table => match key_event.code {
KeyCode::Char('j') | KeyCode::Down => Action::NextRow,
KeyCode::Char('k') | KeyCode::Up => Action::PrevRow,
KeyCode::PageDown => Action::PageDown,
KeyCode::Char('d')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
Action::PageDown
}
KeyCode::PageUp => Action::PageUp,
KeyCode::Char('u')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
Action::PageUp
}
KeyCode::Esc => Action::GoToTop,
KeyCode::Char('g') => Action::ToggleGraph,
_ => Action::Wait,
},
LogSubMode::Graph(_) => match key_event.code {
KeyCode::Char('g') => Action::ToggleGraph,
KeyCode::Char('c') => Action::ToggleGraphByCompletion,
KeyCode::Char('r') => Action::ToggleGraphByRecurring,
_ => Action::Wait,
},
},
Mode::Help => match key_event.code {
KeyCode::Char('j') | KeyCode::Down => Action::ScrollDown,
KeyCode::Char('k') | KeyCode::Up => Action::ScrollUp,
KeyCode::PageDown => Action::PageDown,
KeyCode::Char('d')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
Action::PageDown
}
KeyCode::PageUp => Action::PageUp,
KeyCode::Char('u')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
Action::PageUp
}
KeyCode::Char('v') => Action::ToggleVersionAndUsage,
KeyCode::Char('1') => {
Action::ToggleFileExamples(Examples::Example1)
}
KeyCode::Char('2') => {
Action::ToggleFileExamples(Examples::Example2)
}
KeyCode::Char('3') => {
Action::ToggleFileExamples(Examples::Example3)
}
KeyCode::Char('4') => {
Action::ToggleFileExamples(Examples::Example4)
}
KeyCode::Char('5') => {
Action::ToggleFileExamples(Examples::Example5)
}
KeyCode::Char('6') => {
Action::ToggleFileExamples(Examples::Example6)
}
_ => Action::Wait,
},
Mode::Evergreen => match key_event.code {
KeyCode::Char('j') | KeyCode::Down => Action::ScrollDown,
KeyCode::Char('k') | KeyCode::Up => Action::ScrollUp,
KeyCode::PageDown => Action::PageDown,
KeyCode::Char('d')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
Action::PageDown
}
KeyCode::PageUp => Action::PageUp,
KeyCode::Char('u')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
Action::PageUp
}
KeyCode::Enter => Action::EditEvergreenFile,
_ => Action::Wait,
},
Mode::Config => match key_event.code {
KeyCode::Char('j') | KeyCode::Down => Action::NextRow,
KeyCode::Char('k') | KeyCode::Up => Action::PrevRow,
KeyCode::Enter => Action::EditConfigOption,
KeyCode::Char('r') => Action::ResetConfigOption,
_ => Action::Wait,
},
},
}
}
}
_ => Action::Wait,
};
match &app.action {
Action::Wait => (),
Action::SwitchMode(mode) => {
if app.mode != *mode {
app.mode_init(*mode)?;
}
}
Action::Exit => app.exit = true,
Action::ScrollDown => match app.mode {
Mode::Files => app.filemode.line_offset += 1,
Mode::Help => app.helpmode.line_offset += 1,
Mode::Evergreen => app.evergreenmode.line_offset += 1,
_ => (),
},
Action::ScrollUp => match app.mode {
Mode::Files => {
if app.filemode.line_offset > 0 {
app.filemode.line_offset -= 1;
}
}
Mode::Help => {
if app.helpmode.line_offset > 0 {
app.helpmode.line_offset -= 1;
}
}
Mode::Evergreen => {
if app.evergreenmode.line_offset > 0 {
app.evergreenmode.line_offset -= 1;
}
}
_ => (),
},
Action::PageDown => match app.mode {
Mode::Files => app.filemode.line_offset += 20,
Mode::Help => app.helpmode.line_offset += 20,
Mode::Evergreen => app.evergreenmode.line_offset += 20,
Mode::Log => {
let i = match app.logmode.table.selected() {
Some(i) => {
if i >= app.logmode.data.len() - 30 {
0
} else {
i + 30
}
}
None => 0,
};
app.logmode.table.select(Some(i));
}
_ => (),
},
Action::PageUp => match app.mode {
Mode::Files => {
if app.filemode.line_offset >= 20 {
app.filemode.line_offset -= 20;
} else {
app.filemode.line_offset = 0;
}
}
Mode::Help => {
if app.helpmode.line_offset >= 20 {
app.helpmode.line_offset -= 20;
} else {
app.helpmode.line_offset = 0;
}
}
Mode::Evergreen => {
if app.evergreenmode.line_offset >= 20 {
app.evergreenmode.line_offset -= 20;
} else {
app.evergreenmode.line_offset = 0;
}
}
Mode::Log => {
let i = match app.logmode.table.selected() {
Some(i) => {
if i == 0 {
app.logmode.data.len() - 30
} else {
i.saturating_sub(30)
}
}
None => 0,
};
app.logmode.table.select(Some(i));
}
_ => (),
},
Action::EditEvergreenFile => {
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
edit(app.config.evergreen_file.clone(), None)?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
app.evergreenmode.line_offset = 0;
}
Action::EditConfigOption => {
if let Some(num) = app.configmode.table.selected()
&& let Some(cs) = ConfigSetting::get(num)
{
match cs {
ConfigSetting::Path => {
app.configmode.dialog.open = true;
app.configmode.dialog.working_input =
app.config.path.to_string_lossy().to_string();
}
ConfigSetting::FileExtensions => {
app.configmode.dialog.open = true;
app.configmode.dialog.working_input =
app.config.file_extensions.join(",");
}
ConfigSetting::DaysToStale => {
app.configmode.dialog.open = true;
app.configmode.dialog.working_input =
app.config.days_to_stale.to_string();
}
ConfigSetting::PriorityMarkers => {
app.configmode.dialog.open = true;
app.configmode.dialog.working_input =
app.config.priority_markers.join(",");
}
ConfigSetting::EvergreenFile => {
app.configmode.dialog.open = true;
app.configmode.dialog.working_input =
app.config.evergreen_file.to_string_lossy().to_string();
}
ConfigSetting::IncludeComplete => {
app.config.include_completed = !app.config.include_completed;
app.message_to_user = Some("Setting updated.".to_string());
app.config.save()?;
}
ConfigSetting::StartMode => {
app.configmode.dialog.working_input =
format!("{:?}", app.config.start_mode);
match app.config.start_mode {
Mode::Files => app.config.start_mode = Mode::Tasks,
Mode::Tasks => app.config.start_mode = Mode::Log,
Mode::Log => app.config.start_mode = Mode::Help,
Mode::Help => {
if app.config.evergreen_file != Path::new("").to_path_buf() {
app.config.start_mode = Mode::Evergreen;
} else {
app.config.start_mode = Mode::Config;
}
}
Mode::Evergreen => app.config.start_mode = Mode::Config,
Mode::Config => {
app.config.start_mode = Mode::Files;
}
}
app.message_to_user = Some("Setting updated.".to_string());
app.config.save()?;
}
ConfigSetting::OverdueBlink => {
app.config.overdue_blink = !app.config.overdue_blink;
app.message_to_user = Some("Setting updated.".to_string());
app.config.save()?;
}
}
};
}
Action::ResetConfigOption => {
if let Some(num) = app.configmode.table.selected()
&& let Some(cs) = ConfigSetting::get(num)
{
match cs {
ConfigSetting::Path => {
app.config.path = Config::default_files_path()?;
files_mode::refine_files(app)?;
}
ConfigSetting::FileExtensions => {
app.config.file_extensions = Config::default_file_extensions();
files_mode::refine_files(app)?;
}
ConfigSetting::DaysToStale => {
app.config.days_to_stale = config::DEFAULT_DAYS_TO_STALE;
}
ConfigSetting::IncludeComplete => {
app.config.include_completed = config::DEFAULT_INCLUDE_COMPLETED;
}
ConfigSetting::PriorityMarkers => {
app.config.priority_markers = Config::default_priority_markers();
}
ConfigSetting::EvergreenFile => {
app.config.evergreen_file = Config::default_evergreen_file();
}
ConfigSetting::StartMode => {
app.config.start_mode = Mode::Files;
}
ConfigSetting::OverdueBlink => {
app.config.overdue_blink = config::DEFAULT_OVERDUE_BLINK;
}
}
app.config.save()?;
app.message_to_user = Some("Reset setting to default.".to_string());
};
}
Action::ExportUpcomingTasks => match tasks_mode::export_upcoming_tasks(app) {
Ok(()) => {
app.message_to_user = Some("Upcoming tasks successfully exported.".to_string())
}
Err(e) => app.message_to_user = Some(e.to_string()),
},
Action::ToggleVersionAndUsage => {
let version = std::env!("CARGO_PKG_VERSION");
let vcl = format!("Taskfinder version {version}\n\n{CHANGELOG}\n",);
if app.helpmode.help_text == vcl {
app.helpmode.help_text = USAGE.to_string();
} else {
app.helpmode.help_text = vcl;
}
app.helpmode.line_offset = 0;
}
Action::ToggleFileExamples(example) => {
match example {
Examples::Example1 => {
if app.helpmode.help_text == EXAMPLE1 {
app.helpmode.help_text = USAGE.to_string();
} else {
app.helpmode.help_text = EXAMPLE1.to_string();
}
}
Examples::Example2 => {
if app.helpmode.help_text == EXAMPLE2 {
app.helpmode.help_text = USAGE.to_string();
} else {
app.helpmode.help_text = EXAMPLE2.to_string();
}
}
Examples::Example3 => {
if app.helpmode.help_text == EXAMPLE3 {
app.helpmode.help_text = USAGE.to_string();
} else {
app.helpmode.help_text = EXAMPLE3.to_string();
}
}
Examples::Example4 => {
if app.helpmode.help_text == EXAMPLE4 {
app.helpmode.help_text = USAGE.to_string();
} else {
app.helpmode.help_text = EXAMPLE4.to_string();
}
}
Examples::Example5 => {
if app.helpmode.help_text == EXAMPLE5 {
app.helpmode.help_text = USAGE.to_string();
} else {
app.helpmode.help_text = EXAMPLE5.to_string();
}
}
Examples::Example6 => {
if app.helpmode.help_text == EXAMPLE6 {
app.helpmode.help_text = USAGE.to_string();
} else {
app.helpmode.help_text = EXAMPLE6.to_string();
}
}
}
app.helpmode.line_offset = 0;
}
Action::NextRow => match app.mode {
Mode::Tasks => {
let i = match app.taskmode.table.selected() {
Some(i) => {
if i >= app.taskmode.data.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
app.taskmode.table.select(Some(i));
}
Mode::Log => {
let i = match app.logmode.table.selected() {
Some(i) => {
if i >= app.logmode.data.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
app.logmode.table.select(Some(i));
}
Mode::Config => {
let i = match app.configmode.table.selected() {
Some(i) => {
if i >= app.config.table_rows().len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
app.configmode.table.select(Some(i));
app.configmode.setting = ConfigSetting::get(i);
}
_ => (),
},
Action::PrevRow => match app.mode {
Mode::Tasks => {
let i = match app.taskmode.table.selected() {
Some(i) => {
if i == 0 {
app.taskmode.data.len() - 1
} else {
i - 1
}
}
None => 0,
};
app.taskmode.table.select(Some(i));
}
Mode::Log => {
let i = match app.logmode.table.selected() {
Some(i) => {
if i == 0 {
app.logmode.data.len() - 1
} else {
i - 1
}
}
None => 0,
};
app.logmode.table.select(Some(i));
}
Mode::Config => {
let i = match app.configmode.table.selected() {
Some(i) => {
if i == 0 {
app.config.table_rows().len() - 1
} else {
i - 1
}
}
None => 0,
};
app.configmode.table.select(Some(i));
app.configmode.setting = ConfigSetting::get(i);
}
_ => (),
},
Action::NextFile => {
if app.filemode.files.is_empty() {
app.filemode.current_file = 0;
} else if app.filemode.current_file == app.filemode.files.len() - 1 {
app.filemode.current_file = 0
} else {
app.filemode.current_file += 1;
}
app.filemode.line_offset = 0;
}
Action::PrevFile => {
if app.filemode.files.is_empty() {
app.filemode.current_file = 0;
} else if app.filemode.current_file == 0 {
app.filemode.current_file = app.filemode.files.len() - 1;
} else {
app.filemode.current_file -= 1;
}
app.filemode.line_offset = 0;
}
Action::ToggleGraph => {
app.logmode.submode = match app.logmode.submode {
LogSubMode::Graph(_) => LogSubMode::Table,
LogSubMode::Table => LogSubMode::Graph(GraphKind::All),
}
}
Action::ToggleGraphByCompletion => {
app.logmode.submode = match app.logmode.submode {
LogSubMode::Graph(GraphKind::All) => {
LogSubMode::Graph(GraphKind::CompleteAndIncomplete)
}
LogSubMode::Graph(GraphKind::CompleteAndIncomplete) => {
LogSubMode::Graph(GraphKind::Incomplete)
}
LogSubMode::Graph(GraphKind::Incomplete) => {
LogSubMode::Graph(GraphKind::Complete)
}
LogSubMode::Graph(GraphKind::Complete) => LogSubMode::Graph(GraphKind::All),
_ => LogSubMode::Graph(GraphKind::CompleteAndIncomplete),
}
}
Action::ToggleGraphByRecurring => {
app.logmode.submode = match app.logmode.submode {
LogSubMode::Graph(GraphKind::All) => {
LogSubMode::Graph(GraphKind::RecurringAndNonRecurring)
}
LogSubMode::Graph(GraphKind::RecurringAndNonRecurring) => {
LogSubMode::Graph(GraphKind::IncompleteByRecurring)
}
LogSubMode::Graph(GraphKind::IncompleteByRecurring) => {
LogSubMode::Graph(GraphKind::CompleteByRecurring)
}
LogSubMode::Graph(GraphKind::CompleteByRecurring) => {
LogSubMode::Graph(GraphKind::All)
}
_ => LogSubMode::Graph(GraphKind::RecurringAndNonRecurring),
}
}
Action::GoToTop => match app.mode {
Mode::Log => app.logmode.table = TableState::default().with_selected(0),
Mode::Tasks => app.taskmode.table = TableState::default().with_selected(0),
_ => (),
},
Action::FilterPriority(priority) => {
files_mode::filter_by_priority(app, *priority);
files_mode::refine_files(app)?;
app.filemode.line_offset = 0;
}
Action::FilterComplete => match app.mode {
Mode::Tasks => {
app.taskmode.completion_status = match app.taskmode.completion_status {
CompletionStatus::Incomplete => CompletionStatus::Completed,
CompletionStatus::Completed => CompletionStatus::Incomplete,
};
app.taskmode.data = RichTask::collect(app)?;
if !app.taskmode.data.is_empty() {
app.taskmode.table.select(Some(0));
}
}
Mode::Files => {
app.filemode.completed = !app.filemode.completed;
files_mode::refine_files(app)?;
app.filemode.line_offset = 0;
}
_ => (),
},
Action::FilterArchived => match app.mode {
Mode::Tasks => {
app.taskmode.file_status = match app.taskmode.file_status {
FileStatus::Stale => FileStatus::Active,
FileStatus::Active => FileStatus::Archived,
FileStatus::Archived => FileStatus::Active,
};
app.taskmode.data = RichTask::collect(app)?;
if !app.taskmode.data.is_empty() {
app.taskmode.table.select(Some(0));
}
}
Mode::Files => {
app.filemode.current_file = 0;
app.filemode.file_status = match app.filemode.file_status {
FileStatus::Archived => FileStatus::Active,
FileStatus::Stale => FileStatus::Archived,
FileStatus::Active => FileStatus::Archived,
};
files_mode::refine_files(app)?;
app.filemode.line_offset = 0;
}
_ => (),
},
Action::FilterStale => match app.mode {
Mode::Tasks => {
app.taskmode.file_status = match app.taskmode.file_status {
FileStatus::Stale => FileStatus::Active,
FileStatus::Active => FileStatus::Stale,
FileStatus::Archived => FileStatus::Stale,
};
app.taskmode.data = RichTask::collect(app)?;
if !app.taskmode.data.is_empty() {
app.taskmode.table.select(Some(0));
}
}
Mode::Files => {
app.filemode.current_file = 0;
app.filemode.file_status = match app.filemode.file_status {
FileStatus::Stale => FileStatus::Active,
FileStatus::Active => FileStatus::Stale,
FileStatus::Archived => FileStatus::Stale,
};
app.filemode.completed = true;
files_mode::refine_files(app)?;
app.filemode.line_offset = 0;
}
_ => (),
},
Action::FilterRecurring => {
app.taskmode.recurring_status = match app.taskmode.recurring_status {
RecurringStatus::All => RecurringStatus::Recurring,
RecurringStatus::Recurring => RecurringStatus::NonRecurring,
RecurringStatus::NonRecurring => RecurringStatus::All,
};
app.taskmode.data = RichTask::collect(app)?;
if !app.taskmode.data.is_empty() {
app.taskmode.table.select(Some(0));
}
}
Action::FilterDueDate => {
match app.filemode.due {
DueFilter::Any | DueFilter::OverDue => {
app.filemode.due = DueFilter::WithDueDate
}
DueFilter::WithDueDate => app.filemode.due = DueFilter::Any,
}
app.filemode.current_file = 0;
app.filemode.line_offset = 0;
files_mode::refine_files(app)?;
}
Action::FilterOverdue => {
match app.filemode.due {
DueFilter::Any | DueFilter::WithDueDate => {
app.filemode.due = DueFilter::OverDue
}
DueFilter::OverDue => app.filemode.due = DueFilter::Any,
}
app.filemode.current_file = 0;
app.filemode.line_offset = 0;
files_mode::refine_files(app)?;
}
Action::UseFileModeTagDialog(key_code) => {
app.filemode.tag_dialog.key_action(key_code);
if app.filemode.tag_dialog.submitted {
app.filemode.current_file = 0;
app.filemode.line_offset = 0;
app.mode = Mode::Files;
files_mode::refine_files(app)?;
}
}
Action::UseFileModeSearchDialog(key_code) => {
app.filemode.search_dialog.key_action(key_code);
if app.filemode.search_dialog.submitted {
app.filemode.current_file = 0;
app.filemode.line_offset = 0;
app.mode = Mode::Files;
files_mode::refine_files(app)?;
}
}
Action::UseTaskModeTagDialog(key_code) => {
app.taskmode.tag_dialog.key_action(key_code);
if app.taskmode.tag_dialog.submitted {
app.taskmode.tag_dialog.submitted_input = app
.taskmode
.tag_dialog
.submitted_input
.trim()
.to_lowercase();
app.taskmode.data = RichTask::collect(app)?;
if app.taskmode.data.is_empty() {
app.taskmode.table.select(None);
} else {
app.taskmode.table.select(Some(0));
}
}
}
Action::UseTaskModeSearchDialog(key_code) => {
app.taskmode.search_dialog.key_action(key_code);
if app.taskmode.search_dialog.submitted {
app.taskmode.search_dialog.submitted_input = app
.taskmode
.search_dialog
.submitted_input
.trim()
.to_lowercase();
app.taskmode.data = RichTask::collect(app)?;
if app.taskmode.data.is_empty() {
app.taskmode.table.select(None)
} else {
app.taskmode.table.select(Some(0))
}
}
}
Action::UseConfigModeDialog(key_code) => {
app.configmode.dialog.key_action(key_code);
if app.configmode.dialog.submitted {
match config_mode::submit_config_change(app) {
Ok(true) => app.message_to_user = Some("Setting updated.".to_string()),
Ok(false) => app.message_to_user = Some("No change made.".to_string()),
Err(e) => app.message_to_user = Some(format!("{e}")),
}
}
}
Action::EditFile => match app.mode {
Mode::Tasks => {
if !app.taskmode.data.is_empty()
&& let Some(v) = app.taskmode.table.selected()
{
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
edit(
app.taskmode.data[v].file_path.clone(),
Some(app.taskmode.data[v].task.line),
)?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
app.filemode.files = FileWithTasks::collect(&app.config)?;
app.taskmode.data = RichTask::collect(app)?;
if app.taskmode.data.is_empty() {
app.taskmode.table.select(None);
} else if app.taskmode.table.selected() == Some(app.taskmode.data.len()) {
app.taskmode.table.select(Some(app.taskmode.data.len() - 1));
}
}
}
Mode::Files => {
if !app.filemode.files.is_empty() {
let current_head =
app.filemode.files[app.filemode.current_file].head[0].clone();
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
edit(
app.filemode.files[app.filemode.current_file].file.clone(),
None,
)?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
files_mode::refine_files(app)?;
app.filemode.current_file = 0;
for (i, file) in app.filemode.files.iter().enumerate() {
if file.head[0] == current_head {
app.filemode.current_file = i
}
}
app.filemode.line_offset = 0;
}
}
_ => (),
},
Action::FilterTag => match app.mode {
Mode::Tasks => app.taskmode.tag_dialog.open = true,
Mode::Files => app.filemode.tag_dialog.open = true,
_ => (),
},
Action::FilterTerm => match app.mode {
Mode::Tasks => app.taskmode.search_dialog.open = true,
Mode::Files => app.filemode.search_dialog.open = true,
_ => (),
},
Action::ClearFilters => match app.mode {
Mode::Tasks => {
app.taskmode.file_status = FileStatus::Active;
app.taskmode.completion_status = CompletionStatus::Incomplete;
app.taskmode.tag_dialog.submitted_input.clear();
app.taskmode.search_dialog.submitted_input.clear();
app.taskmode.recurring_status = RecurringStatus::All;
app.taskmode.data = RichTask::collect(app)?;
if app.taskmode.data.is_empty() {
app.taskmode.table.select(None)
} else {
app.taskmode.table.select(Some(0))
}
}
Mode::Files => {
app.filemode.completed = app.config.include_completed;
app.filemode.due = DueFilter::Any;
app.filemode.file_status = FileStatus::Active;
app.filemode.priority = None;
app.filemode.current_file = 0;
app.filemode.line_offset = 0;
app.filemode.tag_dialog.submitted_input.clear();
app.filemode.search_dialog.submitted_input.clear();
files_mode::refine_files(app)?;
}
_ => (),
},
}
app.action = Action::Wait;
}
Ok(())
}
fn render(frame: &mut Frame, app: &mut App) {
let horizontal = Layout::horizontal([Constraint::Fill(1), Constraint::Length(SIDEBAR_SIZE)]);
let vertical = Layout::vertical([Constraint::Percentage(38), Constraint::Percentage(62)]);
let [left, right] = horizontal.areas(frame.area());
let [top_right, bottom_right] = vertical.areas(right);
let main_block = Block::default()
.title_top(Line::from(" Taskfinder ").bold().centered())
.borders(Borders::ALL)
.border_set(border::THICK)
.padding(Padding::horizontal(1));
let info_block = Block::default()
.borders(Borders::ALL)
.border_set(border::THICK);
let controls_block = Block::default()
.title_top(Line::from(" Controls ").centered().bold())
.title_bottom(Line::from(" q to quit ").centered())
.borders(Borders::ALL)
.border_set(border::THICK);
let mut controls_content: Vec<Line<'_>> = vec![
Line::from("Mode".blue().bold().underlined()),
vec!["f".blue(), " Files".into(), " x".blue(), " Config".into()].into(),
vec!["t".blue(), " Tasks".into(), " h".blue(), " Help".into()].into(),
];
if app.config.evergreen_file != Path::new("").to_path_buf() {
controls_content.push(
vec![
"l".blue(),
" Log".into(),
" e".blue(),
" Evergreen ".into(),
]
.into(),
);
} else {
controls_content.push(vec!["l".blue(), " Log".into()].into());
}
match app.mode {
Mode::Files => files_mode::render(
app,
frame,
info_block,
main_block,
top_right,
left,
&mut controls_content,
vertical,
),
Mode::Tasks => tasks_mode::render(
app,
frame,
info_block,
main_block,
top_right,
left,
&mut controls_content,
),
Mode::Config => config_mode::render(
app,
frame,
info_block,
main_block,
top_right,
left,
&mut controls_content,
),
Mode::Log => log_mode::render(
app,
frame,
info_block,
main_block,
top_right,
left,
&mut controls_content,
vertical,
),
Mode::Help => help_mode::render(
app,
frame,
info_block,
main_block,
top_right,
left,
&mut controls_content,
),
Mode::Evergreen => evergreen_mode::render(
app,
frame,
info_block,
main_block,
top_right,
left,
&mut controls_content,
),
}
let controls = Paragraph::new(controls_content).block(controls_block);
frame.render_widget(controls, bottom_right);
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, str::FromStr};
use priority::make_priority_map;
use test_helpers::make_test_config;
#[test]
fn readme_contains_current_version_tag() {
let readme = fs::read_to_string(Path::new("README.md")).unwrap();
assert!(readme.contains(std::env!("CARGO_PKG_VERSION")))
}
#[test]
fn extract_correct_number_of_task_sets() {
let config = make_test_config();
let priority_map = make_priority_map(config.priority_markers);
let file = Path::new("test_files/01_basics.txt");
let file_with_tasks = FileWithTasks::extract(file, 365, priority_map)
.unwrap()
.unwrap();
assert_eq!(file_with_tasks.task_sets.len(), 2);
}
#[test]
fn extract_correct_number_of_tasks_in_task_set() {
let config = make_test_config();
let priority_map = make_priority_map(config.priority_markers);
let file = Path::new("test_files/01_basics.txt");
let task_sets = FileWithTasks::extract(file, 365, priority_map)
.unwrap()
.unwrap()
.task_sets
.clone();
assert_eq!(task_sets[0].tasks.len(), 2);
assert_eq!(task_sets[1].tasks.len(), 4);
}
#[test]
fn tasks_ignored_with_no_todo_header() {
let config = make_test_config();
let priority_map = make_priority_map(config.priority_markers);
let file = Path::new("test_files/no_tasks.txt");
let task_sets = FileWithTasks::extract(file, 365, priority_map).unwrap();
assert!(task_sets.is_none());
}
#[test]
fn due_date_inheritance_is_correct() {
let config = make_test_config();
let priority_map = make_priority_map(config.priority_markers);
let file_with_dates =
FileWithTasks::extract(Path::new("test_files/03_dates.txt"), 365, priority_map)
.unwrap()
.unwrap();
assert_eq!(
file_with_dates.task_sets[0].due_date,
Some(NaiveDate::from_str("2025-04-30").unwrap())
);
assert!(file_with_dates.task_sets[1].due_date.is_none());
assert_eq!(
file_with_dates.task_sets[0].tasks[0].due_date,
Some(NaiveDate::from_str("2025-04-30").unwrap())
);
assert_eq!(
file_with_dates.task_sets[0].tasks[1].due_date,
Some(NaiveDate::from_str("2025-04-30").unwrap())
);
}
#[test]
fn recurring_status_is_correct() {
let config = make_test_config();
let priority_map = make_priority_map(config.priority_markers);
let files = FileWithTasks::extract(Path::new("test_files/06_misc.txt"), 365, priority_map)
.unwrap()
.unwrap();
assert!(!files.task_sets[1].tasks[0].recurring);
assert!(!files.task_sets[1].tasks[1].recurring);
assert!(files.task_sets[1].tasks[2].recurring);
assert!(files.task_sets[1].tasks[3].recurring);
}
}