From 24388033a594e664cc1718fda8ba802399b0d038 Mon Sep 17 00:00:00 2001 From: Patrick Decat Date: Sun, 5 Mar 2023 19:36:35 +0100 Subject: [PATCH] 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 * 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 --- atuin-client/src/settings.rs | 2 + src/command/client/search/interactive.rs | 254 ++++++++++++----------- 2 files changed, 139 insertions(+), 117 deletions(-) diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs index 2975edb..95b01c8 100644 --- a/atuin-client/src/settings.rs +++ b/atuin-client/src/settings.rs @@ -112,6 +112,7 @@ pub struct Settings { pub filter_mode: FilterMode, pub filter_mode_shell_up_key_binding: FilterMode, pub shell_up_key_binding: bool, + pub show_preview: bool, pub exit_mode: ExitMode, #[serde(with = "serde_regex", default = "RegexSet::empty")] pub history_filter: RegexSet, @@ -296,6 +297,7 @@ impl Settings { .set_default("filter_mode", "global")? .set_default("filter_mode_shell_up_key_binding", "global")? .set_default("shell_up_key_binding", false)? + .set_default("show_preview", false)? .set_default("exit_mode", "return-original")? .set_default("session_token", "")? .set_default("style", "auto")? diff --git a/src/command/client/search/interactive.rs b/src/command/client/search/interactive.rs index 903c336..9e7d340 100644 --- a/src/command/client/search/interactive.rs +++ b/src/command/client/search/interactive.rs @@ -175,107 +175,43 @@ impl State { } #[allow(clippy::cast_possible_truncation)] - fn draw(&mut self, f: &mut Frame<'_, T>, results: &[History]) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(0) - .constraints([ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(3), - ]) - .split(f.size()); - - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50); 2]) - .split(chunks[0]); - - let top_left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1); 3]) - .split(top_chunks[0]); - - let top_right_chunks = Layout::default() - .direction(Direction::Vertical) - .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), - ))) + #[allow(clippy::bool_to_int_with_if)] + fn draw( + &mut self, + f: &mut Frame<'_, T>, + results: &[History], + compact: bool, + show_preview: bool, + ) { + let border_size = if compact { 0 } else { 1 }; + let preview_width = f.size().width - 2; + let preview_height = if show_preview { + let longest_command = results + .iter() + .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len())); + longest_command.map_or(0, |v| { + std::cmp::min( + 4, + (v.command.len() as u16 + preview_width - 1 - border_size) + / (preview_width - border_size), + ) + }) + border_size * 2 + } else if compact { + 0 } else { - Paragraph::new(Text::from(Span::styled( - format!(" Atuin v{VERSION}"), - Style::default().add_modifier(Modifier::BOLD), - ))) + 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(&mut self, f: &mut Frame<'_, T>, results: &[History]) { + let show_help = !compact || f.size().height > 1; let chunks = Layout::default() .direction(Direction::Vertical) .margin(0) .horizontal_margin(1) .constraints( [ - Constraint::Length(1), + Constraint::Length(if show_help { 1 } else { 0 }), Constraint::Min(1), - Constraint::Length(1), + Constraint::Length(1 + border_size), + Constraint::Length(preview_height), ] .as_ref(), ) @@ -293,48 +229,136 @@ impl State { ) .split(chunks[0]); - let title = Paragraph::new(Text::from(Span::styled( - format!("Atuin v{VERSION}"), - Style::default().fg(Color::DarkGray), - ))); + let title = self.build_title(); + f.render_widget(title, header_chunks[0]); + 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![ Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" to exit"), ]))) .style(Style::default().fg(Color::DarkGray)) .alignment(Alignment::Center); + help + } + fn build_stats(&mut self) -> Paragraph { let stats = Paragraph::new(Text::from(Span::raw(format!( "history count: {}", self.history_count, )))) .style(Style::default().fg(Color::DarkGray)) .alignment(Alignment::Right); + stats + } - f.render_widget(title, header_chunks[0]); - f.render_widget(help, header_chunks[1]); - f.render_widget(stats, header_chunks[2]); - - let results = HistoryList::new(results); - f.render_stateful_widget(results, chunks[1], &mut self.results_state); + fn build_results_list(compact: bool, results: &[History]) -> HistoryList { + let results_list = if compact { + HistoryList::new(results) + } else { + HistoryList::new(results).block( + 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!( "[{:^14}] {}", self.filter_mode.as_str(), self.input.as_str(), ); - let input = Paragraph::new(input); - f.render_widget(input, chunks[2]); + let input = if compact { + 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()); - - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + extra_width as u16 + PREFIX_LENGTH + 1, - // Move one line down, from the border to the input line - chunks[2].y + 1, - ); + fn build_preview( + &mut self, + results: &[History], + compact: bool, + preview_width: u16, + chunk_width: usize, + ) -> 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::Full => false, }; - if compact { - terminal.draw(|f| app.draw_compact(f, &results))?; - } else { - terminal.draw(|f| app.draw(f, &results))?; - } + terminal.draw(|f| app.draw(f, &results, compact, settings.show_preview))?; let initial_input = app.input.as_str().to_owned(); let initial_filter_mode = app.filter_mode;