better cursor search (#473)

* improve cursor code

* proper unicode support

* refactor and test

* fmt

* clippy

* move methods to state

* refactor search modules
This commit is contained in:
Conrad Ludgate 2022-09-11 16:24:16 +01:00 committed by GitHub
parent 8478a598db
commit 702a644f68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 671 additions and 579 deletions

View file

@ -461,7 +461,7 @@ mod test {
Some("beep boop".to_string()), Some("beep boop".to_string()),
Some("booop".to_string()), Some("booop".to_string()),
); );
return db.save(&history).await; db.save(&history).await
} }
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]

View file

@ -27,16 +27,27 @@ pub enum SearchMode {
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq)]
pub enum FilterMode { pub enum FilterMode {
#[serde(rename = "global")] #[serde(rename = "global")]
Global, Global = 0,
#[serde(rename = "host")] #[serde(rename = "host")]
Host, Host = 1,
#[serde(rename = "session")] #[serde(rename = "session")]
Session, Session = 2,
#[serde(rename = "directory")] #[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 // FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged

View file

@ -10,7 +10,6 @@ use atuin_common::utils::uuid_v4;
#[cfg(feature = "sync")] #[cfg(feature = "sync")]
mod sync; mod sync;
mod event;
mod history; mod history;
mod import; mod import;
mod init; mod init;

View file

@ -1,36 +1,16 @@
use std::{env, io::stdout, ops::Sub, time::Duration};
use chrono::Utc; use chrono::Utc;
use clap::Parser; use clap::Parser;
use eyre::Result; 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::{ use atuin_client::{
database::current_context, database::current_context, database::Database, history::History, settings::Settings,
database::Context,
database::Database,
history::History,
settings::{FilterMode, SearchMode, Settings},
}; };
use super::{ use super::history::ListMode;
event::{Event, Events},
history::ListMode,
};
const VERSION: &str = env!("CARGO_PKG_VERSION"); mod cursor;
mod event;
mod interactive;
#[derive(Parser)] #[derive(Parser)]
pub struct Cmd { pub struct Cmd {
@ -80,7 +60,7 @@ pub struct Cmd {
impl Cmd { impl Cmd {
pub async fn run(self, db: &mut impl Database, settings: &Settings) -> Result<()> { pub async fn run(self, db: &mut impl Database, settings: &Settings) -> Result<()> {
if self.interactive { if self.interactive {
let item = select_history( let item = interactive::history(
&self.query, &self.query,
settings.search_mode, settings.search_mode,
settings.filter_mode, settings.filter_mode,
@ -110,553 +90,6 @@ impl Cmd {
} }
} }
struct State {
input: String,
cursor_index: usize,
filter_mode: FilterMode,
results: Vec<History>,
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<T: tui::backend::Backend>(
&mut self,
f: &mut tui::Frame<T>,
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<ListItem> = 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);
}
}
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)
.await?
}
i => {
db.search(Some(200), search_mode, app.filter_mode, &app.context, i)
.await?
}
};
app.results = results;
if app.results.is_empty() {
app.results_state.select(None);
} else {
app.results_state.select(Some(0));
}
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;
}
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
}
#[allow(clippy::too_many_lines)]
fn key_handler(input: &TermEvent, app: &mut State) -> Option<String> {
match input {
TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(String::from("")),
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()),
);
}
TermEvent::Key(Key::Alt(c)) if ('1'..='9').contains(c) => {
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()),
);
}
TermEvent::Key(Key::Left | Key::Ctrl('h')) => {
if app.cursor_index != 0 {
app.cursor_index -= 1;
}
}
TermEvent::Key(Key::Right | Key::Ctrl('l')) => {
if app.cursor_index < app.input.width() {
app.cursor_index += 1;
}
}
TermEvent::Key(Key::Ctrl('a')) => {
app.cursor_index = 0;
}
TermEvent::Key(Key::Ctrl('e')) => {
app.cursor_index = app.input.chars().count();
}
TermEvent::Key(Key::Char(c)) => {
insert_char_into_input(app, app.cursor_index, *c);
app.cursor_index += 1;
}
TermEvent::Key(Key::Backspace) => {
if app.cursor_index == 0 {
return None;
}
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
{
break;
}
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')) => {
app.input = String::from("");
app.cursor_index = 0;
}
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
}
}
None => 0,
};
app.results_state.select(Some(i));
}
_ => {}
};
None
}
#[allow(clippy::cast_possible_truncation)]
fn draw<T: Backend>(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.clone())
.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
.chars()
.take(app.cursor_index)
.collect::<String>()
.as_str(),
);
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<T: Backend>(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.clone())).block(Block::default());
f.render_widget(title, header_chunks[0]);
f.render_widget(help, header_chunks[1]);
f.render_widget(stats, header_chunks[2]);
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::<String>()
.as_str(),
) + 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<String> {
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 input = query.join(" ");
// Put the cursor at the end of the query by default
let cursor_index = input.chars().count();
let mut app = State {
input,
cursor_index,
results: Vec::new(),
results_state: ListState::default(),
context: current_context(),
filter_mode,
};
query_results(&mut app, search_mode, db).await?;
loop {
let history_count = db.history_count().await?;
let initial_input = app.input.clone();
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);
}
}
// 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);
}
}
if initial_input != app.input || initial_filter_mode != app.filter_mode {
query_results(&mut app, 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| draw_compact(f, history_count, &mut app))?;
} else {
terminal.draw(|f| draw(f, history_count, &mut app))?;
}
}
}
// This is supposed to more-or-less mirror the command line version, so ofc // This is supposed to more-or-less mirror the command line version, so ofc
// it is going to have a lot of args // it is going to have a lot of args
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]

View file

@ -0,0 +1,156 @@
pub struct Cursor {
source: String,
index: usize,
}
impl From<String> 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<char> {
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<char> {
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ö");
}
}

View file

@ -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<History>,
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<T: tui::backend::Backend>(
&mut self,
f: &mut tui::Frame<T>,
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<ListItem> = 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<T: Backend>(&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<T: Backend>(&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<String> {
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))?;
}
}
}