use ratatui::{
Frame,
prelude::{Position, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
};
use crate::i18n;
use crate::state::AppState;
use crate::state::types::AppMode;
use crate::theme::theme;
fn build_input_spans<'a>(
app: &AppState,
search_focused: bool,
th: &'a crate::theme::Theme,
) -> Vec<Span<'a>> {
let mut input_spans: Vec<Span> = Vec::new();
input_spans.push(Span::styled(
"> ",
Style::default().fg(if search_focused {
th.sapphire
} else {
th.overlay1
}),
));
let (input_text, caret_ci, select_anchor) = if matches!(app.app_mode, AppMode::News) {
(
&app.news_search_input,
app.news_search_caret,
app.news_search_select_anchor,
)
} else {
(&app.input, app.search_caret, app.search_select_anchor)
};
if search_focused && app.search_normal_mode {
let (sel_from_ci, sel_to_ci) = select_anchor.map_or((caret_ci, caret_ci), |anchor| {
(anchor.min(caret_ci), anchor.max(caret_ci))
});
let cc = input_text.chars().count();
let sel_from_ci = sel_from_ci.min(cc);
let sel_to_ci = sel_to_ci.min(cc);
let from_b = {
if sel_from_ci == 0 {
0
} else {
input_text
.char_indices()
.map(|(i, _)| i)
.nth(sel_from_ci)
.unwrap_or(input_text.len())
}
};
let to_b = {
if sel_to_ci == 0 {
0
} else {
input_text
.char_indices()
.map(|(i, _)| i)
.nth(sel_to_ci)
.unwrap_or(input_text.len())
}
};
let pre = &input_text[..from_b];
let sel = &input_text[from_b..to_b];
let post = &input_text[to_b..];
if !pre.is_empty() {
input_spans.push(Span::styled(
pre.to_string(),
Style::default().fg(if search_focused { th.text } else { th.subtext0 }),
));
}
if sel_from_ci != sel_to_ci {
input_spans.push(Span::styled(
sel.to_string(),
Style::default()
.fg(th.crust)
.bg(th.lavender)
.add_modifier(Modifier::BOLD),
));
}
if !post.is_empty() {
input_spans.push(Span::styled(
post.to_string(),
Style::default().fg(if search_focused { th.text } else { th.subtext0 }),
));
}
} else {
input_spans.push(Span::styled(
input_text.as_str().to_string(),
Style::default().fg(if search_focused { th.text } else { th.subtext0 }),
));
}
input_spans
}
fn build_title_line<'a>(
app: &AppState,
search_focused: bool,
th: &'a crate::theme::Theme,
) -> (Line<'a>, usize, String) {
let search_title_base = if matches!(app.app_mode, AppMode::News) {
"News search".to_string()
} else if search_focused {
i18n::t(app, "app.titles.search_focused")
} else {
i18n::t(app, "app.titles.search")
};
let search_title_color = if search_focused {
th.mauve
} else {
th.overlay1
};
let base_title_len = search_title_base.chars().count();
let mut title_spans = vec![Span::styled(
search_title_base,
Style::default().fg(search_title_color),
)];
let mode_text = if app.fuzzy_search_enabled {
i18n::t(app, "app.search_mode_fuzzy")
} else {
i18n::t(app, "app.search_mode_normal")
};
let mode_color = if app.fuzzy_search_enabled {
th.sapphire
} else {
th.subtext0
};
title_spans.push(Span::styled(
format!(" [{mode_text}]"),
Style::default().fg(mode_color),
));
(Line::from(title_spans), base_title_len, mode_text)
}
fn store_fuzzy_indicator_rect(
app: &mut AppState,
area: Rect,
base_title_len: usize,
mode_text: &str,
) {
let mode_indicator_len = mode_text.chars().count() + 3; let max_indicator_width = mode_indicator_len.min(20);
let max_indicator_width_u16 = u16::try_from(max_indicator_width).unwrap_or(u16::MAX);
let available_width = area.width.saturating_sub(max_indicator_width_u16);
let title_end_x = area.x.saturating_add(1).saturating_add(
u16::try_from(
base_title_len
.min(available_width as usize)
.min(u16::MAX as usize),
)
.unwrap_or(u16::MAX),
);
app.fuzzy_indicator_rect = Some((
title_end_x,
area.y,
max_indicator_width_u16,
1, ));
}
pub fn render_search(f: &mut Frame, app: &mut AppState, area: Rect) {
let th = theme();
let search_focused = matches!(app.focus, crate::state::Focus::Search);
let input_spans = build_input_spans(app, search_focused, &th);
let input_line = Line::from(input_spans);
let (search_title, base_title_len, mode_text) = build_title_line(app, search_focused, &th);
let input = Paragraph::new(input_line)
.style(
Style::default()
.fg(if search_focused { th.text } else { th.subtext0 })
.bg(th.base),
)
.block(
Block::default()
.title(search_title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(if search_focused {
th.mauve
} else {
th.surface1
})),
);
f.render_widget(input, area);
store_fuzzy_indicator_rect(app, area, base_title_len, &mode_text);
let right = area.x + area.width.saturating_sub(1);
let (input_text, caret_pos) = if matches!(app.app_mode, AppMode::News) {
(&app.news_search_input, app.news_search_caret)
} else {
(&app.input, app.search_caret)
};
let caret_cols: u16 = if search_focused {
let mut ci: u16 = 0;
let mut it = input_text.chars();
for _ in 0..caret_pos {
if it.next().is_some() {
ci = ci.saturating_add(1);
} else {
break;
}
}
ci
} else {
u16::try_from(input_text.len()).unwrap_or(u16::MAX)
};
let x = std::cmp::min(area.x + 1 + 2 + caret_cols, right);
let y = area.y + 1;
f.set_cursor_position(Position::new(x, y));
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
fn init_test_translations(app: &mut crate::state::AppState) {
use std::collections::HashMap;
let mut translations = HashMap::new();
translations.insert("app.titles.search".to_string(), "Search".to_string());
translations.insert(
"app.titles.search_focused".to_string(),
"Search".to_string(),
);
app.translations = translations.clone();
app.translations_fallback = translations;
}
#[test]
fn search_renders_and_sets_cursor_when_focused() {
let backend = TestBackend::new(100, 30);
let mut term = Terminal::new(backend).expect("Failed to create terminal for test");
let mut app = crate::state::AppState::default();
init_test_translations(&mut app);
app.focus = crate::state::Focus::Search;
app.input = "hello".into();
app.search_caret = 3;
term.draw(|f| {
let area = f.area();
render_search(f, &mut app, area);
})
.expect("Failed to render search pane");
}
#[test]
fn search_renders_without_selection_when_not_normal_mode() {
let backend = TestBackend::new(100, 30);
let mut term = Terminal::new(backend).expect("Failed to create terminal for test");
let mut app = crate::state::AppState::default();
init_test_translations(&mut app);
app.focus = crate::state::Focus::Search;
app.input = "test".into();
app.search_normal_mode = false;
app.search_caret = 2;
app.search_select_anchor = Some(1);
term.draw(|f| {
let area = f.area();
render_search(f, &mut app, area);
})
.expect("Failed to render search pane without selection");
}
#[test]
fn search_renders_with_selection_in_normal_mode() {
let backend = TestBackend::new(100, 30);
let mut term = Terminal::new(backend).expect("Failed to create terminal for test");
let mut app = crate::state::AppState::default();
init_test_translations(&mut app);
app.focus = crate::state::Focus::Search;
app.input = "hello".into();
app.search_normal_mode = true;
app.search_caret = 3;
app.search_select_anchor = Some(1);
term.draw(|f| {
let area = f.area();
render_search(f, &mut app, area);
})
.expect("Failed to render search pane with selection");
}
#[test]
fn search_renders_when_unfocused() {
let backend = TestBackend::new(100, 30);
let mut term = Terminal::new(backend).expect("Failed to create terminal for test");
let mut app = crate::state::AppState::default();
init_test_translations(&mut app);
app.focus = crate::state::Focus::Recent;
app.input = "test".into();
app.search_caret = 2;
term.draw(|f| {
let area = f.area();
render_search(f, &mut app, area);
})
.expect("Failed to render unfocused search pane");
}
#[test]
fn search_handles_empty_input() {
let backend = TestBackend::new(100, 30);
let mut term = Terminal::new(backend).expect("failed to create test terminal");
let mut app = crate::state::AppState::default();
init_test_translations(&mut app);
app.focus = crate::state::Focus::Search;
app.input = String::new();
app.search_caret = 0;
term.draw(|f| {
let area = f.area();
render_search(f, &mut app, area);
})
.expect("failed to draw test terminal");
}
}