use super::Preview;
use ratatui::style::Style;
use ratatui::text::Line;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct FindMatch {
pub(super) line: usize,
pub(super) start: usize,
pub(super) end: usize,
}
pub(super) struct FindState {
pub(super) query: String,
pub(super) matches: Vec<FindMatch>,
pub(super) current: usize,
pub(super) saved_cursor: usize,
}
fn compute_matches(lines: &[String], query: &str) -> Vec<FindMatch> {
let search = crate::components::text::LiteralSearch::new(query);
lines
.iter()
.enumerate()
.flat_map(|(line, text)| {
search.ranges(text).into_iter().map(move |range| FindMatch {
line,
start: range.start,
end: range.end,
})
})
.collect()
}
impl Preview {
pub fn findable(&self) -> bool {
self.numbered
}
pub fn find_active(&self) -> bool {
self.find.is_some()
}
pub fn begin_find(&mut self) {
if !self.findable() {
return;
}
self.find = Some(FindState {
query: String::new(),
matches: vec![],
current: 0,
saved_cursor: self.cursor,
});
}
pub fn update_find(&mut self, query: String) {
if !self.findable() {
return;
}
let saved_cursor = self
.find
.as_ref()
.map(|f| f.saved_cursor)
.unwrap_or(self.cursor);
let matches = compute_matches(&self.plain_lines(), &query);
let mut state = FindState {
query,
matches,
current: 0,
saved_cursor,
};
if !state.matches.is_empty() {
let idx = state
.matches
.iter()
.position(|m| m.line >= saved_cursor)
.unwrap_or(0);
state.current = idx;
self.cursor = state.matches[idx].line;
}
self.find = Some(state);
}
pub fn cancel_find(&mut self) {
if let Some(state) = self.find.take() {
self.cursor = state.saved_cursor.min(self.line_count.saturating_sub(1));
}
}
pub fn clear_find(&mut self) {
self.find = None;
}
pub fn find_step(&mut self, delta: i32) -> bool {
let Some(state) = &mut self.find else {
return false;
};
if state.matches.is_empty() {
return false;
}
let len = state.matches.len() as i32;
let next = (state.current as i32 + delta).rem_euclid(len) as usize;
state.current = next;
self.cursor = state.matches[next].line;
true
}
}
pub(super) fn chip_line(
line: &Line<'static>,
ranges: &[(usize, usize, bool)],
match_style: Style,
current_style: Style,
) -> Line<'static> {
let overlays: Vec<_> = ranges
.iter()
.map(|(start, end, current)| {
(
*start..*end,
if *current { current_style } else { match_style },
)
})
.collect();
crate::components::text::paint_line(line, crate::components::text::TextColumns::ALL, &overlays)
}
#[cfg(test)]
mod tests {
use super::*;
fn findable_preview() -> Preview {
let mut p = Preview::new();
p.set_bytes(
"main.rs",
b"fn main() {}\nlet ratatui = 1;\nno match here\nratatui ratatui\n",
);
p
}
#[test]
fn find_lands_on_first_match_at_or_after_cursor() {
let mut p = findable_preview();
p.begin_find();
p.update_find("ratatui".into());
assert_eq!(p.line(), Some(2)); assert_eq!(
p.readout().as_deref(),
Some("1/3 · 2/4"),
"three occurrences (two on line 4)"
);
let mut p = findable_preview();
p.move_cursor(2); p.begin_find();
p.update_find("ratatui".into());
assert_eq!(p.line(), Some(4));
assert_eq!(p.readout().as_deref(), Some("2/3 · 4/4"));
}
#[test]
fn find_step_cycles_per_occurrence_with_wrap() {
let mut p = findable_preview();
p.begin_find();
p.update_find("ratatui".into());
assert!(p.find_step(1));
assert_eq!(p.readout().as_deref(), Some("2/3 · 4/4"));
assert!(p.find_step(1)); assert_eq!(p.readout().as_deref(), Some("3/3 · 4/4"));
assert!(p.find_step(1)); assert_eq!(p.readout().as_deref(), Some("1/3 · 2/4"));
assert!(p.find_step(-1)); assert_eq!(p.readout().as_deref(), Some("3/3 · 4/4"));
}
#[test]
fn find_no_match_keeps_cursor_and_shows_zero() {
let mut p = findable_preview();
p.move_cursor(1);
p.begin_find();
p.update_find("zzzz".into());
assert_eq!(p.line(), Some(2));
assert_eq!(p.readout().as_deref(), Some("0/0 · 2/4"));
assert!(!p.find_step(1));
}
#[test]
fn cancel_restores_cursor_clear_keeps_it() {
let mut p = findable_preview();
p.move_cursor(2); p.begin_find();
p.update_find("ratatui".into());
assert_eq!(p.line(), Some(4));
p.cancel_find();
assert_eq!(p.line(), Some(3), "cancel restores the pre-find line");
assert!(!p.find_active());
let mut p = findable_preview();
p.begin_find();
p.update_find("ratatui".into());
p.clear_find(); assert_eq!(p.line(), Some(2), "clear keeps the match line");
assert!(!p.find_active());
}
#[test]
fn find_requires_text_content() {
let mut p = Preview::new();
p.set_dir("src", vec![]);
assert!(!p.findable());
p.begin_find();
assert!(!p.find_active(), "dir summaries can't be searched");
}
#[test]
fn new_content_drops_the_session() {
let mut p = findable_preview();
p.begin_find();
p.update_find("ratatui".into());
assert!(p.find_active());
p.set_bytes("other.rs", b"fresh\n");
assert!(!p.find_active());
assert_eq!(p.line(), Some(1));
}
#[test]
fn find_is_case_insensitive() {
let mut p = Preview::new();
p.set_bytes("a.rs", b"Ratatui RATATUI\n");
p.begin_find();
p.update_find("ratatui".into());
assert_eq!(p.readout().as_deref(), Some("1/2 · 1/1"));
}
}