From 352008393a0188ba07646cc311f3629090798d9e Mon Sep 17 00:00:00 2001 From: Jun Wu Date: Wed, 21 Jul 2021 00:36:14 -0700 Subject: [PATCH 1/5] pager: add API to configure using &mut Config This can replace `set_` APIs to make the APIs on Pager cleaner. It makes the next change skip adding the `set_` API. --- src/pager.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pager.rs b/src/pager.rs index 7803640..77c8eae 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -241,6 +241,11 @@ impl Pager { self.events.action_sender() } + /// Configure the pager. + pub fn configure(&mut self, mut func: impl FnMut(&mut Config)) { + func(&mut self.config) + } + /// Run Stream Pager. pub fn run(self) -> Result<()> { crate::display::start( From 08d098b01f3deae2ef63b2714ace251ec0cdafdb Mon Sep 17 00:00:00 2001 From: Jun Wu Date: Wed, 21 Jul 2021 03:01:18 -0700 Subject: [PATCH 2/5] screen: scroll_to can now work without centering the line Make it possible to scroll_to a line without centering it. This will be used by the next change. --- src/command.rs | 6 +++--- src/screen.rs | 43 +++++++++++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/command.rs b/src/command.rs index c09d0c8..2f99a93 100644 --- a/src/command.rs +++ b/src/command.rs @@ -7,7 +7,7 @@ use crate::error::Error; use crate::event::EventSender; use crate::file::FileInfo; use crate::prompt::Prompt; -use crate::screen::Screen; +use crate::screen::{Screen, Scroll}; use crate::search::{MatchMotion, Search, SearchKind}; /// Go to a line (Shortcut: ':') @@ -37,7 +37,7 @@ pub(crate) fn goto() -> Prompt { value_percent += 100; } let value = value_percent * (lines - 1) / 100; - screen.scroll_to(value as usize); + screen.scroll_to(Scroll::Center(value as usize)); } Err(e) => { screen.error = Some(e.to_string()); @@ -56,7 +56,7 @@ pub(crate) fn goto() -> Prompt { } else { value - 1 }; - screen.scroll_to(value as usize); + screen.scroll_to(Scroll::Center(value as usize)); } Err(e) => { screen.error = Some(e.to_string()); diff --git a/src/screen.rs b/src/screen.rs index 848c578..365e6a3 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -193,7 +193,7 @@ pub(crate) struct Screen { following_end: bool, /// Scroll to a particular line in the file. - pending_absolute_scroll: Option, + pending_absolute_scroll: Option, /// Scroll relative number of rows. pending_relative_scroll: isize, @@ -208,6 +208,15 @@ pub(crate) struct Screen { repeat_count: Option, } +#[derive(Copy, Clone, Debug)] +pub(crate) enum Scroll { + /// Scroll to the given line and make it the center of the screen. + Center(usize), + + /// Scroll to the given line and make it the top of the screen. + Top(usize), +} + impl Screen { /// Create a screen that displays a file. pub(crate) fn new(file: File, config: Arc) -> Result { @@ -445,13 +454,19 @@ impl Screen { } // Perform pending absolute scroll - if let Some(line) = self.pending_absolute_scroll.take() { - self.top_line = line; - self.top_line_portion = 0; - pending_refresh.add_range(0, file_view_height); - // Scroll up so that the target line is in the center of the - // file view. - self.pending_relative_scroll -= (file_view_height / 2) as isize; + if let Some(scroll) = self.pending_absolute_scroll.take() { + match scroll { + Scroll::Top(line) | Scroll::Center(line) => { + self.top_line = line; + self.top_line_portion = 0; + pending_refresh.add_range(0, file_view_height); + if matches!(scroll, Scroll::Center(_)) { + // Scroll up so that the target line is in the center of the + // file view. + self.pending_relative_scroll -= (file_view_height / 2) as isize; + } + } + } } enum Direction { @@ -1052,8 +1067,8 @@ impl Screen { } /// Scrolls to the given line number. - pub(crate) fn scroll_to(&mut self, line: usize) { - self.pending_absolute_scroll = Some(line); + pub(crate) fn scroll_to(&mut self, scroll: Scroll) { + self.pending_absolute_scroll = Some(scroll); self.pending_relative_scroll = 0; self.following_end = false; } @@ -1164,10 +1179,10 @@ impl Screen { ScrollToTop | ScrollToBottom if self.repeat_count.is_some() => { if let Some(n) = self.repeat_count { // Convert 1-based to 0-based line number. - self.scroll_to(n.max(1) - 1); + self.scroll_to(Scroll::Center(n.max(1) - 1)); } } - ScrollToTop => self.scroll_to(0), + ScrollToTop => self.scroll_to(Scroll::Top(0)), ScrollToBottom => self.following_end = true, ScrollLeftColumns(n) => { let n = self.apply_repeat_count(n); @@ -1345,7 +1360,7 @@ impl Screen { .as_ref() .and_then(|ref search| search.current_match()); if let Some((line_index, _match_index)) = current_match { - self.scroll_to(line_index); + self.scroll_to(Scroll::Center(line_index)); self.refresh_matched_lines(); self.refresh_overlay(); return DisplayAction::Render; @@ -1368,7 +1383,7 @@ impl Screen { let scope = self.rendered.top_line..=self.rendered.bottom_line; search.move_match(motion, scope); if let Some((line_index, _match_index)) = search.current_match() { - self.scroll_to(line_index); + self.scroll_to(Scroll::Center(line_index)); } self.refresh_matched_line(); self.refresh_search_status(); From 07a2d45a178c493dd87ebd7f4a47a8406c6e7d2d Mon Sep 17 00:00:00 2001 From: Jun Wu Date: Wed, 21 Jul 2021 02:47:00 -0700 Subject: [PATCH 3/5] search: show live preview as user types Similar to the hlsearch feature from vim, show search results as user types. This can be turned off via a config. --- src/command.rs | 72 ++++++++++++++++++++++++++++++++++++-------------- src/config.rs | 4 +++ src/display.rs | 3 ++- src/prompt.rs | 37 ++++++++++++++++++++++++-- src/screen.rs | 24 ++++++++++++++++- 5 files changed, 116 insertions(+), 24 deletions(-) diff --git a/src/command.rs b/src/command.rs index 2f99a93..65733f9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -72,27 +72,59 @@ pub(crate) fn goto() -> Prompt { /// Search for text (Shortcuts: '/', '<', '>') /// /// Prompts the user for text to search. -pub(crate) fn search(kind: SearchKind, event_sender: EventSender) -> Prompt { - Prompt::new( - "search", - "Search:", - Box::new( - move |screen: &mut Screen, value: &str| -> Result { - screen.refresh_matched_lines(); - if value.is_empty() { - match kind { - SearchKind::First | SearchKind::FirstAfter(_) => { - screen.move_match(MatchMotion::NextLine) +pub(crate) fn search(kind: SearchKind, event_sender: EventSender, preview: bool) -> Prompt { + let mut prompt = { + let event_sender = event_sender.clone(); + Prompt::new( + "search", + "Search:", + Box::new( + move |screen: &mut Screen, value: &str| -> Result { + screen.refresh_matched_lines(); + if value.is_empty() { + match kind { + SearchKind::First | SearchKind::FirstAfter(_) => { + screen.move_match(MatchMotion::NextLine) + } + SearchKind::FirstBefore(_) => { + screen.move_match(MatchMotion::PreviousLine) + } } - SearchKind::FirstBefore(_) => screen.move_match(MatchMotion::PreviousLine), + } else { + screen.set_search( + Search::new(&screen.file, value, kind, event_sender.clone()).ok(), + ); } - } else { - screen.set_search( - Search::new(&screen.file, value, kind, event_sender.clone()).ok(), - ); + Ok(DisplayAction::Render) + }, + ), + ) + }; + if preview { + let mut orig_top = None; + let mut orig_search = None; + let preview = move |screen: &mut Screen, value: &str| -> Result<(), Error> { + if orig_top.is_none() { + // Remember the original top line position. + orig_top = Some(screen.top_line()); + orig_search = screen.take_search(); + } + screen.refresh_matched_lines(); + if value.is_empty() { + // Cancel a search. Restore to the previous state. + screen.set_search(orig_search.take()); + if let Some(top) = orig_top { + // Restore the original top line position. + screen.scroll_to(Scroll::Top(top)); } - Ok(DisplayAction::Render) - }, - ), - ) + orig_top = None; + } else { + screen + .set_search(Search::new(&screen.file, value, kind, event_sender.clone()).ok()); + } + Ok(()) + }; + prompt = prompt.with_preview(Box::new(preview)); + } + prompt } diff --git a/src/config.rs b/src/config.rs index 25da246..adcb291 100644 --- a/src/config.rs +++ b/src/config.rs @@ -175,6 +175,9 @@ pub struct Config { /// Specify the name of the default key map. pub keymap: KeymapConfig, + + /// Specify whether search is highlighted when typing. + pub highlight_search: bool, } impl Default for Config { @@ -187,6 +190,7 @@ impl Default for Config { show_ruler: true, wrapping_mode: Default::default(), keymap: Default::default(), + highlight_search: true, } } } diff --git a/src/display.rs b/src/display.rs index 750f846..e5eb255 100644 --- a/src/display.rs +++ b/src/display.rs @@ -292,11 +292,12 @@ pub(crate) fn start( } Some(Event::Input(InputEvent::Paste(ref text))) => { let width = screen.width(); + let preview = screen.config().highlight_search; screen .prompt() .get_or_insert_with(|| { // Assume the user wanted to search for what they're pasting. - command::search(SearchKind::First, event_sender.clone()) + command::search(SearchKind::First, event_sender.clone(), preview) }) .paste(text, width) } diff --git a/src/prompt.rs b/src/prompt.rs index fb54194..398365c 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -2,6 +2,8 @@ use std::char; use std::fmt::Write; +use std::sync::Arc; +use std::sync::Mutex; use termwiz::cell::{AttributeChange, CellAttributes}; use termwiz::color::{AnsiColor, ColorAttribute}; @@ -17,6 +19,7 @@ use crate::screen::Screen; use crate::util; type PromptRunFn = dyn FnMut(&mut Screen, &str) -> Result; +type PromptPreviewFn = dyn FnMut(&mut Screen, &str) -> Result<(), Error>; /// A prompt for input from the user. pub(crate) struct Prompt { @@ -28,6 +31,10 @@ pub(crate) struct Prompt { /// The closure to run when the user presses Return. Will only be called once. run: Option>, + + /// The closure to run when the user changes input. + /// Also called with empty text when the prompt is canceled. + preview: Option>>>, } pub(crate) struct PromptState { @@ -330,9 +337,15 @@ impl Prompt { prompt: prompt.to_string(), history: PromptHistory::open(ident), run: Some(run), + preview: None, } } + pub(crate) fn with_preview(mut self, preview: Box) -> Self { + self.preview = Some(Arc::new(Mutex::new(preview))); + self + } + fn state(&self) -> &PromptState { self.history.state() } @@ -365,6 +378,11 @@ impl Prompt { self.state_mut().render(changes, offset, width); } + /// Current text input by the user. + fn value(&self) -> String { + self.state().value[..].iter().collect() + } + /// Dispatch a key press to the prompt. pub(crate) fn dispatch_key(&mut self, key: KeyEvent, width: usize) -> DisplayAction { use termwiz::input::{KeyCode::*, Modifiers}; @@ -372,12 +390,12 @@ impl Prompt { const NONE: Modifiers = Modifiers::NONE; const ALT: Modifiers = Modifiers::ALT; let value_width = width - self.prompt.width() - 4; + let value: String = self.value(); let action = match (key.modifiers, key.key) { (NONE, Enter) | (CTRL, Char('J')) | (CTRL, Char('M')) => { // Finish. let _ = self.history.save(); let mut run = self.run.take(); - let value: String = self.state().value[..].iter().collect(); return DisplayAction::Run(Box::new(move |screen: &mut Screen| { screen.clear_prompt(); if let Some(ref mut run) = run { @@ -389,7 +407,12 @@ impl Prompt { } (NONE, Escape) | (CTRL, Char('C')) => { // Cancel. - return DisplayAction::Run(Box::new(|screen: &mut Screen| { + let preview = self.preview.clone(); + return DisplayAction::Run(Box::new(move |screen: &mut Screen| { + if let Some(preview) = &preview { + let mut preview = preview.lock().unwrap(); + preview(screen, "")?; + } screen.clear_prompt(); Ok(DisplayAction::Render) })); @@ -413,6 +436,16 @@ impl Prompt { _ => return DisplayAction::None, }; self.state_mut().clamp_offset(value_width); + let new_value: String = self.value(); + if let Some(preview) = self.preview.clone() { + if value != new_value && !matches!(action, DisplayAction::Run(_)) { + return DisplayAction::Run(Box::new(move |screen: &mut Screen| { + let mut preview = preview.lock().unwrap(); + preview(screen, &new_value)?; + Ok(DisplayAction::Render) + })); + } + } action } diff --git a/src/screen.rs b/src/screen.rs index 365e6a3..b21135f 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -258,6 +258,16 @@ impl Screen { } } + /// Get the config. + pub(crate) fn config(&self) -> &Config { + &self.config + } + + /// Get the rendered top line position.j + pub(crate) fn top_line(&self) -> usize { + self.top_line + } + /// Get the screen width pub(crate) fn width(&self) -> usize { self.width @@ -1210,18 +1220,24 @@ impl Screen { } PromptGoToLine => self.prompt = Some(command::goto()), PromptSearchFromStart => { - self.prompt = Some(command::search(SearchKind::First, event_sender.clone())) + self.prompt = Some(command::search( + SearchKind::First, + event_sender.clone(), + self.config.highlight_search, + )) } PromptSearchForwards => { self.prompt = Some(command::search( SearchKind::FirstAfter(self.rendered.top_line), event_sender.clone(), + self.config.highlight_search, )) } PromptSearchBackwards => { self.prompt = Some(command::search( SearchKind::FirstBefore(self.rendered.bottom_line), event_sender.clone(), + self.config.highlight_search, )) } PreviousMatch => self.create_or_move_match(MatchMotion::Previous, event_sender.clone()), @@ -1296,6 +1312,12 @@ impl Screen { self.search_line_cache.clear(); } + /// Take the search. Useful for backing up the current search + /// and restore later. + pub(crate) fn take_search(&mut self) -> Option { + self.search.take() + } + /// Set the error file for this file. pub(crate) fn set_error_file(&mut self, error_file: Option) { self.error_file = error_file; From f93ebf357f7cca29185b6801dd3314d5c01b2104 Mon Sep 17 00:00:00 2001 From: Jun Wu Date: Wed, 21 Jul 2021 03:16:05 -0700 Subject: [PATCH 4/5] search: do not center matched line if it's in view If the matched line is already in the current view, do not center it. This reduces scrolling in search live preview. It seems to be a general good default so I didn't add a config option. --- src/screen.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/screen.rs b/src/screen.rs index b21135f..2c267af 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -27,6 +27,7 @@ //! ``` use std::cmp::{max, min}; +use std::ops::RangeInclusive; use std::sync::Arc; use termwiz::cell::{CellAttributes, Intensity}; @@ -1382,7 +1383,10 @@ impl Screen { .as_ref() .and_then(|ref search| search.current_match()); if let Some((line_index, _match_index)) = current_match { - self.scroll_to(Scroll::Center(line_index)); + let range = self.visible_line_range(); + if !range.contains(&line_index) { + self.scroll_to(Scroll::Center(line_index)); + } self.refresh_matched_lines(); self.refresh_overlay(); return DisplayAction::Render; @@ -1398,14 +1402,21 @@ impl Screen { DisplayAction::Render } + /// Range of the visible lines. + pub(crate) fn visible_line_range(&self) -> RangeInclusive { + self.rendered.top_line..=self.rendered.bottom_line + } + /// Move the currently selected match to a new match. pub(crate) fn move_match(&mut self, motion: MatchMotion) { self.refresh_matched_line(); + let scope = self.visible_line_range(); if let Some(ref mut search) = self.search { - let scope = self.rendered.top_line..=self.rendered.bottom_line; - search.move_match(motion, scope); + search.move_match(motion, scope.clone()); if let Some((line_index, _match_index)) = search.current_match() { - self.scroll_to(Scroll::Center(line_index)); + if !scope.contains(&line_index) { + self.scroll_to(Scroll::Center(line_index)); + } } self.refresh_matched_line(); self.refresh_search_status(); From ed69cd60300f2276c8dc8a082045b6290dc22b52 Mon Sep 17 00:00:00 2001 From: Jun Wu Date: Wed, 21 Jul 2021 03:51:38 -0700 Subject: [PATCH 5/5] screen: refresh prompt and search by set_search This resolves some rendering issues where the search and prompt bar does not get re-rendered properly. Repro steps: 1. sp src/screen.rs. 2. Search for 'crate'. 'crate' gets highlighted. 3. Search for 'foo' (no result). Keep the prompt. 4. Ctrl+W. Prompt does not erase the 'foo'. Search status shows 'foo' instead of expecte 'crate'. --- src/screen.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/screen.rs b/src/screen.rs index 2c267af..1c283f9 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -1311,6 +1311,8 @@ impl Screen { pub(crate) fn set_search(&mut self, search: Option) { self.search = search; self.search_line_cache.clear(); + self.refresh_search_status(); + self.refresh_prompt(); } /// Take the search. Useful for backing up the current search