1use ratatui::{
7 Frame,
8 layout::{Constraint, Direction, Layout, Rect},
9 style::{Color, Modifier, Style},
10 text::{Line, Span},
11 widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
12};
13use std::path::PathBuf;
14
15#[derive(Debug, Clone)]
17pub struct SymbolEntry {
18 pub name: String,
20 pub kind: String,
22 pub path: PathBuf,
24 pub uri: Option<String>,
26 pub line: Option<u32>,
28 pub container: Option<String>,
30}
31
32#[derive(Debug, Default)]
34pub struct SymbolSearchState {
35 pub query: String,
37 pub results: Vec<SymbolEntry>,
39 pub selected: usize,
41 pub loading: bool,
43 pub error: Option<String>,
45 pub list_state: ListState,
47}
48
49impl SymbolSearchState {
50 pub fn new() -> Self {
51 Self {
52 query: String::new(),
53 results: Vec::new(),
54 selected: 0,
55 loading: false,
56 error: None,
57 list_state: ListState::default(),
58 }
59 }
60
61 pub fn reset(&mut self) {
63 self.query.clear();
64 self.results.clear();
65 self.selected = 0;
66 self.loading = false;
67 self.error = None;
68 self.list_state = ListState::default();
69 }
70
71 pub fn push_char(&mut self, c: char) {
73 self.query.push(c);
74 self.selected = 0;
75 }
76
77 pub fn pop_char(&mut self) {
79 self.query.pop();
80 self.selected = 0;
81 }
82
83 pub fn select_prev(&mut self) {
85 if !self.results.is_empty() {
86 self.selected = self.selected.saturating_sub(1);
87 self.list_state.select(Some(self.selected));
88 }
89 }
90
91 pub fn select_next(&mut self) {
93 if !self.results.is_empty() {
94 self.selected = (self.selected + 1).min(self.results.len() - 1);
95 self.list_state.select(Some(self.selected));
96 }
97 }
98
99 pub fn selected_symbol(&self) -> Option<&SymbolEntry> {
101 self.results.get(self.selected)
102 }
103
104 pub fn set_results(&mut self, results: Vec<SymbolEntry>) {
106 self.results = results;
107 self.selected = 0;
108 self.loading = false;
109 self.error = None;
110 if !self.results.is_empty() {
111 self.list_state.select(Some(0));
112 }
113 }
114
115 pub fn set_error(&mut self, error: String) {
117 self.error = Some(error);
118 self.loading = false;
119 self.results.clear();
120 }
121
122 pub fn open(&mut self) {
124 self.reset();
125 }
126
127 pub fn close(&mut self) {
129 self.reset();
130 }
131
132 pub fn handle_char(&mut self, c: char) {
134 self.push_char(c);
135 }
136
137 pub fn handle_backspace(&mut self) {
139 self.pop_char();
140 }
141}
142
143pub fn render_symbol_search(f: &mut Frame, state: &mut SymbolSearchState, area: Rect) {
145 let popup_area = centered_rect(80, 60, area);
147
148 f.render_widget(Clear, popup_area);
150
151 let chunks = Layout::default()
152 .direction(Direction::Vertical)
153 .constraints([
154 Constraint::Length(3), Constraint::Min(3), Constraint::Length(1), ])
158 .split(popup_area);
159
160 let input_style = Style::default().fg(Color::Yellow);
162 let input = Paragraph::new(Line::from(vec![
163 Span::styled("๐ ", Style::default().fg(Color::Cyan)),
164 Span::styled(&state.query, input_style),
165 Span::raw("โ"), ]))
167 .block(
168 Block::default()
169 .borders(Borders::ALL)
170 .title(" Symbol Search (Esc: close, Enter: jump) ")
171 .border_style(Style::default().fg(Color::Cyan)),
172 );
173
174 f.render_widget(input, chunks[0]);
175
176 let items: Vec<ListItem> = state
178 .results
179 .iter()
180 .map(|sym| {
181 let kind_color = symbol_kind_color(&sym.kind);
182 let mut spans = vec![
183 Span::styled(format!(" {:8} ", sym.kind), Style::default().fg(kind_color)),
184 Span::styled(&sym.name, Style::default().fg(Color::White).bold()),
185 ];
186
187 if let Some(ref container) = sym.container {
188 spans.push(Span::styled(
189 format!(" ({})", container),
190 Style::default().fg(Color::DarkGray),
191 ));
192 }
193
194 spans.push(Span::styled(
195 format!(
196 " โ {}",
197 sym.path
198 .file_name()
199 .map(|n| n.to_string_lossy().to_string())
200 .unwrap_or_else(|| sym.path.display().to_string())
201 ),
202 Style::default().fg(Color::DarkGray),
203 ));
204
205 if let Some(line) = sym.line {
206 spans.push(Span::styled(
207 format!(":{}", line),
208 Style::default().fg(Color::Yellow),
209 ));
210 }
211
212 if let Some(uri) = &sym.uri {
213 let uri_label = uri
214 .strip_prefix("file://")
215 .unwrap_or(uri)
216 .rsplit('/')
217 .next()
218 .unwrap_or(uri.as_str());
219 spans.push(Span::styled(
220 format!(" ยท {uri_label}"),
221 Style::default().fg(Color::DarkGray),
222 ));
223 }
224
225 ListItem::new(Line::from(spans))
226 })
227 .collect();
228
229 let list = List::new(items)
230 .block(
231 Block::default()
232 .borders(Borders::ALL)
233 .title(format!(" Results ({}) ", state.results.len()))
234 .border_style(Style::default().fg(Color::DarkGray)),
235 )
236 .highlight_style(
237 Style::default()
238 .bg(Color::DarkGray)
239 .add_modifier(Modifier::BOLD),
240 )
241 .highlight_symbol("โถ ");
242
243 state.list_state.select(Some(state.selected));
245 f.render_stateful_widget(list, chunks[1], &mut state.list_state);
246
247 let status_text = if state.loading {
249 Span::styled(" Searching...", Style::default().fg(Color::Yellow))
250 } else if let Some(ref err) = state.error {
251 Span::styled(format!(" Error: {}", err), Style::default().fg(Color::Red))
252 } else if state.results.is_empty() && !state.query.is_empty() {
253 Span::styled(" No symbols found", Style::default().fg(Color::DarkGray))
254 } else {
255 Span::styled(
256 " โโ:navigate Enter:open Esc:close",
257 Style::default().fg(Color::DarkGray),
258 )
259 };
260
261 let status = Paragraph::new(Line::from(status_text));
262 f.render_widget(status, chunks[2]);
263}
264
265fn symbol_kind_color(kind: &str) -> Color {
267 match kind {
268 "Function" | "Method" => Color::Yellow,
269 "Struct" | "Class" | "Enum" | "Interface" => Color::Cyan,
270 "Module" | "Namespace" => Color::Magenta,
271 "Constant" | "Field" | "Property" => Color::Green,
272 "Variable" | "Parameter" => Color::Blue,
273 _ => Color::White,
274 }
275}
276
277fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
279 let popup_layout = Layout::default()
280 .direction(Direction::Vertical)
281 .constraints([
282 Constraint::Percentage((100 - percent_y) / 2),
283 Constraint::Percentage(percent_y),
284 Constraint::Percentage((100 - percent_y) / 2),
285 ])
286 .split(r);
287
288 Layout::default()
289 .direction(Direction::Horizontal)
290 .constraints([
291 Constraint::Percentage((100 - percent_x) / 2),
292 Constraint::Percentage(percent_x),
293 Constraint::Percentage((100 - percent_x) / 2),
294 ])
295 .split(popup_layout[1])[1]
296}