1
1
mirror of https://github.com/wez/wezterm.git synced 2024-12-23 21:32:13 +03:00

Add scrollback search function

For the moment this is basic case sensitive substring matching,
but it is extensible to case insentive and regex matching.

refs: https://github.com/wez/wezterm/issues/91
refs: https://github.com/wez/wezterm/issues/106
This commit is contained in:
Wez Furlong 2020-05-27 18:19:22 -07:00
parent 81ededa9ac
commit c1c6ef6ddb
7 changed files with 460 additions and 12 deletions

View File

@ -20,6 +20,9 @@ brief notes about them may accumulate here.
`action=wezterm.action{ActivateTab=i-1}` to pass the integer argument.
* Windows: now also available with a setup.exe installer
* Added `ClearScrollback` key assignment to clear the scrollback. This is bound to CMD-K and CTRL-SHIFT-K by default.
* Added `Search` key assignment to search the scrollback. This is bound to CMD-F and CTRL-SHIFT-F by default.
It activates the search overlay; type (or paste) to enter a search pattern and highlight matches.
Pressing Enter advances to the next match. Escape cancels the search overlay.
### 20200517-122836-92c201c6

View File

@ -13,6 +13,7 @@ mod overlay;
mod quad;
mod renderstate;
mod scrollbar;
mod search;
mod selection;
mod tabbar;
mod termwindow;

346
src/frontend/gui/search.rs Normal file
View File

