diff --git a/.config/config.json5 b/.config/config.json5 index 34d7614..c77b96e 100644 --- a/.config/config.json5 +++ b/.config/config.json5 @@ -1,8 +1,14 @@ { + "task_report": { + "looping": false, + "selection_indicator": "• ", + }, "keybindings": { // KeyBindings for TaskReport "TaskReport": { "": "Quit", // Quit the application + "": "MoveDown", // MoveDown + "": "MoveUp", // MoveUp "": "Quit", // Another way to quit "": "Quit", // Yet another way to quit "": "Suspend" // Suspend the application diff --git a/src/app.rs b/src/app.rs index edcd45d..63cd873 100644 --- a/src/app.rs +++ b/src/app.rs @@ -33,7 +33,7 @@ pub struct App { impl App { pub fn new(tick_rate: f64, frame_rate: f64, report: &str) -> Result { 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())?; + } + } }; }, _ => {}, diff --git a/src/components.rs b/src/components.rs index a7d15fb..e167df1 100644 --- a/src/components.rs +++ b/src/components.rs @@ -40,7 +40,7 @@ pub trait Component { Ok(None) } #[allow(unused_variables)] - fn update(&mut self, command: Action) -> Result> { + fn update(&mut self, action: Action) -> Result> { Ok(None) } fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()>; diff --git a/src/components/task_report.rs b/src/components/task_report.rs index 4941e7c..e10cefd 100644 --- a/src/components/task_report.rs +++ b/src/components/task_report.rs @@ -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::::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, pub rows: Vec>, - pub headers: Vec, pub state: TableState, pub columns: Vec, pub labels: Vec, pub date_time_vague_precise: bool, pub virtual_tags: Vec, pub description_width: usize, + pub current_selection: usize, + pub current_selection_id: Option, + pub current_selection_uuid: Option, } 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> { - match command { + fn update(&mut self, action: Action) -> Result> { + 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::::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; diff --git a/src/config.rs b/src/config.rs index e2115bf..7f64871 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 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) } diff --git a/src/main.rs b/src/main.rs index e91a0cd..dff0f89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(()) + } }