Initial commit

This commit is contained in:
Dheepak Krishnamurthy 2020-07-21 23:22:01 -06:00
commit 55befc91a0
7 changed files with 1637 additions and 0 deletions

383
src/app.rs Normal file
View file

@ -0,0 +1,383 @@
use serde::{Deserialize, Serialize};
use serde_json::Result;
use shlex::split;
use std::cmp::Ordering;
use std::convert::TryInto;
use std::process::Command;
use task_hookrs::import::import;
use task_hookrs::task::Task;
use task_hookrs::uda::UDAValue;
use task_hookrs::date::Date;
use unicode_width::UnicodeWidthStr;
use chrono::{Local, DateTime, TimeZone, Duration, NaiveDateTime};
use tui::{
backend::{Backend, TermionBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
terminal::Frame,
text::Text,
widgets::{BarChart, Block, Borders, Row, Paragraph, Table, TableState},
Terminal,
};
pub fn cmp(t1: &Task, t2: &Task) -> Ordering {
let urgency1 = match &t1.uda()["urgency"] {
UDAValue::Str(_) => 0.0,
UDAValue::U64(u) => *u as f64,
UDAValue::F64(f) => *f,
};
let urgency2 = match &t2.uda()["urgency"] {
UDAValue::Str(_) => 0.0,
UDAValue::U64(u) => *u as f64,
UDAValue::F64(f) => *f,
};
urgency2.partial_cmp(&urgency1).unwrap()
}
pub fn vague_format_date_time(dt: &Date) -> String {
let now = Local::now().naive_utc();
let seconds = (now - NaiveDateTime::new(dt.date(), dt.time())).num_seconds();
if seconds >= 60 * 60 * 24 * 365 {
return format!("{}y", seconds / 86400 / 365);
} else if seconds >= 60 * 60 * 24 * 90 {
return format!("{}mo", seconds / 60 / 60 / 24 / 30);
} else if seconds >= 60 * 60 * 24 * 14 {
return format!("{}w", seconds / 60 / 60 / 24 / 7);
} else if seconds >= 60 * 60 * 24 {
return format!("{}d", seconds / 60 / 60 / 24);
} else if seconds >= 60 * 60 {
return format!("{}h", seconds / 60 / 60);
} else if seconds >= 60 {
return format!("{}min", seconds / 60);
} else {
return format!("{}s", seconds);
}
}
pub enum InputMode {
Normal,
Command,
}
pub struct App {
pub should_quit: bool,
pub state: TableState,
pub filter: String,
pub tasks: Vec<Task>,
pub task_report_labels: Vec<String>,
pub task_report_columns: Vec<String>,
pub input_mode: InputMode,
}
impl App {
pub fn new() -> App {
let mut app = App {
should_quit: false,
state: TableState::default(),
tasks: vec![],
task_report_labels: vec![],
task_report_columns: vec![],
filter: "status:pending ".to_string(),
input_mode: InputMode::Normal,
};
app.update();
app
}
pub fn draw(&mut self, f: &mut Frame<impl Backend>) {
let rects = Layout::default()
.constraints([
Constraint::Percentage(48),
Constraint::Percentage(48),
Constraint::Max(3),
].as_ref())
.split(f.size());
self.draw_task_report(f, rects[0]);
self.draw_task_details(f, rects[1]);
self.draw_command(f, rects[2]);
match self.input_mode {
InputMode::Normal => (),
InputMode::Command => {
// Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering
f.set_cursor(
// Put cursor past the end of the input text
rects[2].x + self.filter.width() as u16 + 1,
// Move one line down, from the border to the input line
rects[2].y + 1,
)
}
}
}
fn draw_command(&mut self, f: &mut Frame<impl Backend>, rect: Rect) {
let p = Paragraph::new(Text::from(&self.filter[..]))
.block(Block::default().borders(Borders::ALL).title("Command"));
f.render_widget(p, rect);
}
fn draw_task_details(&mut self, f: &mut Frame<impl Backend>, rect: Rect) {
if self.tasks.len() == 0 {
f.render_widget(Block::default().borders(Borders::ALL).title("Task not found"), rect);
return ();
}
let selected = self.state.selected().unwrap_or_default();
let task_id = self.tasks[selected].id().unwrap_or_default();
let output = Command::new("task")
.arg(format!("{}", task_id))
.output()
.expect(
&format!("Unable to show details for `task {}`. Check documentation for more information", task_id)[..]
);
let data = String::from_utf8(output.stdout).unwrap();
let p = Paragraph::new(Text::from(&data[..]))
.block(Block::default().borders(Borders::ALL).title(format!("Task {}", task_id)));
f.render_widget(p, rect);
}
fn draw_task_report(&mut self, f: &mut Frame<impl Backend>, rect: Rect) {
let active_style = Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD);
let normal_style = Style::default();
let (tasks, headers, widths) = self.task_report();
if tasks.len() == 0 {
f.render_widget(Block::default().borders(Borders::ALL).title("Task next"), rect);
return ();
}
let header = headers.iter();
let rows = tasks
.iter()
.map(|i| Row::StyledData(i.iter(), normal_style));
let constraints: Vec<Constraint> = widths
.iter()
.map(|i| Constraint::Percentage(std::cmp::min(50, std::cmp::max(*i, 5)).try_into().unwrap()))
.collect();
let t = Table::new(header, rows)
.block(Block::default().borders(Borders::ALL).title("Task next"))
.highlight_style(normal_style.add_modifier(Modifier::BOLD))
.highlight_symbol("")
.widths(&constraints);
f.render_stateful_widget(t, rect, &mut self.state);
}
pub fn get_string_attribute(&self, attribute: &str, task: &Task) -> String {
let s = match attribute {
"id" => task.id().unwrap_or_default().to_string(),
// "entry" => task.entry().unwrap().to_string(),
"entry" => vague_format_date_time(task.entry()),
"start" => match task.start() {
Some(v) => vague_format_date_time(v),
None => "".to_string(),
},
"description" => task.description().to_string(),
"urgency" => match &task.uda()["urgency"] {
UDAValue::Str(s) => "0.0".to_string(),
UDAValue::U64(u) => (*u as f64).to_string(),
UDAValue::F64(f) => (*f).to_string(),
},
_ => "".to_string(),
};
return s;
}
pub fn task_report(&mut self) -> (Vec<Vec<String>>, Vec<String>, Vec<i16>) {
let mut alltasks = vec![];
// get all tasks as their string representation
for task in &self.tasks {
let mut item = vec![];
for name in &self.task_report_columns {
let attribute = name.split(".").collect::<Vec<&str>>()[0];
let s = self.get_string_attribute(attribute, task);
item.push(s);
}
alltasks.push(item)
}
// find which columns are empty
let null_columns_len;
if alltasks.len() > 0 {
null_columns_len = alltasks[0].len();
} else {
return (vec![], vec![], vec![]);
}
let mut null_columns = vec![0; null_columns_len];
for task in &alltasks {
for (i, s) in task.iter().enumerate() {
null_columns[i] += s.len();
}
}
// filter out columns where everything is empty
let mut tasks = vec![];
for task in &alltasks {
let t = task.clone();
let t: Vec<String> = t
.iter()
.enumerate()
.filter(|&(i, _)| null_columns[i] != 0)
.map(|(_, e)| e.to_owned())
.collect();
tasks.push(t);
}
// filter out header where all columns are empty
let headers: Vec<String> = self
.task_report_labels
.iter()
.enumerate()
.filter(|&(i, _)| null_columns[i] != 0)
.map(|(_, e)| e.to_owned())
.collect();
// set widths proportional to the content
let mut widths: Vec<i16> = vec![0; tasks[0].len()];
for task in &tasks {
for (i, attr) in task.iter().enumerate() {
widths[i] = (attr.len() as i16 * 100
/ task.iter().map(|s| s.len() as i16).sum::<i16>())
.try_into()
.unwrap();
}
}
return (tasks, headers, widths);
}
pub fn update(&mut self) {
self.export_tasks();
self.export_headers();
}
pub fn next(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.tasks.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.state.select(Some(i));
}
pub fn previous(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.tasks.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
}
pub fn export_headers(&mut self) {
self.task_report_columns = vec![];
self.task_report_labels = vec![];
let output = Command::new("task")
.arg("show")
.arg("report.next.columns")
.output()
.expect("Unable to run `task show report.next.columns`. Check documentation for more information");
let data = String::from_utf8(output.stdout).unwrap();
for line in data.split("\n") {
if line.starts_with("report.next.columns") {
let column_names: &str = line.split(" ").collect::<Vec<&str>>()[1];
for column in column_names.split(",") {
self.task_report_columns.push(column.to_string());
}
}
}
let output = Command::new("task")
.arg("show")
.arg("report.next.labels")
.output()
.expect("Unable to run `task show report.next.labels`. Check documentation for more information");
let data = String::from_utf8(output.stdout).unwrap();
for line in data.split("\n") {
if line.starts_with("report.next.labels") {
let label_names: &str = line.split(" ").collect::<Vec<&str>>()[1];
for label in label_names.split(",") {
self.task_report_labels.push(label.to_string());
}
}
}
}
pub fn export_tasks(&mut self) {
let mut task = Command::new("task");
task.arg("export");
match split(&self.filter) {
Some(cmd) => {
for s in cmd {
task.arg(&s);
}
}
None => {
task.arg("");
}
}
let output = task
.output()
.expect("Unable to run `task export`. Check documentation for more information");
let data = String::from_utf8(output.stdout).unwrap();
let imported = import(data.as_bytes());
match imported {
Ok(i) => {
self.tasks = i;
self.tasks.sort_by(cmp);
}
_ => ()
}
}
}
#[cfg(test)]
mod tests {
use crate::app::App;
use std::io::stdin;
use task_hookrs::import::import;
use task_hookrs::task::Task;
#[test]
fn test_app() {
let mut app = App::new();
app.update();
println!("{:?}", app.task_report_columns);
println!("{:?}", app.task_report_labels);
let (t, h, c) = app.task_report();
println!("{:?}", t);
println!("{:?}", t);
println!("{:?}", t);
// if let Ok(tasks) = import(stdin()) {
// for task in tasks {
// println!("Task: {}, entered {:?} is {} -> {}",
// task.uuid(),
// task.entry(),
// task.status(),
// task.description());
// }
// }
}
}

