squash old
This commit is contained in:
commit
007398c576
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1092
Cargo.lock
generated
Normal file
1092
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "todox"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = "0.4.38"
|
||||||
|
color-eyre = "0.6.3"
|
||||||
|
crossterm = "0.27.0"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
itertools = "0.13.0"
|
||||||
|
lazy_static = "1.4.0"
|
||||||
|
nom = "7.1.3"
|
||||||
|
ratatui = "0.26.3"
|
||||||
|
serde = { version = "1.0.203", features = ["derive"] }
|
||||||
|
serde_json = "1.0.117"
|
||||||
|
strum = { version = "0.26.2", features = ["derive"] }
|
||||||
|
tui-input = "0.8.0"
|
||||||
|
tui-textarea = "0.4.0"
|
||||||
|
unescape = "0.1.0"
|
||||||
|
uuid = { version = "1.8.0", features = ["fast-rng", "macro-diagnostics", "v4"] }
|
||||||
10
data.db
Normal file
10
data.db
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
2c8bcfa9-df2c-4cfc-a896-74bba8351a90 | Lorem ipsum | something something 1 | p | never | o
|
||||||
|
93f34fb9-cf34-469f-906f-74c39bc64929 | Lorem1 ipsum | something something 2 | p | 1721684917 | o
|
||||||
|
9097a569-4429-4710-9cbf-00b9c796f130 | Lorem2 ipsum | something something 3 | d | 1718574517 | o
|
||||||
|
e6038f70-4afc-4f83-9125-936e2d8b9b31 | Lorem3 ipsum | something\nsomething 4 | c | 1718488117 | o
|
||||||
|
1e765b2c-012e-4204-bf7d-d0fb891ad85b | Lorem3 ipsum | something\nsomething 5 | d | never | o
|
||||||
|
513dadfa-0750-49b3-b22d-9e58d44c63f8 | Lorem4 ipsum | something\r\nsomething 6 | p | never | o
|
||||||
|
02020217-44a5-4865-9af2-68320eee37be | Lorem5 ipsum | something\tsomething 7 | p | never | o
|
||||||
|
215a04a0-0973-4b5d-a144-c01ddbd33a53 | Lorem6 ipsum | something something 8 | p | never | o
|
||||||
|
54e9e149-2c04-4786-bd6b-5df28dc50906 | Lorem7 ipsum | something something 9 | p | never | o
|
||||||
|
bef1588a-94ee-45f9-be54-db90d2a04751 | Lorem8 ipsum | something something 10 | p | never | o
|
||||||
206
src/app.rs
Normal file
206
src/app.rs
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crossterm::event::{ self, Event, KeyCode, KeyEvent, KeyEventKind };
|
||||||
|
use ratatui::prelude::*;
|
||||||
|
|
||||||
|
use color_eyre::eyre::{ Result, WrapErr };
|
||||||
|
use strum::{ Display, EnumCount, EnumIter, FromRepr };
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
mod td;
|
||||||
|
mod filter;
|
||||||
|
mod main;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Cursor {
|
||||||
|
row: usize,
|
||||||
|
col: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cursor {
|
||||||
|
fn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct App {
|
||||||
|
pub screen: Screen,
|
||||||
|
pub mode: Mode,
|
||||||
|
pub tasks: TaskList,
|
||||||
|
pub selection: HashSet<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Screen {
|
||||||
|
Main { inner: MainScreen, cursor: Cursor },
|
||||||
|
Exiting,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Screen {
|
||||||
|
fn default () -> Self {
|
||||||
|
Self::Main(MainScreen::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq, FromRepr, EnumCount, EnumIter, Clone, Copy, Display)]
|
||||||
|
pub enum MainScreen {
|
||||||
|
#[default]
|
||||||
|
Pending,
|
||||||
|
Done,
|
||||||
|
Cancelled,
|
||||||
|
Recurring,
|
||||||
|
Expired,
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub enum Mode {
|
||||||
|
#[default]
|
||||||
|
Normal,
|
||||||
|
Select,
|
||||||
|
Add,
|
||||||
|
Edit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub const UI_COLUMNS: usize = 6;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
|
||||||
|
pub fn run (&mut self, term: &mut Tui) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
term.draw(|frame| self.render_frame(frame))?;
|
||||||
|
let exiting = self.handle_events().wrap_err("handle events failed")?;
|
||||||
|
|
||||||
|
if exiting { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_frame (&self, frame: &mut Frame) {
|
||||||
|
frame.render_widget(self, frame.size())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_events (&mut self) -> Result<bool> {
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
|
||||||
|
self.handle_key_event(key_event)
|
||||||
|
.wrap_err_with(|| format!("handling key event failed:\n{key_event:#?}"))
|
||||||
|
}
|
||||||
|
_ => Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key_event (&mut self, k: KeyEvent) -> Result<bool> {
|
||||||
|
|
||||||
|
|
||||||
|
match &self.screen {
|
||||||
|
Screen::Main (screen) => {
|
||||||
|
match self.mode {
|
||||||
|
Mode::Normal => {
|
||||||
|
match k.code {
|
||||||
|
KeyCode::Char('q') => self.screen = Screen::Exiting,
|
||||||
|
KeyCode::Tab => {
|
||||||
|
let next = *screen as usize + 1;
|
||||||
|
self.cursor = (0, 0);
|
||||||
|
self.selection.clear();
|
||||||
|
self.screen = Screen::Main(MainScreen::from_repr(next % MainScreen::COUNT).unwrap_or_default());
|
||||||
|
},
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
let prev = (*screen as usize).checked_sub(1).unwrap_or(MainScreen::COUNT - 1);
|
||||||
|
self.cursor = (0, 0);
|
||||||
|
self.selection.clear();
|
||||||
|
self.screen = Screen::Main(MainScreen::from_repr(prev).unwrap_or_default());
|
||||||
|
},
|
||||||
|
KeyCode::Up => {
|
||||||
|
if self.cursor.0 > 0 {
|
||||||
|
self.cursor.0 -= 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Left => {
|
||||||
|
if self.cursor.1 > 0 {
|
||||||
|
self.cursor.1 -= 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Down => {
|
||||||
|
self.cursor.0 += 1;
|
||||||
|
},
|
||||||
|
KeyCode::Right => {
|
||||||
|
self.cursor.1 += 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
KeyCode::Char(' ') => {
|
||||||
|
if self.selection.contains(&self.cursor.0) {
|
||||||
|
self.selection.remove(&self.cursor.0);
|
||||||
|
} else {
|
||||||
|
self.selection.insert(self.cursor.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => ()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Screen::Exiting => {
|
||||||
|
match k.code {
|
||||||
|
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('q') => return Ok(true),
|
||||||
|
KeyCode::Esc | KeyCode::Char('n') => self.screen = Screen::Main(MainScreen::default()),
|
||||||
|
_ => ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl Widget for &App {
|
||||||
|
fn render (self, area: Rect, buf: &mut Buffer) {
|
||||||
|
self.screen.render(self, area, buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl Screen {
|
||||||
|
fn render (&self, app: &App, area: Rect, buf: &mut Buffer) -> () {
|
||||||
|
match self {
|
||||||
|
Self::Main(ms) => main::screen(app, *ms, area, buf),
|
||||||
|
_ => ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
fn centered_rect (px: u16, py: u16, r: Rect) -> Rect {
|
||||||
|
// Cut the given rectangle into three vertical pieces
|
||||||
|
let popup_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - py) / 2),
|
||||||
|
Constraint::Percentage(py),
|
||||||
|
Constraint::Percentage((100 - py) / 2),
|
||||||
|
])
|
||||||
|
.split(r);
|
||||||
|
|
||||||
|
// Then cut the middle vertical piece into three width-wise pieces
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - px) / 2),
|
||||||
|
Constraint::Percentage(px),
|
||||||
|
Constraint::Percentage((100 - px) / 2),
|
||||||
|
])
|
||||||
|
.split(popup_layout[1])[1] // Return the middle chunk
|
||||||
|
}
|
||||||
19
src/app/filter.rs
Normal file
19
src/app/filter.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
use super::MainScreen;
|
||||||
|
|
||||||
|
impl MainScreen {
|
||||||
|
pub fn task_filter (self) -> impl Fn(&&Task) -> bool {
|
||||||
|
move |task: &&Task| match self {
|
||||||
|
Self::All => true,
|
||||||
|
Self::Cancelled => task.status == TaskStatus::Cancelled,
|
||||||
|
Self::Pending => task.status == TaskStatus::Pending,
|
||||||
|
Self::Done => task.status == TaskStatus::Done,
|
||||||
|
Self::Expired => task.expiring_at.is_some_and(|dt| Utc::now() > dt),
|
||||||
|
Self::Recurring => std::mem::discriminant(&task.kind) == std::mem::discriminant(&TaskKind::Recurring { parent: Uuid::default() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/app/main.rs
Normal file
112
src/app/main.rs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
use crate::prelude::*;
|
||||||
|
use super::MainScreen;
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
prelude::*,
|
||||||
|
widgets::{
|
||||||
|
block::*,
|
||||||
|
Block, Borders, List, ListItem, Paragraph
|
||||||
|
}
|
||||||
|
};
|
||||||
|
use utils::truncate_with_ellipsis as tc;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use strum::IntoEnumIterator;
|
||||||
|
|
||||||
|
pub fn screen (app: &App, ms: MainScreen, area: Rect, buf: &mut Buffer) {
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(vec![
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(12),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
Paragraph::new(Line::from(
|
||||||
|
MainScreen::iter().map(|e| {
|
||||||
|
let text = format!(" {e} ");
|
||||||
|
if e == ms {
|
||||||
|
Span::styled(text, Style::default().fg(Color::White).bg(Color::from_u32(0x550055)))
|
||||||
|
} else {
|
||||||
|
Span::styled(text, Style::default().fg(Color::White))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.intersperse(Span::styled(" | ", Style::default().fg(Color::White)))
|
||||||
|
.collect_vec()
|
||||||
|
).centered()).block(Block::default().borders(Borders::BOTTOM).padding(Padding::top(1))).render(layout[0], buf);
|
||||||
|
let mut list_items = Vec::<ListItem>::new();
|
||||||
|
|
||||||
|
|
||||||
|
let w = area.width;
|
||||||
|
|
||||||
|
macro_rules! wx {
|
||||||
|
($i: literal) => {
|
||||||
|
(w / 100).max(1) as usize * $i
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cw: [(usize, &'static str, super::td::TdFn); 6] = [
|
||||||
|
(wx!(4), "#", super::td::id),
|
||||||
|
(wx!(15), "Title", super::td::name),
|
||||||
|
(wx!(40), "Description", super::td::description),
|
||||||
|
(wx!(15), "Expires", super::td::expiry),
|
||||||
|
(wx!(10), "Status", super::td::status),
|
||||||
|
(wx!(10), "Kind", super::td::kind)
|
||||||
|
];
|
||||||
|
|
||||||
|
let th = |col: usize| if col == app.cursor.1 % cw.len() { Style::default().bold().bg(Color::from_u32(0x253325)) } else { Style::default() };
|
||||||
|
|
||||||
|
let delimiter = || Span::styled("|", Style::default());
|
||||||
|
|
||||||
|
let header: Vec<Span> = cw.iter().enumerate().map(|(idx, (width, h, _))| Span::styled(format!("{: ^width$}", h, width = width), th(idx))).intersperse_with(delimiter).collect_vec();
|
||||||
|
|
||||||
|
let iterator = || app.tasks.iter().filter(ms.task_filter());
|
||||||
|
let item_count = iterator().count();
|
||||||
|
|
||||||
|
let td = |row: usize, col: usize| match (row, col) {
|
||||||
|
(r, c) if r == app.cursor.0 % item_count && c == app.cursor.1 % cw.len() => Style::default().bg(Color::from_u32(0x007700)),
|
||||||
|
(r, _) if r == app.cursor.0 % item_count => Style::default().bg(Color::from_u32(0x005500)),
|
||||||
|
// (_, c) if c == app.cursor.1 % cw.len() => Style::default().bg(Color::from_u32(0x001500)),
|
||||||
|
_ => Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let tr = |row: usize| return move |col: usize| td(row, col);
|
||||||
|
|
||||||
|
list_items.push(ListItem::new(Line::from(header)));
|
||||||
|
list_items.push(ListItem::new(Line::from(Span::from("-".repeat(w as usize)))));
|
||||||
|
|
||||||
|
for s @ (idx, _task) in iterator().enumerate() {
|
||||||
|
let idx = idx % item_count;
|
||||||
|
let td = tr(idx);
|
||||||
|
let bg = if app.selection.contains(&idx) {
|
||||||
|
Color::from_u32(0x000044)
|
||||||
|
} else if idx == app.cursor.0 % item_count {
|
||||||
|
Color::from_u32(0x002200)
|
||||||
|
} else {
|
||||||
|
Color::default()
|
||||||
|
};
|
||||||
|
let line = cw.iter().enumerate().map(|(idx, (width, _, content))| Span::styled(format!("{: ^width$}", tc(&content(s), *width), width = width), td(idx))).intersperse_with(delimiter).collect_vec();
|
||||||
|
list_items.push(ListItem::new(Line::from(line).bg(bg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = List::new(list_items);
|
||||||
|
Widget::render(list, layout[1], buf);
|
||||||
|
|
||||||
|
|
||||||
|
let footer = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints(vec![
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
Constraint::Fill(1)
|
||||||
|
])
|
||||||
|
.split(layout[2]);
|
||||||
|
|
||||||
|
if !app.selection.is_empty() {
|
||||||
|
let total = item_count;
|
||||||
|
let selected = app.selection.len();
|
||||||
|
|
||||||
|
Paragraph::new(Line::from(Span::styled(format!("[{selected}/{total}]"), Style::default()))).block(Block::default().borders(Borders::ALL)).render(footer[2], buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
28
src/app/td.rs
Normal file
28
src/app/td.rs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
pub type TdFn<'a> = fn((usize, &'a Task)) -> Cow<'a, str>;
|
||||||
|
//
|
||||||
|
// kind of ugly but don't want to think about it rn too much
|
||||||
|
pub fn id <'a> ((idx, _task): (usize, &'a Task)) -> Cow<'a, str> {
|
||||||
|
return Cow::Owned(idx.to_string());
|
||||||
|
}
|
||||||
|
pub fn name <'a> ((_idx, task): (usize, &'a Task)) -> Cow<'a, str> {
|
||||||
|
return Cow::Borrowed(&task.name);
|
||||||
|
}
|
||||||
|
pub fn description <'a> ((_idx, task): (usize, &'a Task)) -> Cow<'a, str> {
|
||||||
|
return Cow::Owned(task.description.replace(['\n', '\t', '\r'], " "));
|
||||||
|
}
|
||||||
|
pub fn expiry <'a> ((_idx, task): (usize, &'a Task)) -> Cow<'a, str> {
|
||||||
|
return Cow::Owned(task.expiring_at.map(|e| utils::dt_format(e - Utc::now())).unwrap_or_default());
|
||||||
|
}
|
||||||
|
pub fn status <'a> ((_idx, task): (usize, &'a Task)) -> Cow<'a, str> {
|
||||||
|
return Cow::Owned(String::from("tl"));
|
||||||
|
// return Cow::Owned(task.expiring_at.map(|e| e.to_rfc2822()).unwrap_or_default());
|
||||||
|
}
|
||||||
|
pub fn kind <'a> ((_idx, task): (usize, &'a Task)) -> Cow<'a, str> {
|
||||||
|
return Cow::Owned(String::from("kind"));
|
||||||
|
// return Cow::Owned(task.expiring_at.map(|e| e.to_rfc2822()).unwrap_or_default());
|
||||||
|
}
|
||||||
29
src/error.rs
Normal file
29
src/error.rs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
use std::panic;
|
||||||
|
|
||||||
|
use color_eyre::{ config::HookBuilder, eyre };
|
||||||
|
|
||||||
|
use crate::tui;
|
||||||
|
|
||||||
|
/// This replaces the standard color_eyre panic and error hooks with hooks that
|
||||||
|
/// restore the terminal before printing the panic or error.
|
||||||
|
pub fn install_hooks () -> color_eyre::Result<()> {
|
||||||
|
let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks();
|
||||||
|
|
||||||
|
// convert from a color_eyre PanicHook to a standard panic hook
|
||||||
|
let panic_hook = panic_hook.into_panic_hook();
|
||||||
|
panic::set_hook(Box::new(move |panic_info| {
|
||||||
|
tui::restore_raw().unwrap();
|
||||||
|
panic_hook(panic_info);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// convert from a color_eyre EyreHook to a eyre ErrorHook
|
||||||
|
let eyre_hook = eyre_hook.into_eyre_hook();
|
||||||
|
eyre::set_hook(Box::new(
|
||||||
|
move |error: &(dyn std::error::Error + 'static)| {
|
||||||
|
tui::restore_raw().unwrap();
|
||||||
|
eyre_hook(error)
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
31
src/main.rs
Normal file
31
src/main.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#![feature(variant_count)]
|
||||||
|
#![feature(let_chains)]
|
||||||
|
|
||||||
|
use color_eyre::Result;
|
||||||
|
|
||||||
|
pub mod utils;
|
||||||
|
mod prelude;
|
||||||
|
|
||||||
|
mod tui;
|
||||||
|
mod app;
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod task;
|
||||||
|
mod serial;
|
||||||
|
|
||||||
|
use prelude::*;
|
||||||
|
use serial::read_db;
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
error::install_hooks()?;
|
||||||
|
let mut term = tui::init()?;
|
||||||
|
let mut app = App::default();
|
||||||
|
|
||||||
|
app.tasks = read_db();
|
||||||
|
|
||||||
|
app.run(&mut term)?;
|
||||||
|
|
||||||
|
tui::restore(term)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
4
src/prelude.rs
Normal file
4
src/prelude.rs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pub use crate::tui::{ Tui };
|
||||||
|
pub use crate::app::{ App };
|
||||||
|
pub use crate::task::{ Task, TaskKind, TaskStatus, TaskList };
|
||||||
|
pub use crate::utils;
|
||||||
51
src/serial.rs
Normal file
51
src/serial.rs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
use chrono::{ DateTime, Utc };
|
||||||
|
use itertools::Itertools;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use std::fs::read_to_string;
|
||||||
|
|
||||||
|
|
||||||
|
pub fn read_db () -> TaskList {
|
||||||
|
// @todo read from argument or config file
|
||||||
|
let file = read_to_string("data.db").unwrap();
|
||||||
|
|
||||||
|
file.lines().filter_map(|line| {
|
||||||
|
let [id, name, description, status, expiration, kind] = line.split(" | ").collect_vec()[..] else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = Uuid::parse_str(id).ok()?;
|
||||||
|
|
||||||
|
Some(Task {
|
||||||
|
id,
|
||||||
|
name: name.to_owned(),
|
||||||
|
description: unescape::unescape(description).unwrap_or_else(|| description.to_owned()),
|
||||||
|
status: TaskStatus::from_data_part(status),
|
||||||
|
expiring_at: parse_expiration_part(expiration),
|
||||||
|
kind: TaskKind::from_data_part(kind),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.sorted_unstable_by(|at, bt| at.expiring_at.unwrap_or(DateTime::<Utc>::MAX_UTC).cmp(&bt.expiring_at.unwrap_or(DateTime::<Utc>::MAX_UTC)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_expiration_part (part: &str) -> Option<DateTime<Utc>> {
|
||||||
|
if part == "never" { return None; }
|
||||||
|
|
||||||
|
DateTime::from_timestamp(part.parse().ok()?, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::read_db;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn basic () {
|
||||||
|
let list = read_db();
|
||||||
|
println!("{:?}", list);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/task.rs
Normal file
76
src/task.rs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::{ DateTime, Duration, NaiveDateTime, Utc };
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub type TaskList = Vec<Task>;
|
||||||
|
pub type EnabledReccurentTemplated = Vec<RecurringTaskTemplate>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Task {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub kind: TaskKind,
|
||||||
|
|
||||||
|
pub status: TaskStatus,
|
||||||
|
pub expiring_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RecurringTaskTemplate {
|
||||||
|
pub template_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub period: RecurrenceKind,
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
pub status: TaskStatus,
|
||||||
|
pub expiring_duration: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum TaskStatus {
|
||||||
|
Done,
|
||||||
|
Cancelled,
|
||||||
|
#[default]
|
||||||
|
Pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskStatus {
|
||||||
|
pub fn from_data_part (part: &str) -> Self {
|
||||||
|
match part.chars().next() {
|
||||||
|
Some('p') => Self::Pending,
|
||||||
|
Some('c') => Self::Cancelled,
|
||||||
|
Some('d') => Self::Done,
|
||||||
|
_ => Self::Pending // or should we panic here?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub enum TaskKind {
|
||||||
|
#[default]
|
||||||
|
Oneshot,
|
||||||
|
Recurring {
|
||||||
|
parent: Uuid
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskKind {
|
||||||
|
pub fn from_data_part (_part: &str) -> Self {
|
||||||
|
Self::Oneshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes on parsing RecurrenceKind:
|
||||||
|
/// @TODO
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum RecurrenceKind {
|
||||||
|
Simple (Duration),
|
||||||
|
WeekDays (Vec<usize>),
|
||||||
|
MonthDays (Vec<usize>),
|
||||||
|
YearDays (Vec<usize>),
|
||||||
|
Hours (Vec<usize>),
|
||||||
|
Composite (Vec<RecurrenceKind>),
|
||||||
|
}
|
||||||
28
src/tui.rs
Normal file
28
src/tui.rs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
use std::io::{ self, stderr, Stderr };
|
||||||
|
|
||||||
|
use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::* };
|
||||||
|
use ratatui::prelude::*;
|
||||||
|
|
||||||
|
pub type Tui = Terminal<CrosstermBackend<Stderr>>;
|
||||||
|
|
||||||
|
pub fn init () -> io::Result<Tui> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stderr = io::stderr();
|
||||||
|
execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
|
||||||
|
let backend = CrosstermBackend::new(stderr);
|
||||||
|
Terminal::new(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore (mut term: Tui) -> io::Result<()> {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||||
|
term.show_cursor()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_raw () -> io::Result<()> {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(stderr(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
76
src/utils.rs
Normal file
76
src/utils.rs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
use chrono::TimeDelta;
|
||||||
|
|
||||||
|
/// Stolen from `cargo` crate
|
||||||
|
pub fn truncate_with_ellipsis (s: &str, max_width: usize) -> String {
|
||||||
|
// We should truncate at grapheme-boundary and compute character-widths,
|
||||||
|
// yet the dependencies on unicode-segmentation and unicode-width are
|
||||||
|
// not worth it.
|
||||||
|
let mut chars = s.chars();
|
||||||
|
let mut prefix = (&mut chars).take(max_width - 1).collect::<String>();
|
||||||
|
if chars.next().is_some() {
|
||||||
|
prefix.push('…');
|
||||||
|
}
|
||||||
|
prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take two biggest time units we can fit/fill the delta in (years/months, months/days,
|
||||||
|
/// days/hours, hours/minutes, minutes/seconds, seconds), format them into a string
|
||||||
|
pub fn dt_format (mut delta: TimeDelta) -> String {
|
||||||
|
let mut parts = vec![];
|
||||||
|
|
||||||
|
let expired = delta.num_seconds().is_negative();
|
||||||
|
delta = delta.abs();
|
||||||
|
|
||||||
|
'fmt : {
|
||||||
|
let years = delta.num_days() / 365;
|
||||||
|
if years > 0 {
|
||||||
|
delta -= TimeDelta::days(years * 365);
|
||||||
|
parts.push(format!("{years}y"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let months = delta.num_days() / 30;
|
||||||
|
if months > 0 {
|
||||||
|
delta -= TimeDelta::days(months * 30);
|
||||||
|
parts.push(format!("{months}mo"));
|
||||||
|
if parts.len() == 2 { break 'fmt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let days = delta.num_days();
|
||||||
|
if days > 0 {
|
||||||
|
delta -= TimeDelta::days(days);
|
||||||
|
parts.push(format!("{days}d"));
|
||||||
|
if parts.len() == 2 { break 'fmt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let hours = delta.num_hours();
|
||||||
|
if hours > 0 {
|
||||||
|
delta -= TimeDelta::hours(hours);
|
||||||
|
parts.push(format!("{hours}h"));
|
||||||
|
if parts.len() == 2 { break 'fmt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let minutes = delta.num_minutes();
|
||||||
|
if minutes > 0 {
|
||||||
|
delta -= TimeDelta::minutes(minutes);
|
||||||
|
parts.push(format!("{minutes}m"));
|
||||||
|
if parts.len() == 2 { break 'fmt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let seconds = delta.num_seconds();
|
||||||
|
if seconds > 0 {
|
||||||
|
delta -= TimeDelta::seconds(seconds);
|
||||||
|
parts.push(format!("{seconds}s"));
|
||||||
|
if parts.len() == 2 { break 'fmt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.len() == 0 {
|
||||||
|
parts.push(String::from("now"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expired {
|
||||||
|
format!("{} ago", parts.join(" "))
|
||||||
|
} else {
|
||||||
|
format!("in {}", parts.join(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user