use std::{sync::mpsc::Sender, time::Duration};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
};
use tokio::{task::JoinHandle, time::sleep};
use tower_lsp::lsp_types::SymbolKind;
use crate::{
app::{App, SearchItem, SearchKind, SearchRequest, SearchState, SearchStatus},
fetch::{WorkspaceSymbolClient, WorkspaceSymbolMatch},
state::SourceLocation,
};
const WORKSPACE_SYMBOL_SEARCH_DELAY: Duration = Duration::from_millis(200);
pub(super) enum QueryEvent {
Started(u64),
Finished {
request_id: u64,
result: Result<Vec<SearchItem>, String>,
},
}
pub(super) fn schedule(
client: WorkspaceSymbolClient,
request: SearchRequest,
sender: Sender<QueryEvent>,
) -> JoinHandle<()> {
tokio::spawn(async move {
sleep(WORKSPACE_SYMBOL_SEARCH_DELAY).await;
if sender
.send(QueryEvent::Started(request.request_id))
.is_err()
{
return;
}
let result = client
.query(&request.query)
.await
.map(|symbols| {
symbols
.into_iter()
.filter(|symbol| symbol_matches_search(request.kind, symbol.kind))
.map(search_item)
.collect()
})
.map_err(|error| format!("{error:#}"));
let _ = sender.send(QueryEvent::Finished {
request_id: request.request_id,
result,
});
})
}
pub(super) fn handle_key(app: &mut App, key: KeyEvent) -> Option<SearchRequest> {
match key.code {
KeyCode::Esc => {
app.close_search();
None
}
KeyCode::Enter => {
app.accept_search_selection();
None
}
KeyCode::Up => {
app.move_search_selection(-1);
None
}
KeyCode::Down => {
app.move_search_selection(1);
None
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.move_search_selection(-1);
None
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.move_search_selection(1);
None
}
KeyCode::Backspace => app.pop_search_char(),
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
app.push_search_char(character)
}
_ => None,
}
}
pub(super) fn handle_mouse(app: &mut App, mouse: MouseEvent, screen: Rect) {
let Some(search) = app.search.as_ref() else {
return;
};
let (_, _, list_area) = layout(screen);
if !list_area.contains((mouse.column, mouse.row).into()) || search.items.is_empty() {
return;
}
let visible_items = usize::from(list_area.height);
let selected = search.selected.unwrap_or(0);
let offset = selected.saturating_add(1).saturating_sub(visible_items);
let index = offset + usize::from(mouse.row.saturating_sub(list_area.y));
match mouse.kind {
MouseEventKind::Moved => app.select_search_item(index),
MouseEventKind::Down(MouseButton::Left) => {
app.select_search_item(index);
app.accept_search_selection();
}
MouseEventKind::ScrollUp => app.move_search_selection(-1),
MouseEventKind::ScrollDown => app.move_search_selection(1),
_ => {}
}
}
pub(super) fn render(frame: &mut Frame, search: &SearchState) {
let area = modal_area(frame.area());
frame.render_widget(Clear, area);
let title = match search.kind {
SearchKind::Call => " Add call node ",
SearchKind::Type => " Add type node ",
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
frame.render_widget(block, area);
let (input_area, status_area, list_area) = layout(frame.area());
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Cyan)),
Span::raw(&search.input),
])),
input_area,
);
let (status, status_style) = match &search.status {
SearchStatus::Debouncing => (
"Waiting for typing pause…".to_owned(),
Style::default().fg(Color::DarkGray),
),
SearchStatus::Loading => (
"Searching workspace symbols…".to_owned(),
Style::default().fg(Color::Yellow),
),
SearchStatus::Ready if search.items.is_empty() => (
"No matching symbols".to_owned(),
Style::default().fg(Color::DarkGray),
),
SearchStatus::Ready => (
format!("{} symbols", search.items.len()),
Style::default().fg(Color::Green),
),
SearchStatus::Error(error) => (error.clone(), Style::default().fg(Color::Red)),
};
frame.render_widget(Paragraph::new(status).style(status_style), status_area);
let items = search.items.iter().map(|item| {
let container = container_label(item)
.map(|name| format!(" [{name}]"))
.unwrap_or_default();
ListItem::new(Line::from(vec![
Span::styled(&item.name, Style::default().add_modifier(Modifier::BOLD)),
Span::styled(container, Style::default().fg(Color::Cyan)),
Span::styled(
format!(" {}", item.location),
Style::default().fg(Color::DarkGray),
),
]))
});
let list = List::new(items)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut list_state = ListState::default().with_selected(search.selected);
frame.render_stateful_widget(list, list_area, &mut list_state);
}
fn container_label(item: &SearchItem) -> Option<&str> {
item.container_name.as_deref().filter(|container| {
!item.name.starts_with(&format!("{container}::"))
&& !item.name.starts_with(&format!("{container}."))
})
}
pub(super) fn search_item(symbol: WorkspaceSymbolMatch) -> SearchItem {
let name = symbol.display_name();
let uri = symbol.uri.to_string();
let path = symbol
.uri
.to_file_path()
.map(|path| path.display().to_string())
.unwrap_or_else(|()| symbol.uri.to_string());
let (location, line, character) = symbol.range.map_or_else(
|| (path.clone(), None, None),
|range| {
(
format!("{path}:{}", range.start.line + 1),
Some(range.start.line),
Some(range.start.character),
)
},
);
SearchItem {
name,
container_name: symbol.container_name,
location,
source: Some(SourceLocation {
uri,
line,
character,
}),
}
}
pub(super) fn symbol_matches_search(search_kind: SearchKind, symbol_kind: SymbolKind) -> bool {
match search_kind {
SearchKind::Call => matches!(
symbol_kind,
SymbolKind::FUNCTION | SymbolKind::METHOD | SymbolKind::CONSTRUCTOR
),
SearchKind::Type => matches!(
symbol_kind,
SymbolKind::CLASS
| SymbolKind::INTERFACE
| SymbolKind::STRUCT
| SymbolKind::ENUM
| SymbolKind::TYPE_PARAMETER
),
}
}
fn modal_area(screen: Rect) -> Rect {
let width = screen.width.saturating_sub(2).min(100);
let height = screen.height.saturating_sub(2).min(16);
Rect::new(
screen.x + screen.width.saturating_sub(width) / 2,
screen.y + u16::from(screen.height > height),
width,
height,
)
}
fn layout(screen: Rect) -> (Rect, Rect, Rect) {
let area = modal_area(screen);
let inner = Block::default().borders(Borders::ALL).inner(area);
let [input, status, list] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(inner);
(input, status, list)
}
#[cfg(test)]
mod tests {
use super::container_label;
use crate::app::SearchItem;
#[test]
fn hides_container_when_the_provider_name_is_already_qualified() {
let mut item = SearchItem {
name: "Worker::run".to_owned(),
container_name: Some("Worker".to_owned()),
location: "src/lib.rs:1".to_owned(),
source: None,
};
assert_eq!(container_label(&item), None);
item.name = "Worker.run".to_owned();
assert_eq!(container_label(&item), None);
item.name = "run".to_owned();
assert_eq!(container_label(&item), Some("Worker"));
}
}