@ -0,0 +1,346 @@
use crate::frontend::gui::termwindow::TermWindow;
use crate::mux::domain::DomainId;
use crate::mux::renderable::*;
use crate::mux::tab::{Pattern, SearchDirection, SearchResult};
use crate::mux::tab::{Tab, TabId};
use portable_pty::PtySize;
use rangeset::RangeSet;
use std::cell::{RefCell, RefMut};
use std::collections::HashMap;
use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
use term::color::ColorPalette;
use term::{Clipboard, KeyCode, KeyModifiers, Line, MouseEvent, StableRowIndex, TerminalHost};
use termwiz::cell::{Cell, CellAttributes};
use termwiz::color::AnsiColor;
use url::Url;
use window::WindowOps;
pub struct SearchOverlay {
renderer: RefCell<SearchRenderable>,
delegate: Rc<dyn Tab>,
}
struct MatchResult {
range: Range<usize>,
result_index: usize,
}
struct SearchRenderable {
delegate: Rc<dyn Tab>,
/// The text that the user entered
pattern: String,
/// The most recently queried set of matches
results: Vec<SearchResult>,
by_line: HashMap<StableRowIndex, Vec<MatchResult>>,
viewport: Option<StableRowIndex>,
last_bar_pos: Option<StableRowIndex>,
dirty_results: RangeSet<StableRowIndex>,
result_pos: Option<usize>,
/// We use this to cancel ourselves later
window: ::window::Window,
}
impl SearchOverlay {
pub fn with_tab(term_window: &TermWindow, tab: &Rc<dyn Tab>) -> Rc<dyn Tab> {
let viewport = term_window.get_viewport(tab.tab_id());
let window = term_window.window.clone().unwrap();
let mut renderer = SearchRenderable {
delegate: Rc::clone(tab),
pattern: String::new(),
results: vec![],
by_line: HashMap::new(),
dirty_results: RangeSet::default(),
viewport,
last_bar_pos: None,
window,
result_pos: None,
};
let search_row = renderer.compute_search_row();
renderer.dirty_results.add(search_row);
Rc::new(SearchOverlay {
renderer: RefCell::new(renderer),
delegate: Rc::clone(tab),
})
}
pub fn viewport_changed(&self, viewport: Option<StableRowIndex>) {
let mut render = self.renderer.borrow_mut();
if let Some(last) = render.last_bar_pos.take() {
render.dirty_results.add(last);
}
if let Some(pos) = viewport.as_ref() {
render.dirty_results.add(*pos);
}
render.viewport = viewport;
}
}
impl Tab for SearchOverlay {
fn tab_id(&self) -> TabId {
self.delegate.tab_id()
}
fn renderer(&self) -> RefMut<dyn Renderable> {
self.renderer.borrow_mut()
}
fn get_title(&self) -> String {
self.delegate.get_title()
}
fn send_paste(&self, text: &str) -> anyhow::Result<()> {
// paste into the search bar
let mut r = self.renderer.borrow_mut();
r.pattern.push_str(text);
r.update_search();
Ok(())
}
fn reader(&self) -> anyhow::Result<Box<dyn std::io::Read + Send>> {
panic!("do not call reader on SearchOverlay bar tab instance");
}
fn writer(&self) -> RefMut<dyn std::io::Write> {
self.delegate.writer()
}
fn resize(&self, size: PtySize) -> anyhow::Result<()> {
self.delegate.resize(size)
}
fn key_down(&self, key: KeyCode, _mods: KeyModifiers) -> anyhow::Result<()> {
match key {
KeyCode::Escape => self.renderer.borrow().close(),
KeyCode::Enter => {
let mut r = self.renderer.borrow_mut();
if let Some(cur) = r.result_pos.as_ref() {
let prior = if *cur > 0 {
cur - 1
} else {
r.results.len() - 1
};
r.result_pos.replace(prior);
r.set_viewport(Some(r.results[prior].start_y));
}
}
KeyCode::Char(c) => {
let mut r = self.renderer.borrow_mut();
r.pattern.push(c);
r.update_search();
}
KeyCode::Backspace => {
let mut r = self.renderer.borrow_mut();
r.pattern.pop();
r.update_search();
}
_ => {}
}
Ok(())
}
fn mouse_event(&self, event: MouseEvent, host: &mut dyn TerminalHost) -> anyhow::Result<()> {
self.delegate.mouse_event(event, host)
}
fn advance_bytes(&self, buf: &[u8], host: &mut dyn TerminalHost) {
self.delegate.advance_bytes(buf, host)
}
fn is_dead(&self) -> bool {
self.delegate.is_dead()
}
fn palette(&self) -> ColorPalette {
self.delegate.palette()
}
fn domain_id(&self) -> DomainId {
self.delegate.domain_id()
}
fn erase_scrollback(&self) {
self.delegate.erase_scrollback()
}
fn search(
&self,
_row: StableRowIndex,
_direction: SearchDirection,
_pattern: &Pattern,
) -> Vec<SearchResult> {
// You can't search the search bar
vec![]
}
fn is_mouse_grabbed(&self) -> bool {
// Force grabbing off while we're searching
false
}
fn set_clipboard(&self, clipboard: &Arc<dyn Clipboard>) {
self.delegate.set_clipboard(clipboard)
}
fn get_current_working_dir(&self) -> Option<Url> {
self.delegate.get_current_working_dir()
}
}
impl SearchRenderable {
fn compute_search_row(&self) -> StableRowIndex {
let dims = self.delegate.renderer().get_dimensions();
let top = self.viewport.unwrap_or_else(|| dims.physical_top);
let bottom = (top + dims.viewport_rows as StableRowIndex).saturating_sub(1);
bottom
}
fn close(&self) {
TermWindow::schedule_cancel_overlay(self.window.clone(), self.delegate.tab_id());
}
fn set_viewport(&self, row: Option<StableRowIndex>) {
let dims = self.delegate.renderer().get_dimensions();
let tab_id = self.delegate.tab_id();
self.window.apply(move |term_window, _window| {
if let Some(term_window) = term_window.downcast_mut::<TermWindow>() {
term_window.set_viewport(tab_id, row, dims);
}
Ok(())
});
}
fn update_search(&mut self) {
for idx in self.by_line.keys() {
self.dirty_results.add(*idx);
}
if let Some(idx) = self.last_bar_pos.as_ref() {
self.dirty_results.add(*idx);
}
self.results.clear();
self.by_line.clear();
self.result_pos.take();
let bar_pos = self.compute_search_row();
self.dirty_results.add(bar_pos);
if !self.pattern.is_empty() {
self.results = self.delegate.search(
bar_pos + 1,
SearchDirection::Backwards,
&Pattern::String(self.pattern.clone()),
);
self.results.sort();
let dims = self.get_dimensions();
for (result_index, res) in self.results.iter().enumerate() {
for idx in res.start_y..=res.end_y {
let range = if idx == res.start_y && idx == res.end_y {
// Range on same line
res.start_x..res.end_x
} else if idx == res.end_y {
// final line of multi-line
0..res.end_x
} else {
// a middle line
0..dims.cols
};
let result = MatchResult {
range,
result_index,
};
let matches = self.by_line.entry(idx).or_insert_with(|| vec![]);
matches.push(result);
self.dirty_results.add(idx);
}
}
}
if let Some(last) = self.results.last() {
self.result_pos.replace(self.results.len() - 1);
self.set_viewport(Some(last.start_y));
} else {
self.set_viewport(None);
}
}
}
impl Renderable for SearchRenderable {
fn get_cursor_position(&self) -> StableCursorPosition {
// move to the search box
StableCursorPosition {
x: 8 + self.pattern.len(), // FIXME: ucwidth
y: self.compute_search_row(),
shape: termwiz::surface::CursorShape::SteadyBlock,
}
}
fn get_dirty_lines(&self, lines: Range<StableRowIndex>) -> RangeSet<StableRowIndex> {
let mut dirty = self.delegate.renderer().get_dirty_lines(lines.clone());
dirty.add_set(&self.dirty_results);
dirty.intersection_with_range(lines)
}
fn get_lines(&mut self, lines: Range<StableRowIndex>) -> (StableRowIndex, Vec<Line>) {
let dims = self.get_dimensions();
let (top, mut lines) = self.delegate.renderer().get_lines(lines);
// Process the lines; for the search row we want to render instead
// the search UI.
// For rows with search results, we want to highlight the matching ranges
let search_row = self.compute_search_row();
for (idx, line) in lines.iter_mut().enumerate() {
let stable_idx = idx as StableRowIndex + top;
self.dirty_results.remove(stable_idx);
if stable_idx == search_row {
// Replace with search UI
let rev = CellAttributes::default().set_reverse(true).clone();
line.fill_range(0..dims.cols, &Cell::new(' ', rev.clone()));
line.overlay_text_with_attribute(
0,
&format!(
"Search: {} ({}/{} matches)",
self.pattern,
self.result_pos.map(|x| x + 1).unwrap_or(0),
self.results.len()
),
rev,
);
self.last_bar_pos = Some(search_row);
} else if let Some(matches) = self.by_line.get(&stable_idx) {
for m in matches {
// highlight
for cell in &mut line.cells_mut_for_attr_changes_only()[m.range.clone()] {
if Some(m.result_index) == self.result_pos {
cell.attrs_mut()
.set_background(AnsiColor::Yellow)
.set_foreground(AnsiColor::Black)
.set_reverse(false);
} else {
cell.attrs_mut()
.set_background(AnsiColor::Fuschia)
.set_foreground(AnsiColor::Black)
.set_reverse(false);
}
}
}
}
}
(top, lines)
}
fn get_dimensions(&self) -> RenderableDimensions {
self.delegate.renderer().get_dimensions()
}
}

