tui/src/bottom_pane/unified_exec_footer.rs
117 lines
//! Renders and formats unified-exec background session summary text.//!//! This module provides one canonical summary string so the bottom pane can//! either render a dedicated footer row or reuse the same text inline in the//! status row without duplicating copy/grammar logic.use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::widgets::Paragraph;use crate::live_wrap::take_prefix_by_width;use crate::render::renderable::Renderable;/// Tracks active unified-exec processes and renders a compact summary.pub(crate) struct UnifiedExecFooter { processes: Vec<String>,}impl UnifiedExecFooter { pub(crate) fn new() -> Self { Self { processes: Vec::new(), } } pub(crate) fn set_processes(&mut self, processes: Vec<String>) -> bool { if self.processes == processes { return false; } self.processes = processes; true } pub(crate) fn is_empty(&self) -> bool { self.processes.is_empty() } /// Returns the unindented summary text used by both footer and status-row rendering. /// /// The returned string intentionally omits leading spaces and separators so /// callers can choose layout-specific framing (inline separator vs. row /// indentation). Returning `None` means there is nothing to surface. pub(crate) fn summary_text(&self) -> Option<String> { if self.processes.is_empty() { return None; } let count = self.processes.len(); let plural = if count == 1 { "" } else { "s" }; Some(format!( "{count} background terminal{plural} running · /ps to view · /stop to close" )) } fn render_lines(&self, width: u16) -> Vec<Line<'static>> { if width < 4 { return Vec::new(); } let Some(summary) = self.summary_text() else { return Vec::new(); }; let message = format!(" {summary}"); let (truncated, _, _) = take_prefix_by_width(&message, width as usize); vec![Line::from(truncated.dim())] }}impl Renderable for UnifiedExecFooter { fn render(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; } Paragraph::new(self.render_lines(area.width)).render(area, buf); } fn desired_height(&self, width: u16) -> u16 { self.render_lines(width).len() as u16 }}#[cfg(test)]mod tests { use super::*; use insta::assert_snapshot; use pretty_assertions::assert_eq; #[test] fn desired_height_empty() { let footer = UnifiedExecFooter::new(); assert_eq!(footer.desired_height(/*width*/ 40), 0); } #[test] fn render_more_sessions() { let mut footer = UnifiedExecFooter::new(); footer.set_processes(vec!["rg \"foo\" src".to_string()]); let width = 50; let height = footer.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); footer.render(Rect::new(0, 0, width, height), &mut buf); assert_snapshot!("render_more_sessions", format!("{buf:?}")); } #[test] fn render_many_sessions() { let mut footer = UnifiedExecFooter::new(); footer.set_processes((0..123).map(|idx| format!("cmd {idx}")).collect()); let width = 50; let height = footer.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); footer.render(Rect::new(0, 0, width, height), &mut buf); assert_snapshot!("render_many_sessions", format!("{buf:?}")); }}