Filter completion (#88)

* create completion component

* add move_up/down

* fix variable name

* pass config

* create debug component

* remov set

* add reserved words

* remove equal

* allow dead code

* always reset offset

* apply completion candidates correctly

* implement selected_candidate, word

* fix clippy warnings

* complete

* add tests for complete

* fix variable name

* fmt

* add tests for `filterd_candidates`

* add "IN" to reserved words

* remove "IN"

* add test cases

* add debug_assertions

* return complete directly

* add s

* make input field private

* update gobang.gif
pull/102/head
Takayuki Maeda 3 years ago committed by GitHub
parent 7796947b76
commit 6615a235a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

@ -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())

@ -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<String>,
}
impl CompletionComponent {
pub fn new(key_config: KeyConfig, word: impl Into<String>) -> 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<String>) {
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<Item = &String> {
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<String> {
self.filterd_candidates()
.collect::<Vec<&String>>()
.get(self.state.selected()?)
.map(|c| c.to_string())
}
pub fn word(&self) -> String {
self.word.to_string()
}
}
impl MovableComponent for CompletionComponent {
fn draw<B: Backend>(
&mut self,
f: &mut Frame<B>,
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::<Vec<ListItem>>();
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<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
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<&String>>(),
vec![&"AND".to_string()]
);
}
#[test]
fn test_filterd_candidates_uppercase() {
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "AN")
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"AND".to_string()]
);
}
#[test]
fn test_filterd_candidates_multiple_candidates() {
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "n")
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"NOT".to_string(), &"NULL".to_string()]
);
assert_eq!(
CompletionComponent::new(KeyConfig::default(), "N")
.filterd_candidates()
.collect::<Vec<&String>>(),
vec![&"NOT".to_string(), &"NULL".to_string()]
);
}
}

@ -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(())
}
}

@ -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<B: Backend>(&mut self, f: &mut Frame<B>, _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<CommandInfo>) {}
fn event(&mut self, key: Key) -> Result<EventState> {
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)
}
}

@ -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<B: Backend>(&mut self, f: &mut Frame<B>, rect: Rect, focused: bool) -> Result<()>;
}
pub trait MovableComponent {
fn draw<B: Backend>(
&mut self,
f: &mut Frame<B>,
rect: Rect,
focused: bool,
x: u16,
y: u16,
) -> Result<()>;
}
/// base component trait
#[async_trait]
pub trait Component {

@ -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(())
}
}

@ -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) {

@ -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<Table>,
pub input: Vec<char>,
input: Vec<char>,
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::<String>()
.split(' ')
.map(|i| i.to_string())
.collect::<Vec<String>>();
self.completion
.update(input.last().unwrap_or(&String::new()));
}
fn complete(&mut self) -> anyhow::Result<EventState> {
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::<Vec<String>>();
let last = self
.input
.iter()
.enumerate()
.filter(|(i, _)| i >= &self.input_idx)
.map(|(_, c)| c.to_string())
.collect::<Vec<String>>();
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::<Vec<String>>()
} else {
let mut c = candidate
.chars()
.map(|c| c.to_string())
.collect::<Vec<String>>();
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::<u16>();
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::<u16>();
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<EventState> {
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']);
}
}

@ -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'),

Loading…
Cancel
Save