use crate::app::App;
use crate::config::SearchPreview;
use ratatui::{
Frame,
layout::{Constraint, Direction, Flex, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use std::path::PathBuf;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchMode {
FileName,
Content,
}
#[derive(Debug)]
pub struct SearchState {
pub active: bool,
pub query: String,
pub mode: SearchMode,
pub results: Vec<SearchResult>,
pub selected_index: usize,
pub truncated_at_cap: bool,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub path: PathBuf,
pub name: String,
pub match_count: usize,
pub preview: String,
pub first_match_line: Option<usize>,
}
impl Default for SearchState {
fn default() -> Self {
Self {
active: false,
query: String::new(),
mode: SearchMode::FileName,
results: Vec::new(),
selected_index: 0,
truncated_at_cap: false,
}
}
}
impl SearchState {
pub fn activate(&mut self) {
self.active = true;
self.query.clear();
self.results.clear();
self.selected_index = 0;
self.truncated_at_cap = false;
}
pub fn toggle_mode(&mut self) {
self.mode = match self.mode {
SearchMode::FileName => SearchMode::Content,
SearchMode::Content => SearchMode::FileName,
};
self.results.clear();
self.selected_index = 0;
self.truncated_at_cap = false;
}
pub fn next_result(&mut self) {
if !self.results.is_empty() {
self.selected_index = (self.selected_index + 1) % self.results.len();
}
}
pub fn prev_result(&mut self) {
if !self.results.is_empty() {
self.selected_index = if self.selected_index == 0 {
self.results.len() - 1
} else {
self.selected_index - 1
};
}
}
}
pub const RESULT_CAP: usize = 500;
const SNIPPET_WINDOW: usize = 80;
pub fn smartcase_is_sensitive(query: &str) -> bool {
query.chars().any(char::is_uppercase)
}
pub fn build_preview(line: &str, query: &str, mode: SearchPreview) -> String {
let trimmed = line.trim();
match mode {
SearchPreview::FullLine => trimmed.to_string(),
SearchPreview::Snippet => {
let lower_line = trimmed.to_lowercase();
let lower_query = query.to_lowercase();
let match_byte = lower_line.find(lower_query.as_str()).unwrap_or(0);
let match_char = trimmed[..match_byte].chars().count();
let total_chars = trimmed.chars().count();
let half = SNIPPET_WINDOW / 2;
let start_char = match_char.saturating_sub(half);
let start_char = if start_char + SNIPPET_WINDOW > total_chars {
total_chars.saturating_sub(SNIPPET_WINDOW)
} else {
start_char
};
let end_char = (start_char + SNIPPET_WINDOW).min(total_chars);
let snippet: String = trimmed
.chars()
.skip(start_char)
.take(end_char - start_char)
.collect();
if start_char > 0 {
format!("…{snippet}")
} else {
snippet
}
}
}
}
#[allow(clippy::too_many_lines)]
pub fn draw(f: &mut Frame, app: &mut App) {
app.search_result_rects.clear();
if !app.search.active {
return;
}
let p = &app.palette;
let area = f.area();
let popup_area = percent_rect(80, 80, area);
f.render_widget(Clear, popup_area);
let mode_label = match app.search.mode {
SearchMode::FileName => "Files",
SearchMode::Content => "Content",
};
let smartcase_marker = if smartcase_is_sensitive(&app.search.query) {
" Aa"
} else {
" aA"
};
let result_count: usize = app.search.results.len();
let total_matches: usize = app
.search
.results
.iter()
.map(|r| r.match_count.max(1))
.sum();
let outer_block = Block::default()
.title(format!(
" Search [{mode_label}] (Tab: toggle mode Esc: close) "
))
.borders(Borders::ALL)
.border_style(Style::default().fg(p.border_focused))
.style(Style::default().bg(p.help_bg));
let inner = outer_block.inner(popup_area);
f.render_widget(outer_block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(inner);
let query_area = chunks[0];
let results_area = chunks[1];
let footer_area = chunks[2];
let query_line = Line::from(vec![
Span::styled(" / ", Style::default().fg(p.accent_alt)),
Span::raw(app.search.query.as_str()),
Span::styled(
"█",
Style::default()
.fg(p.foreground)
.add_modifier(Modifier::SLOW_BLINK),
),
Span::styled(
format!(" [{mode_label}]{smartcase_marker}"),
Style::default().fg(p.dim),
),
]);
f.render_widget(Paragraph::new(query_line), query_area);
let cursor = app.search.selected_index;
let visible_rows = results_area.height as usize;
let scroll_offset = if result_count == 0 || cursor < visible_rows {
0
} else {
cursor - visible_rows + 1
};
let is_content_mode = app.search.mode == SearchMode::Content;
for (slot, result) in app
.search
.results
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_rows)
{
let row_rect = Rect {
x: results_area.x,
y: results_area.y + crate::cast::u16_sat(slot - scroll_offset),
width: results_area.width,
height: 1,
};
app.search_result_rects.push((slot, row_rect));
let is_selected = slot == cursor;
let parent_str = result
.path
.parent()
.map(|p| {
let comps: Vec<_> = p.components().collect();
if comps.len() <= 2 {
p.to_string_lossy().into_owned()
} else {
let tail: Vec<String> = comps
.iter()
.rev()
.take(2)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
format!("…/{}", tail.join("/"))
}
})
.unwrap_or_default();
let line = if is_content_mode {
let count_label = format!("[{}] ", result.match_count);
let line_tag = result
.first_match_line
.map(|l| format!(" :{}", l + 1))
.unwrap_or_default();
let preview_text = if result.preview.is_empty() {
String::new()
} else {
let budget = (results_area.width as usize)
.saturating_sub(count_label.width())
.saturating_sub(result.name.width())
.saturating_sub(parent_str.width())
.saturating_sub(line_tag.width())
.saturating_sub(5); truncate_str(&result.preview, budget)
};
let preview_suffix = if preview_text.is_empty() {
line_tag
} else {
format!(" — {preview_text}{line_tag}")
};
if is_selected {
let sel_style = Style::default()
.fg(p.selection_fg)
.bg(p.selection_bg)
.add_modifier(Modifier::BOLD);
Line::from(vec![
Span::styled(" ", sel_style),
Span::styled(count_label, sel_style),
Span::styled(result.name.as_str(), sel_style),
Span::styled(format!(" {parent_str}"), sel_style),
Span::styled(preview_suffix, sel_style),
])
} else {
Line::from(vec![
Span::raw(" "),
Span::styled(count_label, Style::default().fg(p.accent_alt)),
Span::styled(
result.name.as_str(),
Style::default()
.fg(p.foreground)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {parent_str}"), Style::default().fg(p.dim)),
Span::styled(preview_suffix, Style::default().fg(p.dim)),
])
}
} else {
if is_selected {
let sel_style = Style::default()
.fg(p.selection_fg)
.bg(p.selection_bg)
.add_modifier(Modifier::BOLD);
Line::from(vec![
Span::styled(" ", sel_style),
Span::styled(result.name.as_str(), sel_style),
Span::styled(format!(" {parent_str}"), sel_style),
])
} else {
Line::from(vec![
Span::raw(" "),
Span::styled(
result.name.as_str(),
Style::default()
.fg(p.foreground)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {parent_str}"), Style::default().fg(p.dim)),
])
}
};
if is_selected {
let bg_block = Block::default().style(Style::default().bg(p.selection_bg));
f.render_widget(bg_block, row_rect);
}
f.render_widget(Paragraph::new(line), row_rect);
}
if app.search.results.is_empty() && !app.search.query.is_empty() {
let hint = Paragraph::new(Span::styled(" No matches", Style::default().fg(p.dim)));
f.render_widget(hint, results_area);
}
let truncation_note = if app.search.truncated_at_cap {
format!(" … results capped at {RESULT_CAP} files")
} else {
String::new()
};
let count_label = if result_count == 0 {
if app.search.query.is_empty() {
String::new()
} else {
" No results".to_string()
}
} else if is_content_mode {
format!(" {result_count} files, {total_matches} matches")
} else {
format!(" {result_count} files")
};
let footer_line = Line::from(vec![
Span::styled(count_label, Style::default().fg(p.dim)),
Span::styled(truncation_note, Style::default().fg(p.accent_alt)),
Span::styled(
" j/k: navigate Enter: open Tab: toggle mode",
Style::default().fg(p.dim),
),
]);
f.render_widget(Paragraph::new(footer_line), footer_area);
}
fn truncate_str(s: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let count = s.chars().count();
if count <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
format!("{truncated}…")
}
}
fn percent_rect(width_pct: u16, height_pct: u16, area: Rect) -> Rect {
let height = (area.height * height_pct / 100).max(4);
let width = (area.width * width_pct / 100).max(20);
let vertical = Layout::vertical([Constraint::Length(height)])
.flex(Flex::Center)
.split(area);
Layout::horizontal([Constraint::Length(width)])
.flex(Flex::Center)
.split(vertical[0])[0]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SearchPreview;
#[test]
fn smartcase_all_lowercase_is_insensitive() {
assert!(!smartcase_is_sensitive("hello world"));
}
#[test]
fn smartcase_mixed_case_is_sensitive() {
assert!(smartcase_is_sensitive("Hello"));
}
#[test]
fn smartcase_all_uppercase_is_sensitive() {
assert!(smartcase_is_sensitive("HELLO"));
}
#[test]
fn smartcase_empty_query_is_insensitive() {
assert!(!smartcase_is_sensitive(""));
}
#[test]
fn smartcase_digits_only_is_insensitive() {
assert!(!smartcase_is_sensitive("12345"));
}
#[test]
fn full_line_preview_trims_whitespace() {
let preview = build_preview(" hello world ", "hello", SearchPreview::FullLine);
assert_eq!(preview, "hello world");
}
#[test]
fn snippet_preview_within_window_returns_full_trimmed() {
let line = "short line with match";
let preview = build_preview(line, "match", SearchPreview::Snippet);
assert_eq!(preview, "short line with match");
}
#[test]
fn snippet_preview_centres_on_match() {
let prefix = "a".repeat(50);
let suffix = "b".repeat(50);
let line = format!("{prefix}MATCH{suffix}");
let preview = build_preview(&line, "MATCH", SearchPreview::Snippet);
assert!(
preview.contains("MATCH"),
"preview should contain the match text"
);
assert!(
preview.chars().count() <= 82,
"snippet too long: {} chars",
preview.chars().count()
);
}
#[test]
fn snippet_adds_ellipsis_when_truncated_from_start() {
let prefix = "x".repeat(100);
let line = format!("{prefix} keyword here");
let preview = build_preview(&line, "keyword", SearchPreview::Snippet);
assert!(
preview.starts_with('…'),
"should start with ellipsis when start was trimmed"
);
}
#[test]
fn build_preview_with_empty_query_does_not_panic() {
let preview = build_preview("some content here", "", SearchPreview::Snippet);
assert!(!preview.is_empty());
}
#[test]
fn truncate_str_no_op_when_short() {
let s = "hello";
assert_eq!(truncate_str(s, 10), "hello");
}
#[test]
fn truncate_str_appends_ellipsis() {
let result = truncate_str("hello world", 8);
assert!(result.ends_with('…'));
assert!(result.chars().count() <= 8);
}
#[test]
fn truncate_str_zero_budget_returns_empty() {
assert_eq!(truncate_str("anything", 0), "");
}
#[test]
fn search_state_next_wraps() {
let mut state = SearchState {
active: true,
query: "q".to_string(),
mode: SearchMode::FileName,
selected_index: 1,
truncated_at_cap: false,
results: vec![
SearchResult {
path: PathBuf::from("/a.md"),
name: "a.md".to_string(),
match_count: 0,
preview: String::new(),
first_match_line: None,
},
SearchResult {
path: PathBuf::from("/b.md"),
name: "b.md".to_string(),
match_count: 0,
preview: String::new(),
first_match_line: None,
},
],
};
state.next_result();
assert_eq!(state.selected_index, 0);
}
#[test]
fn search_state_prev_wraps() {
let mut state = SearchState {
active: true,
query: "q".to_string(),
mode: SearchMode::FileName,
selected_index: 0,
truncated_at_cap: false,
results: vec![
SearchResult {
path: PathBuf::from("/a.md"),
name: "a.md".to_string(),
match_count: 0,
preview: String::new(),
first_match_line: None,
},
SearchResult {
path: PathBuf::from("/b.md"),
name: "b.md".to_string(),
match_count: 0,
preview: String::new(),
first_match_line: None,
},
],
};
state.prev_result();
assert_eq!(state.selected_index, 1);
}
}