zellij/zellij-server/src/panes/search.rs
msirringhaus bae1548495
feat(terminal): search panes (#1521)
* WIP: First draft of searching in panes.

* Add ability to highlight search-results in viewport and move forwards/backwards

* Clear search results when leaving search

* Search newly scrolled in lines and have live-search when entering search-term

* search_forward/backward() now doesn't get the needle again, since we already know it

* Use red and yellow from theme. No idea if we should introduce new 'search'-colors

* Implement moving the viewport for searches outside the current one.

* Implement hacky case-insensitivity (ASCII only at the moment)

* Implement wrap-search and prepare infrastructure for whole-word search

* Add a bunch of tests and an embarrasing amount of bugfixes

* Remember search selection when toggling case-sensitivity (if possible)

* New tab integration tests and make search work with floating panes

* Make highlights work with resize (not keeping the active-selection in most cases)

* Switch the search-algo a bit in order to make multi-line search work

* Don't forget active selection when nothing more is found, reflow found selections and scroll correctly

* Make all search-related function calls in plugin-pane No-ops

* Activate whole word search (ASCII only)

* Run cargo fmt

* Make clippy happy

* Remove unneeded transferred_rows_count

* Remove boilerplate and use macro instead

* Add explanatory comments

* Move search-related functions into SearchResults impl and try to remove duplicate code

* Move clearing of search-results upon mode-switch to appropriate place

* Jump to the first occurence while typing (EnterSearch), if none is found in the current viewport

* Always show needle and also show search modifiers in pane title

* Integration tests now use correct InputMode, so we can test the pane title when doing searches

* Move no-op implementation of search-functions from plugin-pane to pane-trait

* Move SearchResult to its own file

* Try to clean up search_row() a bit

* Make clippy happy

* fix: various typos (#1553)

Because they were wrong.

* flake.lock: Update (#1554)

Flake lock file updates:

• Updated input 'crate2nix':
    'github:kolloch/crate2nix/805cdaf084c859c2ea0c084b74f4527b0483f6aa' (2022-06-17)
  → 'github:kolloch/crate2nix/91f333aca414ee346bc5bdea76fe9938f73a15f9' (2022-07-01)
• Updated input 'flake-utils':
    'github:numtide/flake-utils/1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1' (2022-05-30)
  → 'github:numtide/flake-utils/bee6a7250dd1b01844a2de7e02e4df7d8a0a206c' (2022-06-24)
• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/3d7435c638baffaa826b85459df0fff47f12317d' (2022-06-16)
  → 'github:nixos/nixpkgs/0ea7a8f1b939d74e5df8af9a8f7342097cdf69eb' (2022-07-02)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/da04f39d50ad2844e97a44015048c2510ca06c2f' (2022-06-18)
  → 'github:oxalica/rust-overlay/bbba5e73a21c8c67d5fe1d4d8b3fde60ab6946cd' (2022-07-03)

* fix: fallback to default values when terminal rows/cols are 0 (#1552)

* fix: fallback to default values when terminal rows/cols = 0

* increase retry_pause for failing test

* e2e: load fixtures with cat

* use variable for fixture path

* docs(changelog): fix 0 rows or cols crash

* fix(ci): clippy (#1559)

Install `cargo-make` explicitly in the workflow,
even tough it should be cached from the previous steps.

There are some corner cases in which gh messes the caching up
and can't access it.

* add(nix): add `compact-bar` to the flake outputs (#1560)

The compact bar wasn't an output yet.

* refactor(crates): move shared contents from zellij tile to zellij utils (#1541)

* zellij-tile: Move `data` to zellij-utils

The rationale behind this is that all components of zellij access the
data structures defined in this module, as they define some of the most
basic types in the application. However, so far zellij-tile is treated
like a separate crate from the rest of the program in that it is the
only one that doesn't have access to `zellij-utils`, which contains a
lot of other data structures used throughout zellij.

This poses issues as discussed in
https://github.com/zellij-org/zellij/pull/1242 and is one of the reasons
why the keybindings in the status bar default plugin can't be updated
dynamically. It is also the main reason for why the keybindings are
currently passed to the plugin as strings: The plugins only have access
to `zellij-tile`, but since this is a dependency of `zellij-utils`, it
can't import `zellij-utils` to access the keybindings.
Other weird side-effect are that in some places `server` and `client`
have to access the `zellij-tile` contents "through" `zellij-utils`, as
in `use zellij_utils::zellij_tile::prelude::*`.

By moving these central data structures to one common shared crate
(`zellij-utils`), `zellij-tile` will be enabled to import `zellij-utils`
like `screen` and `client` already do. This will, next to other things,
allow dropping a lot of `std::fmt::Fmt` impls needed to convert core
data structures into strings and as a consequence, a lot of string
parsing in the first place.

* utils: Integrate new `data` module, bump rust ver

Integrates the `data` module that was previously part of `zellij-tile`
to allow sharing the contained data structures between all components of
zellij.

This allows `zellij-tile` to use `utils` as a dependency. However, since
`tile` is build against the wasm target, it cannot include all of
`zellij-utils`, since a lot of dependencies there cannot compile with
`wasm` as target (Examples include: termwiz, log4rs, async-std). Thus we
make all the dependencies that cannot compile against `wasm` optional
and introduce a new feature `full` that will compile the crate with all
dependencies. Along with this, modify `lib.rs` to include most of the
data structures only when compiling against the `full` feature.

This makes the compiles of `zellij-tile` lighter, as it doesn't include
all of `utils`. As a side effect, due to the dependency notation for the
optional dependencies (See
https://doc.rust-lang.org/cargo/reference/features.html#optional-dependencies),
we bump the rust toolchain version to 1.60.0.

* tile: Import `data` from zellij-utils

Add `zellij-utils` as a dependency to `zellij-tile` and allow us access
to the `data` module defined there. Update the re-export in the
`prelude` such that from all of the plugins points of view *absolutely
nothing changes*.

* utils: Fix `data` module dependency

Since the `data` module has been migrated from `zellij-tile` to
`zellij-utils`, we import it from `zellij-utils` directly now.
Also unify the imports for the `data` module members: We import all of
the through `data::` now, not through a mixture of `data::` and
`prelude::`.

* client: Fix `data` module dependency

Since the `data` module has been migrated from `zellij-tile` to
`zellij-utils`, we import it from `zellij-utils` directly now.
Also unify the imports for the `data` module members: We import all of
the through `data::` now, not through a mixture of `data::` and
`prelude::`.
Add the "full" feature flag to the `zellij-utils` dependency so it
includes all the components we need.

* server: Fix `data` module dependency

Since the `data` module has been migrated from `zellij-tile` to
`zellij-utils`, we import it from `zellij-utils` directly now.
Also unify the imports for the `data` module members: We import all of
the through `data::` now, not through a mixture of `data::` and
`prelude::`.
Add the "full" feature flag to the `zellij-utils` dependency so it
includes all the components we need.

* tests: Fix `data` module dependency

Since the `data` module has been migrated from `zellij-tile` to
`zellij-utils`, we import it from `zellij-utils` directly now.

* utils: Remove "full" feature

in favor of conditional compilation using `target_family`. Replace the
rust 1.60 method of specifying optional dependencies based on features
and optionally include the dependencies only when not building for wasm
instead. (I.e. `cfg(not(target_family = "wasm"))`)

* cargo: Update module dependencies

since `client`, `server` and `tile` now all depend on `utils` only.

* docs(changelog): crate refactor

* fix: typo (#1567)

* feat(terminal): sixel support (#1557)

* work

* work

* work

* work

* work

* more work

* work

* work

* work

* hack around stdin repeater

* refactor(sixel): rename sixel structs

* feat(sixel): render text above images

* fix(sixel): reap images once they're past the end of the scrollbuffer

* fix(sixel): display images in the middle of the line

* fix(sixel): render crash

* fix(sixel): react to SIGWINCH

* fix(sixel): behave properly in alternate screen mode

* fix(sixel): reap images on terminal reset

* feat(sixel): handle DECSDM

* fix(terminal): properly respond to XTSMGRAPHICS and device attributes with Sixel

* Add comment

* fix(sixel): hack for unknown event overflow until we fix the api

* feat(input): query terminal for all OSC 4 colors and respond to them in a buggy way

* fix(sixel): do not render corrupted image

* feat(input): improve STDIN queries

* fix(client): mistake in clear terminal attributes string

* fix(ansi): report correct number of supported color registers

* fix(sixel): reap images that are completely covered

* style(comment): fix name

* test(sixel): infra

* test(sixel): cases and fixes

* fix(sixel): forward dcs bytes to sixel parser

* refactor(client): ansi stdin parser

* refactor(output): cleanup

* some refactorings

* fix test

* refactor(grid): sixel-grid / sixel-image-store

* refactor(grid): grid debug method

* refactor(grid): move various logic to sixel.rs

* refactor(grid): remove unused methods

* fix(sixel): work with multiple users

* refactor(pane): remove unused z_index

* style(fmt): prepend unused variable

* style(fmt): rustfmt

* fix(tests): various apis

* chore(dependencies): use published version of sixel crates

* style(fmt): rustfmt

* style(fmt): rustfmt

* style(lint): make clippy happy

* style(lint): make clippy happy... again

* style(lint): make clippy happy... again (chapter 2)

* style(comment): remove unused

* fix(colors): export COLORTERM and respond to XTVERSION

* fix(test): color register count

* fix(stdin): adjust STDIN sleep times

* docs(changelog): sixel support

* flake.lock: Update (#1575)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(ci): add new rust toolchain location to action (#1576)

* rust-toolchain: Update (#1578)

Co-authored-by: a-kenji <a-kenji@users.noreply.github.com>

* chore(nix): hide `nix` directory (#1579)

* chore(gblame): add move to git-blame-ignore-revs

This is not relevant for `git blame` messages.

* chore(docs): add more matrix links (#1581)

* fix: add usage comment to fish shell auto-start snippet (#1574) (#1583)

* docs(changelog): add usage comment to fish script

* Refactor match session name (#1582)

* docs(changelog): refactor get session name (#1582)

* fix(cli): let the exit message be different when detaching (#1573)

* Let the exit message be different when detaching

This patch changes the exit message printed to the user, so the user
does not get the impression that they fat-fingered an "exit" instead of
what was intended (a detach).

For this, the InputHandler::exit() function was refactored, to get the
reason as a parameter. As this function is not pub, this is considered
okay.

Signed-off-by: Matthias Beyer <mail@beyermatthias.de>

* Change detach message

This patch changes the detach message to be more in line with the other
messages zellij displays to the user.

Signed-off-by: Matthias Beyer <mail@beyermatthias.de>

* docs(changelog): detach message

* perf(terminal): better responsiveness (#1585)

* performance(pty): only buffer terminal bytes when screen thread is backed up

* style(fmt): rustfmt

* docs(changelog): performance improvement

* style(fmt): rustfmt

* fix(search): adjust foreground color for better readability

* style(fmt): rustfmt

* test(e2e): update snapshots from SCROLL to SEARCH

* Rename search directions to up/down

* Rename search-functions in tests as well

* Move all search-related functions out of grid.rs and into search.rs and reuse as much as possible

* Fix bug where searches that fall on the line-ending are highlighting the whole line

* Silence clippy on what I think is a false-positive

* fix(terminal): persist cursor hide/show through alternate screen (#1586)

* fix(terminal): persist cursor hide/show through alternate screen

* style(fmt): rustfmt

* style(clippy): make clippy happy

* docs(changelog): cursor show/hide alternate screen fix

* fix(editor): handle editor/visual/configured editor with arguments (#1587)

* fix(editor): handle editor/visual/configured editor with arguments

* style(fmt): rustfmt

* docs(changelog): editor with arguments

* fix(ci): quoting issues (#1589)

* fix(mouse): avoid forwarding click events on pane border (#1584)

* if left click is on pane border do not forward to application

* properly handle frames

* fix comment

* fix another comment

* add tests, fix edge case

* docs(changelog): mouse click on pane frame fix

* flake.lock: Update (#1592)

Flake lock file updates:

• Updated input 'crate2nix':
    'github:kolloch/crate2nix/2d20dec4ae330f39b0bebeb8eb4a201b58d2b82c' (2022-07-09)
  → 'github:kolloch/crate2nix/45d97c7ce62c3d53954743057ceb32e483c31acd' (2022-07-12)
• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/b39924fc7764c08ae3b51beef9a3518c414cdb7d' (2022-07-08)
  → 'github:nixos/nixpkgs/4a01ca36d6bfc133bc617e661916a81327c9bbc8' (2022-07-14)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/3dfc78e42a285caaf83633224a42e7fb7dde191b' (2022-07-10)
  → 'github:oxalica/rust-overlay/2cd36d4aef875867ee1d7963541ccb3ae50b358c' (2022-07-16)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(search): clear search when entering input in non-search-mode

* fix(search): handle searching in updating viewport

Co-authored-by: Martin Sirringhaus <>
Co-authored-by: a-kenji <aks.kenji@protonmail.com>
Co-authored-by: Thomas Linford <tlinford@users.noreply.github.com>
Co-authored-by: Aram Drevekenin <aram@poor.dev>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: a-kenji <a-kenji@users.noreply.github.com>
Co-authored-by: Tassilo Horn <tsdh@gnu.org>
Co-authored-by: Jae-Heon Ji <32578710+jaeheonji@users.noreply.github.com>
Co-authored-by: Matthias Beyer <mail@beyermatthias.de>
2022-07-18 10:54:23 +02:00

663 lines
26 KiB
Rust

use crate::panes::selection::Selection;
use crate::panes::terminal_character::TerminalCharacter;
use crate::panes::{Grid, Row};
use std::borrow::Cow;
use std::fmt::Debug;
use zellij_utils::input::actions::SearchDirection;
use zellij_utils::position::Position;
// If char is neither alphanumeric nor an underscore do we consider it a word-boundary
fn is_word_boundary(x: &Option<char>) -> bool {
x.map_or(true, |c| !c.is_ascii_alphanumeric() && c != '_')
}
#[derive(Debug)]
enum SearchSource<'a> {
Main(&'a Row),
Tail(&'a Row),
}
impl<'a> SearchSource<'a> {
/// Returns true, if a new source was found, false otherwise (reached the end of the tail).
/// If we are in the middle of a line, nothing will be changed.
/// Only, when we have to switch to a new line, will the source update itself,
/// as well as the corresponding indices.
fn get_next_source(
&mut self,
ridx: &mut usize,
hidx: &mut usize,
tailit: &mut std::slice::Iter<&'a Row>,
start: &Option<Position>,
) -> bool {
match self {
SearchSource::Main(row) => {
// If we are at the end of the main row, we need to start looking into the tail
if hidx >= &mut row.columns.len() {
let curr_tail = tailit.next();
// If we are at the end and found a partial hit, we have to extend the search into the next line
if let Some(curr_tail) = start.and(curr_tail) {
*ridx += 1; // Go one line down
*hidx = 0; // and start from the beginning of the new line
*self = SearchSource::Tail(curr_tail);
} else {
return false; // We reached the end of the tail
}
}
},
SearchSource::Tail(tail) => {
if hidx >= &mut tail.columns.len() {
// If we are still searching (didn't hit a mismatch yet) and there is still more tail to go
// just continue with the next line
if let Some(curr_tail) = tailit.next() {
*ridx += 1; // Go one line down
*hidx = 0; // and start from the beginning of the new line
*self = SearchSource::Tail(curr_tail);
} else {
return false; // We reached the end of the tail
}
}
},
}
// We have found a new source, or we are in the middle of a line, so no need to change anything
true
}
// Get the char at hidx and, if existing, the following char as well
fn get_next_two_chars(&self, hidx: usize, whole_word_search: bool) -> (char, Option<char>) {
// Get the current haystack character
let haystack_char = match self {
SearchSource::Main(row) => row.columns[hidx].character,
SearchSource::Tail(tail) => tail.columns[hidx].character,
};
// Get the next haystack character (relevant for whole-word search only)
let next_haystack_char = if whole_word_search {
// Everything (incl. end of line) that is not [a-zA-Z0-9_] is considered a word boundary
match self {
SearchSource::Main(row) => row.columns.get(hidx + 1).map(|c| c.character),
SearchSource::Tail(tail) => tail.columns.get(hidx + 1).map(|c| c.character),
}
} else {
None // Doesn't get used, when not doing whole-word search
};
(haystack_char, next_haystack_char)
}
}
#[derive(Debug, Clone, Default)]
pub struct SearchResult {
// What we have already found in the viewport
pub selections: Vec<Selection>,
// Which of the selections we found is currently 'active' (highlighted differently)
pub active: Option<Selection>,
// What we are looking for
pub needle: String,
// Does case matter?
pub case_insensitive: bool,
// Only search whole words, not parts inside a word
pub whole_word_only: bool, // TODO
// Jump from the bottom to the top (or vice versa), if we run out of lines to search
pub wrap_search: bool,
}
impl SearchResult {
/// This is only used for Debug formatting Grid, which itself is only used
/// for tests.
#[allow(clippy::ptr_arg)]
pub(crate) fn mark_search_results_in_row(&self, row: &mut Cow<Row>, ridx: usize) {
for s in &self.selections {
if s.contains_row(ridx) {
let replacement_char = if Some(s) == self.active.as_ref() {
'_'
} else {
'#'
};
let (skip, take) = if ridx as isize == s.start.line() {
let skip = s.start.column();
let take = if s.end.line() == s.start.line() {
s.end.column() - s.start.column()
} else {
// Just mark the rest of the line. This number is certainly too big but the iterator takes care of this
row.columns.len()
};
(skip, take)
} else if ridx as isize == s.end.line() {
// We wrapped a line and the end is in this row, so take from the begging to the end
(0, s.end.column())
} else {
// We are in the middle (start is above and end is below), so mark all
(0, row.columns.len())
};
row.to_mut()
.columns
.iter_mut()
.skip(skip)
.take(take)
.for_each(|x| *x = TerminalCharacter::new(replacement_char));
}
}
}
pub fn has_modifiers_set(&self) -> bool {
self.wrap_search || self.whole_word_only || self.case_insensitive
}
fn check_if_haystack_char_matches_needle(
&self,
nidx: usize,
needle_char: char,
haystack_char: char,
prev_haystack_char: Option<char>,
) -> bool {
let mut chars_match = if self.case_insensitive {
// Case insensitive search
// Currently only ascii, as this whole search-function is very sub-optimal anyways
haystack_char.to_ascii_lowercase() == needle_char.to_ascii_lowercase()
} else {
// Case sensitive search
haystack_char == needle_char
};
// Whole-word search
// It's a match only, if the first haystack char that is _not_ a hit, is a word-boundary
if chars_match
&& self.whole_word_only
&& nidx == 0
&& !is_word_boundary(&prev_haystack_char)
{
// Start of the match is not a word boundary, so this is not a hit
chars_match = false;
}
chars_match
}
/// Search a row and its tail.
/// The tail are all the non-canonical lines below `row`, with `row` not necessarily being canonical itself.
pub(crate) fn search_row(&self, mut ridx: usize, row: &Row, tail: &[&Row]) -> Vec<Selection> {
let mut res = Vec::new();
if self.needle.is_empty() || row.columns.is_empty() {
return res;
}
let mut tailit = tail.iter();
let mut source = SearchSource::Main(row); // Where we currently get the haystack-characters from
let orig_ridx = ridx;
let mut start = None; // If we find a hit, this is where it starts
let mut nidx = 0; // Needle index
let mut hidx = 0; // Haystack index
let mut prev_haystack_char: Option<char> = None;
loop {
// Get the current and next haystack character
let (mut haystack_char, next_haystack_char) =
source.get_next_two_chars(hidx, self.whole_word_only);
// Get current needle character
let needle_char = self.needle.chars().nth(nidx).unwrap(); // Unwrapping is safe here
// Check if needle and haystack match (with search-options)
let chars_match = self.check_if_haystack_char_matches_needle(
nidx,
needle_char,
haystack_char,
prev_haystack_char,
);
if chars_match {
// If the needle is only 1 long, the next `if` could also happen, so we are not merging it into one big if-else
if nidx == 0 {
start = Some(Position::new(ridx as i32, hidx as u16));
}
if nidx == self.needle.len() - 1 {
let mut end_found = true;
// If we search whole-word-only, the next non-needle char needs to be a word-boundary,
// otherwise its not a hit (e.g. some occurrence inside a longer word).
if self.whole_word_only && !is_word_boundary(&next_haystack_char) {
// The end of the match is not a word boundary, so this is not a hit!
// We have to jump back from where we started (plus one char)
nidx = 0;
ridx = start.unwrap().line() as usize;
hidx = start.unwrap().column(); // Will be incremented below
if start.unwrap().line() as usize == orig_ridx {
source = SearchSource::Main(row);
haystack_char = row.columns[hidx].character; // so that prev_char gets set correctly
} else {
// The -1 comes from the main row
let tail_idx = start.unwrap().line() as usize - orig_ridx - 1;
// We have to reset the tail-iterator as well.
tailit = tail[tail_idx..].iter();
let trow = tailit.next().unwrap();
haystack_char = trow.columns[hidx].character; // so that prev_char gets set correctly
source = SearchSource::Tail(trow);
}
start = None;
end_found = false;
}
if end_found {
let mut selection = Selection::default();
selection.start(start.unwrap());
selection.end(Position::new(ridx as i32, (hidx + 1) as u16));
res.push(selection);
nidx = 0;
if matches!(source, SearchSource::Tail(..)) {
// When searching the tail, we can only find one additional selection, so stopping here
break;
}
}
} else {
nidx += 1;
}
} else {
// Chars don't match. Start searching the needle from the beginning
start = None;
nidx = 0;
if matches!(source, SearchSource::Tail(..)) {
// When searching the tail and we find a mismatch, just quit right now
break;
}
}
hidx += 1;
prev_haystack_char = Some(haystack_char);
// We might need to switch to a new line in the tail
if !source.get_next_source(&mut ridx, &mut hidx, &mut tailit, &start) {
break;
}
}
// The tail may have not been wrapped yet (when coming from lines_below),
// so it could be that the end extends across more characters than the row is wide.
// Therefore we need to reflow the end:
for s in res.iter_mut() {
while s.end.column() > row.width() {
s.end.column.0 -= row.width();
s.end.line.0 += 1;
}
}
res
}
pub(crate) fn move_active_selection_to_next(&mut self) {
if let Some(active_idx) = self.active {
self.active = self
.selections
.iter()
.skip_while(|s| *s != &active_idx)
.nth(1)
.cloned();
} else {
self.active = self.selections.first().cloned();
}
}
pub(crate) fn move_active_selection_to_prev(&mut self) {
if let Some(active_idx) = self.active {
self.active = self
.selections
.iter()
.rev()
.skip_while(|s| *s != &active_idx)
.nth(1)
.cloned();
} else {
self.active = self.selections.last().cloned();
}
}
pub(crate) fn unset_active_selection_if_nonexistent(&mut self) {
if let Some(active_idx) = self.active {
if !self.selections.contains(&active_idx) {
self.active = None;
}
}
}
pub(crate) fn move_down(
&mut self,
amount: usize,
viewport: &[Row],
grid_height: usize,
) -> bool {
let mut found_something = false;
self.selections
.iter_mut()
.chain(self.active.iter_mut())
.for_each(|x| x.move_down(amount));
// Throw out all search-results outside of the new viewport
self.adjust_selections_to_moved_viewport(grid_height);
// Search the new line for our needle
if !self.needle.is_empty() {
if let Some(row) = viewport.first() {
let mut tail = Vec::new();
loop {
let tail_idx = 1 + tail.len();
if tail_idx < viewport.len() && !viewport[tail_idx].is_canonical {
tail.push(&viewport[tail_idx]);
} else {
break;
}
}
let selections = self.search_row(0, row, &tail);
for selection in selections.iter().rev() {
self.selections.insert(0, *selection);
found_something = true;
}
}
}
found_something
}
pub(crate) fn move_up(
&mut self,
amount: usize,
viewport: &[Row],
lines_below: &[Row],
grid_height: usize,
) -> bool {
let mut found_something = false;
self.selections
.iter_mut()
.chain(self.active.iter_mut())
.for_each(|x| x.move_up(amount));
// Throw out all search-results outside of the new viewport
self.adjust_selections_to_moved_viewport(grid_height);
// Search the new line for our needle
if !self.needle.is_empty() {
if let Some(row) = viewport.last() {
let tail: Vec<&Row> = lines_below.iter().take_while(|r| !r.is_canonical).collect();
let selections = self.search_row(viewport.len() - 1, row, &tail);
for selection in selections {
// We are only interested in results that start in the this new row
if selection.start.line() as usize == viewport.len() - 1 {
self.selections.push(selection);
found_something = true;
}
}
}
}
found_something
}
fn adjust_selections_to_moved_viewport(&mut self, grid_height: usize) {
// Throw out all search-results outside of the new viewport
self.selections
.retain(|s| (s.start.line() as usize) < grid_height && s.end.line() >= 0);
// If we have thrown out the active element, set it to None
self.unset_active_selection_if_nonexistent();
}
}
impl Grid {
pub fn search_down(&mut self) {
self.search_scrollbuffer(SearchDirection::Down);
}
pub fn search_up(&mut self) {
self.search_scrollbuffer(SearchDirection::Up);
}
pub fn clear_search(&mut self) {
// Clearing all previous highlights
for res in &self.search_results.selections {
self.output_buffer
.update_lines(res.start.line() as usize, res.end.line() as usize);
}
self.search_results = Default::default();
}
pub fn set_search_string(&mut self, needle: &str) {
self.search_results.needle = needle.to_string();
self.search_viewport();
// If the current viewport does not contain any hits,
// we jump around until we find something. Starting
// going backwards.
if self.search_results.selections.is_empty() {
self.search_up();
}
if self.search_results.selections.is_empty() {
self.search_down();
}
// We still don't want to pre-select anything at this stage
self.search_results.active = None;
self.is_scrolled = true;
}
pub fn search_viewport(&mut self) {
for ridx in 0..self.viewport.len() {
let row = &self.viewport[ridx];
let mut tail = Vec::new();
loop {
let tail_idx = ridx + tail.len() + 1;
if tail_idx < self.viewport.len() && !self.viewport[tail_idx].is_canonical {
tail.push(&self.viewport[tail_idx]);
} else {
break;
}
}
let selections = self.search_results.search_row(ridx, row, &tail);
for sel in &selections {
// Cast works because we can' be negative here
self.output_buffer
.update_lines(sel.start.line() as usize, sel.end.line() as usize);
}
for selection in selections {
self.search_results.selections.push(selection);
}
}
}
pub fn toggle_search_case_sensitivity(&mut self) {
self.search_results.case_insensitive = !self.search_results.case_insensitive;
for line in self.search_results.selections.drain(..) {
self.output_buffer
.update_lines(line.start.line() as usize, line.end.line() as usize);
}
self.search_viewport();
// Maybe the selection we had is now gone
self.search_results.unset_active_selection_if_nonexistent();
}
pub fn toggle_search_wrap(&mut self) {
self.search_results.wrap_search = !self.search_results.wrap_search;
}
pub fn toggle_search_whole_words(&mut self) {
self.search_results.whole_word_only = !self.search_results.whole_word_only;
for line in self.search_results.selections.drain(..) {
self.output_buffer
.update_lines(line.start.line() as usize, line.end.line() as usize);
}
self.search_results.active = None;
self.search_viewport();
// Maybe the selection we had is now gone
self.search_results.unset_active_selection_if_nonexistent();
}
fn search_scrollbuffer(&mut self, dir: SearchDirection) {
let first_sel = self.search_results.selections.first();
let last_sel = self.search_results.selections.last();
let search_viewport_for_the_first_time =
self.search_results.active.is_none() && !self.search_results.selections.is_empty();
// We are not at the end yet, so we can iterate to the next search-result within the current viewport
let search_viewport_again = !self.search_results.selections.is_empty()
&& self.search_results.active.is_some()
&& match dir {
SearchDirection::Up => self.search_results.active.as_ref() != first_sel,
SearchDirection::Down => self.search_results.active.as_ref() != last_sel,
};
if search_viewport_for_the_first_time || search_viewport_again {
// We can stay in the viewport and just move the active selection
self.search_viewport_again(search_viewport_for_the_first_time, dir);
} else {
// Need to move the viewport
let found_something = self.search_viewport_move(dir);
// We haven't found anything, but we are allowed to wrap around
if !found_something && self.search_results.wrap_search {
self.search_viewport_wrap(dir);
}
}
}
fn search_viewport_again(
&mut self,
search_viewport_for_the_first_time: bool,
dir: SearchDirection,
) {
let new_active = match dir {
SearchDirection::Up => self.search_results.selections.last().cloned().unwrap(),
SearchDirection::Down => self.search_results.selections.first().cloned().unwrap(),
};
// We can stay in the viewport and just move the active selection
let active_idx = self.search_results.active.get_or_insert(new_active);
self.output_buffer.update_lines(
active_idx.start.line() as usize,
active_idx.end.line() as usize,
);
if !search_viewport_for_the_first_time {
match dir {
SearchDirection::Up => self.search_results.move_active_selection_to_prev(),
SearchDirection::Down => self.search_results.move_active_selection_to_next(),
};
if let Some(new_active) = self.search_results.active {
self.output_buffer.update_lines(
new_active.start.line() as usize,
new_active.end.line() as usize,
);
}
}
}
fn search_reached_opposite_end(&mut self, dir: SearchDirection) -> bool {
match dir {
SearchDirection::Up => self.lines_above.is_empty(),
SearchDirection::Down => self.lines_below.is_empty(),
}
}
fn search_viewport_move(&mut self, dir: SearchDirection) -> bool {
// We need to move the viewport
let mut rows = 0;
let mut found_something = false;
// We might loose the current selection, if we can't find anything
let current_active_selection = self.search_results.active;
while !found_something && !self.search_reached_opposite_end(dir) {
rows += 1;
found_something = match dir {
SearchDirection::Up => self.scroll_up_one_line(),
SearchDirection::Down => self.scroll_down_one_line(),
};
}
if found_something {
self.search_adjust_to_new_selection(dir);
} else {
// We didn't find something, so we scroll back to the start
for _ in 0..rows {
match dir {
SearchDirection::Up => self.scroll_down_one_line(),
SearchDirection::Down => self.scroll_up_one_line(),
};
}
self.search_results.active = current_active_selection;
}
found_something
}
fn search_adjust_to_new_selection(&mut self, dir: SearchDirection) {
match dir {
SearchDirection::Up => {
self.search_results.move_active_selection_to_prev();
},
SearchDirection::Down => {
// We may need to scroll a bit further, because we are at the beginning of the
// search result, but the end might be invisible
if let Some(last) = self.search_results.selections.last() {
let distance = (last.end.line() - last.start.line()) as usize;
if distance < self.height {
for _ in 0..distance {
self.scroll_down_one_line();
}
}
}
self.search_results.move_active_selection_to_next();
},
}
self.output_buffer.update_all_lines();
}
fn search_viewport_wrap(&mut self, dir: SearchDirection) {
// We might loose the current selection, if we can't find anything
let current_active_selection = self.search_results.active;
// UP
// Go to the opposite end (bottom when searching up and top when searching down)
let mut rows = self.move_viewport_to_opposite_end(dir);
// We are at the bottom or top. Maybe we found already something there
// If not, scroll back again, until we find something
let mut found_something = match dir {
SearchDirection::Up => self.search_results.selections.last().is_some(),
SearchDirection::Down => self.search_results.selections.first().is_some(),
};
// We didn't find anything at the opposing end of the scrollbuffer, so we scroll back until we find something
if !found_something {
while rows >= 0 && !found_something {
rows -= 1;
found_something = match dir {
SearchDirection::Up => self.scroll_up_one_line(),
SearchDirection::Down => self.scroll_down_one_line(),
};
}
}
if found_something {
self.search_results.active = match dir {
SearchDirection::Up => self.search_results.selections.last().cloned(),
SearchDirection::Down => {
// We need to scroll until the found item is at the top
if let Some(first) = self.search_results.selections.first() {
for _ in 0..first.start.line() {
self.scroll_down_one_line();
}
}
self.search_results.selections.first().cloned()
},
};
self.output_buffer.update_all_lines();
} else {
// We didn't find anything, so we reset the old active selection
self.search_results.active = current_active_selection;
}
}
fn move_viewport_to_opposite_end(&mut self, dir: SearchDirection) -> isize {
let mut rows = 0;
match dir {
SearchDirection::Up => {
// Go to the bottom
while !self.lines_below.is_empty() {
rows += 1;
self.scroll_down_one_line();
}
},
SearchDirection::Down => {
// Go to the top
while !self.lines_above.is_empty() {
rows += 1;
self.scroll_up_one_line();
}
},
}
rows
}
}