chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
|
||||
pub fn clear_area(frame: &mut Frame, area: Rect) {
|
||||
for x in area.left()..area.right() {
|
||||
for y in area.top()..area.bottom() {
|
||||
frame.buffer_mut()[(x, y)].reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn left_aligned_content_inset(width: u16, centered: bool) -> u16 {
|
||||
if centered || width <= 1 { 0 } else { 1 }
|
||||
}
|
||||
|
||||
pub fn centered_content_block_width(width: u16, max_width: usize) -> usize {
|
||||
(width as usize).min(max_width).max(1)
|
||||
}
|
||||
|
||||
pub fn left_pad_lines_to_block_width(lines: &mut [Line<'static>], width: u16, block_width: usize) {
|
||||
let block_width = block_width.min(width as usize);
|
||||
let pad = (width as usize).saturating_sub(block_width) / 2;
|
||||
for line in lines {
|
||||
if pad > 0 {
|
||||
line.spans.insert(0, Span::raw(" ".repeat(pad)));
|
||||
}
|
||||
line.alignment = Some(Alignment::Left);
|
||||
}
|
||||
}
|
||||
|
||||
const RIGHT_RAIL_HEADER_HEIGHT: u16 = 1;
|
||||
|
||||
pub fn right_rail_border_style(focused: bool, focus_color: Color, dim_color: Color) -> Style {
|
||||
let border_color = if focused { focus_color } else { dim_color };
|
||||
Style::default().fg(border_color)
|
||||
}
|
||||
|
||||
fn right_rail_inner(area: Rect) -> Rect {
|
||||
Block::default().borders(Borders::LEFT).inner(area)
|
||||
}
|
||||
|
||||
fn right_rail_content_area(area: Rect) -> Option<Rect> {
|
||||
let inner = right_rail_inner(area);
|
||||
if inner.width == 0 || inner.height <= RIGHT_RAIL_HEADER_HEIGHT {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + RIGHT_RAIL_HEADER_HEIGHT,
|
||||
width: inner.width,
|
||||
height: inner.height - RIGHT_RAIL_HEADER_HEIGHT,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn draw_right_rail_chrome(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
title: Line<'static>,
|
||||
border_style: Style,
|
||||
) -> Option<Rect> {
|
||||
let inner = right_rail_inner(area);
|
||||
let content_area = right_rail_content_area(area)?;
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::LEFT)
|
||||
.border_style(border_style);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(title),
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width,
|
||||
height: RIGHT_RAIL_HEADER_HEIGHT,
|
||||
},
|
||||
);
|
||||
|
||||
Some(content_area)
|
||||
}
|
||||
|
||||
/// Set alignment on a line only if it doesn't already have one set.
|
||||
/// This allows markdown rendering to mark code blocks as left-aligned while
|
||||
/// other content inherits the default alignment (e.g., centered mode).
|
||||
pub fn align_if_unset(line: Line<'static>, align: Alignment) -> Line<'static> {
|
||||
if line.alignment.is_some() {
|
||||
line
|
||||
} else {
|
||||
line.alignment(align)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
pub fn rect_contains(outer: Rect, inner: Rect) -> bool {
|
||||
inner.x >= outer.x
|
||||
&& inner.y >= outer.y
|
||||
&& inner.x.saturating_add(inner.width) <= outer.x.saturating_add(outer.width)
|
||||
&& inner.y.saturating_add(inner.height) <= outer.y.saturating_add(outer.height)
|
||||
}
|
||||
|
||||
pub fn point_in_rect(col: u16, row: u16, rect: Rect) -> bool {
|
||||
col >= rect.x
|
||||
&& row >= rect.y
|
||||
&& col < rect.x.saturating_add(rect.width)
|
||||
&& row < rect.y.saturating_add(rect.height)
|
||||
}
|
||||
|
||||
pub fn parse_area_spec(spec: &str) -> Option<Rect> {
|
||||
let mut parts = spec.split('+');
|
||||
let size = parts.next()?;
|
||||
let x = parts.next()?;
|
||||
let y = parts.next()?;
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = size.split_once('x')?;
|
||||
Some(Rect {
|
||||
width: w.parse::<u16>().ok()?,
|
||||
height: h.parse::<u16>().ok()?,
|
||||
x: x.parse::<u16>().ok()?,
|
||||
y: y.parse::<u16>().ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rect_contains_requires_full_containment() {
|
||||
let outer = Rect::new(2, 2, 10, 10);
|
||||
|
||||
assert!(rect_contains(outer, Rect::new(4, 4, 2, 2)));
|
||||
assert!(rect_contains(outer, Rect::new(2, 2, 10, 10)));
|
||||
assert!(!rect_contains(outer, Rect::new(1, 2, 10, 10)));
|
||||
assert!(!rect_contains(outer, Rect::new(2, 2, 11, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_in_rect_uses_half_open_bounds() {
|
||||
let rect = Rect::new(10, 20, 5, 4);
|
||||
|
||||
assert!(point_in_rect(10, 20, rect));
|
||||
assert!(point_in_rect(14, 23, rect));
|
||||
assert!(!point_in_rect(15, 23, rect));
|
||||
assert!(!point_in_rect(14, 24, rect));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_area_spec_parses_geometry() {
|
||||
assert_eq!(parse_area_spec("80x24+4+2"), Some(Rect::new(4, 2, 80, 24)));
|
||||
assert_eq!(parse_area_spec("bad"), None);
|
||||
assert_eq!(parse_area_spec("80x24+4"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
pub mod chrome;
|
||||
pub mod layout;
|
||||
pub mod memory_tiles;
|
||||
pub mod swarm_gallery;
|
||||
pub mod swarm_tiles;
|
||||
|
||||
use ratatui::prelude::{Line, Span, Style};
|
||||
|
||||
pub fn render_rounded_box(
|
||||
title: &str,
|
||||
content: Vec<Line<'static>>,
|
||||
max_width: usize,
|
||||
border_style: Style,
|
||||
) -> Vec<Line<'static>> {
|
||||
if content.is_empty() || max_width < 6 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let max_content_width = content
|
||||
.iter()
|
||||
.map(|line| line.width())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.min(max_width.saturating_sub(4));
|
||||
|
||||
let truncated_title = truncate_line_with_ellipsis_to_width(
|
||||
&Line::from(Span::raw(format!(" {} ", title))),
|
||||
max_width.saturating_sub(2).max(1),
|
||||
);
|
||||
let title_text = line_plain_text(&truncated_title);
|
||||
let title_len = truncated_title.width();
|
||||
let box_content_width = max_content_width.max(title_len.saturating_sub(2));
|
||||
|
||||
if box_content_width < 6 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let box_width = box_content_width + 4;
|
||||
let border_chars = box_width.saturating_sub(title_len + 2);
|
||||
let left_border = "─".repeat(border_chars / 2);
|
||||
let right_border = "─".repeat(border_chars - border_chars / 2);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("╭{}{}{}╮", left_border, title_text, right_border),
|
||||
border_style,
|
||||
)));
|
||||
|
||||
for line in content {
|
||||
let truncated = truncate_line_to_width(&line, box_content_width);
|
||||
let padding = box_content_width.saturating_sub(truncated.width());
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::styled("│ ", border_style));
|
||||
spans.extend(truncated.spans);
|
||||
if padding > 0 {
|
||||
spans.push(Span::raw(" ".repeat(padding)));
|
||||
}
|
||||
spans.push(Span::styled(" │", border_style));
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
let bottom_border = "─".repeat(box_width.saturating_sub(2));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("╰{}╯", bottom_border),
|
||||
border_style,
|
||||
)));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn truncate_line_to_width(line: &Line<'static>, width: usize) -> Line<'static> {
|
||||
if width == 0 {
|
||||
return Line::from("");
|
||||
}
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut remaining = width;
|
||||
for span in &line.spans {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let text = span.content.as_ref();
|
||||
let span_width = unicode_width::UnicodeWidthStr::width(text);
|
||||
if span_width <= remaining {
|
||||
spans.push(span.clone());
|
||||
remaining -= span_width;
|
||||
} else {
|
||||
let mut clipped = String::new();
|
||||
let mut used = 0;
|
||||
for ch in text.chars() {
|
||||
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used + cw > remaining {
|
||||
break;
|
||||
}
|
||||
clipped.push(ch);
|
||||
used += cw;
|
||||
}
|
||||
if !clipped.is_empty() {
|
||||
spans.push(Span::styled(clipped, span.style));
|
||||
}
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if spans.is_empty() {
|
||||
Line::from("")
|
||||
} else {
|
||||
Line::from(spans)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn truncate_line_with_ellipsis_to_width(line: &Line<'static>, width: usize) -> Line<'static> {
|
||||
if width == 0 {
|
||||
return Line::from("");
|
||||
}
|
||||
if line.width() <= width {
|
||||
return line.clone();
|
||||
}
|
||||
if width == 1 {
|
||||
return Line::from(Span::raw("…"));
|
||||
}
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut remaining = width.saturating_sub(1);
|
||||
let mut ellipsis_style = Style::default();
|
||||
|
||||
for span in &line.spans {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let text = span.content.as_ref();
|
||||
let span_width = unicode_width::UnicodeWidthStr::width(text);
|
||||
if span_width <= remaining {
|
||||
spans.push(span.clone());
|
||||
remaining -= span_width;
|
||||
ellipsis_style = span.style;
|
||||
} else {
|
||||
let mut clipped = String::new();
|
||||
let mut used = 0;
|
||||
for ch in text.chars() {
|
||||
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used + cw > remaining {
|
||||
break;
|
||||
}
|
||||
clipped.push(ch);
|
||||
used += cw;
|
||||
}
|
||||
if !clipped.is_empty() {
|
||||
spans.push(Span::styled(clipped, span.style));
|
||||
ellipsis_style = span.style;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
spans.push(Span::styled("…", ellipsis_style));
|
||||
let mut truncated = Line::from(spans);
|
||||
truncated.alignment = line.alignment;
|
||||
truncated
|
||||
}
|
||||
|
||||
pub fn truncate_line_preserving_suffix_to_width(
|
||||
prefix: &Line<'static>,
|
||||
suffix: &Line<'static>,
|
||||
width: usize,
|
||||
) -> Line<'static> {
|
||||
if width == 0 {
|
||||
return Line::from("");
|
||||
}
|
||||
|
||||
if suffix.width() == 0 {
|
||||
return truncate_line_with_ellipsis_to_width(prefix, width);
|
||||
}
|
||||
|
||||
let mut combined_spans = prefix.spans.clone();
|
||||
combined_spans.extend(suffix.spans.clone());
|
||||
let mut combined = Line::from(combined_spans);
|
||||
combined.alignment = prefix.alignment;
|
||||
if combined.width() <= width {
|
||||
return combined;
|
||||
}
|
||||
|
||||
let suffix_width = suffix.width();
|
||||
if suffix_width >= width {
|
||||
let mut truncated = truncate_line_with_ellipsis_to_width(suffix, width);
|
||||
truncated.alignment = prefix.alignment;
|
||||
return truncated;
|
||||
}
|
||||
|
||||
let prefix_budget = width.saturating_sub(suffix_width);
|
||||
let mut prefix_part = truncate_line_with_ellipsis_to_width(prefix, prefix_budget);
|
||||
prefix_part.spans.extend(suffix.spans.clone());
|
||||
prefix_part.alignment = prefix.alignment;
|
||||
prefix_part
|
||||
}
|
||||
|
||||
pub fn line_plain_text(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use ratatui::prelude::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MemoryTilePlan {
|
||||
pub lines: Vec<Line<'static>>,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub score: usize,
|
||||
}
|
||||
|
||||
pub struct MemoryTile {
|
||||
category: String,
|
||||
items: Vec<MemoryTileItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MemoryTileItem {
|
||||
pub content: String,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<String> for MemoryTileItem {
|
||||
fn from(content: String) -> Self {
|
||||
Self {
|
||||
content,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MemoryTileItem {
|
||||
fn from(content: &str) -> Self {
|
||||
Self::from(content.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_memory_display_entries(content: &str) -> Vec<(String, MemoryTileItem)> {
|
||||
let mut entries: Vec<(String, MemoryTileItem)> = Vec::new();
|
||||
let mut current_category = String::new();
|
||||
let mut last_entry_idx: Option<usize> = None;
|
||||
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.starts_with("# ") || line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(category) = line.strip_prefix("## ") {
|
||||
current_category = category.trim().to_string();
|
||||
continue;
|
||||
}
|
||||
if let Some(updated_at_raw) = line
|
||||
.strip_prefix("<!-- updated_at: ")
|
||||
.and_then(|value| value.strip_suffix(" -->"))
|
||||
{
|
||||
if let (Some(idx), Ok(updated_at)) = (
|
||||
last_entry_idx,
|
||||
DateTime::parse_from_rfc3339(updated_at_raw.trim()),
|
||||
) {
|
||||
entries[idx].1.updated_at = Some(updated_at.with_timezone(&Utc));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = if let Some(dot_pos) = line.find(". ") {
|
||||
let prefix = &line[..dot_pos];
|
||||
if prefix.trim().chars().all(|c| c.is_ascii_digit()) {
|
||||
line[dot_pos + 2..].trim()
|
||||
} else {
|
||||
line
|
||||
}
|
||||
} else {
|
||||
line
|
||||
};
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let category = if current_category.is_empty() {
|
||||
"memory".to_string()
|
||||
} else {
|
||||
current_category.clone()
|
||||
};
|
||||
entries.push((
|
||||
category,
|
||||
MemoryTileItem {
|
||||
content: content.to_string(),
|
||||
updated_at: None,
|
||||
},
|
||||
));
|
||||
last_entry_idx = Some(entries.len() - 1);
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
pub fn group_into_tiles<T>(entries: Vec<(String, T)>) -> Vec<MemoryTile>
|
||||
where
|
||||
T: Into<MemoryTileItem>,
|
||||
{
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
let mut map: std::collections::HashMap<String, Vec<MemoryTileItem>> =
|
||||
std::collections::HashMap::new();
|
||||
for (cat, content) in entries {
|
||||
if !map.contains_key(&cat) {
|
||||
order.push(cat.clone());
|
||||
}
|
||||
map.entry(cat).or_default().push(content.into());
|
||||
}
|
||||
order
|
||||
.into_iter()
|
||||
.filter_map(|cat| {
|
||||
map.remove(&cat).map(|items| MemoryTile {
|
||||
category: cat,
|
||||
items,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Split a string into chunks that each fit within `max_width` display columns,
|
||||
/// respecting multi-column characters (CJK characters take 2 columns, etc.).
|
||||
pub fn split_by_display_width(s: &str, max_width: usize) -> Vec<String> {
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
let mut chunks = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut current_width = 0usize;
|
||||
|
||||
for ch in s.chars() {
|
||||
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if current_width + cw > max_width && !current.is_empty() {
|
||||
chunks.push(std::mem::take(&mut current));
|
||||
current_width = 0;
|
||||
}
|
||||
current.push(ch);
|
||||
current_width += cw;
|
||||
}
|
||||
if !current.is_empty() {
|
||||
chunks.push(current);
|
||||
}
|
||||
if chunks.is_empty() {
|
||||
chunks.push(String::new());
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
fn truncate_to_display_width(s: &str, max_width: usize) -> String {
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
if max_width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let full_width = unicode_width::UnicodeWidthStr::width(s);
|
||||
if full_width <= max_width {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
let ellipsis = "…";
|
||||
let ellipsis_width = unicode_width::UnicodeWidthStr::width(ellipsis);
|
||||
if ellipsis_width >= max_width {
|
||||
return ellipsis.to_string();
|
||||
}
|
||||
|
||||
let target_width = max_width - ellipsis_width;
|
||||
let mut truncated = String::new();
|
||||
let mut width = 0usize;
|
||||
for ch in s.chars() {
|
||||
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if width + ch_width > target_width {
|
||||
break;
|
||||
}
|
||||
truncated.push(ch);
|
||||
width += ch_width;
|
||||
}
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
|
||||
fn format_memory_updated_age(updated_at: DateTime<Utc>) -> String {
|
||||
let age = Utc::now().signed_duration_since(updated_at);
|
||||
if age.num_seconds() < 2 {
|
||||
"updated now".to_string()
|
||||
} else if age.num_minutes() < 1 {
|
||||
format!("updated {}s ago", age.num_seconds().max(1))
|
||||
} else if age.num_hours() < 1 {
|
||||
format!("updated {}m ago", age.num_minutes())
|
||||
} else if age.num_days() < 1 {
|
||||
format!("updated {}h ago", age.num_hours())
|
||||
} else if age.num_days() < 7 {
|
||||
format!("updated {}d ago", age.num_days())
|
||||
} else if age.num_days() < 30 {
|
||||
format!("updated {}w ago", (age.num_days() / 7).max(1))
|
||||
} else {
|
||||
format!("updated {}mo ago", (age.num_days() / 30).max(1))
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_age_text_tint(updated_at: Option<DateTime<Utc>>) -> Color {
|
||||
let Some(updated_at) = updated_at else {
|
||||
return Color::Rgb(140, 144, 152);
|
||||
};
|
||||
let age = Utc::now().signed_duration_since(updated_at);
|
||||
if age.num_hours() < 1 {
|
||||
Color::Rgb(146, 156, 149)
|
||||
} else if age.num_days() < 1 {
|
||||
Color::Rgb(142, 148, 156)
|
||||
} else if age.num_days() < 7 {
|
||||
Color::Rgb(145, 144, 154)
|
||||
} else if age.num_days() < 30 {
|
||||
Color::Rgb(150, 143, 147)
|
||||
} else {
|
||||
Color::Rgb(154, 144, 144)
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_tile_content_lines(
|
||||
items: &[MemoryTileItem],
|
||||
inner_width: usize,
|
||||
border_style: Style,
|
||||
text_style: Style,
|
||||
) -> Vec<Line<'static>> {
|
||||
let bullet = "· ";
|
||||
let bullet_width = unicode_width::UnicodeWidthStr::width(bullet);
|
||||
let item_width = inner_width.saturating_sub(bullet_width);
|
||||
|
||||
let mut content_lines: Vec<Line<'static>> = Vec::new();
|
||||
for item in items {
|
||||
let text_fill_style = text_style.fg(memory_age_text_tint(item.updated_at));
|
||||
let meta_fill_style = Style::default().fg(Color::Rgb(160, 165, 172));
|
||||
let text_display_width = unicode_width::UnicodeWidthStr::width(item.content.as_str());
|
||||
if text_display_width <= item_width {
|
||||
let text = item.content.to_string();
|
||||
let padding = inner_width.saturating_sub(bullet_width + text_display_width);
|
||||
let mut spans = vec![
|
||||
Span::styled("│ ", border_style),
|
||||
Span::styled(bullet.to_string(), text_fill_style),
|
||||
Span::styled(text, text_fill_style),
|
||||
];
|
||||
if padding > 0 {
|
||||
spans.push(Span::raw(" ".repeat(padding)));
|
||||
}
|
||||
spans.push(Span::styled(" │", border_style));
|
||||
content_lines.push(Line::from(spans));
|
||||
} else {
|
||||
let indent = bullet_width;
|
||||
let cont_width = inner_width.saturating_sub(indent);
|
||||
let first_chunk_width = item_width;
|
||||
let mut all_chunks: Vec<String> = Vec::new();
|
||||
let first_chunks = split_by_display_width(&item.content, first_chunk_width);
|
||||
if let Some(first) = first_chunks.first() {
|
||||
all_chunks.push(first.clone());
|
||||
let remainder: String = item.content.chars().skip(first.chars().count()).collect();
|
||||
if !remainder.is_empty() {
|
||||
all_chunks.extend(split_by_display_width(&remainder, cont_width));
|
||||
}
|
||||
}
|
||||
for (ci, chunk) in all_chunks.iter().enumerate() {
|
||||
let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str());
|
||||
if ci == 0 {
|
||||
let padding = inner_width.saturating_sub(bullet_width + chunk_width);
|
||||
let mut spans = vec![
|
||||
Span::styled("│ ", border_style),
|
||||
Span::styled(bullet.to_string(), text_fill_style),
|
||||
Span::styled(chunk.clone(), text_fill_style),
|
||||
];
|
||||
if padding > 0 {
|
||||
spans.push(Span::raw(" ".repeat(padding)));
|
||||
}
|
||||
spans.push(Span::styled(" │", border_style));
|
||||
content_lines.push(Line::from(spans));
|
||||
} else {
|
||||
let padding = inner_width.saturating_sub(indent + chunk_width);
|
||||
let mut spans = vec![
|
||||
Span::styled("│ ", border_style),
|
||||
Span::raw(" ".repeat(indent)),
|
||||
Span::styled(chunk.clone(), text_fill_style),
|
||||
];
|
||||
if padding > 0 {
|
||||
spans.push(Span::raw(" ".repeat(padding)));
|
||||
}
|
||||
spans.push(Span::styled(" │", border_style));
|
||||
content_lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(updated_at) = item.updated_at {
|
||||
let meta = format_memory_updated_age(updated_at);
|
||||
let indent = bullet_width;
|
||||
let meta_width = inner_width.saturating_sub(indent).max(1);
|
||||
for chunk in split_by_display_width(&meta, meta_width) {
|
||||
let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str());
|
||||
let padding = inner_width.saturating_sub(indent + chunk_width);
|
||||
content_lines.push(Line::from(vec![
|
||||
Span::styled("│ ", border_style),
|
||||
Span::raw(" ".repeat(indent)),
|
||||
Span::styled(chunk, meta_fill_style),
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(" │", border_style),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if content_lines.is_empty() {
|
||||
content_lines.push(Line::from(vec![
|
||||
Span::styled("│ ", border_style),
|
||||
Span::raw(" ".repeat(inner_width)),
|
||||
Span::styled(" │", border_style),
|
||||
]));
|
||||
}
|
||||
|
||||
content_lines
|
||||
}
|
||||
|
||||
fn render_memory_tile_box(
|
||||
tile: &MemoryTile,
|
||||
box_width: usize,
|
||||
border_style: Style,
|
||||
text_style: Style,
|
||||
) -> Vec<Line<'static>> {
|
||||
let inner_width = box_width.saturating_sub(4);
|
||||
if inner_width < 4 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let title_max_width = box_width.saturating_sub(4);
|
||||
let title_label = truncate_to_display_width(&tile.category.to_lowercase(), title_max_width);
|
||||
let title_text = format!(" {} ", title_label);
|
||||
let title_len = unicode_width::UnicodeWidthStr::width(title_text.as_str());
|
||||
let border_chars = box_width.saturating_sub(title_len + 2);
|
||||
let left_border = "─".repeat(border_chars / 2);
|
||||
let right_border = "─".repeat(border_chars - border_chars / 2);
|
||||
|
||||
let top = Line::from(Span::styled(
|
||||
format!("╭{}{}{}╮", left_border, title_text, right_border),
|
||||
border_style,
|
||||
));
|
||||
let content_lines =
|
||||
memory_tile_content_lines(&tile.items, inner_width, border_style, text_style);
|
||||
let bottom = Line::from(Span::styled(
|
||||
format!("╰{}╯", "─".repeat(box_width.saturating_sub(2))),
|
||||
border_style,
|
||||
));
|
||||
|
||||
let mut lines = Vec::with_capacity(content_lines.len() + 2);
|
||||
lines.push(top);
|
||||
lines.extend(content_lines);
|
||||
lines.push(bottom);
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn plan_memory_tile(
|
||||
tile: &MemoryTile,
|
||||
box_width: usize,
|
||||
border_style: Style,
|
||||
text_style: Style,
|
||||
) -> Option<MemoryTilePlan> {
|
||||
let lines = render_memory_tile_box(tile, box_width, border_style, text_style);
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let width = lines.first().map(Line::width).unwrap_or(box_width);
|
||||
let height = lines.len();
|
||||
let score = tile.items.len() * 10
|
||||
+ tile
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| unicode_width::UnicodeWidthStr::width(item.content.as_str()).min(80))
|
||||
.sum::<usize>();
|
||||
Some(MemoryTilePlan {
|
||||
lines,
|
||||
width,
|
||||
height,
|
||||
score,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn choose_memory_tile_span(
|
||||
tile: &MemoryTile,
|
||||
column_width: usize,
|
||||
gap: usize,
|
||||
max_span: usize,
|
||||
border_style: Style,
|
||||
text_style: Style,
|
||||
) -> Option<(MemoryTilePlan, usize)> {
|
||||
let single = plan_memory_tile(tile, column_width, border_style, text_style)?;
|
||||
let mut best_plan = single.clone();
|
||||
let mut best_span = 1usize;
|
||||
|
||||
for span in 2..=max_span.max(1) {
|
||||
let width = column_width * span + gap * span.saturating_sub(1);
|
||||
let Some(plan) = plan_memory_tile(tile, width, border_style, text_style) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let single_area = single.width * single.height;
|
||||
let span_area = plan.width * plan.height;
|
||||
let height_gain = single.height.saturating_sub(plan.height);
|
||||
let area_gain = single_area.saturating_sub(span_area);
|
||||
|
||||
if height_gain >= 2 || (height_gain >= 1 && area_gain > column_width) {
|
||||
best_plan = plan;
|
||||
best_span = span;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Some((best_plan, best_span))
|
||||
}
|
||||
|
||||
pub fn render_memory_tiles(
|
||||
tiles: &[MemoryTile],
|
||||
total_width: usize,
|
||||
border_style: Style,
|
||||
text_style: Style,
|
||||
header_line: Option<Line<'static>>,
|
||||
) -> Vec<Line<'static>> {
|
||||
if tiles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut all_lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
if let Some(header) = header_line {
|
||||
all_lines.push(header);
|
||||
}
|
||||
|
||||
let min_box_inner = 16usize;
|
||||
let min_box_width = min_box_inner + 4;
|
||||
let gap = 2usize;
|
||||
let row_gap = 0usize;
|
||||
let usable_width = total_width.max(min_box_width);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Placement {
|
||||
x: usize,
|
||||
y: usize,
|
||||
plan: MemoryTilePlan,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PlannedTile {
|
||||
span: usize,
|
||||
plan: MemoryTilePlan,
|
||||
}
|
||||
|
||||
let max_cols = ((usable_width + gap) / (min_box_width + gap)).clamp(1, 4);
|
||||
let mut best_layout: Option<(Vec<Placement>, usize, usize)> = None;
|
||||
|
||||
for column_count in 1..=max_cols {
|
||||
let column_width = (usable_width.saturating_sub((column_count - 1) * gap)) / column_count;
|
||||
if column_width < min_box_width {
|
||||
continue;
|
||||
}
|
||||
|
||||
let max_span = if column_count >= 2 { 2 } else { 1 };
|
||||
let mut planned: Vec<PlannedTile> = tiles
|
||||
.iter()
|
||||
.filter_map(|tile| {
|
||||
let (plan, span) = choose_memory_tile_span(
|
||||
tile,
|
||||
column_width,
|
||||
gap,
|
||||
max_span,
|
||||
border_style,
|
||||
text_style,
|
||||
)?;
|
||||
Some(PlannedTile { span, plan })
|
||||
})
|
||||
.collect();
|
||||
|
||||
if planned.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
planned.sort_by(|a, b| {
|
||||
b.plan
|
||||
.score
|
||||
.cmp(&a.plan.score)
|
||||
.then_with(|| b.span.cmp(&a.span))
|
||||
.then_with(|| b.plan.height.cmp(&a.plan.height))
|
||||
.then_with(|| b.plan.width.cmp(&a.plan.width))
|
||||
});
|
||||
|
||||
let mut column_heights = vec![0usize; column_count];
|
||||
let mut placements: Vec<Placement> = Vec::with_capacity(planned.len());
|
||||
|
||||
for planned_tile in planned {
|
||||
let mut best_start = 0usize;
|
||||
let mut best_y = usize::MAX;
|
||||
|
||||
for start_col in 0..=column_count.saturating_sub(planned_tile.span) {
|
||||
let y = column_heights[start_col..start_col + planned_tile.span]
|
||||
.iter()
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
if y < best_y || (y == best_y && start_col < best_start) {
|
||||
best_start = start_col;
|
||||
best_y = y;
|
||||
}
|
||||
}
|
||||
|
||||
let x = best_start * (column_width + gap);
|
||||
let next_height = best_y + planned_tile.plan.height + row_gap;
|
||||
for height in &mut column_heights[best_start..best_start + planned_tile.span] {
|
||||
*height = next_height;
|
||||
}
|
||||
|
||||
placements.push(Placement {
|
||||
x,
|
||||
y: best_y,
|
||||
plan: planned_tile.plan,
|
||||
});
|
||||
}
|
||||
|
||||
let total_height = column_heights
|
||||
.iter()
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.saturating_sub(row_gap);
|
||||
let imbalance = column_heights.iter().copied().max().unwrap_or(0)
|
||||
- column_heights.iter().copied().min().unwrap_or(0);
|
||||
let used_width = column_count * column_width + gap * column_count.saturating_sub(1);
|
||||
let leftover_width = usable_width.saturating_sub(used_width);
|
||||
|
||||
// Vertical centering: if this column arrangement has imbalanced columns,
|
||||
// center shorter columns' tiles vertically within the available space.
|
||||
let max_col_height = *column_heights.iter().max().unwrap_or(&0);
|
||||
for (col_idx, col_height) in column_heights.iter().enumerate() {
|
||||
if *col_height < max_col_height {
|
||||
let extra = max_col_height - col_height;
|
||||
let offset = extra / 2;
|
||||
if offset > 0 {
|
||||
for placed in placements.iter_mut() {
|
||||
let start_col = placed.x / (column_width + gap);
|
||||
if start_col == col_idx {
|
||||
placed.y += offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let layout_score = total_height * 100 + imbalance * 3 + leftover_width;
|
||||
|
||||
match &best_layout {
|
||||
Some((_, _, best_score)) if *best_score <= layout_score => {}
|
||||
_ => best_layout = Some((placements, total_height, layout_score)),
|
||||
}
|
||||
}
|
||||
|
||||
let Some((mut placements, total_height, _)) = best_layout else {
|
||||
return all_lines;
|
||||
};
|
||||
|
||||
placements.sort_by(|a, b| a.x.cmp(&b.x).then_with(|| a.y.cmp(&b.y)));
|
||||
|
||||
for y in 0..total_height {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
let mut row_has_content = false;
|
||||
for placed in placements
|
||||
.iter()
|
||||
.filter(|placed| y >= placed.y && y < placed.y + placed.plan.height)
|
||||
{
|
||||
if placed.x > cursor {
|
||||
spans.push(Span::raw(" ".repeat(placed.x - cursor)));
|
||||
}
|
||||
spans.extend(placed.plan.lines[y - placed.y].spans.clone());
|
||||
cursor = placed.x + placed.plan.width;
|
||||
row_has_content = true;
|
||||
}
|
||||
if row_has_content {
|
||||
if cursor < usable_width {
|
||||
spans.push(Span::raw(" ".repeat(usable_width - cursor)));
|
||||
}
|
||||
all_lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
all_lines
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,611 @@
|
||||
//! Gallery / grid layout for live swarm-agent viewports.
|
||||
//!
|
||||
//! Unlike [`crate::memory_tiles`], which is a ragged masonry bin-packer optimized
|
||||
//! for boxes whose heights are fixed by their content (a memory entry is however
|
||||
//! many lines it is), this module lays out a set of *streaming viewports* whose
|
||||
//! heights we control. The goals are different:
|
||||
//!
|
||||
//! - Cells should be uniform and scannable (gallery view), not artfully packed.
|
||||
//! - Each cell shows the *tail* of an agent's output, bottom-anchored like a
|
||||
//! terminal, filling exactly its height budget.
|
||||
//! - The total height is a knob: more agents -> shorter viewports each, instead
|
||||
//! of overflowing.
|
||||
//!
|
||||
//! The placement is a simple grid allocator (column count chosen to keep cells
|
||||
//! near a target aspect ratio), reusing the box-drawing/width helpers shared
|
||||
//! with the memory tiles.
|
||||
|
||||
use ratatui::prelude::*;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::memory_tiles::split_by_display_width;
|
||||
|
||||
/// One agent's viewport to render in the gallery.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SwarmTile {
|
||||
/// Title line (e.g. agent friendly name).
|
||||
pub title: String,
|
||||
/// Short status badge text (e.g. "running", "done").
|
||||
pub status: String,
|
||||
/// Color used for the status badge and accents.
|
||||
pub accent: Color,
|
||||
/// Optional role/prefix glyph drawn before the title (e.g. "★").
|
||||
pub role_glyph: Option<String>,
|
||||
/// The agent's recent output, oldest first. The renderer shows the tail.
|
||||
pub body: Vec<String>,
|
||||
}
|
||||
|
||||
impl SwarmTile {
|
||||
pub fn new(title: impl Into<String>, status: impl Into<String>, accent: Color) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
status: status.into(),
|
||||
accent,
|
||||
role_glyph: None,
|
||||
body: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_role_glyph(mut self, glyph: impl Into<String>) -> Self {
|
||||
self.role_glyph = Some(glyph.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_body(mut self, body: Vec<String>) -> Self {
|
||||
self.body = body;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Tunable parameters for the gallery layout.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SwarmGalleryConfig {
|
||||
/// Total height budget for the whole gallery (excluding an optional header).
|
||||
pub max_height: usize,
|
||||
/// Minimum inner width per cell (columns) before we reduce the column count.
|
||||
pub min_cell_inner_width: usize,
|
||||
/// Minimum cell height (including borders). Cells never shrink below this;
|
||||
/// if the budget can't fit all agents at this height, we overflow into a
|
||||
/// "+N more" strip.
|
||||
pub min_cell_height: usize,
|
||||
/// Preferred cell height (including borders) when there is room to spare.
|
||||
pub preferred_cell_height: usize,
|
||||
/// Horizontal gap between cells.
|
||||
pub gap: usize,
|
||||
/// Target width:height ratio for a cell, used to pick the column count.
|
||||
pub target_aspect: f32,
|
||||
}
|
||||
|
||||
impl Default for SwarmGalleryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_height: 16,
|
||||
min_cell_inner_width: 18,
|
||||
min_cell_height: 4,
|
||||
preferred_cell_height: 7,
|
||||
gap: 2,
|
||||
target_aspect: 4.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct GridShape {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
/// Number of tiles actually shown (rest go into the overflow strip).
|
||||
shown: usize,
|
||||
}
|
||||
|
||||
/// Choose how many columns/rows to use for `tile_count` agents in the given
|
||||
/// budget. Picks the column count whose resulting cell aspect ratio is closest
|
||||
/// to `target_aspect`, subject to width/height minimums.
|
||||
fn choose_grid(
|
||||
tile_count: usize,
|
||||
total_width: usize,
|
||||
cfg: &SwarmGalleryConfig,
|
||||
) -> Option<GridShape> {
|
||||
if tile_count == 0 || total_width == 0 || cfg.max_height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Max columns that still give each cell at least the minimum inner width.
|
||||
let min_cell_total = cfg.min_cell_inner_width + 2; // borders
|
||||
let max_cols_by_width =
|
||||
((total_width + cfg.gap) / (min_cell_total + cfg.gap)).clamp(1, tile_count);
|
||||
if max_cols_by_width == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Max rows that fit at least the minimum cell height. Guard against a
|
||||
// degenerate `min_cell_height` of 0 (division by zero).
|
||||
let max_rows_by_height = (cfg.max_height / cfg.min_cell_height.max(1)).max(1);
|
||||
let max_visible_cells = (max_cols_by_width * max_rows_by_height).max(1);
|
||||
|
||||
let mut best: Option<(f32, GridShape)> = None;
|
||||
for cols in 1..=max_cols_by_width {
|
||||
let cell_w = (total_width.saturating_sub((cols - 1) * cfg.gap)) / cols;
|
||||
if cell_w < min_cell_total {
|
||||
continue;
|
||||
}
|
||||
// Rows needed for this column count, capped by what fits vertically.
|
||||
let rows_needed = tile_count.div_ceil(cols);
|
||||
let rows = rows_needed.min(max_rows_by_height).max(1);
|
||||
let shown = (cols * rows).min(tile_count).min(max_visible_cells);
|
||||
let cell_h = (cfg.max_height.saturating_sub((rows - 1) * cfg.gap)) / rows;
|
||||
if cell_h == 0 {
|
||||
continue;
|
||||
}
|
||||
let aspect = cell_w as f32 / cell_h as f32;
|
||||
// Penalize empty slots in the last row (orphan tiles look unbalanced).
|
||||
let empty_slots = (cols * rows).saturating_sub(shown);
|
||||
// Prefer aspect close to target; penalize raggedness; tie-break toward
|
||||
// showing more tiles.
|
||||
let cost =
|
||||
(aspect - cfg.target_aspect).abs() + (empty_slots as f32) * 0.6 - (shown as f32) * 0.01;
|
||||
let shape = GridShape { cols, rows, shown };
|
||||
match &best {
|
||||
Some((best_cost, _)) if *best_cost <= cost => {}
|
||||
_ => best = Some((cost, shape)),
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(_, shape)| shape)
|
||||
}
|
||||
|
||||
/// Render a single cell box of the given inner dimensions. `inner_w`/`inner_h`
|
||||
/// are the content area (excluding borders).
|
||||
fn render_cell(tile: &SwarmTile, inner_w: usize, inner_h: usize) -> Vec<Line<'static>> {
|
||||
let border_style = Style::default().fg(Color::Rgb(80, 80, 92));
|
||||
let accent_style = Style::default().fg(tile.accent);
|
||||
|
||||
// ---- Title bar (drawn into the top border line) ----
|
||||
let glyph = tile.role_glyph.as_deref().unwrap_or("");
|
||||
let title_raw = if glyph.is_empty() {
|
||||
tile.title.clone()
|
||||
} else {
|
||||
format!("{} {}", glyph, tile.title)
|
||||
};
|
||||
let box_width = inner_w + 2;
|
||||
|
||||
// Top border, total width == box_width, structured as:
|
||||
// ╭─ <title> <dashes> <badge> ─╮
|
||||
// Fixed cost: ╭(1) ─(1) space(1) ... space(1) ─(1) ╮(1) = 6 columns plus
|
||||
// the dash-fill segment of at least 1. Badge is included only if it fits.
|
||||
let fixed = 6usize; // corners + two leading dashes/space wrappers
|
||||
let mut badge = format!("[{}]", tile.status);
|
||||
let mut badge_w = UnicodeWidthStr::width(badge.as_str());
|
||||
|
||||
// Reserve room: at least 1 title char, 1 dash filler, and a space before
|
||||
// the badge. If the badge can't fit, drop it.
|
||||
let min_dashes = 1usize;
|
||||
let title_budget = box_width
|
||||
.saturating_sub(fixed + min_dashes + badge_w + 1)
|
||||
.max(1);
|
||||
let mut title_text = truncate_w(&title_raw, title_budget);
|
||||
let mut title_w = UnicodeWidthStr::width(title_text.as_str());
|
||||
|
||||
if fixed + title_w + min_dashes + badge_w + 1 > box_width {
|
||||
// No room for the badge; drop it and give the title the space.
|
||||
badge = String::new();
|
||||
badge_w = 0;
|
||||
let title_budget = box_width.saturating_sub(fixed + min_dashes).max(1);
|
||||
title_text = truncate_w(&title_raw, title_budget);
|
||||
title_w = UnicodeWidthStr::width(title_text.as_str());
|
||||
}
|
||||
|
||||
// Remaining columns become the dash filler. Subtract: corners(2),
|
||||
// leading "─ "(2), title, trailing " ─"(2), and "[badge] " if present.
|
||||
let badge_segment = if badge.is_empty() { 0 } else { badge_w + 1 };
|
||||
let dashes = box_width
|
||||
.saturating_sub(2 + 2 + title_w + 2 + badge_segment)
|
||||
.max(1);
|
||||
|
||||
let mut top_spans: Vec<Span<'static>> = vec![
|
||||
Span::styled("╭─ ".to_string(), border_style),
|
||||
Span::styled(title_text, accent_style.bold()),
|
||||
Span::styled(" ".to_string(), border_style),
|
||||
Span::styled("─".repeat(dashes), border_style),
|
||||
];
|
||||
if !badge.is_empty() {
|
||||
top_spans.push(Span::styled(" ".to_string(), border_style));
|
||||
top_spans.push(Span::styled(badge, accent_style));
|
||||
}
|
||||
top_spans.push(Span::styled("─╮".to_string(), border_style));
|
||||
// Final guard against off-by-one from width rounding and against boxes
|
||||
// narrower than the fixed title-bar chrome.
|
||||
normalize_top_width(&mut top_spans, box_width, border_style);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::with_capacity(inner_h + 2);
|
||||
lines.push(Line::from(top_spans));
|
||||
|
||||
// ---- Body: bottom-anchored tail of the stream ----
|
||||
let body_lines = wrap_tail(&tile.body, inner_w, inner_h);
|
||||
let blank_top = inner_h.saturating_sub(body_lines.len());
|
||||
let text_style = Style::default().fg(Color::Rgb(170, 172, 180));
|
||||
for _ in 0..blank_top {
|
||||
lines.push(content_line("", inner_w, border_style, text_style));
|
||||
}
|
||||
for text in body_lines {
|
||||
lines.push(content_line(&text, inner_w, border_style, text_style));
|
||||
}
|
||||
|
||||
// ---- Bottom border ----
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("╰{}╯", "─".repeat(box_width.saturating_sub(2))),
|
||||
border_style,
|
||||
)));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn normalize_top_width(spans: &mut Vec<Span<'static>>, box_width: usize, border_style: Style) {
|
||||
let cur: usize = spans
|
||||
.iter()
|
||||
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
|
||||
.sum();
|
||||
if cur < box_width {
|
||||
// Pad just before the trailing " ─╮".
|
||||
let pad = box_width - cur;
|
||||
let insert_at = spans.len().saturating_sub(1);
|
||||
spans.insert(insert_at, Span::styled("─".repeat(pad), border_style));
|
||||
} else if cur > box_width {
|
||||
// The box is too narrow for the titled border (fixed chrome is ~8
|
||||
// columns). Fall back to a plain border of exactly `box_width`.
|
||||
let fill = "─".repeat(box_width.saturating_sub(2));
|
||||
spans.clear();
|
||||
spans.push(Span::styled(format!("╭{fill}╮"), border_style));
|
||||
}
|
||||
}
|
||||
|
||||
fn content_line(
|
||||
text: &str,
|
||||
inner_w: usize,
|
||||
border_style: Style,
|
||||
text_style: Style,
|
||||
) -> Line<'static> {
|
||||
let truncated = truncate_w(text, inner_w);
|
||||
let w = UnicodeWidthStr::width(truncated.as_str());
|
||||
let pad = inner_w.saturating_sub(w);
|
||||
Line::from(vec![
|
||||
Span::styled("│".to_string(), border_style),
|
||||
Span::styled(truncated, text_style),
|
||||
Span::raw(" ".repeat(pad)),
|
||||
Span::styled("│".to_string(), border_style),
|
||||
])
|
||||
}
|
||||
|
||||
/// Take the tail of `body`, wrapping each logical line to `width`, and return at
|
||||
/// most `height` rendered rows (the most recent ones).
|
||||
fn wrap_tail(body: &[String], width: usize, height: usize) -> Vec<String> {
|
||||
if width == 0 || height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut rows: Vec<String> = Vec::new();
|
||||
// Wrap from the end until we have enough rows.
|
||||
for logical in body.iter().rev() {
|
||||
let mut wrapped = if logical.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
split_by_display_width(logical, width)
|
||||
};
|
||||
// Keep wrapped chunks in natural (top-to-bottom) order, but we are
|
||||
// walking logical lines bottom-up, so prepend.
|
||||
let mut prefix = std::mem::take(&mut wrapped);
|
||||
prefix.extend(rows);
|
||||
rows = prefix;
|
||||
if rows.len() >= height {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if rows.len() > height {
|
||||
let start = rows.len() - height;
|
||||
rows = rows.split_off(start);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn truncate_w(s: &str, max_width: usize) -> String {
|
||||
if max_width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let full = UnicodeWidthStr::width(s);
|
||||
if full <= max_width {
|
||||
return s.to_string();
|
||||
}
|
||||
let ellipsis = "…";
|
||||
let target = max_width.saturating_sub(1);
|
||||
let mut out = String::new();
|
||||
let mut w = 0usize;
|
||||
for ch in s.chars() {
|
||||
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if w + cw > target {
|
||||
break;
|
||||
}
|
||||
out.push(ch);
|
||||
w += cw;
|
||||
}
|
||||
out.push_str(ellipsis);
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a single tile filling exactly `width` x `height` (including borders).
|
||||
/// Used by the list+detail swarm panel to draw the focused agent's viewport.
|
||||
/// Returns an empty vec when the area is too small to draw a bordered box.
|
||||
pub fn render_single_tile(tile: &SwarmTile, width: usize, height: usize) -> Vec<Line<'static>> {
|
||||
if width < 4 || height < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
let inner_w = width - 2;
|
||||
let inner_h = height - 2;
|
||||
let mut lines = render_cell(tile, inner_w, inner_h);
|
||||
// render_cell already yields `height` lines, but pad/truncate defensively so
|
||||
// callers can rely on the exact height.
|
||||
while lines.len() < height {
|
||||
lines.push(Line::from(Span::raw(" ".repeat(width))));
|
||||
}
|
||||
lines.truncate(height);
|
||||
lines
|
||||
}
|
||||
|
||||
/// Render the swarm gallery. Returns lines ready to draw, fitting within
|
||||
/// `total_width` columns and roughly `cfg.max_height` rows (plus header).
|
||||
pub fn render_swarm_gallery(
|
||||
tiles: &[SwarmTile],
|
||||
total_width: usize,
|
||||
cfg: &SwarmGalleryConfig,
|
||||
header: Option<Line<'static>>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
if let Some(header) = header {
|
||||
out.push(header);
|
||||
}
|
||||
if tiles.is_empty() {
|
||||
return out;
|
||||
}
|
||||
|
||||
let Some(shape) = choose_grid(tiles.len(), total_width, cfg) else {
|
||||
return out;
|
||||
};
|
||||
|
||||
let cols = shape.cols;
|
||||
let rows = shape.rows;
|
||||
let shown = shape.shown;
|
||||
let cell_total_w = (total_width.saturating_sub((cols - 1) * cfg.gap)) / cols;
|
||||
let cell_inner_w = cell_total_w.saturating_sub(2);
|
||||
|
||||
// Distribute the height budget across rows; cap at preferred height.
|
||||
// Guard against inverted config (preferred < min) which would panic clamp.
|
||||
let max_cell_h = cfg.preferred_cell_height.max(cfg.min_cell_height);
|
||||
let cell_total_h = (cfg.max_height / rows.max(1)).clamp(cfg.min_cell_height, max_cell_h);
|
||||
let cell_inner_h = cell_total_h.saturating_sub(2);
|
||||
|
||||
// Render each shown tile into a grid of line buffers.
|
||||
let visible = &tiles[..shown];
|
||||
for row in 0..rows {
|
||||
let row_start = row * cols;
|
||||
if row_start >= shown {
|
||||
break;
|
||||
}
|
||||
let row_end = (row_start + cols).min(shown);
|
||||
let row_tiles = &visible[row_start..row_end];
|
||||
|
||||
// Render each cell in this row.
|
||||
let cell_blocks: Vec<Vec<Line<'static>>> = row_tiles
|
||||
.iter()
|
||||
.map(|tile| render_cell(tile, cell_inner_w, cell_inner_h))
|
||||
.collect();
|
||||
|
||||
for line_idx in 0..cell_total_h {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
for (ci, block) in cell_blocks.iter().enumerate() {
|
||||
if ci > 0 {
|
||||
spans.push(Span::raw(" ".repeat(cfg.gap)));
|
||||
}
|
||||
if let Some(line) = block.get(line_idx) {
|
||||
spans.extend(line.spans.clone());
|
||||
} else {
|
||||
spans.push(Span::raw(" ".repeat(cell_total_w)));
|
||||
}
|
||||
}
|
||||
out.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
let hidden = tiles.len().saturating_sub(shown);
|
||||
if hidden > 0 {
|
||||
out.push(Line::from(Span::styled(
|
||||
format!(
|
||||
" +{} more agent{}",
|
||||
hidden,
|
||||
if hidden == 1 { "" } else { "s" }
|
||||
),
|
||||
Style::default().fg(Color::Rgb(140, 140, 150)),
|
||||
)));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tile(name: &str, body: &[&str]) -> SwarmTile {
|
||||
SwarmTile::new(name, "running", Color::Rgb(255, 200, 100))
|
||||
.with_body(body.iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_agent_uses_one_column() {
|
||||
let tiles = vec![tile("alpha", &["hello", "world"])];
|
||||
let cfg = SwarmGalleryConfig::default();
|
||||
let lines = render_swarm_gallery(&tiles, 60, &cfg, None);
|
||||
assert!(!lines.is_empty());
|
||||
// Top border present.
|
||||
let top = plain(&lines[0]);
|
||||
assert!(top.starts_with("╭─ alpha"), "got: {top}");
|
||||
assert!(top.contains("[running]"), "got: {top}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_agents_form_multiple_columns() {
|
||||
let tiles: Vec<SwarmTile> = (0..4).map(|i| tile(&format!("a{i}"), &["x"])).collect();
|
||||
let shape = choose_grid(4, 100, &SwarmGalleryConfig::default()).unwrap();
|
||||
assert!(shape.cols >= 2, "expected multi-column, got {shape:?}");
|
||||
let lines = render_swarm_gallery(&tiles, 100, &SwarmGalleryConfig::default(), None);
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_shows_most_recent_lines() {
|
||||
let body: Vec<String> = (0..20).map(|i| format!("line{i}")).collect();
|
||||
let rows = wrap_tail(&body, 20, 3);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[2], "line19");
|
||||
assert_eq!(rows[0], "line17");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_emits_more_strip() {
|
||||
let tiles: Vec<SwarmTile> = (0..50).map(|i| tile(&format!("a{i}"), &["x"])).collect();
|
||||
let cfg = SwarmGalleryConfig {
|
||||
max_height: 8,
|
||||
..Default::default()
|
||||
};
|
||||
let lines = render_swarm_gallery(&tiles, 60, &cfg, None);
|
||||
let last = plain(lines.last().unwrap());
|
||||
assert!(last.contains("more agent"), "got: {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_are_width_bounded() {
|
||||
let tiles: Vec<SwarmTile> = (0..3)
|
||||
.map(|i| {
|
||||
tile(
|
||||
&format!("a{i}"),
|
||||
&["a very long line that should be truncated nicely"],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let cfg = SwarmGalleryConfig::default();
|
||||
let lines = render_swarm_gallery(&tiles, 80, &cfg, None);
|
||||
for line in &lines {
|
||||
assert!(line.width() <= 80, "line too wide: {}", plain(line));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tiles_render_nothing_but_header() {
|
||||
let cfg = SwarmGalleryConfig::default();
|
||||
assert!(render_swarm_gallery(&[], 80, &cfg, None).is_empty());
|
||||
let lines = render_swarm_gallery(&[], 80, &cfg, Some(Line::from("hdr")));
|
||||
assert_eq!(lines.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sized_budgets_do_not_panic() {
|
||||
let tiles = vec![tile("a", &["x"])];
|
||||
for (w, h) in [(0usize, 0usize), (0, 16), (60, 0), (1, 1), (3, 3)] {
|
||||
let cfg = SwarmGalleryConfig {
|
||||
max_height: h,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = render_swarm_gallery(&tiles, w, &cfg, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_config_does_not_panic() {
|
||||
let tiles = vec![tile("a", &["x"])];
|
||||
// min_cell_height = 0 previously divided by zero in choose_grid.
|
||||
let cfg = SwarmGalleryConfig {
|
||||
min_cell_height: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = render_swarm_gallery(&tiles, 60, &cfg, None);
|
||||
// preferred < min previously panicked in clamp().
|
||||
let cfg = SwarmGalleryConfig {
|
||||
min_cell_height: 4,
|
||||
preferred_cell_height: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let _ = render_swarm_gallery(&tiles, 60, &cfg, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hundreds_of_tiles_small_terminal_stay_bounded() {
|
||||
let tiles: Vec<SwarmTile> = (0..300)
|
||||
.map(|i| tile(&format!("agent-{i}"), &["node gate-7 running", "ok"]))
|
||||
.collect();
|
||||
for width in [1usize, 5, 10, 20, 21, 40, 80] {
|
||||
for mh in [1usize, 2, 4, 8, 16] {
|
||||
let cfg = SwarmGalleryConfig {
|
||||
max_height: mh,
|
||||
..Default::default()
|
||||
};
|
||||
let lines = render_swarm_gallery(&tiles, width, &cfg, None);
|
||||
for line in &lines {
|
||||
assert!(
|
||||
line.width() <= width.max(1),
|
||||
"width={width} mh={mh}: line {} wider than {width}: {:?}",
|
||||
line.width(),
|
||||
plain(line)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_tile_tiny_sizes_stay_bounded() {
|
||||
let t = SwarmTile::new("日本語のタイトル", "実行中", Color::Cyan).with_body(vec![
|
||||
"こんにちは世界こんにちは世界".to_string(),
|
||||
"🐝🐝🐝🐝🐝🐝".to_string(),
|
||||
"e\u{301}e\u{301} combining".to_string(),
|
||||
]);
|
||||
for w in 0..24usize {
|
||||
for h in 0..8usize {
|
||||
let lines = render_single_tile(&t, w, h);
|
||||
if w < 4 || h < 3 {
|
||||
assert!(lines.is_empty(), "expected empty at {w}x{h}");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(lines.len(), h, "height mismatch at {w}x{h}");
|
||||
for line in &lines {
|
||||
assert!(
|
||||
line.width() <= w,
|
||||
"{w}x{h}: line {} too wide: {:?}",
|
||||
line.width(),
|
||||
plain(line)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_tail_handles_zero_and_empty() {
|
||||
assert!(wrap_tail(&[], 10, 3).is_empty());
|
||||
assert!(wrap_tail(&["x".to_string()], 0, 3).is_empty());
|
||||
assert!(wrap_tail(&["x".to_string()], 10, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_w_handles_wide_chars() {
|
||||
assert_eq!(truncate_w("", 5), "");
|
||||
assert_eq!(truncate_w("abc", 0), "");
|
||||
// 2-column chars never split mid-glyph and result stays within budget.
|
||||
for max in 1..8usize {
|
||||
let out = truncate_w("日本語テスト", max);
|
||||
assert!(
|
||||
UnicodeWidthStr::width(out.as_str()) <= max,
|
||||
"max={max} out={out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn plain(line: &Line<'static>) -> String {
|
||||
line.spans.iter().map(|s| s.content.as_ref()).collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user