Skip to main content

matchmaker/nucleo/
worker.rs

1// Original code from https://github.com/helix-editor/helix (MPL 2.0)
2// Modified by Squirreljetpack, 2025
3
4use super::{Line, Span, Style, Text};
5use bitflags::bitflags;
6use std::{
7    borrow::Cow,
8    mem::take,
9    sync::{
10        Arc,
11        atomic::{self, AtomicU32},
12    },
13};
14use unicode_segmentation::UnicodeSegmentation;
15use unicode_width::UnicodeWidthStr;
16
17use super::{injector::WorkerInjector, query::PickerQuery};
18use crate::{
19    SSS,
20    config::AutoscrollSettings,
21    nucleo::Render,
22    utils::text::{
23        hscroll_indicator, text_to_string, truncation_indicator, wrap_text, wrapping_indicator,
24    },
25};
26
27type ColumnFormatFn<T> = Box<dyn for<'a> Fn(&'a T) -> Text<'a> + Send + Sync>;
28pub struct Column<T> {
29    pub name: Arc<str>,
30    pub(super) format: ColumnFormatFn<T>,
31    /// Whether the column should be passed to nucleo for matching and filtering.
32    pub(super) filter: bool,
33}
34
35impl<T> Column<T> {
36    pub fn new_boxed(name: impl Into<Arc<str>>, format: ColumnFormatFn<T>) -> Self {
37        Self {
38            name: name.into(),
39            format,
40            filter: true,
41        }
42    }
43
44    pub fn new<F>(name: impl Into<Arc<str>>, f: F) -> Self
45    where
46        F: for<'a> Fn(&'a T) -> Text<'a> + SSS,
47    {
48        Self {
49            name: name.into(),
50            format: Box::new(f),
51            filter: true,
52        }
53    }
54
55    /// Disable filtering.
56    pub fn without_filtering(mut self) -> Self {
57        self.filter = false;
58        self
59    }
60
61    pub fn format<'a>(&self, item: &'a T) -> Text<'a> {
62        (self.format)(item)
63    }
64
65    // Note: the characters should match the output of [`Self::format`]
66    pub fn format_text<'a>(&self, item: &'a T) -> Cow<'a, str> {
67        Cow::Owned(text_to_string(&(self.format)(item)))
68    }
69}
70
71/// Worker: can instantiate, push, and get results. A view into computation.
72///
73/// Additionally, the worker can affect the computation via find and restart.
74pub struct Worker<T>
75where
76    T: SSS,
77{
78    /// The inner `Nucleo` fuzzy matcher.
79    pub nucleo: nucleo::Nucleo<T>,
80    /// The last pattern that was matched against.
81    pub query: PickerQuery,
82    /// A pre-allocated buffer used to collect match indices when fetching the results
83    /// from the matcher. This avoids having to re-allocate on each pass.
84    pub col_indices_buffer: Vec<u32>,
85    pub columns: Arc<[Column<T>]>,
86
87    // Background tasks which push to the injector check their version matches this or exit
88    pub(super) version: Arc<AtomicU32>,
89    // pub settings: WorkerSettings,
90    column_options: Vec<ColumnOptions>,
91}
92
93// #[derive(Debug, Default)]
94// pub struct WorkerSettings {
95//     pub stable: bool,
96// }
97
98bitflags! {
99    #[derive(Default, Clone, Debug)]
100    pub struct ColumnOptions: u8 {
101        const Optional = 1 << 0;
102        const OrUseDefault = 1 << 2;
103    }
104}
105
106impl<T> Worker<T>
107where
108    T: SSS,
109{
110    /// Column names must be distinct!
111    pub fn new(columns: impl IntoIterator<Item = Column<T>>, default_column: usize) -> Self {
112        let columns: Arc<[_]> = columns.into_iter().collect();
113        let matcher_columns = columns.iter().filter(|col| col.filter).count() as u32;
114
115        let inner = nucleo::Nucleo::new(
116            nucleo::Config::DEFAULT,
117            Arc::new(|| {}),
118            None,
119            matcher_columns,
120        );
121
122        Self {
123            nucleo: inner,
124            col_indices_buffer: Vec::with_capacity(128),
125            query: PickerQuery::new(columns.iter().map(|col| &col.name).cloned(), default_column),
126            column_options: vec![ColumnOptions::default(); columns.len()],
127            columns,
128            version: Arc::new(AtomicU32::new(0)),
129        }
130    }
131
132    #[cfg(feature = "experimental")]
133    pub fn set_column_options(&mut self, index: usize, options: ColumnOptions) {
134        if options.contains(ColumnOptions::Optional) {
135            self.nucleo
136                .pattern
137                .configure_column(index, nucleo::pattern::Variant::Optional)
138        }
139
140        self.column_options[index] = options
141    }
142
143    #[cfg(feature = "experimental")]
144    pub fn reverse_items(&mut self, reverse_items: bool) {
145        self.nucleo.reverse_items(reverse_items);
146    }
147
148    pub fn injector(&self) -> WorkerInjector<T> {
149        WorkerInjector {
150            inner: self.nucleo.injector(),
151            columns: self.columns.clone(),
152            version: self.version.load(atomic::Ordering::Relaxed),
153            picker_version: self.version.clone(),
154        }
155    }
156
157    pub fn find(&mut self, line: &str) {
158        let old_query = self.query.parse(line);
159        if self.query == old_query {
160            return;
161        }
162        for (i, column) in self
163            .columns
164            .iter()
165            .filter(|column| column.filter)
166            .enumerate()
167        {
168            let pattern = self
169                .query
170                .get(&column.name)
171                .map(|s| &**s)
172                .unwrap_or_else(|| {
173                    self.column_options[i]
174                        .contains(ColumnOptions::OrUseDefault)
175                        .then(|| self.query.primary_column_query())
176                        .flatten()
177                        .unwrap_or_default()
178                });
179
180            let old_pattern = old_query
181                .get(&column.name)
182                .map(|s| &**s)
183                .unwrap_or_else(|| {
184                    self.column_options[i]
185                        .contains(ColumnOptions::OrUseDefault)
186                        .then(|| {
187                            let name = self.query.primary_column_name()?;
188                            old_query.get(name).map(|s| &**s)
189                        })
190                        .flatten()
191                        .unwrap_or_default()
192                });
193
194            // Fastlane: most columns will remain unchanged after each edit.
195            if pattern == old_pattern {
196                continue;
197            }
198            let is_append = pattern.starts_with(old_pattern);
199
200            self.nucleo.pattern.reparse(
201                i,
202                pattern,
203                nucleo::pattern::CaseMatching::Smart,
204                nucleo::pattern::Normalization::Smart,
205                is_append,
206            );
207        }
208    }
209
210    // --------- UTILS
211    pub fn get_nth(&self, n: u32) -> Option<&T> {
212        self.nucleo
213            .snapshot()
214            .get_matched_item(n)
215            .map(|item| item.data)
216    }
217
218    pub fn new_snapshot(nucleo: &mut nucleo::Nucleo<T>) -> (&nucleo::Snapshot<T>, Status) {
219        let nucleo::Status { changed, running } = nucleo.tick(10);
220        let snapshot = nucleo.snapshot();
221        (
222            snapshot,
223            Status {
224                item_count: snapshot.item_count(),
225                matched_count: snapshot.matched_item_count(),
226                running,
227                changed,
228            },
229        )
230    }
231
232    pub fn raw_results(&self) -> impl ExactSizeIterator<Item = &T> + DoubleEndedIterator + '_ {
233        let snapshot = self.nucleo.snapshot();
234        snapshot.matched_items(..).map(|item| item.data)
235    }
236
237    /// matched item count, total item count
238    pub fn counts(&self) -> (u32, u32) {
239        let snapshot = self.nucleo.snapshot();
240        (snapshot.matched_item_count(), snapshot.item_count())
241    }
242
243    #[cfg(feature = "experimental")]
244    pub fn set_stability(&mut self, threshold: u32) {
245        self.nucleo.set_stability(threshold);
246    }
247
248    #[cfg(feature = "experimental")]
249    pub fn get_stability(&self) -> u32 {
250        self.nucleo.get_stability()
251    }
252
253    pub fn restart(&mut self, clear_snapshot: bool) {
254        self.nucleo.restart(clear_snapshot);
255    }
256}
257
258#[derive(Debug, Default, Clone)]
259pub struct Status {
260    pub item_count: u32,
261    pub matched_count: u32,
262    pub running: bool,
263    pub changed: bool,
264}
265
266#[derive(Debug, thiserror::Error)]
267pub enum WorkerError {
268    #[error("the matcher injector has been shut down")]
269    InjectorShutdown,
270    #[error("{0}")]
271    Custom(&'static str),
272}
273
274/// A vec of ItemResult, each ItemResult being the Column Texts of the Item, and Item
275pub type WorkerResults<'a, T> = Vec<(Vec<Text<'a>>, &'a T)>;
276
277impl<T: SSS> Worker<T> {
278    /// Returns:
279    /// 1. Table of (Row, item, height)
280    /// 2. Final column widths
281    /// 3. Status
282    ///
283    /// # Notes
284    /// - Final column width is at least header width
285    pub fn results(
286        &mut self,
287        start: u32,
288        end: u32,
289        width_limits: &[u16],
290        wrap: bool,
291        max_height: usize,
292        highlight_style: Style,
293        matcher: &mut nucleo::Matcher,
294        autoscroll: AutoscrollSettings,
295        hscroll_offset: i8,
296        vscroll: (u8, bool),
297        show_skipped: bool,
298    ) -> (WorkerResults<'_, T>, Vec<u16>, Status) {
299        let (snapshot, status) = Self::new_snapshot(&mut self.nucleo);
300
301        let mut widths = vec![0u16; self.columns.len()];
302
303        let iter =
304            snapshot.matched_items(start.min(status.matched_count)..end.min(status.matched_count));
305
306        let (vscroll_offset, stacked) = vscroll;
307
308        let table = iter
309            .filter_map(|item| {
310                let mut row = vec![];
311
312                let mut to_skip = vscroll_offset as usize;
313                let mut skip = !show_skipped;
314                for c in self.columns.iter() {
315                    let mut t = c.format(item.data);
316                    if stacked {
317                        if to_skip >= t.height() {
318                            to_skip -= t.height();
319                            t.lines.clear();
320                        } else {
321                            skip = false;
322                            t.lines.drain(..to_skip);
323                            to_skip = 0;
324                            if max_height > 0 && t.height() > max_height {
325                                t.lines.truncate(max_height);
326                                if let Some(last_line) = t.lines.last_mut() {
327                                    last_line.spans.push(truncation_indicator());
328                                }
329                            }
330                        }
331                    } else {
332                        if t.height() > to_skip {
333                            skip = false;
334                        }
335                        t.lines.drain(..to_skip);
336                        if max_height > 0 && t.height() > max_height {
337                            t.lines.truncate(max_height);
338                            if let Some(last_line) = t.lines.last_mut() {
339                                last_line.spans.push(truncation_indicator());
340                            }
341                        }
342                    }
343                    row.push(t);
344                }
345                if skip {
346                    return None;
347                }
348
349                let row: Vec<Text> = row
350                    .into_iter()
351                    .enumerate()
352                    .zip(width_limits.iter().chain(std::iter::repeat(&u16::MAX)))
353                    .map(|((col_idx, cell), &width_limit)| {
354                        let column = &self.columns[col_idx];
355
356                        let (cell, _) = if width_limit == 0 {
357                            (Default::default(), if wrap { 1 } else { cell.width() })
358                        } else if column.filter {
359                            render_cell(
360                                cell,
361                                col_idx,
362                                snapshot,
363                                &item,
364                                matcher,
365                                highlight_style,
366                                wrap,
367                                width_limit,
368                                &mut self.col_indices_buffer,
369                                autoscroll,
370                                hscroll_offset,
371                            )
372                        } else if wrap {
373                            let (cell, wrapped) = wrap_text(cell, width_limit);
374
375                            let width = if wrapped {
376                                width_limit as usize
377                            } else {
378                                cell.width()
379                            };
380                            (cell, width)
381                        } else {
382                            let width = cell.width();
383                            (cell, width)
384                        };
385
386                        cell
387                    })
388                    .collect();
389
390                // update col width, row height
391                for (w, cell) in widths.iter_mut().zip(&row) {
392                    let width = cell.width() as u16;
393                    if width > *w {
394                        *w = width;
395                    }
396                }
397
398                Some((row, item.data))
399            })
400            .collect();
401
402        // Nonempty columns should have width at least their header
403        for (w, c) in widths.iter_mut().zip(self.columns.iter()) {
404            let name_width = c.name.width() as u16;
405            if *w != 0 {
406                *w = (*w).max(name_width);
407            }
408        }
409
410        (table, widths, status)
411    }
412
413    pub fn exact_column_match(&mut self, column: &str) -> Option<&T> {
414        let (i, col) = self
415            .columns
416            .iter()
417            .enumerate()
418            .find(|(_, c)| column == &*c.name)?;
419
420        let query = self.query.get(column).map(|s| &**s).or_else(|| {
421            self.column_options[i]
422                .contains(ColumnOptions::OrUseDefault)
423                .then(|| self.query.primary_column_query())
424                .flatten()
425        })?;
426
427        let snapshot = self.nucleo.snapshot();
428        snapshot.matched_items(..).find_map(|item| {
429            let content = col.format_text(item.data);
430            if content.as_str() == query {
431                Some(item.data)
432            } else {
433                None
434            }
435        })
436    }
437
438    pub fn format_with<'a>(&'a self, item: &'a T, col: &str) -> Option<Cow<'a, str>> {
439        self.columns
440            .iter()
441            .find(|c| &*c.name == col)
442            .map(|c| c.format_text(item))
443    }
444}
445
446fn render_cell<T: SSS>(
447    cell: Text<'_>,
448    col_idx: usize,
449    snapshot: &nucleo::Snapshot<T>,
450    item: &nucleo::Item<T>,
451    matcher: &mut nucleo::Matcher,
452    highlight_style: Style,
453    wrap: bool,
454    width_limit: u16,
455    col_indices_buffer: &mut Vec<u32>,
456    mut autoscroll: AutoscrollSettings,
457    hscroll_offset: i8,
458) -> (Text<'static>, usize) {
459    // disable right autoscroll if wrap
460    autoscroll.end &= !wrap;
461
462    let mut cell_width = 0;
463    let mut wrapped = false;
464
465    // get indices
466    let indices_buffer = col_indices_buffer;
467    indices_buffer.clear();
468    snapshot.pattern().column_pattern(col_idx).indices(
469        item.matcher_columns[col_idx].slice(..),
470        matcher,
471        indices_buffer,
472    );
473    indices_buffer.sort_unstable();
474    indices_buffer.dedup();
475    let mut indices = indices_buffer.drain(..);
476
477    let mut lines = vec![];
478    let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX);
479    let mut grapheme_idx = 0u32;
480
481    let mut line_graphemes = Vec::new();
482
483    for line in &cell {
484        // 1: Collect graphemes, compute styles, and find the relevant match on this line.
485        line_graphemes.clear();
486        let mut match_idx = None;
487
488        for span in line {
489            // this looks like a bug on first glance, we are iterating
490            // graphemes but treating them as char indices. The reason that
491            // this is correct is that nucleo will only ever consider the first char
492            // of a grapheme (and discard the rest of the grapheme) so the indices
493            // returned by nucleo are essentially grapheme indecies
494            for grapheme in span.content.graphemes(true) {
495                let is_match = grapheme_idx == next_highlight_idx;
496
497                let style = if is_match {
498                    next_highlight_idx = indices.next().unwrap_or(u32::MAX);
499                    span.style.patch(highlight_style)
500                } else {
501                    span.style
502                };
503
504                if is_match && (autoscroll.end || match_idx.is_none()) {
505                    match_idx = Some(line_graphemes.len());
506                }
507
508                line_graphemes.push((grapheme, style));
509                grapheme_idx += 1;
510            }
511        }
512
513        // 2: Calculate where to start rendering this line
514        let mut i; // start_idx
515
516        if autoscroll.enabled && autoscroll.end {
517            i = match_idx.unwrap_or(line_graphemes.len());
518
519            let target_width = if let Some(x) = match_idx {
520                (width_limit as usize)
521                    .saturating_sub(autoscroll.context.min(line_graphemes.len() - x - 1))
522            } else {
523                width_limit as usize
524            }
525            .saturating_sub(1);
526
527            let mut current_width = 0;
528
529            while i > 0 {
530                let w = line_graphemes[i - 1].0.width();
531                if current_width + w > target_width {
532                    break;
533                }
534                i -= 1;
535                current_width += w;
536            }
537            if i > 1 {
538                i += 1;
539            } else {
540                i = 0;
541            }
542        } else if autoscroll.enabled
543            && let Some(m_idx) = match_idx
544        {
545            i = (m_idx as i32 + hscroll_offset as i32 - autoscroll.context as i32).max(0) as usize;
546
547            let mut tail_width: usize = line_graphemes[i..].iter().map(|(g, _)| g.width()).sum();
548
549            let preserved_width = line_graphemes
550                [..autoscroll.initial_preserved.min(line_graphemes.len())]
551                .iter()
552                .map(|(g, _)| g.width())
553                .sum::<usize>();
554
555            // Expand leftwards as long as the total rendered width <= width_limit
556            while i > autoscroll.initial_preserved {
557                let prev_width = line_graphemes[i - 1].0.width();
558                if tail_width + preserved_width + 1 + prev_width <= width_limit as usize {
559                    i -= 1;
560                    tail_width += prev_width;
561                } else {
562                    break;
563                }
564            }
565
566            if i <= autoscroll.initial_preserved + 1 {
567                i = 0;
568            }
569        } else {
570            i = hscroll_offset.max(0) as usize;
571        };
572
573        // 3: Apply the standard wrapping and Span generation logic to the visible slice
574        let mut current_spans = Vec::new();
575        let mut current_span = String::new();
576        let mut current_style = Style::default();
577        let mut current_width = 0;
578
579        // Add preserved prefix and ellipsis if needed
580        if i > 0 && autoscroll.enabled {
581            let preserved = autoscroll.initial_preserved;
582            for (g, s) in line_graphemes.drain(..preserved) {
583                if s != current_style {
584                    if !current_span.is_empty() {
585                        current_spans.push(Span::styled(current_span, current_style));
586                    }
587                    current_span = String::new();
588                    current_style = s;
589                }
590                current_span.push_str(g);
591            }
592            if !current_span.is_empty() {
593                current_spans.push(Span::styled(current_span, current_style));
594            }
595            i -= preserved;
596
597            current_width += current_spans.iter().map(|x| x.width()).sum::<usize>();
598            current_spans.push(hscroll_indicator());
599            current_width += 1;
600
601            current_span = String::new();
602            current_style = Style::default();
603        }
604
605        let full_line_width = (!wrap).then(|| {
606            current_width
607                + line_graphemes[i..]
608                    .iter()
609                    .map(|(g, _)| g.width())
610                    .sum::<usize>()
611        });
612
613        let mut graphemes = line_graphemes.drain(i..);
614
615        while let Some((mut grapheme, mut style)) = graphemes.next() {
616            if current_width + grapheme.width() > width_limit as usize {
617                if !current_span.is_empty() {
618                    current_spans.push(Span::styled(current_span, current_style));
619                    current_span = String::new();
620                }
621                if wrap {
622                    current_spans.push(wrapping_indicator());
623                    lines.push(Line::from(take(&mut current_spans)));
624
625                    current_width = 0;
626                    wrapped = true;
627                } else {
628                    break;
629                }
630            } else if current_width + grapheme.width() == width_limit as usize {
631                if wrap {
632                    let mut new = grapheme.to_string();
633                    if current_style != style {
634                        current_spans.push(Span::styled(take(&mut current_span), current_style));
635                        current_style = style;
636                    };
637                    while let Some((grapheme2, style2)) = graphemes.next() {
638                        if grapheme2.width() == 0 {
639                            new.push_str(grapheme2);
640                        } else {
641                            if !current_span.is_empty() {
642                                current_spans.push(Span::styled(current_span, current_style));
643                            }
644                            current_spans.push(wrapping_indicator());
645                            lines.push(Line::from(take(&mut current_spans)));
646
647                            // new line starts from last char
648                            current_span = new.clone(); // rust can't tell that clone is unnecessary here
649                            current_width = grapheme.width();
650                            wrapped = true;
651
652                            grapheme = grapheme2;
653                            style = style2;
654                            break; // continue normal processing
655                        }
656                    }
657                    if !wrapped {
658                        current_span.push_str(&new);
659                        // we reached the end of the line exactly, end line
660                        current_spans.push(Span::styled(take(&mut current_span), style));
661                        current_style = style;
662                        current_width += grapheme.width();
663                        break;
664                    }
665                } else {
666                    if style != current_style {
667                        if !current_span.is_empty() {
668                            current_spans.push(Span::styled(current_span, current_style));
669                        }
670                        current_span = String::new();
671                        current_style = style;
672                    }
673                    current_span.push_str(grapheme);
674                    current_width += grapheme.width();
675                    break;
676                }
677            }
678
679            // normal processing
680            if style != current_style {
681                if !current_span.is_empty() {
682                    current_spans.push(Span::styled(current_span, current_style))
683                }
684                current_span = String::new();
685                current_style = style;
686            }
687            current_span.push_str(grapheme);
688            current_width += grapheme.width();
689        }
690
691        current_spans.push(Span::styled(current_span, current_style));
692        lines.push(Line::from(current_spans));
693        cell_width = cell_width.max(full_line_width.unwrap_or(current_width));
694
695        grapheme_idx += 1; // newline
696    }
697
698    (
699        Text::from(lines),
700        if wrapped {
701            width_limit as usize
702        } else {
703            cell_width
704        },
705    )
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use nucleo::{Matcher, Nucleo};
712    use ratatui::style::{Color, Style};
713    use ratatui::text::Text;
714    use std::sync::Arc;
715
716    /// Sets up the necessary Nucleo state to trigger a match
717    fn setup_nucleo_mocks(
718        search_query: &str,
719        item_text: &str,
720    ) -> (Nucleo<String>, Matcher, Vec<u32>) {
721        let mut nucleo = Nucleo::<String>::new(nucleo::Config::DEFAULT, Arc::new(|| {}), None, 1);
722
723        let injector = nucleo.injector();
724        injector.push(item_text.to_string(), |item, columns| {
725            columns[0] = item.clone().into();
726        });
727
728        nucleo.pattern.reparse(
729            0,
730            search_query,
731            nucleo::pattern::CaseMatching::Ignore,
732            nucleo::pattern::Normalization::Smart,
733            false,
734        );
735
736        nucleo.tick(10); // Process the item
737
738        let matcher = Matcher::default();
739        let buffer = Vec::new();
740
741        (nucleo, matcher, buffer)
742    }
743
744    #[test]
745    fn test_no_scroll_context_renders_normally() {
746        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("match", "hello match world");
747        let snapshot = nucleo.snapshot();
748        let item = snapshot.get_item(0).unwrap();
749
750        let cell = Text::from("hello match world");
751        let highlight = Style::default().fg(Color::Red);
752
753        let (result_text, width) = render_cell(
754            cell,
755            0,
756            &snapshot,
757            &item,
758            &mut matcher,
759            highlight,
760            false,
761            u16::MAX,
762            &mut buffer,
763            AutoscrollSettings {
764                enabled: false,
765                ..Default::default()
766            },
767            0,
768        );
769
770        let output_str = text_to_string(&result_text);
771        assert_eq!(output_str, "hello match world");
772        assert_eq!(width, 17);
773    }
774
775    #[test]
776    fn test_scroll_context_cuts_prefix_correctly() {
777        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("match", "hello match world");
778        let snapshot = nucleo.snapshot();
779        let item = snapshot.get_item(0).unwrap();
780
781        let cell = Text::from("hello match world");
782        let highlight = Style::default().fg(Color::Red);
783
784        let (result_text, _) = render_cell(
785            cell,
786            0,
787            &snapshot,
788            &item,
789            &mut matcher,
790            highlight,
791            false,
792            u16::MAX,
793            &mut buffer,
794            AutoscrollSettings {
795                initial_preserved: 0,
796                context: 2,
797                ..Default::default()
798            },
799            0,
800        );
801
802        let output_str = text_to_string(&result_text);
803        assert_eq!(output_str, "hello match world");
804    }
805
806    #[test]
807    fn test_scroll_context_backfills_to_fill_width_limit() {
808        // Query "match". Starts at index 10.
809        // "abcdefghijmatch"
810        // autoscroll = Some((preserved=0, context=1))
811        // initial_start_idx = 10 + 0 - 1 = 9 ("jmatch").
812        // width_limit = 10.
813        // tail_width ("jmatch") = 6.
814        // Try to decrease start_idx.
815        // start_idx=8 ("ijmatch"), tail_width=7.
816        // start_idx=7 ("hijmatch"), tail_width=8.
817        // start_idx=6 ("ghijmatch"), tail_width=9.
818        // start_idx=5 ("fghijmatch"), tail_width=10.
819        // start_idx=4 ("efghijmatch"), tail_width=11 > 10 (STOP).
820        // Result start_idx = 5. Output: "fghijmatch"
821
822        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("match", "abcdefghijmatch");
823        let snapshot = nucleo.snapshot();
824        let item = snapshot.get_item(0).unwrap();
825
826        let cell = Text::from("abcdefghijmatch");
827        let highlight = Style::default().fg(Color::Red);
828
829        let (result_text, width) = render_cell(
830            cell,
831            0,
832            &snapshot,
833            &item,
834            &mut matcher,
835            highlight,
836            false,
837            10,
838            &mut buffer,
839            AutoscrollSettings {
840                initial_preserved: 0,
841                context: 1,
842                ..Default::default()
843            },
844            0,
845        );
846
847        let output_str = text_to_string(&result_text);
848        assert_eq!(output_str, "…ghijmatch");
849        assert_eq!(width, 10);
850    }
851
852    #[test]
853    fn test_preserved_prefix_and_ellipsis() {
854        // Query "match". Starts at index 10.
855        // "abcdefghijmatch"
856        // autoscroll = Some((preserved=3, context=1))
857        // initial_start_idx = 10 + 0 - 1 = 9.
858        // start_idx = 9.
859        // width_limit = 10.
860        // preserved_width ("abc") = 3.
861        // gap_indicator_width ("…") = 1.
862        // tail_width ("jmatch") = 6.
863        // total = 3 + 1 + 6 = 10.
864        // start_idx=9, preserved=3. 9 > 3 + 1 (9 > 4) -> preserved_prefix = "abc", output: "abc…jmatch"
865
866        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("match", "abcdefghijmatch");
867        let snapshot = nucleo.snapshot();
868        let item = snapshot.get_item(0).unwrap();
869
870        let cell = Text::from("abcdefghijmatch");
871        let highlight = Style::default().fg(Color::Red);
872
873        let (result_text, width) = render_cell(
874            cell,
875            0,
876            &snapshot,
877            &item,
878            &mut matcher,
879            highlight,
880            false,
881            10,
882            &mut buffer,
883            AutoscrollSettings {
884                initial_preserved: 3,
885                context: 1,
886                ..Default::default()
887            },
888            0,
889        );
890
891        let output_str = text_to_string(&result_text);
892        assert_eq!(output_str, "abc…jmatch");
893        assert_eq!(width, 10);
894    }
895
896    #[test]
897    fn test_wrap() {
898        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("match", "abcdefmatch");
899        let snapshot = nucleo.snapshot();
900        let item = snapshot.get_item(0).unwrap();
901
902        let cell = Text::from("abcdefmatch");
903        let highlight = Style::default().fg(Color::Red);
904
905        let (result_text, width) = render_cell(
906            cell,
907            0,
908            &snapshot,
909            &item,
910            &mut matcher,
911            highlight,
912            true,
913            10,
914            &mut buffer,
915            AutoscrollSettings {
916                initial_preserved: 3,
917                context: 1,
918                ..Default::default()
919            },
920            -2,
921        );
922
923        let output_str = text_to_string(&result_text);
924        assert_eq!(output_str, "abcdefmat↵\nch");
925        assert_eq!(width, 10);
926    }
927
928    #[test]
929    fn test_wrap_edge_case_6_chars_width_5() {
930        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("", "123456");
931        let snapshot = nucleo.snapshot();
932        let item = snapshot.get_item(0).unwrap();
933
934        let cell = Text::from("123456");
935        let highlight = Style::default().fg(Color::Red);
936
937        let (result_text, width) = render_cell(
938            cell,
939            0,
940            &snapshot,
941            &item,
942            &mut matcher,
943            highlight,
944            true,
945            5,
946            &mut buffer,
947            AutoscrollSettings {
948                enabled: false,
949                ..Default::default()
950            },
951            0,
952        );
953
954        let output_str = text_to_string(&result_text);
955        // Expecting "1234↵" and "56"
956        assert_eq!(output_str, "1234↵\n56");
957        assert_eq!(width, 5);
958    }
959
960    #[test]
961    fn test_autoscroll_end() {
962        let (nucleo, mut matcher, mut buffer) = setup_nucleo_mocks("match", "abcdefghijmatch");
963        let snapshot = nucleo.snapshot();
964        let item = snapshot.get_item(0).unwrap();
965
966        let cell = Text::from("abcdefghijmatch");
967        let highlight = Style::default().fg(Color::Red);
968
969        let (result_text, width) = render_cell(
970            cell,
971            0,
972            &snapshot,
973            &item,
974            &mut matcher,
975            highlight,
976            false,
977            10,
978            &mut buffer,
979            AutoscrollSettings {
980                end: true,
981                context: 4,
982                ..Default::default()
983            },
984            0,
985        );
986
987        let output_str = text_to_string(&result_text);
988        assert_eq!(output_str, "…ghijmatch");
989        assert_eq!(width, 10);
990    }
991}