├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── src ├── events.rs └── main.rs └── todo_preview.png /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | 3 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 4 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 5 | Cargo.lock 6 | 7 | # These are backup files generated by rustfmt 8 | **/*.rs.bk 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | tui = {version="0.16", features=["crossterm"], default-features=false} 10 | crossterm = "0.21" 11 | serde = {version = "1.0", features=["derive"]} 12 | serde_json = "1.0" 13 | chrono = {version = "0.4.19", features=["serde"]} 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | todo is dual-licensed under either 2 | 3 | * MIT License (docs/LICENSE-MIT or http://opensource.org/licenses/MIT) 4 | * Apache License, Version 2.0 (docs/LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) 5 | 6 | at your option. 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # todo 2 | A simple todo application for the terminal. 3 | ![preview_image](todo_preview.png) 4 | 5 | ## features: 6 | 1. create/remove tasks 7 | 2. daily occuring tasks, depending on weekday, you can always have certain tasks appear, for example: 8 | 9 | monday - business day Tuesday - Optimization day 10 | ☑ check business mail ☑ Add optimization tasks for today 11 | ☐ taxes ☑ Drink water 12 | 13 | ## Controlls: 14 | 15 | [in edit mode] 16 | i: enter insert mode (make new task) 17 | j,k: move up or down task list. 18 | h,l: mark task as done / not done. 19 | x: remove task 20 | q: close application 21 | 22 | [in insert mode] 23 | [Enter]: submit new task 24 | [Esc]: cancel new task, enter edit mode 25 | 26 | ## License 27 | todo is dual-licensed under either 28 | 29 | * MIT License (docs/LICENSE-MIT or http://opensource.org/licenses/MIT) 30 | * Apache License, Version 2.0 (docs/LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) 31 | 32 | at your option. 33 | -------------------------------------------------------------------------------- /src/events.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | use std::sync::mpsc; 3 | use std::thread; 4 | use std::time::Duration; 5 | 6 | use crossterm::event::{self, KeyCode, Event as CEvent}; 7 | 8 | //use termion::event::Key; 9 | //use termion::input::TermRead; 10 | 11 | pub enum Event { 12 | Input(I), 13 | Tick, 14 | } 15 | 16 | /// A small event handler that wrap termion input and tick events. Each event 17 | /// type is handled in its own thread and returned to a common `Receiver` 18 | pub struct Events { 19 | rx: mpsc::Receiver>, 20 | _input_handle: thread::JoinHandle<()>, 21 | } 22 | 23 | #[derive(Debug, Clone, Copy)] 24 | pub struct Config { 25 | pub tick_rate: Duration, 26 | } 27 | 28 | impl Default for Config { 29 | fn default() -> Config { 30 | Config { 31 | tick_rate: Duration::from_millis(250), 32 | } 33 | } 34 | } 35 | 36 | impl Events { 37 | pub fn new() -> Events { 38 | Events::with_config(Config::default()) 39 | } 40 | 41 | pub fn with_config(config: Config) -> Events { 42 | let (tx, rx) = mpsc::channel(); 43 | let _input_handle = { 44 | let tx = tx.clone(); 45 | thread::spawn(move || { 46 | //let stdin = io::stdin(); 47 | let mut last_tick = Instant::now(); 48 | loop { 49 | let timeout = config.tick_rate 50 | .checked_sub(last_tick.elapsed()) 51 | .unwrap_or_else(|| Duration::from_secs(0)); 52 | if event::poll(timeout).unwrap() { 53 | if let CEvent::Key(key) = event::read().unwrap() { 54 | tx.send(Event::Input(key.code)).unwrap(); 55 | } 56 | } 57 | if last_tick.elapsed() >= config.tick_rate { 58 | tx.send(Event::Tick).unwrap(); 59 | last_tick = Instant::now(); 60 | } 61 | } 62 | } 63 | )}; 64 | Events { 65 | rx, 66 | _input_handle, 67 | } 68 | } 69 | 70 | pub fn next(&self) -> Result, mpsc::RecvError> { 71 | self.rx.recv() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | use std::io; 4 | use crossterm::{ 5 | event::KeyCode, 6 | terminal::enable_raw_mode, 7 | }; 8 | use tui::layout::{Constraint, Direction, Layout}; 9 | use tui::style::{Color, Style}; 10 | use tui::text::{Span, Text}; 11 | use tui::widgets::{Block, Borders}; 12 | use tui::widgets::{List, ListItem, Paragraph}; 13 | use tui::{Terminal, backend::CrosstermBackend}; 14 | 15 | use chrono::prelude::*; 16 | mod events; 17 | use events::*; 18 | 19 | // the tasks for today 20 | // saved to file 21 | #[derive(Serialize, Deserialize, Debug)] 22 | struct Today { 23 | tasks: Vec, 24 | date: Option>, 25 | } 26 | 27 | impl Default for Today { 28 | fn default() -> Self { 29 | Self { 30 | tasks: Vec::new(), 31 | date: None, 32 | } 33 | } 34 | } 35 | 36 | #[derive(Serialize, Deserialize, Debug, Clone)] 37 | struct WeekdayTask { 38 | tasks: Vec, 39 | day_info: String, 40 | } 41 | 42 | #[derive(Serialize, Deserialize, Debug)] 43 | struct WeekdayTasks { 44 | tasks: HashMap, 45 | } 46 | 47 | impl Default for WeekdayTasks { 48 | fn default() -> Self { 49 | let tasks: HashMap = [ 50 | ( 51 | Weekday::Mon, 52 | WeekdayTask { 53 | tasks: ["check mail".to_string()].into(), 54 | day_info: "business day".to_string(), 55 | }, 56 | ), 57 | ( 58 | Weekday::Fri, 59 | WeekdayTask { 60 | tasks: ["cleanup mail".to_string()].into(), 61 | day_info: "wrap up day".to_string(), 62 | }, 63 | ), 64 | ] 65 | .iter() 66 | .cloned() 67 | .collect(); 68 | Self { tasks } 69 | } 70 | } 71 | 72 | #[derive(PartialEq)] 73 | enum AppMode { 74 | Edit, 75 | Insert, 76 | } 77 | 78 | #[derive(Serialize, Deserialize, Debug, Copy, Clone)] 79 | enum Status { 80 | Todo, 81 | Done, 82 | } 83 | 84 | #[derive(Serialize, Deserialize, Debug, Clone)] 85 | struct Task { 86 | status: Status, 87 | info: String, 88 | } 89 | 90 | #[cfg(target_os = "windows")] 91 | fn get_status_char(status: &Status) -> &str { 92 | match status { 93 | Status::Todo => "[ ]", 94 | Status::Done => "[D]", 95 | } 96 | } 97 | 98 | #[cfg(not(target_os = "windows"))] 99 | fn get_status_char(status: &Status) -> &str { 100 | match status { 101 | Status::Todo => "☐", 102 | Status::Done => "☑", 103 | } 104 | } 105 | 106 | impl Task { 107 | fn into_list_item(&self) -> ListItem { 108 | let box_token: &str = get_status_char(&self.status); 109 | 110 | let span = Span::raw(format!("{} {}", box_token, self.info)); 111 | ListItem::new(span) 112 | } 113 | } 114 | 115 | fn save_today(tasks: &Vec, path: &str) { 116 | let local: DateTime = Local::now(); 117 | let today = Today { 118 | tasks: tasks.to_vec(), 119 | date: Some(local), 120 | }; 121 | let serialized = serde_json::to_string(&today).unwrap(); 122 | let _save_result = std::fs::write(path, serialized); 123 | // println!("tried saving file {}, result: {:?}", path, _save_result); 124 | } 125 | 126 | fn main() -> Result<(), io::Error> { 127 | let mut working_path = std::env::current_exe().unwrap(); 128 | // get rid of application name 129 | working_path.pop(); 130 | let daily_path = format!( 131 | "{}/{}", 132 | working_path.to_str().unwrap(), 133 | "daily_occuring.json" 134 | ); 135 | let today_path = format!("{}/{}", working_path.to_str().unwrap(), "today.json"); 136 | // println!("files path: {:?}", today_path); 137 | 138 | let file_result = std::fs::read_to_string(&daily_path); 139 | let weekday_tasks = match file_result { 140 | Ok(file_string) => { 141 | let daily_occuring = serde_json::from_str(&file_string).expect("corrupt file"); 142 | daily_occuring 143 | } 144 | Err(_file_error) => { 145 | // return default daily occuring data 146 | let default_weekday_tasks = WeekdayTasks::default(); 147 | let serialized = serde_json::to_string(&default_weekday_tasks).unwrap(); 148 | let _save_result = std::fs::write(&daily_path, serialized); 149 | // println!("saved to daily_occuring? {:?}", _save_result); 150 | default_weekday_tasks 151 | } 152 | }; 153 | let local: DateTime = Local::now(); 154 | let weekday = local.date().weekday(); 155 | let mut tasks = Vec::::new(); 156 | if let Some(day_tasks) = weekday_tasks.tasks.get(&weekday) { 157 | for day_task in day_tasks.tasks.iter() { 158 | tasks.push(Task { 159 | status: Status::Todo, 160 | info: day_task.clone(), 161 | }); 162 | } 163 | } 164 | // check if we have a save for today 165 | let today_file_result = std::fs::read_to_string(&today_path); 166 | if let Ok(today_string) = today_file_result { 167 | if let Ok(today) = serde_json::from_str::(&today_string) { 168 | if let Some(today_date) = today.date { 169 | if today_date.weekday() == weekday { 170 | for saved_task in today.tasks.into_iter() { 171 | for loaded_task in tasks.iter_mut() { 172 | if loaded_task.info == saved_task.info { 173 | loaded_task.status = saved_task.status; 174 | } 175 | } 176 | if !tasks.iter().any(|t| t.info == saved_task.info) { 177 | tasks.push(saved_task); 178 | } 179 | } 180 | } 181 | } 182 | } 183 | } 184 | 185 | //let stdout = io::stdout().into_raw_mode()?; 186 | enable_raw_mode().unwrap(); 187 | let stdout = io::stdout(); 188 | let backend = CrosstermBackend::new(stdout); 189 | let mut terminal = Terminal::new(backend)?; 190 | terminal.clear().unwrap(); 191 | 192 | let mut selected: i32 = 0; 193 | let events = Events::new(); 194 | let mut app_mode = AppMode::Edit; 195 | let mut input_string = String::new(); 196 | 197 | loop { 198 | terminal.draw(|f| { 199 | let chunks = Layout::default() 200 | .direction(Direction::Vertical) 201 | .margin(1) 202 | .constraints( 203 | [ 204 | Constraint::Length(1), 205 | Constraint::Min(0), 206 | Constraint::Length(3), 207 | ] 208 | .as_ref(), 209 | ) 210 | .split(f.size()); 211 | 212 | let items: Vec = tasks 213 | .iter() 214 | .enumerate() 215 | .map(|(index, task)| { 216 | let mut list_item = task.into_list_item(); 217 | // modify style if selected 218 | if index == selected as usize { 219 | list_item = ListItem::style(list_item, Style::default().bg(Color::Magenta)); 220 | } 221 | 222 | list_item 223 | }) 224 | .collect(); 225 | 226 | let title = match weekday_tasks.tasks.get(&weekday) { 227 | Some(task_data) => Paragraph::new(Text::raw(format!( 228 | "{}: {}", 229 | weekday, 230 | task_data.day_info.clone() 231 | ))), 232 | None => Paragraph::new(Text::raw(format!( 233 | "no daily occuring task for {:?}", 234 | weekday 235 | ))), 236 | }; 237 | f.render_widget(title, chunks[0]); 238 | 239 | let list = List::new(items); 240 | f.render_widget(list, chunks[1]); 241 | 242 | if AppMode::Insert == app_mode { 243 | let input = Paragraph::new(Text::raw(input_string.as_str())) 244 | .block(Block::default().borders(Borders::ALL).title("new task")); 245 | f.render_widget(input, chunks[2]); 246 | f.set_cursor( 247 | // Put cursor past the end of the input text 248 | chunks[2].x + input_string.len() as u16 + 1, 249 | // Move one line down, from the border to the input line 250 | chunks[2].y + 1, 251 | ) 252 | } 253 | })?; 254 | 255 | for event in events.next() { 256 | if let Event::Input(input) = event { 257 | match app_mode { 258 | AppMode::Edit => { 259 | match input { 260 | KeyCode::Char('j') => { 261 | selected += 1; 262 | if selected as usize >= tasks.len() { 263 | selected = 0; 264 | } 265 | } 266 | KeyCode::Char('k') => { 267 | selected -= 1; 268 | if selected < 0 { 269 | selected = (tasks.len() - 1) as i32; 270 | } 271 | } 272 | KeyCode::Char('l') => { 273 | // modify the current selected task 274 | let mut task = tasks.get_mut(selected as usize).unwrap(); 275 | task.status = Status::Done; 276 | } 277 | KeyCode::Char('h') => { 278 | // modify the current selected task 279 | let mut task = tasks.get_mut(selected as usize).unwrap(); 280 | task.status = Status::Todo; 281 | } 282 | // enter insert mode 283 | KeyCode::Char('i') => { 284 | // modify the current selected task 285 | app_mode = AppMode::Insert; 286 | } 287 | KeyCode::Char('x') => { 288 | // remove entry 289 | if selected >= 0 && selected < tasks.len() as i32 { 290 | tasks.remove(selected as usize); 291 | if selected as usize >= tasks.len() { 292 | selected = tasks.len() as i32 - 1; 293 | } 294 | } 295 | } 296 | KeyCode::Char('q') => { 297 | save_today(&tasks, &today_path); 298 | let _ = crossterm::terminal::disable_raw_mode(); 299 | return Ok(()); 300 | } 301 | _ => {} 302 | } 303 | } 304 | AppMode::Insert => { 305 | match input { 306 | KeyCode::Esc => { 307 | app_mode = AppMode::Edit; 308 | input_string.clear(); 309 | } 310 | KeyCode::Enter => { 311 | //KeyCode::Char('\n') => { 312 | // submit 313 | app_mode = AppMode::Edit; 314 | tasks.push(Task { 315 | status: Status::Todo, 316 | info: input_string.drain(..).collect(), 317 | }); 318 | } 319 | KeyCode::Backspace => { 320 | input_string.pop(); 321 | } 322 | KeyCode::Char(c) => { 323 | input_string.push(c); 324 | } 325 | _ => {} 326 | } 327 | } 328 | } 329 | } 330 | } 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /todo_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TanTanDev/todo/e1f9532cf2e793ed252bb73b5ce1c81f0fdba252/todo_preview.png --------------------------------------------------------------------------------