use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use super::theme::EditorTheme;
#[derive(Debug, Clone)]
pub struct SearchMatch {
pub line: usize,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, Default)]
pub struct SearchState {
pattern: String,
matches: Vec<SearchMatch>,
line_index: std::collections::HashMap<usize, (usize, usize)>,
current_index: usize,
}
impl SearchState {
pub fn new() -> Self {
Self::default()
}
pub fn is_searching(&self) -> bool {
!self.pattern.is_empty()
}
pub fn search(&mut self, pattern: &str, lines: &[String]) -> usize {
self.pattern = pattern.to_string();
self.matches.clear();
self.line_index.clear();
self.current_index = 0;
if pattern.is_empty() {
return 0;
}
let pattern_char_len = pattern.chars().count();
for (line_idx, line) in lines.iter().enumerate() {
let match_start_idx = self.matches.len();
let mut byte_start = 0;
while let Some(byte_pos) = line[byte_start..].find(pattern) {
let abs_byte = byte_start + byte_pos;
let char_start = line[..abs_byte].chars().count();
self.matches.push(SearchMatch {
line: line_idx,
start: char_start,
end: char_start + pattern_char_len,
});
byte_start = abs_byte + pattern.len();
if byte_start >= line.len() {
break;
}
}
let count = self.matches.len() - match_start_idx;
if count > 0 {
self.line_index.insert(line_idx, (match_start_idx, count));
}
}
self.matches.len()
}
pub fn current_match(&self) -> Option<&SearchMatch> {
self.matches.get(self.current_index)
}
pub fn next_match(&mut self) {
if !self.matches.is_empty() {
self.current_index = (self.current_index + 1) % self.matches.len();
}
}
pub fn prev_match(&mut self) {
if !self.matches.is_empty() {
self.current_index = if self.current_index == 0 {
self.matches.len() - 1
} else {
self.current_index - 1
};
}
}
pub fn match_count(&self) -> usize {
self.matches.len()
}
pub fn highlight_line(
&self,
line_idx: usize,
line: &str,
theme: &EditorTheme,
char_offset: usize,
) -> Vec<Span<'static>> {
let normal_style = Style::default().fg(theme.text_normal);
let highlight_style = Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let line_matches = if let Some(&(start, count)) = self.line_index.get(&line_idx) {
&self.matches[start..start + count]
} else {
return vec![Span::styled(line.to_string(), normal_style)];
};
if line_matches.is_empty() || self.pattern.is_empty() {
return vec![Span::styled(line.to_string(), normal_style)];
}
let chars: Vec<char> = line.chars().collect();
let char_end = char_offset + chars.len();
let mut spans = Vec::new();
let mut last_local = 0;
for m in line_matches {
if m.end <= char_offset || m.start >= char_end {
continue;
}
let local_start = m.start.saturating_sub(char_offset).min(chars.len());
let local_end = m.end.saturating_sub(char_offset).min(chars.len());
if local_start > last_local {
let text: String = chars[last_local..local_start].iter().collect();
spans.push(Span::styled(text, normal_style));
}
if local_start < local_end {
let match_text: String = chars[local_start..local_end].iter().collect();
spans.push(Span::styled(match_text, highlight_style));
}
last_local = local_end;
}
if last_local < chars.len() {
let text: String = chars[last_local..].iter().collect();
spans.push(Span::styled(text, normal_style));
}
if spans.is_empty() {
return vec![Span::styled(line.to_string(), normal_style)];
}
spans
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
fn test_theme() -> EditorTheme {
EditorTheme {
bg_primary: Color::Reset,
bg_input: Color::Reset,
code_bg: Color::DarkGray,
cursor_fg: Color::Black,
cursor_bg: Color::Cyan,
text_normal: Color::White,
text_dim: Color::DarkGray,
text_bold: Color::White,
md_h1: Color::Cyan,
md_h2: Color::Green,
md_h3: Color::Yellow,
md_h4: Color::Magenta,
md_link: Color::Blue,
md_list_bullet: Color::Yellow,
md_blockquote_bar: Color::Cyan,
md_blockquote_bg: Color::DarkGray,
md_blockquote_text: Color::Gray,
md_inline_code_fg: Color::Magenta,
md_inline_code_bg: Color::DarkGray,
code_default: Color::White,
code_keyword: Color::Magenta,
code_string: Color::Green,
code_comment: Color::DarkGray,
code_number: Color::Yellow,
code_type: Color::Yellow,
code_primitive: Color::Cyan,
code_macro: Color::LightCyan,
code_lifetime: Color::LightMagenta,
code_attribute: Color::LightBlue,
code_shell_var: Color::LightCyan,
}
}
#[test]
fn test_search() {
let mut search = SearchState::new();
let lines = vec!["hello world".to_string(), "hello universe".to_string()];
let count = search.search("hello", &lines);
assert_eq!(count, 2);
assert_eq!(search.match_count(), 2);
}
#[test]
fn test_navigation() {
let mut search = SearchState::new();
let lines = vec!["aaa bbb aaa".to_string()];
search.search("aaa", &lines);
let m = search.current_match().unwrap();
assert_eq!(m.start, 0);
search.next_match();
let m = search.current_match().unwrap();
assert_eq!(m.start, 8);
search.prev_match();
let m = search.current_match().unwrap();
assert_eq!(m.start, 0);
}
#[test]
fn test_search_chinese() {
let mut search = SearchState::new();
let lines = vec!["你好世界,你好宇宙".to_string()];
let count = search.search("你好", &lines);
assert_eq!(count, 2);
let m = search.current_match().unwrap();
assert_eq!(m.start, 0);
assert_eq!(m.end, 2);
search.next_match();
let m = search.current_match().unwrap();
assert_eq!(m.start, 5);
assert_eq!(m.end, 7);
}
#[test]
fn test_highlight_line_with_offset() {
let mut search = SearchState::new();
let lines = vec!["hello world hello".to_string()];
search.search("hello", &lines);
assert_eq!(search.match_count(), 2);
let theme = test_theme();
let spans = search.highlight_line(0, "d hello", &theme, 10);
assert!(spans.len() >= 2);
}
#[test]
fn test_highlight_chinese_no_panic() {
let mut search = SearchState::new();
let lines = vec!["测试中文搜索功能".to_string()];
search.search("中文", &lines);
let theme = test_theme();
let spans = search.highlight_line(0, "测试中文搜索功能", &theme, 0);
assert!(!spans.is_empty());
let spans = search.highlight_line(0, "搜索功能", &theme, 4);
assert!(!spans.is_empty());
}
}