use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use super::super::{SEARCH_MAX_VISIBLE, SEARCH_POPUP_WIDTH, centered_popup, truncate};
use crate::app::App;
use crate::theme::Theme;
pub(in crate::ui) fn draw_search(frame: &mut Frame, app: &App, area: Rect) {
let theme = &app.theme;
let max_visible = SEARCH_MAX_VISIBLE.min(app.search.results.len().max(1));
let pref_height = max_visible as u16 + 5;
let title = if app.search.query.is_empty() {
" Search ".to_string()
} else {
format!(" Search [{}] ", app.search.query)
};
let (popup_area, block) =
centered_popup(frame, area, SEARCH_POPUP_WIDTH, pref_height, &title, theme);
let inner_height = popup_area.height.saturating_sub(2) as usize; let footer_lines = 2; let visible_rows = inner_height.saturating_sub(footer_lines);
let scroll_offset = if app.search.index >= visible_rows {
app.search.index - visible_rows + 1
} else {
0
};
let mut lines: Vec<Line> = Vec::new();
let inner_w = popup_area.width.saturating_sub(2) as usize;
if app.search.results.is_empty() {
let msg = if app.search.query.is_empty() {
" Type to search..."
} else {
" No results found"
};
lines.push(Line::from(Span::styled(
msg,
Style::default().fg(theme.fg_muted),
)));
} else {
let end = (scroll_offset + visible_rows).min(app.search.results.len());
for (i, result) in app.search.results[scroll_offset..end].iter().enumerate() {
let actual_index = scroll_offset + i;
let is_selected = actual_index == app.search.index;
let conv_prefix = if app.active_conversation.is_some() {
String::new()
} else {
format!("[{}] ", truncate(&result.conv_name, 12))
};
let sender_display = truncate(&result.sender, 10);
let prefix = format!(" {conv_prefix}{sender_display}: ");
let body_max = inner_w.saturating_sub(prefix.len());
let body_snippet = search_snippet(&result.body, &app.search.query, body_max);
let prefix_style = if is_selected {
Style::default().bg(theme.bg_selected).fg(theme.accent)
} else {
Style::default().fg(theme.accent)
};
let body_style = if is_selected {
Style::default().bg(theme.bg_selected).fg(theme.fg)
} else {
Style::default().fg(theme.fg_secondary)
};
let mut spans = vec![Span::styled(prefix, prefix_style)];
spans.extend(highlight_match_spans(
&body_snippet,
&app.search.query,
body_style,
is_selected,
theme,
));
lines.push(Line::from(spans));
}
}
while lines.len() < visible_rows {
lines.push(Line::from(""));
}
lines.push(Line::from(""));
let count_text = if app.search.results.is_empty() {
String::new()
} else {
format!(" {}/{}", app.search.index + 1, app.search.results.len())
};
lines.push(Line::from(vec![
Span::styled(count_text, Style::default().fg(theme.warning)),
Span::styled(
" j/k nav | Enter jump | n/N cycle | Esc close",
Style::default().fg(theme.fg_muted),
),
]));
let popup = Paragraph::new(lines).block(block);
frame.render_widget(popup, popup_area);
}
pub(in crate::ui) fn search_snippet(body: &str, query: &str, max_len: usize) -> String {
let body = body.replace('\n', " ");
let body = body.as_str();
let char_count = body.chars().count();
if char_count <= max_len {
return body.to_string();
}
let body_lower = body.to_lowercase();
let query_lower = query.to_lowercase();
let match_byte_pos = body_lower.find(&query_lower).unwrap_or(0);
let match_char_pos = body_lower[..match_byte_pos].chars().count();
let half = max_len / 2;
let start = match_char_pos.saturating_sub(half);
let end = (start + max_len).min(char_count);
let start = if end == char_count {
end.saturating_sub(max_len)
} else {
start
};
let snippet: String = body.chars().skip(start).take(end - start).collect();
let mut result = snippet;
if start > 0 {
result = format!("…{}", result.chars().skip(1).collect::<String>());
}
if end < char_count {
let trimmed: String = result
.chars()
.take(result.chars().count().saturating_sub(1))
.collect();
result = format!("{trimmed}…");
}
result
}
fn highlight_match_spans<'a>(
text: &str,
query: &str,
base_style: Style,
is_selected: bool,
theme: &Theme,
) -> Vec<Span<'a>> {
if query.is_empty() {
return vec![Span::styled(text.to_string(), base_style)];
}
let match_style = if is_selected {
Style::default()
.bg(theme.bg_selected)
.fg(theme.warning)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD)
};
let text_lower = text.to_lowercase();
let query_lower = query.to_lowercase();
let query_len = query_lower.len();
let mut match_ranges: Vec<(usize, usize)> = Vec::new();
let mut search_pos = 0;
while search_pos < text_lower.len() {
if let Some(m) = text_lower[search_pos..].find(&query_lower) {
let start = search_pos + m;
let end = start + query_len;
match_ranges.push((start, end));
search_pos = end;
} else {
break;
}
}
if match_ranges.is_empty() {
return vec![Span::styled(text.to_string(), base_style)];
}
let orig_chars: Vec<(usize, char)> = text.char_indices().collect();
let lower_chars: Vec<(usize, char)> = text_lower.char_indices().collect();
let char_count = orig_chars.len().min(lower_chars.len());
let mut orig_ranges: Vec<(usize, usize)> = Vec::new();
for &(low_start, low_end) in &match_ranges {
let start_char = lower_chars.iter().position(|&(pos, _)| pos == low_start);
let end_char = lower_chars
.iter()
.position(|&(pos, _)| pos == low_end)
.unwrap_or(char_count);
if let Some(sc) = start_char {
let orig_start = orig_chars[sc].0;
let orig_end = if end_char < orig_chars.len() {
orig_chars[end_char].0
} else {
text.len()
};
orig_ranges.push((orig_start, orig_end));
}
}
let mut spans = Vec::new();
let mut pos = 0;
for (start, end) in orig_ranges {
if start > pos {
spans.push(Span::styled(text[pos..start].to_string(), base_style));
}
spans.push(Span::styled(text[start..end].to_string(), match_style));
pos = end;
}
if pos < text.len() {
spans.push(Span::styled(text[pos..].to_string(), base_style));
}
if spans.is_empty() {
spans.push(Span::styled(text.to_string(), base_style));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_snippet_short_passthrough() {
let body = "short text";
assert_eq!(search_snippet(body, "short", 100), body);
}
#[test]
fn search_snippet_centers_on_match() {
let body = "a".repeat(100) + "NEEDLE" + &"b".repeat(100);
let snippet = search_snippet(&body, "NEEDLE", 30);
assert!(
snippet.chars().count() <= 30,
"snippet too long ({} chars): {snippet}",
snippet.chars().count()
);
assert!(
snippet.contains("NEEDLE"),
"expected query in snippet: {snippet}"
);
}
}