diff --git a/resources/gobang.gif b/resources/gobang.gif index ba3ac39..8ce113c 100644 Binary files a/resources/gobang.gif and b/resources/gobang.gif differ diff --git a/src/app.rs b/src/app.rs index 51d87bc..77e1905 100644 --- a/src/app.rs +++ b/src/app.rs @@ -275,7 +275,7 @@ impl App { &database, &table, 0, - if self.record_table.filter.input.is_empty() { + if self.record_table.filter.input_str().is_empty() { None } else { Some(self.record_table.filter.input_str()) @@ -367,7 +367,7 @@ impl App { &database, &table, index as u16, - if self.record_table.filter.input.is_empty() { + if self.record_table.filter.input_str().is_empty() { None } else { Some(self.record_table.filter.input_str()) diff --git a/src/components/completion.rs b/src/components/completion.rs new file mode 100644 index 0000000..42b36f8 --- /dev/null +++ b/src/components/completion.rs @@ -0,0 +1,178 @@ +use super::{Component, EventState, MovableComponent}; +use crate::components::command::CommandInfo; +use crate::config::KeyConfig; +use crate::event::Key; +use anyhow::Result; +use tui::{ + backend::Backend, + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + Frame, +}; + +const RESERVED_WORDS: &[&str] = &["IN", "AND", "OR", "NOT", "NULL", "IS"]; + +pub struct CompletionComponent { + key_config: KeyConfig, + state: ListState, + word: String, + candidates: Vec, +} + +impl CompletionComponent { + pub fn new(key_config: KeyConfig, word: impl Into) -> Self { + Self { + key_config, + state: ListState::default(), + word: word.into(), + candidates: RESERVED_WORDS.iter().map(|w| w.to_string()).collect(), + } + } + + pub fn update(&mut self, word: impl Into) { + self.word = word.into(); + self.state.select(None); + self.state.select(Some(0)) + } + + fn next(&mut self) { + let i = match self.state.selected() { + Some(i) => { + if i >= self.filterd_candidates().count() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + } + + fn previous(&mut self) { + let i = match self.state.selected() { + Some(i) => { + if i == 0 { + self.filterd_candidates().count() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + } + + fn filterd_candidates(&self) -> impl Iterator { + self.candidates.iter().filter(move |c| { + (c.starts_with(self.word.to_lowercase().as_str()) + || c.starts_with(self.word.to_uppercase().as_str())) + && !self.word.is_empty() + }) + } + + pub fn selected_candidate(&self) -> Option { + self.filterd_candidates() + .collect::>() + .get(self.state.selected()?) + .map(|c| c.to_string()) + } + + pub fn word(&self) -> String { + self.word.to_string() + } +} + +impl MovableComponent for CompletionComponent { + fn draw( + &mut self, + f: &mut Frame, + area: Rect, + _focused: bool, + x: u16, + y: u16, + ) -> Result<()> { + if !self.word.is_empty() { + let width = 30; + let candidates = self + .filterd_candidates() + .map(|c| ListItem::new(c.to_string())) + .collect::>(); + if candidates.clone().is_empty() { + return Ok(()); + } + let candidate_list = List::new(candidates.clone()) + .block(Block::default().borders(Borders::ALL)) + .highlight_style(Style::default().bg(Color::Blue)) + .style(Style::default()); + + let area = Rect::new( + area.x + x, + area.y + y + 2, + width.min(f.size().width), + candidates.len().min(5) as u16 + 2, + ); + f.render_widget(Clear, area); + f.render_stateful_widget(candidate_list, area, &mut self.state); + } + Ok(()) + } +} + +impl Component for CompletionComponent { + fn commands(&self, _out: &mut Vec) {} + + fn event(&mut self, key: Key) -> Result { + if key == self.key_config.move_down { + self.next(); + return Ok(EventState::Consumed); + } else if key == self.key_config.move_up { + self.previous(); + return Ok(EventState::Consumed); + } + Ok(EventState::NotConsumed) + } +} + +#[cfg(test)] +mod test { + use super::{CompletionComponent, KeyConfig}; + + #[test] + fn test_filterd_candidates_lowercase() { + assert_eq!( + CompletionComponent::new(KeyConfig::default(), "an") + .filterd_candidates() + .collect::>(), + vec![&"AND".to_string()] + ); + } + + #[test] + fn test_filterd_candidates_uppercase() { + assert_eq!( + CompletionComponent::new(KeyConfig::default(), "AN") + .filterd_candidates() + .collect::>(), + vec![&"AND".to_string()] + ); + } + + #[test] + fn test_filterd_candidates_multiple_candidates() { + assert_eq!( + CompletionComponent::new(KeyConfig::default(), "n") + .filterd_candidates() + .collect::>(), + vec![&"NOT".to_string(), &"NULL".to_string()] + ); + + assert_eq!( + CompletionComponent::new(KeyConfig::default(), "N") + .filterd_candidates() + .collect::>(), + vec![&"NOT".to_string(), &"NULL".to_string()] + ); + } +} diff --git a/src/components/connections.rs b/src/components/connections.rs index 455b2c2..f05665d 100644 --- a/src/components/connections.rs +++ b/src/components/connections.rs @@ -93,7 +93,7 @@ impl DrawableComponent for ConnectionsComponent { .style(Style::default()), ) } - let tasks = List::new(connections) + let connections = List::new(connections) .block(Block::default().borders(Borders::ALL).title("Connections")) .highlight_style(Style::default().bg(Color::Blue)) .style(Style::default()); @@ -104,8 +104,9 @@ impl DrawableComponent for ConnectionsComponent { width.min(f.size().width), height.min(f.size().height), ); + f.render_widget(Clear, area); - f.render_stateful_widget(tasks, area, &mut self.state); + f.render_stateful_widget(connections, area, &mut self.state); Ok(()) } } diff --git a/src/components/debug.rs b/src/components/debug.rs new file mode 100644 index 0000000..b5f9628 --- /dev/null +++ b/src/components/debug.rs @@ -0,0 +1,66 @@ +use super::{Component, DrawableComponent, EventState}; +use crate::components::command::CommandInfo; +use crate::config::KeyConfig; +use crate::event::Key; +use anyhow::Result; +use tui::{ + backend::Backend, + layout::{Alignment, Rect}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +pub struct DebugComponent { + msg: String, + visible: bool, + key_config: KeyConfig, +} + +impl DebugComponent { + #[allow(dead_code)] + pub fn new(key_config: KeyConfig, msg: String) -> Self { + Self { + msg, + visible: false, + key_config, + } + } +} + +impl DrawableComponent for DebugComponent { + fn draw(&mut self, f: &mut Frame, _area: Rect, _focused: bool) -> Result<()> { + if true { + let width = 65; + let height = 10; + let error = Paragraph::new(self.msg.to_string()) + .block(Block::default().title("Debug").borders(Borders::ALL)) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }); + let area = Rect::new( + (f.size().width.saturating_sub(width)) / 2, + (f.size().height.saturating_sub(height)) / 2, + width.min(f.size().width), + height.min(f.size().height), + ); + f.render_widget(Clear, area); + f.render_widget(error, area); + } + Ok(()) + } +} + +impl Component for DebugComponent { + fn commands(&self, _out: &mut Vec) {} + + fn event(&mut self, key: Key) -> Result { + if self.visible { + if key == self.key_config.exit_popup { + self.msg = String::new(); + self.hide(); + return Ok(EventState::Consumed); + } + return Ok(EventState::NotConsumed); + } + Ok(EventState::NotConsumed) + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 67d32c7..46d7dce 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,4 +1,5 @@ pub mod command; +pub mod completion; pub mod connections; pub mod databases; pub mod error; @@ -11,7 +12,11 @@ pub mod table_status; pub mod table_value; pub mod utils; +#[cfg(debug_assertions)] +pub mod debug; + pub use command::{CommandInfo, CommandText}; +pub use completion::CompletionComponent; pub use connections::ConnectionsComponent; pub use databases::DatabasesComponent; pub use error::ErrorComponent; @@ -23,6 +28,9 @@ pub use table_filter::TableFilterComponent; pub use table_status::TableStatusComponent; pub use table_value::TableValueComponent; +#[cfg(debug_assertions)] +pub use debug::DebugComponent; + use anyhow::Result; use async_trait::async_trait; use std::convert::TryInto; @@ -55,6 +63,17 @@ pub trait DrawableComponent { fn draw(&mut self, f: &mut Frame, rect: Rect, focused: bool) -> Result<()>; } +pub trait MovableComponent { + fn draw( + &mut self, + f: &mut Frame, + rect: Rect, + focused: bool, + x: u16, + y: u16, + ) -> Result<()>; +} + /// base component trait #[async_trait] pub trait Component { diff --git a/src/components/record_table.rs b/src/components/record_table.rs index ebdf1ba..dea2800 100644 --- a/src/components/record_table.rs +++ b/src/components/record_table.rs @@ -26,7 +26,7 @@ pub struct RecordTableComponent { impl RecordTableComponent { pub fn new(key_config: KeyConfig) -> Self { Self { - filter: TableFilterComponent::default(), + filter: TableFilterComponent::new(key_config.clone()), table: TableComponent::new(key_config.clone()), focus: Focus::Table, key_config, @@ -61,11 +61,11 @@ impl DrawableComponent for RecordTableComponent { .constraints(vec![Constraint::Length(3), Constraint::Length(5)]) .split(area); - self.filter - .draw(f, layout[0], focused && matches!(self.focus, Focus::Filter))?; - self.table .draw(f, layout[1], focused && matches!(self.focus, Focus::Table))?; + + self.filter + .draw(f, layout[0], focused && matches!(self.focus, Focus::Filter))?; Ok(()) } } diff --git a/src/components/table.rs b/src/components/table.rs index 6cb07a6..988e3ea 100644 --- a/src/components/table.rs +++ b/src/components/table.rs @@ -59,8 +59,8 @@ impl TableComponent { database: Database, table: DTable, ) { + self.selected_row.select(None); if !rows.is_empty() { - self.selected_row.select(None); self.selected_row.select(Some(0)) } self.headers = headers; @@ -97,7 +97,7 @@ impl TableComponent { let i = match self.selected_row.selected() { Some(i) => { if i + lines >= self.rows.len() { - Some(self.rows.len() - 1) + Some(self.rows.len().saturating_sub(1)) } else { Some(i + lines) } @@ -114,7 +114,7 @@ impl TableComponent { if i <= lines { Some(0) } else { - Some(i - lines) + Some(i.saturating_sub(lines)) } } None => None, @@ -136,7 +136,8 @@ impl TableComponent { return; } self.reset_selection(); - self.selected_row.select(Some(self.rows.len() - 1)); + self.selected_row + .select(Some(self.rows.len().saturating_sub(1))); } fn next_column(&mut self) { diff --git a/src/components/table_filter.rs b/src/components/table_filter.rs index 001038c..1361cea 100644 --- a/src/components/table_filter.rs +++ b/src/components/table_filter.rs @@ -1,5 +1,9 @@ -use super::{compute_character_width, Component, DrawableComponent, EventState}; +use super::{ + compute_character_width, CompletionComponent, Component, DrawableComponent, EventState, + MovableComponent, +}; use crate::components::command::CommandInfo; +use crate::config::KeyConfig; use crate::event::Key; use anyhow::Result; use database_tree::Table; @@ -14,24 +18,26 @@ use tui::{ use unicode_width::UnicodeWidthStr; pub struct TableFilterComponent { + key_config: KeyConfig, pub table: Option, - pub input: Vec, + input: Vec, input_idx: usize, input_cursor_position: u16, + completion: CompletionComponent, } -impl Default for TableFilterComponent { - fn default() -> Self { +impl TableFilterComponent { + pub fn new(key_config: KeyConfig) -> Self { Self { + key_config: key_config.clone(), table: None, input: Vec::new(), input_idx: 0, input_cursor_position: 0, + completion: CompletionComponent::new(key_config, ""), } } -} -impl TableFilterComponent { pub fn input_str(&self) -> String { self.input.iter().collect() } @@ -42,6 +48,85 @@ impl TableFilterComponent { self.input_idx = 0; self.input_cursor_position = 0; } + + fn update_completion(&mut self) { + let input = &self + .input + .iter() + .enumerate() + .filter(|(i, _)| i < &self.input_idx) + .map(|(_, i)| i) + .collect::() + .split(' ') + .map(|i| i.to_string()) + .collect::>(); + self.completion + .update(input.last().unwrap_or(&String::new())); + } + + fn complete(&mut self) -> anyhow::Result { + if let Some(candidate) = self.completion.selected_candidate() { + let mut input = Vec::new(); + let first = self + .input + .iter() + .enumerate() + .filter(|(i, _)| i < &self.input_idx.saturating_sub(self.completion.word().len())) + .map(|(_, c)| c.to_string()) + .collect::>(); + let last = self + .input + .iter() + .enumerate() + .filter(|(i, _)| i >= &self.input_idx) + .map(|(_, c)| c.to_string()) + .collect::>(); + + let is_last_word = last.first().map_or(false, |c| c == &" ".to_string()); + + let middle = if is_last_word { + candidate + .chars() + .map(|c| c.to_string()) + .collect::>() + } else { + let mut c = candidate + .chars() + .map(|c| c.to_string()) + .collect::>(); + c.push(" ".to_string()); + c + }; + + input.extend(first); + input.extend(middle.clone()); + input.extend(last); + + self.input = input.join("").chars().collect(); + self.input_idx += &middle.len(); + if is_last_word { + self.input_idx += 1; + } + self.input_idx -= self.completion.word().len(); + self.input_cursor_position += middle + .join("") + .chars() + .map(compute_character_width) + .sum::(); + if is_last_word { + self.input_cursor_position += " ".to_string().width() as u16 + } + self.input_cursor_position -= self + .completion + .word() + .chars() + .map(compute_character_width) + .sum::(); + self.update_completion(); + return Ok(EventState::Consumed); + } + Ok(EventState::NotConsumed) + } } impl DrawableComponent for TableFilterComponent { @@ -69,6 +154,24 @@ impl DrawableComponent for TableFilterComponent { }) .block(Block::default().borders(Borders::ALL)); f.render_widget(query, area); + + if focused { + self.completion.draw( + f, + area, + false, + (self + .table + .as_ref() + .map_or(String::new(), |table| { + format!("{} ", table.name.to_string()) + }) + .width() as u16) + .saturating_add(self.input_cursor_position), + 0, + )?; + }; + if focused { f.set_cursor( (area.x @@ -91,21 +194,31 @@ impl Component for TableFilterComponent { fn event(&mut self, key: Key) -> Result { let input_str: String = self.input.iter().collect(); + + // apply comletion candidates + if key == self.key_config.enter { + return self.complete(); + } + + self.completion.selected_candidate(); + match key { Key::Char(c) => { self.input.insert(self.input_idx, c); self.input_idx += 1; self.input_cursor_position += compute_character_width(c); + self.update_completion(); - return Ok(EventState::Consumed); + Ok(EventState::Consumed) } Key::Delete | Key::Backspace => { 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); + self.completion.update(""); } - return Ok(EventState::Consumed); + Ok(EventState::Consumed) } Key::Left => { if !self.input.is_empty() && self.input_idx > 0 { @@ -113,33 +226,75 @@ impl Component for TableFilterComponent { self.input_cursor_position = self .input_cursor_position .saturating_sub(compute_character_width(self.input[self.input_idx])); + self.completion.update(""); } - return Ok(EventState::Consumed); + Ok(EventState::Consumed) } Key::Ctrl('a') => { if !self.input.is_empty() && self.input_idx > 0 { self.input_idx = 0; self.input_cursor_position = 0 } - return Ok(EventState::Consumed); + Ok(EventState::Consumed) } Key::Right => { if self.input_idx < self.input.len() { let next_c = self.input[self.input_idx]; self.input_idx += 1; self.input_cursor_position += compute_character_width(next_c); + self.completion.update(""); } - return Ok(EventState::Consumed); + Ok(EventState::Consumed) } Key::Ctrl('e') => { if self.input_idx < self.input.len() { self.input_idx = self.input.len(); self.input_cursor_position = self.input_str().width() as u16; } - return Ok(EventState::Consumed); + Ok(EventState::Consumed) } - _ => (), + key => self.completion.event(key), } - Ok(EventState::NotConsumed) + } +} + +#[cfg(test)] +mod test { + use super::{KeyConfig, TableFilterComponent}; + + #[test] + fn test_complete() { + let mut filter = TableFilterComponent::new(KeyConfig::default()); + filter.input_idx = 2; + filter.input = vec!['a', 'n', ' ', 'c', 'd', 'e', 'f', 'g']; + filter.completion.update("an"); + assert!(filter.complete().is_ok()); + assert_eq!( + filter.input, + vec!['A', 'N', 'D', ' ', 'c', 'd', 'e', 'f', 'g'] + ); + } + + #[test] + fn test_complete_end() { + let mut filter = TableFilterComponent::new(KeyConfig::default()); + filter.input_idx = 9; + filter.input = vec!['a', 'b', ' ', 'c', 'd', 'e', 'f', ' ', 'i']; + filter.completion.update('i'); + assert!(filter.complete().is_ok()); + assert_eq!( + filter.input, + vec!['a', 'b', ' ', 'c', 'd', 'e', 'f', ' ', 'I', 'N', ' '] + ); + } + + #[test] + fn test_complete_no_candidates() { + let mut filter = TableFilterComponent::new(KeyConfig::default()); + filter.input_idx = 2; + filter.input = vec!['a', 'n', ' ', 'c', 'd', 'e', 'f', 'g']; + filter.completion.update("foo"); + assert!(filter.complete().is_ok()); + assert_eq!(filter.input, vec!['a', 'n', ' ', 'c', 'd', 'e', 'f', 'g']); } } diff --git a/src/config.rs b/src/config.rs index b7c4048..1c26f0e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -77,6 +77,8 @@ pub struct KeyConfig { pub scroll_down: Key, pub scroll_right: Key, pub scroll_left: Key, + pub move_up: Key, + pub move_down: Key, pub copy: Key, pub enter: Key, pub exit: Key, @@ -109,6 +111,8 @@ impl Default for KeyConfig { scroll_down: Key::Char('j'), scroll_right: Key::Char('l'), scroll_left: Key::Char('h'), + move_up: Key::Up, + move_down: Key::Down, copy: Key::Char('y'), enter: Key::Enter, exit: Key::Ctrl('c'),