Add multiple selection

This commit is contained in:
Dheepak Krishnamurthy 2021-03-23 09:03:11 -06:00
parent e5a8094105
commit 6311cc3f97
5 changed files with 241 additions and 84 deletions

11
Cargo.lock generated
View file

@ -808,6 +808,7 @@ dependencies = [
"shellexpand", "shellexpand",
"shlex", "shlex",
"task-hookrs", "task-hookrs",
"tinyset",
"tui", "tui",
"unicode-segmentation", "unicode-segmentation",
"unicode-width", "unicode-width",
@ -842,6 +843,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "tinyset"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784f540960a63144d63992caf430ed87e39d920f2c474cb8ac586ff31fb861fc"
dependencies = [
"itertools",
"rand",
]
[[package]] [[package]]
name = "tui" name = "tui"
version = "0.12.0" version = "0.12.0"

View file

@ -36,6 +36,7 @@ rustyline = "7.1.0"
uuid = { version = "0.8.1", features = ["serde", "v4"] } uuid = { version = "0.8.1", features = ["serde", "v4"] }
better-panic = "0.2.0" better-panic = "0.2.0"
shellexpand = "2.1" shellexpand = "2.1"
tinyset = "0.4"
[package.metadata.rpm] [package.metadata.rpm]
package = "taskwarrior-tui" package = "taskwarrior-tui"

View file

@ -4,7 +4,7 @@ use crate::config::Config;
use crate::context::Context; use crate::context::Context;
use crate::help::Help; use crate::help::Help;
use crate::keyconfig::KeyConfig; use crate::keyconfig::KeyConfig;
use crate::table::{Row, Table, TableState}; use crate::table::{Row, Table, TableMode, TableState};
use crate::task_report::TaskReportTable; use crate::task_report::TaskReportTable;
use crate::util::Key; use crate::util::Key;
use crate::util::{Event, Events}; use crate::util::{Event, Events};
@ -320,10 +320,22 @@ impl TTApp {
self.draw_task_details(f, split_task_layout[1]); self.draw_task_details(f, split_task_layout[1]);
} }
let selected = self.task_table_state.selected().unwrap_or_default(); let selected = self.task_table_state.selected().unwrap_or_default();
let task_id = if tasks_len == 0 { let task_ids = if tasks_len == 0 {
0 vec!["0".to_string()]
} else { } 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 { match self.mode {
AppMode::TaskReport => self.draw_command(f, rects[1], self.filter.as_str(), "Filter Tasks"), AppMode::TaskReport => self.draw_command(f, rects[1], self.filter.as_str(), "Filter Tasks"),
@ -346,10 +358,7 @@ impl TTApp {
f, f,
rects[1], rects[1],
self.command.as_str(), self.command.as_str(),
Span::styled( Span::styled("Log Tasks", Style::default().add_modifier(Modifier::BOLD)),
format!("Modify Task {}", task_id).as_str(),
Style::default().add_modifier(Modifier::BOLD),
),
); );
} }
AppMode::TaskSubprocess => { AppMode::TaskSubprocess => {
@ -360,32 +369,39 @@ impl TTApp {
f, f,
rects[1], rects[1],
self.command.as_str(), 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 => { AppMode::TaskModify => {
let position = self.get_position(&self.modify); let position = self.get_position(&self.modify);
f.set_cursor(rects[1].x + position as u16 + 1, rects[1].y + 1); f.set_cursor(rects[1].x + position as u16 + 1, rects[1].y + 1);
f.render_widget(Clear, rects[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( self.draw_command(
f, f,
rects[1], rects[1],
self.modify.as_str(), 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 => { AppMode::TaskAnnotate => {
let position = self.get_position(&self.command); let position = self.get_position(&self.command);
f.set_cursor(rects[1].x + position as u16 + 1, rects[1].y + 1); f.set_cursor(rects[1].x + position as u16 + 1, rects[1].y + 1);
f.render_widget(Clear, rects[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( self.draw_command(
f, f,
rects[1], rects[1],
self.command.as_str(), self.command.as_str(),
Span::styled( Span::styled(label, Style::default().add_modifier(Modifier::BOLD)),
format!("Annotate Task {}", task_id).as_str(),
Style::default().add_modifier(Modifier::BOLD),
),
); );
} }
AppMode::TaskAdd => { AppMode::TaskAdd => {
@ -656,6 +672,9 @@ impl TTApp {
if header == "ID" || header == "Name" { if header == "ID" || header == "Name" {
// always give ID a couple of extra for indicator // always give ID a couple of extra for indicator
widths[i] += self.config.uda_selection_indicator.as_str().graphemes(true).count(); 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(()); return Ok(());
} }
let shell = self.command.as_str().replace("'", "\\'"); let shell = self.command.as_str();
match shlex::split(&shell) { match shlex::split(&shell) {
Some(cmd) => { Some(cmd) => {
@ -1072,7 +1091,7 @@ impl TTApp {
command.arg("log"); command.arg("log");
let shell = self.command.as_str().replace("'", "\\'"); let shell = self.command.as_str();
match shlex::split(&shell) { match shlex::split(&shell) {
Some(cmd) => { Some(cmd) => {
@ -1091,10 +1110,7 @@ impl TTApp {
)), )),
} }
} }
None => Err(format!( None => Err(format!("Unable to run `task log`. Cannot shlex split `{}`", shell)),
"Unable to run `task log`. Cannot shlex split `{}`",
shell.as_str()
)),
} }
} }
@ -1102,16 +1118,26 @@ impl TTApp {
if self.tasks.lock().unwrap().is_empty() { if self.tasks.lock().unwrap().is_empty() {
return Ok(()); return Ok(());
} }
let selected = self.task_table_state.selected().unwrap_or_default(); let selected = match self.task_table_state.mode() {
let task_id = self.tasks.lock().unwrap()[selected].id().unwrap_or_default(); TableMode::SingleSelection => vec![self.task_table_state.selected().unwrap_or_default()],
let task_uuid = *self.tasks.lock().unwrap()[selected].uuid(); TableMode::MultipleSelection => self.task_table_state.marked().collect::<Vec<usize>>(),
};
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]; let shell = &self.config.uda_shortcuts[s];
if shell.is_empty() { if shell.is_empty() {
return Err("Trying to run empty shortcut.".to_string()); 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(); let shell = shellexpand::tilde(&shell).into_owned();
match shlex::split(&shell) { match shlex::split(&shell) {
Some(cmd) => { Some(cmd) => {
@ -1143,14 +1169,28 @@ impl TTApp {
if self.tasks.lock().unwrap().is_empty() { if self.tasks.lock().unwrap().is_empty() {
return Ok(()); 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::<Vec<usize>>(),
};
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) { match shlex::split(&shell) {
Some(cmd) => { Some(cmd) => {
@ -1164,23 +1204,16 @@ impl TTApp {
self.modify.update("", 0); self.modify.update("", 0);
Ok(()) Ok(())
} else { } else {
Err(format!( Err(format!("Modify failed. {}", String::from_utf8_lossy(&o.stdout),))
"Unable to modify task with uuid {}. Failed with status code {}",
task_uuid,
o.status.code().unwrap()
))
} }
} }
Err(_) => Err(format!( Err(_) => Err(format!(
"Cannot run `task {} modify {}`. Check documentation for more information", "Cannot run `task {:?} modify {}`. Check documentation for more information",
task_uuid, shell, task_uuids, shell,
)), )),
} }
} }
None => Err(format!( None => Err(format!("Cannot shlex split `{}`", shell,)),
"Unable to run `task {} modify`. Cannot shlex split `{}`",
task_uuid, shell,
)),
} }
} }
@ -1188,13 +1221,28 @@ impl TTApp {
if self.tasks.lock().unwrap().is_empty() { if self.tasks.lock().unwrap().is_empty() {
return Ok(()); 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::<Vec<usize>>(),
};
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) { match shlex::split(&shell) {
Some(cmd) => { Some(cmd) => {
@ -1208,23 +1256,17 @@ impl TTApp {
self.command.update("", 0); self.command.update("", 0);
Ok(()) Ok(())
} else { } else {
Err(format!( Err(format!("Annotate failed. {}", String::from_utf8_lossy(&o.stdout),))
"Unable to annotate task with uuid {}. Failed with status code {}",
task_uuid,
o.status.code().unwrap()
))
} }
} }
Err(_) => Err(format!( Err(_) => Err(format!(
"Cannot run `task {} annotate {}`. Check documentation for more information", "Cannot run `task {} annotate {}`. Check documentation for more information",
task_uuid, shell task_uuids.join(" "),
shell
)), )),
} }
} }
None => Err(format!( None => Err(format!("Cannot shlex split `{}`", shell)),
"Unable to run `task {} annotate`. Cannot shlex split `{}`",
task_uuid, shell
)),
} }
} }
@ -1538,8 +1580,14 @@ impl TTApp {
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
match self.mode { match self.mode {
AppMode::TaskReport => { 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; 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 { } else if input == self.keyconfig.refresh {
self.update(true)?; self.update(true)?;
} else if input == self.keyconfig.go_to_bottom || input == Key::End { } else if input == self.keyconfig.go_to_bottom || input == Key::End {
@ -1603,12 +1651,15 @@ impl TTApp {
} }
} else if input == self.keyconfig.modify { } else if input == self.keyconfig.modify {
self.mode = AppMode::TaskModify; self.mode = AppMode::TaskModify;
match self.task_current() { match self.task_table_state.mode() {
Some(t) => { TableMode::SingleSelection => match self.task_current() {
let s = format!("{} ", t.description()); Some(t) => {
self.modify.update(&s, s.as_str().len()) let s = format!("{} ", t.description());
} self.modify.update(&s, s.as_str().len())
None => self.modify.update("", 0), }
None => self.modify.update("", 0),
},
TableMode::MultipleSelection => self.modify.update("", 0),
} }
} else if input == self.keyconfig.shell { } else if input == self.keyconfig.shell {
self.mode = AppMode::TaskSubprocess; self.mode = AppMode::TaskSubprocess;

View file

@ -18,6 +18,7 @@ pub struct KeyConfig {
pub delete: Key, pub delete: Key,
pub done: Key, pub done: Key,
pub start_stop: Key, pub start_stop: Key,
pub select: Key,
pub undo: Key, pub undo: Key,
pub edit: Key, pub edit: Key,
pub modify: Key, pub modify: Key,
@ -57,6 +58,7 @@ impl Default for KeyConfig {
delete: Key::Char('x'), delete: Key::Char('x'),
done: Key::Char('d'), done: Key::Char('d'),
start_stop: Key::Char('s'), start_stop: Key::Char('s'),
select: Key::Char('v'),
undo: Key::Char('u'), undo: Key::Char('u'),
edit: Key::Char('e'), edit: Key::Char('e'),
modify: Key::Char('m'), modify: Key::Char('m'),
@ -117,6 +119,9 @@ impl KeyConfig {
self.start_stop = self self.start_stop = self
.get_config("uda.taskwarrior-tui.keyconfig.start-stop") .get_config("uda.taskwarrior-tui.keyconfig.start-stop")
.unwrap_or(self.start_stop); .unwrap_or(self.start_stop);
self.select = self
.get_config("uda.taskwarrior-tui.keyconfig.select")
.unwrap_or(self.select);
self.undo = self self.undo = self
.get_config("uda.taskwarrior-tui.keyconfig.undo") .get_config("uda.taskwarrior-tui.keyconfig.undo")
.unwrap_or(self.undo); .unwrap_or(self.undo);
@ -164,6 +169,7 @@ impl KeyConfig {
&self.page_up, &self.page_up,
&self.delete, &self.delete,
&self.done, &self.done,
&self.select,
&self.start_stop, &self.start_stop,
&self.undo, &self.undo,
&self.edit, &self.edit,

View file

@ -8,6 +8,7 @@ use std::{
fmt::Display, fmt::Display,
iter::{self, Iterator}, iter::{self, Iterator},
}; };
use tinyset::SetUsize;
use tui::{ use tui::{
buffer::Buffer, buffer::Buffer,
layout::{Constraint, Rect}, layout::{Constraint, Rect},
@ -19,31 +20,69 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum TableMode {
SingleSelection,
MultipleSelection,
}
#[derive(Clone)]
pub struct TableState { pub struct TableState {
offset: usize, offset: usize,
selected: Option<usize>, current_selection: Option<usize>,
selected: SetUsize,
mode: TableMode,
} }
impl Default for TableState { impl Default for TableState {
fn default() -> TableState { fn default() -> TableState {
TableState { TableState {
offset: 0, offset: 0,
selected: None, current_selection: None,
selected: SetUsize::new(),
mode: TableMode::SingleSelection,
} }
} }
} }
impl TableState { 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<usize> { pub fn selected(&self) -> Option<usize> {
self.selected self.current_selection
} }
pub fn select(&mut self, index: Option<usize>) { pub fn select(&mut self, index: Option<usize>) {
self.selected = index; self.current_selection = index;
if index.is_none() { if index.is_none() {
self.offset = 0; self.offset = 0;
} }
} }
pub fn toggle_mark(&mut self, index: Option<usize>) {
if let Some(i) = index {
if !self.selected.insert(i) {
self.selected.remove(i);
}
}
}
pub fn marked(&self) -> impl Iterator<Item = usize> + '_ {
self.selected.iter()
}
pub fn clear(&mut self) {
self.selected.drain().for_each(drop);
}
} }
/// Holds data to be displayed in a Table widget /// Holds data to be displayed in a Table widget
@ -99,8 +138,10 @@ pub struct Table<'a, H, R> {
header_gap: u16, header_gap: u16,
/// Style used to render the selected row /// Style used to render the selected row
highlight_style: Style, highlight_style: Style,
/// Symbol in front of the selected rom /// Symbol in front of the selected row
highlight_symbol: Option<&'a str>, highlight_symbol: Option<&'a str>,
/// Symbol in front of the marked row
mark_symbol: Option<&'a str>,
/// Data to display in each row /// Data to display in each row
rows: R, rows: R,
} }
@ -121,6 +162,7 @@ where
header_gap: 1, header_gap: 1,
highlight_style: Style::default(), highlight_style: Style::default(),
highlight_symbol: None, highlight_symbol: None,
mark_symbol: None,
rows: R::default(), rows: R::default(),
} }
} }
@ -143,6 +185,7 @@ where
header_gap: 1, header_gap: 1,
highlight_style: Style::default(), highlight_style: Style::default(),
highlight_symbol: None, highlight_symbol: None,
mark_symbol: None,
rows, rows,
} }
} }
@ -304,12 +347,32 @@ where
y += 1 + self.header_gap; y += 1 + self.header_gap;
// Use highlight_style only if something is selected // Use highlight_style only if something is selected
let (selected, highlight_style) = match state.selected { let (selected, highlight_style) = if state.selected().is_some() {
Some(i) => (Some(i), self.highlight_style), (state.selected(), self.highlight_style)
None => (None, self.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::<String>(),
}; };
let highlight_symbol = self.highlight_symbol.unwrap_or("");
let blank_symbol = iter::repeat(" ").take(highlight_symbol.width()).collect::<String>();
// Draw rows // Draw rows
let default_style = Style::default(); let default_style = Style::default();
@ -317,11 +380,11 @@ where
let remaining = (table_area.bottom() - y) as usize; let remaining = (table_area.bottom() - y) as usize;
// Make sure the table shows the selected item // Make sure the table shows the selected item
state.offset = if let Some(selected) = selected { state.offset = if let Some(s) = selected {
if selected >= remaining + state.offset - 1 { if s >= remaining + state.offset - 1 {
selected + 1 - remaining s + 1 - remaining
} else if selected < state.offset { } else if s < state.offset {
selected s
} else { } else {
state.offset state.offset
} }
@ -330,11 +393,32 @@ where
}; };
for (i, row) in self.rows.skip(state.offset).take(remaining).enumerate() { for (i, row) in self.rows.skip(state.offset).take(remaining).enumerate() {
let (data, style, symbol) = match row { let (data, style, symbol) = match row {
Row::Data(d) | Row::StyledData(d, _) if Some(i) == state.selected.map(|s| s - state.offset) => { Row::Data(d) | Row::StyledData(d, _) if Some(i) == state.selected().map(|s| s - state.offset) => {
(d, highlight_style, highlight_symbol) 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(); x = table_area.left();
for (c, (w, elt)) in solved_widths.iter().zip(data).enumerate() { for (c, (w, elt)) in solved_widths.iter().zip(data).enumerate() {
@ -347,6 +431,10 @@ where
style, style,
); );
if c == header_index { if c == header_index {
let symbol = match state.mode {
TableMode::SingleSelection => &symbol,
TableMode::MultipleSelection => &symbol,
};
format!( format!(
"{symbol}{elt:>width$}", "{symbol}{elt:>width$}",
symbol = symbol, symbol = symbol,