1use 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#[derive(Clone, Debug)]
59pub struct JumpSource<T> {
60 pub id: String,
62 pub title: String,
64 pub prefix: Option<char>,
66 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 #[must_use]
85 pub fn prefix(mut self, prefix: char) -> Self {
86 self.prefix = Some(prefix);
87 self
88 }
89
90 #[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#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct JumpHit {
118 pub item: usize,
119 pub score: i64,
120 pub positions: Vec<usize>,
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct JumpGroup {
126 pub source: usize,
128 pub hits: Vec<JumpHit>,
130 pub more: usize,
132}
133
134#[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 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct Jumped<T> {
226 pub source: String,
228 pub index: usize,
230 pub label: String,
231 pub value: T,
232}
233
234pub 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#[derive(Clone, Debug, PartialEq, Eq)]
259enum Row {
260 Title(usize, usize),
262 Hit(usize, usize, Vec<usize>),
264}
265
266#[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 only: Option<usize>,
275 rows: Vec<Row>,
276 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 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 pub fn source(mut self, source: JumpSource<T>) -> Self {
322 self.index.sources.push(source);
323 self.refresh();
324 self
325 }
326
327 pub fn query(mut self, query: impl Into<String>) -> Self {
329 self.query = query.into();
330 self.refresh();
331 self
332 }
333
334 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 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 pub fn with_mouse(mut self, on: bool) -> Self {
359 self.mouse = on;
360 self
361 }
362
363 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 pub fn query_text(&self) -> &str {
379 &self.query
380 }
381
382 pub fn narrowed(&self) -> Option<usize> {
384 self.index.scope(&self.query).0.or(self.only)
385 }
386
387 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 pub fn result_count(&self) -> usize {
397 self.rows
398 .iter()
399 .filter(|row| matches!(row, Row::Hit(..)))
400 .count()
401 }
402
403 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 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 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 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 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 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 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 fn narrow(&mut self, forward: bool) {
495 let count = self.index.sources.len();
496 if count == 0 {
497 return;
498 }
499 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 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 _ => 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}