use bcode_markdown_render::markdown_to_plain_text;
use bmux_tui::component::{Component, Constraints, LayoutCx};
use bmux_tui::geometry::{Insets, Size};
use bmux_tui::paint::PaintCx;
use bmux_tui::prelude::{Line, Span};
use bmux_tui::style::Modifier;
use bmux_tui_components::dialog::{Dialog, DialogComponent};
use bmux_tui_components::modal_frame::{ModalPlacement, ModalSizing};
use super::render::TuiTheme;
use super::timeline_dialog::{TimelineDialogState, TimelineEntry};
const MIN_DIALOG_WIDTH: u16 = 60;
const MAX_DIALOG_WIDTH: u16 = 110;
const MIN_DIALOG_HEIGHT: u16 = 12;
const MAX_DIALOG_HEIGHT: u16 = 28;
const TIMESTAMP_WIDTH: usize = 19;
pub fn render_timeline_dialog_with_keymap(
state: &mut TimelineDialogState,
frame: &mut PaintCx<'_, '_>,
theme: TuiTheme,
keymap: &super::keymap::BmuxKeyMap,
) {
let sizing = dialog_sizing();
let action_state = std::cell::Cell::new(bmux_tui_components::action_row::ActionRowState::new());
let probe = DialogComponent::new(
"timeline",
Dialog::new(&[], &[], theme.modal_theme())
.title(" Timeline ")
.sizing(sizing)
.placement(ModalPlacement::Centered),
&action_state,
);
let constraints = Constraints::tight(Size::new(frame.area().width, frame.area().height));
let layout = probe.layout(constraints, &mut LayoutCx::new());
let Some(body_layout) = layout.find(&bmux_tui::component::LayoutId::new("timeline.body"))
else {
return;
};
let visible_entries =
usize::try_from(body_layout.size.height.saturating_sub(3)).unwrap_or(usize::MAX);
state.sync_scroll(visible_entries);
let mut body = rows(
state,
u16::try_from(body_layout.size.width).unwrap_or(u16::MAX),
visible_entries,
theme,
keymap,
);
body.push(Line::from_spans(vec![Span::styled(
format!(
"{} select ยท {} page ยท {} go ยท {} close",
keymap.hint("timeline", "tui.control.down"),
keymap.hint("timeline", "tui.control.page_down"),
keymap.hint("timeline", "tui.control.submit"),
keymap.hint("timeline", "tui.control.cancel")
),
theme.muted,
)]));
let dialog = DialogComponent::new(
"timeline",
Dialog::new(&body, &[], theme.modal_theme())
.title(" Timeline ")
.sizing(sizing)
.placement(ModalPlacement::Centered),
&action_state,
);
let layout = dialog.layout(constraints, &mut LayoutCx::new());
dialog.paint(&layout, frame);
}
const fn dialog_sizing() -> ModalSizing {
ModalSizing::new(
Size::new(MIN_DIALOG_WIDTH, MIN_DIALOG_HEIGHT),
Size::new(MAX_DIALOG_WIDTH, MAX_DIALOG_HEIGHT),
Insets::all(4),
)
}
fn rows(
state: &TimelineDialogState,
width: u16,
visible_entries: usize,
theme: TuiTheme,
keymap: &super::keymap::BmuxKeyMap,
) -> Vec<Line> {
let mut rows = Vec::new();
rows.push(Line::from_spans(vec![Span::styled(
format!(
"Timeline ยท {} milestones ยท {} prompts ยท {} next page",
keymap.hint("timeline", "tui.timeline.milestones"),
keymap.hint("timeline", "tui.timeline.prompts"),
keymap.hint("timeline", "tui.timeline.nextMilestonePage")
),
theme.border.add_modifier(Modifier::BOLD),
)]));
if state.entries().is_empty() {
rows.push(Line::from_spans(vec![Span::styled(
if state.is_loading() {
"Loading canonical milestonesโฆ"
} else {
"No entries in this bounded view."
},
theme.muted,
)]));
} else {
rows.extend(
state
.entries()
.iter()
.enumerate()
.skip(state.scroll())
.take(visible_entries)
.map(|(index, entry)| entry_line(entry, index == state.selected(), width, theme)),
);
}
rows
}
fn entry_line(entry: &TimelineEntry, selected: bool, width: u16, theme: TuiTheme) -> Line {
let marker = if selected { "โบ" } else { " " };
let base = if selected {
theme.selection.add_modifier(Modifier::BOLD)
} else {
theme.text
};
let accent = if selected {
theme.selection.add_modifier(Modifier::BOLD)
} else {
theme.border
};
let dim = if selected {
theme.selection
} else {
theme.muted
};
let reserved = TIMESTAMP_WIDTH.saturating_add(4);
let preview_width = usize::from(width).saturating_sub(reserved);
Line::from_spans(vec![
Span::styled(marker, accent),
Span::styled(" ", base),
Span::styled(format_timestamp(entry.timestamp_ms()), dim),
Span::styled(" ", base),
Span::styled(markdown_preview(entry.text(), preview_width), base),
])
.truncate(usize::from(width))
}
fn format_timestamp(timestamp_ms: u64) -> String {
if timestamp_ms == 0 {
return "time unavailable".to_owned();
}
let seconds = timestamp_ms / 1_000;
let (year, month, day, hour, minute, second) = utc_components(seconds);
format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}")
}
fn utc_components(seconds: u64) -> (i32, u32, u32, u64, u64, u64) {
let days = i64::try_from(seconds / 86_400).unwrap_or(i64::MAX);
let seconds_in_day = seconds % 86_400;
let (year, month, day) = civil_from_days(days);
(
year,
month,
day,
seconds_in_day / 3_600,
(seconds_in_day % 3_600) / 60,
seconds_in_day % 60,
)
}
fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) {
let days = days_since_epoch.saturating_add(719_468);
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
let year = year + i64::from(month <= 2);
(
i32::try_from(year).unwrap_or(i32::MAX),
u32::try_from(month).unwrap_or(1),
u32::try_from(day).unwrap_or(1),
)
}
fn markdown_preview(value: &str, width: usize) -> String {
truncate_display_width(&markdown_to_plain_text(value), width)
}
fn truncate_display_width(value: &str, width: usize) -> String {
bcode_tui_components::compact::truncate_width(value, width)
}
#[cfg(test)]
mod tests {
use super::{markdown_preview, truncate_display_width};
use bmux_tui::text_width::display_width;
#[test]
fn full_timeline_dialog_resizes_and_clips_unicode_and_loading() {
use bmux_tui::{buffer::Buffer, frame::Frame, geometry::Rect, paint::PaintCx};
let keymap =
super::super::keymap::BmuxKeyMap::from_config(&bcode_config::TuiConfig::default());
let theme = super::TuiTheme::for_theme_id("terminal-native");
let mut state = super::TimelineDialogState::new(
(1..=50)
.map(|sequence| {
super::TimelineEntry::new(sequence, 1_000, "ๅญ ๐ฉโ๐ป e\u{301} **milestone**")
})
.collect(),
);
for (width, height) in [
(110, 32),
(0, 0),
(1, 1),
(2, 3),
(12, 8),
(45, 12),
(110, 32),
] {
let area = Rect::new(0, 0, width, height);
let mut buffer = Buffer::empty(area);
let mut frame = Frame::new(&mut buffer);
super::render_timeline_dialog_with_keymap(
&mut state,
&mut PaintCx::new(&mut frame),
theme,
&keymap,
);
for hit in frame.hits().regions() {
assert!(hit.area.right() <= area.right());
assert!(hit.area.bottom() <= area.bottom());
}
assert_eq!(state.selected_entry().unwrap().sequence(), 50);
}
let mut requests = super::super::timeline_dialog::MilestonePageRequests::default();
requests
.begin(bcode_session_models::SessionId::new(), None, &mut state)
.unwrap();
let area = Rect::new(0, 0, 110, 32);
let mut buffer = Buffer::empty(area);
let mut frame = Frame::new(&mut buffer);
super::render_timeline_dialog_with_keymap(
&mut state,
&mut PaintCx::new(&mut frame),
theme,
&keymap,
);
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
if let Some(cell) = frame.buffer().get(bmux_tui::geometry::Point::new(x, y)) {
text.push_str(&cell.symbol);
}
}
}
assert!(text.contains("Loading canonical milestones"));
assert!(state.selected_entry().is_none());
}
#[test]
fn unicode_entries_remain_bounded_at_tiny_widths() {
let entry = super::TimelineEntry::new(42, 0, "ๅญ ๐ฉโ๐ป e\u{301} milestone");
for width in [0, 1, 2, 3, 8, 20] {
let line = super::entry_line(
&entry,
true,
width,
super::TuiTheme::for_theme_id("terminal-native"),
);
assert!(line.width() <= usize::from(width));
assert_eq!(entry.sequence(), 42);
}
}
#[test]
fn timeline_preview_flattens_markdown_semantically() {
assert_eq!(
markdown_preview("# Request\n\n- Use **care**\n- Run `cargo test`", 80),
"Request Use care Run cargo test"
);
}
#[test]
fn timestamp_for_milestone_is_not_fabricated() {
assert_eq!(super::format_timestamp(0), "time unavailable");
}
#[test]
fn timeline_preview_truncates_by_terminal_display_width() {
let preview = truncate_display_width("ใใใซใกใฏ world", 8);
assert!(display_width(&preview) <= 8);
assert!(preview.ends_with('โฆ'));
}
}