Skip to main content

datui_lib/
find.rs

1//! Find in the table: `/` (or `f`) asks for a pattern, `n` and `N` move between matching
2//! cells. While typing, matches among rows on hand light up and are counted (`3 on
3//! screen`) without reading; Ctrl+G keeps matching rows as a sidebar filter. The view
4//! (query, filters, sort, shown columns) is searched as is and never changed; only the
5//! cursor moves. Searches run as jobs reading a window at a time from the buffer
6//! outward, finding only the next match, so the total is known only as far as finds
7//! have walked.
8
9use std::ops::Range;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14use polars::prelude::*;
15
16use crate::app::jobs::{Answer, Job, Progress};
17use crate::app::modals::filter_modal::{FilterOperator, FilterStatement, LogicalOperator};
18use crate::table::ViewRows;
19use crate::widgets::text_input::{TextInput, TextInputEvent};
20use crate::{App, AppEvent, InputMode, InputType};
21
22/// The row index a window is read with.
23const ROW: &str = "__datui_find_row";
24/// The rows a window held.
25const ROWS: &str = "__datui_find_rows";
26/// Rows in the first window past the buffer; each next window doubles, so a view that
27/// cannot skip (filter, CSV) reads at most about twice a single pass, while a nearby
28/// match costs one small read. A view seeing every row first (a sort), and the range
29/// behind the cursor on a non-skipping view, are read in one window. Forward on such a
30/// view, a window is never smaller than the rows above it, which it reads too.
31const FIRST_WINDOW: usize = 65_536;
32/// The most rows one window reads: a slice's length is a `u32`.
33const LARGEST_WINDOW: usize = u32::MAX as usize;
34
35/// What a find looks for.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct FindSpec {
38    pub pattern: String,
39    /// The pattern is a regular expression rather than plain text.
40    pub regex: bool,
41    /// The pattern's letters in order, anything between: `smth` finds `Smith`.
42    /// Spaces in the pattern are ignored.
43    pub fuzzy: bool,
44    /// Only this column, when set; otherwise every column shown.
45    pub column: Option<String>,
46}
47
48impl FindSpec {
49    /// Smart case: a pattern with no capital letter ignores case. In a regex an
50    /// escape such as `\S` is a class, not a capital.
51    pub fn ignores_case(&self) -> bool {
52        let mut chars = self.pattern.chars();
53        while let Some(c) = chars.next() {
54            if self.regex && c == '\\' {
55                chars.next();
56                continue;
57            }
58            if c.is_uppercase() {
59                return false;
60            }
61        }
62        true
63    }
64
65    /// The regex a cell's text is matched with, or `None` for a case-sensitive plain
66    /// pattern, which is matched literally.
67    pub(crate) fn regex_source(&self) -> Option<String> {
68        let case = if self.ignores_case() { "(?i)" } else { "" };
69        if self.fuzzy {
70            let letters: Vec<String> = self
71                .pattern
72                .chars()
73                .filter(|c| !c.is_whitespace())
74                .map(|c| regex::escape(&c.to_string()))
75                .collect();
76            return Some(format!("{case}{}", letters.join(".*")));
77        }
78        match (self.regex, self.ignores_case()) {
79            (false, false) => None,
80            (false, true) => Some(format!("{case}{}", regex::escape(&self.pattern))),
81            (true, _) => Some(format!("{case}{}", self.pattern)),
82        }
83    }
84
85    /// Why the pattern cannot be searched, in one line.
86    pub fn check(&self) -> Result<(), String> {
87        let Some(source) = self.regex_source().filter(|_| self.regex && !self.fuzzy) else {
88            return Ok(());
89        };
90        regex::Regex::new(&source).map(|_| ()).map_err(|e| {
91            // The crate's message draws the pattern with a caret under it; the last
92            // line is the reason.
93            let text = e.to_string();
94            let reason = text
95                .lines()
96                .rev()
97                .find(|line| !line.trim().is_empty())
98                .unwrap_or("invalid")
99                .trim()
100                .trim_start_matches("error: ")
101                .to_string();
102            format!("Not a regex: {reason}")
103        })
104    }
105
106    /// Whether a cell's `text` matches; false for a null.
107    fn matches(&self, text: Expr) -> Expr {
108        let found = match self.regex_source() {
109            None => text.str().contains_literal(lit(self.pattern.clone())),
110            Some(source) => text.str().contains(lit(source), true),
111        };
112        found.fill_null(lit(false))
113    }
114
115    /// How the pattern reads on the footer: quoted plain text, or a regex
116    /// between slashes, cut short when long.
117    pub fn label(&self) -> String {
118        const LONGEST: usize = 18;
119        let g = crate::glyphs::get();
120        let mut text: String = self.pattern.chars().take(LONGEST).collect();
121        if self.pattern.chars().count() > LONGEST {
122            text.push_str(g.ellipsis);
123        }
124        if self.fuzzy {
125            format!("~{text}")
126        } else if self.regex {
127            format!("/{text}/")
128        } else {
129            format!("\"{text}\"")
130        }
131    }
132}
133
134/// Whether a cell of column `name` matches `spec`, or `None` for a column a find
135/// skips: the one place a cell is matched, for a find and for the filter it keeps.
136pub(crate) fn cell_matches(spec: &FindSpec, name: &str, dtype: &DataType) -> Option<Expr> {
137    Some(spec.matches(text_of(name, dtype)?))
138}
139
140/// A column's values as text to match, or `None` for a column a find skips: bytes,
141/// nested values and nulls have no text of their own.
142fn text_of(name: &str, dtype: &DataType) -> Option<Expr> {
143    if dtype.is_nested()
144        || dtype.is_object()
145        || matches!(
146            dtype,
147            DataType::Binary | DataType::BinaryOffset | DataType::Null
148        )
149    {
150        return None;
151    }
152    let column = col(name);
153    Some(if dtype.is_string() {
154        column
155    } else if let DataType::Duration(unit) = dtype {
156        // Polars has no cast from a duration to text; written as the table shows it.
157        duration_text(column, *unit)
158    } else if crate::past_calendar::can_leave_calendar(dtype) {
159        // A plain cast panics on a date past the calendar; this writes it as its
160        // stored number, as the table does.
161        crate::past_calendar::text_expr(column, polars::chunked_array::cast::CastOptions::NonStrict)
162    } else {
163        column.cast(DataType::String)
164    })
165}
166
167/// A duration column as the text the table shows for it, such as `1d 2h`.
168fn duration_text(column: Expr, unit: TimeUnit) -> Expr {
169    column.map_with_fmt_str(
170        move |c| {
171            let text = c
172                .as_materialized_series()
173                .to_physical_repr()
174                .i64()?
175                .apply_into_string_amortized(|v, out| {
176                    use std::fmt::Write;
177                    let _ = write!(out, "{}", AnyValue::Duration(v, unit));
178                });
179            Ok(text.with_name(c.name().clone()).into_column())
180        },
181        |_: &Schema, field: &Field| Ok(Field::new(field.name().clone(), DataType::String)),
182        "find_duration_text",
183    )
184}
185
186/// The columns a find over `order` reads, in that order, and the match of each.
187pub(crate) fn searched_columns(
188    order: &[String],
189    schema: &Schema,
190    spec: &FindSpec,
191) -> Vec<(String, Expr)> {
192    order
193        .iter()
194        .filter(|name| spec.column.as_ref().is_none_or(|only| only == *name))
195        .filter_map(|name| {
196            let text = text_of(name, schema.get(name)?)?;
197            Some((name.clone(), spec.matches(text)))
198        })
199        .collect()
200}
201
202/// Which way a find goes.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum Direction {
205    Next,
206    Previous,
207}
208
209/// Where a find starts: a view row and the cursor's place among the searched columns;
210/// with none, the whole row (`f` finds the first match at or after the cursor).
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub(crate) struct Start {
213    pub(crate) row: usize,
214    pub(crate) column: Option<At>,
215}
216
217/// The cursor's place in its row, as an index into the columns searched.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub(crate) enum At {
220    /// On a searched column.
221    On(usize),
222    /// On a column not searched, just before this searched one (or past the last).
223    Before(usize),
224}
225
226impl At {
227    /// The first searched column past the cursor.
228    fn ahead(self) -> usize {
229        match self {
230            At::On(c) => c + 1,
231            At::Before(c) => c,
232        }
233    }
234
235    /// The searched columns before this one are behind the cursor.
236    fn behind(self) -> usize {
237        match self {
238            At::On(c) | At::Before(c) => c,
239        }
240    }
241}
242
243/// The cell a find landed on.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct Found {
246    pub row: usize,
247    pub column: String,
248    /// It went past the end (or the start) and came round to reach it.
249    pub wrapped: bool,
250}
251
252/// In row `row`, only the columns in `columns` are in reach: the cells on the far
253/// side of the start are left to the other half of the search.
254#[derive(Debug, Clone)]
255struct Limit {
256    row: usize,
257    columns: Range<usize>,
258}
259
260/// Why a search stopped before it answered.
261pub(crate) const CANCELLED: &str = "Find cancelled";
262
263/// One search through a view, on a worker.
264pub(crate) struct Search {
265    rows: ViewRows,
266    columns: Vec<String>,
267    exprs: Vec<Expr>,
268    stop: Arc<AtomicBool>,
269    report: Box<dyn Fn(usize) + Send>,
270    /// Rows read so far, for the progress line.
271    read: usize,
272    /// Rows in the next window read past the buffer.
273    window: usize,
274}
275
276impl Search {
277    pub(crate) fn new(
278        rows: ViewRows,
279        columns: Vec<(String, Expr)>,
280        stop: Arc<AtomicBool>,
281        report: impl Fn(usize) + Send + 'static,
282    ) -> Self {
283        let (names, exprs): (Vec<String>, Vec<Expr>) = columns
284            .into_iter()
285            .enumerate()
286            .map(|(i, (name, expr))| (name, expr.alias(format!("m{i}"))))
287            .unzip();
288        // The buffer serves only while it holds every column searched: hiding a
289        // column re-reads it narrower.
290        let mut rows = rows;
291        if rows
292            .buffer
293            .as_ref()
294            .is_some_and(|(df, _)| names.iter().any(|n| df.column(n).is_err()))
295        {
296            rows.buffer = None;
297        }
298        // A sort costs a whole pass for any window, so it gets one.
299        let window = if rows.whole {
300            LARGEST_WINDOW
301        } else {
302            FIRST_WINDOW
303        };
304        Self {
305            rows,
306            columns: names,
307            exprs,
308            stop,
309            report: Box::new(report),
310            read: 0,
311            window,
312        }
313    }
314
315    /// Find the next match from `start` going `direction`, wrapping round the view
316    /// once. `None` when nothing in the view matches.
317    pub(crate) fn run(
318        mut self,
319        start: Start,
320        direction: Direction,
321    ) -> Result<Option<Found>, String> {
322        let n = self.columns.len();
323        let r = start.row;
324        let found = |(row, column): (usize, usize), wrapped: bool, columns: &[String]| Found {
325            row,
326            column: columns[column].clone(),
327            wrapped,
328        };
329        match direction {
330            Direction::Next => {
331                let ahead = start.column.map(|at| Limit {
332                    row: r,
333                    columns: at.ahead()..n,
334                });
335                if let Some(hit) = self.forward(r, None, ahead.as_ref(), false)? {
336                    return Ok(Some(found(hit, false, &self.columns)));
337                }
338                // Round from the top: up to the start row, and in it the cells up to
339                // the cursor's, which is a match of its own when it is the only one.
340                let (end, behind) = match start.column {
341                    Some(at) => (
342                        r + 1,
343                        Some(Limit {
344                            row: r,
345                            columns: 0..at.ahead(),
346                        }),
347                    ),
348                    None => (r, None),
349                };
350                Ok(self
351                    .forward(0, Some(end), behind.as_ref(), false)?
352                    .map(|hit| found(hit, true, &self.columns)))
353            }
354            Direction::Previous => {
355                let behind = start.column.map(|at| Limit {
356                    row: r,
357                    columns: 0..at.behind(),
358                });
359                if let Some(hit) = self.backward(0, r + 1, behind.as_ref())? {
360                    return Ok(Some(found(hit, false, &self.columns)));
361                }
362                // Round from the bottom, down to the cursor's cell.
363                let (from, ahead) = match start.column {
364                    Some(at) => (
365                        r,
366                        Some(Limit {
367                            row: r,
368                            columns: at.behind()..n,
369                        }),
370                    ),
371                    None => (r + 1, None),
372                };
373                let hit = match self.rows.num_rows {
374                    Some(total) => self.backward(from, total, ahead.as_ref())?,
375                    // With no count, the end is found by reading to it.
376                    None => self.forward(from, None, ahead.as_ref(), true)?,
377                };
378                Ok(hit.map(|hit| found(hit, true, &self.columns)))
379            }
380        }
381    }
382
383    /// The first match in rows `[from, end)`, or the last with `last`. With no `end`
384    /// the rows run to the view's end, found by reading to it.
385    fn forward(
386        &mut self,
387        from: usize,
388        end: Option<usize>,
389        limit: Option<&Limit>,
390        last: bool,
391    ) -> Result<Option<(usize, usize)>, String> {
392        let end = end.or(self.rows.num_rows);
393        let mut at = from;
394        let mut best = None;
395        while end.is_none_or(|end| at < end) {
396            self.check_stop()?;
397            let (len, buffered) = self.plan_forward(at, end, last);
398            if len == 0 {
399                break;
400            }
401            let (rows, hit) = self.window_at(at, len, buffered, limit, last)?;
402            if hit.is_some() {
403                if !last {
404                    return Ok(hit);
405                }
406                best = hit;
407            }
408            if rows < len {
409                break;
410            }
411            at += len;
412        }
413        Ok(best)
414    }
415
416    /// The last match in rows `[from, end)`, read from the end down.
417    fn backward(
418        &mut self,
419        from: usize,
420        end: usize,
421        limit: Option<&Limit>,
422    ) -> Result<Option<(usize, usize)>, String> {
423        let mut end = end;
424        while end > from {
425            self.check_stop()?;
426            let (start, buffered) = self.plan_backward(from, end);
427            let (_, hit) = self.window_at(start, end - start, buffered, limit, true)?;
428            if hit.is_some() {
429                return Ok(hit);
430            }
431            end = start;
432        }
433        Ok(None)
434    }
435
436    fn check_stop(&self) -> Result<(), String> {
437        if self.stop.load(Ordering::Relaxed) {
438            return Err(CANCELLED.to_string());
439        }
440        Ok(())
441    }
442
443    /// The buffer's rows, as `(start, end)`.
444    fn buffered(&self) -> Option<(usize, usize)> {
445        self.rows
446            .buffer
447            .as_ref()
448            .map(|(df, start)| (*start, start + df.height()))
449    }
450
451    /// The next window from `at`: the rest of the buffer if `at` is in it, else rows from
452    /// the view up to the buffer. For the `last` match, all rows to the end at once.
453    fn plan_forward(&mut self, at: usize, end: Option<usize>, last: bool) -> (usize, bool) {
454        let end = end.unwrap_or(usize::MAX);
455        if let Some((start, stop)) = self.buffered()
456            && (start..stop).contains(&at)
457        {
458            return (stop.min(end) - at, true);
459        }
460        let mut window = self.window_for(last);
461        // A non-skipping view reads the rows above a window anyway: a window at least that big
462        // keeps deep finds from rereading them per doubling.
463        if self.rows.reads_up_to {
464            window = window.max(at);
465        }
466        let mut stop = at.saturating_add(window).min(end);
467        if let Some((start, _)) = self.buffered()
468            && at < start
469        {
470            stop = stop.min(start);
471        }
472        self.window = window.saturating_mul(2).min(LARGEST_WINDOW);
473        (stop - at, false)
474    }
475
476    /// Rows in the next window read from the view. Read backwards, or to the end, a
477    /// view that cannot skip to a window takes the whole range in one.
478    fn window_for(&self, whole_range: bool) -> usize {
479        if whole_range && self.rows.reads_up_to {
480            LARGEST_WINDOW
481        } else {
482            self.window
483        }
484    }
485
486    /// The window that ends at `end`, no lower than `from`: the buffer's rows when
487    /// the row before `end` is in it, else rows read from the view.
488    fn plan_backward(&mut self, from: usize, end: usize) -> (usize, bool) {
489        if let Some((start, stop)) = self.buffered()
490            && (start..stop).contains(&(end - 1))
491        {
492            return (start.max(from), true);
493        }
494        let mut start = end.saturating_sub(self.window_for(true)).max(from);
495        if let Some((_, stop)) = self.buffered()
496            && end > stop
497        {
498            start = start.max(stop);
499        }
500        self.window = self.window.saturating_mul(2).min(LARGEST_WINDOW);
501        (start, false)
502    }
503
504    /// Read rows `[start, start + len)` and find the first (or with `last`, the last) match,
505    /// returning the rows read and the match's row and column. One aggregate per column;
506    /// matching cells are never collected.
507    fn window_at(
508        &mut self,
509        start: usize,
510        len: usize,
511        buffered: bool,
512        limit: Option<&Limit>,
513        last: bool,
514    ) -> Result<(usize, Option<(usize, usize)>), String> {
515        let message = |e: PolarsError| crate::error_display::user_message_from_polars(&e);
516        let lf = match self.rows.buffer.as_ref().filter(|_| buffered) {
517            Some((df, at)) => df
518                .slice((start - at) as i64, len)
519                .lazy()
520                .select(self.exprs.clone()),
521            None => self
522                .rows
523                .window(start, len, self.exprs.clone())
524                .map_err(message)?,
525        };
526        let offset = IdxSize::try_from(start)
527            .map_err(|_| "The view has too many rows to find in".to_string())?;
528        let row = || col(ROW).cast(DataType::UInt64);
529        let mut aggregates = vec![polars::prelude::len().cast(DataType::UInt64).alias(ROWS)];
530        for i in 0..self.columns.len() {
531            let mut cell = col(format!("m{i}"));
532            if let Some(limit) = limit
533                && !limit.columns.contains(&i)
534            {
535                cell = cell.and(row().neq(lit(limit.row as u64)));
536            }
537            let rows = row().filter(cell);
538            let at = if last { rows.max() } else { rows.min() };
539            aggregates.push(at.alias(format!("f{i}")));
540        }
541        let lf = lf.with_row_index(ROW, Some(offset)).select(aggregates);
542        let df =
543            crate::analysis::statistics::collect_lazy(lf, self.rows.streaming).map_err(message)?;
544        let get = |name: &str| -> Option<u64> { df.column(name).ok()?.u64().ok()?.get(0) };
545        let rows = get(ROWS).unwrap_or(0) as usize;
546        let mut best: Option<(usize, usize)> = None;
547        for i in 0..self.columns.len() {
548            let Some(at) = get(&format!("f{i}")).map(|r| r as usize) else {
549                continue;
550            };
551            // Reading order: the first row, and in it the first column; backwards,
552            // the last row and the last column.
553            let better = best.is_none_or(|(b, _)| if last { at >= b } else { at < b });
554            if better {
555                best = Some((at, i));
556            }
557        }
558        self.read += rows;
559        (self.report)(self.read);
560        Ok((rows, best))
561    }
562}
563
564/// The find prompt, and the find in effect.
565pub struct Find {
566    pub input: TextInput,
567    /// The prompt's pattern is a regex.
568    pub regex: bool,
569    /// The prompt's pattern is letters in order.
570    pub fuzzy: bool,
571    /// The prompt limits the find to `column`.
572    pub in_column: bool,
573    /// The column the prompt opened on: the column cursor's.
574    pub column: Option<String>,
575    /// Why the pattern typed cannot be searched.
576    pub error: Option<String>,
577    /// The find `n` and `N` repeat.
578    pub active: Option<ActiveFind>,
579    /// The cells the prompt's pattern matches among the rows on hand.
580    pub live: Option<LiveMatches>,
581    /// The rows on hand `live` was worked out over: their first view row, how many,
582    /// and the frame. Rows that arrive or a new frame make it stale.
583    live_rows: Option<(usize, usize, u64)>,
584    /// Rows the find reading has read, for the footer's progress line.
585    pub read: Option<usize>,
586}
587
588/// The view rows a pattern being typed matches among the rows on hand, by column
589/// name: looked up by the table as it draws, without a name cloned per cell.
590pub type MatchCells = std::collections::HashMap<String, std::collections::HashSet<usize>>;
591
592/// The cells a pattern being typed matches among the rows on hand. Worked out in
593/// memory as the pattern or the rows on hand change; never read.
594#[derive(Debug, Clone, Default)]
595pub struct LiveMatches {
596    pub cells: Arc<MatchCells>,
597}
598
599impl LiveMatches {
600    /// The matches in view rows `rows`: the count the prompt shows.
601    pub fn within(&self, rows: Range<usize>) -> usize {
602        self.cells
603            .values()
604            .map(|hits| hits.iter().filter(|r| rows.contains(r)).count())
605            .sum()
606    }
607}
608
609/// The find in effect, and where it last landed.
610#[derive(Debug, Clone)]
611pub struct ActiveFind {
612    pub spec: FindSpec,
613    /// The dataset it was made for.
614    pub dataset: u64,
615    /// The frame (`len_generation`) its cell is a cell of.
616    pub frame: u64,
617    /// The cell it landed on: view row and column name.
618    pub hit: Option<(usize, String)>,
619    /// Which match that is, counting from the top, when the finds so far say.
620    pub ordinal: Option<usize>,
621}
622
623impl Find {
624    pub fn new(input: TextInput) -> Self {
625        Self {
626            input,
627            regex: false,
628            fuzzy: false,
629            in_column: false,
630            column: None,
631            error: None,
632            active: None,
633            live: None,
634            live_rows: None,
635            read: None,
636        }
637    }
638
639    /// The spec the prompt describes.
640    pub(crate) fn prompt_spec(&self) -> FindSpec {
641        FindSpec {
642            pattern: self.input.value().to_string(),
643            regex: self.regex,
644            fuzzy: self.fuzzy,
645            column: self.column.clone().filter(|_| self.in_column),
646        }
647    }
648}
649
650/// A find running on a worker: how to stop it, and what its answer is judged by.
651#[derive(Debug, Clone)]
652pub(crate) struct FindRun {
653    pub(crate) stop: Arc<AtomicBool>,
654    pub(crate) dataset: u64,
655    pub(crate) frame: u64,
656    pub(crate) direction: Direction,
657    /// It started before every cell of the view, so its first match is match 1.
658    pub(crate) from_top: bool,
659    /// It started from the cell the last find landed on, with that match's number.
660    pub(crate) from_hit: Option<Option<usize>>,
661}
662
663/// What the footer says while a find reads.
664fn finding_status(spec: &FindSpec, read: Option<usize>) -> String {
665    match read {
666        // Abbreviated, as the row count beside it is: the line shares a narrow bar
667        // with the way out and the count.
668        Some(rows) if rows > 0 => format!(
669            "Finding {}... {} rows",
670            spec.label(),
671            crate::home::discover::format_rows(rows)
672        ),
673        _ => format!("Finding {}...", spec.label()),
674    }
675}
676
677impl App {
678    /// `/` (or `f`) at the table: the find prompt, holding the last pattern, selected
679    /// so that typing replaces it.
680    pub(crate) fn open_find(&mut self) {
681        if self.data_table_state.is_none() {
682            return;
683        }
684        self.prompt.find.column = self.find_column();
685        self.prompt.find.error = None;
686        match self.prompt.find.active.as_ref() {
687            Some(active) => {
688                let pattern = active.spec.pattern.clone();
689                self.prompt.find.input.set_value(pattern);
690                self.prompt.find.input.select_all();
691            }
692            None => self.prompt.find.input.clear(),
693        }
694        self.prompt.find.input.set_focused(true);
695        self.input_mode = InputMode::Editing;
696        self.prompt.input_type = Some(InputType::Find);
697        self.refresh_live_matches();
698    }
699
700    /// Light up pattern matches among the on-screen rows and a page either side, from rows
701    /// in memory only, so typing never waits on a read or scans a whole row group.
702    pub(crate) fn refresh_live_matches(&mut self) {
703        self.prompt.find.live = None;
704        self.prompt.find.live_rows = self.rows_on_hand_key();
705        let spec = self.prompt.find.prompt_spec();
706        if spec.pattern.trim().is_empty() || spec.check().is_err() {
707            return;
708        }
709        let Some((df, start)) = self.live_window() else {
710            return;
711        };
712        let Some(state) = self.data_table_state.as_ref() else {
713            return;
714        };
715        let columns: Vec<(String, Expr)> =
716            searched_columns(state.get_column_order(), state.schema(), &spec)
717                .into_iter()
718                .filter(|(name, _)| df.column(name).is_ok())
719                .collect();
720        if columns.is_empty() {
721            self.prompt.find.live = Some(LiveMatches::default());
722            return;
723        }
724        let exprs: Vec<Expr> = columns
725            .iter()
726            .enumerate()
727            .map(|(i, (_, expr))| expr.clone().alias(format!("m{i}")))
728            .collect();
729        // In memory: the rows on hand are a frame already collected.
730        let Ok(found) = df.lazy().select(exprs).collect() else {
731            return;
732        };
733        let mut cells = MatchCells::new();
734        for (i, (name, _)) in columns.iter().enumerate() {
735            let Ok(hits) = found
736                .column(&format!("m{i}"))
737                .and_then(|c| c.bool().cloned())
738            else {
739                continue;
740            };
741            let rows: std::collections::HashSet<usize> = hits
742                .iter()
743                .enumerate()
744                .filter(|(_, hit)| *hit == Some(true))
745                .map(|(row, _)| start + row)
746                .collect();
747            if !rows.is_empty() {
748                cells.insert(name.clone(), rows);
749            }
750        }
751        self.prompt.find.live = Some(LiveMatches {
752            cells: Arc::new(cells),
753        });
754    }
755
756    /// The rows the live find matches: those on screen and a page either side, of
757    /// the rows on hand, and the row the first is.
758    fn live_window(&self) -> Option<(DataFrame, usize)> {
759        let state = self.data_table_state.as_ref()?;
760        let (df, start) = state.rows_on_hand()?;
761        let page = state.visible_rows.max(1);
762        let from = state.start_row().saturating_sub(page).max(start);
763        let to = (state.start_row() + 2 * page).min(start + df.height());
764        (to > from).then(|| (df.slice((from - start) as i64, to - from), from))
765    }
766
767    /// Which rows the live matches were worked out over, to tell when they need
768    /// working out again.
769    fn rows_on_hand_key(&self) -> Option<(usize, usize, u64)> {
770        let state = self.data_table_state.as_ref()?;
771        let (df, start) = self.live_window()?;
772        Some((start, df.height(), state.len_generation()))
773    }
774
775    /// With the find prompt open, rematch when the rows on hand or the view changed (a
776    /// collect, a follow's rows, a resize). Whether it did.
777    pub(crate) fn refresh_stale_live_matches(&mut self) -> bool {
778        let stale = self.prompt.input_type == Some(InputType::Find)
779            && self.prompt.find.live_rows != self.rows_on_hand_key();
780        if stale {
781            self.refresh_live_matches();
782        }
783        stale
784    }
785
786    /// The matches the prompt's pattern has among the rows on screen, while the
787    /// prompt is open.
788    pub fn live_on_screen(&self) -> Option<usize> {
789        let live = self.prompt.find.live.as_ref()?;
790        let state = self.data_table_state.as_ref()?;
791        let start = state.start_row();
792        Some(live.within(start..start + state.visible_rows))
793    }
794
795    /// The cells to light up: the prompt's matches while it is open.
796    pub fn live_cells(&self) -> Option<Arc<MatchCells>> {
797        (self.prompt.input_type == Some(InputType::Find))
798            .then_some(self.prompt.find.live.as_ref())
799            .flatten()
800            .map(|live| live.cells.clone())
801    }
802
803    /// The column a find limited to one column searches: the column cursor's, which a
804    /// find moves to the cell it lands on.
805    pub(crate) fn find_column(&self) -> Option<String> {
806        self.data_table_state
807            .as_ref()?
808            .current_column()
809            .map(str::to_string)
810    }
811
812    fn close_find_prompt(&mut self) {
813        self.prompt.find.input.set_focused(false);
814        self.prompt.find.error = None;
815        self.prompt.find.live = None;
816        self.show_table();
817    }
818
819    /// A key in the find prompt: Ctrl+R regex, Ctrl+T letters in order, Ctrl+L column
820    /// limit, Ctrl+G keep matching rows; readline keys and history otherwise.
821    pub(crate) fn find_prompt_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
822        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
823        if event.is_press() && ctrl {
824            match event.code {
825                KeyCode::Char('r') => {
826                    self.prompt.find.regex = !self.prompt.find.regex;
827                    self.prompt.find.fuzzy &= !self.prompt.find.regex;
828                    self.prompt.find.error = None;
829                    self.refresh_live_matches();
830                    return None;
831                }
832                KeyCode::Char('t') => {
833                    self.prompt.find.fuzzy = !self.prompt.find.fuzzy;
834                    self.prompt.find.regex &= !self.prompt.find.fuzzy;
835                    self.prompt.find.error = None;
836                    self.refresh_live_matches();
837                    return None;
838                }
839                KeyCode::Char('l') => {
840                    self.prompt.find.in_column = !self.prompt.find.in_column;
841                    self.refresh_live_matches();
842                    return None;
843                }
844                KeyCode::Char('g') => return self.keep_matches(),
845                _ => {}
846            }
847        }
848        let before = self.prompt.find.input.value().to_string();
849        match self.prompt.find.input.handle_key(event, Some(&self.cache)) {
850            TextInputEvent::Submit => {
851                let spec = self.prompt.find.prompt_spec();
852                if spec.pattern.is_empty() {
853                    // An emptied field is how a find is taken back (#644).
854                    self.prompt.find.active = None;
855                    self.close_find_prompt();
856                    return None;
857                }
858                if let Err(reason) = spec.check() {
859                    self.prompt.find.error = Some(reason);
860                    return None;
861                }
862                let _ = self.prompt.find.input.save_to_history(&self.cache);
863                self.close_find_prompt();
864                self.start_find(spec, Direction::Next, true);
865            }
866            TextInputEvent::Cancel => self.close_find_prompt(),
867            TextInputEvent::HistoryChanged | TextInputEvent::None => {
868                if self.prompt.find.input.value() != before {
869                    self.prompt.find.error = None;
870                    self.refresh_live_matches();
871                }
872            }
873        }
874        None
875    }
876
877    /// Ctrl+G in the find prompt: keep only the rows that match, as a filter added to
878    /// the sidebar's, and leave the find in effect for `n` and `N` among them.
879    fn keep_matches(&mut self) -> Option<AppEvent> {
880        let spec = self.prompt.find.prompt_spec();
881        if spec.pattern.trim().is_empty() {
882            return None;
883        }
884        if let Err(reason) = spec.check() {
885            self.prompt.find.error = Some(reason);
886            return None;
887        }
888        let state = self.data_table_state.as_ref()?;
889        let operator = if spec.fuzzy {
890            FilterOperator::HasFuzzy
891        } else if spec.regex {
892            FilterOperator::HasRegex
893        } else {
894            FilterOperator::Has
895        };
896        // Over every column, the ones shown now: what the find searched.
897        let columns = if spec.column.is_none() {
898            state.get_column_order().to_vec()
899        } else {
900            Vec::new()
901        };
902        let statement = FilterStatement {
903            columns,
904            column: spec
905                .column
906                .clone()
907                .unwrap_or_else(|| crate::app::modals::filter_modal::ANY_COLUMN.to_string()),
908            operator,
909            value: spec.pattern.clone(),
910            logical_op: LogicalOperator::And,
911        };
912        let mut statements = state.view_filters().to_vec();
913        let frame = state.len_generation();
914        let _ = self.prompt.find.input.save_to_history(&self.cache);
915        self.close_find_prompt();
916        self.prompt.find.active = Some(ActiveFind {
917            spec,
918            dataset: self.dataset_generation,
919            frame,
920            hit: None,
921            ordinal: None,
922        });
923        if statements.contains(&statement) {
924            return None;
925        }
926        statements.push(statement);
927        Some(AppEvent::Applied(crate::Applied::Filter(statements)))
928    }
929
930    /// `n` / `N` at the table: the find in effect again, from the cursor's cell.
931    pub(crate) fn find_again(&mut self, direction: Direction) {
932        match self.prompt.find.active.as_ref() {
933            Some(active) if active.dataset == self.dataset_generation => {
934                let spec = active.spec.clone();
935                self.start_find(spec, direction, false);
936            }
937            _ => self.flash_note("Nothing to find yet: / finds".to_string()),
938        }
939    }
940
941    /// The cell the find in effect landed on, while the view is the one it searched.
942    pub fn find_hit(&self) -> Option<(usize, String)> {
943        let active = self.prompt.find.active.as_ref()?;
944        let state = self.data_table_state.as_ref()?;
945        (active.dataset == self.dataset_generation && active.frame == state.len_generation())
946            .then(|| active.hit.clone())
947            .flatten()
948    }
949
950    /// What the footer says about the find in effect: the pattern, and which
951    /// match the cursor is on when that is known.
952    pub fn find_mark(&self) -> Option<String> {
953        let active = self.prompt.find.active.as_ref()?;
954        // While it reads, the busy line names the pattern and the bar needs the room
955        // for the rows read; while the prompt is open, the prompt is the find.
956        if active.dataset != self.dataset_generation
957            || self.finding()
958            || self.prompt.input_type == Some(InputType::Find)
959        {
960            return None;
961        }
962        let mut mark = format!("find {}", active.spec.label());
963        if let Some(column) = &active.spec.column {
964            mark.push_str(&format!(" in {column}"));
965        }
966        if let Some(k) = active.ordinal.filter(|_| self.find_hit().is_some()) {
967            mark.push_str(&format!(
968                " {} match {}",
969                crate::glyphs::get().middot,
970                crate::numfmt::group_chrome(k)
971            ));
972        }
973        Some(mark)
974    }
975
976    /// Whether a find is in effect on this dataset, so Esc at the table clears it.
977    pub(crate) fn find_shown(&self) -> bool {
978        self.prompt
979            .find
980            .active
981            .as_ref()
982            .is_some_and(|active| active.dataset == self.dataset_generation)
983    }
984
985    /// Whether a find is reading.
986    pub fn finding(&self) -> bool {
987        self.jobs
988            .current(|job| matches!(job, Job::Find(_) | Job::HexFind(_)))
989            .is_some()
990    }
991
992    /// Stop the find that is reading: Esc while it runs. Its worker stops at its next
993    /// window, and its answer, if it comes first, is dropped.
994    pub(crate) fn cancel_find(&mut self) {
995        if self.stop_find() {
996            self.flash_note(CANCELLED.to_string());
997        }
998    }
999
1000    /// Stop the find that is reading, if one is. Returns whether one was.
1001    pub(crate) fn stop_find(&mut self) -> bool {
1002        if self.stop_hex_find() {
1003            return true;
1004        }
1005        let Some((_, Job::Find(run))) = self.jobs.current(|job| matches!(job, Job::Find(_))) else {
1006            return false;
1007        };
1008        run.stop.store(true, Ordering::Relaxed);
1009        self.jobs.cancel(|job| matches!(job, Job::Find(_)));
1010        self.status_message = None;
1011        self.prompt.find.read = None;
1012        true
1013    }
1014
1015    /// Start a find for `spec` from the cursor. `n` and `N` start past the cursor's
1016    /// cell; `fresh` (`f`), the whole cursor row is in reach.
1017    fn start_find(&mut self, spec: FindSpec, direction: Direction, fresh: bool) {
1018        let Some(state) = self.data_table_state.as_ref() else {
1019            return;
1020        };
1021        let columns = searched_columns(state.get_column_order(), state.schema(), &spec);
1022        if columns.is_empty() {
1023            self.flash_note(match &spec.column {
1024                Some(column) if !state.get_column_order().contains(column) => {
1025                    format!("Nothing to find in {column}: it is not shown")
1026                }
1027                Some(column) => format!("Nothing to find in {column}: it holds no text"),
1028                None => "No column to find in".to_string(),
1029            });
1030            return;
1031        }
1032        let frame = state.len_generation();
1033        let row = state.cursor_row();
1034        // `n` and `N` go on from the cursor's cell, as in vim; `f` reads its whole row.
1035        let at = state.current_column().filter(|_| !fresh).map(|name| {
1036            match columns.iter().position(|(n, _)| n == name) {
1037                Some(c) => At::On(c),
1038                None => {
1039                    let order = state.get_column_order();
1040                    let place = |n: &str| order.iter().position(|o| o == n);
1041                    let cursor = place(name);
1042                    At::Before(columns.iter().filter(|(n, _)| place(n) < cursor).count())
1043                }
1044            }
1045        });
1046        let previous =
1047            self.prompt.find.active.as_ref().filter(|a| {
1048                a.dataset == self.dataset_generation && a.frame == frame && a.spec == spec
1049            });
1050        // On the cell the last find landed on, the count of matches goes on from it.
1051        let on_hit = previous
1052            .and_then(|a| Some((a.hit.as_ref()?, a.ordinal)))
1053            .and_then(|((hit_row, name), ordinal)| {
1054                let c = columns.iter().position(|(n, _)| n == name)?;
1055                (*hit_row == row && at == Some(At::On(c))).then(|| (name.clone(), ordinal))
1056            });
1057        let start = Start { row, column: at };
1058        self.prompt.find.active = Some(ActiveFind {
1059            spec: spec.clone(),
1060            dataset: self.dataset_generation,
1061            frame,
1062            hit: on_hit.as_ref().map(|(name, _)| (row, name.clone())),
1063            ordinal: on_hit.as_ref().and_then(|(_, ordinal)| *ordinal),
1064        });
1065        let stop = Arc::new(AtomicBool::new(false));
1066        let run = FindRun {
1067            stop: stop.clone(),
1068            dataset: self.dataset_generation,
1069            frame,
1070            direction,
1071            from_top: row == 0 && at.is_none_or(|at| at.ahead() == 0),
1072            from_hit: on_hit.as_ref().map(|(_, ordinal)| *ordinal),
1073        };
1074        let rows = state.view_rows();
1075        let status = finding_status(&spec, None);
1076        self.prompt.find.read = None;
1077        self.spawn_job(Job::Find(run), Some(&status), move |worker| {
1078            let report = worker.reporter();
1079            let search = Search::new(rows, columns, stop, move |read| {
1080                report(Progress::Finding { rows: read })
1081            });
1082            Ok(Answer::Found(search.run(start, direction)?))
1083        });
1084    }
1085
1086    /// A find's progress: the rows it has read.
1087    pub(crate) fn find_progress(&mut self, rows: usize) {
1088        self.prompt.find.read = Some(rows);
1089        if let Some(active) = self.prompt.find.active.as_ref() {
1090            self.status_message = Some(finding_status(&active.spec, Some(rows)));
1091        }
1092    }
1093
1094    /// A find answered. The cursor goes to the cell found, the view as it was.
1095    pub(crate) fn find_answered(&mut self, run: FindRun, current: bool, found: Option<Found>) {
1096        if !current {
1097            return;
1098        }
1099        // The line was the find's progress, which the job's own line no longer is.
1100        self.status_message = None;
1101        self.prompt.find.read = None;
1102        let Some(active) = self.prompt.find.active.as_mut() else {
1103            return;
1104        };
1105        let Some(state) = self.data_table_state.as_mut() else {
1106            return;
1107        };
1108        if active.dataset != run.dataset
1109            || active.frame != run.frame
1110            || state.len_generation() != run.frame
1111        {
1112            return;
1113        }
1114        let Some(found) = found else {
1115            active.hit = None;
1116            active.ordinal = None;
1117            let message = format!("No match for {}", active.spec.label());
1118            self.flash_note(message);
1119            return;
1120        };
1121        let same_cell = run.from_hit.is_some()
1122            && active
1123                .hit
1124                .as_ref()
1125                .is_some_and(|(row, name)| *row == found.row && *name == found.column);
1126        let before = run.from_hit.flatten();
1127        active.ordinal = match run.direction {
1128            _ if same_cell => before,
1129            // Round from the top, it is the first match in the view.
1130            Direction::Next if found.wrapped => Some(1),
1131            Direction::Next if run.from_hit.is_some() => before.map(|k| k + 1),
1132            Direction::Next => run.from_top.then_some(1),
1133            Direction::Previous if found.wrapped => None,
1134            Direction::Previous => before.and_then(|k| k.checked_sub(1)).filter(|k| *k > 0),
1135        };
1136        active.hit = Some((found.row, found.column.clone()));
1137        let needs_rows = state.go_to_found_row(found.row);
1138        // The cursor takes the found cell's column, scrolling as little as it takes.
1139        state.set_current_column(&found.column);
1140        if found.wrapped {
1141            self.flash_note(match run.direction {
1142                Direction::Next => "Wrapped to the top".to_string(),
1143                Direction::Previous => "Wrapped to the bottom".to_string(),
1144            });
1145        }
1146        if needs_rows {
1147            self.spawn_async_collect(Self::LOADING_BUFFER);
1148        }
1149    }
1150
1151    /// A find failed: the reason on the footer, and nothing moved.
1152    pub(crate) fn find_failed(&mut self, current: bool, message: &str) {
1153        if !current {
1154            return;
1155        }
1156        self.status_message = None;
1157        self.prompt.find.read = None;
1158        if message != CANCELLED {
1159            self.flash_note(format!("Find failed: {message}"));
1160        }
1161    }
1162}
1163
1164#[cfg(test)]
1165mod tests;
1166
1167/// The keys at the table, through the app.
1168#[cfg(test)]
1169mod app_tests;