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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+91
View File
@@ -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)
}
}
+64
View File
@@ -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);
}
}
+202
View File
@@ -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>()
}
+587
View File
@@ -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
+611
View File
@@ -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()
}
}