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:
teernisse
2026-02-18 23:40:30 -05:00
parent 418417b0f4
commit 146eb61623
45 changed files with 5216 additions and 207 deletions

View 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.
});
}
}