From f957953f7a2fe00206acb3bdbf82fcc2e5be05c3 Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Sun, 3 Jul 2022 12:21:07 +0100 Subject: [PATCH 1/7] improve cursor code --- src/command/client/search.rs | 42 +++++++++++------------------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/src/command/client/search.rs b/src/command/client/search.rs index c50c492c3f9..2f2c271a6dd 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -282,28 +282,18 @@ async fn query_results( Ok(()) } -fn get_input_prefix(app: &mut State, i: usize) -> String { - return app.input.chars().take(i).collect(); -} -fn get_input_suffix(app: &mut State, i: usize) -> String { - return app.input.chars().skip(i).collect(); -} - fn insert_char_into_input(app: &mut State, i: usize, c: char) { - let mut result = String::from(""); - result.push_str(&get_input_prefix(app, i)); - result.push_str(&c.to_string()); - result.push_str(&get_input_suffix(app, i)); - app.input = result; + match app.input.char_indices().nth(i) { + Some((i, _)) => app.input.insert(i, c), + None => app.input.push(c), + } } fn remove_char_from_input(app: &mut State, i: usize) -> char { - let mut result = String::from(""); - result.push_str(&get_input_prefix(app, i - 1)); - result.push_str(&get_input_suffix(app, i)); - let c = app.input.chars().nth(i - 1).unwrap(); - app.input = result; - c + match app.input.char_indices().nth(i) { + Some((i, _)) => app.input.remove(i), + None => app.input.pop().unwrap(), + } } #[allow(clippy::too_many_lines)] @@ -350,27 +340,19 @@ fn key_handler(input: &TermEvent, app: &mut State) -> Option { app.cursor_index += 1; } TermEvent::Key(Key::Backspace) => { - if app.cursor_index == 0 { - return None; - } + app.cursor_index = app.cursor_index.checked_sub(1)?; remove_char_from_input(app, app.cursor_index); - app.cursor_index -= 1; } TermEvent::Key(Key::Ctrl('w')) => { let mut stop_on_next_whitespace = false; - loop { - if app.cursor_index == 0 { - break; - } - if app.input.chars().nth(app.cursor_index - 1) == Some(' ') - && stop_on_next_whitespace - { + while let Some(i) = app.cursor_index.checked_sub(1) { + if stop_on_next_whitespace && app.input.chars().nth(i) == Some(' ') { break; } + app.cursor_index = i; if !remove_char_from_input(app, app.cursor_index).is_whitespace() { stop_on_next_whitespace = true; } - app.cursor_index -= 1; } } TermEvent::Key(Key::Ctrl('u')) => { From 7267a48ba800a9f13349e6899f7b99ab1aabbfbc Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Sun, 3 Jul 2022 12:44:44 +0100 Subject: [PATCH 2/7] proper unicode support --- src/command/client/search.rs | 77 ++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/src/command/client/search.rs b/src/command/client/search.rs index 2f2c271a6dd..a37d3ffb4a4 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -282,20 +282,6 @@ async fn query_results( Ok(()) } -fn insert_char_into_input(app: &mut State, i: usize, c: char) { - match app.input.char_indices().nth(i) { - Some((i, _)) => app.input.insert(i, c), - None => app.input.push(c), - } -} - -fn remove_char_from_input(app: &mut State, i: usize) -> char { - match app.input.char_indices().nth(i) { - Some((i, _)) => app.input.remove(i), - None => app.input.pop().unwrap(), - } -} - #[allow(clippy::too_many_lines)] fn key_handler(input: &TermEvent, app: &mut State) -> Option { match input { @@ -320,43 +306,72 @@ fn key_handler(input: &TermEvent, app: &mut State) -> Option { ); } TermEvent::Key(Key::Left | Key::Ctrl('h')) => { - if app.cursor_index != 0 { - app.cursor_index -= 1; + if app.cursor_index > 0 { + // find the prev utf8 char + loop { + app.cursor_index -= 1; + if app.input.is_char_boundary(app.cursor_index) { + break; + } + } } } TermEvent::Key(Key::Right | Key::Ctrl('l')) => { - if app.cursor_index < app.input.width() { - app.cursor_index += 1; + if app.cursor_index < app.input.len() { + // find the next utf8 char. + loop { + app.cursor_index += 1; + if app.input.is_char_boundary(app.cursor_index) { + break; + } + } } } TermEvent::Key(Key::Ctrl('a')) => { app.cursor_index = 0; } TermEvent::Key(Key::Ctrl('e')) => { - app.cursor_index = app.input.chars().count(); + app.cursor_index = app.input.len(); } TermEvent::Key(Key::Char(c)) => { - insert_char_into_input(app, app.cursor_index, *c); - app.cursor_index += 1; + app.input.insert(app.cursor_index, *c); + app.cursor_index += c.len_utf8(); } TermEvent::Key(Key::Backspace) => { - app.cursor_index = app.cursor_index.checked_sub(1)?; - remove_char_from_input(app, app.cursor_index); + if app.cursor_index > 0 { + // find the prev utf8 char + loop { + app.cursor_index -= 1; + if app.input.is_char_boundary(app.cursor_index) { + break; + } + } + app.input.remove(app.cursor_index); + } } TermEvent::Key(Key::Ctrl('w')) => { let mut stop_on_next_whitespace = false; - while let Some(i) = app.cursor_index.checked_sub(1) { - if stop_on_next_whitespace && app.input.chars().nth(i) == Some(' ') { + while app.cursor_index > 0 { + // find the prev utf8 char + let mut i = app.cursor_index; + loop { + i -= 1; + if app.input.is_char_boundary(i) { + break; + } + } + if stop_on_next_whitespace && app.input[i..].chars().next().unwrap().is_whitespace() + { break; } app.cursor_index = i; - if !remove_char_from_input(app, app.cursor_index).is_whitespace() { + if !app.input.remove(app.cursor_index).is_whitespace() { stop_on_next_whitespace = true; } } } TermEvent::Key(Key::Ctrl('u')) => { - app.input = String::from(""); + app.input.clear(); app.cursor_index = 0; } TermEvent::Key(Key::Ctrl('r')) => { @@ -472,13 +487,7 @@ fn draw(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { ); f.render_widget(input, chunks[2]); - let width = UnicodeWidthStr::width( - app.input - .chars() - .take(app.cursor_index) - .collect::() - .as_str(), - ); + let width = UnicodeWidthStr::width(&app.input[..app.cursor_index]); f.set_cursor( // Put cursor past the end of the input text chunks[2].x + width as u16 + 1, From 57399ac2bb565056d55af822473c06e2d219f379 Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Sun, 3 Jul 2022 13:26:14 +0100 Subject: [PATCH 3/7] refactor and test --- src/command/client/search.rs | 271 ++++++++++++++++++++++++----------- 1 file changed, 186 insertions(+), 85 deletions(-) diff --git a/src/command/client/search.rs b/src/command/client/search.rs index a37d3ffb4a4..34e702c6645 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -111,9 +111,7 @@ impl Cmd { } struct State { - input: String, - - cursor_index: usize, + input: Cursor, filter_mode: FilterMode, @@ -283,97 +281,50 @@ async fn query_results( } #[allow(clippy::too_many_lines)] -fn key_handler(input: &TermEvent, app: &mut State) -> Option { +fn key_handler<'app>(input: &TermEvent, app: &'app mut State) -> Option<&'app str> { match input { - TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(String::from("")), + TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(""), TermEvent::Key(Key::Char('\n')) => { let i = app.results_state.selected().unwrap_or(0); return Some( app.results .get(i) - .map_or(app.input.clone(), |h| h.command.clone()), + .map_or(app.input.as_str(), |h| h.command.as_str()), ); } - TermEvent::Key(Key::Alt(c)) if ('1'..='9').contains(c) => { + TermEvent::Key(Key::Alt(c @ '1'..='9')) => { let c = c.to_digit(10)? as usize; let i = app.results_state.selected()? + c; return Some( app.results .get(i) - .map_or(app.input.clone(), |h| h.command.clone()), + .map_or(app.input.as_str(), |h| h.command.as_str()), ); } TermEvent::Key(Key::Left | Key::Ctrl('h')) => { - if app.cursor_index > 0 { - // find the prev utf8 char - loop { - app.cursor_index -= 1; - if app.input.is_char_boundary(app.cursor_index) { - break; - } - } - } - } - TermEvent::Key(Key::Right | Key::Ctrl('l')) => { - if app.cursor_index < app.input.len() { - // find the next utf8 char. - loop { - app.cursor_index += 1; - if app.input.is_char_boundary(app.cursor_index) { - break; - } - } - } - } - TermEvent::Key(Key::Ctrl('a')) => { - app.cursor_index = 0; - } - TermEvent::Key(Key::Ctrl('e')) => { - app.cursor_index = app.input.len(); - } - TermEvent::Key(Key::Char(c)) => { - app.input.insert(app.cursor_index, *c); - app.cursor_index += c.len_utf8(); + app.input.left(); } + TermEvent::Key(Key::Right | Key::Ctrl('l')) => app.input.right(), + TermEvent::Key(Key::Ctrl('a')) => app.input.start(), + TermEvent::Key(Key::Ctrl('e')) => app.input.end(), + TermEvent::Key(Key::Char(c)) => app.input.insert(*c), TermEvent::Key(Key::Backspace) => { - if app.cursor_index > 0 { - // find the prev utf8 char - loop { - app.cursor_index -= 1; - if app.input.is_char_boundary(app.cursor_index) { - break; - } - } - app.input.remove(app.cursor_index); - } + app.input.back(); } TermEvent::Key(Key::Ctrl('w')) => { - let mut stop_on_next_whitespace = false; - while app.cursor_index > 0 { - // find the prev utf8 char - let mut i = app.cursor_index; - loop { - i -= 1; - if app.input.is_char_boundary(i) { - break; - } - } - if stop_on_next_whitespace && app.input[i..].chars().next().unwrap().is_whitespace() - { + // remove the first batch of whitespace + while matches!(app.input.back(), Some(c) if c.is_whitespace()) {} + while app.input.left() { + if app.input.char().unwrap().is_whitespace() { + app.input.right(); // found whitespace, go back right break; } - app.cursor_index = i; - if !app.input.remove(app.cursor_index).is_whitespace() { - stop_on_next_whitespace = true; - } + app.input.remove(); } } - TermEvent::Key(Key::Ctrl('u')) => { - app.input.clear(); - app.cursor_index = 0; - } + TermEvent::Key(Key::Ctrl('u')) => app.input.clear(), TermEvent::Key(Key::Ctrl('r')) => { app.filter_mode = match app.filter_mode { FilterMode::Global => FilterMode::Host, @@ -467,7 +418,7 @@ fn draw(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { FilterMode::Directory => "DIRECTORY", }; - let input = Paragraph::new(app.input.clone()) + let input = Paragraph::new(app.input.as_str().to_owned()) .block(Block::default().borders(Borders::ALL).title(filter_mode)); let stats = Paragraph::new(Text::from(Span::raw(format!( @@ -487,7 +438,7 @@ fn draw(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { ); f.render_widget(input, chunks[2]); - let width = UnicodeWidthStr::width(&app.input[..app.cursor_index]); + let width = UnicodeWidthStr::width(app.input.substring()); f.set_cursor( // Put cursor past the end of the input text chunks[2].x + width as u16 + 1, @@ -551,7 +502,7 @@ fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut }; let input = - Paragraph::new(format!("{}] {}", filter_mode, app.input.clone())).block(Block::default()); + Paragraph::new(format!("{}] {}", filter_mode, app.input.as_str())).block(Block::default()); f.render_widget(title, header_chunks[0]); f.render_widget(help, header_chunks[1]); @@ -560,13 +511,7 @@ fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut app.render_results(f, chunks[1], Block::default()); f.render_widget(input, chunks[2]); - let extra_width = UnicodeWidthStr::width( - app.input - .chars() - .take(app.cursor_index) - .collect::() - .as_str(), - ) + filter_mode.len(); + let extra_width = UnicodeWidthStr::width(app.input.substring()) + filter_mode.len(); f.set_cursor( // Put cursor past the end of the input text @@ -596,12 +541,11 @@ async fn select_history( // Setup event handlers let events = Events::new(); - let input = query.join(" "); + let mut input = Cursor::from(query.join(" ")); // Put the cursor at the end of the query by default - let cursor_index = input.chars().count(); + input.end(); let mut app = State { input, - cursor_index, results: Vec::new(), results_state: ListState::default(), context: current_context(), @@ -612,24 +556,24 @@ async fn select_history( loop { let history_count = db.history_count().await?; - let initial_input = app.input.clone(); + let initial_input = app.input.as_str().to_owned(); let initial_filter_mode = app.filter_mode; // Handle input if let Event::Input(input) = events.next()? { if let Some(output) = key_handler(&input, &mut app) { - return Ok(output); + return Ok(output.to_owned()); } } // After we receive input process the whole event channel before query/render. while let Ok(Event::Input(input)) = events.try_next() { if let Some(output) = key_handler(&input, &mut app) { - return Ok(output); + return Ok(output.to_owned()); } } - if initial_input != app.input || initial_filter_mode != app.filter_mode { + if initial_input != app.input.as_str() || initial_filter_mode != app.filter_mode { query_results(&mut app, search_mode, db).await?; } @@ -747,3 +691,160 @@ async fn run_non_interactive( super::history::print_list(&results, list_mode); Ok(()) } + +struct Cursor { + source: String, + index: usize, +} + +impl From for Cursor { + fn from(source: String) -> Self { + Self { source, index: 0 } + } +} + +impl Cursor { + pub fn as_str(&self) -> &str { + self.source.as_str() + } + + /// Returns the string before the cursor + pub fn substring(&self) -> &str { + &self.source[..self.index] + } + + pub fn index(&self) -> usize { + self.index + } + + /// Returns the currently selected [`char`] + pub fn char(&self) -> Option { + self.source[self.index..].chars().next() + } + + pub fn right(&mut self) { + if self.index < self.source.len() { + loop { + self.index += 1; + if self.source.is_char_boundary(self.index) { + break; + } + } + } + } + + pub fn left(&mut self) -> bool { + if self.index > 0 { + loop { + self.index -= 1; + if self.source.is_char_boundary(self.index) { + break true; + } + } + } else { + false + } + } + + pub fn insert(&mut self, c: char) { + self.source.insert(self.index, c); + self.index += c.len_utf8(); + } + + pub fn remove(&mut self) -> char { + self.source.remove(self.index) + } + + pub fn back(&mut self) -> Option { + if self.left() { + Some(self.remove()) + } else { + None + } + } + + pub fn clear(&mut self) { + self.source.clear(); + self.index = 0; + } + + pub fn end(&mut self) { + self.index = self.source.len(); + } + + pub fn start(&mut self) { + self.index = 0; + } +} + +#[cfg(test)] +mod cursor_tests { + use super::Cursor; + + #[test] + fn right() { + // ö is 2 bytes + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; + for i in indices { + assert_eq!(c.index(), i); + c.right(); + } + } + + #[test] + fn left() { + // ö is 2 bytes + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + c.end(); + let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; + for i in indices { + assert_eq!(c.index(), i); + c.left(); + } + } + + #[test] + fn pop() { + let mut s = String::from("öaöböcödöeöfö"); + let mut c = Cursor::from(s.clone()); + c.end(); + while !s.is_empty() { + let c1 = s.pop(); + let c2 = c.back(); + assert_eq!(c1, c2); + assert_eq!(s.as_str(), c.substring()); + } + let c1 = s.pop(); + let c2 = c.back(); + assert_eq!(c1, c2); + } + + #[test] + fn back() { + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + // move to ^ + for _ in 0..4 { c.right() } + assert_eq!(c.substring(), "öaöb"); + assert_eq!(c.back(), Some('b')); + assert_eq!(c.back(), Some('ö')); + assert_eq!(c.back(), Some('a')); + assert_eq!(c.back(), Some('ö')); + assert_eq!(c.back(), None); + assert_eq!(c.as_str(), "öcödöeöfö"); + } + + #[test] + fn insert() { + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + // move to ^ + for _ in 0..4 { c.right() } + assert_eq!(c.substring(), "öaöb"); + c.insert('ö'); + c.insert('g'); + c.insert('ö'); + c.insert('h'); + assert_eq!(c.substring(), "öaöbögöh"); + assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö"); + } +} From 7ffc31659acc5c527392dfeeee915a58ea2dd445 Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Sun, 3 Jul 2022 13:29:26 +0100 Subject: [PATCH 4/7] fmt --- src/command/client/search.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/command/client/search.rs b/src/command/client/search.rs index 34e702c6645..a8a38cba6be 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -824,7 +824,9 @@ mod cursor_tests { fn back() { let mut c = Cursor::from(String::from("öaöböcödöeöfö")); // move to ^ - for _ in 0..4 { c.right() } + for _ in 0..4 { + c.right() + } assert_eq!(c.substring(), "öaöb"); assert_eq!(c.back(), Some('b')); assert_eq!(c.back(), Some('ö')); @@ -838,7 +840,9 @@ mod cursor_tests { fn insert() { let mut c = Cursor::from(String::from("öaöböcödöeöfö")); // move to ^ - for _ in 0..4 { c.right() } + for _ in 0..4 { + c.right() + } assert_eq!(c.substring(), "öaöb"); c.insert('ö'); c.insert('g'); From 6be33d4662e08d58937c7a4f958a71b31d7a0c3f Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Sun, 3 Jul 2022 13:32:57 +0100 Subject: [PATCH 5/7] clippy --- src/command/client/search.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/command/client/search.rs b/src/command/client/search.rs index a8a38cba6be..4400a15930d 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -713,10 +713,6 @@ impl Cursor { &self.source[..self.index] } - pub fn index(&self) -> usize { - self.index - } - /// Returns the currently selected [`char`] pub fn char(&self) -> Option { self.source[self.index..].chars().next() @@ -787,7 +783,7 @@ mod cursor_tests { let mut c = Cursor::from(String::from("öaöböcödöeöfö")); let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; for i in indices { - assert_eq!(c.index(), i); + assert_eq!(c.index, i); c.right(); } } @@ -799,7 +795,7 @@ mod cursor_tests { c.end(); let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; for i in indices { - assert_eq!(c.index(), i); + assert_eq!(c.index, i); c.left(); } } @@ -825,7 +821,7 @@ mod cursor_tests { let mut c = Cursor::from(String::from("öaöböcödöeöfö")); // move to ^ for _ in 0..4 { - c.right() + c.right(); } assert_eq!(c.substring(), "öaöb"); assert_eq!(c.back(), Some('b')); @@ -841,7 +837,7 @@ mod cursor_tests { let mut c = Cursor::from(String::from("öaöböcödöeöfö")); // move to ^ for _ in 0..4 { - c.right() + c.right(); } assert_eq!(c.substring(), "öaöb"); c.insert('ö'); From 8bb9b239030b9b7b70569102ac75d8f8893eedbc Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Sat, 10 Sep 2022 10:43:40 +0100 Subject: [PATCH 6/7] move methods to state --- atuin-client/src/settings.rs | 19 +- src/command/client/search.rs | 499 +++++++++++++++++------------------ 2 files changed, 255 insertions(+), 263 deletions(-) diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs index d8720574da7..f836ce022a4 100644 --- a/atuin-client/src/settings.rs +++ b/atuin-client/src/settings.rs @@ -27,16 +27,27 @@ pub enum SearchMode { #[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq)] pub enum FilterMode { #[serde(rename = "global")] - Global, + Global = 0, #[serde(rename = "host")] - Host, + Host = 1, #[serde(rename = "session")] - Session, + Session = 2, #[serde(rename = "directory")] - Directory, + Directory = 3, +} + +impl FilterMode { + pub fn as_str(&self) -> &'static str { + match self { + FilterMode::Global => "GLOBAL", + FilterMode::Host => "HOST", + FilterMode::Session => "SESSION", + FilterMode::Directory => "DIRECTORY", + } + } } // FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged diff --git a/src/command/client/search.rs b/src/command/client/search.rs index 4400a15930d..32a0656e718 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -253,272 +253,253 @@ impl State { } } -async fn query_results( - app: &mut State, - search_mode: SearchMode, - db: &mut impl Database, -) -> Result<()> { - let results = match app.input.as_str() { - "" => { - db.list(app.filter_mode, &app.context, Some(200), true) +impl State { + async fn query_results( + &mut self, + search_mode: SearchMode, + db: &mut impl Database, + ) -> Result<()> { + let i = self.input.as_str(); + let results = if i.is_empty() { + db.list(self.filter_mode, &self.context, Some(200), true) .await? - } - i => { - db.search(Some(200), search_mode, app.filter_mode, &app.context, i) + } else { + db.search(Some(200), search_mode, self.filter_mode, &self.context, i) .await? - } - }; + }; - app.results = results; + self.results = results; - if app.results.is_empty() { - app.results_state.select(None); - } else { - app.results_state.select(Some(0)); + if self.results.is_empty() { + self.results_state.select(None); + } else { + self.results_state.select(Some(0)); + } + + Ok(()) } - Ok(()) -} + fn handle_input(&mut self, input: &TermEvent) -> Option<&str> { + match input { + TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(""), + TermEvent::Key(Key::Char('\n')) => { + let i = self.results_state.selected().unwrap_or(0); -#[allow(clippy::too_many_lines)] -fn key_handler<'app>(input: &TermEvent, app: &'app mut State) -> Option<&'app str> { - match input { - TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(""), - TermEvent::Key(Key::Char('\n')) => { - let i = app.results_state.selected().unwrap_or(0); - - return Some( - app.results - .get(i) - .map_or(app.input.as_str(), |h| h.command.as_str()), - ); - } - TermEvent::Key(Key::Alt(c @ '1'..='9')) => { - let c = c.to_digit(10)? as usize; - let i = app.results_state.selected()? + c; - - return Some( - app.results - .get(i) - .map_or(app.input.as_str(), |h| h.command.as_str()), - ); - } - TermEvent::Key(Key::Left | Key::Ctrl('h')) => { - app.input.left(); - } - TermEvent::Key(Key::Right | Key::Ctrl('l')) => app.input.right(), - TermEvent::Key(Key::Ctrl('a')) => app.input.start(), - TermEvent::Key(Key::Ctrl('e')) => app.input.end(), - TermEvent::Key(Key::Char(c)) => app.input.insert(*c), - TermEvent::Key(Key::Backspace) => { - app.input.back(); - } - TermEvent::Key(Key::Ctrl('w')) => { - // remove the first batch of whitespace - while matches!(app.input.back(), Some(c) if c.is_whitespace()) {} - while app.input.left() { - if app.input.char().unwrap().is_whitespace() { - app.input.right(); // found whitespace, go back right - break; - } - app.input.remove(); + return Some( + self.results + .get(i) + .map_or(self.input.as_str(), |h| h.command.as_str()), + ); } - } - TermEvent::Key(Key::Ctrl('u')) => app.input.clear(), - TermEvent::Key(Key::Ctrl('r')) => { - app.filter_mode = match app.filter_mode { - FilterMode::Global => FilterMode::Host, - FilterMode::Host => FilterMode::Session, - FilterMode::Session => FilterMode::Directory, - FilterMode::Directory => FilterMode::Global, - }; - } - TermEvent::Key(Key::Down | Key::Ctrl('n' | 'j')) - | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => { - let i = match app.results_state.selected() { - Some(i) => { - if i == 0 { - 0 - } else { - i - 1 - } - } - None => 0, - }; - app.results_state.select(Some(i)); - } - TermEvent::Key(Key::Up | Key::Ctrl('p' | 'k')) - | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => { - let i = match app.results_state.selected() { - Some(i) => { - if i >= app.results.len() - 1 { - app.results.len() - 1 - } else { - i + 1 + TermEvent::Key(Key::Alt(c @ '1'..='9')) => { + let c = c.to_digit(10)? as usize; + let i = self.results_state.selected()? + c; + + return Some( + self.results + .get(i) + .map_or(self.input.as_str(), |h| h.command.as_str()), + ); + } + TermEvent::Key(Key::Left | Key::Ctrl('h')) => { + self.input.left(); + } + TermEvent::Key(Key::Right | Key::Ctrl('l')) => self.input.right(), + TermEvent::Key(Key::Ctrl('a')) => self.input.start(), + TermEvent::Key(Key::Ctrl('e')) => self.input.end(), + TermEvent::Key(Key::Char(c)) => self.input.insert(*c), + TermEvent::Key(Key::Backspace) => { + self.input.back(); + } + TermEvent::Key(Key::Ctrl('w')) => { + // remove the first batch of whitespace + while matches!(self.input.back(), Some(c) if c.is_whitespace()) {} + while self.input.left() { + if self.input.char().unwrap().is_whitespace() { + self.input.right(); // found whitespace, go back right + break; } + self.input.remove(); } - None => 0, - }; - app.results_state.select(Some(i)); - } - _ => {} - }; - - None -} - -#[allow(clippy::cast_possible_truncation)] -fn draw(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints( - [ - Constraint::Length(2), - Constraint::Min(1), - Constraint::Length(3), - ] - .as_ref(), - ) - .split(f.size()); - - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) - .split(chunks[0]); - - let top_left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) - .split(top_chunks[0]); - - let top_right_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) - .split(top_chunks[1]); - - let title = Paragraph::new(Text::from(Span::styled( - format!("Atuin v{}", VERSION), - Style::default().add_modifier(Modifier::BOLD), - ))); - - let help = vec![ - Span::raw("Press "), - Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit."), - ]; - - let help = Text::from(Spans::from(help)); - let help = Paragraph::new(help); - - let filter_mode = match app.filter_mode { - FilterMode::Global => "GLOBAL", - FilterMode::Host => "HOST", - FilterMode::Session => "SESSION", - FilterMode::Directory => "DIRECTORY", - }; - - let input = Paragraph::new(app.input.as_str().to_owned()) - .block(Block::default().borders(Borders::ALL).title(filter_mode)); - - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - history_count, - )))) - .alignment(Alignment::Right); - - f.render_widget(title, top_left_chunks[0]); - f.render_widget(help, top_left_chunks[1]); - f.render_widget(stats, top_right_chunks[0]); - - app.render_results( - f, - chunks[1], - Block::default().borders(Borders::ALL).title("History"), - ); - f.render_widget(input, chunks[2]); - - let width = UnicodeWidthStr::width(app.input.substring()); - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + width as u16 + 1, - // Move one line down, from the border to the input line - chunks[2].y + 1, - ); -} - -#[allow(clippy::cast_possible_truncation)] -fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(0) - .horizontal_margin(1) - .constraints( - [ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ] - .as_ref(), - ) - .split(f.size()); - - let header_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ] - .as_ref(), - ) - .split(chunks[0]); - - let title = Paragraph::new(Text::from(Span::styled( - format!("Atuin v{}", VERSION), - Style::default().fg(Color::DarkGray), - ))); - - let help = Paragraph::new(Text::from(Spans::from(vec![ - Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit"), - ]))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - history_count, - )))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Right); - - let filter_mode = match app.filter_mode { - FilterMode::Global => "GLOBAL", - FilterMode::Host => "HOST", - FilterMode::Session => "SESSION", - FilterMode::Directory => "DIRECTORY", - }; - - let input = - Paragraph::new(format!("{}] {}", filter_mode, app.input.as_str())).block(Block::default()); - - f.render_widget(title, header_chunks[0]); - f.render_widget(help, header_chunks[1]); - f.render_widget(stats, header_chunks[2]); + } + TermEvent::Key(Key::Ctrl('u')) => self.input.clear(), + TermEvent::Key(Key::Ctrl('r')) => { + pub static FILTER_MODES: [FilterMode; 4] = [ + FilterMode::Global, + FilterMode::Host, + FilterMode::Session, + FilterMode::Directory, + ]; + let i = self.filter_mode as usize; + let i = (i + 1) % FILTER_MODES.len(); + self.filter_mode = FILTER_MODES[i]; + } + TermEvent::Key(Key::Down | Key::Ctrl('n' | 'j')) + | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => { + let i = self + .results_state + .selected() // try get current selection + .map_or(0, |i| i.saturating_sub(1)); // subtract 1 if possible + self.results_state.select(Some(i)); + } + TermEvent::Key(Key::Up | Key::Ctrl('p' | 'k')) + | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => { + let i = self + .results_state + .selected() + .map_or(0, |i| i + 1) // increment the selected index + .min(self.results.len() - 1); // clamp it to the last entry + self.results_state.select(Some(i)); + } + _ => {} + }; - app.render_results(f, chunks[1], Block::default()); - f.render_widget(input, chunks[2]); + None + } - let extra_width = UnicodeWidthStr::width(app.input.substring()) + filter_mode.len(); + #[allow(clippy::cast_possible_truncation)] + fn draw(&mut self, f: &mut Frame<'_, T>, history_count: i64) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(3), + ] + .as_ref(), + ) + .split(f.size()); + + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(chunks[0]); + + let top_left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) + .split(top_chunks[0]); + + let top_right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) + .split(top_chunks[1]); + + let title = Paragraph::new(Text::from(Span::styled( + format!("Atuin v{}", VERSION), + Style::default().add_modifier(Modifier::BOLD), + ))); + + let help = vec![ + Span::raw("Press "), + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to exit."), + ]; + + let help = Text::from(Spans::from(help)); + let help = Paragraph::new(help); + + let input = Paragraph::new(self.input.as_str().to_owned()).block( + Block::default() + .borders(Borders::ALL) + .title(self.filter_mode.as_str()), + ); + + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + history_count, + )))) + .alignment(Alignment::Right); + + f.render_widget(title, top_left_chunks[0]); + f.render_widget(help, top_left_chunks[1]); + f.render_widget(stats, top_right_chunks[0]); + + self.render_results( + f, + chunks[1], + Block::default().borders(Borders::ALL).title("History"), + ); + f.render_widget(input, chunks[2]); + + let width = UnicodeWidthStr::width(self.input.substring()); + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + width as u16 + 1, + // Move one line down, from the border to the input line + chunks[2].y + 1, + ); + } - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + extra_width as u16 + 2, - // Move one line down, from the border to the input line - chunks[2].y + 1, - ); + #[allow(clippy::cast_possible_truncation)] + fn draw_compact(&mut self, f: &mut Frame<'_, T>, history_count: i64) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(0) + .horizontal_margin(1) + .constraints( + [ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ] + .as_ref(), + ) + .split(f.size()); + + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ] + .as_ref(), + ) + .split(chunks[0]); + + let title = Paragraph::new(Text::from(Span::styled( + format!("Atuin v{}", VERSION), + Style::default().fg(Color::DarkGray), + ))); + + let help = Paragraph::new(Text::from(Spans::from(vec![ + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to exit"), + ]))) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + history_count, + )))) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Right); + + let filter_mode = self.filter_mode.as_str(); + let input = Paragraph::new(format!("{}] {}", filter_mode, self.input.as_str())) + .block(Block::default()); + + f.render_widget(title, header_chunks[0]); + f.render_widget(help, header_chunks[1]); + f.render_widget(stats, header_chunks[2]); + + self.render_results(f, chunks[1], Block::default()); + f.render_widget(input, chunks[2]); + + let extra_width = UnicodeWidthStr::width(self.input.substring()) + filter_mode.len(); + + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + extra_width as u16 + 2, + // Move one line down, from the border to the input line + chunks[2].y + 1, + ); + } } // this is a big blob of horrible! clean it up! @@ -552,7 +533,7 @@ async fn select_history( filter_mode, }; - query_results(&mut app, search_mode, db).await?; + app.query_results(search_mode, db).await?; loop { let history_count = db.history_count().await?; @@ -561,20 +542,20 @@ async fn select_history( // Handle input if let Event::Input(input) = events.next()? { - if let Some(output) = key_handler(&input, &mut app) { + if let Some(output) = app.handle_input(&input) { return Ok(output.to_owned()); } } // After we receive input process the whole event channel before query/render. while let Ok(Event::Input(input)) = events.try_next() { - if let Some(output) = key_handler(&input, &mut app) { + if let Some(output) = app.handle_input(&input) { return Ok(output.to_owned()); } } if initial_input != app.input.as_str() || initial_filter_mode != app.filter_mode { - query_results(&mut app, search_mode, db).await?; + app.query_results(search_mode, db).await?; } let compact = match style { @@ -585,9 +566,9 @@ async fn select_history( atuin_client::settings::Style::Full => false, }; if compact { - terminal.draw(|f| draw_compact(f, history_count, &mut app))?; + terminal.draw(|f| app.draw_compact(f, history_count))?; } else { - terminal.draw(|f| draw(f, history_count, &mut app))?; + terminal.draw(|f| app.draw(f, history_count))?; } } } From f51633b6cd135fe2cb12d2def90be0dc5d0d7cb3 Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Sat, 10 Sep 2022 10:50:35 +0100 Subject: [PATCH 7/7] refactor search modules --- atuin-client/src/database.rs | 2 +- src/command/client.rs | 1 - src/command/client/search.rs | 652 +---------------------- src/command/client/search/cursor.rs | 156 ++++++ src/command/client/{ => search}/event.rs | 0 src/command/client/search/interactive.rs | 493 +++++++++++++++++ 6 files changed, 656 insertions(+), 648 deletions(-) create mode 100644 src/command/client/search/cursor.rs rename src/command/client/{ => search}/event.rs (100%) create mode 100644 src/command/client/search/interactive.rs diff --git a/atuin-client/src/database.rs b/atuin-client/src/database.rs index 7b3ab3beb28..ba28daf3069 100644 --- a/atuin-client/src/database.rs +++ b/atuin-client/src/database.rs @@ -461,7 +461,7 @@ mod test { Some("beep boop".to_string()), Some("booop".to_string()), ); - return db.save(&history).await; + db.save(&history).await } #[tokio::test(flavor = "multi_thread")] diff --git a/src/command/client.rs b/src/command/client.rs index b9d43b399a3..ae49b857c9a 100644 --- a/src/command/client.rs +++ b/src/command/client.rs @@ -10,7 +10,6 @@ use atuin_common::utils::uuid_v4; #[cfg(feature = "sync")] mod sync; -mod event; mod history; mod import; mod init; diff --git a/src/command/client/search.rs b/src/command/client/search.rs index 32a0656e718..7b84d410d53 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -1,36 +1,16 @@ -use std::{env, io::stdout, ops::Sub, time::Duration}; - use chrono::Utc; use clap::Parser; use eyre::Result; -use termion::{ - event::Event as TermEvent, event::Key, event::MouseButton, event::MouseEvent, - input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen, -}; -use tui::{ - backend::{Backend, TermionBackend}, - layout::{Alignment, Constraint, Corner, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Span, Spans, Text}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, - Frame, Terminal, -}; -use unicode_width::UnicodeWidthStr; use atuin_client::{ - database::current_context, - database::Context, - database::Database, - history::History, - settings::{FilterMode, SearchMode, Settings}, + database::current_context, database::Database, history::History, settings::Settings, }; -use super::{ - event::{Event, Events}, - history::ListMode, -}; +use super::history::ListMode; -const VERSION: &str = env!("CARGO_PKG_VERSION"); +mod cursor; +mod event; +mod interactive; #[derive(Parser)] pub struct Cmd { @@ -80,7 +60,7 @@ pub struct Cmd { impl Cmd { pub async fn run(self, db: &mut impl Database, settings: &Settings) -> Result<()> { if self.interactive { - let item = select_history( + let item = interactive::history( &self.query, settings.search_mode, settings.filter_mode, @@ -110,469 +90,6 @@ impl Cmd { } } -struct State { - input: Cursor, - - filter_mode: FilterMode, - - results: Vec, - - results_state: ListState, - - context: Context, -} - -impl State { - #[allow(clippy::cast_sign_loss)] - fn durations(&self) -> Vec<(String, String)> { - self.results - .iter() - .map(|h| { - let duration = - Duration::from_millis(std::cmp::max(h.duration, 0) as u64 / 1_000_000); - let duration = humantime::format_duration(duration).to_string(); - let duration: Vec<&str> = duration.split(' ').collect(); - - let ago = chrono::Utc::now().sub(h.timestamp); - - // Account for the chance that h.timestamp is "in the future" - // This would mean that "ago" is negative, and the unwrap here - // would fail. - // If the timestamp would otherwise be in the future, display - // the time ago as 0. - let ago = humantime::format_duration( - ago.to_std().unwrap_or_else(|_| Duration::new(0, 0)), - ) - .to_string(); - let ago: Vec<&str> = ago.split(' ').collect(); - - ( - duration[0] - .to_string() - .replace("days", "d") - .replace("day", "d") - .replace("weeks", "w") - .replace("week", "w") - .replace("months", "mo") - .replace("month", "mo") - .replace("years", "y") - .replace("year", "y"), - ago[0] - .to_string() - .replace("days", "d") - .replace("day", "d") - .replace("weeks", "w") - .replace("week", "w") - .replace("months", "mo") - .replace("month", "mo") - .replace("years", "y") - .replace("year", "y") - + " ago", - ) - }) - .collect() - } - - fn render_results( - &mut self, - f: &mut tui::Frame, - r: tui::layout::Rect, - b: tui::widgets::Block, - ) { - let durations = self.durations(); - let max_length = durations.iter().fold(0, |largest, i| { - std::cmp::max(largest, i.0.len() + i.1.len()) - }); - - let results: Vec = self - .results - .iter() - .enumerate() - .map(|(i, m)| { - let command = m.command.to_string().replace('\n', " ").replace('\t', " "); - - let mut command = Span::raw(command); - - let (duration, mut ago) = durations[i].clone(); - - while (duration.len() + ago.len()) < max_length { - ago = format!(" {}", ago); - } - - let selected_index = match self.results_state.selected() { - None => Span::raw(" "), - Some(selected) => match i.checked_sub(selected) { - None => Span::raw(" "), - Some(diff) => { - if 0 < diff && diff < 10 { - Span::raw(format!(" {} ", diff)) - } else { - Span::raw(" ") - } - } - }, - }; - - let duration = Span::styled( - duration, - Style::default().fg(if m.success() { - Color::Green - } else { - Color::Red - }), - ); - - let ago = Span::styled(ago, Style::default().fg(Color::Blue)); - - if let Some(selected) = self.results_state.selected() { - if selected == i { - command.style = - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); - } - } - - let spans = Spans::from(vec![ - selected_index, - duration, - Span::raw(" "), - ago, - Span::raw(" "), - command, - ]); - - ListItem::new(spans) - }) - .collect(); - - let results = List::new(results) - .block(b) - .start_corner(Corner::BottomLeft) - .highlight_symbol(">> "); - - f.render_stateful_widget(results, r, &mut self.results_state); - } -} - -impl State { - async fn query_results( - &mut self, - search_mode: SearchMode, - db: &mut impl Database, - ) -> Result<()> { - let i = self.input.as_str(); - let results = if i.is_empty() { - db.list(self.filter_mode, &self.context, Some(200), true) - .await? - } else { - db.search(Some(200), search_mode, self.filter_mode, &self.context, i) - .await? - }; - - self.results = results; - - if self.results.is_empty() { - self.results_state.select(None); - } else { - self.results_state.select(Some(0)); - } - - Ok(()) - } - - fn handle_input(&mut self, input: &TermEvent) -> Option<&str> { - match input { - TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(""), - TermEvent::Key(Key::Char('\n')) => { - let i = self.results_state.selected().unwrap_or(0); - - return Some( - self.results - .get(i) - .map_or(self.input.as_str(), |h| h.command.as_str()), - ); - } - TermEvent::Key(Key::Alt(c @ '1'..='9')) => { - let c = c.to_digit(10)? as usize; - let i = self.results_state.selected()? + c; - - return Some( - self.results - .get(i) - .map_or(self.input.as_str(), |h| h.command.as_str()), - ); - } - TermEvent::Key(Key::Left | Key::Ctrl('h')) => { - self.input.left(); - } - TermEvent::Key(Key::Right | Key::Ctrl('l')) => self.input.right(), - TermEvent::Key(Key::Ctrl('a')) => self.input.start(), - TermEvent::Key(Key::Ctrl('e')) => self.input.end(), - TermEvent::Key(Key::Char(c)) => self.input.insert(*c), - TermEvent::Key(Key::Backspace) => { - self.input.back(); - } - TermEvent::Key(Key::Ctrl('w')) => { - // remove the first batch of whitespace - while matches!(self.input.back(), Some(c) if c.is_whitespace()) {} - while self.input.left() { - if self.input.char().unwrap().is_whitespace() { - self.input.right(); // found whitespace, go back right - break; - } - self.input.remove(); - } - } - TermEvent::Key(Key::Ctrl('u')) => self.input.clear(), - TermEvent::Key(Key::Ctrl('r')) => { - pub static FILTER_MODES: [FilterMode; 4] = [ - FilterMode::Global, - FilterMode::Host, - FilterMode::Session, - FilterMode::Directory, - ]; - let i = self.filter_mode as usize; - let i = (i + 1) % FILTER_MODES.len(); - self.filter_mode = FILTER_MODES[i]; - } - TermEvent::Key(Key::Down | Key::Ctrl('n' | 'j')) - | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => { - let i = self - .results_state - .selected() // try get current selection - .map_or(0, |i| i.saturating_sub(1)); // subtract 1 if possible - self.results_state.select(Some(i)); - } - TermEvent::Key(Key::Up | Key::Ctrl('p' | 'k')) - | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => { - let i = self - .results_state - .selected() - .map_or(0, |i| i + 1) // increment the selected index - .min(self.results.len() - 1); // clamp it to the last entry - self.results_state.select(Some(i)); - } - _ => {} - }; - - None - } - - #[allow(clippy::cast_possible_truncation)] - fn draw(&mut self, f: &mut Frame<'_, T>, history_count: i64) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints( - [ - Constraint::Length(2), - Constraint::Min(1), - Constraint::Length(3), - ] - .as_ref(), - ) - .split(f.size()); - - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) - .split(chunks[0]); - - let top_left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) - .split(top_chunks[0]); - - let top_right_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) - .split(top_chunks[1]); - - let title = Paragraph::new(Text::from(Span::styled( - format!("Atuin v{}", VERSION), - Style::default().add_modifier(Modifier::BOLD), - ))); - - let help = vec![ - Span::raw("Press "), - Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit."), - ]; - - let help = Text::from(Spans::from(help)); - let help = Paragraph::new(help); - - let input = Paragraph::new(self.input.as_str().to_owned()).block( - Block::default() - .borders(Borders::ALL) - .title(self.filter_mode.as_str()), - ); - - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - history_count, - )))) - .alignment(Alignment::Right); - - f.render_widget(title, top_left_chunks[0]); - f.render_widget(help, top_left_chunks[1]); - f.render_widget(stats, top_right_chunks[0]); - - self.render_results( - f, - chunks[1], - Block::default().borders(Borders::ALL).title("History"), - ); - f.render_widget(input, chunks[2]); - - let width = UnicodeWidthStr::width(self.input.substring()); - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + width as u16 + 1, - // Move one line down, from the border to the input line - chunks[2].y + 1, - ); - } - - #[allow(clippy::cast_possible_truncation)] - fn draw_compact(&mut self, f: &mut Frame<'_, T>, history_count: i64) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(0) - .horizontal_margin(1) - .constraints( - [ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ] - .as_ref(), - ) - .split(f.size()); - - let header_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ] - .as_ref(), - ) - .split(chunks[0]); - - let title = Paragraph::new(Text::from(Span::styled( - format!("Atuin v{}", VERSION), - Style::default().fg(Color::DarkGray), - ))); - - let help = Paragraph::new(Text::from(Spans::from(vec![ - Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit"), - ]))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - history_count, - )))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Right); - - let filter_mode = self.filter_mode.as_str(); - let input = Paragraph::new(format!("{}] {}", filter_mode, self.input.as_str())) - .block(Block::default()); - - f.render_widget(title, header_chunks[0]); - f.render_widget(help, header_chunks[1]); - f.render_widget(stats, header_chunks[2]); - - self.render_results(f, chunks[1], Block::default()); - f.render_widget(input, chunks[2]); - - let extra_width = UnicodeWidthStr::width(self.input.substring()) + filter_mode.len(); - - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + extra_width as u16 + 2, - // Move one line down, from the border to the input line - chunks[2].y + 1, - ); - } -} - -// this is a big blob of horrible! clean it up! -// for now, it works. But it'd be great if it were more easily readable, and -// modular. I'd like to add some more stats and stuff at some point -#[allow(clippy::cast_possible_truncation)] -async fn select_history( - query: &[String], - search_mode: SearchMode, - filter_mode: FilterMode, - style: atuin_client::settings::Style, - db: &mut impl Database, -) -> Result { - let stdout = stdout().into_raw_mode()?; - let stdout = MouseTerminal::from(stdout); - let stdout = AlternateScreen::from(stdout); - let backend = TermionBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Setup event handlers - let events = Events::new(); - - let mut input = Cursor::from(query.join(" ")); - // Put the cursor at the end of the query by default - input.end(); - let mut app = State { - input, - results: Vec::new(), - results_state: ListState::default(), - context: current_context(), - filter_mode, - }; - - app.query_results(search_mode, db).await?; - - loop { - let history_count = db.history_count().await?; - let initial_input = app.input.as_str().to_owned(); - let initial_filter_mode = app.filter_mode; - - // Handle input - if let Event::Input(input) = events.next()? { - if let Some(output) = app.handle_input(&input) { - return Ok(output.to_owned()); - } - } - - // After we receive input process the whole event channel before query/render. - while let Ok(Event::Input(input)) = events.try_next() { - if let Some(output) = app.handle_input(&input) { - return Ok(output.to_owned()); - } - } - - if initial_input != app.input.as_str() || initial_filter_mode != app.filter_mode { - app.query_results(search_mode, db).await?; - } - - let compact = match style { - atuin_client::settings::Style::Auto => { - terminal.size().map(|size| size.height < 14).unwrap_or(true) - } - atuin_client::settings::Style::Compact => true, - atuin_client::settings::Style::Full => false, - }; - if compact { - terminal.draw(|f| app.draw_compact(f, history_count))?; - } else { - terminal.draw(|f| app.draw(f, history_count))?; - } - } -} - // This is supposed to more-or-less mirror the command line version, so ofc // it is going to have a lot of args #[allow(clippy::too_many_arguments)] @@ -672,160 +189,3 @@ async fn run_non_interactive( super::history::print_list(&results, list_mode); Ok(()) } - -struct Cursor { - source: String, - index: usize, -} - -impl From for Cursor { - fn from(source: String) -> Self { - Self { source, index: 0 } - } -} - -impl Cursor { - pub fn as_str(&self) -> &str { - self.source.as_str() - } - - /// Returns the string before the cursor - pub fn substring(&self) -> &str { - &self.source[..self.index] - } - - /// Returns the currently selected [`char`] - pub fn char(&self) -> Option { - self.source[self.index..].chars().next() - } - - pub fn right(&mut self) { - if self.index < self.source.len() { - loop { - self.index += 1; - if self.source.is_char_boundary(self.index) { - break; - } - } - } - } - - pub fn left(&mut self) -> bool { - if self.index > 0 { - loop { - self.index -= 1; - if self.source.is_char_boundary(self.index) { - break true; - } - } - } else { - false - } - } - - pub fn insert(&mut self, c: char) { - self.source.insert(self.index, c); - self.index += c.len_utf8(); - } - - pub fn remove(&mut self) -> char { - self.source.remove(self.index) - } - - pub fn back(&mut self) -> Option { - if self.left() { - Some(self.remove()) - } else { - None - } - } - - pub fn clear(&mut self) { - self.source.clear(); - self.index = 0; - } - - pub fn end(&mut self) { - self.index = self.source.len(); - } - - pub fn start(&mut self) { - self.index = 0; - } -} - -#[cfg(test)] -mod cursor_tests { - use super::Cursor; - - #[test] - fn right() { - // ö is 2 bytes - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; - for i in indices { - assert_eq!(c.index, i); - c.right(); - } - } - - #[test] - fn left() { - // ö is 2 bytes - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - c.end(); - let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; - for i in indices { - assert_eq!(c.index, i); - c.left(); - } - } - - #[test] - fn pop() { - let mut s = String::from("öaöböcödöeöfö"); - let mut c = Cursor::from(s.clone()); - c.end(); - while !s.is_empty() { - let c1 = s.pop(); - let c2 = c.back(); - assert_eq!(c1, c2); - assert_eq!(s.as_str(), c.substring()); - } - let c1 = s.pop(); - let c2 = c.back(); - assert_eq!(c1, c2); - } - - #[test] - fn back() { - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - // move to ^ - for _ in 0..4 { - c.right(); - } - assert_eq!(c.substring(), "öaöb"); - assert_eq!(c.back(), Some('b')); - assert_eq!(c.back(), Some('ö')); - assert_eq!(c.back(), Some('a')); - assert_eq!(c.back(), Some('ö')); - assert_eq!(c.back(), None); - assert_eq!(c.as_str(), "öcödöeöfö"); - } - - #[test] - fn insert() { - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - // move to ^ - for _ in 0..4 { - c.right(); - } - assert_eq!(c.substring(), "öaöb"); - c.insert('ö'); - c.insert('g'); - c.insert('ö'); - c.insert('h'); - assert_eq!(c.substring(), "öaöbögöh"); - assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö"); - } -} diff --git a/src/command/client/search/cursor.rs b/src/command/client/search/cursor.rs new file mode 100644 index 00000000000..1d0e6b8e500 --- /dev/null +++ b/src/command/client/search/cursor.rs @@ -0,0 +1,156 @@ +pub struct Cursor { + source: String, + index: usize, +} + +impl From for Cursor { + fn from(source: String) -> Self { + Self { source, index: 0 } + } +} + +impl Cursor { + pub fn as_str(&self) -> &str { + self.source.as_str() + } + + /// Returns the string before the cursor + pub fn substring(&self) -> &str { + &self.source[..self.index] + } + + /// Returns the currently selected [`char`] + pub fn char(&self) -> Option { + self.source[self.index..].chars().next() + } + + pub fn right(&mut self) { + if self.index < self.source.len() { + loop { + self.index += 1; + if self.source.is_char_boundary(self.index) { + break; + } + } + } + } + + pub fn left(&mut self) -> bool { + if self.index > 0 { + loop { + self.index -= 1; + if self.source.is_char_boundary(self.index) { + break true; + } + } + } else { + false + } + } + + pub fn insert(&mut self, c: char) { + self.source.insert(self.index, c); + self.index += c.len_utf8(); + } + + pub fn remove(&mut self) -> char { + self.source.remove(self.index) + } + + pub fn back(&mut self) -> Option { + if self.left() { + Some(self.remove()) + } else { + None + } + } + + pub fn clear(&mut self) { + self.source.clear(); + self.index = 0; + } + + pub fn end(&mut self) { + self.index = self.source.len(); + } + + pub fn start(&mut self) { + self.index = 0; + } +} + +#[cfg(test)] +mod cursor_tests { + use super::Cursor; + + #[test] + fn right() { + // ö is 2 bytes + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; + for i in indices { + assert_eq!(c.index, i); + c.right(); + } + } + + #[test] + fn left() { + // ö is 2 bytes + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + c.end(); + let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; + for i in indices { + assert_eq!(c.index, i); + c.left(); + } + } + + #[test] + fn pop() { + let mut s = String::from("öaöböcödöeöfö"); + let mut c = Cursor::from(s.clone()); + c.end(); + while !s.is_empty() { + let c1 = s.pop(); + let c2 = c.back(); + assert_eq!(c1, c2); + assert_eq!(s.as_str(), c.substring()); + } + let c1 = s.pop(); + let c2 = c.back(); + assert_eq!(c1, c2); + } + + #[test] + fn back() { + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + // move to ^ + for _ in 0..4 { + c.right(); + } + assert_eq!(c.substring(), "öaöb"); + assert_eq!(c.back(), Some('b')); + assert_eq!(c.back(), Some('ö')); + assert_eq!(c.back(), Some('a')); + assert_eq!(c.back(), Some('ö')); + assert_eq!(c.back(), None); + assert_eq!(c.as_str(), "öcödöeöfö"); + } + + #[test] + fn insert() { + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + // move to ^ + for _ in 0..4 { + c.right(); + } + assert_eq!(c.substring(), "öaöb"); + c.insert('ö'); + c.insert('g'); + c.insert('ö'); + c.insert('h'); + assert_eq!(c.substring(), "öaöbögöh"); + assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö"); + } +} diff --git a/src/command/client/event.rs b/src/command/client/search/event.rs similarity index 100% rename from src/command/client/event.rs rename to src/command/client/search/event.rs diff --git a/src/command/client/search/interactive.rs b/src/command/client/search/interactive.rs new file mode 100644 index 00000000000..e355b3f2600 --- /dev/null +++ b/src/command/client/search/interactive.rs @@ -0,0 +1,493 @@ +use std::{io::stdout, ops::Sub, time::Duration}; + +use eyre::Result; +use termion::{ + event::Event as TermEvent, event::Key, event::MouseButton, event::MouseEvent, + input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen, +}; +use tui::{ + backend::{Backend, TermionBackend}, + layout::{Alignment, Constraint, Corner, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Span, Spans, Text}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, Terminal, +}; +use unicode_width::UnicodeWidthStr; + +use atuin_client::{ + database::current_context, + database::Context, + database::Database, + history::History, + settings::{FilterMode, SearchMode}, +}; + +use super::{ + cursor::Cursor, + event::{Event, Events}, +}; +use crate::VERSION; + +struct State { + input: Cursor, + + filter_mode: FilterMode, + + results: Vec, + + results_state: ListState, + + context: Context, +} + +impl State { + #[allow(clippy::cast_sign_loss)] + fn durations(&self) -> Vec<(String, String)> { + self.results + .iter() + .map(|h| { + let duration = + Duration::from_millis(std::cmp::max(h.duration, 0) as u64 / 1_000_000); + let duration = humantime::format_duration(duration).to_string(); + let duration: Vec<&str> = duration.split(' ').collect(); + + let ago = chrono::Utc::now().sub(h.timestamp); + + // Account for the chance that h.timestamp is "in the future" + // This would mean that "ago" is negative, and the unwrap here + // would fail. + // If the timestamp would otherwise be in the future, display + // the time ago as 0. + let ago = humantime::format_duration( + ago.to_std().unwrap_or_else(|_| Duration::new(0, 0)), + ) + .to_string(); + let ago: Vec<&str> = ago.split(' ').collect(); + + ( + duration[0] + .to_string() + .replace("days", "d") + .replace("day", "d") + .replace("weeks", "w") + .replace("week", "w") + .replace("months", "mo") + .replace("month", "mo") + .replace("years", "y") + .replace("year", "y"), + ago[0] + .to_string() + .replace("days", "d") + .replace("day", "d") + .replace("weeks", "w") + .replace("week", "w") + .replace("months", "mo") + .replace("month", "mo") + .replace("years", "y") + .replace("year", "y") + + " ago", + ) + }) + .collect() + } + + fn render_results( + &mut self, + f: &mut tui::Frame, + r: tui::layout::Rect, + b: tui::widgets::Block, + ) { + let durations = self.durations(); + let max_length = durations.iter().fold(0, |largest, i| { + std::cmp::max(largest, i.0.len() + i.1.len()) + }); + + let results: Vec = self + .results + .iter() + .enumerate() + .map(|(i, m)| { + let command = m.command.to_string().replace('\n', " ").replace('\t', " "); + + let mut command = Span::raw(command); + + let (duration, mut ago) = durations[i].clone(); + + while (duration.len() + ago.len()) < max_length { + ago = format!(" {}", ago); + } + + let selected_index = match self.results_state.selected() { + None => Span::raw(" "), + Some(selected) => match i.checked_sub(selected) { + None => Span::raw(" "), + Some(diff) => { + if 0 < diff && diff < 10 { + Span::raw(format!(" {} ", diff)) + } else { + Span::raw(" ") + } + } + }, + }; + + let duration = Span::styled( + duration, + Style::default().fg(if m.success() { + Color::Green + } else { + Color::Red + }), + ); + + let ago = Span::styled(ago, Style::default().fg(Color::Blue)); + + if let Some(selected) = self.results_state.selected() { + if selected == i { + command.style = + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); + } + } + + let spans = Spans::from(vec![ + selected_index, + duration, + Span::raw(" "), + ago, + Span::raw(" "), + command, + ]); + + ListItem::new(spans) + }) + .collect(); + + let results = List::new(results) + .block(b) + .start_corner(Corner::BottomLeft) + .highlight_symbol(">> "); + + f.render_stateful_widget(results, r, &mut self.results_state); + } +} + +impl State { + async fn query_results( + &mut self, + search_mode: SearchMode, + db: &mut impl Database, + ) -> Result<()> { + let i = self.input.as_str(); + let results = if i.is_empty() { + db.list(self.filter_mode, &self.context, Some(200), true) + .await? + } else { + db.search(Some(200), search_mode, self.filter_mode, &self.context, i) + .await? + }; + + self.results = results; + + if self.results.is_empty() { + self.results_state.select(None); + } else { + self.results_state.select(Some(0)); + } + + Ok(()) + } + + fn handle_input(&mut self, input: &TermEvent) -> Option<&str> { + match input { + TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(""), + TermEvent::Key(Key::Char('\n')) => { + let i = self.results_state.selected().unwrap_or(0); + + return Some( + self.results + .get(i) + .map_or(self.input.as_str(), |h| h.command.as_str()), + ); + } + TermEvent::Key(Key::Alt(c @ '1'..='9')) => { + let c = c.to_digit(10)? as usize; + let i = self.results_state.selected()? + c; + + return Some( + self.results + .get(i) + .map_or(self.input.as_str(), |h| h.command.as_str()), + ); + } + TermEvent::Key(Key::Left | Key::Ctrl('h')) => { + self.input.left(); + } + TermEvent::Key(Key::Right | Key::Ctrl('l')) => self.input.right(), + TermEvent::Key(Key::Ctrl('a')) => self.input.start(), + TermEvent::Key(Key::Ctrl('e')) => self.input.end(), + TermEvent::Key(Key::Char(c)) => self.input.insert(*c), + TermEvent::Key(Key::Backspace) => { + self.input.back(); + } + TermEvent::Key(Key::Ctrl('w')) => { + // remove the first batch of whitespace + while matches!(self.input.back(), Some(c) if c.is_whitespace()) {} + while self.input.left() { + if self.input.char().unwrap().is_whitespace() { + self.input.right(); // found whitespace, go back right + break; + } + self.input.remove(); + } + } + TermEvent::Key(Key::Ctrl('u')) => self.input.clear(), + TermEvent::Key(Key::Ctrl('r')) => { + pub static FILTER_MODES: [FilterMode; 4] = [ + FilterMode::Global, + FilterMode::Host, + FilterMode::Session, + FilterMode::Directory, + ]; + let i = self.filter_mode as usize; + let i = (i + 1) % FILTER_MODES.len(); + self.filter_mode = FILTER_MODES[i]; + } + TermEvent::Key(Key::Down | Key::Ctrl('n' | 'j')) + | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => { + let i = self + .results_state + .selected() // try get current selection + .map_or(0, |i| i.saturating_sub(1)); // subtract 1 if possible + self.results_state.select(Some(i)); + } + TermEvent::Key(Key::Up | Key::Ctrl('p' | 'k')) + | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => { + let i = self + .results_state + .selected() + .map_or(0, |i| i + 1) // increment the selected index + .min(self.results.len() - 1); // clamp it to the last entry + self.results_state.select(Some(i)); + } + _ => {} + }; + + None + } + + #[allow(clippy::cast_possible_truncation)] + fn draw(&mut self, f: &mut Frame<'_, T>, history_count: i64) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(3), + ] + .as_ref(), + ) + .split(f.size()); + + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(chunks[0]); + + let top_left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) + .split(top_chunks[0]); + + let top_right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) + .split(top_chunks[1]); + + let title = Paragraph::new(Text::from(Span::styled( + format!("Atuin v{}", VERSION), + Style::default().add_modifier(Modifier::BOLD), + ))); + + let help = vec![ + Span::raw("Press "), + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to exit."), + ]; + + let help = Text::from(Spans::from(help)); + let help = Paragraph::new(help); + + let input = Paragraph::new(self.input.as_str().to_owned()).block( + Block::default() + .borders(Borders::ALL) + .title(self.filter_mode.as_str()), + ); + + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + history_count, + )))) + .alignment(Alignment::Right); + + f.render_widget(title, top_left_chunks[0]); + f.render_widget(help, top_left_chunks[1]); + f.render_widget(stats, top_right_chunks[0]); + + self.render_results( + f, + chunks[1], + Block::default().borders(Borders::ALL).title("History"), + ); + f.render_widget(input, chunks[2]); + + let width = UnicodeWidthStr::width(self.input.substring()); + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + width as u16 + 1, + // Move one line down, from the border to the input line + chunks[2].y + 1, + ); + } + + #[allow(clippy::cast_possible_truncation)] + fn draw_compact(&mut self, f: &mut Frame<'_, T>, history_count: i64) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(0) + .horizontal_margin(1) + .constraints( + [ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ] + .as_ref(), + ) + .split(f.size()); + + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ] + .as_ref(), + ) + .split(chunks[0]); + + let title = Paragraph::new(Text::from(Span::styled( + format!("Atuin v{}", VERSION), + Style::default().fg(Color::DarkGray), + ))); + + let help = Paragraph::new(Text::from(Spans::from(vec![ + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to exit"), + ]))) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + history_count, + )))) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Right); + + let filter_mode = self.filter_mode.as_str(); + let input = Paragraph::new(format!("{}] {}", filter_mode, self.input.as_str())) + .block(Block::default()); + + f.render_widget(title, header_chunks[0]); + f.render_widget(help, header_chunks[1]); + f.render_widget(stats, header_chunks[2]); + + self.render_results(f, chunks[1], Block::default()); + f.render_widget(input, chunks[2]); + + let extra_width = UnicodeWidthStr::width(self.input.substring()) + filter_mode.len(); + + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + extra_width as u16 + 2, + // Move one line down, from the border to the input line + chunks[2].y + 1, + ); + } +} + +// this is a big blob of horrible! clean it up! +// for now, it works. But it'd be great if it were more easily readable, and +// modular. I'd like to add some more stats and stuff at some point +#[allow(clippy::cast_possible_truncation)] +pub async fn history( + query: &[String], + search_mode: SearchMode, + filter_mode: FilterMode, + style: atuin_client::settings::Style, + db: &mut impl Database, +) -> Result { + let stdout = stdout().into_raw_mode()?; + let stdout = MouseTerminal::from(stdout); + let stdout = AlternateScreen::from(stdout); + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Setup event handlers + let events = Events::new(); + + let mut input = Cursor::from(query.join(" ")); + // Put the cursor at the end of the query by default + input.end(); + let mut app = State { + input, + results: Vec::new(), + results_state: ListState::default(), + context: current_context(), + filter_mode, + }; + + app.query_results(search_mode, db).await?; + + loop { + let history_count = db.history_count().await?; + let initial_input = app.input.as_str().to_owned(); + let initial_filter_mode = app.filter_mode; + + // Handle input + if let Event::Input(input) = events.next()? { + if let Some(output) = app.handle_input(&input) { + return Ok(output.to_owned()); + } + } + + // After we receive input process the whole event channel before query/render. + while let Ok(Event::Input(input)) = events.try_next() { + if let Some(output) = app.handle_input(&input) { + return Ok(output.to_owned()); + } + } + + if initial_input != app.input.as_str() || initial_filter_mode != app.filter_mode { + app.query_results(search_mode, db).await?; + } + + let compact = match style { + atuin_client::settings::Style::Auto => { + terminal.size().map(|size| size.height < 14).unwrap_or(true) + } + atuin_client::settings::Style::Compact => true, + atuin_client::settings::Style::Full => false, + }; + if compact { + terminal.draw(|f| app.draw_compact(f, history_count))?; + } else { + terminal.draw(|f| app.draw(f, history_count))?; + } + } +}