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)
268 lines
8.7 KiB
Rust
268 lines
8.7 KiB
Rust
#![allow(dead_code)] // Phase 1: screen content renders added in Phase 2+
|
|
|
|
//! Top-level view dispatch for the lore TUI.
|
|
//!
|
|
//! [`render_screen`] is the entry point called from `LoreApp::view()`.
|
|
//! It composes the layout: breadcrumb bar, screen content area, status
|
|
//! bar, and optional overlays (help, error toast).
|
|
|
|
pub mod bootstrap;
|
|
pub mod command_palette;
|
|
pub mod common;
|
|
pub mod dashboard;
|
|
pub mod doctor;
|
|
pub mod file_history;
|
|
pub mod issue_detail;
|
|
pub mod issue_list;
|
|
pub mod mr_detail;
|
|
pub mod mr_list;
|
|
pub mod scope_picker;
|
|
pub mod search;
|
|
pub mod stats;
|
|
pub mod sync;
|
|
pub mod timeline;
|
|
pub mod trace;
|
|
pub mod who;
|
|
|
|
use ftui::layout::{Constraint, Flex};
|
|
use ftui::render::cell::PackedRgba;
|
|
use ftui::render::frame::Frame;
|
|
|
|
use crate::app::LoreApp;
|
|
use crate::message::Screen;
|
|
|
|
use bootstrap::render_bootstrap;
|
|
use command_palette::render_command_palette;
|
|
use common::{
|
|
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
|
|
};
|
|
use dashboard::render_dashboard;
|
|
use doctor::render_doctor;
|
|
use file_history::render_file_history;
|
|
use issue_detail::render_issue_detail;
|
|
use issue_list::render_issue_list;
|
|
use mr_detail::render_mr_detail;
|
|
use mr_list::render_mr_list;
|
|
use scope_picker::render_scope_picker;
|
|
use search::render_search;
|
|
use stats::render_stats;
|
|
use sync::render_sync;
|
|
use timeline::render_timeline;
|
|
use trace::render_trace;
|
|
use who::render_who;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
|
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
|
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
|
|
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
|
|
const ERROR_BG: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
|
|
const ERROR_FG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
|
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// render_screen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Top-level view dispatch: composes breadcrumb + content + status bar + overlays.
|
|
///
|
|
/// Called from `LoreApp::view()`. The layout is:
|
|
/// ```text
|
|
/// +-----------------------------------+
|
|
/// | Breadcrumb (1 row) |
|
|
/// +-----------------------------------+
|
|
/// | |
|
|
/// | Screen content (fill) |
|
|
/// | |
|
|
/// +-----------------------------------+
|
|
/// | Status bar (1 row) |
|
|
/// +-----------------------------------+
|
|
/// ```
|
|
///
|
|
/// Overlays (help, error toast) render on top of existing content.
|
|
pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
|
|
let bounds = frame.bounds();
|
|
if bounds.width < 3 || bounds.height < 3 {
|
|
return; // Terminal too small to render anything useful.
|
|
}
|
|
|
|
// Split vertically: breadcrumb (1) | content (fill) | status bar (1).
|
|
let regions = Flex::vertical()
|
|
.constraints([
|
|
Constraint::Fixed(1), // breadcrumb
|
|
Constraint::Fill, // content
|
|
Constraint::Fixed(1), // status bar
|
|
])
|
|
.split(bounds);
|
|
|
|
let breadcrumb_area = regions[0];
|
|
let content_area = regions[1];
|
|
let status_area = regions[2];
|
|
|
|
let screen = app.navigation.current();
|
|
|
|
// --- Breadcrumb ---
|
|
render_breadcrumb(frame, breadcrumb_area, &app.navigation, TEXT, TEXT_MUTED);
|
|
|
|
// --- Screen content ---
|
|
let load_state = app.state.load_state.get(screen);
|
|
// tick=0 placeholder — animation wired up when Msg::Tick increments a counter.
|
|
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
|
|
|
|
// Per-screen content dispatch (other screens wired in later phases).
|
|
if screen == &Screen::Bootstrap {
|
|
render_bootstrap(frame, &app.state.bootstrap, content_area);
|
|
} else if screen == &Screen::Sync {
|
|
render_sync(frame, &app.state.sync, content_area);
|
|
} else if screen == &Screen::Dashboard {
|
|
render_dashboard(frame, &app.state.dashboard, content_area);
|
|
} else if screen == &Screen::IssueList {
|
|
render_issue_list(frame, &app.state.issue_list, content_area);
|
|
} else if screen == &Screen::MrList {
|
|
render_mr_list(frame, &app.state.mr_list, content_area);
|
|
} else if matches!(screen, Screen::IssueDetail(_)) {
|
|
render_issue_detail(frame, &app.state.issue_detail, content_area, &*app.clock);
|
|
} else if matches!(screen, Screen::MrDetail(_)) {
|
|
render_mr_detail(frame, &app.state.mr_detail, content_area, &*app.clock);
|
|
} else if screen == &Screen::Search {
|
|
render_search(frame, &app.state.search, content_area);
|
|
} else if screen == &Screen::Timeline {
|
|
render_timeline(frame, &app.state.timeline, content_area, &*app.clock);
|
|
} else if screen == &Screen::Who {
|
|
render_who(frame, &app.state.who, content_area);
|
|
} else if screen == &Screen::FileHistory {
|
|
render_file_history(frame, &app.state.file_history, content_area);
|
|
} else if screen == &Screen::Trace {
|
|
render_trace(frame, &app.state.trace, content_area);
|
|
} else if screen == &Screen::Doctor {
|
|
render_doctor(frame, &app.state.doctor, content_area);
|
|
} else if screen == &Screen::Stats {
|
|
render_stats(frame, &app.state.stats, content_area);
|
|
}
|
|
|
|
// --- Status bar ---
|
|
render_status_bar(
|
|
frame,
|
|
status_area,
|
|
&app.command_registry,
|
|
screen,
|
|
&app.input_mode,
|
|
BG_SURFACE,
|
|
TEXT,
|
|
ACCENT,
|
|
);
|
|
|
|
// --- Overlays (render last, on top of everything) ---
|
|
|
|
// Error toast.
|
|
if let Some(ref error_msg) = app.state.error_toast {
|
|
render_error_toast(frame, bounds, error_msg, ERROR_BG, ERROR_FG);
|
|
}
|
|
|
|
// Command palette overlay.
|
|
render_command_palette(frame, &app.state.command_palette, bounds);
|
|
|
|
// Scope picker overlay.
|
|
render_scope_picker(
|
|
frame,
|
|
&app.state.scope_picker,
|
|
&app.state.global_scope,
|
|
bounds,
|
|
);
|
|
|
|
// Help overlay.
|
|
if app.state.show_help {
|
|
render_help_overlay(
|
|
frame,
|
|
bounds,
|
|
&app.command_registry,
|
|
screen,
|
|
BORDER,
|
|
TEXT,
|
|
TEXT_MUTED,
|
|
0, // scroll_offset — tracked in future phase
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app::LoreApp;
|
|
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
|
|
}};
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_screen_does_not_panic() {
|
|
with_frame!(80, 24, |frame| {
|
|
let app = LoreApp::new();
|
|
render_screen(&mut frame, &app);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_screen_tiny_terminal_noop() {
|
|
with_frame!(2, 2, |frame| {
|
|
let app = LoreApp::new();
|
|
render_screen(&mut frame, &app);
|
|
// Should not panic — early return for tiny terminals.
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_screen_with_error_toast() {
|
|
with_frame!(80, 24, |frame| {
|
|
let mut app = LoreApp::new();
|
|
app.state.set_error("test error".into());
|
|
render_screen(&mut frame, &app);
|
|
// Should render without panicking.
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_screen_with_help_overlay() {
|
|
with_frame!(80, 24, |frame| {
|
|
let mut app = LoreApp::new();
|
|
app.state.show_help = true;
|
|
render_screen(&mut frame, &app);
|
|
// Should render without panicking.
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_screen_narrow_terminal() {
|
|
with_frame!(20, 5, |frame| {
|
|
let app = LoreApp::new();
|
|
render_screen(&mut frame, &app);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_screen_sync_has_content() {
|
|
with_frame!(80, 24, |frame| {
|
|
let mut app = LoreApp::new();
|
|
app.navigation.push(Screen::Sync);
|
|
render_screen(&mut frame, &app);
|
|
|
|
let has_content = (20..60u16).any(|x| {
|
|
(8..16u16).any(|y| frame.buffer.get(x, y).is_some_and(|cell| !cell.is_empty()))
|
|
});
|
|
assert!(has_content, "Expected sync idle content in center area");
|
|
});
|
|
}
|
|
}
|