Compare commits

...

4 Commits

Author SHA1 Message Date
mxhagen
f48eb23f0d factor out app state and make keybinds remappable via hashmap. 2024-10-06 23:39:59 +02:00
mxhagen
f35d1f353e impl Default, explicitly use SortMode, autoformatting 2024-10-06 23:39:29 +02:00
mxhagen
6b5b13af6b clippy fix 2024-10-06 23:38:46 +02:00
mxhagen
2c40b9b726 add to readme 2024-10-06 23:38:24 +02:00
6 changed files with 331 additions and 81 deletions

View File

@ -10,9 +10,89 @@ Keep track of TODOs and deadlines using an interactive markdown TUI.
This is roughly what the UI looks like in the terminal This is roughly what the UI looks like in the terminal
```md ```md
Chores [todue] Chores
- [x] Get groceries (2024-06-20; 16:00) [x] (2024-06-20 16:00) Get groceries
- [ ] Do the dishes (2024-06-20; 20:00) [ ] (2024-06-20 20:00) Do the dishes
- [ ] Take out the trash (2024-06-20; 21:00) [ ] (2024-06-20 21:00) Take out the trash
``` ```
### Control scheme
The control scheme is vim-like and features a minimal line editor as well as datetime-input.
- `j`/`k`: move focus down/up
- `J`/`K`: move focused entry down/up
- `<space>`: toggle focused entry completed
- `q`: save and quit
- `Q`: quit without saving
- `g`/`G`: move focus to top/bottom
- `s`: cycle sort mode.
### TODO
Things that might be implemented in the future
- more controls
- `a`/`A`: append to entry text (enters line editor)
- `c`/`C`: change entry text (enters line editor)
- `i`/`I`: insert before entry text (enters line editor)
- `o`/`O`: edit new entry (after/before current - enters line then datetime editor)
- `r`: replace entry
- `/`/`?`: search entry by text (backwards) (wrapping)
- later on regex search
- `u`/`<ctrl-z>`: undo
- `<ctrl-r>`/`<ctrl-y>`: redo
- `z`: collapse/expand current group
- `yd`: copy entry date
- `yt`: copy entry text
- `yy`: copy entire entry
- `0`-`9`: as prefix for repeated commands
- line editor with vim commands (prefixed with mode)
- normal: `<esc>`: exit line editor
- normal: `a`/`A`: append (to end)
- normal: `i`/`I`: insert (at beginning)
- normal: `d`: delete
- normal: `x`: remove character
- normal: `c`/`C`: change
- normal: `r`/`R`: replace
- normal: `s`/`S`: substitute (equal to `cl` and `cc` respectively)
- normal: `v`: visual mode
- normal: `y`/`Y`: copy
- normal: `p`/`P`: paste
- normal: `u`/`<ctrl-z>`: undo
- normal: `<ctrl-r>`/`<ctrl-y>`: redo
- normal: `f`/`F` and `t`/`T`: find (until) (backwards)
- normal: `/`/`?`: search (backwards) (wrapping to beginning of line)
- later on regex search
- insert: `w`,`b`,`e`: like vim, including uppercase equivalent
- insert: `<esc>`: exit line editor
- insert: `<ctrl-w>`/`<ctrl-backspace>`: delete last word
- insert: `<ctrl-shift-v>`/`<shift-insert>`: paste
- visual: `a`/`i`: select all/inside of...
- datetime editor
- highlight date part (YYYY for example)
- `d`: remove entire deadline
- `<enter>`: go to next part
- `0`-`9`: input number (ignoring invalid inputs like months >12)
- sort mode: cycle through modes and set ascending/descending separately
- `r`: insert before entry text (enters line editor)
- collapsable todo group hierarchy
- detect indent width from md
- group entries together under previous entry with lower indent level
- display expandable groups in tui
- config
- keybinds
- some other options (?)
- some `:`-commands?
- regex substitution
- set commands for config entries
- help command that shows controls
- `set` with no key shows explicitly set keys (config and live)
- config wizard & write current config state to config file

63
src/app.rs Normal file
View File