View File

@ -10,6 +10,7 @@ use crate::frontend::activity::Activity;
use crate::frontend::front_end;
use crate::frontend::gui::overlay::{launcher, start_overlay, tab_navigator};
use crate::frontend::gui::scrollbar::*;
use crate::frontend::gui::search::*;
use crate::frontend::gui::selection::*;
use crate::frontend::gui::tabbar::{TabBarItem, TabBarState};
use crate::keyassignment::{
@ -298,8 +299,7 @@ impl WindowCallbacks for TermWindow {
WMEK::VertWheel(amount) if !tab.is_mouse_grabbed() => {
// adjust viewport
let render = tab.renderer();
let dims = render.get_dimensions();
let dims = tab.renderer().get_dimensions();
let position = self
.get_viewport(tab.tab_id())
.unwrap_or(dims.physical_top)
@ -333,6 +333,7 @@ impl WindowCallbacks for TermWindow {
self.terminal_size,
&self.dimensions,
);
drop(render);
self.set_viewport(tab.tab_id(), Some(row), dims);
context.invalidate();
return;
@ -1144,6 +1145,7 @@ impl TermWindow {
.get_viewport(tab.tab_id())
.unwrap_or(dims.physical_top)
.saturating_add(amount * dims.viewport_rows as isize);
drop(render);
self.set_viewport(tab.tab_id(), Some(position), dims);
if let Some(win) = self.window.as_ref() {
win.invalidate();
@ -1474,6 +1476,10 @@ impl TermWindow {
let window = self.window.as_ref().unwrap();
window.invalidate();
}
Search => {
let search = SearchOverlay::with_tab(self, tab);
self.assign_overlay(tab.tab_id(), search);
}
};
Ok(())
}
@ -2642,11 +2648,11 @@ impl TermWindow {
RefMut::map(self.tab_state(tab_id), |state| &mut state.selection)
}
fn get_viewport(&self, tab_id: TabId) -> Option<StableRowIndex> {
pub fn get_viewport(&self, tab_id: TabId) -> Option<StableRowIndex> {
self.tab_state(tab_id).viewport
}
fn set_viewport(
pub fn set_viewport(
&mut self,
tab_id: TabId,
position: Option<StableRowIndex>,
@ -2663,7 +2669,17 @@ impl TermWindow {
}
None => None,
};
self.tab_state(tab_id).viewport = pos;
let mut state = self.tab_state(tab_id);
state.viewport = pos;
// This is a bit gross. If we add other overlays that need this information,
// this should get extracted out into a trait
if let Some(overlay) = state.overlay.as_ref() {
if let Some(search_overlay) = overlay.downcast_ref::<SearchOverlay>() {
search_overlay.viewport_changed(pos);
}
}
}
fn mouse_event_tab_bar(&mut self, x: usize, event: &MouseEvent, context: &dyn WindowOps) {
@ -2709,13 +2725,16 @@ impl TermWindow {
let dims = render.get_dimensions();
let current_viewport = self.get_viewport(tab.tab_id());
match ScrollHit::test(
let hit_result = ScrollHit::test(
event.coords.y,
&*render,
current_viewport,
self.terminal_size,
&self.dimensions,
) {
);
drop(render);
match hit_result {
ScrollHit::Above => {
// Page up
self.set_viewport(

View File

@ -98,6 +98,7 @@ pub enum KeyAssignment {
SpawnCommandInNewWindow(SpawnCommand),
ShowLauncher,
ClearScrollback,
Search,
SelectTextAtMouseCursor(SelectionMode),
ExtendSelectionToMouseCursor(Option<SelectionMode>),
@ -161,6 +162,8 @@ impl InputMap {
[ctrl_shift, KeyCode::Char('N'), SpawnWindow],
[KeyModifiers::SUPER, KeyCode::Char('k'), ClearScrollback],
[ctrl_shift, KeyCode::Char('K'), ClearScrollback],
[KeyModifiers::SUPER, KeyCode::Char('f'), Search],
[ctrl_shift, KeyCode::Char('F'), Search],
// Font size manipulation
[KeyModifiers::CTRL, KeyCode::Char('-'), DecreaseFontSize],
[KeyModifiers::CTRL, KeyCode::Char('0'), ResetFontSize],

View File

@ -1,12 +1,13 @@
use crate::mux::domain::DomainId;
use crate::mux::renderable::Renderable;
use crate::mux::tab::{alloc_tab_id, Tab, TabId};
use crate::mux::tab::{Pattern, SearchDirection, SearchResult};
use anyhow::Error;
use portable_pty::{Child, MasterPty, PtySize};
use std::cell::{RefCell, RefMut};
use std::sync::Arc;
use term::color::ColorPalette;
use term::{Clipboard, KeyCode, KeyModifiers, MouseEvent, Terminal, TerminalHost};
use term::{Clipboard, KeyCode, KeyModifiers, MouseEvent, StableRowIndex, Terminal, TerminalHost};
use url::Url;
pub struct LocalTab {
@ -102,6 +103,80 @@ impl Tab for LocalTab {
fn get_current_working_dir(&self) -> Option<Url> {
self.terminal.borrow().get_current_dir().cloned()
}
fn search(
&self,
_row: StableRowIndex,
_direction: SearchDirection,
pattern: &Pattern,
) -> Vec<SearchResult> {
let term = self.terminal.borrow();
let screen = term.screen();
let mut results = vec![];
let mut haystack = String::new();
let mut byte_pos_to_stable_idx = vec![];
fn haystack_idx_to_coord(
idx: usize,
byte_pos_to_stable_idx: &[(usize, StableRowIndex)],
) -> (usize, StableRowIndex) {
for (start, row) in byte_pos_to_stable_idx.iter().rev() {
if idx >= *start {
return (idx - *start, *row);
}
}
unreachable!();
}
fn collect_matches(
results: &mut Vec<SearchResult>,
pattern: &Pattern,
haystack: &str,
byte_pos_to_stable_idx: &[(usize, StableRowIndex)],
) {
if haystack.is_empty() {
return;
}
match pattern {
Pattern::String(s) => {
for (idx, s) in haystack.match_indices(s) {
let (start_x, start_y) = haystack_idx_to_coord(idx, byte_pos_to_stable_idx);
let (end_x, end_y) =
haystack_idx_to_coord(idx + s.len(), byte_pos_to_stable_idx);
results.push(SearchResult {
start_x,
start_y,
end_x,
end_y,
});
}
} /*
Pattern::Regex(r) => {
// TODO
}
*/
}
}
for (idx, line) in screen.lines.iter().enumerate() {
byte_pos_to_stable_idx.push((haystack.len(), screen.phys_to_stable_row_index(idx)));
let mut wrapped = false;
for (_, cell) in line.visible_cells() {
haystack.push_str(cell.str());
wrapped = cell.attrs().wrapped();
}
if !wrapped {
collect_matches(&mut results, pattern, &haystack, &byte_pos_to_stable_idx);
haystack.clear();
byte_pos_to_stable_idx.clear();
}
}
collect_matches(&mut results, pattern, &haystack, &byte_pos_to_stable_idx);
results
}
}
impl LocalTab {

View File

@ -46,20 +46,21 @@ fn schedule_next_paste(paste: &Arc<Mutex<Paste>>) {
pub enum Pattern {
String(String),
Regex(regex::Regex),
// Regex(regex::Regex),
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum SearchDirection {
Backwards,
Forwards,
// Forwards,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)]
pub struct SearchResult {
pub start_x: usize,
pub start_y: StableRowIndex,
pub end_x: usize,
pub end_y: StableRowIndex,
pub start_x: usize,
pub end_x: usize,
}
pub trait Tab: Downcast {