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