From 196d5bec0903eb43682b2c1addeb12aa5f9c6632 Mon Sep 17 00:00:00 2001 From: Arne Beer Date: Sun, 28 Aug 2022 17:33:02 +0200 Subject: [PATCH] refactor: Add TableBuilder --- Cargo.lock | 24 ++-- client/display/helper.rs | 73 +++++++---- client/display/mod.rs | 1 + client/display/state.rs | 203 ++++------------------------ client/display/style.rs | 1 + client/display/table_builder.rs | 225 ++++++++++++++++++++++++++++++++ 6 files changed, 311 insertions(+), 216 deletions(-) create mode 100644 client/display/table_builder.rs diff --git a/Cargo.lock b/Cargo.lock index c626b3a..1e7b91b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,9 +885,9 @@ dependencies = [ [[package]] name = "pest" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0560d531d1febc25a3c9398a62a71256c0178f2e3443baedd9ad4bb8c9deb4" +checksum = "cb779fcf4bb850fbbb0edc96ff6cf34fd90c4b1a112ce042653280d9a7364048" dependencies = [ "thiserror", "ucd-trie", @@ -895,9 +895,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "905708f7f674518498c1f8d644481440f476d39ca6ecae83319bba7c6c12da91" +checksum = "502b62a6d0245378b04ffe0a7fb4f4419a4815fce813bd8a0ec89a56e07d67b1" dependencies = [ "pest", "pest_generator", @@ -905,9 +905,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5803d8284a629cc999094ecd630f55e91b561a1d1ba75e233b00ae13b91a69ad" +checksum = "451e629bf49b750254da26132f1a5a9d11fd8a95a3df51d15c4abd1ba154cb6c" dependencies = [ "pest", "pest_meta", @@ -918,13 +918,13 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1538eb784f07615c6d9a8ab061089c6c54a344c5b4301db51990ca1c241e8c04" +checksum = "bcec162c71c45e269dfc3fc2916eaeb97feab22993a21bcce4721d08cd7801a6" dependencies = [ "once_cell", "pest", - "sha-1", + "sha1", ] [[package]] @@ -1425,10 +1425,10 @@ dependencies = [ ] [[package]] -name = "sha-1" -version = "0.10.0" +name = "sha1" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" +checksum = "006769ba83e921b3085caa8334186b00cf92b4cb1a6cf4632fbccc8eff5c7549" dependencies = [ "cfg-if", "cpufeatures", diff --git a/client/display/helper.rs b/client/display/helper.rs index 57cf37c..8397318 100644 --- a/client/display/helper.rs +++ b/client/display/helper.rs @@ -1,30 +1,8 @@ use std::collections::BTreeMap; -use pueue_lib::task::{Task, TaskStatus}; +use chrono::Local; -/// This is a helper function for working with tables when calling `pueue status`. -/// -/// By default, several columns aren't shown until there's at least one task with relevant data. -/// This function determines whether any of those columns should be shown. -pub fn has_special_columns(tasks: &[Task]) -> (bool, bool, bool) { - // Check whether there are any delayed tasks. - let has_delayed_tasks = tasks.iter().any(|task| { - matches!( - task.status, - TaskStatus::Stashed { - enqueue_at: Some(_) - } - ) - }); - - // Check whether there are any tasks with dependencies. - let has_dependencies = tasks.iter().any(|task| !task.dependencies.is_empty()); - - // Check whether there are any tasks a label. - let has_labels = tasks.iter().any(|task| task.label.is_some()); - - (has_delayed_tasks, has_dependencies, has_labels) -} +use pueue_lib::{settings::Settings, task::Task}; /// Sort given tasks by their groups. /// This is needed to print a table for each group. @@ -40,3 +18,50 @@ pub fn sort_tasks_by_group(tasks: Vec) -> BTreeMap> { sorted_task_groups } + +/// Returns the formatted `start` and `end` text for a given task. +/// +/// 1. If the start || end is today, skip the date. +/// 2. Otherwise show the date in both. +/// +/// If the task doesn't have a start and/or end yet, an empty string will be returned +/// for the respective field. +pub fn formatted_start_end(task: &Task, settings: &Settings) -> (String, String) { + // Get the start time. + // If the task didn't start yet, just return two empty strings. + let start = match task.start { + Some(start) => start, + None => return ("".into(), "".into()), + }; + + // If the task started today, just show the time. + // Otherwise show the full date and time. + let started_today = start >= Local::today().and_hms(0, 0, 0); + let formatted_start = if started_today { + start + .format(&settings.client.status_time_format) + .to_string() + } else { + start + .format(&settings.client.status_datetime_format) + .to_string() + }; + + // Get finish time, if already set. Otherwise only return the formatted start. + let end = match task.end { + Some(end) => end, + None => return (formatted_start, "".into()), + }; + + // If the task ended today we only show the time. + // In all other circumstances, we show the full date. + let finished_today = end >= Local::today().and_hms(0, 0, 0); + let formatted_end = if finished_today { + end.format(&settings.client.status_time_format).to_string() + } else { + end.format(&settings.client.status_datetime_format) + .to_string() + }; + + (formatted_start, formatted_end) +} diff --git a/client/display/mod.rs b/client/display/mod.rs index 525571a..87d48aa 100644 --- a/client/display/mod.rs +++ b/client/display/mod.rs @@ -8,6 +8,7 @@ pub mod helper; mod log; mod state; pub mod style; +mod table_builder; use crossterm::style::Color; diff --git a/client/display/state.rs b/client/display/state.rs index 7013628..98a7bed 100644 --- a/client/display/state.rs +++ b/client/display/state.rs @@ -1,14 +1,8 @@ -use std::string::ToString; - -use chrono::{Duration, Local}; -use comfy_table::presets::UTF8_HORIZONTAL_ONLY; -use comfy_table::*; - use pueue_lib::settings::Settings; use pueue_lib::state::{State, PUEUE_DEFAULT_GROUP}; -use pueue_lib::task::{Task, TaskResult, TaskStatus}; +use pueue_lib::task::Task; -use super::{helper::*, OutputStyle}; +use super::{helper::*, table_builder::TableBuilder, OutputStyle}; use crate::cli::SubCommand; use crate::display::group::get_group_headline; @@ -17,12 +11,12 @@ use crate::display::group::get_group_headline; /// /// We pass the tasks as a separate parameter and as a list. /// This allows us to print the tasks in any user-defined order. -pub fn print_state( +pub fn print_state<'a>( state: State, tasks: Vec, cli_command: &SubCommand, - style: &OutputStyle, - settings: &Settings, + style: &'a OutputStyle, + settings: &'a Settings, ) { let (json, group_only) = match cli_command { SubCommand::Status { json, group } => (*json, group.clone()), @@ -36,12 +30,14 @@ pub fn print_state( return; } + let table_builder = TableBuilder::new(settings, style); + if let Some(group) = group_only { - print_single_group(state, tasks, settings, style, group); + print_single_group(state, tasks, style, group, table_builder); return; } - print_all_groups(state, tasks, settings, style); + print_all_groups(state, tasks, style, table_builder); } /// The user requested only a single group to be displayed. @@ -50,9 +46,9 @@ pub fn print_state( fn print_single_group( state: State, tasks: Vec, - settings: &Settings, style: &OutputStyle, group_name: String, + table_builder: TableBuilder, ) { // Sort all tasks by their respective group; let mut sorted_tasks = sort_tasks_by_group(tasks); @@ -74,13 +70,20 @@ fn print_single_group( println!("Task list is empty. Add tasks with `pueue add -g {group_name} -- [cmd]`"); return; } - print_table(tasks, style, settings); + + let table = table_builder.build(tasks); + println!("{table}"); } /// Print all groups. All tasks will be shown in the table of their assigned group. /// /// This will create multiple tables, one table for each group. -fn print_all_groups(state: State, tasks: Vec, settings: &Settings, style: &OutputStyle) { +fn print_all_groups( + state: State, + tasks: Vec, + style: &OutputStyle, + table_builder: TableBuilder, +) { // Early exit and hint if there are no tasks in the queue // Print the state of the default group anyway, since this is information one wants to // see most of the time anyway. @@ -107,7 +110,8 @@ fn print_all_groups(state: State, tasks: Vec, settings: &Settings, style: style, ); println!("{headline}"); - print_table(tasks, style, settings); + let table = table_builder.clone().build(tasks); + println!("{table}"); // Add a newline if there are further groups to be printed if sorted_tasks.len() > 1 { @@ -126,7 +130,8 @@ fn print_all_groups(state: State, tasks: Vec, settings: &Settings, style: let headline = get_group_headline(group, state.groups.get(group).unwrap(), style); println!("{headline}"); - print_table(tasks, style, settings); + let table = table_builder.clone().build(tasks); + println!("{table}"); // Add a newline between groups if sorted_iter.peek().is_some() { @@ -134,165 +139,3 @@ fn print_all_groups(state: State, tasks: Vec, settings: &Settings, style: } } } - -/// Display tasks in a nicely formatted table. -/// -/// For info on how this works, take a look at [comfy_table]. -fn print_table(tasks: &[Task], style: &OutputStyle, settings: &Settings) { - let (has_delayed_tasks, has_dependencies, has_labels) = has_special_columns(tasks); - - // Create table header row - let mut headers = vec![Cell::new("Id"), Cell::new("Status")]; - - if has_delayed_tasks { - headers.push(Cell::new("Enqueue At")); - } - if has_dependencies { - headers.push(Cell::new("Deps")); - } - if has_labels { - headers.push(Cell::new("Label")); - } - - headers.append(&mut vec![ - Cell::new("Command"), - Cell::new("Path"), - Cell::new("Start"), - Cell::new("End"), - ]); - - // Initialize comfy table. - let mut table = Table::new(); - table - .set_content_arrangement(ContentArrangement::Dynamic) - .load_preset(UTF8_HORIZONTAL_ONLY) - .set_header(headers); - - // Explicitly force styling, in case we aren't on a tty, but `--color=always` is set. - if style.enabled { - table.enforce_styling(); - } - - // Add rows one by one. - for task in tasks.iter() { - let mut row = Row::new(); - if let Some(height) = settings.client.max_status_lines { - row.max_height(height); - } - row.add_cell(Cell::new(&task.id)); - - // Determine the human readable task status representation and the respective color. - let status_string = task.status.to_string(); - let (status_text, color) = match &task.status { - TaskStatus::Running => (status_string, Color::Green), - TaskStatus::Paused | TaskStatus::Locked => (status_string, Color::White), - TaskStatus::Done(result) => match result { - TaskResult::Success => (TaskResult::Success.to_string(), Color::Green), - TaskResult::DependencyFailed => ("Dependency failed".to_string(), Color::Red), - TaskResult::FailedToSpawn(_) => ("Failed to spawn".to_string(), Color::Red), - TaskResult::Failed(code) => (format!("Failed ({code})"), Color::Red), - _ => (result.to_string(), Color::Red), - }, - _ => (status_string, Color::Yellow), - }; - row.add_cell(style.styled_cell(status_text, Some(color), None)); - - if has_delayed_tasks { - if let TaskStatus::Stashed { - enqueue_at: Some(enqueue_at), - } = task.status - { - // Only show the date if the task is not supposed to be enqueued today. - let enqueue_today = - enqueue_at <= Local::today().and_hms(0, 0, 0) + Duration::days(1); - let formatted_enqueue_at = if enqueue_today { - enqueue_at.format(&settings.client.status_time_format) - } else { - enqueue_at.format(&settings.client.status_datetime_format) - }; - row.add_cell(Cell::new(formatted_enqueue_at)); - } else { - row.add_cell(Cell::new("")); - } - } - - if has_dependencies { - let text = task - .dependencies - .iter() - .map(|id| id.to_string()) - .collect::>() - .join(", "); - row.add_cell(Cell::new(text)); - } - - if has_labels { - row.add_cell(Cell::new(&task.label.as_deref().unwrap_or_default())); - } - - // Add command and path. - if settings.client.show_expanded_aliases { - row.add_cell(Cell::new(&task.command)); - } else { - row.add_cell(Cell::new(&task.original_command)); - } - row.add_cell(Cell::new(&task.path.to_string_lossy())); - - // Add start and end info - let (start, end) = formatted_start_end(task, settings); - row.add_cell(Cell::new(start)); - row.add_cell(Cell::new(end)); - - table.add_row(row); - } - - // Print the table. - println!("{table}"); -} - -/// Returns the formatted `start` and `end` text for a given task. -/// -/// 1. If the start || end is today, skip the date. -/// 2. Otherwise show the date in both. -/// -/// If the task doesn't have a start and/or end yet, an empty string will be returned -/// for the respective field. -fn formatted_start_end(task: &Task, settings: &Settings) -> (String, String) { - // Get the start time. - // If the task didn't start yet, just return two empty strings. - let start = match task.start { - Some(start) => start, - None => return ("".into(), "".into()), - }; - - // If the task started today, just show the time. - // Otherwise show the full date and time. - let started_today = start >= Local::today().and_hms(0, 0, 0); - let formatted_start = if started_today { - start - .format(&settings.client.status_time_format) - .to_string() - } else { - start - .format(&settings.client.status_datetime_format) - .to_string() - }; - - // Get finish time, if already set. Otherwise only return the formatted start. - let end = match task.end { - Some(end) => end, - None => return (formatted_start, "".into()), - }; - - // If the task ended today we only show the time. - // In all other circumstances, we show the full date. - let finished_today = end >= Local::today().and_hms(0, 0, 0); - let formatted_end = if finished_today { - end.format(&settings.client.status_time_format).to_string() - } else { - end.format(&settings.client.status_datetime_format) - .to_string() - }; - - (formatted_start, formatted_end) -} diff --git a/client/display/style.rs b/client/display/style.rs index 9254d2e..b0c1705 100644 --- a/client/display/style.rs +++ b/client/display/style.rs @@ -6,6 +6,7 @@ use crossterm::style::{style, Attribute, Color, Stylize}; /// OutputStyle wrapper for actual colors depending on settings /// - Enables styles if color mode is 'always', or if color mode is 'auto' and output is a tty. /// - Using dark colors if dark_mode is enabled +#[derive(Debug, Clone)] pub struct OutputStyle { /// whether or not ANSI styling is enabled pub enabled: bool, diff --git a/client/display/table_builder.rs b/client/display/table_builder.rs new file mode 100644 index 0000000..34d0bf9 --- /dev/null +++ b/client/display/table_builder.rs @@ -0,0 +1,225 @@ +use chrono::{Duration, Local}; +use comfy_table::presets::UTF8_HORIZONTAL_ONLY; +use comfy_table::*; + +use pueue_lib::settings::Settings; +use pueue_lib::task::{Task, TaskResult, TaskStatus}; + +use super::helper::formatted_start_end; +use super::OutputStyle; + +/// This builder is responsible for determining which table columns should be displayed and +/// building a full [comfy_table] from a list of given [Task]s. +#[derive(Debug, Clone)] +pub struct TableBuilder<'a> { + settings: &'a Settings, + style: &'a OutputStyle, + + /// This following fields represent which columns should be displayed when executing + /// `pueue status`. `true` for any column means that it'll be shown in the table. + id: bool, + status: bool, + enqueue_at: bool, + dependencies: bool, + label: bool, + command: bool, + path: bool, + start: bool, + end: bool, +} + +impl<'a> TableBuilder<'a> { + pub fn new(settings: &'a Settings, style: &'a OutputStyle) -> Self { + Self { + settings, + style, + + id: true, + status: true, + enqueue_at: false, + dependencies: false, + label: false, + command: true, + path: true, + start: true, + end: true, + } + } + + pub fn build(mut self, tasks: &[Task]) -> Table { + self.determine_special_columns(tasks); + + let mut table = Table::new(); + table + .set_content_arrangement(ContentArrangement::Dynamic) + .load_preset(UTF8_HORIZONTAL_ONLY) + .set_header(self.build_header()) + .add_rows(self.build_task_rows(tasks)); + + // Explicitly force styling, in case we aren't on a tty, but `--color=always` is set. + if self.style.enabled { + table.enforce_styling(); + } + + table + } + + /// By default, several columns aren't shown until there's at least one task with relevant data. + /// This function determines whether any of those columns should be shown. + fn determine_special_columns(&mut self, tasks: &[Task]) { + // Check whether there are any delayed tasks. + let has_delayed_tasks = tasks.iter().any(|task| { + matches!( + task.status, + TaskStatus::Stashed { + enqueue_at: Some(_) + } + ) + }); + if has_delayed_tasks { + self.enqueue_at = true; + } + + // Check whether there are any tasks with dependencies. + if tasks.iter().any(|task| !task.dependencies.is_empty()) { + self.dependencies = true; + } + + // Check whether there are any tasks a label. + if tasks.iter().any(|task| task.label.is_some()) { + self.label = true; + } + } + + /// Build a header row based on the current selection of columns. + fn build_header(&self) -> Row { + let mut header = Vec::new(); + + // Create table header row + if self.id { + header.push(Cell::new("Id")); + } + if self.status { + header.push(Cell::new("Status")); + } + + if self.enqueue_at { + header.push(Cell::new("Enqueue At")); + } + if self.dependencies { + header.push(Cell::new("Deps")); + } + if self.label { + header.push(Cell::new("Label")); + } + if self.command { + header.push(Cell::new("Command")); + } + if self.path { + header.push(Cell::new("Path")); + } + if self.start { + header.push(Cell::new("Start")); + } + if self.end { + header.push(Cell::new("End")); + } + + Row::from(header) + } + + fn build_task_rows(&self, tasks: &[Task]) -> Vec { + let mut rows = Vec::new(); + // Add rows one by one. + for task in tasks.iter() { + let mut row = Row::new(); + // Users can set a max height per row. + if let Some(height) = self.settings.client.max_status_lines { + row.max_height(height); + } + + if self.id { + row.add_cell(Cell::new(&task.id)); + } + + if self.status { + // Determine the human readable task status representation and the respective color. + let status_string = task.status.to_string(); + let (status_text, color) = match &task.status { + TaskStatus::Running => (status_string, Color::Green), + TaskStatus::Paused | TaskStatus::Locked => (status_string, Color::White), + TaskStatus::Done(result) => match result { + TaskResult::Success => (TaskResult::Success.to_string(), Color::Green), + TaskResult::DependencyFailed => { + ("Dependency failed".to_string(), Color::Red) + } + TaskResult::FailedToSpawn(_) => ("Failed to spawn".to_string(), Color::Red), + TaskResult::Failed(code) => (format!("Failed ({code})"), Color::Red), + _ => (result.to_string(), Color::Red), + }, + _ => (status_string, Color::Yellow), + }; + row.add_cell(self.style.styled_cell(status_text, Some(color), None)); + } + + if self.enqueue_at { + if let TaskStatus::Stashed { + enqueue_at: Some(enqueue_at), + } = task.status + { + // Only show the date if the task is not supposed to be enqueued today. + let enqueue_today = + enqueue_at <= Local::today().and_hms(0, 0, 0) + Duration::days(1); + let formatted_enqueue_at = if enqueue_today { + enqueue_at.format(&self.settings.client.status_time_format) + } else { + enqueue_at.format(&self.settings.client.status_datetime_format) + }; + row.add_cell(Cell::new(formatted_enqueue_at)); + } else { + row.add_cell(Cell::new("")); + } + } + + if self.dependencies { + let text = task + .dependencies + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", "); + row.add_cell(Cell::new(text)); + } + + if self.label { + row.add_cell(Cell::new(&task.label.as_deref().unwrap_or_default())); + } + + // Add command and path. + if self.command { + if self.settings.client.show_expanded_aliases { + row.add_cell(Cell::new(&task.command)); + } else { + row.add_cell(Cell::new(&task.original_command)); + } + } + + if self.path { + row.add_cell(Cell::new(&task.path.to_string_lossy())); + } + + // Add start and end info + let (start, end) = formatted_start_end(task, self.settings); + if self.start { + row.add_cell(Cell::new(start)); + } + if self.end { + row.add_cell(Cell::new(end)); + } + + rows.push(row); + } + + rows + } +}