From 6311cc3f97107e8265d7d2e4fc15abee6481c6c3 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Tue, 23 Mar 2021 09:03:11 -0600 Subject: [PATCH] Add multiple selection --- Cargo.lock | 11 +++ Cargo.toml | 1 + src/app.rs | 181 ++++++++++++++++++++++++++++++----------------- src/keyconfig.rs | 6 ++ src/table.rs | 126 ++++++++++++++++++++++++++++----- 5 files changed, 241 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce24be8..2940bac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,6 +808,7 @@ dependencies = [ "shellexpand", "shlex", "task-hookrs", + "tinyset", "tui", "unicode-segmentation", "unicode-width", @@ -842,6 +843,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "tinyset" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784f540960a63144d63992caf430ed87e39d920f2c474cb8ac586ff31fb861fc" +dependencies = [ + "itertools", + "rand", +] + [[package]] name = "tui" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index 64dec19..e52958d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ rustyline = "7.1.0" uuid = { version = "0.8.1", features = ["serde", "v4"] } better-panic = "0.2.0" shellexpand = "2.1" +tinyset = "0.4" [package.metadata.rpm] package = "taskwarrior-tui" diff --git a/src/app.rs b/src/app.rs index 69344c3..c55b090 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use crate::config::Config; use crate::context::Context; use crate::help::Help; use crate::keyconfig::KeyConfig; -use crate::table::{Row, Table, TableState}; +use crate::table::{Row, Table, TableMode, TableState}; use crate::task_report::TaskReportTable; use crate::util::Key; use crate::util::{Event, Events}; @@ -320,10 +320,22 @@ impl TTApp { self.draw_task_details(f, split_task_layout[1]); } let selected = self.task_table_state.selected().unwrap_or_default(); - let task_id = if tasks_len == 0 { - 0 + let task_ids = if tasks_len == 0 { + vec!["0".to_string()] } else { - self.tasks.lock().unwrap()[selected].id().unwrap_or_default() + match self.task_table_state.mode() { + TableMode::SingleSelection => vec![self.tasks.lock().unwrap()[selected] + .id() + .unwrap_or_default() + .to_string()], + TableMode::MultipleSelection => { + let mut tids = vec![]; + for s in self.task_table_state.marked() { + tids.push(self.tasks.lock().unwrap()[s].id().unwrap_or_default().to_string()); + } + tids + } + } }; match self.mode { AppMode::TaskReport => self.draw_command(f, rects[1], self.filter.as_str(), "Filter Tasks"), @@ -346,10 +358,7 @@ impl TTApp { f, rects[1], self.command.as_str(), - Span::styled( - format!("Modify Task {}", task_id).as_str(), - Style::default().add_modifier(Modifier::BOLD), - ), + Span::styled("Log Tasks", Style::default().add_modifier(Modifier::BOLD)), ); } AppMode::TaskSubprocess => { @@ -360,32 +369,39 @@ impl TTApp { f, rects[1], self.command.as_str(), - Span::styled("Log Tasks", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("Shell Command", Style::default().add_modifier(Modifier::BOLD)), ); } AppMode::TaskModify => { let position = self.get_position(&self.modify); f.set_cursor(rects[1].x + position as u16 + 1, rects[1].y + 1); f.render_widget(Clear, rects[1]); + let label = if task_ids.len() > 1 { + format!("Modify Tasks {}", task_ids.join(",")) + } else { + format!("Modify Task {}", task_ids.join(",")) + }; self.draw_command( f, rects[1], self.modify.as_str(), - Span::styled("Shell Command", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(label, Style::default().add_modifier(Modifier::BOLD)), ); } AppMode::TaskAnnotate => { let position = self.get_position(&self.command); f.set_cursor(rects[1].x + position as u16 + 1, rects[1].y + 1); f.render_widget(Clear, rects[1]); + let label = if task_ids.len() > 1 { + format!("Annotate Tasks {}", task_ids.join(",")) + } else { + format!("Annotate Task {}", task_ids.join(",")) + }; self.draw_command( f, rects[1], self.command.as_str(), - Span::styled( - format!("Annotate Task {}", task_id).as_str(), - Style::default().add_modifier(Modifier::BOLD), - ), + Span::styled(label, Style::default().add_modifier(Modifier::BOLD)), ); } AppMode::TaskAdd => { @@ -656,6 +672,9 @@ impl TTApp { if header == "ID" || header == "Name" { // always give ID a couple of extra for indicator widths[i] += self.config.uda_selection_indicator.as_str().graphemes(true).count(); + if let TableMode::MultipleSelection = self.task_table_state.mode() { + widths[i] += 2 + }; } } @@ -1037,7 +1056,7 @@ impl TTApp { return Ok(()); } - let shell = self.command.as_str().replace("'", "\\'"); + let shell = self.command.as_str(); match shlex::split(&shell) { Some(cmd) => { @@ -1072,7 +1091,7 @@ impl TTApp { command.arg("log"); - let shell = self.command.as_str().replace("'", "\\'"); + let shell = self.command.as_str(); match shlex::split(&shell) { Some(cmd) => { @@ -1091,10 +1110,7 @@ impl TTApp { )), } } - None => Err(format!( - "Unable to run `task log`. Cannot shlex split `{}`", - shell.as_str() - )), + None => Err(format!("Unable to run `task log`. Cannot shlex split `{}`", shell)), } } @@ -1102,16 +1118,26 @@ impl TTApp { if self.tasks.lock().unwrap().is_empty() { return Ok(()); } - let selected = self.task_table_state.selected().unwrap_or_default(); - let task_id = self.tasks.lock().unwrap()[selected].id().unwrap_or_default(); - let task_uuid = *self.tasks.lock().unwrap()[selected].uuid(); + let selected = match self.task_table_state.mode() { + TableMode::SingleSelection => vec![self.task_table_state.selected().unwrap_or_default()], + TableMode::MultipleSelection => self.task_table_state.marked().collect::>(), + }; + + let mut task_uuids = vec![]; + + for s in selected { + let task_id = self.tasks.lock().unwrap()[s].id().unwrap_or_default(); + let task_uuid = *self.tasks.lock().unwrap()[s].uuid(); + task_uuids.push(task_uuid.to_string()); + } + let shell = &self.config.uda_shortcuts[s]; if shell.is_empty() { return Err("Trying to run empty shortcut.".to_string()); } - let shell = format!("{} {}", shell, task_uuid); + let shell = format!("{} {}", shell, task_uuids.join(" ")); let shell = shellexpand::tilde(&shell).into_owned(); match shlex::split(&shell) { Some(cmd) => { @@ -1143,14 +1169,28 @@ impl TTApp { if self.tasks.lock().unwrap().is_empty() { return Ok(()); } - let selected = self.task_table_state.selected().unwrap_or_default(); - let task_id = self.tasks.lock().unwrap()[selected].id().unwrap_or_default(); - let task_uuid = *self.tasks.lock().unwrap()[selected].uuid(); - let mut command = Command::new("task"); - command.arg("rc.confirmation=off"); - command.arg(format!("{}", task_uuid)).arg("modify"); - let shell = self.modify.as_str().replace("'", "\\'"); + let selected = match self.task_table_state.mode() { + TableMode::SingleSelection => vec![self.task_table_state.selected().unwrap_or_default()], + TableMode::MultipleSelection => self.task_table_state.marked().collect::>(), + }; + + let mut task_uuids = vec![]; + + for s in selected { + let task_id = self.tasks.lock().unwrap()[s].id().unwrap_or_default(); + let task_uuid = *self.tasks.lock().unwrap()[s].uuid(); + task_uuids.push(task_uuid.to_string()); + } + + let mut command = Command::new("task"); + command.arg("rc.bulk=0"); + command.arg("rc.confirmation=off"); + command.arg("rc.dependency.confirmation=off"); + command.arg("rc.recurrence.confirmation=off"); + command.arg(task_uuids.join(" ")).arg("modify"); + + let shell = self.modify.as_str(); match shlex::split(&shell) { Some(cmd) => { @@ -1164,23 +1204,16 @@ impl TTApp { self.modify.update("", 0); Ok(()) } else { - Err(format!( - "Unable to modify task with uuid {}. Failed with status code {}", - task_uuid, - o.status.code().unwrap() - )) + Err(format!("Modify failed. {}", String::from_utf8_lossy(&o.stdout),)) } } Err(_) => Err(format!( - "Cannot run `task {} modify {}`. Check documentation for more information", - task_uuid, shell, + "Cannot run `task {:?} modify {}`. Check documentation for more information", + task_uuids, shell, )), } } - None => Err(format!( - "Unable to run `task {} modify`. Cannot shlex split `{}`", - task_uuid, shell, - )), + None => Err(format!("Cannot shlex split `{}`", shell,)), } } @@ -1188,13 +1221,28 @@ impl TTApp { if self.tasks.lock().unwrap().is_empty() { return Ok(()); } - let selected = self.task_table_state.selected().unwrap_or_default(); - let task_id = self.tasks.lock().unwrap()[selected].id().unwrap_or_default(); - let task_uuid = *self.tasks.lock().unwrap()[selected].uuid(); - let mut command = Command::new("task"); - command.arg(format!("{}", task_uuid)).arg("annotate"); - let shell = self.command.as_str().replace("'", "\\'"); + let selected = match self.task_table_state.mode() { + TableMode::SingleSelection => vec![self.task_table_state.selected().unwrap_or_default()], + TableMode::MultipleSelection => self.task_table_state.marked().collect::>(), + }; + + let mut task_uuids = vec![]; + + for s in selected { + let task_id = self.tasks.lock().unwrap()[s].id().unwrap_or_default(); + let task_uuid = *self.tasks.lock().unwrap()[s].uuid(); + task_uuids.push(task_uuid.to_string()); + } + + let mut command = Command::new("task"); + command.arg("rc.bulk=0"); + command.arg("rc.confirmation=off"); + command.arg("rc.dependency.confirmation=off"); + command.arg("rc.recurrence.confirmation=off"); + command.arg(task_uuids.join(" ")).arg("annotate"); + + let shell = self.command.as_str(); match shlex::split(&shell) { Some(cmd) => { @@ -1208,23 +1256,17 @@ impl TTApp { self.command.update("", 0); Ok(()) } else { - Err(format!( - "Unable to annotate task with uuid {}. Failed with status code {}", - task_uuid, - o.status.code().unwrap() - )) + Err(format!("Annotate failed. {}", String::from_utf8_lossy(&o.stdout),)) } } Err(_) => Err(format!( "Cannot run `task {} annotate {}`. Check documentation for more information", - task_uuid, shell + task_uuids.join(" "), + shell )), } } - None => Err(format!( - "Unable to run `task {} annotate`. Cannot shlex split `{}`", - task_uuid, shell - )), + None => Err(format!("Cannot shlex split `{}`", shell)), } } @@ -1538,8 +1580,14 @@ impl TTApp { ) -> Result<(), Box> { match self.mode { AppMode::TaskReport => { - if input == self.keyconfig.quit || input == Key::Ctrl('c') { + if input == Key::Esc { + self.task_table_state.single_selection(); + self.task_table_state.clear(); + } else if input == self.keyconfig.quit || input == Key::Ctrl('c') { self.should_quit = true; + } else if input == self.keyconfig.select { + self.task_table_state.multiple_selection(); + self.task_table_state.toggle_mark(self.task_table_state.selected()); } else if input == self.keyconfig.refresh { self.update(true)?; } else if input == self.keyconfig.go_to_bottom || input == Key::End { @@ -1603,12 +1651,15 @@ impl TTApp { } } else if input == self.keyconfig.modify { self.mode = AppMode::TaskModify; - match self.task_current() { - Some(t) => { - let s = format!("{} ", t.description()); - self.modify.update(&s, s.as_str().len()) - } - None => self.modify.update("", 0), + match self.task_table_state.mode() { + TableMode::SingleSelection => match self.task_current() { + Some(t) => { + let s = format!("{} ", t.description()); + self.modify.update(&s, s.as_str().len()) + } + None => self.modify.update("", 0), + }, + TableMode::MultipleSelection => self.modify.update("", 0), } } else if input == self.keyconfig.shell { self.mode = AppMode::TaskSubprocess; diff --git a/src/keyconfig.rs b/src/keyconfig.rs index b92ffba..a0300de 100644 --- a/src/keyconfig.rs +++ b/src/keyconfig.rs @@ -18,6 +18,7 @@ pub struct KeyConfig { pub delete: Key, pub done: Key, pub start_stop: Key, + pub select: Key, pub undo: Key, pub edit: Key, pub modify: Key, @@ -57,6 +58,7 @@ impl Default for KeyConfig { delete: Key::Char('x'), done: Key::Char('d'), start_stop: Key::Char('s'), + select: Key::Char('v'), undo: Key::Char('u'), edit: Key::Char('e'), modify: Key::Char('m'), @@ -117,6 +119,9 @@ impl KeyConfig { self.start_stop = self .get_config("uda.taskwarrior-tui.keyconfig.start-stop") .unwrap_or(self.start_stop); + self.select = self + .get_config("uda.taskwarrior-tui.keyconfig.select") + .unwrap_or(self.select); self.undo = self .get_config("uda.taskwarrior-tui.keyconfig.undo") .unwrap_or(self.undo); @@ -164,6 +169,7 @@ impl KeyConfig { &self.page_up, &self.delete, &self.done, + &self.select, &self.start_stop, &self.undo, &self.edit, diff --git a/src/table.rs b/src/table.rs index bca6581..cc65539 100644 --- a/src/table.rs +++ b/src/table.rs @@ -8,6 +8,7 @@ use std::{ fmt::Display, iter::{self, Iterator}, }; +use tinyset::SetUsize; use tui::{ buffer::Buffer, layout::{Constraint, Rect}, @@ -19,31 +20,69 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone)] +pub enum TableMode { + SingleSelection, + MultipleSelection, +} + +#[derive(Clone)] pub struct TableState { offset: usize, - selected: Option, + current_selection: Option, + selected: SetUsize, + mode: TableMode, } impl Default for TableState { fn default() -> TableState { TableState { offset: 0, - selected: None, + current_selection: None, + selected: SetUsize::new(), + mode: TableMode::SingleSelection, } } } impl TableState { + pub fn mode(&self) -> TableMode { + self.mode.clone() + } + + pub fn multiple_selection(&mut self) { + self.mode = TableMode::MultipleSelection; + } + + pub fn single_selection(&mut self) { + self.mode = TableMode::SingleSelection; + } + pub fn selected(&self) -> Option { - self.selected + self.current_selection } pub fn select(&mut self, index: Option) { - self.selected = index; + self.current_selection = index; if index.is_none() { self.offset = 0; } } + + pub fn toggle_mark(&mut self, index: Option) { + if let Some(i) = index { + if !self.selected.insert(i) { + self.selected.remove(i); + } + } + } + + pub fn marked(&self) -> impl Iterator + '_ { + self.selected.iter() + } + + pub fn clear(&mut self) { + self.selected.drain().for_each(drop); + } } /// Holds data to be displayed in a Table widget @@ -99,8 +138,10 @@ pub struct Table<'a, H, R> { header_gap: u16, /// Style used to render the selected row highlight_style: Style, - /// Symbol in front of the selected rom + /// Symbol in front of the selected row highlight_symbol: Option<&'a str>, + /// Symbol in front of the marked row + mark_symbol: Option<&'a str>, /// Data to display in each row rows: R, } @@ -121,6 +162,7 @@ where header_gap: 1, highlight_style: Style::default(), highlight_symbol: None, + mark_symbol: None, rows: R::default(), } } @@ -143,6 +185,7 @@ where header_gap: 1, highlight_style: Style::default(), highlight_symbol: None, + mark_symbol: None, rows, } } @@ -304,12 +347,32 @@ where y += 1 + self.header_gap; // Use highlight_style only if something is selected - let (selected, highlight_style) = match state.selected { - Some(i) => (Some(i), self.highlight_style), - None => (None, self.style), + let (selected, highlight_style) = if state.selected().is_some() { + (state.selected(), self.highlight_style) + } else { + (None, self.style) + }; + + let highlight_symbol = match state.mode { + TableMode::MultipleSelection => { + let s = self.highlight_symbol.unwrap_or("• ").trim_end(); + format!("[{}] ", s) + } + TableMode::SingleSelection => self.highlight_symbol.unwrap_or("").to_string(), + }; + + let mark_symbol = match state.mode { + TableMode::MultipleSelection => { + let s = self.mark_symbol.unwrap_or("x ").trim_end(); + format!("[{}] ", s) + } + TableMode::SingleSelection => self.highlight_symbol.unwrap_or("").to_string(), + }; + + let blank_symbol = match state.mode { + TableMode::MultipleSelection => "[ ] ".to_string(), + TableMode::SingleSelection => iter::repeat(" ").take(highlight_symbol.width()).collect::(), }; - let highlight_symbol = self.highlight_symbol.unwrap_or(""); - let blank_symbol = iter::repeat(" ").take(highlight_symbol.width()).collect::(); // Draw rows let default_style = Style::default(); @@ -317,11 +380,11 @@ where let remaining = (table_area.bottom() - y) as usize; // Make sure the table shows the selected item - state.offset = if let Some(selected) = selected { - if selected >= remaining + state.offset - 1 { - selected + 1 - remaining - } else if selected < state.offset { - selected + state.offset = if let Some(s) = selected { + if s >= remaining + state.offset - 1 { + s + 1 - remaining + } else if s < state.offset { + s } else { state.offset } @@ -330,11 +393,32 @@ where }; for (i, row) in self.rows.skip(state.offset).take(remaining).enumerate() { let (data, style, symbol) = match row { - Row::Data(d) | Row::StyledData(d, _) if Some(i) == state.selected.map(|s| s - state.offset) => { - (d, highlight_style, highlight_symbol) + Row::Data(d) | Row::StyledData(d, _) if Some(i) == state.selected().map(|s| s - state.offset) => { + match state.mode { + TableMode::MultipleSelection => { + if state.selected.contains(i) { + (d, highlight_style, mark_symbol.to_string()) + } else { + (d, highlight_style, blank_symbol.to_string()) + } + } + TableMode::SingleSelection => (d, highlight_style, highlight_symbol.to_string()), + } + } + Row::Data(d) => { + if state.selected.contains(i) { + (d, default_style, mark_symbol.to_string()) + } else { + (d, default_style, blank_symbol.to_string()) + } + } + Row::StyledData(d, s) => { + if state.selected.contains(i) { + (d, s, mark_symbol.to_string()) + } else { + (d, s, blank_symbol.to_string()) + } } - Row::Data(d) => (d, default_style, blank_symbol.as_ref()), - Row::StyledData(d, s) => (d, s, blank_symbol.as_ref()), }; x = table_area.left(); for (c, (w, elt)) in solved_widths.iter().zip(data).enumerate() { @@ -347,6 +431,10 @@ where style, ); if c == header_index { + let symbol = match state.mode { + TableMode::SingleSelection => &symbol, + TableMode::MultipleSelection => &symbol, + }; format!( "{symbol}{elt:>width$}", symbol = symbol,