tess/overlay/
tag_picker.rs1use 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 name: String,
17 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 (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 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 assert!(f.status.contains("[2/3]"));
184 }
185}