use crate::summarizer::SummarySnapshot;
use ratatui::{
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Paragraph, Wrap},
};
#[derive(Debug, Default)]
pub(crate) struct SummaryDisplay {
pub(crate) session_id: Option<String>,
pub(crate) enabled: bool,
pub(crate) running: bool,
pub(crate) entries: Vec<String>,
pub(crate) error: Option<String>,
pub(crate) scroll: u16,
pub(crate) requested_enabled: Option<bool>,
}
impl SummaryDisplay {
pub(crate) fn take_requested_enabled(&mut self) -> Option<bool> {
let enabled = self.requested_enabled.take()?;
self.enabled = enabled;
Some(enabled)
}
pub(crate) fn apply(&mut self, snapshot: SummarySnapshot) {
let mut entries: Vec<_> = snapshot
.entries
.into_iter()
.rev()
.take(128)
.map(|entry| crate::tui::activity::sanitize_and_bound_preview(&entry))
.collect();
entries.reverse();
if self.session_id.as_deref() != Some(snapshot.session_id.as_str())
|| self.entries != entries
{
self.scroll = u16::MAX;
}
self.session_id = Some(snapshot.session_id);
self.enabled = snapshot.enabled;
self.running = snapshot.running;
self.entries = entries;
self.error = snapshot
.error
.map(|text| crate::tui::activity::sanitize_and_bound_preview(&text));
}
pub(crate) fn text(&self) -> Text<'_> {
let heading_style = Style::default().add_modifier(Modifier::BOLD);
let mut lines = Vec::new();
if let Some(error) = &self.error {
lines.push(Line::styled("Error:", heading_style));
lines.extend(error.split('\n').map(Line::raw));
lines.push(Line::default());
}
if self.entries.is_empty() {
lines.push(Line::raw("No summary yet."));
} else {
for (index, entry) in self.entries.iter().enumerate() {
if index > 0 {
lines.push(Line::default());
}
let latest = if index + 1 == self.entries.len() {
" · Latest"
} else {
""
};
lines.push(Line::from(Span::styled(
format!("── Entry {}{latest} ──", index + 1),
heading_style,
)));
lines.extend(entry.split('\n').map(Line::raw));
}
}
Text::from(lines)
}
pub(crate) fn max_scroll(&self, width: u16, height: u16) -> u16 {
Paragraph::new(self.text())
.wrap(Wrap { trim: false })
.line_count(width.max(1))
.saturating_sub(usize::from(height))
.min(usize::from(u16::MAX)) as u16
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
fn snapshot(session_id: &str, entries: &[&str]) -> SummarySnapshot {
SummarySnapshot {
session_id: session_id.into(),
enabled: true,
running: false,
entries: entries.iter().map(|entry| (*entry).into()).collect(),
error: None,
}
}
#[test]
fn new_or_replaced_entries_scroll_to_latest() {
let mut display = SummaryDisplay::default();
display.apply(snapshot("session", &["First"]));
display.scroll = 2;
display.apply(snapshot("session", &["First", "Second"]));
assert_eq!(display.scroll, u16::MAX);
display.scroll = 1;
display.apply(snapshot("session", &["First", "Revised second"]));
assert_eq!(display.scroll, u16::MAX);
}
#[test]
fn unchanged_entries_and_status_updates_preserve_scroll() {
let mut display = SummaryDisplay::default();
let mut update = snapshot("session", &["First", "Second"]);
display.apply(update.clone());
display.scroll = 3;
display.apply(update.clone());
assert_eq!(display.scroll, 3);
update.enabled = false;
update.running = true;
update.error = Some("Summary unavailable".into());
display.apply(update);
assert_eq!(display.scroll, 3);
assert!(!display.enabled);
assert!(display.running);
assert_eq!(display.error.as_deref(), Some("Summary unavailable"));
}
#[test]
fn sanitized_equivalent_entries_preserve_scroll() {
let mut display = SummaryDisplay::default();
display.apply(snapshot("session", &["First"]));
display.scroll = 2;
display.apply(snapshot("session", &["\x1b[31mFirst\x1b[0m"]));
assert_eq!(display.entries, ["First"]);
assert_eq!(display.scroll, 2);
}
#[test]
fn cap_rollover_scrolls_even_when_entry_count_is_unchanged() {
let mut display = SummaryDisplay::default();
let mut update = snapshot("session", &[]);
update.entries = (0..128).map(|index| format!("Entry {index}")).collect();
display.apply(update.clone());
display.scroll = 4;
update.entries.push("Entry 128".into());
display.apply(update.clone());
assert_eq!(display.entries.len(), 128);
assert_eq!(display.entries.first().unwrap(), "Entry 1");
assert_eq!(display.entries.last().unwrap(), "Entry 128");
assert_eq!(display.scroll, u16::MAX);
display.scroll = 4;
update.entries[0] = "Changed discarded entry".into();
display.apply(update);
assert_eq!(display.scroll, 4);
}
#[test]
fn switching_sessions_scrolls_even_with_identical_entries() {
let mut display = SummaryDisplay::default();
display.apply(snapshot("first", &["Same content"]));
display.scroll = 1;
display.apply(snapshot("second", &["Same content"]));
assert_eq!(display.session_id.as_deref(), Some("second"));
assert_eq!(display.scroll, u16::MAX);
}
#[test]
fn projection_labels_entries_and_preserves_body_line_gaps() {
let mut display = SummaryDisplay::default();
display.apply(snapshot("session", &["First\n\nDetail", "Second"]));
let text = display.text();
assert_eq!(
text.to_string(),
"── Entry 1 ──\nFirst\n\nDetail\n\n── Entry 2 · Latest ──\nSecond"
);
for index in [0, 5] {
assert!(
text.lines[index].spans[0]
.style
.add_modifier
.contains(Modifier::BOLD)
);
}
}
#[test]
fn error_is_labeled_alongside_empty_state() {
let mut display = SummaryDisplay::default();
let mut update = snapshot("session", &[]);
update.error = Some("Unavailable\nTry again".into());
display.apply(update);
assert_eq!(
display.text().to_string(),
"Error:\nUnavailable\nTry again\n\nNo summary yet."
);
}
#[test]
fn scroll_limit_matches_wrapped_projection_at_content_width() {
let mut display = SummaryDisplay::default();
display.apply(snapshot("session", &["abcdefghijklmnopqrstuvwxyz"]));
assert_eq!(display.max_scroll(26, 2), 0);
assert_eq!(display.max_scroll(13, 2), 2);
assert_eq!(display.max_scroll(13, 10), 0);
assert_eq!(display.max_scroll(0, 2), display.max_scroll(1, 2));
let area = Rect::new(0, 0, 13, 2);
let mut buffer = Buffer::empty(area);
Paragraph::new(display.text())
.wrap(Wrap { trim: false })
.scroll((display.max_scroll(area.width, area.height), 0))
.render(area, &mut buffer);
assert_eq!(
buffer,
Buffer::with_lines(["abcdefghijklm", "nopqrstuvwxyz"])
);
}
}