use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
symbols::border,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::models::{Priority, Task, TaskStatus};
use crate::utils::format_relative_date;
use super::theme;
#[derive(Debug, Clone)]
pub struct SearchResult {
pub task: Task,
pub title_match: Option<(usize, usize)>,
pub desc_snippet: Option<String>,
pub tag_match: Option<String>,
}
pub fn search_tasks(query: &str, tasks: &[Task]) -> Vec<SearchResult> {
let query_lower = query.to_lowercase();
if query_lower.is_empty() {
return Vec::new();
}
let tag_query = if query_lower.starts_with('#') {
Some(query_lower.trim_start_matches('#'))
} else {
None
};
tasks
.iter()
.filter_map(|task| {
let title_lower = task.title.to_lowercase();
let title_match = title_lower.find(&query_lower).map(|start| {
(start, start + query_lower.len())
});
let desc_snippet = task.description.as_ref().and_then(|desc| {
let desc_lower = desc.to_lowercase();
desc_lower.find(&query_lower).map(|pos| {
let start = pos.saturating_sub(20);
let end = (pos + query_lower.len() + 30).min(desc.len());
let prefix = if start > 0 { "..." } else { "" };
let suffix = if end < desc.len() { "..." } else { "" };
format!("{}{}{}", prefix, &desc[start..end], suffix)
})
});
let search_term = tag_query.unwrap_or(&query_lower);
let tag_match = task.tags.iter().find(|tag| {
tag.to_lowercase().contains(search_term)
}).cloned();
if title_match.is_some() || desc_snippet.is_some() || tag_match.is_some() {
Some(SearchResult {
task: task.clone(),
title_match,
desc_snippet,
tag_match,
})
} else {
None
}
})
.collect()
}
pub fn render_search(
frame: &mut Frame,
query: &str,
cursor_pos: usize,
results: &[SearchResult],
selected_index: usize,
area: Rect,
) {
render_search_with_context(frame, query, cursor_pos, results, selected_index, area, None);
}
pub fn render_search_with_context(
frame: &mut Frame,
query: &str,
cursor_pos: usize,
results: &[SearchResult],
selected_index: usize,
area: Rect,
project_name: Option<&str>,
) {
frame.render_widget(Clear, area);
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Min(0), ])
.split(area);
render_search_input_with_context(frame, query, cursor_pos, chunks[0], project_name);
render_search_results(frame, query, results, selected_index, chunks[1]);
}
fn render_search_input_with_context(
frame: &mut Frame,
query: &str,
cursor_pos: usize,
area: Rect,
project_name: Option<&str>,
) {
let title = match project_name {
Some(name) if name != "All Tasks" => format!(" Search in: {} ", name),
_ => " Search Tasks ".to_string(),
};
let block = Block::default()
.title(Span::styled(
title,
Style::default().fg(theme::INFO).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(theme::INFO))
.style(Style::default().bg(theme::BG_ELEVATED));
let inner = block.inner(area);
frame.render_widget(block, area);
let display_text = format!("/{}", query);
let mut spans = Vec::new();
for (i, c) in display_text.chars().enumerate() {
let style = if i == cursor_pos + 1 {
Style::default().bg(theme::ACCENT).fg(theme::BG_DARK)
} else {
Style::default().fg(theme::TEXT_PRIMARY)
};
spans.push(Span::styled(c.to_string(), style));
}
if cursor_pos >= query.len() {
spans.push(Span::styled(" ", Style::default().bg(theme::ACCENT)));
}
let paragraph = Paragraph::new(Line::from(spans));
frame.render_widget(paragraph, inner);
}
fn render_search_results(
frame: &mut Frame,
query: &str,
results: &[SearchResult],
selected_index: usize,
area: Rect,
) {
let block = Block::default()
.title(Span::styled(
format!(" Results ({}) ", results.len()),
Style::default().fg(theme::TEXT_SECONDARY),
))
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(theme::BORDER))
.style(Style::default().bg(theme::BG_ELEVATED));
let inner = block.inner(area);
frame.render_widget(block, area);
if results.is_empty() {
let msg = if query.is_empty() {
"Type to search..."
} else {
"No matching tasks found"
};
let paragraph = Paragraph::new(msg)
.style(Style::default().fg(theme::TEXT_MUTED));
frame.render_widget(paragraph, inner);
return;
}
let mut lines: Vec<Line> = Vec::new();
let visible_height = inner.height as usize;
let lines_per_result = 2;
let visible_results = visible_height / lines_per_result;
let scroll_offset = if selected_index >= visible_results {
selected_index - visible_results + 1
} else {
0
};
for (i, result) in results.iter().enumerate().skip(scroll_offset) {
if lines.len() >= visible_height {
break;
}
let is_selected = i == selected_index;
let task_line = render_task_result(&result.task, result.title_match, is_selected, inner.width);
lines.push(task_line);
if lines.len() < visible_height {
if let Some(ref snippet) = result.desc_snippet {
let desc_line = render_description_snippet(snippet, query, is_selected);
lines.push(desc_line);
} else if let Some(ref tag) = result.tag_match {
let tag_line = render_tag_match(tag, query, is_selected);
lines.push(tag_line);
} else {
lines.push(Line::from(""));
}
}
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
fn render_task_result(
task: &Task,
title_match: Option<(usize, usize)>,
is_selected: bool,
width: u16,
) -> Line<'static> {
let checkbox = match task.status {
TaskStatus::Pending => "[ ]",
TaskStatus::InProgress => "[â–¸]",
TaskStatus::Completed | TaskStatus::Archived => "[✓]",
};
let priority = match task.priority {
Priority::Urgent => "!!",
Priority::High => " !",
Priority::Medium => " ",
Priority::Low => " ↓",
};
let priority_style = match task.priority {
Priority::Urgent => Style::default().fg(theme::PRIORITY_URGENT).add_modifier(Modifier::BOLD),
Priority::High => Style::default().fg(theme::PRIORITY_HIGH),
Priority::Medium => Style::default(),
Priority::Low => Style::default().fg(theme::PRIORITY_LOW),
};
let due_str = task
.due_date
.map(format_relative_date)
.unwrap_or_default();
let fixed_width = 5 + 3 + 3 + 2 + due_str.len() + 2; let title_width = (width as usize).saturating_sub(fixed_width).max(10);
let base_style = if task.status == TaskStatus::Completed || task.status == TaskStatus::Archived {
Style::default().fg(theme::TEXT_COMPLETED)
} else if task.is_overdue() {
Style::default().fg(theme::DUE_OVERDUE)
} else if task.is_due_today() {
Style::default().fg(theme::DUE_TODAY)
} else if task.is_due_this_week() {
Style::default().fg(theme::DUE_WEEK)
} else {
Style::default().fg(theme::TEXT_PRIMARY)
};
let (selector, selector_style) = if is_selected {
("â–¶ ", Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD))
} else {
(" ", Style::default())
};
let row_style = if is_selected {
base_style.add_modifier(Modifier::BOLD)
} else {
base_style
};
let mut spans = vec![
Span::styled(selector.to_string(), selector_style),
Span::styled(format!("{} ", checkbox), row_style),
Span::styled(format!("{} ", priority), priority_style),
];
let title = &task.title;
if let Some((start, end)) = title_match {
if start > 0 {
let before = truncate_str(&title[..start], title_width);
spans.push(Span::styled(before, row_style));
}
let match_text = &title[start..end.min(title.len())];
let match_style = row_style.add_modifier(Modifier::UNDERLINED | Modifier::BOLD);
spans.push(Span::styled(match_text.to_string(), match_style));
if end < title.len() {
let remaining_width = title_width.saturating_sub(end);
let after = truncate_str(&title[end..], remaining_width);
spans.push(Span::styled(after, row_style));
}
} else {
let truncated = truncate_str(title, title_width);
spans.push(Span::styled(truncated, row_style));
}
if !due_str.is_empty() {
spans.push(Span::styled(
format!(" {}", due_str),
Style::default().fg(theme::TEXT_MUTED),
));
}
Line::from(spans)
}
fn render_tag_match(tag: &str, query: &str, _is_selected: bool) -> Line<'static> {
let indent = " "; let tag_style = Style::default().fg(theme::TAG);
let query_clean = query.to_lowercase().trim_start_matches('#').to_string();
let tag_lower = tag.to_lowercase();
if let Some(pos) = tag_lower.find(&query_clean) {
let before = &tag[..pos];
let matched = &tag[pos..pos + query_clean.len()];
let after = &tag[pos + query_clean.len()..];
let match_style = tag_style.add_modifier(Modifier::UNDERLINED | Modifier::BOLD);
Line::from(vec![
Span::styled(indent.to_string(), Style::default()),
Span::styled("Tag: #".to_string(), Style::default().fg(theme::TEXT_MUTED)),
Span::styled(before.to_string(), tag_style),
Span::styled(matched.to_string(), match_style),
Span::styled(after.to_string(), tag_style),
])
} else {
Line::from(vec![
Span::styled(indent.to_string(), Style::default()),
Span::styled("Tag: ".to_string(), Style::default().fg(theme::TEXT_MUTED)),
Span::styled(format!("#{}", tag), tag_style),
])
}
}
fn render_description_snippet(snippet: &str, query: &str, _is_selected: bool) -> Line<'static> {
let indent = " "; let base_style = Style::default().fg(theme::TEXT_MUTED);
let query_lower = query.to_lowercase();
let snippet_lower = snippet.to_lowercase();
if let Some(pos) = snippet_lower.find(&query_lower) {
let before = &snippet[..pos];
let matched = &snippet[pos..pos + query.len()];
let after = &snippet[pos + query.len()..];
let match_style = base_style.add_modifier(Modifier::UNDERLINED | Modifier::BOLD);
Line::from(vec![
Span::styled(indent.to_string(), Style::default()),
Span::styled(before.to_string(), base_style),
Span::styled(matched.to_string(), match_style),
Span::styled(after.to_string(), base_style),
])
} else {
Line::from(vec![
Span::styled(indent.to_string(), Style::default()),
Span::styled(snippet.to_string(), base_style),
])
}
}
fn truncate_str(s: &str, max_width: usize) -> String {
if s.len() <= max_width {
s.to_string()
} else if max_width > 3 {
format!("{}...", &s[..max_width - 3])
} else {
s[..max_width].to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Priority;
fn sample_tasks() -> Vec<Task> {
vec![
{
let mut t = Task::new("Buy groceries");
t.description = Some("Get milk and bread from the store".to_string());
t.tags = vec!["shopping".to_string(), "home".to_string()];
t
},
{
let mut t = Task::new("Write documentation");
t.description = Some("Update the README file".to_string());
t.tags = vec!["work".to_string()];
t
},
{
let mut t = Task::new("Fix bug in search");
t.priority = Priority::High;
t.tags = vec!["work".to_string(), "urgent".to_string()];
t
},
]
}
#[test]
fn test_search_by_title() {
let tasks = sample_tasks();
let results = search_tasks("groceries", &tasks);
assert_eq!(results.len(), 1);
assert_eq!(results[0].task.title, "Buy groceries");
assert!(results[0].title_match.is_some());
}
#[test]
fn test_search_by_description() {
let tasks = sample_tasks();
let results = search_tasks("README", &tasks);
assert_eq!(results.len(), 1);
assert_eq!(results[0].task.title, "Write documentation");
assert!(results[0].desc_snippet.is_some());
}
#[test]
fn test_search_by_tag() {
let tasks = sample_tasks();
let results = search_tasks("shopping", &tasks);
assert_eq!(results.len(), 1);
assert_eq!(results[0].task.title, "Buy groceries");
assert!(results[0].tag_match.is_some());
assert_eq!(results[0].tag_match.as_ref().unwrap(), "shopping");
}
#[test]
fn test_search_by_tag_with_hash() {
let tasks = sample_tasks();
let results = search_tasks("#urgent", &tasks);
assert_eq!(results.len(), 1);
assert_eq!(results[0].task.title, "Fix bug in search");
assert!(results[0].tag_match.is_some());
}
#[test]
fn test_search_tag_multiple_results() {
let tasks = sample_tasks();
let results = search_tasks("work", &tasks);
assert_eq!(results.len(), 2); }
#[test]
fn test_search_case_insensitive() {
let tasks = sample_tasks();
let results = search_tasks("BUG", &tasks);
assert_eq!(results.len(), 1);
assert_eq!(results[0].task.title, "Fix bug in search");
}
#[test]
fn test_search_no_results() {
let tasks = sample_tasks();
let results = search_tasks("nonexistent", &tasks);
assert!(results.is_empty());
}
#[test]
fn test_search_empty_query() {
let tasks = sample_tasks();
let results = search_tasks("", &tasks);
assert!(results.is_empty());
}
#[test]
fn test_search_multiple_results() {
let tasks = sample_tasks();
let results = search_tasks("the", &tasks);
assert_eq!(results.len(), 2);
}
}