@ -0,0 +1,63 @@
use crate::*;
use std::io::Stdout;
#[derive()]
pub struct App {
pub ui: Ui<Stdout>,
pub md_file: String,
pub running: bool,
pub keymap: Keymap,
pub mode: Mode,
}
impl App {
pub fn init() -> anyhow::Result<Self> {
let cli = cli::new();
let args = cli.clone().get_matches();
let md_file = match args.get_one::<String>("file") {
Some(path) => path.into(),
_ => {
Log::warn("No filename specified -- falling back to `todo.md`");
"todo.md".to_string()
}
};
if !path::Path::new(&md_file).exists() {
Log::error_exit_with(
ErrorCode::IO,
format!("Markdown file `{md_file}` not found. Exiting..."),
);
}
let md = fs::read_to_string(&md_file).unwrap();
let document = Document::from_md(md)?;
let ui = Ui::init(io::stdout(), document);
Ok(Self {
ui,
md_file,
running: true,
keymap: Keymap::default(),
mode: Mode::Normal,
})
}
pub fn handle_input(&mut self) -> anyhow::Result<()> {
if poll(time::Duration::from_millis(250)).unwrap_or(false) {
if let Ok(Key(k)) = read() {
let a = self.keymap.map.get(&(self.mode.clone(), k));
if let Some(callback) = a {
(callback.clone())(self);
}
}
}
Ok(())
}
pub fn quit(&mut self) {
self.running = false;
}
}

114
src/control.rs Normal file
View File

@ -0,0 +1,114 @@
#![allow(dead_code, unused)] // todo
use crate::*;
use std::collections::HashMap;
use anyhow::*;
use crossterm::event::KeyEvent;
/// Mode of the TUI
#[derive(Default, Debug, PartialEq, Eq, Hash, Clone)]
pub enum Mode {
#[default]
Normal,
Insert(EditMode),
Datetime,
Visual,
}
/// Mode for the line editor
#[derive(Default, Debug, PartialEq, Eq, Hash, Clone)]
pub enum EditMode {
#[default]
Normal,
Insert,
Visual,
Replace,
}
type KeymapCallback = Box<for<'a> fn(&'a mut App)>;
#[derive(Debug)]
pub struct Keymap {
pub map: HashMap<(Mode, KeyEvent), KeymapCallback>,
}
impl Keymap {
pub fn handle(&self, key: KeyEvent, app: &mut App) -> anyhow::Result<()> {
self.map
.get(&(app.mode.clone(), key))
.map(|f| f(app))
.ok_or(anyhow!("Associated mapping not found."))
}
pub fn register(&mut self, mode: Mode, key: KeyEvent, f: KeymapCallback) {
self.map.insert((mode, key), f);
}
}
impl Default for Keymap {
fn default() -> Self {
let mut map = Self {
map: HashMap::new(),
};
use Mode::*;
map.register(Normal, Char('q').into(), Box::new(App::quit));
map.register(
Normal,
Char('Q').into(),
Box::new(|app: &mut App| {
app.ui.dont_save_on_quit();
app.quit()
}),
);
map.register(
Normal,
Char('j').into(),
Box::new(|app: &mut App| app.ui.move_selection(Down).unwrap()),
);
map.register(
Normal,
Char('k').into(),
Box::new(|app: &mut App| app.ui.move_selection(Up).unwrap()),
);
map.register(
Normal,
Char('J').into(),
Box::new(|app: &mut App| app.ui.move_selected_entry(Down)),
);
map.register(
Normal,
Char('K').into(),
Box::new(|app: &mut App| app.ui.move_selected_entry(Up)),
);
map.register(
Normal,
Char('G').into(),
Box::new(|app: &mut App| app.ui.move_selection_to_top()),
);
map.register(
Normal,
Char('g').into(),
Box::new(|app: &mut App| app.ui.move_selection_to_bottom()),
);
map.register(
Normal,
Char(' ').into(),
Box::new(|app: &mut App| app.ui.toggle_active_entry()),
);
map.register(
Normal,
Char('s').into(),
Box::new(|app: &mut App| app.ui.cycle_sort_mode()),
);
map.register(
Normal,
Char('-').into(),
Box::new(|app: &mut App| {
Log::info("Debug panic keybind '-' invoked -- panicking...");
panic!();
}),
);
map
}
}

View File

