//! Doctor screen view — health check results. //! //! Renders a vertical list of health checks with colored status //! indicators (green PASS, yellow WARN, red FAIL). use ftui::core::geometry::Rect; use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; use crate::layout::classify_width; use crate::state::doctor::{DoctorState, HealthStatus}; use super::{TEXT, TEXT_MUTED}; /// Pass green. const PASS_FG: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); /// Warning yellow. const WARN_FG: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); /// Fail red. const FAIL_FG: PackedRgba = PackedRgba::rgb(0xD1, 0x4D, 0x41); // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- /// Render the doctor screen. pub fn render_doctor(frame: &mut Frame<'_>, state: &DoctorState, area: Rect) { if area.width < 10 || area.height < 3 { return; } let max_x = area.right(); if !state.loaded { // Not yet loaded — show centered prompt. let msg = "Loading health checks..."; let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2; let y = area.y + area.height / 2; frame.print_text_clipped( x, y, msg, Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); return; } // Title. let overall = state.overall_status(); let title_fg = status_color(overall); let title = format!("Doctor — {}", overall.label()); frame.print_text_clipped( area.x + 2, area.y + 1, &title, Cell { fg: title_fg, ..Cell::default() }, max_x, ); // Summary line. let pass_count = state.count_by_status(HealthStatus::Pass); let warn_count = state.count_by_status(HealthStatus::Warn); let fail_count = state.count_by_status(HealthStatus::Fail); let summary = format!( "{} passed, {} warnings, {} failed", pass_count, warn_count, fail_count ); frame.print_text_clipped( area.x + 2, area.y + 2, &summary, Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); // Health check rows — name column adapts to breakpoint. let bp = classify_width(area.width); let rows_start_y = area.y + 4; let name_width = match bp { ftui::layout::Breakpoint::Xs => 10u16, ftui::layout::Breakpoint::Sm => 13, _ => 16, }; for (i, check) in state.checks.iter().enumerate() { let y = rows_start_y + i as u16; if y >= area.bottom().saturating_sub(2) { break; } // Status badge. let badge = format!("[{}]", check.status.label()); let badge_fg = status_color(check.status); frame.print_text_clipped( area.x + 2, y, &badge, Cell { fg: badge_fg, ..Cell::default() }, max_x, ); // Check name. let name_x = area.x + 2 + 7; // "[PASS] " = 7 chars let name = format!("{: max_detail { format!( "{}...", &check.detail[..check .detail .floor_char_boundary(max_detail.saturating_sub(3))] ) } else { check.detail.clone() }; frame.print_text_clipped( detail_x, y, &detail, Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); } // Hint at bottom. let hint_y = area.bottom().saturating_sub(1); frame.print_text_clipped( area.x + 2, hint_y, "Esc: back | lore doctor (full check)", Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); } /// Map health status to a display color. fn status_color(status: HealthStatus) -> PackedRgba { match status { HealthStatus::Pass => PASS_FG, HealthStatus::Warn => WARN_FG, HealthStatus::Fail => FAIL_FG, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::state::doctor::HealthCheck; 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_checks() -> Vec { vec![ HealthCheck { name: "Config".into(), status: HealthStatus::Pass, detail: "/home/user/.config/lore/config.json".into(), }, HealthCheck { name: "Database".into(), status: HealthStatus::Pass, detail: "schema v12".into(), }, HealthCheck { name: "Projects".into(), status: HealthStatus::Warn, detail: "0 projects configured".into(), }, HealthCheck { name: "FTS Index".into(), status: HealthStatus::Fail, detail: "No documents indexed".into(), }, ] } #[test] fn test_render_not_loaded() { with_frame!(80, 24, |frame| { let state = DoctorState::default(); let area = frame.bounds(); render_doctor(&mut frame, &state, area); }); } #[test] fn test_render_with_checks() { with_frame!(80, 24, |frame| { let mut state = DoctorState::default(); state.apply_checks(sample_checks()); let area = frame.bounds(); render_doctor(&mut frame, &state, area); }); } #[test] fn test_render_all_pass() { with_frame!(80, 24, |frame| { let mut state = DoctorState::default(); state.apply_checks(vec![HealthCheck { name: "Config".into(), status: HealthStatus::Pass, detail: "ok".into(), }]); let area = frame.bounds(); render_doctor(&mut frame, &state, area); }); } #[test] fn test_render_tiny_terminal() { with_frame!(8, 2, |frame| { let mut state = DoctorState::default(); state.apply_checks(sample_checks()); let area = frame.bounds(); render_doctor(&mut frame, &state, area); // Should not panic. }); } #[test] fn test_render_narrow_terminal_truncates() { with_frame!(40, 20, |frame| { let mut state = DoctorState::default(); state.apply_checks(vec![HealthCheck { name: "Database".into(), status: HealthStatus::Pass, detail: "This is a very long detail string that should be truncated".into(), }]); let area = frame.bounds(); render_doctor(&mut frame, &state, area); }); } #[test] fn test_render_many_checks_clips() { with_frame!(80, 10, |frame| { let mut state = DoctorState::default(); let mut checks = Vec::new(); for i in 0..20 { checks.push(HealthCheck { name: format!("Check {i}"), status: HealthStatus::Pass, detail: "ok".into(), }); } state.apply_checks(checks); let area = frame.bounds(); render_doctor(&mut frame, &state, area); // Should clip without panicking. }); } }