use crate::buffer::{Buffer, Cell};
use crate::fiber::{FiberId, FiberKind, FiberTree, Rect};
use crate::props::{GradientDirection, Overflow, TextWrap};
use crate::style::{Attrs, BorderStyle, Color, Weight};
use crate::text::{truncate_line, wrap_text};
pub(crate) fn paint(tree: &FiberTree, buf: &mut Buffer) {
if let Some(root) = tree.root {
let full = Rect {
x: 0,
y: 0,
width: buf.width(),
height: buf.height(),
};
let mut overlays = Vec::new();
paint_fiber(tree, root, buf, full, (0, 0), &mut overlays, false);
for id in overlays {
let mut inner_overlays = Vec::new();
paint_fiber(tree, id, buf, full, (0, 0), &mut inner_overlays, true);
debug_assert!(
inner_overlays.is_empty(),
"overlay nested inside overlay (fiber {id:?}) is not supported and was not painted"
);
}
}
}
fn intersect(a: Rect, b: Rect) -> Rect {
let x1 = a.x.max(b.x);
let y1 = a.y.max(b.y);
let x2 = a.x.saturating_add(a.width).min(b.x.saturating_add(b.width));
let y2 =
a.y.saturating_add(a.height)
.min(b.y.saturating_add(b.height));
Rect {
x: x1,
y: y1,
width: x2.saturating_sub(x1),
height: y2.saturating_sub(y1),
}
}
fn put(buf: &mut Buffer, clip: Rect, x: i32, y: i32, cell: Cell) {
let (cx, cy) = (clip.x as i32, clip.y as i32);
if x >= cx && y >= cy && x < cx + clip.width as i32 && y < cy + clip.height as i32 {
buf.set(x as u16, y as u16, cell);
}
}
fn onscreen_rect(ox: i32, oy: i32, w: u16, h: u16) -> Rect {
let x0 = ox.max(0);
let y0 = oy.max(0);
let x1 = (ox + w as i32).max(0);
let y1 = (oy + h as i32).max(0);
Rect {
x: x0.min(u16::MAX as i32) as u16,
y: y0.min(u16::MAX as i32) as u16,
width: (x1 - x0).min(u16::MAX as i32) as u16,
height: (y1 - y0).min(u16::MAX as i32) as u16,
}
}
#[allow(clippy::too_many_arguments)]
fn paint_fiber(
tree: &FiberTree,
id: FiberId,
buf: &mut Buffer,
clip: Rect,
offset: (i32, i32),
overlays: &mut Vec<FiberId>,
is_overlay_root: bool,
) {
let fiber = tree.get(id);
if !is_overlay_root
&& let FiberKind::View(props) = &fiber.kind
&& props.overlay.is_some()
{
overlays.push(id);
return;
}
let r = fiber.layout;
let (ox, oy) = (r.x as i32 + offset.0, r.y as i32 + offset.1);
match &fiber.kind {
FiberKind::View(props) => {
if let Some((from, to, dir)) = props.background_gradient {
for dy in 0..r.height as i32 {
for dx in 0..r.width as i32 {
let t = gradient_t(dx, dy, r.width, r.height, dir);
put(
buf,
clip,
ox + dx,
oy + dy,
Cell {
bg: Color::lerp(from, to, t),
..Cell::default()
},
);
}
}
} else if props.background != Color::Reset {
for dy in 0..r.height as i32 {
for dx in 0..r.width as i32 {
put(
buf,
clip,
ox + dx,
oy + dy,
Cell {
bg: props.background,
..Cell::default()
},
);
}
}
}
if props.border_style != BorderStyle::None && r.width >= 2 && r.height >= 2 {
draw_border(
buf,
clip,
ox,
oy,
r.width,
r.height,
props.border_style,
props.border_color,
props.background,
);
}
}
FiberKind::Text(props) => {
let fallback;
let lines: &[String] = if let Some(cached) = &fiber.wrapped {
cached
} else {
fallback = match props.wrap {
TextWrap::Wrap => wrap_text(&props.content, r.width as usize),
TextWrap::Truncate => vec![truncate_line(&props.content, r.width as usize)],
};
&fallback
};
let attrs = Attrs {
bold: props.weight == Weight::Bold,
..Attrs::default()
};
for (dy, line) in lines.iter().take(r.height as usize).enumerate() {
let line_len = props
.color_gradient
.is_some()
.then(|| line.chars().count().max(1));
for (dx, ch) in line.chars().take(r.width as usize).enumerate() {
let ch = if is_display_hazard(ch) { ' ' } else { ch };
let (x, y) = (ox + dx as i32, oy + dy as i32);
let bg = if x >= 0
&& y >= 0
&& (x as u16) < buf.width()
&& (y as u16) < buf.height()
{
buf.get(x as u16, y as u16).bg
} else {
Color::Reset
};
let fg = if let Some((from, to)) = props.color_gradient {
let t = dx as f32 / (line_len.unwrap().saturating_sub(1).max(1)) as f32;
Color::lerp(from, to, t)
} else {
props.color
};
put(buf, clip, x, y, Cell { ch, fg, bg, attrs });
}
}
}
_ => {}
}
let (child_clip, child_offset) = match &fiber.kind {
FiberKind::View(props) => {
let clip = if props.overflow != Overflow::Visible {
intersect(clip, onscreen_rect(ox, oy, r.width, r.height))
} else {
clip
};
let scroll_dy = props
.scroll
.as_ref()
.map(|s| s.offset() as i32)
.unwrap_or(0);
(clip, (offset.0, offset.1 - scroll_dy))
}
_ => (clip, offset),
};
for c in &fiber.children {
paint_fiber(tree, *c, buf, child_clip, child_offset, overlays, false);
}
}
fn gradient_t(dx: i32, dy: i32, w: u16, h: u16, dir: GradientDirection) -> f32 {
match dir {
GradientDirection::Horizontal => dx as f32 / (w.saturating_sub(1).max(1)) as f32,
GradientDirection::Vertical => dy as f32 / (h.saturating_sub(1).max(1)) as f32,
}
}
fn is_display_hazard(ch: char) -> bool {
ch.is_control()
|| matches!(
ch,
'\u{00AD}' | '\u{061C}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}' )
}
fn border_chars(style: BorderStyle) -> [char; 6] {
match style {
BorderStyle::Single => ['─', '│', '┌', '┐', '└', '┘'],
BorderStyle::Round => ['─', '│', '╭', '╮', '╰', '╯'],
BorderStyle::Double => ['═', '║', '╔', '╗', '╚', '╝'],
BorderStyle::None => [' '; 6],
}
}
#[allow(clippy::too_many_arguments)]
fn draw_border(
buf: &mut Buffer,
clip: Rect,
ox: i32,
oy: i32,
w: u16,
h: u16,
style: BorderStyle,
color: Color,
bg: Color,
) {
let [hc, vc, tl, tr, bl, br] = border_chars(style);
let (x2, y2) = (ox + w as i32 - 1, oy + h as i32 - 1);
let cell = |ch| Cell {
ch,
fg: color,
bg,
attrs: Attrs::default(),
};
for dx in 1..w as i32 - 1 {
put(buf, clip, ox + dx, oy, cell(hc));
put(buf, clip, ox + dx, y2, cell(hc));
}
for dy in 1..h as i32 - 1 {
put(buf, clip, ox, oy + dy, cell(vc));
put(buf, clip, x2, oy + dy, cell(vc));
}
put(buf, clip, ox, oy, cell(tl));
put(buf, clip, x2, oy, cell(tr));
put(buf, clip, ox, y2, cell(bl));
put(buf, clip, x2, y2, cell(br));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::Buffer;
use crate::element::Element;
use crate::fiber::FiberTree;
use crate::hooks::RuntimeHandle;
use crate::layout::compute_layout;
use crate::props::{
Dimension, FlexDirection, GradientDirection, JustifyContent, Overflow, TextProps, TextWrap,
ViewProps,
};
use crate::style::{BorderStyle, Color, Weight};
fn render_to_text(el: Element, w: u16, h: u16) -> String {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(el, &rt);
compute_layout(&mut tree, w, h);
let mut buf = Buffer::new(w, h);
paint(&tree, &mut buf);
buf.to_text()
}
#[test]
fn bordered_box_with_text() {
let out = render_to_text(
Element::view(
ViewProps {
border_style: BorderStyle::Round,
width: Dimension::Cells(8),
height: Dimension::Cells(3),
..Default::default()
},
vec![Element::text(TextProps {
content: "hi".into(),
..Default::default()
})],
),
8,
3,
);
assert_eq!(out, "╭──────╮\n│hi │\n╰──────╯");
}
#[test]
fn column_with_wrap() {
let out = render_to_text(
Element::view(
ViewProps {
flex_direction: FlexDirection::Column,
width: Dimension::Cells(5),
height: Dimension::Cells(3),
..Default::default()
},
vec![Element::text(TextProps {
content: "one two".into(),
..Default::default()
})],
),
5,
3,
);
assert_eq!(out, "one \ntwo \n ");
}
#[test]
fn text_styles_land_in_cells() {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::text(TextProps {
content: "x".into(),
color: Color::Yellow,
weight: Weight::Bold,
wrap: TextWrap::Truncate,
..Default::default()
}),
&rt,
);
compute_layout(&mut tree, 3, 1);
let mut buf = Buffer::new(3, 1);
paint(&tree, &mut buf);
let cell = buf.get(0, 0);
assert_eq!(cell.fg, Color::Yellow);
assert!(cell.attrs.bold);
}
#[test]
fn view_background_fills_cells() {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::view(
ViewProps {
background: Color::Blue,
width: Dimension::Cells(3),
height: Dimension::Cells(3),
..Default::default()
},
vec![],
),
&rt,
);
compute_layout(&mut tree, 3, 3);
let mut buf = Buffer::new(3, 3);
paint(&tree, &mut buf);
assert_eq!(buf.get(1, 1).bg, Color::Blue);
}
fn painted(content: &str, w: u16, h: u16) -> Buffer {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::text(TextProps {
content: content.into(),
..Default::default()
}),
&rt,
);
compute_layout(&mut tree, w, h);
let mut buf = Buffer::new(w, h);
paint(&tree, &mut buf);
buf
}
#[test]
fn a_bidi_override_cannot_disguise_the_text_around_it() {
let buf = painted("evil\u{202E}gnp.exe", 20, 1);
let out = buf.to_text();
assert!(
!out.contains('\u{202E}'),
"the override reached the terminal: {out:?}"
);
assert!(
out.contains("evil") && out.contains("gnp.exe"),
"the real name should still be readable: {out:?}"
);
}
#[test]
fn zero_width_characters_do_not_shift_the_columns_after_them() {
let plain = painted("abcdef", 10, 1);
let sneaky = painted("abc\u{200B}def", 10, 1);
for x in 0..3 {
assert_eq!(plain.get(x, 0).ch, sneaky.get(x, 0).ch, "column {x}");
}
assert_eq!(sneaky.get(3, 0).ch, ' ', "the zero-width char became a gap");
for (x, ch) in "def".chars().enumerate() {
assert_eq!(sneaky.get(4 + x as u16, 0).ch, ch, "column {}", 4 + x);
}
}
#[test]
fn every_known_display_hazard_is_neutralised() {
for hazard in [
'\u{0000}', '\u{001B}', '\u{007F}', '\u{009B}', '\u{00AD}', '\u{061C}', '\u{180E}', '\u{200B}', '\u{200D}', '\u{200F}', '\u{202A}', '\u{202E}', '\u{2060}', '\u{2069}', '\u{FEFF}', '\u{FFFA}',
] {
let buf = painted(&format!("a{hazard}b"), 8, 1);
assert_eq!(
buf.get(1, 0).ch,
' ',
"U+{:04X} survived painting",
hazard as u32
);
assert_eq!(buf.get(0, 0).ch, 'a');
assert_eq!(
buf.get(2, 0).ch,
'b',
"U+{:04X} shifted the line",
hazard as u32
);
}
}
#[test]
fn ordinary_text_is_left_alone() {
for ch in ['é', '中', '\u{05D0}', '→', '✓'] {
let buf = painted(&format!("a{ch}b"), 8, 1);
assert_eq!(buf.get(1, 0).ch, ch, "U+{:04X} was mangled", ch as u32);
}
}
#[test]
fn text_control_chars_are_sanitized() {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::text(TextProps {
content: "\x1b]52;c;evil\x07hi".into(),
..Default::default()
}),
&rt,
);
compute_layout(&mut tree, 20, 3);
let mut buf = Buffer::new(20, 3);
paint(&tree, &mut buf);
for y in 0..buf.height() {
for x in 0..buf.width() {
assert!(
!buf.get(x, y).ch.is_control(),
"control char leaked at ({x},{y})"
);
}
}
let out = buf.to_text();
assert!(out.contains("hi"), "visible text missing: {out:?}");
}
#[test]
fn border_color_lands_on_border_cells() {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::view(
ViewProps {
border_style: BorderStyle::Single,
border_color: Color::Red,
width: Dimension::Cells(3),
height: Dimension::Cells(3),
..Default::default()
},
vec![],
),
&rt,
);
compute_layout(&mut tree, 3, 3);
let mut buf = Buffer::new(3, 3);
paint(&tree, &mut buf);
let cell = buf.get(0, 0);
assert_eq!(cell.fg, Color::Red);
assert_eq!(cell.ch, '┌');
}
#[test]
fn justify_content_end_bottom_aligns() {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::view(
ViewProps {
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::End,
width: Dimension::Cells(3),
height: Dimension::Cells(4),
..Default::default()
},
vec![Element::text(TextProps {
content: "x".into(),
..Default::default()
})],
),
&rt,
);
compute_layout(&mut tree, 3, 4);
let mut buf = Buffer::new(3, 4);
paint(&tree, &mut buf);
assert_eq!(buf.get(0, 3).ch, 'x', "child should sit on the bottom row");
assert_eq!(buf.get(0, 0).ch, ' ', "top row should be empty");
}
#[test]
fn overflow_clip_hides_spill() {
fn render_at(overflow: Overflow) -> Buffer {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::view(
ViewProps {
overflow,
flex_direction: FlexDirection::Column,
width: Dimension::Cells(4),
height: Dimension::Cells(2),
..Default::default()
},
vec![Element::text(TextProps {
content: "abcdefghij".into(),
..Default::default()
})],
),
&rt,
);
compute_layout(&mut tree, 4, 4);
let mut buf = Buffer::new(4, 4);
paint(&tree, &mut buf);
buf
}
assert_eq!(
render_at(Overflow::Visible).get(0, 2).ch,
'i',
"spill painted"
);
let clipped = render_at(Overflow::Clip);
assert_eq!(clipped.get(0, 2).ch, ' ', "spill clipped away");
assert_eq!(clipped.get(0, 0).ch, 'a', "visible rows intact");
}
#[test]
fn scroll_offset_and_bottom_follow() {
use crate::hooks::scroll::Scroll;
let (rt, _rx) = RuntimeHandle::test_handle();
let scroll = Scroll::new(0, rt.wake.clone());
let render = |scroll: &Scroll| -> String {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
let rows: Vec<Element> = (0..5)
.map(|i| {
Element::text(TextProps {
content: format!("row{i}"),
..Default::default()
})
})
.collect();
tree.mount_root(
Element::view(
ViewProps {
overflow: Overflow::Scroll,
scroll: Some(scroll.clone()),
flex_direction: FlexDirection::Column,
width: Dimension::Cells(4),
height: Dimension::Cells(2),
..Default::default()
},
rows,
),
&rt,
);
compute_layout(&mut tree, 4, 2);
let mut buf = Buffer::new(4, 2);
paint(&tree, &mut buf);
buf.to_text()
};
assert_eq!(render(&scroll), "row3\nrow4");
assert_eq!(scroll.max_offset(), 3);
assert!(scroll.at_bottom());
scroll.to_top();
assert_eq!(render(&scroll), "row0\nrow1");
scroll.scroll_to(1);
assert_eq!(render(&scroll), "row1\nrow2");
}
#[test]
fn background_gradient_interpolates_across_the_box() {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::view(
ViewProps {
background_gradient: Some((
Color::Rgb(0, 0, 0),
Color::Rgb(255, 0, 0),
GradientDirection::Horizontal,
)),
width: Dimension::Cells(3),
height: Dimension::Cells(1),
..Default::default()
},
vec![],
),
&rt,
);
compute_layout(&mut tree, 3, 1);
let mut buf = Buffer::new(3, 1);
paint(&tree, &mut buf);
assert_eq!(buf.get(0, 0).bg, Color::Rgb(0, 0, 0));
assert_eq!(buf.get(1, 0).bg, Color::Rgb(128, 0, 0));
assert_eq!(buf.get(2, 0).bg, Color::Rgb(255, 0, 0));
}
#[test]
fn background_gradient_interpolates_top_to_bottom_for_vertical() {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::view(
ViewProps {
background_gradient: Some((
Color::Rgb(0, 0, 0),
Color::Rgb(255, 255, 255),
GradientDirection::Vertical,
)),
width: Dimension::Cells(3),
height: Dimension::Cells(10),
..Default::default()
},
vec![],
),
&rt,
);
compute_layout(&mut tree, 3, 10);
let mut buf = Buffer::new(3, 10);
paint(&tree, &mut buf);
assert_eq!(buf.get(0, 0).bg, Color::Rgb(0, 0, 0));
assert_eq!(buf.get(0, 9).bg, Color::Rgb(255, 255, 255));
assert_ne!(buf.get(0, 0).bg, buf.get(0, 9).bg);
for y in 0..10u16 {
let row_color = buf.get(0, y).bg;
assert_eq!(buf.get(1, y).bg, row_color, "row {y} col 1 mismatch");
assert_eq!(buf.get(2, y).bg, row_color, "row {y} col 2 mismatch");
}
}
#[test]
fn text_color_gradient_interpolates_across_the_line() {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::text(TextProps {
content: "abc".into(),
color_gradient: Some((Color::Rgb(0, 0, 0), Color::Rgb(0, 0, 255))),
wrap: TextWrap::Truncate,
..Default::default()
}),
&rt,
);
compute_layout(&mut tree, 3, 1);
let mut buf = Buffer::new(3, 1);
paint(&tree, &mut buf);
assert_eq!(buf.get(0, 0).fg, Color::Rgb(0, 0, 0));
assert_eq!(buf.get(1, 0).fg, Color::Rgb(0, 0, 128));
assert_eq!(buf.get(2, 0).fg, Color::Rgb(0, 0, 255));
}
#[test]
fn double_border_no_root_and_text_past_buffer() {
let mut empty = Buffer::new(3, 1);
paint(&FiberTree::new(), &mut empty);
let out = render_to_text(
Element::view(
ViewProps {
border_style: BorderStyle::Double,
width: Dimension::Cells(4),
height: Dimension::Cells(3),
..Default::default()
},
vec![],
),
4,
3,
);
assert!(out.contains('\u{2554}'), "double corner");
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::text(TextProps {
content: "abcdef".into(),
..Default::default()
}),
&rt,
);
compute_layout(&mut tree, 6, 1);
let mut small = Buffer::new(2, 1);
paint(&tree, &mut small);
assert_eq!(small.get(0, 0).ch, 'a');
}
#[test]
fn overlay_layer_paints_after_a_later_sibling_that_would_otherwise_cover_it() {
let (rt, _rx) = RuntimeHandle::test_handle();
let mut tree = FiberTree::new();
tree.mount_root(
Element::fragment(vec![
Element::view(
ViewProps {
width: Dimension::Cells(1),
height: Dimension::Cells(1),
..Default::default()
},
vec![Element::view(
ViewProps {
overlay: Some(crate::props::Anchor::Center),
width: Dimension::Cells(4),
height: Dimension::Cells(4),
background: Color::Red,
..Default::default()
},
vec![],
)],
),
Element::view(
ViewProps {
width: Dimension::Cells(10),
height: Dimension::Cells(10),
background: Color::Green,
..Default::default()
},
vec![],
),
]),
&rt,
);
compute_layout(&mut tree, 10, 10);
let mut buf = Buffer::new(10, 10);
paint(&tree, &mut buf);
assert_eq!(buf.get(4, 4).bg, Color::Red, "overlay should paint on top");
assert_eq!(
buf.get(9, 9).bg,
Color::Green,
"outside it, the later sibling still shows"
);
}
}