Skip to main content

rich_interact/components/
jump.rs

1//! A jump-to-anything navigator (#484): one fuzzy box over several
2//! sources at once (files, headings, symbols, commands, recent places),
3//! with the results grouped by source and a preview of the focused one.
4//!
5//! A [`JumpIndex`] is the searchable index: [`JumpSource`]s of
6//! [`Item`]s, each with an id, a title and an optional prefix character.
7//! [`JumpIndex::search`] ranks every source's items with the
8//! [fuzzy](crate::fuzzy) matcher and returns [`JumpGroup`]s, so the index
9//! works without the component too (a command palette of your own, a
10//! test). [`Jump`] is the component over it: type to search, ↑/↓ move
11//! across the groups, Alt+↑/↓ jump between groups, Tab narrows the search
12//! to one source and Shift+Tab goes back, and Enter returns a
13//! [`Jumped`]: the source's id and the item's value. A query that starts
14//! with a source's prefix (`>` for commands, `@` for symbols, say)
15//! searches only that source. Without a narrowed source, each group shows
16//! its best few (its [`limit`](JumpSource::limit)) and says how many more
17//! there are.
18//!
19//! Group titles are drawn in the console theme's `jump.group` style, and
20//! the count of more results in `jump.more` (bold and dim unless set).
21//!
22//! With the mouse on, a click focuses a result and a second click picks
23//! it, a click on a group's title narrows to it, and the wheel moves.
24//!
25//! Without a terminal it reads one line and answers with the best match
26//! for it; asked for a default, it answers with the best match for the
27//! query it started with, if any.
28//!
29//! ```
30//! use rich_interact::components::{Jump, JumpSource};
31//! use rich_interact::{headless, Item, Outcome};
32//!
33//! let jump = Jump::new("Jump")
34//!     .source(JumpSource::new("files", "Files").items(["src/main.rs".to_string(), "README.md".into()]))
35//!     .source(
36//!         JumpSource::new("commands", "Commands")
37//!             .prefix('>')
38//!             .item(Item::new("cargo build".to_string(), "Build")),
39//!     );
40//! let (outcome, _) = headless::run(jump, headless::Script::new().text(">bu").keys("enter"), 80, 12);
41//! let Outcome::Done(jumped) = outcome.unwrap() else { panic!() };
42//! assert_eq!((jumped.source.as_str(), jumped.value.as_str()), ("commands", "cargo build"));
43//! ```
44
45use std::cell::Cell;
46
47use rich::{Segment, Style};
48
49use crate::component::{Component, Context, Flow, View};
50use crate::components::{PreviewLayout, Theme, fit, highlight, pad, pasted, plain, question, text};
51use crate::event::{Button, Event, KeyCode, Modifiers, MouseKind};
52use crate::fuzzy::rank;
53use crate::item::Item;
54use crate::keymap::{Keymap, keys};
55use crate::policy::{LineIo, NotInteractive};
56
57/// One source of a [`JumpIndex`]: files, headings, commands.
58#[derive(Clone, Debug)]
59pub struct JumpSource<T> {
60    /// What the caller matches on: `files`.
61    pub id: String,
62    /// What the group is titled: `Files`.
63    pub title: String,
64    /// A character that, typed first, searches only this source.
65    pub prefix: Option<char>,
66    /// The most results the group shows while the search is over every
67    /// source (default 5); narrowed to this source, all show.
68    pub limit: usize,
69    pub items: Vec<Item<T>>,
70}
71
72impl<T> JumpSource<T> {
73    pub fn new(id: impl Into<String>, title: impl Into<String>) -> JumpSource<T> {
74        JumpSource {
75            id: id.into(),
76            title: title.into(),
77            prefix: None,
78            limit: 5,
79            items: Vec::new(),
80        }
81    }
82
83    /// Typed first, `prefix` searches only this source.
84    #[must_use]
85    pub fn prefix(mut self, prefix: char) -> Self {
86        self.prefix = Some(prefix);
87        self
88    }
89
90    /// Show at most `results` while every source is searched.
91    #[must_use]
92    pub fn limit(mut self, results: usize) -> Self {
93        self.limit = results.max(1);
94        self
95    }
96
97    #[must_use]
98    pub fn item(mut self, item: impl Into<Item<T>>) -> Self {
99        self.items.push(item.into());
100        self
101    }
102
103    #[must_use]
104    pub fn items<I, V>(mut self, items: I) -> Self
105    where
106        I: IntoIterator<Item = V>,
107        V: Into<Item<T>>,
108    {
109        self.items.extend(items.into_iter().map(Into::into));
110        self
111    }
112}
113
114/// One result: an item of a source, how well it matched, and the label
115/// characters to highlight.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct JumpHit {
118    pub item: usize,
119    pub score: i64,
120    pub positions: Vec<usize>,
121}
122
123/// One source's results, best first.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct JumpGroup {
126    /// The source's position in the index.
127    pub source: usize,
128    /// The results shown.
129    pub hits: Vec<JumpHit>,
130    /// How many more matched than are shown.
131    pub more: usize,
132}
133
134/// A searchable index over several sources: what [`Jump`] searches.
135#[derive(Clone, Debug)]
136pub struct JumpIndex<T> {
137    pub sources: Vec<JumpSource<T>>,
138}
139
140impl<T> Default for JumpIndex<T> {
141    fn default() -> Self {
142        JumpIndex {
143            sources: Vec::new(),
144        }
145    }
146}
147
148impl<T> JumpIndex<T> {
149    pub fn new() -> JumpIndex<T> {
150        JumpIndex::default()
151    }
152
153    #[must_use]
154    pub fn source(mut self, source: JumpSource<T>) -> Self {
155        self.sources.push(source);
156        self
157    }
158
159    /// The source a query names with its first character, and the rest of
160    /// the query: `>bu` is `bu` in the source whose prefix is `>`.
161    pub fn scope<'q>(&self, query: &'q str) -> (Option<usize>, &'q str) {
162        let mut chars = query.chars();
163        if let Some(first) = chars.next()
164            && let Some(at) = self.sources.iter().position(|s| s.prefix == Some(first))
165        {
166            return (Some(at), chars.as_str().trim_start());
167        }
168        (None, query)
169    }
170
171    /// Search for `query` in every source, or in `only` (as a typed
172    /// prefix does too): the groups that match, in the sources' order, each
173    /// cut to its limit unless one source is searched.
174    pub fn search(&self, query: &str, only: Option<usize>) -> Vec<JumpGroup> {
175        let (named, query) = self.scope(query);
176        let only = named.or(only);
177        let mut groups = Vec::new();
178        for (at, source) in self.sources.iter().enumerate() {
179            if only.is_some_and(|only| only != at) {
180                continue;
181            }
182            let texts: Vec<String> = source.items.iter().map(Item::search_text).collect();
183            let mut hits: Vec<JumpHit> = rank(query, texts.iter().map(String::as_str))
184                .into_iter()
185                .map(|(item, found)| {
186                    let limit = source.items[item].label.chars().count();
187                    JumpHit {
188                        item,
189                        score: found.score,
190                        positions: found.positions.into_iter().filter(|p| *p < limit).collect(),
191                    }
192                })
193                .collect();
194            if hits.is_empty() {
195                continue;
196            }
197            let shown = if only.is_some() {
198                hits.len()
199            } else {
200                source.limit.min(hits.len())
201            };
202            let more = hits.len() - shown;
203            hits.truncate(shown);
204            groups.push(JumpGroup {
205                source: at,
206                hits,
207                more,
208            });
209        }
210        groups
211    }
212
213    /// The best match for `query` over every source: `(source, item)`.
214    pub fn best(&self, query: &str) -> Option<(usize, usize)> {
215        self.search(query, None)
216            .iter()
217            .flat_map(|group| group.hits.iter().map(move |hit| (group.source, hit)))
218            .max_by(|a, b| a.1.score.cmp(&b.1.score).then(b.0.cmp(&a.0)))
219            .map(|(source, hit)| (source, hit.item))
220    }
221}
222
223/// What [`Jump`] returns: where to go.
224#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct Jumped<T> {
226    /// The source's id.
227    pub source: String,
228    /// The item's index in its source.
229    pub index: usize,
230    pub label: String,
231    pub value: T,
232}
233
234/// The keys of a [`Jump`], in context `jump`.
235pub fn jump_keymap() -> Keymap {
236    Keymap::new("jump")
237        .bind("pick", keys("enter"), "jump")
238        .bind("cancel", keys("escape"), "cancel")
239        .bind("up", keys("up ctrl+p"), "move up")
240        .bind("down", keys("down ctrl+n"), "move down")
241        .bind("page-up", keys("pageup"), "page up")
242        .bind("page-down", keys("pagedown"), "page down")
243        .bind("first", keys("home"), "first result")
244        .bind("last", keys("end"), "last result")
245        .bind("previous-group", keys("alt+up"), "previous group")
246        .bind("next-group", keys("alt+down"), "next group")
247        .bind("narrow", keys("tab"), "search the next source only")
248        .bind(
249            "widen",
250            keys("shift+tab"),
251            "search the previous source, or all",
252        )
253        .bind("clear", keys("ctrl+u"), "clear the query")
254        .bind("delete", keys("backspace"), "delete a character")
255}
256
257/// A row of the result list.
258#[derive(Clone, Debug, PartialEq, Eq)]
259enum Row {
260    /// A group's title: its source, and how many more there are.
261    Title(usize, usize),
262    /// A result: source, item, positions to highlight.
263    Hit(usize, usize, Vec<usize>),
264}
265
266/// One fuzzy box over several sources, the results grouped by source.
267/// Returns a [`Jumped`].
268#[must_use = "a component does nothing until it is run or mounted"]
269pub struct Jump<T> {
270    prompt: String,
271    index: JumpIndex<T>,
272    query: String,
273    /// Tab's narrowing: one source searched.
274    only: Option<usize>,
275    rows: Vec<Row>,
276    /// The cursor, a row that is a result.
277    cursor: usize,
278    offset: usize,
279    height: usize,
280    preview: PreviewLayout,
281    preview_height: usize,
282    theme: Theme,
283    mouse: bool,
284    keymap: Keymap,
285    clicked: Option<usize>,
286    answer: Option<Option<String>>,
287    space: Cell<usize>,
288}
289
290impl<T> Jump<T> {
291    pub fn new(prompt: impl Into<String>) -> Jump<T> {
292        Jump {
293            prompt: prompt.into(),
294            index: JumpIndex::new(),
295            query: String::new(),
296            only: None,
297            rows: Vec::new(),
298            cursor: 0,
299            offset: 0,
300            height: 12,
301            preview: PreviewLayout::Auto,
302            preview_height: 8,
303            theme: Theme::default(),
304            mouse: false,
305            keymap: jump_keymap(),
306            clicked: None,
307            answer: None,
308            space: Cell::new(usize::MAX),
309        }
310    }
311
312    /// Search `index`.
313    pub fn index(mut self, index: JumpIndex<T>) -> Self {
314        self.index = index;
315        self.only = None;
316        self.refresh();
317        self
318    }
319
320    /// Add `source` to the index.
321    pub fn source(mut self, source: JumpSource<T>) -> Self {
322        self.index.sources.push(source);
323        self.refresh();
324        self
325    }
326
327    /// Start with `query` typed.
328    pub fn query(mut self, query: impl Into<String>) -> Self {
329        self.query = query.into();
330        self.refresh();
331        self
332    }
333
334    /// Show at most `rows` rows of results, titles included (default 12).
335    pub fn height(mut self, rows: usize) -> Self {
336        self.height = rows.max(1);
337        self
338    }
339
340    pub fn preview(mut self, layout: PreviewLayout) -> Self {
341        self.preview = layout;
342        self
343    }
344
345    /// Rows the preview takes when it is below the list (default 8).
346    pub fn preview_height(mut self, rows: usize) -> Self {
347        self.preview_height = rows.max(1);
348        self
349    }
350
351    pub fn theme(mut self, theme: Theme) -> Self {
352        self.theme = theme;
353        self
354    }
355
356    /// Report the mouse (#476): a click focuses a result and a second one
357    /// picks it, a click on a group's title narrows to it.
358    pub fn with_mouse(mut self, on: bool) -> Self {
359        self.mouse = on;
360        self
361    }
362
363    /// Make `keys` do `action` (see [`jump_keymap`]) here only.
364    pub fn rebind(
365        mut self,
366        action: &str,
367        keys: impl IntoIterator<Item = crate::event::Key>,
368    ) -> Self {
369        self.keymap.rebind(action, keys);
370        self
371    }
372
373    pub fn search_index(&self) -> &JumpIndex<T> {
374        &self.index
375    }
376
377    /// What is typed.
378    pub fn query_text(&self) -> &str {
379        &self.query
380    }
381
382    /// The source searched alone, by a prefix typed or by Tab.
383    pub fn narrowed(&self) -> Option<usize> {
384        self.index.scope(&self.query).0.or(self.only)
385    }
386
387    /// The focused result: `(source, item)`.
388    pub fn focused(&self) -> Option<(usize, usize)> {
389        match self.rows.get(self.cursor)? {
390            Row::Hit(source, item, _) => Some((*source, *item)),
391            Row::Title(..) => None,
392        }
393    }
394
395    /// How many results are listed.
396    pub fn result_count(&self) -> usize {
397        self.rows
398            .iter()
399            .filter(|row| matches!(row, Row::Hit(..)))
400            .count()
401    }
402
403    /// Search again and put the cursor on the best result.
404    fn refresh(&mut self) {
405        let groups = self.index.search(&self.query, self.only);
406        let mut best: Option<(i64, usize)> = None;
407        self.rows.clear();
408        for group in groups {
409            self.rows.push(Row::Title(group.source, group.more));
410            for hit in group.hits {
411                if best.is_none_or(|(score, _)| hit.score > score) {
412                    best = Some((hit.score, self.rows.len()));
413                }
414                self.rows
415                    .push(Row::Hit(group.source, hit.item, hit.positions));
416            }
417        }
418        self.cursor = best.map_or(0, |(_, row)| row);
419        // With nothing typed, the first result is the place to start.
420        if self.query.is_empty() {
421            self.cursor = self.first_hit_from(0, 1).unwrap_or(0);
422        }
423        self.offset = 0;
424        self.clicked = None;
425        self.follow();
426    }
427
428    /// The first result row from `row` going `direction` (±1).
429    fn first_hit_from(&self, row: usize, direction: isize) -> Option<usize> {
430        let mut at = row as isize;
431        while at >= 0 && (at as usize) < self.rows.len() {
432            if matches!(self.rows[at as usize], Row::Hit(..)) {
433                return Some(at as usize);
434            }
435            at += direction;
436        }
437        None
438    }
439
440    /// Rows for results: the height asked for, cut to the terminal beside
441    /// the question and the footer.
442    fn page(&self) -> usize {
443        self.height.min(self.space.get().saturating_sub(2)).max(1)
444    }
445
446    fn follow(&mut self) {
447        let page = self.page();
448        // A group's title shows above its first result.
449        let top = match self.cursor.checked_sub(1).map(|row| &self.rows[row]) {
450            Some(Row::Title(..)) => self.cursor - 1,
451            _ => self.cursor,
452        };
453        if top < self.offset {
454            self.offset = top;
455        } else if self.cursor >= self.offset + page {
456            self.offset = self.cursor + 1 - page;
457        }
458    }
459
460    /// Move by `delta` results, stopping at the ends.
461    fn step(&mut self, delta: isize) {
462        let hits: Vec<usize> = (0..self.rows.len())
463            .filter(|&row| matches!(self.rows[row], Row::Hit(..)))
464            .collect();
465        let Some(at) = hits.iter().position(|&row| row == self.cursor) else {
466            return;
467        };
468        let to = at.saturating_add_signed(delta).min(hits.len() - 1);
469        self.cursor = hits[to];
470        self.follow();
471    }
472
473    /// To the first result of the next (or previous) group.
474    fn jump_group(&mut self, forward: bool) {
475        let titles: Vec<usize> = (0..self.rows.len())
476            .filter(|&row| matches!(self.rows[row], Row::Title(..)))
477            .collect();
478        let current = titles.iter().rposition(|&title| title < self.cursor);
479        let target = match (current, forward) {
480            (Some(at), true) => titles.get(at + 1),
481            (Some(at), false) if at > 0 => titles.get(at - 1),
482            _ => None,
483        };
484        if let Some(&title) = target
485            && let Some(hit) = self.first_hit_from(title, 1)
486        {
487            self.cursor = hit;
488            self.follow();
489        }
490    }
491
492    /// Narrow to the next source (`forward`) or the previous, through
493    /// "every source" at either end.
494    fn narrow(&mut self, forward: bool) {
495        let count = self.index.sources.len();
496        if count == 0 {
497            return;
498        }
499        // A typed prefix gives way to Tab's choice.
500        if let (Some(_), rest) = self.index.scope(&self.query) {
501            self.query = rest.to_string();
502        }
503        self.only = match (self.only, forward) {
504            (None, true) => Some(0),
505            (None, false) => Some(count - 1),
506            (Some(at), true) => (at + 1 < count).then_some(at + 1),
507            (Some(at), false) => at.checked_sub(1),
508        };
509        self.refresh();
510    }
511
512    fn pick(&mut self) -> Option<(usize, usize)> {
513        let (source, item) = self.focused()?;
514        let label = &self.index.sources[source].items[item].label;
515        self.answer = Some(Some(label.clone()));
516        Some((source, item))
517    }
518
519    fn mouse_event(
520        &mut self,
521        kind: MouseKind,
522        column: usize,
523        row: usize,
524        width: usize,
525    ) -> Flow<(usize, usize)> {
526        let (layout, left) = self.layout(width);
527        match kind {
528            MouseKind::ScrollUp => self.step(-1),
529            MouseKind::ScrollDown => self.step(1),
530            MouseKind::Down(Button::Left) => {
531                if row == 0
532                    || row > self.page()
533                    || (layout == PreviewLayout::Right && column >= left)
534                {
535                    return Flow::Continue;
536                }
537                let at = self.offset + row - 1;
538                match self.rows.get(at) {
539                    Some(Row::Hit(..)) => {
540                        if self.clicked == Some(at) && self.cursor == at {
541                            return self.pick().map_or(Flow::Continue, Flow::Done);
542                        }
543                        self.cursor = at;
544                        self.clicked = Some(at);
545                    }
546                    Some(Row::Title(source, _)) => {
547                        let source = *source;
548                        if let (Some(_), rest) = self.index.scope(&self.query) {
549                            self.query = rest.to_string();
550                        }
551                        self.only = if self.only == Some(source) {
552                            None
553                        } else {
554                            Some(source)
555                        };
556                        self.refresh();
557                    }
558                    None => {}
559                }
560            }
561            _ => return Flow::Ignored,
562        }
563        Flow::Continue
564    }
565
566    fn event(&mut self, event: &Event, context: &Context<'_>) -> Flow<(usize, usize)> {
567        self.space.set(context.height);
568        if let Some(mouse) = event.mouse() {
569            return self.mouse_event(
570                mouse.kind,
571                mouse.column as usize,
572                mouse.row as usize,
573                context.width,
574            );
575        }
576        if let Event::Paste(text) = event {
577            self.query.push_str(&pasted(text, " "));
578            self.refresh();
579            return Flow::Continue;
580        }
581        let Some(key) = event.key() else {
582            return Flow::Ignored;
583        };
584        self.clicked = None;
585        let action = self.keymap.action(key).or_else(|| {
586            let typing = matches!(key.code, KeyCode::Char(_));
587            (!typing && key.modifiers != Modifiers::NONE)
588                .then(|| self.keymap.action(crate::event::Key::new(key.code)))
589                .flatten()
590        });
591        let page = self.page() as isize;
592        match action {
593            Some("pick") => {
594                return self.pick().map_or(Flow::Continue, Flow::Done);
595            }
596            Some("cancel") => {
597                self.answer = Some(None);
598                return Flow::Cancel;
599            }
600            Some("up") => self.step(-1),
601            Some("down") => self.step(1),
602            Some("page-up") => self.step(-page),
603            Some("page-down") => self.step(page),
604            Some("first") => self.step(isize::MIN / 2),
605            Some("last") => self.step(isize::MAX / 2),
606            Some("previous-group") => self.jump_group(false),
607            Some("next-group") => self.jump_group(true),
608            Some("narrow") => self.narrow(true),
609            Some("widen") => self.narrow(false),
610            Some("clear") => {
611                self.query.clear();
612                self.refresh();
613            }
614            Some("delete") => {
615                if self.query.pop().is_some() {
616                    self.refresh();
617                }
618            }
619            _ => match key.code {
620                KeyCode::Char(c) if !key.modifiers.ctrl && !key.modifiers.alt => {
621                    self.query.push(c);
622                    self.refresh();
623                }
624                _ => return Flow::Ignored,
625            },
626        }
627        Flow::Continue
628    }
629
630    fn layout(&self, width: usize) -> (PreviewLayout, usize) {
631        let preview = self
632            .focused()
633            .is_some_and(|(source, item)| self.index.sources[source].items[item].preview.is_some())
634            && self.preview != PreviewLayout::Hidden;
635        if !preview {
636            return (PreviewLayout::Hidden, width);
637        }
638        let layout = match self.preview {
639            PreviewLayout::Auto if width >= 72 => PreviewLayout::Right,
640            PreviewLayout::Auto => PreviewLayout::Below,
641            layout => layout,
642        };
643        let left = (width * 45 / 100).max(20).min(width);
644        (
645            layout,
646            if layout == PreviewLayout::Right {
647                left
648            } else {
649                width
650            },
651        )
652    }
653
654    fn theme_style(context: &Context<'_>, key: &str, default: &str) -> Style {
655        context
656            .console
657            .theme()
658            .get(key)
659            .cloned()
660            .unwrap_or_else(|| Style::parse(default).expect("a built-in style"))
661    }
662
663    fn row(&self, context: &Context<'_>, at: usize, width: usize) -> Vec<Segment> {
664        let theme = &self.theme;
665        match &self.rows[at] {
666            Row::Title(source, more) => {
667                let source = &self.index.sources[*source];
668                let mut line = vec![text(
669                    source.title.clone(),
670                    &Self::theme_style(context, "jump.group", "bold"),
671                )];
672                if let Some(prefix) = source.prefix {
673                    line.push(text(format!(" {prefix}"), &theme.hint));
674                }
675                if *more > 0 {
676                    line.push(text(
677                        format!("  +{more} more · tab"),
678                        &Self::theme_style(context, "jump.more", "dim"),
679                    ));
680                }
681                fit(line, width)
682            }
683            Row::Hit(source, item, positions) => {
684                let item = &self.index.sources[*source].items[*item];
685                let focused = at == self.cursor;
686                let mut line = if focused {
687                    vec![text(format!("{} ", theme.pointer), &theme.pointer_style)]
688                } else {
689                    vec![plain("  ")]
690                };
691                line.extend(highlight(
692                    &item.label,
693                    positions,
694                    focused.then_some(&theme.focused),
695                    &theme.matched,
696                ));
697                if let Some(description) = &item.description {
698                    line.push(text(format!("  {description}"), &theme.hint));
699                }
700                fit(line, width)
701            }
702        }
703    }
704
705    fn render_view(&self, context: &Context<'_>) -> View {
706        self.space.set(context.height);
707        let width = context.width;
708        let theme = &self.theme;
709        let mut header = question(theme, &self.prompt);
710        if let Some(answer) = &self.answer {
711            header.push(match answer {
712                Some(answer) => text(answer.clone(), &theme.answer),
713                None => text("cancelled", &theme.hint),
714            });
715            return View::new(vec![fit(header, width)]);
716        }
717        if let Some(only) = self.only {
718            header.push(text(
719                format!("[{}] ", self.index.sources[only].title),
720                &theme.checked_style,
721            ));
722        }
723        let column = crate::components::width(&header) + rich::cells::cell_len(&self.query);
724        header.push(plain(self.query.clone()));
725        let mut lines = vec![fit(header, width)];
726        let page = self.page();
727        let (layout, left) = self.layout(width);
728        let mut list: Vec<Vec<Segment>> = (self.offset..self.rows.len())
729            .take(page)
730            .map(|at| self.row(context, at, left))
731            .collect();
732        if self.rows.is_empty() {
733            list.push(vec![text("  no matches", &theme.hint)]);
734        }
735        let shown = page.min(self.rows.len().max(1));
736        list.resize_with(shown, Vec::new);
737        let narrowed = self.narrowed();
738        let mut hint = format!("  {} results", self.result_count());
739        hint.push_str(match narrowed {
740            Some(_) => " · shift+tab widen",
741            None => " · tab narrow",
742        });
743        hint.push_str(" · ↑↓ move · alt+↑↓ group · enter jump · esc cancel");
744        let prefixes: Vec<String> = self
745            .index
746            .sources
747            .iter()
748            .filter_map(|s| s.prefix.map(|p| format!("{p} {}", s.title.to_lowercase())))
749            .collect();
750        if narrowed.is_none() && !prefixes.is_empty() {
751            hint.push_str(&format!(" · {}", prefixes.join(", ")));
752        }
753        let footer = fit(vec![text(hint, &theme.hint)], width);
754        let preview = self
755            .focused()
756            .and_then(|(source, item)| self.index.sources[source].items[item].preview.as_ref());
757        match (preview, layout) {
758            (Some(preview), PreviewLayout::Right) => {
759                let right = width.saturating_sub(left + 3).max(1);
760                let shown = context.lines_at(&*preview.renderable(), right);
761                for (row, line) in list.into_iter().enumerate() {
762                    let mut line = pad(line, left);
763                    line.push(text(" │ ", &theme.border));
764                    if let Some(preview) = shown.get(row) {
765                        line.extend(fit(preview.clone(), right));
766                    }
767                    lines.push(line);
768                }
769                lines.push(footer);
770            }
771            (Some(preview), PreviewLayout::Below) => {
772                lines.extend(list);
773                lines.push(footer);
774                lines.push(vec![text("─".repeat(width), &theme.border)]);
775                let shown = context.lines_at(&*preview.renderable(), width);
776                lines.extend(
777                    shown
778                        .into_iter()
779                        .take(self.preview_height)
780                        .map(|line| fit(line, width)),
781                );
782            }
783            _ => {
784                lines.extend(list);
785                lines.push(footer);
786            }
787        }
788        View::new(lines).with_cursor(0, column.min(width.saturating_sub(1)))
789    }
790}
791
792impl<T: Clone> Jump<T> {
793    fn jumped(&self, (source, item): (usize, usize)) -> Jumped<T> {
794        let entry = &self.index.sources[source].items[item];
795        Jumped {
796            source: self.index.sources[source].id.clone(),
797            index: item,
798            label: entry.label.clone(),
799            value: entry.value.clone(),
800        }
801    }
802}
803
804impl<T: Clone> Component for Jump<T> {
805    type Output = Jumped<T>;
806
807    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<Jumped<T>> {
808        match self.event(event, context) {
809            Flow::Done(at) => Flow::Done(self.jumped(at)),
810            Flow::Cancel => Flow::Cancel,
811            Flow::Ignored => Flow::Ignored,
812            _ => Flow::Continue,
813        }
814    }
815
816    fn render(&self, context: &Context<'_>) -> View {
817        self.render_view(context)
818    }
819
820    fn mouse(&self) -> bool {
821        self.mouse
822    }
823
824    fn keymap(&self) -> Keymap {
825        self.keymap.clone()
826    }
827
828    /// The best match for the query it started with; none with nothing
829    /// typed.
830    fn default_value(&self) -> Option<Jumped<T>> {
831        if self.query.trim().is_empty() {
832            return None;
833        }
834        self.index.best(&self.query).map(|at| self.jumped(at))
835    }
836
837    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<Jumped<T>>, NotInteractive> {
838        io.write(&format!("{}: ", self.prompt));
839        let line = match io.read_line() {
840            Some(line) if !line.trim().is_empty() => line,
841            // At the end of input or on an empty line, the default answers.
842            _ => return self.default_value().map(Some).ok_or(NotInteractive::Ended),
843        };
844        match self.index.best(line.trim()) {
845            Some(at) => Ok(Some(self.jumped(at))),
846            None => Err(NotInteractive::Invalid(format!(
847                "nothing matches {:?}",
848                line.trim()
849            ))),
850        }
851    }
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857
858    fn index() -> JumpIndex<&'static str> {
859        JumpIndex::new()
860            .source(
861                JumpSource::new("files", "Files")
862                    .items(["src/main.rs", "src/lib.rs", "README.md"])
863                    .limit(2),
864            )
865            .source(
866                JumpSource::new("commands", "Commands")
867                    .prefix('>')
868                    .items(["build", "test"]),
869            )
870    }
871
872    #[test]
873    fn searches_group_by_source_and_cut_to_limits() {
874        let groups = index().search("", None);
875        assert_eq!(groups.len(), 2);
876        assert_eq!((groups[0].hits.len(), groups[0].more), (2, 1));
877        let narrowed = index().search("", Some(0));
878        assert_eq!((narrowed.len(), narrowed[0].hits.len()), (1, 3));
879        let commands = index().search(">te", None);
880        assert_eq!(commands.len(), 1);
881        assert_eq!(commands[0].source, 1);
882        assert_eq!(index().best("lib"), Some((0, 1)));
883        assert_eq!(index().best("zzz"), None);
884    }
885}