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
```md
Chores
- [x] Get groceries (2024-06-20; 16:00)
- [ ] Do the dishes (2024-06-20; 20:00)
- [ ] Take out the trash (2024-06-20; 21:00)
[todue] Chores
[x] (2024-06-20 16:00) Get groceries
[ ] (2024-06-20 20:00) Do the dishes
[ ] (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,
io::{self, Write},
path, process, time,
@ -6,6 +6,12 @@ use std::{
use crossterm::event::{poll, read, Event::*, KeyCode::Char};
mod app;
use app::*;
mod control;
use control::*;
mod md;
use md::*;
@ -20,74 +26,42 @@ pub use log::*;
mod tests;
fn main() -> anyhow::Result<()> {
let command = cli::new();
let args = command.clone().get_matches();
let mut app = App::init()?;
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 {
// clean up terminal state even on panics
exec_on_drop: || {
{
// needs two braces to function properly
let _ = Ui::init(&mut io::stdout(), Document::default()).deinit();
}
.into()
Log::flush();
},
};
loop {
ui.draw()?;
if poll(time::Duration::from_millis(250)).unwrap_or(false) {
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; },
_ => {}
}
}
}
while app.running {
app.ui.draw()?;
app.handle_input()?;
}
if ui.save_on_quit {
if app.ui.save_on_quit {
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() {
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());
}
write!(file.unwrap(), "{}", ui.document.to_md()).unwrap_or_else(|e| {
Log::error(format!("Failed to write to file: `{md_file}`: {e}"));
write!(file.unwrap(), "{}", app.ui.document.to_md()).unwrap_or_else(|e| {
Log::error(format!("Failed to write to file: `{}`: {}", app.md_file, e));
process::exit(ErrorCode::IO.into());
});
}
ui.deinit()?;
app.ui.deinit()?;
Log::flush();
Ok(())
}

View File

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

View File

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