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