use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
};
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct SymbolEntry {
pub name: String,
pub kind: String,
pub path: PathBuf,
pub uri: Option<String>,
pub line: Option<u32>,
pub container: Option<String>,
}
#[derive(Debug, Default)]
pub struct SymbolSearchState {
pub query: String,
pub results: Vec<SymbolEntry>,
pub selected: usize,
pub loading: bool,
pub error: Option<String>,
pub list_state: ListState,
}
impl SymbolSearchState {
pub fn new() -> Self {
Self {
query: String::new(),
results: Vec::new(),
selected: 0,
loading: false,
error: None,
list_state: ListState::default(),
}
}
pub fn reset(&mut self) {
self.query.clear();
self.results.clear();
self.selected = 0;
self.loading = false;
self.error = None;
self.list_state = ListState::default();
}
pub fn push_char(&mut self, c: char) {
self.query.push(c);
self.selected = 0;
}
pub fn pop_char(&mut self) {
self.query.pop();
self.selected = 0;
}
pub fn select_prev(&mut self) {
if !self.results.is_empty() {
self.selected = self.selected.saturating_sub(1);
self.list_state.select(Some(self.selected));
}
}
pub fn select_next(&mut self) {
if !self.results.is_empty() {
self.selected = (self.selected + 1).min(self.results.len() - 1);
self.list_state.select(Some(self.selected));
}
}
pub fn selected_symbol(&self) -> Option<&SymbolEntry> {
self.results.get(self.selected)
}
pub fn set_results(&mut self, results: Vec<SymbolEntry>) {
self.results = results;
self.selected = 0;
self.loading = false;
self.error = None;
if !self.results.is_empty() {
self.list_state.select(Some(0));
}
}
pub fn set_error(&mut self, error: String) {
self.error = Some(error);
self.loading = false;
self.results.clear();
}
pub fn open(&mut self) {
self.reset();
}
pub fn close(&mut self) {
self.reset();
}
pub fn handle_char(&mut self, c: char) {
self.push_char(c);
}
pub fn handle_backspace(&mut self) {
self.pop_char();
}
}
pub fn render_symbol_search(f: &mut Frame, state: &mut SymbolSearchState, area: Rect) {
let popup_area = centered_rect(80, 60, area);
f.render_widget(Clear, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(3), Constraint::Length(1), ])
.split(popup_area);
let input_style = Style::default().fg(Color::Yellow);
let input = Paragraph::new(Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Cyan)),
Span::styled(&state.query, input_style),
Span::raw("▏"), ]))
.block(
Block::default()
.borders(Borders::ALL)
.title(" Symbol Search (Esc: close, Enter: jump) ")
.border_style(Style::default().fg(Color::Cyan)),
);
f.render_widget(input, chunks[0]);
let items: Vec<ListItem> = state
.results
.iter()
.map(|sym| {
let kind_color = symbol_kind_color(&sym.kind);
let mut spans = vec![
Span::styled(format!(" {:8} ", sym.kind), Style::default().fg(kind_color)),
Span::styled(&sym.name, Style::default().fg(Color::White).bold()),
];
if let Some(ref container) = sym.container {
spans.push(Span::styled(
format!(" ({})", container),
Style::default().fg(Color::DarkGray),
));
}
spans.push(Span::styled(
format!(
" → {}",
sym.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| sym.path.display().to_string())
),
Style::default().fg(Color::DarkGray),
));
if let Some(line) = sym.line {
spans.push(Span::styled(
format!(":{}", line),
Style::default().fg(Color::Yellow),
));
}
if let Some(uri) = &sym.uri {
let uri_label = uri
.strip_prefix("file://")
.unwrap_or(uri)
.rsplit('/')
.next()
.unwrap_or(uri.as_str());
spans.push(Span::styled(
format!(" · {uri_label}"),
Style::default().fg(Color::DarkGray),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Results ({}) ", state.results.len()))
.border_style(Style::default().fg(Color::DarkGray)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
state.list_state.select(Some(state.selected));
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
let status_text = if state.loading {
Span::styled(" Searching...", Style::default().fg(Color::Yellow))
} else if let Some(ref err) = state.error {
Span::styled(format!(" Error: {}", err), Style::default().fg(Color::Red))
} else if state.results.is_empty() && !state.query.is_empty() {
Span::styled(" No symbols found", Style::default().fg(Color::DarkGray))
} else {
Span::styled(
" ↑↓:navigate Enter:open Esc:close",
Style::default().fg(Color::DarkGray),
)
};
let status = Paragraph::new(Line::from(status_text));
f.render_widget(status, chunks[2]);
}
fn symbol_kind_color(kind: &str) -> Color {
match kind {
"Function" | "Method" => Color::Yellow,
"Struct" | "Class" | "Enum" | "Interface" => Color::Cyan,
"Module" | "Namespace" => Color::Magenta,
"Constant" | "Field" | "Property" => Color::Green,
"Variable" | "Parameter" => Color::Blue,
_ => Color::White,
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}