#![allow(dead_code)] // Phase 3: consumed by view/mod.rs screen dispatch //! Search screen view — query bar, mode indicator, and results list. //! //! Layout: //! ```text //! +--[ FTS ]--- Search ──────────────────────+ //! | > query text here_ | //! +───────────────────────────────────────────+ //! | #42 Fix login bug group/proj | //! | !99 Add retry logic group/proj | //! | #10 Update docs other/repo | //! +───────────────────────────────────────────+ //! | Tab: mode /: focus j/k: nav Enter: go | //! +───────────────────────────────────────────+ //! ``` use ftui::core::geometry::Rect; use ftui::render::cell::Cell; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; use crate::layout::{classify_width, search_show_project}; use crate::message::EntityKind; use crate::state::search::SearchState; use crate::text_width::cursor_cell_offset; use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED}; // --------------------------------------------------------------------------- // render_search // --------------------------------------------------------------------------- /// Render the search screen. /// /// Composes: mode indicator + query bar (row 0), separator (row 1), /// results list (fill), and a hint bar at the bottom. pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) { if area.height < 4 || area.width < 20 { return; } let bp = classify_width(area.width); let show_project = search_show_project(bp); let mut y = area.y; let max_x = area.right(); // -- Mode indicator + query bar ------------------------------------------ y = render_query_bar(frame, state, area.x, y, area.width, max_x); // -- Separator ----------------------------------------------------------- if y >= area.bottom() { return; } let sep_cell = Cell { fg: BORDER, bg: BG_SURFACE, ..Cell::default() }; let sep_line = "─".repeat(area.width as usize); frame.print_text_clipped(area.x, y, &sep_line, sep_cell, max_x); y += 1; // -- No-index warning ---------------------------------------------------- if !state.capabilities.has_any_index() { if y >= area.bottom() { return; } let warn_cell = Cell { fg: ACCENT, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped(area.x + 1, y, "No search indexes found.", warn_cell, max_x); y += 1; if y < area.bottom() { let hint_cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped( area.x + 1, y, "Run: lore generate-docs && lore embed", hint_cell, max_x, ); } return; } // -- Results list -------------------------------------------------------- let bottom_hint_row = area.bottom().saturating_sub(1); let list_bottom = bottom_hint_row; let list_height = list_bottom.saturating_sub(y) as usize; if list_height == 0 { return; } if state.results.is_empty() { render_empty_state(frame, state, area.x + 1, y, max_x); } else { render_result_list( frame, state, area.x, y, area.width, list_height, show_project, ); } // -- Bottom hint bar ----------------------------------------------------- if bottom_hint_row < area.bottom() { render_hint_bar(frame, state, area.x, bottom_hint_row, max_x); } } // --------------------------------------------------------------------------- // Query bar // --------------------------------------------------------------------------- /// Render the mode badge and query input. Returns the next y position. fn render_query_bar( frame: &mut Frame<'_>, state: &SearchState, x: u16, y: u16, width: u16, max_x: u16, ) -> u16 { // Mode badge: [ FTS ] or [ Hybrid ] or [ Vec ] let mode_label = format!("[ {} ]", state.mode.label()); let mode_cell = Cell { fg: ACCENT, bg: BG_SURFACE, ..Cell::default() }; let after_mode = frame.print_text_clipped(x, y, &mode_label, mode_cell, max_x); // Space separator. let after_sep = frame.print_text_clipped( after_mode, y, " ", Cell { bg: BG_SURFACE, ..Cell::default() }, max_x, ); // Prompt. let prompt = "> "; let prompt_cell = Cell { fg: ACCENT, bg: BG_SURFACE, ..Cell::default() }; let after_prompt = frame.print_text_clipped(after_sep, y, prompt, prompt_cell, max_x); // Query text (or placeholder). let (display_text, text_fg) = if state.query.is_empty() { ("Type to search...", TEXT_MUTED) } else { (state.query.as_str(), TEXT) }; let text_cell = Cell { fg: text_fg, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped(after_prompt, y, display_text, text_cell, max_x); // Cursor (only when focused and has query text). if state.query_focused && !state.query.is_empty() { let cursor_x = after_prompt + cursor_cell_offset(&state.query, state.cursor); if cursor_x < max_x { let cursor_cell = Cell { fg: BG_SURFACE, bg: TEXT, ..Cell::default() }; let cursor_char = state .query .get(state.cursor..) .and_then(|s| s.chars().next()) .unwrap_or(' '); frame.print_text_clipped(cursor_x, y, &cursor_char.to_string(), cursor_cell, max_x); } } // Loading indicator (right-aligned). if state.loading { let loading_text = " searching... "; let loading_x = (x + width).saturating_sub(loading_text.len() as u16); let loading_cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped(loading_x, y, loading_text, loading_cell, max_x); } y + 1 } // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- /// Show a message when there are no results. fn render_empty_state(frame: &mut Frame<'_>, state: &SearchState, x: u16, y: u16, max_x: u16) { let msg = if state.query.is_empty() { "Enter a search query above" } else { "No results found" }; let cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped(x, y, msg, cell, max_x); } // --------------------------------------------------------------------------- // Result list // --------------------------------------------------------------------------- /// Render the scrollable list of search results. fn render_result_list( frame: &mut Frame<'_>, state: &SearchState, x: u16, start_y: u16, width: u16, list_height: usize, show_project: bool, ) { let max_x = x + width; // Scroll so selected item is always visible. let scroll_offset = if state.selected_index >= list_height { state.selected_index - list_height + 1 } else { 0 }; let normal = Cell { fg: TEXT, bg: BG_SURFACE, ..Cell::default() }; let selected = Cell { fg: BG_SURFACE, bg: ACCENT, ..Cell::default() }; let muted = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; let muted_selected = Cell { fg: BG_SURFACE, bg: ACCENT, ..Cell::default() }; for (i, result) in state .results .iter() .skip(scroll_offset) .enumerate() .take(list_height) { let y = start_y + i as u16; let is_selected = i + scroll_offset == state.selected_index; let (label_style, detail_style) = if is_selected { (selected, muted_selected) } else { (normal, muted) }; // Fill row background for selected item. if is_selected { for col in x..max_x { frame.buffer.set(col, y, selected); } } // Entity prefix: # for issues, ! for MRs. let prefix = match result.key.kind { EntityKind::Issue => "#", EntityKind::MergeRequest => "!", }; let iid_str = format!("{}{}", prefix, result.key.iid); let after_iid = frame.print_text_clipped(x + 1, y, &iid_str, label_style, max_x); // Title. let after_title = frame.print_text_clipped(after_iid + 1, y, &result.title, label_style, max_x); // Project path (right-aligned, hidden on narrow terminals). if show_project { let path_width = result.project_path.len() as u16 + 2; let path_x = max_x.saturating_sub(path_width); if path_x > after_title + 1 { frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x); } } } // Scroll indicator (overlaid on last visible row when results overflow). if state.results.len() > list_height && list_height > 0 { let indicator = format!( " {}/{} ", (scroll_offset + list_height).min(state.results.len()), state.results.len() ); let ind_x = max_x.saturating_sub(indicator.len() as u16); let ind_y = start_y + list_height.saturating_sub(1) as u16; let ind_cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped(ind_x, ind_y, &indicator, ind_cell, max_x); } } // --------------------------------------------------------------------------- // Hint bar // --------------------------------------------------------------------------- /// Render keybinding hints at the bottom of the search screen. fn render_hint_bar(frame: &mut Frame<'_>, state: &SearchState, x: u16, y: u16, max_x: u16) { let hint_cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; let hints = if state.query_focused { "Tab: mode Esc: blur Enter: search" } else { "Tab: mode /: focus j/k: nav Enter: open" }; frame.print_text_clipped(x + 1, y, hints, hint_cell, max_x); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::message::{EntityKey, SearchResult}; use crate::state::search::{SearchCapabilities, SearchState}; use ftui::render::grapheme_pool::GraphemePool; macro_rules! with_frame { ($width:expr, $height:expr, |$frame:ident| $body:block) => {{ let mut pool = GraphemePool::new(); let mut $frame = Frame::new($width, $height, &mut pool); $body }}; } fn fts_caps() -> SearchCapabilities { SearchCapabilities { has_fts: true, has_embeddings: false, embedding_coverage_pct: 0.0, } } fn sample_results(count: usize) -> Vec { (0..count) .map(|i| SearchResult { key: EntityKey::issue(1, (i + 1) as i64), title: format!("Result {}", i + 1), score: 1.0 - (i as f64 * 0.1), snippet: "matched text".into(), project_path: "group/project".into(), }) .collect() } #[test] fn test_render_search_empty_no_panic() { with_frame!(80, 24, |frame| { let state = SearchState::default(); render_search(&mut frame, &state, Rect::new(0, 0, 80, 24)); }); } #[test] fn test_render_search_with_capabilities_no_panic() { with_frame!(80, 24, |frame| { let mut state = SearchState::default(); state.enter(fts_caps()); render_search(&mut frame, &state, Rect::new(0, 0, 80, 24)); }); } #[test] fn test_render_search_with_results_no_panic() { with_frame!(100, 30, |frame| { let mut state = SearchState::default(); state.enter(fts_caps()); state.results = sample_results(5); render_search(&mut frame, &state, Rect::new(0, 0, 100, 30)); }); } #[test] fn test_render_search_with_query_no_panic() { with_frame!(80, 24, |frame| { let mut state = SearchState::default(); state.enter(fts_caps()); state.insert_char('h'); state.insert_char('e'); state.insert_char('l'); state.insert_char('l'); state.insert_char('o'); state.results = sample_results(3); render_search(&mut frame, &state, Rect::new(0, 0, 80, 24)); }); } #[test] fn test_render_search_with_selection_no_panic() { with_frame!(80, 24, |frame| { let mut state = SearchState::default(); state.enter(fts_caps()); state.results = sample_results(10); state.select_next(); state.select_next(); render_search(&mut frame, &state, Rect::new(0, 0, 80, 24)); }); } #[test] fn test_render_search_tiny_terminal_noop() { with_frame!(15, 3, |frame| { let mut state = SearchState::default(); state.enter(fts_caps()); render_search(&mut frame, &state, Rect::new(0, 0, 15, 3)); }); } #[test] fn test_render_search_no_indexes_warning() { with_frame!(80, 24, |frame| { let state = SearchState::default(); // capabilities are default (no indexes) render_search(&mut frame, &state, Rect::new(0, 0, 80, 24)); // Should show "No search indexes found" without panicking. }); } #[test] fn test_render_search_loading_indicator() { with_frame!(80, 24, |frame| { let mut state = SearchState::default(); state.enter(fts_caps()); state.loading = true; render_search(&mut frame, &state, Rect::new(0, 0, 80, 24)); }); } #[test] fn test_render_search_scrollable_results() { with_frame!(80, 10, |frame| { let mut state = SearchState::default(); state.enter(fts_caps()); state.results = sample_results(20); // Select item near the bottom to trigger scrolling. for _ in 0..15 { state.select_next(); } render_search(&mut frame, &state, Rect::new(0, 0, 80, 10)); }); } #[test] fn test_render_search_responsive_breakpoints() { // Narrow (Xs=50): project path hidden. with_frame!(50, 24, |frame| { let mut state = SearchState::default(); state.enter(fts_caps()); state.results = sample_results(3); render_search(&mut frame, &state, Rect::new(0, 0, 50, 24)); }); // Standard (Md=100): project path shown. with_frame!(100, 24, |frame| { let mut state = SearchState::default(); state.enter(fts_caps()); state.results = sample_results(3); render_search(&mut frame, &state, Rect::new(0, 0, 100, 24)); }); } }