squash old

This commit is contained in:
YK 2024-07-16 05:09:29 +03:00
commit 007398c576
15 changed files with 1784 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1092
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(" "))
}
}