//! Scope picker overlay — modal project filter selector. //! //! Renders a centered modal listing all available projects. The user //! selects "All Projects" or a specific project to filter all screens. use ftui::core::geometry::Rect; use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::{BorderChars, Draw}; use ftui::render::frame::Frame; use crate::state::ScopeContext; use crate::state::scope_picker::ScopePickerState; use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED}; /// Selection highlight background. const SELECTION_BG: PackedRgba = PackedRgba::rgb(0x3A, 0x3A, 0x34); // --------------------------------------------------------------------------- // render_scope_picker // --------------------------------------------------------------------------- /// Render the scope picker overlay centered on the screen. /// /// Only renders if `state.visible`. The modal is 50% width, up to 40x20. pub fn render_scope_picker( frame: &mut Frame<'_>, state: &ScopePickerState, current_scope: &ScopeContext, area: Rect, ) { if !state.visible { return; } if area.height < 5 || area.width < 20 { return; } // Modal dimensions. let modal_width = (area.width / 2).clamp(25, 40); let row_count = state.row_count(); // +3 for border top, title gap, border bottom. let modal_height = ((row_count + 3) as u16).clamp(5, 20).min(area.height - 2); let modal_x = area.x + (area.width.saturating_sub(modal_width)) / 2; let modal_y = area.y + (area.height.saturating_sub(modal_height)) / 2; let modal_rect = Rect::new(modal_x, modal_y, modal_width, modal_height); // Clear background. let bg_cell = Cell { fg: TEXT, bg: BG_SURFACE, ..Cell::default() }; for y in modal_rect.y..modal_rect.bottom() { for x in modal_rect.x..modal_rect.right() { frame.buffer.set(x, y, bg_cell); } } // Border. let border_cell = Cell { fg: BORDER, bg: BG_SURFACE, ..Cell::default() }; frame.draw_border(modal_rect, BorderChars::ROUNDED, border_cell); // Title. let title = " Project Scope "; let title_x = modal_x + (modal_width.saturating_sub(title.len() as u16)) / 2; let title_cell = Cell { fg: ACCENT, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped(title_x, modal_y, title, title_cell, modal_rect.right()); // Content area (inside border). let content_x = modal_x + 1; let content_max_x = modal_rect.right().saturating_sub(1); let content_width = content_max_x.saturating_sub(content_x); let first_row_y = modal_y + 1; let max_rows = (modal_height.saturating_sub(2)) as usize; // Inside borders. // Render rows. let visible_end = (state.scroll_offset + max_rows).min(row_count); for vis_idx in 0..max_rows { let row_idx = state.scroll_offset + vis_idx; if row_idx >= row_count { break; } let y = first_row_y + vis_idx as u16; let selected = row_idx == state.selected_index; let bg = if selected { SELECTION_BG } else { BG_SURFACE }; // Fill row background. if selected { let sel_cell = Cell { fg: TEXT, bg, ..Cell::default() }; for x in content_x..content_max_x { frame.buffer.set(x, y, sel_cell); } } // Row content. let (label, is_active) = if row_idx == 0 { let active = current_scope.project_id.is_none(); ("All Projects".to_string(), active) } else { let project = &state.projects[row_idx - 1]; let active = current_scope.project_id == Some(project.id); (project.path.clone(), active) }; // Active indicator. let prefix = if is_active { "> " } else { " " }; let fg = if is_active { ACCENT } else { TEXT }; let cell = Cell { fg, bg, ..Cell::default() }; // Truncate label to fit. let max_label_len = content_width.saturating_sub(2) as usize; // 2 for prefix let display = if label.len() > max_label_len { format!( "{prefix}{}...", &label[..label.floor_char_boundary(max_label_len.saturating_sub(3))] ) } else { format!("{prefix}{label}") }; frame.print_text_clipped(content_x, y, &display, cell, content_max_x); } // Scroll indicators. if state.scroll_offset > 0 { let arrow_cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped( content_max_x.saturating_sub(1), first_row_y, "^", arrow_cell, modal_rect.right(), ); } if visible_end < row_count { let arrow_cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; let bottom_y = first_row_y + (max_rows as u16).saturating_sub(1); frame.print_text_clipped( content_max_x.saturating_sub(1), bottom_y, "v", arrow_cell, modal_rect.right(), ); } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::scope::ProjectInfo; 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 sample_projects() -> Vec { vec![ ProjectInfo { id: 1, path: "alpha/repo".into(), }, ProjectInfo { id: 2, path: "beta/repo".into(), }, ] } #[test] fn test_render_hidden_noop() { with_frame!(80, 24, |frame| { let state = ScopePickerState::default(); let scope = ScopeContext::default(); let area = frame.bounds(); render_scope_picker(&mut frame, &state, &scope, area); // Should not panic. }); } #[test] fn test_render_visible_no_panic() { with_frame!(80, 24, |frame| { let mut state = ScopePickerState::default(); let scope = ScopeContext::default(); state.open(sample_projects(), &scope); let area = frame.bounds(); render_scope_picker(&mut frame, &state, &scope, area); }); } #[test] fn test_render_with_selection() { with_frame!(80, 24, |frame| { let mut state = ScopePickerState::default(); let scope = ScopeContext::default(); state.open(sample_projects(), &scope); state.select_next(); // Move to first project let area = frame.bounds(); render_scope_picker(&mut frame, &state, &scope, area); }); } #[test] fn test_render_tiny_terminal_noop() { with_frame!(15, 4, |frame| { let mut state = ScopePickerState::default(); let scope = ScopeContext::default(); state.open(sample_projects(), &scope); let area = frame.bounds(); render_scope_picker(&mut frame, &state, &scope, area); // Should not panic on tiny terminals. }); } #[test] fn test_render_active_scope_highlighted() { with_frame!(80, 24, |frame| { let mut state = ScopePickerState::default(); let scope = ScopeContext { project_id: Some(2), project_name: Some("beta/repo".into()), }; state.open(sample_projects(), &scope); let area = frame.bounds(); render_scope_picker(&mut frame, &state, &scope, area); }); } #[test] fn test_render_empty_project_list() { with_frame!(80, 24, |frame| { let mut state = ScopePickerState::default(); let scope = ScopeContext::default(); state.open(vec![], &scope); let area = frame.bounds(); render_scope_picker(&mut frame, &state, &scope, area); // Only "All Projects" row, should not panic. }); } }