Skip to main content

tess/overlay/
tag_picker.rs

1//! Tag picker overlay (`:tselect`). Lists every match for a tag name
2//! and lets the user pick one with the keyboard. Enter dispatches a
3//! `SelectTagMatch(idx)` command back to the app loop.
4
5use std::borrow::Cow;
6use std::cell::Cell;
7
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9
10use crate::input::Command;
11use crate::overlay::{Overlay, OverlayFrame, OverlayOutcome};
12use crate::tags::{TagAddress, TagEntry};
13
14pub struct TagPicker {
15    /// The tag name being selected for (shown in the title).
16    name: String,
17    /// All matches, exactly as they appear in TagStack::active. The index
18    /// emitted via `SelectTagMatch(idx)` is into this vec.
19    entries: Vec<TagEntry>,
20    cursor: usize,
21    rows_offset: Cell<usize>,
22}
23
24impl TagPicker {
25    pub fn new(name: String, entries: Vec<TagEntry>, initial_cursor: usize) -> Self {
26        let cursor = initial_cursor.min(entries.len().saturating_sub(1));
27        Self {
28            name,
29            entries,
30            cursor,
31            rows_offset: Cell::new(0),
32        }
33    }
34
35    fn format_row(&self, idx: usize) -> String {
36        let e = &self.entries[idx];
37        let file = e.file.display().to_string();
38        let addr = match &e.address {
39            TagAddress::Line(n) => format!(":{n}"),
40            TagAddress::Pattern(p) => format!("  /{p}/"),
41            TagAddress::Chained(parts) => format!("  ({} steps)", parts.len()),
42            TagAddress::Unsupported(_) => "  (unsupported)".to_string(),
43        };
44        format!("{:>3}. {file}{addr}", idx + 1)
45    }
46}
47
48impl Overlay for TagPicker {
49    fn handle_key(&mut self, key: KeyEvent) -> OverlayOutcome {
50        match (key.code, key.modifiers) {
51            (KeyCode::Esc, _) => OverlayOutcome::Close,
52            (KeyCode::Enter, _) => OverlayOutcome::CloseAnd(Command::SelectTagMatch(self.cursor)),
53            (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
54                self.cursor = self.cursor.saturating_sub(1);
55                OverlayOutcome::Stay
56            }
57            (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => {
58                if self.cursor + 1 < self.entries.len() {
59                    self.cursor += 1;
60                }
61                OverlayOutcome::Stay
62            }
63            (KeyCode::Home, _) | (KeyCode::Char('g'), KeyModifiers::NONE) => {
64                self.cursor = 0;
65                OverlayOutcome::Stay
66            }
67            (KeyCode::End, _) | (KeyCode::Char('G'), KeyModifiers::SHIFT) => {
68                self.cursor = self.entries.len().saturating_sub(1);
69                OverlayOutcome::Stay
70            }
71            // Number shortcuts: 1-9 jump to that match (when in range).
72            (KeyCode::Char(c), KeyModifiers::NONE) if c.is_ascii_digit() && c != '0' => {
73                let n = (c as u8 - b'0') as usize;
74                if n <= self.entries.len() {
75                    self.cursor = n - 1;
76                    OverlayOutcome::CloseAnd(Command::SelectTagMatch(self.cursor))
77                } else {
78                    OverlayOutcome::Stay
79                }
80            }
81            _ => OverlayOutcome::Stay,
82        }
83    }
84
85    fn render(&self, _width: u16, height: u16) -> OverlayFrame {
86        let body_rows = (height as usize).saturating_sub(1).max(1);
87
88        // Adjust rows_offset so the cursor stays visible.
89        let mut off = self.rows_offset.get();
90        if self.cursor < off {
91            off = self.cursor;
92        } else if self.cursor >= off + body_rows {
93            off = self.cursor + 1 - body_rows;
94        }
95        off = off.min(self.entries.len().saturating_sub(body_rows));
96        self.rows_offset.set(off);
97
98        let mut body: Vec<String> = Vec::with_capacity(body_rows);
99        for slot in 0..body_rows {
100            let row_idx = off + slot;
101            if row_idx >= self.entries.len() {
102                body.push(String::new());
103                continue;
104            }
105            let marker = if row_idx == self.cursor { "> " } else { "  " };
106            body.push(format!("{marker}{}", self.format_row(row_idx)));
107        }
108
109        let status = format!(
110            "tselect: {}  [{}/{}]  Enter=jump  Esc=cancel  1-9=quick",
111            self.name,
112            self.cursor + 1,
113            self.entries.len(),
114        );
115        OverlayFrame { body, status }
116    }
117
118    fn title(&self) -> Cow<'_, str> {
119        Cow::Owned(format!("tselect: {}", self.name))
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use std::path::PathBuf;
127
128    fn entries(n: usize) -> Vec<TagEntry> {
129        (0..n)
130            .map(|i| TagEntry {
131                file: PathBuf::from(format!("src/f{i}.rs")),
132                address: TagAddress::Line(i + 1),
133            })
134            .collect()
135    }
136
137    #[test]
138    fn enter_emits_select_with_cursor_index() {
139        let mut p = TagPicker::new("foo".into(), entries(3), 0);
140        match p.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) {
141            OverlayOutcome::Stay => {}
142            other => panic!("expected Stay, got {other:?}"),
143        }
144        match p.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
145            OverlayOutcome::CloseAnd(Command::SelectTagMatch(1)) => {}
146            other => panic!("expected SelectTagMatch(1), got {other:?}"),
147        }
148    }
149
150    #[test]
151    fn number_shortcut_picks_directly() {
152        let mut p = TagPicker::new("foo".into(), entries(5), 0);
153        match p.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)) {
154            OverlayOutcome::CloseAnd(Command::SelectTagMatch(2)) => {}
155            other => panic!("expected SelectTagMatch(2), got {other:?}"),
156        }
157    }
158
159    #[test]
160    fn number_shortcut_out_of_range_stays() {
161        let mut p = TagPicker::new("foo".into(), entries(2), 0);
162        match p.handle_key(KeyEvent::new(KeyCode::Char('9'), KeyModifiers::NONE)) {
163            OverlayOutcome::Stay => {}
164            other => panic!("expected Stay, got {other:?}"),
165        }
166    }
167
168    #[test]
169    fn esc_closes() {
170        let mut p = TagPicker::new("foo".into(), entries(3), 0);
171        match p.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) {
172            OverlayOutcome::Close => {}
173            other => panic!("expected Close, got {other:?}"),
174        }
175    }
176
177    #[test]
178    fn render_marks_cursor_row() {
179        let p = TagPicker::new("foo".into(), entries(3), 1);
180        let f = p.render(80, 10);
181        assert!(f.body.iter().any(|l| l.starts_with("> ")));
182        // Title-ish status with current/total.
183        assert!(f.status.contains("[2/3]"));
184    }
185}