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,11 @@
|
||||
[package]
|
||||
name = "jcode-tui-render"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
jcode-tui-style = { path = "../jcode-tui-style" }
|
||||
ratatui = "0.30"
|
||||
unicode-width = "0.2"
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Interactive, animated demo of the inline swarm gallery.
|
||||
//!
|
||||
//! This runs as a real full-screen terminal app so you can *see* the inline
|
||||
//! gallery the way it looks live in the jcode TUI, without touching any running
|
||||
//! agents. It simulates a handful of mock swarm workers that stream output,
|
||||
//! change status, and finish over time, rendering them through the exact same
|
||||
//! `render_swarm_gallery` layout the real TUI uses.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo run --profile selfdev -p jcode-tui-render --example swarm_gallery_live
|
||||
//!
|
||||
//! Controls:
|
||||
//! q / Esc quit
|
||||
//! + / - more / fewer agents
|
||||
//! [ / ] shrink / grow the gallery band (the max_pct knob)
|
||||
//! space pause / resume the animation
|
||||
|
||||
use std::io::{self, Stdout};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::crossterm::{
|
||||
event::{self, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||
|
||||
use jcode_tui_render::swarm_gallery::{GalleryMember, humanize_age, render_gallery};
|
||||
|
||||
/// A simulated worker, mirroring the fields the real adapter reads from a
|
||||
/// `SwarmMemberStatus` (name, role, status, streamed output tail).
|
||||
struct MockWorker {
|
||||
name: String,
|
||||
role: Option<&'static str>,
|
||||
status: String,
|
||||
/// Full streamed transcript so far; the tile shows the tail.
|
||||
transcript: Vec<String>,
|
||||
/// Scripted lines this worker will "stream" over time.
|
||||
script: Vec<String>,
|
||||
next_line: usize,
|
||||
/// Ticks until the next line is appended.
|
||||
cooldown: u16,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl MockWorker {
|
||||
fn new(name: &str, role: Option<&'static str>, script: Vec<&str>) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
role,
|
||||
status: "spawned".to_string(),
|
||||
transcript: Vec::new(),
|
||||
script: script.into_iter().map(|s| s.to_string()).collect(),
|
||||
next_line: 0,
|
||||
cooldown: 2,
|
||||
started: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self) {
|
||||
if self.next_line >= self.script.len() {
|
||||
if self.status != "completed" {
|
||||
self.status = "completed".to_string();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.cooldown > 0 {
|
||||
self.cooldown -= 1;
|
||||
// While waiting, alternate running/thinking to show the badge change.
|
||||
if self.transcript.is_empty() {
|
||||
self.status = "thinking".to_string();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let line = self.script[self.next_line].clone();
|
||||
self.status = if line.starts_with("! ") {
|
||||
"blocked".to_string()
|
||||
} else {
|
||||
"running".to_string()
|
||||
};
|
||||
self.transcript
|
||||
.push(line.trim_start_matches("! ").to_string());
|
||||
self.next_line += 1;
|
||||
self.cooldown = 2 + (self.next_line as u16 % 3);
|
||||
}
|
||||
|
||||
fn age_secs(&self) -> u64 {
|
||||
self.started.elapsed().as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
fn workers_to_members(workers: &[MockWorker]) -> Vec<GalleryMember> {
|
||||
workers
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let mut body: Vec<String> = w.transcript.clone();
|
||||
body.push(format!("· {} ago", humanize_age(w.age_secs())));
|
||||
GalleryMember {
|
||||
label: w.name.clone(),
|
||||
icon: None,
|
||||
status: w.status.clone(),
|
||||
task: None,
|
||||
role: w.role.map(str::to_string),
|
||||
body,
|
||||
sort_key: w.name.clone(),
|
||||
todo: None,
|
||||
todo_items: Vec::new(),
|
||||
model: None,
|
||||
provider: None,
|
||||
auth_method: None,
|
||||
effort: None,
|
||||
elapsed_secs: Some(w.age_secs()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn make_workers(n: usize) -> Vec<MockWorker> {
|
||||
let pool: Vec<(&str, Option<&'static str>, Vec<&str>)> = vec![
|
||||
(
|
||||
"researcher",
|
||||
Some("coordinator"),
|
||||
vec![
|
||||
"Searching the codebase for the auth flow...",
|
||||
"Found 12 candidate files.",
|
||||
"Reading crates/jcode-app-core/src/auth.rs",
|
||||
"The OAuth callback is handled in handle_login()",
|
||||
"Now cross-referencing the token refresh path.",
|
||||
"Refresh happens in refresh_session() (line 412).",
|
||||
"Summarizing findings for the implementer.",
|
||||
],
|
||||
),
|
||||
(
|
||||
"implementer",
|
||||
None,
|
||||
vec![
|
||||
"Editing crates/jcode-base/src/config.rs",
|
||||
"Added swarm_spawn_mode = inline",
|
||||
"Running cargo check...",
|
||||
"warning: unused import `Foo`",
|
||||
"Fixing the import.",
|
||||
"cargo check clean.",
|
||||
"Wiring the gallery into the draw path.",
|
||||
],
|
||||
),
|
||||
(
|
||||
"reviewer",
|
||||
None,
|
||||
vec![
|
||||
"Waiting for the implementer to finish.",
|
||||
"Reviewing the diff...",
|
||||
"Reviewed 4 files.",
|
||||
"! Blocked: needs a test for the new branch.",
|
||||
"Test added, re-reviewing.",
|
||||
"No blocking issues found.",
|
||||
"LGTM",
|
||||
],
|
||||
),
|
||||
(
|
||||
"tester",
|
||||
None,
|
||||
vec![
|
||||
"Building the selfdev profile...",
|
||||
"Compiling jcode-tui-render",
|
||||
"Running 5 tests",
|
||||
"test cells_are_width_bounded ... ok",
|
||||
"test many_agents_form_multiple_columns ... ok",
|
||||
"All tests passed.",
|
||||
],
|
||||
),
|
||||
(
|
||||
"doc-writer",
|
||||
None,
|
||||
vec![
|
||||
"Drafting the config docs.",
|
||||
"Documenting swarm_gallery_max_pct.",
|
||||
"Adding an example to default_file.rs.",
|
||||
"Proofreading.",
|
||||
"Docs ready.",
|
||||
],
|
||||
),
|
||||
(
|
||||
"packager",
|
||||
None,
|
||||
vec![
|
||||
"Preparing the release worktree.",
|
||||
"Bumping version.",
|
||||
"Staging changes.",
|
||||
"Commit drafted.",
|
||||
],
|
||||
),
|
||||
(
|
||||
"benchmarker",
|
||||
None,
|
||||
vec![
|
||||
"Warming up the bench harness.",
|
||||
"Running memory_recall_bench...",
|
||||
"p50 latency 18ms",
|
||||
"p99 latency 64ms",
|
||||
"Results recorded.",
|
||||
],
|
||||
),
|
||||
(
|
||||
"linter",
|
||||
None,
|
||||
vec![
|
||||
"Running clippy...",
|
||||
"warning: needless clone (3)",
|
||||
"Auto-fixing.",
|
||||
"clippy clean.",
|
||||
],
|
||||
),
|
||||
];
|
||||
pool.into_iter()
|
||||
.take(n.max(1))
|
||||
.map(|(name, role, script)| MockWorker::new(name, role, script))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_gallery_lines(
|
||||
workers: &[MockWorker],
|
||||
width: usize,
|
||||
max_height: usize,
|
||||
) -> Vec<Line<'static>> {
|
||||
// Delegate to the exact same shared renderer the live TUI uses; only the
|
||||
// member data (built from mock workers) differs.
|
||||
render_gallery(&workers_to_members(workers), width, max_height)
|
||||
}
|
||||
|
||||
fn draw(f: &mut Frame, workers: &[MockWorker], max_pct: usize, paused: bool) {
|
||||
let area = f.area();
|
||||
|
||||
// Reserve a top band like the real TUI does: a configurable share of the
|
||||
// chat column height, capped, with a >=5 row floor before it shows.
|
||||
let budget = ((area.height as usize * max_pct) / 100).clamp(0, 18);
|
||||
let lines = if budget >= 5 && area.width >= 24 {
|
||||
render_gallery_lines(workers, area.width as usize, budget)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let band_h = if lines.is_empty() {
|
||||
0u16
|
||||
} else {
|
||||
((lines.len() as u16) + 1).min(area.height / 2)
|
||||
};
|
||||
|
||||
let band = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: band_h,
|
||||
};
|
||||
let chat = Rect {
|
||||
x: area.x,
|
||||
y: area.y + band_h,
|
||||
width: area.width,
|
||||
height: area.height.saturating_sub(band_h),
|
||||
};
|
||||
|
||||
if band_h > 0 {
|
||||
f.render_widget(Clear, band);
|
||||
f.render_widget(Paragraph::new(lines), band);
|
||||
}
|
||||
|
||||
// Mock chat / instructions below the band.
|
||||
let help = vec![
|
||||
Line::from(vec![Span::styled(
|
||||
" inline swarm gallery — live demo (no real agents touched)",
|
||||
Style::default()
|
||||
.fg(Color::Rgb(200, 200, 210))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
" The band above is the same renderer the jcode TUI shows above your chat",
|
||||
Style::default().fg(Color::Rgb(150, 150, 160)),
|
||||
)]),
|
||||
Line::from(vec![Span::styled(
|
||||
" when you run a swarm with swarm_spawn_mode = \"inline\".",
|
||||
Style::default().fg(Color::Rgb(150, 150, 160)),
|
||||
)]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
" band size: ",
|
||||
Style::default().fg(Color::Rgb(150, 150, 160)),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{max_pct}% "),
|
||||
Style::default().fg(Color::Rgb(255, 200, 100)),
|
||||
),
|
||||
Span::styled(
|
||||
"(agents.swarm_gallery_max_pct)",
|
||||
Style::default().fg(Color::Rgb(110, 110, 120)),
|
||||
),
|
||||
Span::styled(
|
||||
if paused { " [PAUSED]" } else { "" },
|
||||
Style::default().fg(Color::Rgb(255, 170, 80)),
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
" keys: q/Esc quit +/- agents [ / ] band size space pause",
|
||||
Style::default().fg(Color::Rgb(120, 120, 130)),
|
||||
)]),
|
||||
];
|
||||
let block = Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Style::default().fg(Color::Rgb(70, 70, 80)))
|
||||
.title(Span::styled(
|
||||
" chat ",
|
||||
Style::default().fg(Color::Rgb(120, 120, 130)),
|
||||
));
|
||||
f.render_widget(
|
||||
Paragraph::new(help).block(block).wrap(Wrap { trim: false }),
|
||||
chat,
|
||||
);
|
||||
}
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let mut stdout = io::stdout();
|
||||
enable_raw_mode()?;
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let res = run(&mut terminal);
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut() as &mut CrosstermBackend<Stdout>,
|
||||
LeaveAlternateScreen
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
res
|
||||
}
|
||||
|
||||
fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
|
||||
let mut n_agents = 4usize;
|
||||
let mut workers = make_workers(n_agents);
|
||||
let mut max_pct = 40usize;
|
||||
let mut paused = false;
|
||||
let tick = Duration::from_millis(350);
|
||||
let mut last_tick = Instant::now();
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| draw(f, &workers, max_pct, paused))?;
|
||||
|
||||
let timeout = tick.saturating_sub(last_tick.elapsed());
|
||||
if event::poll(timeout)?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
{
|
||||
let ctrl_c =
|
||||
key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c');
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||
_ if ctrl_c => break,
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||
n_agents = (n_agents + 1).min(8);
|
||||
workers = make_workers(n_agents);
|
||||
}
|
||||
KeyCode::Char('-') | KeyCode::Char('_') => {
|
||||
n_agents = n_agents.saturating_sub(1).max(1);
|
||||
workers = make_workers(n_agents);
|
||||
}
|
||||
KeyCode::Char(']') => max_pct = (max_pct + 5).min(90),
|
||||
KeyCode::Char('[') => max_pct = max_pct.saturating_sub(5).max(5),
|
||||
KeyCode::Char(' ') => paused = !paused,
|
||||
KeyCode::Char('r') => {
|
||||
workers = make_workers(n_agents);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= tick {
|
||||
if !paused {
|
||||
for w in workers.iter_mut() {
|
||||
w.tick();
|
||||
}
|
||||
// When everything is done, loop the demo after a short beat.
|
||||
if workers.iter().all(|w| w.status == "completed")
|
||||
&& workers.iter().all(|w| w.age_secs() > 1)
|
||||
{
|
||||
// restart so the demo keeps streaming
|
||||
workers = make_workers(n_agents);
|
||||
}
|
||||
}
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Visual preview of the swarm gallery layout against mock streams.
|
||||
//!
|
||||
//! Run with: `cargo run --profile selfdev -p jcode-tui-render --example swarm_gallery_preview`
|
||||
|
||||
use jcode_tui_render::swarm_gallery::{
|
||||
GalleryMember, SwarmStripHint, render_swarm_compact, render_swarm_dock, render_swarm_panel,
|
||||
render_swarm_strip, render_swarm_strip_vertical,
|
||||
};
|
||||
use jcode_tui_render::swarm_tiles::{SwarmGalleryConfig, SwarmTile, render_swarm_gallery};
|
||||
use ratatui::prelude::*;
|
||||
|
||||
fn accent(status: &str) -> Color {
|
||||
match status {
|
||||
"running" => Color::Rgb(255, 200, 100),
|
||||
"thinking" => Color::Rgb(140, 180, 255),
|
||||
"done" => Color::Rgb(100, 200, 100),
|
||||
"blocked" => Color::Rgb(255, 170, 80),
|
||||
"failed" => Color::Rgb(255, 100, 100),
|
||||
_ => Color::Rgb(140, 140, 150),
|
||||
}
|
||||
}
|
||||
|
||||
fn mk(name: &str, role: Option<&str>, status: &str, body: &[&str]) -> SwarmTile {
|
||||
let mut t = SwarmTile::new(name, status, accent(status))
|
||||
.with_body(body.iter().map(|s| s.to_string()).collect());
|
||||
if let Some(r) = role {
|
||||
t = t.with_role_glyph(r);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
fn print_lines(label: &str, lines: &[Line<'static>]) {
|
||||
println!("\n=== {label} ===");
|
||||
for line in lines {
|
||||
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
println!("{text}");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let header = Line::from(Span::styled(
|
||||
"🐝 swarm · 3 agents running",
|
||||
Style::default().fg(Color::Rgb(255, 200, 100)),
|
||||
));
|
||||
|
||||
let three = vec![
|
||||
mk(
|
||||
"researcher",
|
||||
Some("★"),
|
||||
"thinking",
|
||||
&[
|
||||
"Searching the codebase for the auth flow...",
|
||||
"Found 12 candidate files.",
|
||||
"Reading crates/jcode-app-core/src/auth.rs",
|
||||
"The OAuth callback is handled in handle_login()",
|
||||
"Now cross-referencing the token refresh path.",
|
||||
],
|
||||
),
|
||||
mk(
|
||||
"implementer",
|
||||
None,
|
||||
"running",
|
||||
&[
|
||||
"Editing crates/jcode-base/src/config.rs",
|
||||
"Added swarm_spawn_mode = inline",
|
||||
"Running cargo check...",
|
||||
"warning: unused import `Foo`",
|
||||
"Fixing the import.",
|
||||
],
|
||||
),
|
||||
mk(
|
||||
"reviewer",
|
||||
None,
|
||||
"done",
|
||||
&["Reviewed 4 files.", "No blocking issues found.", "LGTM ✓"],
|
||||
),
|
||||
];
|
||||
|
||||
let cfg = SwarmGalleryConfig::default();
|
||||
print_lines(
|
||||
"3 agents @ width 100",
|
||||
&render_swarm_gallery(&three, 100, &cfg, Some(header.clone())),
|
||||
);
|
||||
print_lines(
|
||||
"3 agents @ width 60",
|
||||
&render_swarm_gallery(&three, 60, &cfg, Some(header.clone())),
|
||||
);
|
||||
|
||||
let six: Vec<SwarmTile> = (0..6)
|
||||
.map(|i| {
|
||||
let status = ["running", "thinking", "done", "blocked"][i % 4];
|
||||
mk(
|
||||
&format!("agent-{i}"),
|
||||
None,
|
||||
status,
|
||||
&[
|
||||
&format!("step {i}.1 doing work"),
|
||||
&format!("step {i}.2 still going"),
|
||||
&format!("step {i}.3 almost there"),
|
||||
],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
print_lines(
|
||||
"6 agents @ width 120",
|
||||
&render_swarm_gallery(&six, 120, &cfg, Some(header.clone())),
|
||||
);
|
||||
|
||||
let many: Vec<SwarmTile> = (0..12)
|
||||
.map(|i| {
|
||||
mk(
|
||||
&format!("worker-{i:02}"),
|
||||
None,
|
||||
"running",
|
||||
&["...", "working"],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let tight = SwarmGalleryConfig {
|
||||
max_height: 12,
|
||||
..Default::default()
|
||||
};
|
||||
print_lines(
|
||||
"12 agents @ width 120, height 12",
|
||||
&render_swarm_gallery(&many, 120, &tight, Some(header)),
|
||||
);
|
||||
|
||||
// ---- New list+detail panel ----
|
||||
let gm = |name: &str, role: Option<&str>, status: &str, body: &[&str]| GalleryMember {
|
||||
label: name.to_string(),
|
||||
icon: None,
|
||||
status: status.to_string(),
|
||||
task: None,
|
||||
role: role.map(str::to_string),
|
||||
body: body.iter().map(|s| s.to_string()).collect(),
|
||||
sort_key: name.to_string(),
|
||||
todo: None,
|
||||
todo_items: Vec::new(),
|
||||
model: None,
|
||||
provider: None,
|
||||
auth_method: None,
|
||||
effort: None,
|
||||
elapsed_secs: None,
|
||||
};
|
||||
let panel_members = vec![
|
||||
gm(
|
||||
"researcher",
|
||||
Some("coordinator"),
|
||||
"thinking",
|
||||
&["Cross-referencing the token refresh path.", "· 2s ago"],
|
||||
),
|
||||
gm(
|
||||
"implementer",
|
||||
None,
|
||||
"running",
|
||||
&[
|
||||
"Running cargo check...",
|
||||
"warning: unused import `Foo`",
|
||||
"· 5s ago",
|
||||
],
|
||||
),
|
||||
gm("reviewer", None, "done", &["LGTM ✓", "· 1m ago"]),
|
||||
gm(
|
||||
"doc-writer",
|
||||
None,
|
||||
"blocked",
|
||||
&["waiting on reviewer", "· 12s ago"],
|
||||
),
|
||||
];
|
||||
print_lines(
|
||||
"PANEL: 4 agents, selected #1 (implementer), focused @ width 70 h 14",
|
||||
&render_swarm_panel(&panel_members, 1, true, 70, 14),
|
||||
);
|
||||
print_lines(
|
||||
"PANEL: 4 agents, selected #0, unfocused @ width 70 h 14",
|
||||
&render_swarm_panel(&panel_members, 0, false, 70, 14),
|
||||
);
|
||||
print_lines(
|
||||
"PANEL: narrow @ width 44 h 12",
|
||||
&render_swarm_panel(&panel_members, 2, true, 44, 12),
|
||||
);
|
||||
|
||||
// ---- New compact strip (above status line) ----
|
||||
let hints = vec![
|
||||
SwarmStripHint {
|
||||
key: "alt+↑/↓".into(),
|
||||
label: "select".into(),
|
||||
},
|
||||
SwarmStripHint {
|
||||
key: "alt+o".into(),
|
||||
label: "open".into(),
|
||||
},
|
||||
SwarmStripHint {
|
||||
key: "alt+shift+p".into(),
|
||||
label: "prompt".into(),
|
||||
},
|
||||
SwarmStripHint {
|
||||
key: "esc".into(),
|
||||
label: "exit".into(),
|
||||
},
|
||||
];
|
||||
print_lines(
|
||||
"STRIP: unfocused @ width 90",
|
||||
&render_swarm_strip(
|
||||
&panel_members,
|
||||
1,
|
||||
false,
|
||||
&hints,
|
||||
Some("alt+n controls"),
|
||||
0,
|
||||
90,
|
||||
12,
|
||||
),
|
||||
);
|
||||
print_lines(
|
||||
"STRIP: focused, selected #1 @ width 90",
|
||||
&render_swarm_strip(&panel_members, 1, true, &hints, None, 3, 90, 12),
|
||||
);
|
||||
print_lines(
|
||||
"STRIP: focused narrow @ width 54",
|
||||
&render_swarm_strip(&panel_members, 0, true, &hints, None, 5, 54, 12),
|
||||
);
|
||||
|
||||
// ---- Vertical strip (default layout: one agent per row) ----
|
||||
let mut vert_members = panel_members.clone();
|
||||
for (m, icon) in vert_members.iter_mut().zip(["🦊", "🐝", "🐅", "🦉"]) {
|
||||
m.icon = Some(icon.to_string());
|
||||
m.task = Some(match m.label.as_str() {
|
||||
"researcher" => "wire the auth flow".to_string(),
|
||||
"implementer" => "audit the webhook path".to_string(),
|
||||
"reviewer" => "support/contact page".to_string(),
|
||||
_ => "misc background task".to_string(),
|
||||
});
|
||||
}
|
||||
vert_members[1].todo = Some((3, 9));
|
||||
print_lines(
|
||||
"VERTICAL: unfocused @ width 90",
|
||||
&render_swarm_strip_vertical(
|
||||
&vert_members,
|
||||
1,
|
||||
false,
|
||||
&hints,
|
||||
Some("alt+n controls"),
|
||||
0,
|
||||
90,
|
||||
4,
|
||||
12,
|
||||
),
|
||||
);
|
||||
print_lines(
|
||||
"VERTICAL: focused (accordion), selected #1 @ width 90",
|
||||
&render_swarm_strip_vertical(&vert_members, 1, true, &hints, None, 3, 90, 4, 12),
|
||||
);
|
||||
print_lines(
|
||||
"VERTICAL: 7 agents overflow @ width 80",
|
||||
&render_swarm_strip_vertical(
|
||||
&{
|
||||
let mut many = Vec::new();
|
||||
for i in 0..7 {
|
||||
let mut m = gm(&format!("agent-{i}"), None, "running", &[]);
|
||||
m.icon = Some("🐜".to_string());
|
||||
many.push(m);
|
||||
}
|
||||
many
|
||||
},
|
||||
0,
|
||||
false,
|
||||
&hints,
|
||||
Some("alt+n controls"),
|
||||
0,
|
||||
80,
|
||||
4,
|
||||
12,
|
||||
),
|
||||
);
|
||||
|
||||
// ---- Dock (vertical agent list for the info-widget margins) ----
|
||||
print_lines(
|
||||
"DOCK: 4 agents, selected #0, unfocused @ width 34 h 12",
|
||||
&render_swarm_dock(&panel_members, 0, false, Some((3, 7)), 0, 34, 12),
|
||||
);
|
||||
print_lines(
|
||||
"DOCK: 4 agents, selected #1, focused @ width 34 h 14",
|
||||
&render_swarm_dock(&panel_members, 1, true, Some((3, 7)), 2, 34, 14),
|
||||
);
|
||||
print_lines(
|
||||
"DOCK: narrow @ width 24 h 8",
|
||||
&render_swarm_dock(&panel_members, 2, false, None, 0, 24, 8),
|
||||
);
|
||||
|
||||
// ---- Compact summary (two lines: tally + plan bar) ----
|
||||
print_lines(
|
||||
"COMPACT: 4 agents, nodes 5/12 (3 running) @ width 34",
|
||||
&render_swarm_compact(&panel_members, Some((5, 3, 12)), 34, 2),
|
||||
);
|
||||
print_lines(
|
||||
"COMPACT: no plan @ width 34",
|
||||
&render_swarm_compact(&panel_members, None, 34, 2),
|
||||
);
|
||||
print_lines(
|
||||
"COMPACT: narrow @ width 20",
|
||||
&render_swarm_compact(&panel_members, Some((5, 3, 12)), 20, 2),
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Audit sweep: panic-safety and width-bound checks for the swarm gallery,
|
||||
//! panel, and strip renderers across degenerate inputs (empty members, huge
|
||||
//! member counts, tiny widths/heights, wide glyphs).
|
||||
|
||||
use jcode_tui_render::swarm_gallery::{
|
||||
GalleryMember, SwarmStripHint, render_gallery, render_swarm_dock, render_swarm_panel,
|
||||
render_swarm_strip,
|
||||
};
|
||||
use ratatui::prelude::Line;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
fn member(id: &str, status: &str, role: Option<&str>, body: &[&str]) -> GalleryMember {
|
||||
GalleryMember {
|
||||
label: id.to_string(),
|
||||
icon: None,
|
||||
status: status.to_string(),
|
||||
task: None,
|
||||
role: role.map(str::to_string),
|
||||
body: body.iter().map(|s| s.to_string()).collect(),
|
||||
sort_key: id.to_string(),
|
||||
todo: None,
|
||||
todo_items: Vec::new(),
|
||||
model: None,
|
||||
provider: None,
|
||||
auth_method: None,
|
||||
effort: None,
|
||||
elapsed_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn plain(line: &Line<'_>) -> String {
|
||||
line.spans.iter().map(|s| s.content.as_ref()).collect()
|
||||
}
|
||||
|
||||
fn hints() -> Vec<SwarmStripHint> {
|
||||
vec![
|
||||
SwarmStripHint {
|
||||
key: "alt+w".into(),
|
||||
label: "focus".into(),
|
||||
},
|
||||
SwarmStripHint {
|
||||
key: "esc".into(),
|
||||
label: "back".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn member_sets() -> Vec<Vec<GalleryMember>> {
|
||||
let wide = {
|
||||
let mut m = member(
|
||||
"🐝🦀日本語エージェント",
|
||||
"running",
|
||||
Some("coordinator"),
|
||||
&["全角テキスト🐝🐝🐝 very wide glyph body line", "· 5s ago"],
|
||||
);
|
||||
m.todo = Some((3, 8));
|
||||
m
|
||||
};
|
||||
let huge: Vec<GalleryMember> = (0..1500)
|
||||
.map(|i| {
|
||||
let mut m = member(
|
||||
&format!("agent-{i:04}"),
|
||||
match i % 6 {
|
||||
0 => "running",
|
||||
1 => "thinking",
|
||||
2 => "done",
|
||||
3 => "failed",
|
||||
4 => "blocked",
|
||||
_ => "spawned",
|
||||
},
|
||||
if i == 0 { Some("coordinator") } else { None },
|
||||
&["some output", "· 2m ago"],
|
||||
);
|
||||
m.todo = Some((i as u32 % 10, 10));
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
vec![
|
||||
vec![],
|
||||
vec![member("a", "running", None, &[])],
|
||||
vec![wide.clone()],
|
||||
vec![
|
||||
wide,
|
||||
member("b", "done", Some("mystery_role_2"), &["ok", "· 1h ago"]),
|
||||
member(
|
||||
"",
|
||||
"weird-status-xyz",
|
||||
Some("unknown-role"),
|
||||
&["", " ", "·"],
|
||||
),
|
||||
],
|
||||
huge,
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gallery_never_panics_and_stays_width_bounded() {
|
||||
for members in member_sets() {
|
||||
for width in 0..=60 {
|
||||
for max_height in 0..=20 {
|
||||
let lines = render_gallery(&members, width, max_height);
|
||||
for line in &lines {
|
||||
let w = plain(line).as_str().width();
|
||||
assert!(
|
||||
w <= width,
|
||||
"gallery width={width} h={max_height} n={} overflow {w}: {:?}",
|
||||
members.len(),
|
||||
plain(line)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A couple of large widths too.
|
||||
for width in [80usize, 200, 500] {
|
||||
let _ = render_gallery(&members, width, 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_never_panics_across_degenerate_inputs() {
|
||||
for members in member_sets() {
|
||||
for width in 0..=40 {
|
||||
for max_height in 0..=16 {
|
||||
for selected in [0usize, 1, 5, usize::MAX] {
|
||||
for focused in [false, true] {
|
||||
let _ = render_swarm_panel(&members, selected, focused, width, max_height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_lines_respect_width_bound() {
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
for members in member_sets() {
|
||||
if members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for width in 8..=60 {
|
||||
for max_height in 3..=16 {
|
||||
let lines = render_swarm_panel(&members, 0, true, width, max_height);
|
||||
for line in &lines {
|
||||
let text = plain(line);
|
||||
let w = text.as_str().width();
|
||||
if w > width {
|
||||
violations.push(format!(
|
||||
"n={} width={width} h={max_height}: {w} cols: {text:?}",
|
||||
members.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"panel emitted over-wide lines ({}):\n{}",
|
||||
violations.len(),
|
||||
violations[..violations.len().min(10)].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_never_panics_and_stays_width_bounded() {
|
||||
for members in member_sets() {
|
||||
for width in 0..=60 {
|
||||
for selected in [0usize, 3, usize::MAX] {
|
||||
for focused in [false, true] {
|
||||
for spinner in [0usize, 7, usize::MAX] {
|
||||
let lines = render_swarm_strip(
|
||||
&members,
|
||||
selected,
|
||||
focused,
|
||||
&hints(),
|
||||
Some("ctrl+t controls"),
|
||||
spinner,
|
||||
width,
|
||||
12,
|
||||
);
|
||||
for line in &lines {
|
||||
let text = plain(line);
|
||||
let w = text.as_str().width();
|
||||
assert!(
|
||||
w <= width,
|
||||
"strip width={width} n={} overflow {w}: {text:?}",
|
||||
members.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_never_panics_and_respects_width_and_height_bounds() {
|
||||
for members in member_sets() {
|
||||
for width in 0..=48 {
|
||||
for max_height in 0..=16 {
|
||||
for selected in [0usize, 3, usize::MAX] {
|
||||
for focused in [false, true] {
|
||||
for plan in [None, Some((3u32, 7u32))] {
|
||||
let lines = render_swarm_dock(
|
||||
&members,
|
||||
selected,
|
||||
focused,
|
||||
plan,
|
||||
usize::MAX,
|
||||
width,
|
||||
max_height,
|
||||
);
|
||||
assert!(
|
||||
lines.len() <= max_height.max(1),
|
||||
"dock width={width} h={max_height} n={}: {} lines",
|
||||
members.len(),
|
||||
lines.len()
|
||||
);
|
||||
for line in &lines {
|
||||
let text = plain(line);
|
||||
let w = text.as_str().width();
|
||||
assert!(
|
||||
w <= width,
|
||||
"dock width={width} n={} overflow {w}: {text:?}",
|
||||
members.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user