@ -1,4 +1,4 @@
use std::{ pub use std::{
fs, fs,
io::{self, Write}, io::{self, Write},
path, process, time, path, process, time,
@ -6,6 +6,12 @@ use std::{
use crossterm::event::{poll, read, Event::*, KeyCode::Char}; use crossterm::event::{poll, read, Event::*, KeyCode::Char};
mod app;
use app::*;
mod control;
use control::*;
mod md; mod md;
use md::*; use md::*;
@ -20,74 +26,42 @@ pub use log::*;
mod tests; mod tests;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let command = cli::new(); let mut app = App::init()?;
let args = command.clone().get_matches();
let md_file = match args.get_one::<String>("file") {
Some(path) => path,
_ => {
Log::warn("No filename specified -- falling back to `todo.md`");
&"todo.md".to_string()
}
};
if !path::Path::new(md_file).exists() {
Log::error_exit_with(
ErrorCode::IO,
format!("Markdown file `{md_file}` not found. Exiting..."),
);
}
let md = fs::read_to_string(md_file).unwrap();
let document = Document::from_md(md)?;
let mut ui = Ui::init(io::stdout(), document);
let _guard = DropGuard { let _guard = DropGuard {
// clean up terminal state even on panics
exec_on_drop: || { exec_on_drop: || {
{ // needs two braces to function properly
let _ = Ui::init(&mut io::stdout(), Document::default()).deinit(); let _ = Ui::init(&mut io::stdout(), Document::default()).deinit();
} Log::flush();
.into()
}, },
}; };
loop { while app.running {
ui.draw()?; app.ui.draw()?;
if poll(time::Duration::from_millis(250)).unwrap_or(false) { app.handle_input()?;
if let Ok(Key(k)) = read() {
match k.code {
Char('q') => break,
Char('j') => ui.move_selection(Down)?,
Char('k') => ui.move_selection(Up)?,
Char('J') => ui.move_selected_entry(Down),
Char('K') => ui.move_selected_entry(Up),
Char('G') => ui.move_selection_to_bottom(),
Char('g') => ui.move_selection_to_top(),
Char(' ') => ui.toggle_active_entry(),
Char('s') => ui.cycle_sort_mode(),
Char('Q') => { ui.dont_save_on_quit(); break; },
_ => {}
}
}
}
} }
if ui.save_on_quit { if app.ui.save_on_quit {
Log::info(format!( Log::info(format!(
"Writing updated markdown to file `{md_file}` and exiting..." "Writing updated markdown to file `{}` and exiting...",
app.md_file
)); ));
let file = fs::File::create(md_file); let file = fs::File::create(&app.md_file);
if file.is_err() { if file.is_err() {
Log::error(format!("Failed to open file for writing: `{md_file}`")); Log::error(format!(
"Failed to open file for writing: `{}`",
app.md_file
));
process::exit(ErrorCode::IO.into()); process::exit(ErrorCode::IO.into());
} }
write!(file.unwrap(), "{}", ui.document.to_md()).unwrap_or_else(|e| { write!(file.unwrap(), "{}", app.ui.document.to_md()).unwrap_or_else(|e| {
Log::error(format!("Failed to write to file: `{md_file}`: {e}")); Log::error(format!("Failed to write to file: `{}`: {}", app.md_file, e));
process::exit(ErrorCode::IO.into()); process::exit(ErrorCode::IO.into());
}); });
} }
ui.deinit()?; app.ui.deinit()?;
Log::flush(); Log::flush();
Ok(()) Ok(())
} }

View File

@ -32,11 +32,7 @@ impl Markdown for Entry {
let mut md = format!("- [{}] ", if self.done { "x" } else { " " }); let mut md = format!("- [{}] ", if self.done { "x" } else { " " });
match self.deadline { match self.deadline {
Some(deadline) => md += &format!("{} ", deadline.format("(%Y-%m-%d %H:%M)")), Some(deadline) => md += &format!("{} ", deadline.format("(%Y-%m-%d %H:%M)")),
None => { None => md += &str::repeat(" ", "(YYYY-mm-dd HH:MM) ".len()),
md += &std::iter::repeat(' ')
.take("(YYYY-mm-dd HH:MM) ".len())
.collect::<String>()
}
} }
md += &self.text; md += &self.text;
md md

View File

@ -17,6 +17,7 @@ use crossterm::{
}; };
/// state of the user interface /// state of the user interface
#[derive(Debug)]
pub struct Ui<T> pub struct Ui<T>
where where
T: io::Write, T: io::Write,
@ -35,7 +36,7 @@ where
pub height: usize, pub height: usize,
pub active_entry_idx: usize, pub active_entry_idx: usize,
pub current_scroll_offset: usize, pub current_scroll_offset: usize,
queue_sort_update: bool queue_sort_update: bool,
} }
impl<T> Ui<T> impl<T> Ui<T>
@ -59,7 +60,7 @@ where
inactive_color_pair, inactive_color_pair,
inactive_done_color_pair, inactive_done_color_pair,
header_color_pair, header_color_pair,
current_sort_mode: Default, current_sort_mode: SortMode::Default,
active_entry_idx: 0, active_entry_idx: 0,
scrolloff: 8, scrolloff: 8,
current_scroll_offset: 0, current_scroll_offset: 0,
@ -95,6 +96,7 @@ where
RestorePosition, RestorePosition,
cursor::Show cursor::Show
)?; )?;
writeln!(self.ostream)?;
Ok(()) Ok(())
} }
@ -209,11 +211,15 @@ where
/// update the index of the first *shown* entry using `self.scrolloff` /// update the index of the first *shown* entry using `self.scrolloff`
pub fn update_scroll_offset(&mut self) { pub fn update_scroll_offset(&mut self) {
if self.current_scroll_offset + self.scrolloff >= self.active_entry_idx { if self.current_scroll_offset + self.scrolloff >= self.active_entry_idx {
let diff = (self.current_scroll_offset + self.scrolloff).abs_diff(self.active_entry_idx); let diff =
(self.current_scroll_offset + self.scrolloff).abs_diff(self.active_entry_idx);
self.current_scroll_offset = self.current_scroll_offset.saturating_sub(diff); self.current_scroll_offset = self.current_scroll_offset.saturating_sub(diff);
} else if (self.current_scroll_offset + self.inner_height()).saturating_sub(self.scrolloff)
} else if (self.current_scroll_offset + self.inner_height()).saturating_sub(self.scrolloff) <= self.active_entry_idx { <= self.active_entry_idx
let diff = (self.current_scroll_offset + self.inner_height()).saturating_sub(self.scrolloff).abs_diff(self.active_entry_idx); {
let diff = (self.current_scroll_offset + self.inner_height())
.saturating_sub(self.scrolloff)
.abs_diff(self.active_entry_idx);
self.current_scroll_offset = (self.current_scroll_offset + diff).min( self.current_scroll_offset = (self.current_scroll_offset + diff).min(
self.document self.document
.entries .entries
@ -269,11 +275,11 @@ where
pub fn cycle_sort_mode(&mut self) { pub fn cycle_sort_mode(&mut self) {
self.current_sort_mode = match self.current_sort_mode { self.current_sort_mode = match self.current_sort_mode {
Default => ByDeadlineDescending, SortMode::Default => SortMode::ByDeadlineDescending,
ByDeadlineDescending => ByDeadlineAscending, SortMode::ByDeadlineDescending => SortMode::ByDeadlineAscending,
ByDeadlineAscending => ByTextAscending, SortMode::ByDeadlineAscending => SortMode::ByTextAscending,
ByTextAscending => ByTextDescending, SortMode::ByTextAscending => SortMode::ByTextDescending,
ByTextDescending => Default, SortMode::ByTextDescending => SortMode::Default,
}; };
self.queue_sort_update = true; self.queue_sort_update = true;
} }
@ -281,17 +287,24 @@ where
pub fn apply_sort_mode(&mut self) { pub fn apply_sort_mode(&mut self) {
if self.queue_sort_update { if self.queue_sort_update {
match self.current_sort_mode { match self.current_sort_mode {
Default => self.document = self.original_document.clone(), SortMode::Default => self.document = self.original_document.clone(),
ByDeadlineDescending => { SortMode::ByDeadlineDescending => {
self.document.entries.sort_by_key(|entry| entry.deadline); self.document.entries.sort_by_key(|entry| entry.deadline);
self.document.entries.reverse(); self.document.entries.reverse();
}, }
ByDeadlineAscending => self.document.entries.sort_by_key(|entry| entry.deadline), SortMode::ByDeadlineAscending => {
ByTextAscending => self.document.entries.sort_by_key(|entry| entry.text.to_lowercase()), self.document.entries.sort_by_key(|entry| entry.deadline)
ByTextDescending => { }
self.document.entries.sort_by_key(|entry| entry.text.to_lowercase()); SortMode::ByTextAscending => self
.document
.entries
.sort_by_key(|entry| entry.text.to_lowercase()),
SortMode::ByTextDescending => {
self.document
.entries
.sort_by_key(|entry| entry.text.to_lowercase());
self.document.entries.reverse(); self.document.entries.reverse();
}, }
} }
self.queue_sort_update = false; self.queue_sort_update = false;
} }
@ -303,17 +316,27 @@ where
} }
} }
impl<T> Default for Ui<T>
where
T: Default + io::Write,
{
fn default() -> Self {
Self::init(T::default(), Document::default())
}
}
pub enum MoveDirection { pub enum MoveDirection {
Down, Down,
Up, Up,
} }
pub use MoveDirection::*; pub use MoveDirection::*;
#[derive(Debug, Default)]
pub enum SortMode { pub enum SortMode {
#[default]
Default, Default,
ByDeadlineDescending, ByDeadlineDescending,
ByDeadlineAscending, ByDeadlineAscending,
ByTextAscending, ByTextAscending,
ByTextDescending, ByTextDescending,
} }
pub use SortMode::*;