feat(tui): Phase 4 completion + Phase 5 session/lock/text-width
Phase 4 (bd-1df9) — all 5 acceptance criteria met: - Sync screen with delta ledger (bd-2x2h, bd-y095) - Doctor screen with health checks (bd-2iqk) - Stats screen with document counts (bd-2iqk) - CLI integration: lore tui subcommand (bd-26lp) - CLI integration: lore sync --tui flag (bd-3l56) Phase 5 (bd-3h00) — session persistence + instance lock + text width: - text_width.rs: Unicode-aware measurement, truncation, padding (16 tests) - instance_lock.rs: Advisory PID lock with stale recovery (6 tests) - session.rs: Atomic write + CRC32 checksum + quarantine (9 tests) Closes: bd-26lp, bd-3h00, bd-3l56, bd-1df9, bd-y095
This commit is contained in:
276
crates/lore-tui/src/view/scope_picker.rs
Normal file
276
crates/lore-tui/src/view/scope_picker.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! 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::scope_picker::ScopePickerState;
|
||||
use crate::state::ScopeContext;
|
||||
|
||||
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.
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user