83
src/main.rs Normal file
View file

@ -0,0 +1,83 @@
#![allow(dead_code)]
#![allow(unused_imports)]
#![allow(unused_variables)]
mod util;
#[allow(dead_code)]
mod app;
use crate::util::{Event, Events, Config};
use std::{error::Error, io};
use termion::{
event::Key,
input::MouseTerminal,
raw::{IntoRawMode, RawTerminal},
screen::AlternateScreen,
};
use tui::{backend::TermionBackend, Terminal};
use unicode_width::UnicodeWidthStr;
use std::time::Duration;
use app::App;
use app::InputMode;
type B = TermionBackend<AlternateScreen<MouseTerminal<RawTerminal<io::Stdout>>>>;
fn setup_terminal() -> Result<Terminal<B>, io::Error> {
let stdout = io::stdout().into_raw_mode()?;
let stdout = MouseTerminal::from(stdout);
let stdout = AlternateScreen::from(stdout);
let backend = TermionBackend::new(stdout);
Terminal::new(backend)
}
fn main() -> Result<(), Box<dyn Error>> {
// Terminal initialization
let mut terminal = setup_terminal()?;
// Setup event handlers
let events = Events::with_config(Config{
exit_key: Key::Char('q'),
tick_rate: Duration::from_secs(5),
});
let mut app = App::new();
app.next();
loop {
terminal.draw(|mut frame| app.draw(&mut frame)).unwrap();
// Handle input
if let Event::Input(input) = events.next()? {
match app.input_mode {
InputMode::Normal => match input {
Key::Ctrl('c') | Key::Char('q') => break,
Key::Char('r') => app.update(),
Key::Down | Key::Char('j') => app.next(),
Key::Up | Key::Char('k') => app.previous(),
Key::Char('i') => {
app.input_mode = InputMode::Command;
}
_ => {},
},
InputMode::Command => match input {
Key::Char('\n') | Key::Esc => {
app.input_mode = InputMode::Normal;
}
Key::Char(c) => {
app.filter.push(c);
app.update();
}
Key::Backspace => {
app.filter.pop();
app.update();
}
_ => {}
},
}
}
}
Ok(())
}

