mirror of
https://github.com/kdheepak/taskwarrior-tui.git
synced 2025-08-24 23:46:41 +02:00
WIP
This commit is contained in:
parent
84589c3ab3
commit
ff6c0e60b9
6 changed files with 220 additions and 94 deletions
18
src/app.rs
18
src/app.rs
|
@ -33,7 +33,7 @@ pub struct App {
|
|||
impl App {
|
||||
pub fn new(tick_rate: f64, frame_rate: f64, report: &str) -> Result<Self> {
|
||||
let app = TaskReport::new().report(report.into());
|
||||
let config = Config::new()?;
|
||||
let config = Config::new().unwrap();
|
||||
let mode = Mode::TaskReport;
|
||||
Ok(Self {
|
||||
tick_rate,
|
||||
|
@ -75,11 +75,21 @@ impl App {
|
|||
tui::Event::Render => action_tx.send(Action::Render)?,
|
||||
tui::Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
|
||||
tui::Event::Key(key) => {
|
||||
self.last_tick_key_events.push(key);
|
||||
if let Some(keymap) = self.config.keybindings.get(&self.mode) {
|
||||
if let Some(action) = keymap.get(&self.last_tick_key_events) {
|
||||
if let Some(action) = keymap.get(&vec![key.clone()]) {
|
||||
log::info!("Got action: {action:?}");
|
||||
action_tx.send(action.clone())?;
|
||||
};
|
||||
} else {
|
||||
// If the key was not handled as a single key action,
|
||||
// then consider it for multi-key combinations.
|
||||
self.last_tick_key_events.push(key);
|
||||
|
||||
// Check for multi-key combinations
|
||||
if let Some(action) = keymap.get(&self.last_tick_key_events) {
|
||||
log::info!("Got action: {action:?}");
|
||||
action_tx.send(action.clone())?;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
_ => {},
|
||||
|
|
|
@ -40,7 +40,7 @@ pub trait Component {
|
|||
Ok(None)
|
||||
}
|
||||
#[allow(unused_variables)]
|
||||
fn update(&mut self, command: Action) -> Result<Option<Action>> {
|
||||
fn update(&mut self, action: Action) -> Result<Option<Action>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()>;
|
||||
|
|
|
@ -18,75 +18,6 @@ use crate::{
|
|||
config::{Config, KeyBindings},
|
||||
};
|
||||
|
||||
pub fn format_date_time(dt: NaiveDateTime) -> String {
|
||||
let dt = Local.from_local_datetime(&dt).unwrap();
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
pub fn format_date(dt: NaiveDateTime) -> String {
|
||||
let offset = Local.offset_from_utc_datetime(&dt);
|
||||
let dt = DateTime::<Local>::from_naive_utc_and_offset(dt, offset);
|
||||
dt.format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
pub fn vague_format_date_time(from_dt: NaiveDateTime, to_dt: NaiveDateTime, with_remainder: bool) -> String {
|
||||
let to_dt = Local.from_local_datetime(&to_dt).unwrap();
|
||||
let from_dt = Local.from_local_datetime(&from_dt).unwrap();
|
||||
let mut seconds = (to_dt - from_dt).num_seconds();
|
||||
let minus = if seconds < 0 {
|
||||
seconds *= -1;
|
||||
"-"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let year = 60 * 60 * 24 * 365;
|
||||
let month = 60 * 60 * 24 * 30;
|
||||
let week = 60 * 60 * 24 * 7;
|
||||
let day = 60 * 60 * 24;
|
||||
let hour = 60 * 60;
|
||||
let minute = 60;
|
||||
|
||||
if seconds >= 60 * 60 * 24 * 365 {
|
||||
return if with_remainder {
|
||||
format!("{}{}y{}mo", minus, seconds / year, (seconds - year * (seconds / year)) / month)
|
||||
} else {
|
||||
format!("{}{}y", minus, seconds / year)
|
||||
};
|
||||
} else if seconds >= 60 * 60 * 24 * 90 {
|
||||
return if with_remainder {
|
||||
format!("{}{}mo{}w", minus, seconds / month, (seconds - month * (seconds / month)) / week)
|
||||
} else {
|
||||
format!("{}{}mo", minus, seconds / month)
|
||||
};
|
||||
} else if seconds >= 60 * 60 * 24 * 14 {
|
||||
return if with_remainder {
|
||||
format!("{}{}w{}d", minus, seconds / week, (seconds - week * (seconds / week)) / day)
|
||||
} else {
|
||||
format!("{}{}w", minus, seconds / week)
|
||||
};
|
||||
} else if seconds >= 60 * 60 * 24 {
|
||||
return if with_remainder {
|
||||
format!("{}{}d{}h", minus, seconds / day, (seconds - day * (seconds / day)) / hour)
|
||||
} else {
|
||||
format!("{}{}d", minus, seconds / day)
|
||||
};
|
||||
} else if seconds >= 60 * 60 {
|
||||
return if with_remainder {
|
||||
format!("{}{}h{}min", minus, seconds / hour, (seconds - hour * (seconds / hour)) / minute)
|
||||
} else {
|
||||
format!("{}{}h", minus, seconds / hour)
|
||||
};
|
||||
} else if seconds >= 60 {
|
||||
return if with_remainder {
|
||||
format!("{}{}min{}s", minus, seconds / minute, (seconds - minute * (seconds / minute)))
|
||||
} else {
|
||||
format!("{}{}min", minus, seconds / minute)
|
||||
};
|
||||
}
|
||||
format!("{}{}s", minus, seconds)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TaskReport {
|
||||
pub config: Config,
|
||||
|
@ -97,13 +28,15 @@ pub struct TaskReport {
|
|||
pub current_context_filter: String,
|
||||
pub tasks: Vec<Task>,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub headers: Vec<String>,
|
||||
pub state: TableState,
|
||||
pub columns: Vec<String>,
|
||||
pub labels: Vec<String>,
|
||||
pub date_time_vague_precise: bool,
|
||||
pub virtual_tags: Vec<String>,
|
||||
pub description_width: usize,
|
||||
pub current_selection: usize,
|
||||
pub current_selection_id: Option<u64>,
|
||||
pub current_selection_uuid: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl TaskReport {
|
||||
|
@ -178,9 +111,9 @@ impl TaskReport {
|
|||
}
|
||||
}
|
||||
}
|
||||
let num_labels = self.labels.len();
|
||||
let num_columns = self.columns.len();
|
||||
assert!(num_labels == num_columns, "Must have the same number of labels (currently {}) and columns (currently {}). Compare their values as shown by \"task show report.{}.\" and fix your taskwarrior config.", num_labels, num_columns, &self.report);
|
||||
if self.labels.len() != self.columns.len() {
|
||||
return Err(color_eyre::eyre::eyre!(format!("`{}` expects to have the same number of labels and columns ({} != {}). Compare their values as shown by `task show report.{}.` and fix your taskwarrior config.", env!("CARGO_PKG_NAME"), self.labels.len(), self.columns.len(), &self.report)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -470,7 +403,7 @@ impl TaskReport {
|
|||
|
||||
task.arg(&self.report);
|
||||
|
||||
log::info!("Running `{:?}`", task);
|
||||
log::debug!("Running `{:?}`", task);
|
||||
let output = task.output()?;
|
||||
let data = String::from_utf8_lossy(&output.stdout);
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
|
@ -479,7 +412,7 @@ impl TaskReport {
|
|||
let imported = import(data.as_bytes());
|
||||
if imported.is_ok() {
|
||||
self.tasks = imported?;
|
||||
log::info!("Imported {} tasks", self.tasks.len());
|
||||
log::debug!("Imported {} tasks", self.tasks.len());
|
||||
self.send_action(Action::ShowTaskReport)?;
|
||||
} else {
|
||||
imported?;
|
||||
|
@ -490,6 +423,50 @@ impl TaskReport {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next(&mut self) {
|
||||
if self.tasks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = {
|
||||
if self.current_selection >= self.tasks.len() - 1 {
|
||||
if self.config.task_report.looping {
|
||||
0
|
||||
} else {
|
||||
self.current_selection
|
||||
}
|
||||
} else {
|
||||
self.current_selection + 1
|
||||
}
|
||||
};
|
||||
self.current_selection = i;
|
||||
self.current_selection_id = None;
|
||||
self.current_selection_uuid = None;
|
||||
self.state.select(Some(self.current_selection));
|
||||
log::info!("{:?}", self.state);
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
if self.tasks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = {
|
||||
if self.current_selection == 0 {
|
||||
if self.config.task_report.looping {
|
||||
self.tasks.len() - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
self.current_selection - 1
|
||||
}
|
||||
};
|
||||
self.current_selection = i;
|
||||
self.current_selection_id = None;
|
||||
self.current_selection_uuid = None;
|
||||
self.state.select(Some(self.current_selection));
|
||||
log::info!("{:?}", self.state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for TaskReport {
|
||||
|
@ -503,30 +480,124 @@ impl Component for TaskReport {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn update(&mut self, command: Action) -> Result<Option<Action>> {
|
||||
match command {
|
||||
fn update(&mut self, action: Action) -> Result<Option<Action>> {
|
||||
match action {
|
||||
Action::Tick => {
|
||||
self.task_export()?;
|
||||
self.export_headers()?;
|
||||
self.generate_rows()?;
|
||||
},
|
||||
Action::MoveDown => self.next(),
|
||||
Action::MoveUp => self.previous(),
|
||||
_ => {},
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
|
||||
let mut constraints = vec![];
|
||||
if self.rows.len() == 0 {
|
||||
f.render_widget(Paragraph::new("No data found").block(Block::new().borders(Borders::all())), rect);
|
||||
return Ok(());
|
||||
}
|
||||
let mut total_fixed_widths = 0;
|
||||
let mut constraints = Vec::with_capacity(self.rows[0].len());
|
||||
|
||||
for i in 0..self.rows[0].len() {
|
||||
constraints.push(Constraint::Percentage(100 / self.rows[0].len() as u16));
|
||||
if self.columns[i] == "description" {
|
||||
constraints.push(Constraint::Min(0)); // temporary, will update later
|
||||
} else {
|
||||
let max_width = self.rows.iter().map(|row| row[i].len() as u16).max().unwrap_or(0);
|
||||
total_fixed_widths += max_width + 2; // adding 2 for padding
|
||||
constraints.push(Constraint::Length(max_width + 2));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pos) = self.columns.iter().position(|x| x == "description") {
|
||||
let description_width = rect.width.saturating_sub(total_fixed_widths).saturating_sub(4);
|
||||
constraints[pos] = Constraint::Length(description_width);
|
||||
}
|
||||
let rows = self.rows.iter().map(|row| Row::new(row.clone()));
|
||||
let table = Table::new(rows).header(Row::new(self.headers.clone())).widths(&constraints);
|
||||
let table = Table::new(rows)
|
||||
.header(Row::new(self.columns.clone()))
|
||||
.widths(&constraints)
|
||||
.block(Block::new().borders(Borders::ALL))
|
||||
.highlight_symbol(&self.config.task_report.selection_indicator)
|
||||
.highlight_spacing(HighlightSpacing::Always);
|
||||
f.render_stateful_widget(table, rect, &mut self.state);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_date_time(dt: NaiveDateTime) -> String {
|
||||
let dt = Local.from_local_datetime(&dt).unwrap();
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
pub fn format_date(dt: NaiveDateTime) -> String {
|
||||
let offset = Local.offset_from_utc_datetime(&dt);
|
||||
let dt = DateTime::<Local>::from_naive_utc_and_offset(dt, offset);
|
||||
dt.format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
pub fn vague_format_date_time(from_dt: NaiveDateTime, to_dt: NaiveDateTime, with_remainder: bool) -> String {
|
||||
let to_dt = Local.from_local_datetime(&to_dt).unwrap();
|
||||
let from_dt = Local.from_local_datetime(&from_dt).unwrap();
|
||||
let mut seconds = (to_dt - from_dt).num_seconds();
|
||||
let minus = if seconds < 0 {
|
||||
seconds *= -1;
|
||||
"-"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let year = 60 * 60 * 24 * 365;
|
||||
let month = 60 * 60 * 24 * 30;
|
||||
let week = 60 * 60 * 24 * 7;
|
||||
let day = 60 * 60 * 24;
|
||||
let hour = 60 * 60;
|
||||
let minute = 60;
|
||||
|
||||
if seconds >= 60 * 60 * 24 * 365 {
|
||||
return if with_remainder {
|
||||
format!("{}{}y{}mo", minus, seconds / year, (seconds - year * (seconds / year)) / month)
|
||||
} else {
|
||||
format!("{}{}y", minus, seconds / year)
|
||||
};
|
||||
} else if seconds >= 60 * 60 * 24 * 90 {
|
||||
return if with_remainder {
|
||||
format!("{}{}mo{}w", minus, seconds / month, (seconds - month * (seconds / month)) / week)
|
||||
} else {
|
||||
format!("{}{}mo", minus, seconds / month)
|
||||
};
|
||||
} else if seconds >= 60 * 60 * 24 * 14 {
|
||||
return if with_remainder {
|
||||
format!("{}{}w{}d", minus, seconds / week, (seconds - week * (seconds / week)) / day)
|
||||
} else {
|
||||
format!("{}{}w", minus, seconds / week)
|
||||
};
|
||||
} else if seconds >= 60 * 60 * 24 {
|
||||
return if with_remainder {
|
||||
format!("{}{}d{}h", minus, seconds / day, (seconds - day * (seconds / day)) / hour)
|
||||
} else {
|
||||
format!("{}{}d", minus, seconds / day)
|
||||
};
|
||||
} else if seconds >= 60 * 60 {
|
||||
return if with_remainder {
|
||||
format!("{}{}h{}min", minus, seconds / hour, (seconds - hour * (seconds / hour)) / minute)
|
||||
} else {
|
||||
format!("{}{}h", minus, seconds / hour)
|
||||
};
|
||||
} else if seconds >= 60 {
|
||||
return if with_remainder {
|
||||
format!("{}{}min{}s", minus, seconds / minute, (seconds - minute * (seconds / minute)))
|
||||
} else {
|
||||
format!("{}{}min", minus, seconds / minute)
|
||||
};
|
||||
}
|
||||
format!("{}{}s", minus, seconds)
|
||||
}
|
||||
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
use std::{collections::HashMap, fmt, path::PathBuf};
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
use config::Value;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use derive_deref::{Deref, DerefMut};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
@ -11,6 +12,24 @@ use crate::{action::Action, app::Mode};
|
|||
|
||||
const CONFIG: &str = include_str!("../.config/config.json5");
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Default)]
|
||||
pub struct TaskReportConfig {
|
||||
#[serde(default)]
|
||||
pub looping: bool,
|
||||
#[serde(default)]
|
||||
pub selection_indicator: String,
|
||||
}
|
||||
|
||||
impl Into<Value> for TaskReportConfig {
|
||||
fn into(self) -> Value {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("looping".to_string(), Value::from(self.looping));
|
||||
map.insert("selection_indicator".to_string(), Value::from(self.selection_indicator));
|
||||
|
||||
Value::from(map)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Default)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
|
@ -21,7 +40,9 @@ pub struct AppConfig {
|
|||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default, flatten)]
|
||||
#[serde(default)]
|
||||
pub task_report: TaskReportConfig,
|
||||
#[serde(default)]
|
||||
pub config: AppConfig,
|
||||
#[serde(default)]
|
||||
pub keybindings: KeyBindings,
|
||||
|
@ -35,15 +56,23 @@ impl Config {
|
|||
let data_dir = crate::utils::get_data_dir();
|
||||
let config_dir = crate::utils::get_config_dir();
|
||||
let mut builder = config::Config::builder()
|
||||
.set_default("task_report", default_config.task_report)?
|
||||
.set_default("_data_dir", data_dir.to_str().unwrap())?
|
||||
.set_default("_config_dir", config_dir.to_str().unwrap())?;
|
||||
|
||||
builder = builder
|
||||
.add_source(config::File::from(config_dir.join("config.json5")).format(config::FileFormat::Json5).required(false))
|
||||
.add_source(config::File::from(config_dir.join("config.json")).format(config::FileFormat::Json).required(false))
|
||||
.add_source(config::File::from(config_dir.join("config.yaml")).format(config::FileFormat::Yaml).required(false))
|
||||
.add_source(config::File::from(config_dir.join("config.toml")).format(config::FileFormat::Toml).required(false))
|
||||
.add_source(config::File::from(config_dir.join("config.ini")).format(config::FileFormat::Ini).required(false));
|
||||
// List of potential configuration files provided by the user
|
||||
let config_files = [
|
||||
("config.json5", config::FileFormat::Json5),
|
||||
("config.json", config::FileFormat::Json),
|
||||
("config.yaml", config::FileFormat::Yaml),
|
||||
("config.toml", config::FileFormat::Toml),
|
||||
("config.ini", config::FileFormat::Ini),
|
||||
];
|
||||
for (file, format) in &config_files {
|
||||
if config_dir.join(file).exists() {
|
||||
builder = builder.add_source(config::File::from(config_dir.join(file)).format(*format).required(false));
|
||||
}
|
||||
}
|
||||
|
||||
let mut cfg: Self = builder.build()?.try_deserialize()?;
|
||||
|
||||
|
@ -53,6 +82,12 @@ impl Config {
|
|||
user_bindings.entry(key.clone()).or_insert_with(|| cmd.clone());
|
||||
}
|
||||
}
|
||||
for (mode, default_styles) in default_config.styles.iter() {
|
||||
let user_styles = cfg.styles.entry(*mode).or_default();
|
||||
for (style_key, style) in default_styles.iter() {
|
||||
user_styles.entry(style_key.clone()).or_insert_with(|| style.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
|
|
|
@ -33,6 +33,10 @@ async fn tokio_main() -> Result<()> {
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tokio_main().await.unwrap();
|
||||
Ok(())
|
||||
if let Err(e) = tokio_main().await {
|
||||
eprintln!("{} error: Something went wrong", env!("CARGO_PKG_NAME"));
|
||||
Err(e)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue