use super::pane::{Entry, EntryKind, Pane};
use super::vim_input::{Outcome, SubMode, VimInput};
use crate::action::Action;
use crate::mode::Mode;
use crate::provider::SearchItem;
use crate::theme::Theme;
use ratatui::Frame;
use ratatui::crossterm::cursor::SetCursorStyle;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Input,
Results,
}
pub struct SearchPopup {
pub forge: String,
pub input: VimInput,
results: Pane,
focus: Focus,
filter: VimInput,
filtering: bool,
pre_filter: String,
pending: bool,
error: Option<String>,
submitted_once: bool,
}
impl Default for SearchPopup {
fn default() -> Self {
Self::new()
}
}
impl SearchPopup {
pub fn new() -> Self {
Self::with_prefill(None)
}
pub fn with_prefill(prefill: Option<&str>) -> Self {
let mut results = Pane::new("results", vec![]);
results.show_badges = true;
let mut input = VimInput::new();
if let Some(p) = prefill {
input.prefill(p);
}
SearchPopup {
forge: String::new(),
input,
results,
focus: Focus::Input,
filter: VimInput::transient(),
filtering: false,
pre_filter: String::new(),
pending: false,
error: None,
submitted_once: false,
}
}
pub fn update(&mut self, action: &Action) {
match action {
Action::SearchSubmitted(_) => {
self.submitted_once = true;
self.pending = true;
self.error = None;
self.focus = Focus::Results;
}
Action::SearchResults { items } => {
self.pending = false;
let entries = items
.iter()
.map(|item| match item {
SearchItem::Repo(full) => Entry::new(full, EntryKind::Repo),
SearchItem::Org(login) => Entry::new(login, EntryKind::Org),
})
.collect();
self.results = Pane::new("results", entries);
self.results.show_badges = true;
}
Action::SearchFailed { error } => {
self.pending = false;
self.error = Some(crate::app::provider_status(error));
self.results = Pane::new("results", vec![]);
self.results.show_badges = true;
}
_ => {}
}
}
pub fn effective_mode(&self) -> Mode {
if self.filtering {
return Mode::Search;
}
match self.focus {
Focus::Input => match self.input.submode {
SubMode::Insert => Mode::Insert,
SubMode::Normal => Mode::Normal,
},
Focus::Results => Mode::Browse,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Action {
if self.filtering {
return match self.filter.handle_key(key) {
Outcome::Changed => {
self.results.set_filter(self.filter.value());
Action::Noop
}
Outcome::Submitted => {
self.filtering = false; Action::Noop
}
Outcome::Cancelled => {
self.results.set_filter(self.pre_filter.clone());
self.filtering = false;
Action::Noop
}
Outcome::Noop => Action::Noop,
};
}
if key.code == KeyCode::Tab {
self.toggle_focus();
return Action::Noop;
}
match self.focus {
Focus::Input => match self.input.handle_key(key) {
Outcome::Submitted => {
self.filter.clear();
Action::SearchSubmitted(self.input.value())
}
Outcome::Cancelled => Action::ClosePopup,
_ => Action::Noop,
},
Focus::Results => match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.results.update(&Action::MoveDown);
Action::Noop
}
KeyCode::Char('k') | KeyCode::Up => {
self.results.update(&Action::MoveUp);
Action::Noop
}
KeyCode::Char('/') => {
self.pre_filter = self.results.filter.clone();
self.filter.set(&self.pre_filter);
self.filtering = true;
Action::Noop
}
KeyCode::Char('y') => Action::SearchYank,
KeyCode::Enter => {
if let Some(entry) = self.results.selected_entry() {
return match entry.kind {
EntryKind::Org => Action::OrgSelected(entry.name.clone()),
_ => {
let (owner, name) = split_repo(&entry.name);
Action::RepoSelected { owner, name }
}
};
}
Action::Noop
}
KeyCode::Esc if !self.results.filter.is_empty() => {
self.results.set_filter(String::new());
Action::Noop
}
KeyCode::Esc => Action::ClosePopup,
_ => Action::Noop,
},
}
}
pub fn selected_entry(&self) -> Option<&crate::components::pane::Entry> {
self.results.selected_entry()
}
fn toggle_focus(&mut self) {
self.focus = match self.focus {
Focus::Input => Focus::Results,
Focus::Results => {
self.input.submode = SubMode::Insert;
Focus::Input
}
};
}
pub fn cursor_style(&self) -> Option<SetCursorStyle> {
if self.focus != Focus::Input {
return None;
}
Some(match self.input.submode {
SubMode::Insert => SetCursorStyle::SteadyBar,
SubMode::Normal => SetCursorStyle::SteadyBlock,
})
}
pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let sem = &theme.semantic;
let popup = super::centered(area, 60, 60);
frame.render_widget(Clear, popup);
let hint = if self.filtering {
" type to filter · enter commit · esc cancel "
} else {
" tab focus · enter submit/select · / filter · esc close "
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type())
.border_style(Style::default().fg(sem.border_focused))
.style(Style::default().bg(sem.mantle))
.title(Span::styled(
format!(" search {} ", self.forge),
Style::default().fg(sem.text).add_modifier(Modifier::BOLD),
))
.title_bottom(Span::styled(hint, Style::default().fg(sem.hint)));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(inner);
let input_border = if self.focus == Focus::Input {
sem.border_focused
} else {
sem.border_unfocused
};
let input_block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type())
.border_style(Style::default().fg(input_border))
.style(Style::default().bg(sem.base));
let input_inner = input_block.inner(rows[0]);
frame.render_widget(input_block, rows[0]);
let prompt = if self.focus == Focus::Input {
Span::styled("❯ ", Style::default().fg(sem.border_focused))
} else {
Span::styled("❯ ", Style::default().fg(sem.overlay0))
};
frame.render_widget(
Paragraph::new(Line::from(vec![
prompt,
Span::styled(self.input.value(), Style::default().fg(sem.text)),
])),
input_inner,
);
if self.focus == Focus::Input {
let x = input_inner.x + 2 + self.input.cursor() as u16;
if x < input_inner.x + input_inner.width {
frame.set_cursor_position((x, input_inner.y));
}
}
self.results.title = if self.pending {
"results — searching…".into()
} else if let Some(error) = &self.error {
format!("results — error: {error}")
} else if self.results.entries.is_empty() && self.submitted_once {
"results — no matches".into()
} else {
"results".into()
};
self.results.focused = self.focus == Focus::Results;
self.results.render(frame, rows[1], theme);
}
}
fn split_repo(full: &str) -> (String, String) {
let mut parts = full.splitn(2, '/');
(
parts.next().unwrap_or_default().to_string(),
parts.next().unwrap_or_default().to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_shape_follows_input_submode() {
let mut popup = SearchPopup::new();
assert!(matches!(
popup.cursor_style(),
Some(SetCursorStyle::SteadyBar)
));
popup.input.submode = SubMode::Normal;
assert!(matches!(
popup.cursor_style(),
Some(SetCursorStyle::SteadyBlock)
));
popup.toggle_focus(); assert!(popup.cursor_style().is_none());
}
#[test]
fn prefill_seeds_query_and_replaces_on_typing() {
use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
let mut popup = SearchPopup::with_prefill(Some("ratatui/ratatui"));
assert_eq!(popup.input.value(), "ratatui/ratatui");
popup.input.handle_key(key(KeyCode::Char('h')));
popup.input.handle_key(key(KeyCode::Char('i')));
assert_eq!(popup.input.value(), "hi");
}
}