use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
widgets::Widget,
};
use crate::tui::theme::{TaskStatus, Theme, VerbColor};
use crate::tui::utils::truncate_str;
use ratatui::style::Color;
const DEFAULT_PROGRESS_COLOR: Color = Color::Rgb(34, 197, 94);
const DEFAULT_FAILED_COLOR: Color = Color::Rgb(239, 68, 68);
const DEFAULT_MUTED_CONTENT_COLOR: Color = Color::Rgb(156, 163, 175);
const DEFAULT_ACTIVE_CONTENT_COLOR: Color = Color::Rgb(243, 244, 246);
const DEFAULT_PENDING_BADGE_COLOR: Color = Color::Rgb(107, 114, 128);
const DEFAULT_RUNNING_BADGE_COLOR: Color = Color::Rgb(245, 158, 11);
const DEFAULT_SUCCESS_BADGE_COLOR: Color = Color::Rgb(34, 197, 94);
const DEFAULT_PAUSED_BADGE_COLOR: Color = Color::Rgb(6, 182, 212);
const DEFAULT_FOR_EACH_COLOR: Color = Color::Rgb(139, 92, 246);
const DEFAULT_ESTIMATE_COLOR: Color = Color::Rgb(107, 114, 128);
const DEFAULT_SECONDARY_TEXT_COLOR: Color = Color::Rgb(156, 163, 175);
const SPINNER_FRAMES: &[&str] = &["◐", "◓", "◑", "◒"];
const SUCCESS_FRAMES: &[&str] = &["✓", "✔", "✓", "✔"];
const PROGRESS_EMPTY: char = '░';
const PROGRESS_FILLED: char = '▓';
const PROGRESS_PARTIAL: char = '▒';
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum NodeBoxMode {
#[default]
Minimal,
Expanded,
}
#[derive(Debug, Clone)]
pub struct NodeBoxData {
pub id: String,
pub verb: VerbColor,
pub status: TaskStatus,
pub estimate: String,
pub prompt_preview: Option<String>,
pub model: Option<String>,
pub for_each_count: Option<usize>,
pub for_each_items: Vec<String>,
}
impl NodeBoxData {
pub fn new(id: impl Into<String>, verb: VerbColor) -> Self {
Self {
id: id.into(),
verb,
status: TaskStatus::Pending,
estimate: String::new(),
prompt_preview: None,
model: None,
for_each_count: None,
for_each_items: Vec::new(),
}
}
pub fn with_status(mut self, status: TaskStatus) -> Self {
self.status = status;
self
}
pub fn with_estimate(mut self, estimate: impl Into<String>) -> Self {
self.estimate = estimate.into();
self
}
pub fn with_prompt_preview(mut self, preview: impl Into<String>) -> Self {
self.prompt_preview = Some(preview.into());
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn with_for_each_count(mut self, count: usize) -> Self {
self.for_each_count = Some(count);
self
}
pub fn with_for_each_items(mut self, items: Vec<String>) -> Self {
self.for_each_items = items;
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BorderStyle {
#[default]
Sharp,
Rounded,
}
#[derive(Debug, Clone, Copy)]
struct BorderChars {
tl: char,
tr: char,
bl: char,
br: char,
h: char,
v: char,
}
impl BorderChars {
fn for_status(status: TaskStatus, style: BorderStyle) -> Self {
match (status, style) {
(TaskStatus::Queued, BorderStyle::Rounded) => Self {
tl: '╭',
tr: '╮',
bl: '╰',
br: '╯',
h: '┄',
v: '┆',
},
(TaskStatus::Queued, BorderStyle::Sharp) => Self {
tl: '┌',
tr: '┐',
bl: '└',
br: '┘',
h: '┄',
v: '┆',
},
(TaskStatus::Pending, BorderStyle::Rounded) => Self {
tl: '╭',
tr: '╮',
bl: '╰',
br: '╯',
h: '┄',
v: '┆',
},
(TaskStatus::Pending, BorderStyle::Sharp) => Self {
tl: '┌',
tr: '┐',
bl: '└',
br: '┘',
h: '┄',
v: '┆',
},
(TaskStatus::Running, BorderStyle::Rounded) => Self {
tl: '╭',
tr: '╮',
bl: '╰',
br: '╯',
h: '━',
v: '┃',
},
(TaskStatus::Running, BorderStyle::Sharp) => Self {
tl: '┏',
tr: '┓',
bl: '┗',
br: '┛',
h: '━',
v: '┃',
},
(TaskStatus::Success, BorderStyle::Rounded) => Self {
tl: '╭',
tr: '╮',
bl: '╰',
br: '╯',
h: '═',
v: '║',
},
(TaskStatus::Success, BorderStyle::Sharp) => Self {
tl: '╔',
tr: '╗',
bl: '╚',
br: '╝',
h: '═',
v: '║',
},
(TaskStatus::Failed, BorderStyle::Rounded) => Self {
tl: '╭',
tr: '╮',
bl: '╰',
br: '╯',
h: '═',
v: '║',
},
(TaskStatus::Failed, BorderStyle::Sharp) => Self {
tl: '╔',
tr: '╗',
bl: '╚',
br: '╝',
h: '═',
v: '║',
},
(TaskStatus::Paused, BorderStyle::Rounded) => Self {
tl: '╭',
tr: '╮',
bl: '╰',
br: '╯',
h: '┈',
v: '┊',
},
(TaskStatus::Paused, BorderStyle::Sharp) => Self {
tl: '┌',
tr: '┐',
bl: '└',
br: '┘',
h: '┈',
v: '┊',
},
(TaskStatus::Skipped, BorderStyle::Rounded) => Self {
tl: '╭',
tr: '╮',
bl: '╰',
br: '╯',
h: '┄',
v: '┆',
},
(TaskStatus::Skipped, BorderStyle::Sharp) => Self {
tl: '┌',
tr: '┐',
bl: '└',
br: '┘',
h: '┄',
v: '┆',
},
}
}
}
pub struct NodeBox<'a> {
data: &'a NodeBoxData,
mode: NodeBoxMode,
focused: bool,
frame: u8,
border_style: BorderStyle,
progress: Option<u8>,
theme: Option<&'a Theme>,
}
impl<'a> NodeBox<'a> {
pub fn new(data: &'a NodeBoxData) -> Self {
Self {
data,
mode: NodeBoxMode::default(),
focused: false,
frame: 0,
border_style: BorderStyle::Rounded, progress: None,
theme: None,
}
}
pub fn with_theme(mut self, theme: &'a Theme) -> Self {
self.theme = Some(theme);
self
}
pub fn mode(mut self, mode: NodeBoxMode) -> Self {
self.mode = mode;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn frame(mut self, frame: u8) -> Self {
self.frame = frame;
self
}
pub fn with_border_style(mut self, style: BorderStyle) -> Self {
self.border_style = style;
self
}
pub fn progress(mut self, progress: Option<u8>) -> Self {
self.progress = progress.map(|p| p.min(100));
self
}
pub fn required_width(&self) -> u16 {
let icon_width = 2; let badge_width = 1;
let spacing = 3; let borders = 2;
let id_width = self.data.id.len();
let estimate_width = self.data.estimate.len();
let content_width = icon_width + id_width + estimate_width + badge_width + spacing;
let for_each_width = self.data.for_each_count.map_or(0, |count| {
format!(" x{}", count).len()
});
(content_width + for_each_width + borders)
.max(12)
.min(u16::MAX as usize) as u16
}
pub fn required_height(&self) -> u16 {
match self.mode {
NodeBoxMode::Minimal => 3, NodeBoxMode::Expanded => {
let mut height = 3; if self.data.prompt_preview.is_some() {
height += 1;
}
if self.data.model.is_some() {
height += 1;
}
height
}
}
}
fn status_badge(&self) -> &'static str {
match self.data.status {
TaskStatus::Queued => "○",
TaskStatus::Pending => "◦",
TaskStatus::Running => {
let idx = (self.frame as usize / 4) % SPINNER_FRAMES.len();
SPINNER_FRAMES[idx]
}
TaskStatus::Success => {
let idx = (self.frame as usize / 8) % SUCCESS_FRAMES.len();
SUCCESS_FRAMES[idx]
}
TaskStatus::Failed => "✗",
TaskStatus::Paused => "◐",
TaskStatus::Skipped => "⊘",
}
}
fn render_progress_bar(&self, buf: &mut Buffer, x: u16, y: u16, width: u16) {
if let Some(progress) = self.progress {
let filled = (progress as u16).saturating_mul(width) / 100;
let progress_color = self
.theme
.map(|t| t.status_success)
.unwrap_or(DEFAULT_PROGRESS_COLOR);
let style = Style::default().fg(progress_color);
for i in 0..width {
let ch = if i < filled {
PROGRESS_FILLED
} else if i == filled && progress > 0 {
PROGRESS_PARTIAL
} else {
PROGRESS_EMPTY
};
buf.set_string(x + i, y, ch.to_string(), style);
}
}
}
fn border_render_style(&self) -> Style {
let failed_color = self
.theme
.map(|t| t.status_failed)
.unwrap_or(DEFAULT_FAILED_COLOR);
let color = match self.data.status {
TaskStatus::Queued => self.data.verb.muted(),
TaskStatus::Pending => self.data.verb.muted(),
TaskStatus::Running => self.data.verb.rgb(),
TaskStatus::Success => self.data.verb.rgb(),
TaskStatus::Failed => failed_color,
TaskStatus::Paused => self.data.verb.muted(),
TaskStatus::Skipped => self.data.verb.muted(),
};
let mut style = Style::default().fg(color);
if self.focused {
style = style.add_modifier(Modifier::BOLD);
}
if self.data.status == TaskStatus::Running {
style = style.add_modifier(Modifier::BOLD);
}
style
}
fn content_style(&self) -> Style {
let muted_color = self
.theme
.map(|t| t.text_muted)
.unwrap_or(DEFAULT_MUTED_CONTENT_COLOR);
let active_color = self
.theme
.map(|t| t.text_primary)
.unwrap_or(DEFAULT_ACTIVE_CONTENT_COLOR);
let failed_color = self
.theme
.map(|t| t.status_failed)
.unwrap_or(DEFAULT_FAILED_COLOR);
let color = match self.data.status {
TaskStatus::Queued => muted_color,
TaskStatus::Pending => muted_color,
TaskStatus::Running => active_color,
TaskStatus::Success => active_color,
TaskStatus::Failed => failed_color,
TaskStatus::Paused => muted_color,
TaskStatus::Skipped => muted_color,
};
Style::default().fg(color)
}
fn badge_style(&self) -> Style {
let pending_color = self
.theme
.map(|t| t.status_pending)
.unwrap_or(DEFAULT_PENDING_BADGE_COLOR);
let running_color = self
.theme
.map(|t| t.status_running)
.unwrap_or(DEFAULT_RUNNING_BADGE_COLOR);
let success_color = self
.theme
.map(|t| t.status_success)
.unwrap_or(DEFAULT_SUCCESS_BADGE_COLOR);
let failed_color = self
.theme
.map(|t| t.status_failed)
.unwrap_or(DEFAULT_FAILED_COLOR);
let paused_color = self
.theme
.map(|t| t.status_paused)
.unwrap_or(DEFAULT_PAUSED_BADGE_COLOR);
let color = match self.data.status {
TaskStatus::Queued => pending_color,
TaskStatus::Pending => pending_color,
TaskStatus::Running => running_color,
TaskStatus::Success => success_color,
TaskStatus::Failed => failed_color,
TaskStatus::Paused => paused_color,
TaskStatus::Skipped => paused_color,
};
Style::default().fg(color)
}
fn get_render_colors(&self) -> (Color, Color, Color) {
let for_each_color = self
.theme
.map(|t| t.highlight)
.unwrap_or(DEFAULT_FOR_EACH_COLOR);
let estimate_color = self
.theme
.map(|t| t.text_muted)
.unwrap_or(DEFAULT_ESTIMATE_COLOR);
let secondary_color = self
.theme
.map(|t| t.text_secondary)
.unwrap_or(DEFAULT_SECONDARY_TEXT_COLOR);
(for_each_color, estimate_color, secondary_color)
}
}
impl Widget for NodeBox<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height < 3 || area.width < 5 {
return;
}
let (for_each_color, estimate_color, secondary_color) = self.get_render_colors();
let border_chars = BorderChars::for_status(self.data.status, self.border_style);
let border_render_style = self.border_render_style();
let content_style = self.content_style();
buf.set_string(
area.x,
area.y,
border_chars.tl.to_string(),
border_render_style,
);
for x in (area.x + 1)..(area.x + area.width - 1) {
buf.set_string(x, area.y, border_chars.h.to_string(), border_render_style);
}
buf.set_string(
area.x + area.width - 1,
area.y,
border_chars.tr.to_string(),
border_render_style,
);
let content_y = area.y + 1;
buf.set_string(
area.x,
content_y,
border_chars.v.to_string(),
border_render_style,
);
for x in (area.x + 1)..(area.x + area.width - 1) {
buf.set_string(x, content_y, " ", content_style);
}
let icon = self.data.verb.icon();
let badge = self.status_badge();
let mut x = area.x + 1;
let max_x = area.x + area.width - 2;
if x + 2 <= max_x {
buf.set_string(x, content_y, icon, content_style);
x += 2; }
if x < max_x {
x += 1;
}
let available_for_id = (max_x - x).saturating_sub(self.data.estimate.len() as u16 + 3);
let id_display = truncate_str(&self.data.id, available_for_id as usize);
if x + id_display.len() as u16 <= max_x {
buf.set_string(x, content_y, &id_display, content_style);
x += id_display.len() as u16;
}
if let Some(count) = self.data.for_each_count {
let indicator = format!(" x{}", count);
if x + indicator.len() as u16 <= max_x {
buf.set_string(
x,
content_y,
&indicator,
Style::default().fg(for_each_color),
);
x += indicator.len() as u16;
}
}
if x < max_x && !self.data.estimate.is_empty() {
x += 1;
}
if !self.data.estimate.is_empty() && x + self.data.estimate.len() as u16 + 2 <= max_x {
buf.set_string(
x,
content_y,
&self.data.estimate,
Style::default().fg(estimate_color),
);
x += self.data.estimate.len() as u16;
}
let badge_x = area.x + area.width - 2;
if badge_x > x {
buf.set_string(badge_x, content_y, badge, self.badge_style());
}
buf.set_string(
area.x + area.width - 1,
content_y,
border_chars.v.to_string(),
border_render_style,
);
if self.mode == NodeBoxMode::Expanded && area.height >= 4 {
let mut extra_y = content_y + 1;
if let Some(model) = &self.data.model {
if extra_y < area.y + area.height - 1 {
buf.set_string(
area.x,
extra_y,
border_chars.v.to_string(),
border_render_style,
);
for x in (area.x + 1)..(area.x + area.width - 1) {
buf.set_string(x, extra_y, " ", content_style);
}
let model_text = format!(" {}", model);
let truncated =
truncate_str(&model_text, (area.width as usize).saturating_sub(3));
buf.set_string(
area.x + 1,
extra_y,
&truncated,
Style::default().fg(secondary_color),
);
buf.set_string(
area.x + area.width - 1,
extra_y,
border_chars.v.to_string(),
border_render_style,
);
extra_y += 1;
}
}
if let Some(preview) = &self.data.prompt_preview {
if extra_y < area.y + area.height - 1 {
buf.set_string(
area.x,
extra_y,
border_chars.v.to_string(),
border_render_style,
);
for x in (area.x + 1)..(area.x + area.width - 1) {
buf.set_string(x, extra_y, " ", content_style);
}
let preview_text = format!(" \"{}\"", preview);
let max_chars = (area.width as usize).saturating_sub(3);
let truncated = if preview_text.chars().count() > max_chars {
let content: String = preview_text
.chars()
.take(max_chars.saturating_sub(4))
.collect();
format!("{}...\"", content)
} else {
preview_text
};
buf.set_string(
area.x + 1,
extra_y,
&truncated,
Style::default()
.fg(secondary_color)
.add_modifier(Modifier::ITALIC),
);
buf.set_string(
area.x + area.width - 1,
extra_y,
border_chars.v.to_string(),
border_render_style,
);
}
}
}
let bottom_y = area.y + area.height - 1;
buf.set_string(
area.x,
bottom_y,
border_chars.bl.to_string(),
border_render_style,
);
for x in (area.x + 1)..(area.x + area.width - 1) {
buf.set_string(x, bottom_y, border_chars.h.to_string(), border_render_style);
}
buf.set_string(
area.x + area.width - 1,
bottom_y,
border_chars.br.to_string(),
border_render_style,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_node_box_creation() {
let data = NodeBoxData::new("generate", VerbColor::Infer)
.with_status(TaskStatus::Running)
.with_estimate("~2s");
assert_eq!(data.id, "generate");
assert_eq!(data.verb, VerbColor::Infer);
assert_eq!(data.status, TaskStatus::Running);
assert_eq!(data.estimate, "~2s");
}
#[test]
fn test_node_box_data_builder_methods() {
let data = NodeBoxData::new("task1", VerbColor::Exec)
.with_status(TaskStatus::Success)
.with_estimate("1.5s")
.with_prompt_preview("Generate landing page...")
.with_model("claude-sonnet-4")
.with_for_each_count(3)
.with_for_each_items(vec!["a".into(), "b".into(), "c".into()]);
assert_eq!(data.id, "task1");
assert_eq!(data.verb, VerbColor::Exec);
assert_eq!(data.status, TaskStatus::Success);
assert_eq!(data.estimate, "1.5s");
assert_eq!(
data.prompt_preview,
Some("Generate landing page...".to_string())
);
assert_eq!(data.model, Some("claude-sonnet-4".to_string()));
assert_eq!(data.for_each_count, Some(3));
assert_eq!(data.for_each_items, vec!["a", "b", "c"]);
}
#[test]
fn test_required_dimensions() {
let data = NodeBoxData::new("task", VerbColor::Infer).with_estimate("~1s");
let widget = NodeBox::new(&data);
assert_eq!(widget.required_height(), 3);
let width = widget.required_width();
assert!(width >= 12, "Width {} should be >= 12", width);
}
#[test]
fn test_required_height_expanded() {
let data = NodeBoxData::new("task", VerbColor::Infer)
.with_prompt_preview("Test prompt")
.with_model("claude-sonnet");
let widget = NodeBox::new(&data).mode(NodeBoxMode::Expanded);
assert_eq!(widget.required_height(), 5);
}
#[test]
fn test_border_chars_by_status_sharp() {
let pending = BorderChars::for_status(TaskStatus::Pending, BorderStyle::Sharp);
assert_eq!(pending.h, '┄');
assert_eq!(pending.v, '┆');
assert_eq!(pending.tl, '┌');
let running = BorderChars::for_status(TaskStatus::Running, BorderStyle::Sharp);
assert_eq!(running.h, '━');
assert_eq!(running.v, '┃');
assert_eq!(running.tl, '┏');
let success = BorderChars::for_status(TaskStatus::Success, BorderStyle::Sharp);
assert_eq!(success.h, '═');
assert_eq!(success.v, '║');
assert_eq!(success.tl, '╔');
let failed = BorderChars::for_status(TaskStatus::Failed, BorderStyle::Sharp);
assert_eq!(failed.h, '═');
assert_eq!(failed.v, '║');
let paused = BorderChars::for_status(TaskStatus::Paused, BorderStyle::Sharp);
assert_eq!(paused.h, '┈');
assert_eq!(paused.v, '┊');
}
#[test]
fn test_border_chars_by_status_rounded() {
let pending = BorderChars::for_status(TaskStatus::Pending, BorderStyle::Rounded);
assert_eq!(pending.h, '┄');
assert_eq!(pending.v, '┆');
assert_eq!(pending.tl, '╭');
let running = BorderChars::for_status(TaskStatus::Running, BorderStyle::Rounded);
assert_eq!(running.h, '━');
assert_eq!(running.v, '┃');
assert_eq!(running.tl, '╭');
let success = BorderChars::for_status(TaskStatus::Success, BorderStyle::Rounded);
assert_eq!(success.h, '═');
assert_eq!(success.v, '║');
assert_eq!(success.tl, '╭'); }
#[test]
fn test_minimal_vs_expanded_height() {
let data = NodeBoxData::new("test", VerbColor::Agent)
.with_prompt_preview("Some prompt")
.with_model("gpt-4");
let minimal = NodeBox::new(&data).mode(NodeBoxMode::Minimal);
assert_eq!(minimal.required_height(), 3);
let expanded = NodeBox::new(&data).mode(NodeBoxMode::Expanded);
assert_eq!(expanded.required_height(), 5);
}
#[test]
fn test_status_badges() {
let data = NodeBoxData::new("t", VerbColor::Infer);
let widget = NodeBox::new(&data);
assert_eq!(widget.status_badge(), "◦");
let data = NodeBoxData::new("t", VerbColor::Infer).with_status(TaskStatus::Queued);
let widget = NodeBox::new(&data);
assert_eq!(widget.status_badge(), "○");
let data = NodeBoxData::new("t", VerbColor::Infer).with_status(TaskStatus::Running);
let widget = NodeBox::new(&data);
assert!(
SPINNER_FRAMES.contains(&widget.status_badge()),
"Running badge should be a spinner frame"
);
let data = NodeBoxData::new("t", VerbColor::Infer).with_status(TaskStatus::Success);
let widget = NodeBox::new(&data);
assert!(
SUCCESS_FRAMES.contains(&widget.status_badge()),
"Success badge should be a success frame"
);
let data = NodeBoxData::new("t", VerbColor::Infer).with_status(TaskStatus::Failed);
let widget = NodeBox::new(&data);
assert_eq!(widget.status_badge(), "✗");
let data = NodeBoxData::new("t", VerbColor::Infer).with_status(TaskStatus::Paused);
let widget = NodeBox::new(&data);
assert_eq!(widget.status_badge(), "◐");
let data = NodeBoxData::new("t", VerbColor::Infer).with_status(TaskStatus::Skipped);
let widget = NodeBox::new(&data);
assert_eq!(widget.status_badge(), "⊘");
}
#[test]
fn test_node_box_mode_default() {
let mode = NodeBoxMode::default();
assert_eq!(mode, NodeBoxMode::Minimal);
}
#[test]
fn test_node_box_with_for_each() {
let data = NodeBoxData::new("parallel", VerbColor::Fetch)
.with_for_each_count(10)
.with_for_each_items(vec!["a".into(), "b".into()]);
let widget = NodeBox::new(&data);
let width = widget.required_width();
let data_without = NodeBoxData::new("parallel", VerbColor::Fetch);
let width_without = NodeBox::new(&data_without).required_width();
assert!(
width > width_without,
"Width with for_each ({}) should be > without ({})",
width,
width_without
);
}
#[test]
fn test_node_box_rendering_does_not_panic() {
let data = NodeBoxData::new("test_task", VerbColor::Exec)
.with_status(TaskStatus::Running)
.with_estimate("~3s")
.with_prompt_preview("Test prompt preview")
.with_model("claude-sonnet-4");
let widget = NodeBox::new(&data)
.mode(NodeBoxMode::Expanded)
.focused(true);
let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 6));
widget.render(Rect::new(0, 0, 30, 6), &mut buffer);
let cell = buffer.cell((0, 0)).unwrap();
assert_eq!(cell.symbol(), "╭"); }
#[test]
fn test_node_box_rendering_sharp_borders() {
let data = NodeBoxData::new("test_task", VerbColor::Exec).with_status(TaskStatus::Running);
let widget = NodeBox::new(&data).with_border_style(BorderStyle::Sharp);
let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 6));
widget.render(Rect::new(0, 0, 30, 6), &mut buffer);
let cell = buffer.cell((0, 0)).unwrap();
assert_eq!(cell.symbol(), "┏"); }
#[test]
fn test_node_box_renders_in_minimal_area() {
let data = NodeBoxData::new("x", VerbColor::Infer);
let widget = NodeBox::new(&data);
let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
widget.render(Rect::new(0, 0, 10, 3), &mut buffer);
let cell = buffer.cell((0, 0)).unwrap();
assert!(!cell.symbol().is_empty());
}
#[test]
fn test_node_box_skips_render_if_too_small() {
let data = NodeBoxData::new("x", VerbColor::Infer);
let widget = NodeBox::new(&data);
let mut buffer = Buffer::empty(Rect::new(0, 0, 3, 2));
widget.render(Rect::new(0, 0, 3, 2), &mut buffer);
let cell = buffer.cell((0, 0)).unwrap();
assert_eq!(cell.symbol(), " ");
}
}