Compare commits
3 Commits
611ce59eb3
...
b3103d0173
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3103d0173 | ||
|
|
7bd97697c2 | ||
|
|
78f8585d27 |
63
src/app.rs
Normal file
63
src/app.rs
Normal 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
114
src/control.rs
Normal 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
|
||||
}
|
||||
}
|
||||
78
src/main.rs
78
src/main.rs
@ -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: || {
|
||||
{
|
||||
let _ = Ui::init(&mut io::stdout(), Document::default()).deinit();
|
||||
}
|
||||
.into()
|
||||
// needs two braces to function properly
|
||||
let _ = Ui::init(&mut io::stdout(), Document::default()).deinit();
|
||||
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(())
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
63
src/ui.rs
63
src/ui.rs
@ -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::*;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user