Show preview of selected command (#643)

* Only show help if terminal strictly has more than one line

* There is no border around the input line in compact mode

* Add command preview

* Dynamic preview height

* Avoid extra allocations

Co-authored-by: Conrad Ludgate <oon@conradludgate.com>

* Address clippy error

* Merge normal and compact views code

* Add show_preview setting, disabled by default

* Convert `bool` to `int` with `if` for legibility

---------

Co-authored-by: Conrad Ludgate <oon@conradludgate.com>
This commit is contained in:
Patrick Decat 2023-03-05 19:36:35 +01:00 committed by GitHub
parent 9e05d747ea
commit 24388033a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 139 additions and 117 deletions

View file

@ -112,6 +112,7 @@ pub struct Settings {
pub filter_mode: FilterMode, pub filter_mode: FilterMode,
pub filter_mode_shell_up_key_binding: FilterMode, pub filter_mode_shell_up_key_binding: FilterMode,
pub shell_up_key_binding: bool, pub shell_up_key_binding: bool,
pub show_preview: bool,
pub exit_mode: ExitMode, pub exit_mode: ExitMode,
#[serde(with = "serde_regex", default = "RegexSet::empty")] #[serde(with = "serde_regex", default = "RegexSet::empty")]
pub history_filter: RegexSet, pub history_filter: RegexSet,
@ -296,6 +297,7 @@ impl Settings {
.set_default("filter_mode", "global")? .set_default("filter_mode", "global")?
.set_default("filter_mode_shell_up_key_binding", "global")? .set_default("filter_mode_shell_up_key_binding", "global")?
.set_default("shell_up_key_binding", false)? .set_default("shell_up_key_binding", false)?
.set_default("show_preview", false)?
.set_default("exit_mode", "return-original")? .set_default("exit_mode", "return-original")?
.set_default("session_token", "")? .set_default("session_token", "")?
.set_default("style", "auto")? .set_default("style", "auto")?

View file

@ -175,107 +175,43 @@ impl State {
} }
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_possible_truncation)]
fn draw<T: Backend>(&mut self, f: &mut Frame<'_, T>, results: &[History]) { #[allow(clippy::bool_to_int_with_if)]
let chunks = Layout::default() fn draw<T: Backend>(
.direction(Direction::Vertical) &mut self,
.margin(0) f: &mut Frame<'_, T>,
.constraints([ results: &[History],
Constraint::Length(3), compact: bool,
Constraint::Min(1), show_preview: bool,
Constraint::Length(3), ) {
]) let border_size = if compact { 0 } else { 1 };
.split(f.size()); let preview_width = f.size().width - 2;
let preview_height = if show_preview {
let top_chunks = Layout::default() let longest_command = results
.direction(Direction::Horizontal) .iter()
.constraints([Constraint::Percentage(50); 2]) .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len()));
.split(chunks[0]); longest_command.map_or(0, |v| {
std::cmp::min(
let top_left_chunks = Layout::default() 4,
.direction(Direction::Vertical) (v.command.len() as u16 + preview_width - 1 - border_size)
.constraints([Constraint::Length(1); 3]) / (preview_width - border_size),
.split(top_chunks[0]); )
}) + border_size * 2
let top_right_chunks = Layout::default() } else if compact {
.direction(Direction::Vertical) 0
.constraints([Constraint::Length(1); 3])
.split(top_chunks[1]);
let title = if self.update_needed.is_some() {
let version = self.update_needed.clone().unwrap();
Paragraph::new(Text::from(Span::styled(
format!(" Atuin v{VERSION} - UPDATE AVAILABLE {version}"),
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
)))
} else { } else {
Paragraph::new(Text::from(Span::styled( 1
format!(" Atuin v{VERSION}"),
Style::default().add_modifier(Modifier::BOLD),
)))
}; };
let show_help = !compact || f.size().height > 1;
let help = vec![
Span::raw(" Press "),
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to exit."),
];
let help = Paragraph::new(Text::from(Spans::from(help)));
let stats = Paragraph::new(Text::from(Span::raw(format!(
"history count: {} ",
self.history_count
))));
f.render_widget(title, top_left_chunks[1]);
f.render_widget(help, top_left_chunks[2]);
f.render_widget(stats.alignment(Alignment::Right), top_right_chunks[1]);
let results = HistoryList::new(results).block(
Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded),
);
f.render_stateful_widget(results, chunks[1], &mut self.results_state);
let input = format!(
"[{:^14}] {}",
self.filter_mode.as_str(),
self.input.as_str(),
);
let input = Paragraph::new(input).block(
Block::default()
.borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.title(format!(
"{:─>width$}",
"",
width = chunks[2].width as usize - 2
)),
);
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 + PREFIX_LENGTH + 2,
// 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>, results: &[History]) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(0) .margin(0)
.horizontal_margin(1) .horizontal_margin(1)
.constraints( .constraints(
[ [
Constraint::Length(1), Constraint::Length(if show_help { 1 } else { 0 }),
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(1), Constraint::Length(1 + border_size),
Constraint::Length(preview_height),
] ]
.as_ref(), .as_ref(),
) )
@ -293,48 +229,136 @@ impl State {
) )
.split(chunks[0]); .split(chunks[0]);
let title = Paragraph::new(Text::from(Span::styled( let title = self.build_title();
format!("Atuin v{VERSION}"), f.render_widget(title, header_chunks[0]);
Style::default().fg(Color::DarkGray),
)));
let help = self.build_help();
f.render_widget(help, header_chunks[1]);
let stats = self.build_stats();
f.render_widget(stats, header_chunks[2]);
let results_list = Self::build_results_list(compact, results);
f.render_stateful_widget(results_list, chunks[1], &mut self.results_state);
let input = self.build_input(compact, chunks[2].width.into());
f.render_widget(input, chunks[2]);
let preview = self.build_preview(results, compact, preview_width, chunks[3].width.into());
f.render_widget(preview, chunks[3]);
let extra_width = UnicodeWidthStr::width(self.input.substring());
let cursor_offset = if compact { 0 } else { 1 };
f.set_cursor(
// Put cursor past the end of the input text
chunks[2].x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset,
chunks[2].y + cursor_offset,
);
}
fn build_title(&mut self) -> Paragraph {
let title = if self.update_needed.is_some() {
let version = self.update_needed.clone().unwrap();
Paragraph::new(Text::from(Span::styled(
format!(" Atuin v{VERSION} - UPDATE AVAILABLE {version}"),
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
)))
} else {
Paragraph::new(Text::from(Span::styled(
format!(" Atuin v{VERSION}"),
Style::default().add_modifier(Modifier::BOLD),
)))
};
title
}
#[allow(clippy::unused_self)]
fn build_help(&mut self) -> Paragraph {
let help = Paragraph::new(Text::from(Spans::from(vec![ let help = Paragraph::new(Text::from(Spans::from(vec![
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to exit"), Span::raw(" to exit"),
]))) ])))
.style(Style::default().fg(Color::DarkGray)) .style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center); .alignment(Alignment::Center);
help
}
fn build_stats(&mut self) -> Paragraph {
let stats = Paragraph::new(Text::from(Span::raw(format!( let stats = Paragraph::new(Text::from(Span::raw(format!(
"history count: {}", "history count: {}",
self.history_count, self.history_count,
)))) ))))
.style(Style::default().fg(Color::DarkGray)) .style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Right); .alignment(Alignment::Right);
stats
}
f.render_widget(title, header_chunks[0]); fn build_results_list(compact: bool, results: &[History]) -> HistoryList {
f.render_widget(help, header_chunks[1]); let results_list = if compact {
f.render_widget(stats, header_chunks[2]); HistoryList::new(results)
} else {
let results = HistoryList::new(results); HistoryList::new(results).block(
f.render_stateful_widget(results, chunks[1], &mut self.results_state); Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded),
)
};
results_list
}
fn build_input(&mut self, compact: bool, chunk_width: usize) -> Paragraph {
let input = format!( let input = format!(
"[{:^14}] {}", "[{:^14}] {}",
self.filter_mode.as_str(), self.filter_mode.as_str(),
self.input.as_str(), self.input.as_str(),
); );
let input = Paragraph::new(input); let input = if compact {
f.render_widget(input, chunks[2]); Paragraph::new(input)
} else {
Paragraph::new(input).block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.title(format!("{:─>width$}", "", width = chunk_width - 2)),
)
};
input
}
let extra_width = UnicodeWidthStr::width(self.input.substring()); fn build_preview(
&mut self,
f.set_cursor( results: &[History],
// Put cursor past the end of the input text compact: bool,
chunks[2].x + extra_width as u16 + PREFIX_LENGTH + 1, preview_width: u16,
// Move one line down, from the border to the input line chunk_width: usize,
chunks[2].y + 1, ) -> Paragraph {
); let selected = self.results_state.selected();
let command = if results.is_empty() {
String::new()
} else {
use itertools::Itertools as _;
let s = &results[selected].command;
s.char_indices()
.step_by(preview_width.into())
.map(|(i, _)| i)
.chain(Some(s.len()))
.tuple_windows()
.map(|(a, b)| &s[a..b])
.join("\n")
};
let preview = if compact {
Paragraph::new(command).style(Style::default().fg(Color::DarkGray))
} else {
Paragraph::new(command).block(
Block::default()
.borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.title(format!("{:─>width$}", "", width = chunk_width - 2)),
)
};
preview
} }
} }
@ -420,11 +444,7 @@ pub async fn history(
atuin_client::settings::Style::Compact => true, atuin_client::settings::Style::Compact => true,
atuin_client::settings::Style::Full => false, atuin_client::settings::Style::Full => false,
}; };
if compact { terminal.draw(|f| app.draw(f, &results, compact, settings.show_preview))?;
terminal.draw(|f| app.draw_compact(f, &results))?;
} else {
terminal.draw(|f| app.draw(f, &results))?;
}
let initial_input = app.input.as_str().to_owned(); let initial_input = app.input.as_str().to_owned();
let initial_filter_mode = app.filter_mode; let initial_filter_mode = app.filter_mode;