use escriba_memori::{CaretLine, CaretMove};
use crate::engine::{Direction, SearchMatch, Step, find_all, step, step_inclusive};
use crate::pattern::{CaseMode, PatternError, SearchPattern};
pub const HISTORY_LIMIT: usize = 50;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Prompt {
pub direction: Direction,
line: CaretLine,
pub origin: usize,
preview_skip: usize,
history_index: Option<usize>,
stashed: Option<String>,
}
impl Prompt {
#[must_use]
pub const fn preview_skip(&self) -> usize {
self.preview_skip
}
#[must_use]
pub const fn caret(&self) -> usize {
self.line.caret()
}
#[must_use]
pub fn text(&self) -> &str {
self.line.text()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Preview {
Idle,
Incomplete,
NoMatch,
Landed { step: Step, total: usize },
}
impl Preview {
#[must_use]
pub fn step(&self) -> Option<&Step> {
match self {
Self::Landed { step, .. } => Some(step),
Self::Idle | Self::Incomplete | Self::NoMatch => None,
}
}
#[must_use]
pub const fn total(&self) -> usize {
match self {
Self::Landed { total, .. } => *total,
Self::Idle | Self::Incomplete | Self::NoMatch => 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Accepted {
Committed,
ReusedPrevious,
NothingToRepeat,
Invalid(PatternError),
}
#[derive(Debug, Clone, Default)]
pub struct SearchState {
prompt: Option<Prompt>,
pattern: Option<SearchPattern>,
direction: Direction,
matches: Vec<SearchMatch>,
highlight: bool,
history: Vec<String>,
case: CaseMode,
}
impl SearchState {
#[must_use]
pub fn new(case: CaseMode) -> Self {
Self {
case,
highlight: true,
..Self::default()
}
}
pub fn open(&mut self, direction: Direction, origin: usize) {
self.prompt = Some(Prompt {
direction,
line: CaretLine::default(),
origin,
preview_skip: 0,
history_index: None,
stashed: None,
});
}
#[must_use]
pub const fn prompt(&self) -> Option<&Prompt> {
self.prompt.as_ref()
}
#[must_use]
pub fn committed_pattern(&self) -> Option<&str> {
self.pattern.as_ref().map(SearchPattern::source)
}
#[must_use]
pub fn match_count(&self) -> usize {
self.matches.len()
}
#[must_use]
pub const fn is_prompting(&self) -> bool {
self.prompt.is_some()
}
pub fn push(&mut self, ch: char) {
if let Some(p) = self.prompt.as_mut() {
p.line.insert(ch);
p.history_index = None;
p.preview_skip = 0;
}
}
pub fn move_caret(&mut self, to: CaretMove) {
if let Some(p) = self.prompt.as_mut() {
p.line.move_caret(to);
}
}
pub fn delete_at_caret(&mut self) {
if let Some(p) = self.prompt.as_mut() {
if p.line.caret() < p.line.len_chars() {
p.line.delete();
p.history_index = None;
p.preview_skip = 0;
}
}
}
pub fn delete_word_before_caret(&mut self) {
let Some(p) = self.prompt.as_mut() else {
return;
};
p.line.delete_word_before();
p.history_index = None;
p.preview_skip = 0;
}
pub fn clear_before_caret(&mut self) {
if let Some(p) = self.prompt.as_mut() {
p.line.clear_before_caret();
p.history_index = None;
p.preview_skip = 0;
}
}
pub fn backspace(&mut self) -> bool {
let Some(p) = self.prompt.as_mut() else {
return false;
};
p.history_index = None;
p.preview_skip = 0;
if p.line.caret() == 0 {
if p.line.text().is_empty() {
self.prompt = None;
return true;
}
return false;
}
p.line.backspace();
false
}
pub fn cancel(&mut self) -> Option<usize> {
self.prompt.take().map(|p| p.origin)
}
pub fn accept(&mut self, text: &str) -> Accepted {
let Some(p) = self.prompt.take() else {
return Accepted::NothingToRepeat;
};
let direction = p.direction;
if p.line.text().is_empty() {
return if self.pattern.is_some() {
self.arm(direction, text);
Accepted::ReusedPrevious
} else {
Accepted::NothingToRepeat
};
}
match SearchPattern::compile(p.line.text(), self.case) {
Ok(pattern) => {
self.remember(p.line.text());
self.pattern = Some(pattern);
self.arm(direction, text);
Accepted::Committed
}
Err(e) => {
self.prompt = Some(p);
Accepted::Invalid(e)
}
}
}
fn remember(&mut self, raw: &str) {
self.history.retain(|h| h != raw);
self.history.push(raw.to_string());
if self.history.len() > HISTORY_LIMIT {
self.history.remove(0);
}
}
#[must_use]
pub fn history(&self) -> &[String] {
&self.history
}
pub fn history_step(&mut self, back: bool) {
if self.history.is_empty() {
return;
}
let len = self.history.len();
let Some(p) = self.prompt.as_mut() else {
return;
};
match (p.history_index, back) {
(None, true) => {
p.stashed = Some(p.line.text().to_owned());
p.history_index = Some(len - 1);
p.line.set_text(self.history[len - 1].clone());
}
(Some(i), true) if i > 0 => {
p.history_index = Some(i - 1);
p.line.set_text(self.history[i - 1].clone());
}
(Some(i), false) if i + 1 < len => {
p.history_index = Some(i + 1);
p.line.set_text(self.history[i + 1].clone());
}
(Some(_), false) => {
p.history_index = None;
p.line.set_text(p.stashed.take().unwrap_or_default());
}
_ => {}
}
p.line.move_caret(CaretMove::End);
p.preview_skip = 0;
}
pub fn refresh(&mut self, text: &str) {
self.matches = self
.pattern
.as_ref()
.map_or_else(Vec::new, |p| find_all(text, p));
}
fn arm(&mut self, direction: Direction, text: &str) {
self.direction = direction;
self.highlight = true;
self.refresh(text);
}
#[must_use]
pub fn preview(&self, text: &str) -> Preview {
let Some(p) = self.prompt.as_ref() else {
return Preview::Idle;
};
if p.line.text().is_empty() {
return Preview::Idle;
}
let Ok(pattern) = SearchPattern::compile(p.line.text(), self.case) else {
return Preview::Incomplete;
};
let matches = find_all(text, &pattern);
let total = matches.len();
let Some(mut landed) = step_inclusive(&matches, p.origin, p.direction) else {
return Preview::NoMatch;
};
for _ in 0..p.preview_skip {
let Some(next) = step(&matches, landed.target.start, p.direction) else {
return Preview::NoMatch;
};
landed = next;
}
Preview::Landed {
step: landed,
total,
}
}
pub fn preview_step(&mut self, forward: bool) {
if let Some(p) = self.prompt.as_mut() {
p.preview_skip = if forward {
p.preview_skip.saturating_add(1)
} else {
p.preview_skip.saturating_sub(1)
};
}
}
#[must_use]
pub fn prompt_error(&self) -> Option<PatternError> {
let p = self.prompt.as_ref()?;
if p.line.text().is_empty() {
return None;
}
SearchPattern::compile(p.line.text(), self.case).err()
}
#[must_use]
pub fn prompt_is_empty(&self) -> bool {
self.prompt
.as_ref()
.is_none_or(|p| p.line.text().is_empty())
}
#[must_use]
pub fn commit_step(&self, origin: usize) -> Option<Step> {
self.commit_step_skipping(origin, 0)
}
#[must_use]
pub fn commit_step_skipping(&self, origin: usize, skip: usize) -> Option<Step> {
let mut landed = step_inclusive(&self.matches, origin, self.direction)?;
for _ in 0..skip {
landed = step(&self.matches, landed.target.start, self.direction)?;
}
Some(landed)
}
#[must_use]
pub fn repeat(&self, from: usize, reverse: bool) -> Option<Step> {
let dir = if reverse {
self.direction.reversed()
} else {
self.direction
};
step(&self.matches, from, dir)
}
pub fn search_word(&mut self, text: &str, cursor: usize, direction: Direction) -> Option<Step> {
let word = crate::engine::word_at(text, cursor)?;
let pattern = SearchPattern::whole_word(&word, self.case).ok()?;
self.remember(pattern.raw());
self.pattern = Some(pattern);
self.arm(direction, text);
self.repeat(cursor, false)
}
#[must_use]
pub const fn pattern(&self) -> Option<&SearchPattern> {
self.pattern.as_ref()
}
#[must_use]
pub const fn direction(&self) -> Direction {
self.direction
}
#[must_use]
pub fn matches(&self) -> &[SearchMatch] {
&self.matches
}
#[must_use]
pub fn highlights(&self) -> &[SearchMatch] {
if self.highlight { &self.matches } else { &[] }
}
#[must_use]
pub const fn highlight_enabled(&self) -> bool {
self.highlight
}
pub fn relight(&mut self) {
self.highlight = true;
}
pub fn clear_highlight(&mut self) {
self.highlight = false;
}
pub fn set_case(&mut self, case: CaseMode) {
self.case = case;
}
#[must_use]
pub const fn case(&self) -> CaseMode {
self.case
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEXT: &str = "foo bar\nbaz foo\nqux foo end";
fn committed(pat: &str) -> SearchState {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0);
for c in pat.chars() {
s.push(c);
}
assert_eq!(s.accept(TEXT), Accepted::Committed);
s
}
#[test]
fn typing_then_accepting_commits_and_finds() {
let s = committed("foo");
assert_eq!(s.pattern().unwrap().raw(), "foo");
assert_eq!(s.matches().len(), 3);
assert!(!s.is_prompting(), "prompt closes on accept");
}
#[test]
fn cancel_keeps_the_previous_search_intact() {
let mut s = committed("foo");
s.open(Direction::Forward, 5);
s.push('z');
let origin = s.cancel();
assert_eq!(origin, Some(5), "cancel returns the cursor home");
assert_eq!(s.pattern().unwrap().raw(), "foo", "old pattern survives");
assert_eq!(s.matches().len(), 3, "old highlights survive");
assert!(!s.is_prompting());
}
#[test]
fn backspacing_past_the_slash_closes_the_prompt() {
let mut s = SearchState::new(CaseMode::Smart);
s.open(Direction::Forward, 0);
s.push('a');
assert!(!s.backspace(), "still has text");
assert!(s.backspace(), "empty -> closes");
assert!(!s.is_prompting());
}
#[test]
fn an_invalid_pattern_keeps_the_prompt_open_so_typing_is_not_lost() {
let mut s = SearchState::new(CaseMode::Smart);
s.open(Direction::Forward, 0);
for c in "a[b".chars() {
s.push(c);
}
assert!(matches!(s.accept(TEXT), Accepted::Invalid(_)));
assert!(s.is_prompting(), "prompt must stay open");
assert_eq!(s.prompt().unwrap().text(), "a[b", "text must survive");
}
#[test]
fn bare_enter_reuses_the_previous_pattern() {
let mut s = committed("foo");
s.open(Direction::Backward, 0);
assert_eq!(s.accept(TEXT), Accepted::ReusedPrevious);
assert_eq!(s.pattern().unwrap().raw(), "foo");
assert_eq!(s.direction(), Direction::Backward, "direction updates");
}
#[test]
fn bare_enter_with_no_history_does_nothing() {
let mut s = SearchState::new(CaseMode::Smart);
s.open(Direction::Forward, 0);
assert_eq!(s.accept(TEXT), Accepted::NothingToRepeat);
}
#[test]
#[allow(non_snake_case)]
fn n_and_N_move_opposite_ways() {
let s = committed("foo");
let fwd = s.repeat(0, false).unwrap();
let back = s.repeat(20, true).unwrap();
assert!(fwd.target.start > 0);
assert!(back.target.start < 20);
}
#[test]
#[allow(non_snake_case)]
fn N_after_a_backward_search_goes_forward() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Backward, 0);
for c in "foo".chars() {
s.push(c);
}
s.accept(TEXT);
assert_eq!(s.direction(), Direction::Backward);
let n = s.repeat(0, true).unwrap();
assert_eq!(n.target.start, 12, "first match strictly after 0");
}
#[test]
fn noh_stops_highlighting_but_n_still_works() {
let mut s = committed("foo");
assert_eq!(s.highlights().len(), 3);
s.clear_highlight();
assert!(s.highlights().is_empty(), "nothing lit");
assert_eq!(s.matches().len(), 3, "but matches are remembered");
assert!(s.repeat(0, false).is_some(), "and n still moves");
}
#[test]
fn incremental_preview_does_not_commit() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0);
for c in "baz".chars() {
s.push(c);
}
assert!(s.preview(TEXT).step().is_some(), "preview finds it");
assert!(s.pattern().is_none(), "but nothing is committed yet");
assert!(s.matches().is_empty());
}
#[test]
fn preview_finds_a_match_starting_at_the_cursor() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0); for c in "foo".chars() {
s.push(c);
}
assert_eq!(
s.preview(TEXT).step().copied().unwrap().target.start,
0,
"must light the one under the cursor"
);
}
#[test]
fn preview_of_an_invalid_pattern_is_none_not_a_panic() {
let mut s = SearchState::new(CaseMode::Smart);
s.open(Direction::Forward, 0);
for c in "a[b".chars() {
s.push(c);
}
assert!(s.preview(TEXT).step().is_none());
}
#[test]
fn history_records_accepted_searches_newest_last() {
let mut s = SearchState::new(CaseMode::Sensitive);
for p in ["foo", "bar", "baz"] {
s.open(Direction::Forward, 0);
for c in p.chars() {
s.push(c);
}
s.accept(TEXT);
}
assert_eq!(s.history(), ["foo", "bar", "baz"]);
}
#[test]
fn repeating_a_search_moves_it_to_the_front_without_duplicating() {
let mut s = SearchState::new(CaseMode::Sensitive);
for p in ["foo", "bar", "foo"] {
s.open(Direction::Forward, 0);
for c in p.chars() {
s.push(c);
}
s.accept(TEXT);
}
assert_eq!(s.history(), ["bar", "foo"], "no duplicate 'foo'");
}
#[test]
fn arrowing_up_walks_back_through_history() {
let mut s = SearchState::new(CaseMode::Sensitive);
for p in ["one", "two"] {
s.open(Direction::Forward, 0);
for c in p.chars() {
s.push(c);
}
s.accept(TEXT);
}
s.open(Direction::Forward, 0);
s.history_step(true);
assert_eq!(s.prompt().unwrap().text(), "two");
s.history_step(true);
assert_eq!(s.prompt().unwrap().text(), "one");
s.history_step(false);
assert_eq!(s.prompt().unwrap().text(), "two");
}
#[test]
fn arrowing_back_down_restores_what_you_were_typing() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0);
for c in "old".chars() {
s.push(c);
}
s.accept(TEXT);
s.open(Direction::Forward, 0);
for c in "typ".chars() {
s.push(c);
}
s.history_step(true);
assert_eq!(s.prompt().unwrap().text(), "old");
s.history_step(false);
assert_eq!(s.prompt().unwrap().text(), "typ", "the stash comes back");
}
#[test]
fn history_is_bounded() {
let mut s = SearchState::new(CaseMode::Sensitive);
for i in 0..(HISTORY_LIMIT + 10) {
s.open(Direction::Forward, 0);
for c in i.to_string().chars() {
s.push(c);
}
s.accept(TEXT);
}
assert_eq!(s.history().len(), HISTORY_LIMIT);
}
#[test]
fn star_searches_the_whole_word_under_the_cursor() {
let text = "foo foobar foo";
let mut s = SearchState::new(CaseMode::Sensitive);
let hit = s.search_word(text, 0, Direction::Forward).unwrap();
assert_eq!(hit.target.start, 11);
assert_eq!(s.matches().len(), 2, "two whole-word 'foo', not three");
}
#[test]
fn star_on_a_wordless_line_is_none_and_changes_nothing() {
let mut s = committed("foo");
let before = s.matches().len();
assert!(s.search_word(" \n", 0, Direction::Forward).is_none());
assert_eq!(s.matches().len(), before, "state untouched");
}
#[test]
fn typing_after_browsing_history_stops_browsing() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0);
for c in "old".chars() {
s.push(c);
}
s.accept(TEXT);
s.open(Direction::Forward, 0);
s.history_step(true);
assert_eq!(s.prompt().unwrap().text(), "old");
s.push('x');
assert_eq!(s.prompt().unwrap().text(), "oldx");
s.history_step(false);
assert_eq!(s.prompt().unwrap().text(), "oldx");
}
#[test]
fn refresh_tracks_an_edited_buffer() {
let mut s = committed("foo");
assert_eq!(s.matches().len(), 3);
s.refresh("foo");
assert_eq!(s.matches().len(), 1, "matches follow the new text");
}
#[test]
fn pushing_into_a_closed_prompt_is_a_no_op_not_a_panic() {
let mut s = SearchState::new(CaseMode::Smart);
s.push('x');
assert!(!s.is_prompting());
assert!(!s.backspace());
assert_eq!(s.cancel(), None);
}
fn prompting(text: &str) -> SearchState {
let mut st = SearchState::new(CaseMode::Smart);
st.open(Direction::Forward, 0);
for c in text.chars() {
st.push(c);
}
st
}
fn shown(st: &SearchState) -> (String, usize) {
let p = st.prompt().expect("prompting");
(p.line.text().to_owned(), p.caret())
}
#[test]
fn typing_appends_and_the_caret_follows() {
let st = prompting("foo");
assert_eq!(shown(&st), ("foo".to_string(), 3));
}
#[test]
fn a_character_typed_mid_pattern_lands_at_the_caret() {
let mut st = prompting("fo");
st.move_caret(CaretMove::Left);
st.push('X');
assert_eq!(shown(&st), ("fXo".to_string(), 2), "inserted AT the caret");
}
#[test]
fn backspace_deletes_before_the_caret_not_at_the_end() {
let mut st = prompting("abc");
st.move_caret(CaretMove::Left); assert!(!st.backspace());
assert_eq!(shown(&st), ("ac".to_string(), 1), "deleted `b`, not `c`");
}
#[test]
fn delete_at_caret_removes_the_character_ahead() {
let mut st = prompting("abc");
st.move_caret(CaretMove::Start);
st.delete_at_caret();
assert_eq!(shown(&st), ("bc".to_string(), 0));
}
#[test]
fn forward_delete_never_closes_the_prompt() {
let mut st = prompting("a");
st.move_caret(CaretMove::Start);
st.delete_at_caret();
assert!(st.is_prompting(), "prompt must stay open");
assert_eq!(shown(&st), (String::new(), 0));
}
#[test]
fn backspace_at_the_start_with_text_ahead_is_a_no_op_not_a_cancel() {
let mut st = prompting("abc");
st.move_caret(CaretMove::Start);
assert!(!st.backspace(), "must not report a close");
assert!(st.is_prompting(), "prompt survives");
assert_eq!(shown(&st), ("abc".to_string(), 0), "text untouched");
}
#[test]
fn backspace_on_an_empty_prompt_still_closes_it() {
let mut st = prompting("");
assert!(st.backspace(), "empty + backspace closes");
assert!(!st.is_prompting());
}
#[test]
fn caret_movement_saturates_at_both_ends() {
let mut st = prompting("ab");
for _ in 0..5 {
st.move_caret(CaretMove::Left);
}
assert_eq!(shown(&st).1, 0, "cannot go left of the start");
for _ in 0..5 {
st.move_caret(CaretMove::Right);
}
assert_eq!(shown(&st).1, 2, "cannot go right of the end");
}
#[test]
fn start_and_end_jump_the_caret() {
let mut st = prompting("hello");
st.move_caret(CaretMove::Start);
assert_eq!(shown(&st).1, 0);
st.move_caret(CaretMove::End);
assert_eq!(shown(&st).1, 5);
}
#[test]
#[allow(non_snake_case)]
fn the_caret_counts_CHARS_not_bytes() {
let mut st = prompting("héllo");
st.move_caret(CaretMove::Start);
st.move_caret(CaretMove::Right);
st.move_caret(CaretMove::Right); st.push('X');
assert_eq!(shown(&st), ("héXllo".to_string(), 3));
}
#[test]
fn editing_multibyte_text_backwards_does_not_panic() {
let mut st = prompting("🔥é日");
st.move_caret(CaretMove::End);
assert!(!st.backspace());
assert!(!st.backspace());
assert_eq!(shown(&st), ("🔥".to_string(), 1));
}
#[test]
fn ctrl_w_deletes_the_word_before_the_caret() {
let mut st = prompting("foo bar");
st.delete_word_before_caret();
assert_eq!(shown(&st), ("foo ".to_string(), 4));
}
#[test]
fn a_second_ctrl_w_eats_the_gap_and_the_next_word() {
let mut st = prompting("foo bar");
st.delete_word_before_caret();
st.delete_word_before_caret();
assert_eq!(shown(&st), (String::new(), 0));
}
#[test]
fn ctrl_w_keeps_what_is_ahead_of_the_caret() {
let mut st = prompting("foo bar");
st.move_caret(CaretMove::Start);
st.move_caret(CaretMove::Right);
st.move_caret(CaretMove::Right);
st.move_caret(CaretMove::Right); st.delete_word_before_caret();
assert_eq!(shown(&st), (" bar".to_string(), 0));
}
#[test]
fn ctrl_u_clears_back_to_the_start_only() {
let mut st = prompting("abcdef");
st.move_caret(CaretMove::Start);
for _ in 0..3 {
st.move_caret(CaretMove::Right);
}
st.clear_before_caret();
assert_eq!(shown(&st), ("def".to_string(), 0));
}
#[test]
fn history_recall_parks_the_caret_at_the_end() {
let mut st = SearchState::new(CaseMode::Smart);
st.open(Direction::Forward, 0);
for c in "alpha".chars() {
st.push(c);
}
let _ = st.accept("alpha beta");
st.open(Direction::Forward, 0);
st.history_step(true);
let (text, caret) = shown(&st);
assert_eq!(caret, text.chars().count(), "continue typing at the end");
}
#[test]
fn the_caret_never_exceeds_the_text_length() {
let mut st = prompting("hello");
let ops: &[CaretMove] = &[
CaretMove::End,
CaretMove::Left,
CaretMove::Start,
CaretMove::Right,
];
for op in ops {
st.move_caret(*op);
st.delete_at_caret();
let (t, c) = shown(&st);
assert!(c <= t.chars().count(), "caret {c} past {t:?}");
}
}
const HAYSTACK: &str = "aa bb aa bb aa";
fn previewing(pat: &str) -> SearchState {
let mut st = SearchState::new(CaseMode::Smart);
st.open(Direction::Forward, 0);
for c in pat.chars() {
st.push(c);
}
st
}
#[test]
fn preview_starts_on_the_first_match() {
let st = previewing("aa");
assert_eq!(
st.preview(HAYSTACK)
.step()
.copied()
.expect("a match")
.target
.start,
0
);
assert_eq!(st.prompt().expect("open").preview_skip(), 0);
}
#[test]
fn ctrl_g_walks_the_preview_forward_one_match_at_a_time() {
let mut st = previewing("aa");
st.preview_step(true);
assert_eq!(
st.preview(HAYSTACK)
.step()
.copied()
.expect("a match")
.target
.start,
6
);
st.preview_step(true);
assert_eq!(
st.preview(HAYSTACK)
.step()
.copied()
.expect("a match")
.target
.start,
12
);
}
#[test]
fn ctrl_t_walks_it_back() {
let mut st = previewing("aa");
st.preview_step(true);
st.preview_step(true);
st.preview_step(false);
assert_eq!(
st.preview(HAYSTACK)
.step()
.copied()
.expect("a match")
.target
.start,
6
);
}
#[test]
fn stepping_back_saturates_at_the_first_match() {
let mut st = previewing("aa");
for _ in 0..5 {
st.preview_step(false);
}
assert_eq!(st.prompt().expect("open").preview_skip(), 0);
assert_eq!(
st.preview(HAYSTACK)
.step()
.copied()
.expect("a match")
.target
.start,
0
);
}
#[test]
fn stepping_past_the_last_match_wraps() {
let mut st = previewing("aa");
for _ in 0..3 {
st.preview_step(true);
}
assert_eq!(
st.preview(HAYSTACK)
.step()
.copied()
.expect("a match")
.target
.start,
0,
"three steps past three matches comes back to the first",
);
}
#[test]
fn editing_the_pattern_resets_the_step() {
let mut st = previewing("aa");
st.preview_step(true);
assert_eq!(st.prompt().expect("open").preview_skip(), 1);
st.push(' ');
assert_eq!(
st.prompt().expect("open").preview_skip(),
0,
"typing resets"
);
st.preview_step(true);
st.backspace();
assert_eq!(
st.prompt().expect("open").preview_skip(),
0,
"backspace resets"
);
}
#[test]
fn every_pattern_edit_resets_the_step() {
for (name, op) in [
("delete_at_caret", 0),
("delete_word_before_caret", 1),
("clear_before_caret", 2),
] {
let mut st = previewing("aa bb");
st.preview_step(true);
match op {
0 => {
st.move_caret(CaretMove::Start);
st.delete_at_caret();
}
1 => st.delete_word_before_caret(),
_ => st.clear_before_caret(),
}
assert_eq!(
st.prompt().expect("open").preview_skip(),
0,
"{name} must reset the preview step",
);
}
}
#[test]
fn committing_lands_where_the_stepped_preview_showed() {
let mut st = previewing("aa");
st.preview_step(true);
let previewed = st
.preview(HAYSTACK)
.step()
.copied()
.expect("a match")
.target
.start;
let prompt = st.prompt().expect("open");
let (origin, skip) = (prompt.origin, prompt.preview_skip());
assert_eq!(st.accept(HAYSTACK), Accepted::Committed);
assert_eq!(previewed, 6, "the preview HAD moved");
assert_eq!(
st.commit_step_skipping(origin, skip)
.expect("a match")
.target
.start,
previewed,
"Enter must land on the match the stepped preview was showing",
);
}
}