From 7e8291be221a8f126e2e24c253e3c7ddfb8e2f0f Mon Sep 17 00:00:00 2001 From: Takayuki Maeda <41065217+TaKO8Ki@users.noreply.github.com> Date: Sun, 25 Jul 2021 19:22:31 +0900 Subject: [PATCH] Select multiple cells (#30) * select multiple cells * define `expand_selected_area_x`, `expand_selected_area_y` * make some table component fields private * fix keymap * fix a cell selection bug in page 2 and after * add tests for `is_selected_cell` and `selected_cells` * select cells while scrolling right * calculate the number of cells considering cell spacing * add tests for expand_selected_area * add comments * implement `reset` * fix clippy warnings --- README.md | 4 +- src/app.rs | 19 +- src/components/record_table.rs | 4 +- src/components/table.rs | 447 ++++++++++++++++++++++++++++----- src/components/table_filter.rs | 10 +- src/utils.rs | 16 +- 6 files changed, 412 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 0e7e880..9d92fc5 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ $ cargo install --version 0.1.0-alpha.0 gobang | Key | Description | | ---- | ---- | -| h | move right | +| h | move left | | j | move down | | k | move up | -| l | move left | +| l | move right | | Ctrl + d | scroll down multiple lines | | Ctrl + u | scroll up multiple lines | diff --git a/src/app.rs b/src/app.rs index ff69fbd..2e38d45 100644 --- a/src/app.rs +++ b/src/app.rs @@ -191,7 +191,6 @@ impl App { .get_columns(&database, &table.name) .await?; self.structure_table = TableComponent::new(records, headers); - self.table_status .update(self.record_table.len() as u64, table); } @@ -206,7 +205,7 @@ impl App { }; if let Key::Char('y') = key { - if let Some(text) = self.record_table.table.selected_cell() { + if let Some(text) = self.record_table.table.selected_cells() { self.clipboard.store(text) } } @@ -276,7 +275,7 @@ impl App { }; if let Key::Char('y') = key { - if let Some(text) = self.structure_table.selected_cell() { + if let Some(text) = self.structure_table.selected_cells() { self.clipboard.store(text) } }; @@ -302,20 +301,18 @@ impl App { return Ok(EventState::Consumed); } } - Focus::DabataseList => match key { - Key::Right if self.databases.tree_focused() => { + Focus::DabataseList => { + if matches!(key, Key::Right) && self.databases.tree_focused() { self.focus = Focus::Table; return Ok(EventState::Consumed); } - _ => (), - }, - Focus::Table => match key { - Key::Left => { + } + Focus::Table => { + if let Key::Left = key { self.focus = Focus::DabataseList; return Ok(EventState::Consumed); } - _ => (), - }, + } } Ok(EventState::NotConsumed) } diff --git a/src/components/record_table.rs b/src/components/record_table.rs index 3e30304..9ed757b 100644 --- a/src/components/record_table.rs +++ b/src/components/record_table.rs @@ -91,8 +91,8 @@ impl Component for RecordTableComponent { self.focus = Focus::Filter; return Ok(EventState::Consumed); } - key if matches!(self.focus, Focus::Filter) => return Ok(self.filter.event(key)?), - key if matches!(self.focus, Focus::Table) => return Ok(self.table.event(key)?), + key if matches!(self.focus, Focus::Filter) => return self.filter.event(key), + key if matches!(self.focus, Focus::Table) => return self.table.event(key), _ => (), } Ok(EventState::NotConsumed) diff --git a/src/components/table.rs b/src/components/table.rs index 1bba7a0..a4ae13a 100644 --- a/src/components/table.rs +++ b/src/components/table.rs @@ -18,22 +18,24 @@ pub struct TableComponent { pub state: TableState, pub headers: Vec, pub rows: Vec>, - pub column_index: usize, - pub column_page: usize, - pub column_page_start: std::cell::Cell, - pub scroll: VerticalScroll, - pub select_entire_row: bool, pub eod: bool, + select_state: TableState, + selected_left_column_index: usize, + selected_right_cell: Option<(usize, usize)>, + column_page_start: std::cell::Cell, + scroll: VerticalScroll, + select_entire_row: bool, } impl Default for TableComponent { fn default() -> Self { Self { state: TableState::default(), + select_state: TableState::default(), headers: vec![], rows: vec![], - column_page: 0, - column_index: 0, + selected_left_column_index: 0, + selected_right_cell: None, column_page_start: std::cell::Cell::new(0), scroll: VerticalScroll::new(), select_entire_row: false, @@ -50,13 +52,19 @@ impl TableComponent { state.select(Some(0)) } Self { - rows, - headers, state, + headers, + rows, ..Self::default() } } + pub fn reset(&mut self) { + self.select_state.select(None); + self.selected_right_cell = None; + self.select_entire_row = false; + } + pub fn end(&mut self) { self.eod = true; } @@ -72,10 +80,24 @@ impl TableComponent { } None => None, }; - self.select_entire_row = false; + self.reset(); self.state.select(i); } + pub fn next_select(&mut self, lines: usize) { + let i = match self.select_state.selected() { + Some(i) => { + if i + lines >= self.rows.len() { + Some(self.rows.len() - 1) + } else { + Some(i + lines) + } + } + None => None, + }; + self.select_state.select(i); + } + pub fn previous(&mut self, lines: usize) { let i = match self.state.selected() { Some(i) => { @@ -87,15 +109,29 @@ impl TableComponent { } None => None, }; - self.select_entire_row = false; + self.reset(); self.state.select(i); } + pub fn previout_select(&mut self, lines: usize) { + let i = match self.select_state.selected() { + Some(i) => { + if i <= lines { + Some(0) + } else { + Some(i - lines) + } + } + None => None, + }; + self.select_state.select(i); + } + pub fn scroll_top(&mut self) { if self.rows.is_empty() { return; } - self.state.select(None); + self.reset(); self.state.select(Some(0)); } @@ -103,6 +139,7 @@ impl TableComponent { if self.rows.is_empty() { return; } + self.reset(); self.state.select(Some(self.rows.len() - 1)); } @@ -110,37 +147,124 @@ impl TableComponent { if self.rows.is_empty() { return; } - if self.column_index >= self.headers().len().saturating_sub(1) { + if self.selected_left_column_index >= self.headers.len().saturating_sub(1) { return; } - self.select_entire_row = false; - self.column_index += 1; + self.reset(); + self.selected_left_column_index += 1; } pub fn previous_column(&mut self) { if self.rows.is_empty() { return; } - if self.column_index == 0 { + if self.selected_left_column_index == 0 { return; } - self.select_entire_row = false; - self.column_index -= 1; + self.reset(); + self.selected_left_column_index -= 1; + } + + pub fn expand_selected_area_x(&mut self, positive: bool) { + if self.selected_right_cell.is_none() { + self.selected_right_cell = Some(( + self.selected_left_column_index, + self.state.selected().unwrap_or(0), + )); + } + if let Some((x, y)) = self.selected_right_cell { + self.selected_right_cell = Some(( + if positive { + (x + 1).min(self.headers.len().saturating_sub(1)) + } else { + x.saturating_sub(1) + }, + y, + )); + } + } + + pub fn expand_selected_area_y(&mut self, positive: bool) { + if self.select_state.selected().is_none() { + self.select_state = self.state.clone(); + } + + if positive { + self.next_select(1); + } else { + self.previout_select(1); + } + + if self.selected_right_cell.is_none() { + self.selected_right_cell = Some(( + self.selected_left_column_index, + self.state.selected().unwrap_or(0), + )); + } + if let Some((x, y)) = self.selected_right_cell { + self.selected_right_cell = Some(( + x, + if positive { + (y + 1).min(self.rows.len().saturating_sub(1)) + } else { + y.saturating_sub(1) + }, + )); + } } pub fn is_row_number_clumn(&self, row_index: usize, column_index: usize) -> bool { matches!(self.state.selected(), Some(selected_row_index) if row_index == selected_row_index && 0 == column_index) } - pub fn selected_cell(&self) -> Option { + pub fn selected_cells(&self) -> Option { + if let Some((x, y)) = self.selected_right_cell { + let selected_row_index = self.state.selected()?; + return Some( + self.rows[y.min(selected_row_index)..y.max(selected_row_index) + 1] + .iter() + .map(|row| { + row[x.min(self.selected_left_column_index) + ..x.max(self.selected_left_column_index) + 1] + .join(",") + }) + .collect::>() + .join("\n"), + ); + } self.rows .get(self.state.selected()?)? - .get(self.column_index) + .get(self.selected_left_column_index) .map(|cell| cell.to_string()) } - pub fn headers(&self) -> Vec { - self.headers.clone() + pub fn selected_column_index(&self) -> usize { + if let Some((x, _)) = self.selected_right_cell { + return x; + } + self.selected_left_column_index + } + + pub fn is_selected_cell( + &self, + row_index: usize, + column_index: usize, + selected_column_index: usize, + ) -> bool { + if let Some((x, y)) = self.selected_right_cell { + let x_in_page = x + .saturating_add(1) + .saturating_sub(self.column_page_start.get()); + return matches!( + self.state.selected(), + Some(selected_row_index) + if (x_in_page.min(selected_column_index).max(1)..x_in_page.max(selected_column_index) + 1) + .contains(&column_index) + && (y.min(selected_row_index)..y.max(selected_row_index) + 1) + .contains(&row_index) + ); + } + matches!(self.state.selected(), Some(selected_row_index) if row_index == selected_row_index && column_index == selected_column_index) } pub fn headers_with_number(&self, left: usize, right: usize) -> Vec { @@ -149,10 +273,6 @@ impl TableComponent { headers } - pub fn rows(&self) -> Vec> { - self.rows.clone() - } - pub fn rows_with_number(&self, left: usize, right: usize) -> Vec> { let rows = self .rows @@ -174,17 +294,17 @@ impl TableComponent { if self.rows.is_empty() { return (0, Vec::new(), Vec::new(), Vec::new()); } - if self.column_index < self.column_page_start.get() { - self.column_page_start.set(self.column_index); + if self.selected_column_index() < self.column_page_start.get() { + self.column_page_start.set(self.selected_column_index()); } - let right_column_index = self.column_index.clone(); - let mut column_index = self.column_index; + let right_column_index = self.selected_column_index(); + let mut column_index = self.selected_column_index(); let number_clomn_width = (self.rows.len() + 1).to_string().width() as u16; let mut widths = vec![]; loop { let length = self - .rows() + .rows .iter() .map(|row| { row.get(column_index) @@ -197,16 +317,14 @@ impl TableComponent { .map_or(3, |v| { *v.max( &self - .headers() + .headers .get(column_index) .map_or(3, |header| header.to_string().width()), ) - .clamp(&3, &20) as u16 + .clamp(&3, &20) }); - if widths.iter().map(|(_, width)| width).sum::() + length - > area_width - .saturating_sub(7) - .saturating_sub(number_clomn_width) + if widths.iter().map(|(_, width)| width).sum::() + length + widths.len() + > area_width.saturating_sub(number_clomn_width) as usize { column_index += 1; break; @@ -221,13 +339,11 @@ impl TableComponent { widths.reverse(); let selected_column_index = widths.len().saturating_sub(1); let mut column_index = right_column_index + 1; - while widths.iter().map(|(_, width)| width).sum::() - <= area_width - .saturating_sub(7) - .saturating_sub(number_clomn_width) + while widths.iter().map(|(_, width)| width).sum::() + widths.len() + <= area_width.saturating_sub(number_clomn_width) as usize { let length = self - .rows() + .rows .iter() .map(|row| { row.get(column_index) @@ -239,14 +355,14 @@ impl TableComponent { .max() .map_or(3, |v| { *v.max( - self.headers() + self.headers .iter() .map(|header| header.to_string().width()) .collect::>() .get(column_index) .unwrap_or(&3), ) - .clamp(&3, &20) as u16 + .clamp(&3, &20) }); match self.headers.get(column_index) { Some(header) => { @@ -256,21 +372,30 @@ impl TableComponent { } column_index += 1 } - if self.column_index != self.headers.len().saturating_sub(1) { + if self.selected_column_index() != self.headers.len().saturating_sub(1) { widths.pop(); } let right_column_index = column_index; let mut constraints = widths .iter() - .map(|(_, width)| Constraint::Length(*width)) + .map(|(_, width)| Constraint::Length(*width as u16)) .collect::>(); - if self.column_index != self.headers.len().saturating_sub(1) { + if self.selected_column_index() != self.headers.len().saturating_sub(1) { constraints.push(Constraint::Min(10)); } constraints.insert(0, Constraint::Length(number_clomn_width)); self.column_page_start.set(left_column_index); ( - selected_column_index + 1, + self.selected_right_cell + .map_or(selected_column_index + 1, |(x, _)| { + if x > self.selected_left_column_index { + (selected_column_index + 1) + .saturating_sub(x.saturating_sub(self.selected_left_column_index)) + } else { + (selected_column_index + 1) + .saturating_add(self.selected_left_column_index.saturating_sub(x)) + } + }), self.headers_with_number(left_column_index, right_column_index), self.rows_with_number(left_column_index, right_column_index), constraints, @@ -298,7 +423,7 @@ impl DrawableComponent for TableComponent { }, ); - TableValueComponent::new(self.selected_cell().unwrap_or_default()) + TableValueComponent::new(self.selected_cells().unwrap_or_default()) .draw(f, layout[0], focused)?; let block = Block::default().borders(Borders::ALL).title("Records"); @@ -320,13 +445,15 @@ impl DrawableComponent for TableComponent { .unwrap_or(0) + 1; let cells = item.iter().enumerate().map(|(column_index, c)| { - Cell::from(c.to_string()).style(if matches!(self.state.selected(), Some(selected_row_index) if row_index == selected_row_index && selected_column_index == column_index) { - Style::default().bg(Color::Blue) - } else if self.is_row_number_clumn(row_index, column_index) { - Style::default().add_modifier(Modifier::BOLD) - } else { - Style::default() - }) + Cell::from(c.to_string()).style( + if self.is_selected_cell(row_index, column_index, selected_column_index) { + Style::default().bg(Color::Blue) + } else if self.is_row_number_clumn(row_index, column_index) { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ) }); Row::new(cells).height(height as u16).bottom_margin(1) }); @@ -345,7 +472,15 @@ impl DrawableComponent for TableComponent { Style::default().fg(Color::DarkGray) }) .widths(&constraints); - f.render_stateful_widget(table, layout[1], &mut self.state); + f.render_stateful_widget( + table, + layout[1], + if self.select_state.selected().is_some() { + &mut self.select_state + } else { + &mut self.state + }, + ); self.scroll.draw(f, layout[1]); Ok(()) @@ -391,6 +526,22 @@ impl Component for TableComponent { self.next_column(); return Ok(EventState::Consumed); } + Key::Char('H') => { + self.expand_selected_area_x(false); + return Ok(EventState::Consumed); + } + Key::Char('K') => { + self.expand_selected_area_y(false); + return Ok(EventState::Consumed); + } + Key::Char('J') => { + self.expand_selected_area_y(true); + return Ok(EventState::Consumed); + } + Key::Char('L') => { + self.expand_selected_area_x(true); + return Ok(EventState::Consumed); + } _ => (), } Ok(EventState::NotConsumed) @@ -422,6 +573,184 @@ mod test { ) } + #[test] + fn test_expand_selected_area_x_left() { + // before + // 1 2 3 + // 1 a b c + // 2 d |e| f + + // after + // 1 2 3 + // 1 a b c + // 2 |d e| f + + let mut component = TableComponent::default(); + component.headers = vec!["1", "2", "3"].iter().map(|h| h.to_string()).collect(); + component.rows = vec![ + vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(), + vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), + ]; + component.state.select(Some(1)); + component.selected_left_column_index = 1; + component.expand_selected_area_x(false); + assert_eq!(component.selected_right_cell, Some((0, 1))); + assert_eq!(component.selected_cells(), Some("d,e".to_string())); + } + + #[test] + fn test_expand_selected_area_x_right() { + // before + // 1 2 3 + // 1 a b c + // 2 d |e| f + + // after + // 1 2 3 + // 1 a b c + // 2 d |e f| + + let mut component = TableComponent::default(); + component.headers = vec!["1", "2", "3"].iter().map(|h| h.to_string()).collect(); + component.rows = vec![ + vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(), + vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), + ]; + component.state.select(Some(1)); + component.selected_left_column_index = 1; + component.expand_selected_area_x(true); + assert_eq!(component.selected_right_cell, Some((2, 1))); + assert_eq!(component.selected_cells(), Some("e,f".to_string())); + } + + #[test] + fn test_expand_selected_area_y_up() { + // before + // 1 2 3 + // 1 a b c + // 2 d |e| f + + // after + // 1 2 3 + // 1 a |b| c + // 2 d |e| f + + let mut component = TableComponent::default(); + component.rows = vec![ + vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(), + vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), + ]; + component.state.select(Some(1)); + component.selected_left_column_index = 1; + component.expand_selected_area_y(false); + assert_eq!(component.selected_right_cell, Some((1, 0))); + assert_eq!(component.selected_cells(), Some("b\ne".to_string())); + } + + #[test] + fn test_expand_selected_area_y_down() { + // before + // 1 2 3 + // 1 a |b| c + // 2 d e f + + // after + // 1 2 3 + // 1 a |b| c + // 2 d |e| f + + let mut component = TableComponent::default(); + component.rows = vec![ + vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(), + vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), + ]; + component.state.select(Some(0)); + component.selected_left_column_index = 1; + component.expand_selected_area_y(true); + assert_eq!(component.selected_right_cell, Some((1, 1))); + assert_eq!(component.selected_cells(), Some("b\ne".to_string())); + } + + #[test] + fn test_selected_cell_when_one_cell_selected() { + // 1 2 3 + // 1 |a| b c + // 2 d e f + + let mut component = TableComponent::default(); + component.headers = vec!["1", "2", "3"].iter().map(|h| h.to_string()).collect(); + component.rows = vec![ + vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(), + vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), + ]; + component.state.select(Some(0)); + assert_eq!(component.selected_cells(), Some("a".to_string())); + } + + #[test] + fn test_selected_cell_when_multiple_cells_selected() { + // 1 2 3 + // 1 |a b| c + // 2 |d e| f + + let mut component = TableComponent::default(); + component.headers = vec!["1", "2", "3"].iter().map(|h| h.to_string()).collect(); + component.rows = vec![ + vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(), + vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), + ]; + component.state.select(Some(0)); + component.selected_right_cell = Some((1, 1)); + assert_eq!(component.selected_cells(), Some("a,b\nd,e".to_string())); + } + + #[test] + fn test_is_selected_cell_when_one_cell_selected() { + // 1 2 3 + // 1 |a| b c + // 2 d e f + + let mut component = TableComponent::default(); + component.headers = vec!["1", "2", "3"].iter().map(|h| h.to_string()).collect(); + component.rows = vec![ + vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(), + vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), + ]; + component.state.select(Some(0)); + // a + assert!(component.is_selected_cell(0, 1, 1)); + // d + assert!(!component.is_selected_cell(1, 1, 1)); + // e + assert!(!component.is_selected_cell(1, 2, 1)); + } + + #[test] + fn test_is_selected_cell_when_multiple_cells_selected() { + // 1 2 3 + // 1 |a b| c + // 2 |d e| f + + let mut component = TableComponent::default(); + component.headers = vec!["1", "2", "3"].iter().map(|h| h.to_string()).collect(); + component.rows = vec![ + vec!["a", "b", "c"].iter().map(|h| h.to_string()).collect(), + vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), + ]; + component.state.select(Some(0)); + component.selected_right_cell = Some((1, 1)); + // a + assert!(component.is_selected_cell(0, 1, 1)); + // b + assert!(component.is_selected_cell(0, 2, 1)); + // d + assert!(component.is_selected_cell(1, 1, 1)); + // e + assert!(component.is_selected_cell(1, 2, 1)); + // f + assert!(!component.is_selected_cell(1, 3, 1)); + } + #[test] fn test_calculate_widths() { let mut component = TableComponent::default(); @@ -433,7 +762,7 @@ mod test { .collect(), vec!["d", "e", "f"].iter().map(|h| h.to_string()).collect(), ]; - let (selected_column_index, headers, rows, constraints) = component.calculate_widths(17); + let (selected_column_index, headers, rows, constraints) = component.calculate_widths(10); assert_eq!(selected_column_index, 1); assert_eq!(headers, vec!["", "1", "2"]); assert_eq!(rows, vec![vec!["1", "aaaaa", "bbbbb"], vec!["2", "d", "e"]]); @@ -446,7 +775,7 @@ mod test { ] ); - let (selected_column_index, headers, rows, constraints) = component.calculate_widths(27); + let (selected_column_index, headers, rows, constraints) = component.calculate_widths(20); assert_eq!(selected_column_index, 1); assert_eq!(headers, vec!["", "1", "2", "3"]); assert_eq!( diff --git a/src/components/table_filter.rs b/src/components/table_filter.rs index 8c878cb..b64e006 100644 --- a/src/components/table_filter.rs +++ b/src/components/table_filter.rs @@ -89,12 +89,10 @@ impl Component for TableFilterComponent { return Ok(EventState::Consumed); } Key::Delete | Key::Backspace => { - if input_str.width() > 0 { - if !self.input.is_empty() && self.input_idx > 0 { - let last_c = self.input.remove(self.input_idx - 1); - self.input_idx -= 1; - self.input_cursor_position -= compute_character_width(last_c); - } + if input_str.width() > 0 && !self.input.is_empty() && self.input_idx > 0 { + let last_c = self.input.remove(self.input_idx - 1); + self.input_idx -= 1; + self.input_cursor_position -= compute_character_width(last_c); } return Ok(EventState::Consumed); } diff --git a/src/utils.rs b/src/utils.rs index a09e62d..56c3c9d 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -13,15 +13,15 @@ pub trait Pool { async fn get_tables(&self, database: String) -> anyhow::Result>; async fn get_records( &self, - database: &String, - table: &String, + database: &str, + table: &str, page: u16, filter: Option, ) -> anyhow::Result<(Vec, Vec>)>; async fn get_columns( &self, - database: &String, - table: &String, + database: &str, + table: &str, ) -> anyhow::Result<(Vec, Vec>)>; async fn close(&self); } @@ -67,8 +67,8 @@ impl Pool for MySqlPool { async fn get_records( &self, - database: &String, - table: &String, + database: &str, + table: &str, page: u16, filter: Option, ) -> anyhow::Result<(Vec, Vec>)> { @@ -111,8 +111,8 @@ impl Pool for MySqlPool { async fn get_columns( &self, - database: &String, - table: &String, + database: &str, + table: &str, ) -> anyhow::Result<(Vec, Vec>)> { let query = format!("SHOW FULL COLUMNS FROM `{}`.`{}`", database, table); let mut rows = sqlx::query(query.as_str()).fetch(&self.pool);