Mechanical formatting pass to satisfy rustfmt line-width limits and clippy pedantic/nursery lints. No behavioral changes. Formatting (rustfmt line wrapping): - action/sync.rs: multiline tuple destructure, function call args in tests - state/sync.rs: if-let chain formatting, remove unnecessary Vec collect - view/sync.rs: multiline array entries, format!(), vec! literals - view/doctor.rs: multiline floor_char_boundary chain - view/scope_picker.rs: multiline format!() with floor_char_boundary - view/stats.rs: multiline render_stat_row call - view/mod.rs: multiline assert!() in test - app/update.rs: multiline enum variant destructure - entity_cache.rs: multiline assert_eq!() with messages - render_cache.rs: multiline retain() closure - session.rs: multiline serde_json/File::create/parent() chains Clippy: - action/sync.rs: #[allow(clippy::too_many_arguments)] on test helper Import/module ordering (alphabetical): - state/mod.rs: move scope_picker mod + pub use to sorted position - view/mod.rs: move scope_picker, stats, sync mod + use to sorted position - view/scope_picker.rs: sort use imports (ScopeContext before ScopePickerState)
280 lines
8.5 KiB
Rust
280 lines
8.5 KiB
Rust
//! 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<ProjectInfo> {
|
|
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.
|
|
});
|
|
}
|
|
}
|