#![allow(dead_code)] // Phase 3: consumed by view/mod.rs screen dispatch //! Timeline screen view — chronological event stream with color-coded types. //! //! Layout: //! ```text //! +─── Timeline ──────────────────────────────+ //! | 3h ago #42 Created: Fix login bug | //! | 2h ago #42 State changed to closed | //! | 1h ago !99 Label added: backend | //! | 30m ago !99 Merged | //! +───────────────────────────────────────────+ //! | j/k: nav Enter: open q: back | //! +───────────────────────────────────────────+ //! ``` use ftui::core::geometry::Rect; use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; use crate::clock::Clock; use crate::layout::{classify_width, timeline_time_width}; use crate::message::TimelineEventKind; use crate::state::timeline::TimelineState; use crate::view::common::discussion_tree::format_relative_time; use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED}; // --------------------------------------------------------------------------- // Colors for event kinds (Flexoki palette) // --------------------------------------------------------------------------- const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // Created const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // StateChanged const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // Closed (via StateChanged) const PURPLE: PackedRgba = PackedRgba::rgb(0x8B, 0x7E, 0xC8); // Merged const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // Label const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F); // bg (dark) /// Map event kind to its display color. fn event_color(kind: TimelineEventKind, detail: Option<&str>) -> PackedRgba { match kind { TimelineEventKind::Created => GREEN, TimelineEventKind::StateChanged => { if detail == Some("closed") { RED } else { YELLOW } } TimelineEventKind::LabelAdded | TimelineEventKind::LabelRemoved => CYAN, TimelineEventKind::MilestoneSet | TimelineEventKind::MilestoneRemoved => ACCENT, TimelineEventKind::Merged => PURPLE, } } // --------------------------------------------------------------------------- // render_timeline // --------------------------------------------------------------------------- /// Render the timeline screen. /// /// Composes: scope header (row 0), separator (row 1), /// event list (fill), and a hint bar at the bottom. pub fn render_timeline( frame: &mut Frame<'_>, state: &TimelineState, area: Rect, clock: &dyn Clock, ) { if area.height < 4 || area.width < 20 { return; } let mut y = area.y; let max_x = area.right(); // -- Scope header -- let scope_label = match &state.scope { crate::state::timeline::TimelineScope::All => "All events".to_string(), crate::state::timeline::TimelineScope::Entity(key) => { let sigil = match key.kind { crate::message::EntityKind::Issue => "#", crate::message::EntityKind::MergeRequest => "!", }; format!("Entity {sigil}{}", key.iid) } crate::state::timeline::TimelineScope::Author(name) => format!("Author: {name}"), }; let header = format!("Timeline: {scope_label}"); let header_cell = Cell { fg: ACCENT, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped(area.x, y, &header, header_cell, max_x); y += 1; // -- Separator -- if y >= area.bottom() { return; } let sep_cell = Cell { fg: BORDER, bg: BG_SURFACE, ..Cell::default() }; let sep_line = "─".repeat(area.width as usize); frame.print_text_clipped(area.x, y, &sep_line, sep_cell, max_x); y += 1; // -- Event list -- let bottom_hint_row = area.bottom().saturating_sub(1); let list_height = bottom_hint_row.saturating_sub(y) as usize; if list_height == 0 { return; } if state.events.is_empty() { render_empty_state(frame, state, area.x + 1, y, max_x); } else { let bp = classify_width(area.width); let time_col_width = timeline_time_width(bp); render_event_list( frame, state, area.x, y, area.width, list_height, clock, time_col_width, ); } // -- Hint bar -- if bottom_hint_row < area.bottom() { render_hint_bar(frame, area.x, bottom_hint_row, max_x); } } // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- fn render_empty_state(frame: &mut Frame<'_>, state: &TimelineState, x: u16, y: u16, max_x: u16) { let msg = if state.loading { "Loading timeline..." } else { "No timeline events found" }; let cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped(x, y, msg, cell, max_x); } // --------------------------------------------------------------------------- // Event list // --------------------------------------------------------------------------- /// Render the scrollable list of timeline events. #[allow(clippy::too_many_arguments)] fn render_event_list( frame: &mut Frame<'_>, state: &TimelineState, x: u16, start_y: u16, width: u16, list_height: usize, clock: &dyn Clock, time_col_width: u16, ) { let max_x = x + width; // Scroll so selected item is always visible. let scroll_offset = if state.selected_index >= list_height { state.selected_index - list_height + 1 } else { 0 }; let selected_cell = Cell { fg: SELECTED_FG, bg: ACCENT, ..Cell::default() }; for (i, event) in state .events .iter() .skip(scroll_offset) .enumerate() .take(list_height) { let y = start_y + i as u16; let is_selected = i + scroll_offset == state.selected_index; let kind_color = event_color(event.event_kind, event.detail.as_deref()); // Fill row background for selected item. if is_selected { for col in x..max_x { frame.buffer.set(col, y, selected_cell); } } let mut cx = x + 1; // Timestamp gutter (right-aligned, width varies by breakpoint). let time_str = format_relative_time(event.timestamp_ms, clock); let time_x = cx + time_col_width.saturating_sub(time_str.len() as u16); let time_cell = if is_selected { selected_cell } else { Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() } }; frame.print_text_clipped(time_x, y, &time_str, time_cell, cx + time_col_width); cx += time_col_width + 1; // Entity prefix: #42 or !99 let prefix = match event.entity_key.kind { crate::message::EntityKind::Issue => "#", crate::message::EntityKind::MergeRequest => "!", }; let entity_str = format!("{prefix}{}", event.entity_key.iid); let entity_cell = if is_selected { selected_cell } else { Cell { fg: kind_color, bg: BG_SURFACE, ..Cell::default() } }; let after_entity = frame.print_text_clipped(cx, y, &entity_str, entity_cell, max_x); cx = after_entity + 1; // Event kind badge. let badge = event.event_kind.label(); let badge_cell = if is_selected { selected_cell } else { Cell { fg: kind_color, bg: BG_SURFACE, ..Cell::default() } }; let after_badge = frame.print_text_clipped(cx, y, badge, badge_cell, max_x); cx = after_badge + 1; // Summary text. let summary_cell = if is_selected { selected_cell } else { Cell { fg: TEXT, bg: BG_SURFACE, ..Cell::default() } }; frame.print_text_clipped(cx, y, &event.summary, summary_cell, max_x); // Actor (right-aligned) if there's room. if let Some(ref actor) = event.actor { let actor_str = format!(" {actor} "); let actor_width = actor_str.len() as u16; let actor_x = max_x.saturating_sub(actor_width); if actor_x > cx + 5 { let actor_cell = if is_selected { selected_cell } else { Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() } }; frame.print_text_clipped(actor_x, y, &actor_str, actor_cell, max_x); } } } // Scroll indicator (overlaid on last visible row when events overflow). if state.events.len() > list_height && list_height > 0 { let indicator = format!( " {}/{} ", (scroll_offset + list_height).min(state.events.len()), state.events.len() ); let ind_x = max_x.saturating_sub(indicator.len() as u16); let ind_y = start_y + list_height.saturating_sub(1) as u16; let ind_cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; frame.print_text_clipped(ind_x, ind_y, &indicator, ind_cell, max_x); } } // --------------------------------------------------------------------------- // Hint bar // --------------------------------------------------------------------------- fn render_hint_bar(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) { let hint_cell = Cell { fg: TEXT_MUTED, bg: BG_SURFACE, ..Cell::default() }; let hints = "j/k: nav Enter: open q: back"; frame.print_text_clipped(x + 1, y, hints, hint_cell, max_x); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::clock::FakeClock; use crate::message::{EntityKey, TimelineEvent, TimelineEventKind}; use crate::state::timeline::TimelineState; 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_event(timestamp_ms: i64, iid: i64, kind: TimelineEventKind) -> TimelineEvent { TimelineEvent { timestamp_ms, entity_key: EntityKey::issue(1, iid), event_kind: kind, summary: format!("Event for #{iid}"), detail: None, actor: Some("alice".into()), project_path: "group/project".into(), } } fn test_clock() -> FakeClock { FakeClock::from_ms(1_700_000_100_000) } #[test] fn test_render_timeline_empty_no_panic() { with_frame!(80, 24, |frame| { let state = TimelineState::default(); let clock = test_clock(); render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock); }); } #[test] fn test_render_timeline_with_events_no_panic() { with_frame!(100, 30, |frame| { let state = TimelineState { events: vec![ sample_event(1_700_000_000_000, 1, TimelineEventKind::Created), sample_event(1_700_000_050_000, 2, TimelineEventKind::StateChanged), sample_event(1_700_000_080_000, 3, TimelineEventKind::Merged), ], ..TimelineState::default() }; let clock = test_clock(); render_timeline(&mut frame, &state, Rect::new(0, 0, 100, 30), &clock); }); } #[test] fn test_render_timeline_with_selection_no_panic() { with_frame!(80, 24, |frame| { let state = TimelineState { events: vec![ sample_event(1_700_000_000_000, 1, TimelineEventKind::Created), sample_event(1_700_000_050_000, 2, TimelineEventKind::LabelAdded), ], selected_index: 1, ..TimelineState::default() }; let clock = test_clock(); render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock); }); } #[test] fn test_render_timeline_tiny_terminal_noop() { with_frame!(15, 3, |frame| { let state = TimelineState::default(); let clock = test_clock(); render_timeline(&mut frame, &state, Rect::new(0, 0, 15, 3), &clock); }); } #[test] fn test_render_timeline_loading_state() { with_frame!(80, 24, |frame| { let state = TimelineState { loading: true, ..TimelineState::default() }; let clock = test_clock(); render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock); }); } #[test] fn test_render_timeline_scrollable_events() { with_frame!(80, 10, |frame| { let state = TimelineState { events: (0..20) .map(|i| { sample_event( 1_700_000_000_000 + i * 10_000, i + 1, TimelineEventKind::Created, ) }) .collect(), selected_index: 15, ..TimelineState::default() }; let clock = test_clock(); render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 10), &clock); }); } #[test] fn test_event_color_created_is_green() { assert_eq!(event_color(TimelineEventKind::Created, None), GREEN); } #[test] fn test_event_color_closed_is_red() { assert_eq!( event_color(TimelineEventKind::StateChanged, Some("closed")), RED ); } #[test] fn test_event_color_merged_is_purple() { assert_eq!(event_color(TimelineEventKind::Merged, None), PURPLE); } }