96
src/util.rs Normal file
View file

@ -0,0 +1,96 @@
#[cfg(feature = "termion")]
use std::io;
use std::sync::mpsc;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread;
use std::time::Duration;
use termion::event::Key;
use termion::input::TermRead;
pub enum Event<I> {
Input(I),
Tick,
}
/// A small event handler that wrap termion input and tick events. Each event
/// type is handled in its own thread and returned to a common `Receiver`
pub struct Events {
rx: mpsc::Receiver<Event<Key>>,
input_handle: thread::JoinHandle<()>,
ignore_exit_key: Arc<AtomicBool>,
tick_handle: thread::JoinHandle<()>,
}
#[derive(Debug, Clone, Copy)]
pub struct Config {
pub exit_key: Key,
pub tick_rate: Duration,
}
impl Default for Config {
fn default() -> Config {
Config {
exit_key: Key::Char('q'),
tick_rate: Duration::from_millis(250),
}
}
}
impl Events {
pub fn new() -> Events {
Events::with_config(Config::default())
}
pub fn with_config(config: Config) -> Events {
let (tx, rx) = mpsc::channel();
let ignore_exit_key = Arc::new(AtomicBool::new(false));
let input_handle = {
let tx = tx.clone();
let ignore_exit_key = ignore_exit_key.clone();
thread::spawn(move || {
let stdin = io::stdin();
for evt in stdin.keys() {
if let Ok(key) = evt {
if let Err(err) = tx.send(Event::Input(key)) {
eprintln!("{}", err);
return;
}
if !ignore_exit_key.load(Ordering::Relaxed) && key == config.exit_key {
return;
}
}
}
})
};
let tick_handle = {
thread::spawn(move || loop {
if tx.send(Event::Tick).is_err() {
break;
}
thread::sleep(config.tick_rate);
})
};
Events {
rx,
ignore_exit_key,
input_handle,
tick_handle,
}
}
pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> {
self.rx.recv()
}
pub fn disable_exit_key(&mut self) {
self.ignore_exit_key.store(true, Ordering::Relaxed);
}
pub fn enable_exit_key(&mut self) {
self.ignore_exit_key.store(false, Ordering::Relaxed);
}
}