use crate::color::ColorProfile;
use crate::core::box_model::{BoxSpec, render_box};
use crate::core::measure::{
Align, Chunk, ELLIPSIS, chunks, display_width, kept_chars, pad_display, truncate_display,
};
use crate::core::registry::{LayoutNode, Overflow, PaneBox, PaneGeometry};
use crate::style_spec::StyleSpec;
use crate::term::marks::LineMark;
use crate::theme::Palette;
#[derive(Clone, PartialEq, Debug, Default)]
pub struct PaneBlock {
pub lines: Vec<String>,
pub marks: Vec<LineMark>,
}
pub struct PaneChrome<'a> {
pub title: &'a str,
pub cadence: &'a str,
pub stamp: &'a str,
pub failure: Option<&'a str>,
pub looping: bool,
pub truncated: Option<&'a str>,
}
pub fn render_pane(
output: &[String],
marks: &[LineMark],
pane: &PaneBox,
geom: PaneGeometry,
chrome: &PaneChrome<'_>,
palette: &Palette,
profile: ColorProfile,
) -> PaneBlock {
let rows = geom.inner_rows as usize;
let cols = geom.inner_cols as usize;
let dropped = match pane.overflow {
Overflow::KeepTop => 0,
Overflow::KeepBottom => output.len().saturating_sub(rows),
};
let mut content: Vec<String> = output.iter().skip(dropped).take(rows).cloned().collect();
content.resize(rows, String::new());
if pane.chrome {
content.push(chrome_row(chrome, cols, profile));
}
let spec = BoxSpec {
width: Some(cols),
align: Align::Left,
padding: pane.padding,
border: pane.border,
border_style: StyleSpec {
foreground: Some(palette.border),
..StyleSpec::default()
},
title: Some(chrome.title),
..BoxSpec::default()
};
let lines = render_box(&content, &spec, profile);
let row_shift = pane.edge_cells() as usize + pane.padding.top;
let char_shift = pane.edge_cells() as usize + pane.padding.left;
let mut shifted = vec![LineMark::default(); lines.len()];
for (i, mark) in marks.iter().enumerate().skip(dropped).take(rows) {
if !mark.changed {
continue;
}
let Some(slot) = shifted.get_mut(i - dropped + row_shift) else {
continue;
};
slot.changed = true;
let limit = output
.get(i)
.map_or(0, |line| kept_chars(line, cols, ELLIPSIS));
slot.cells = mark
.cells
.iter()
.map(|run| run.start.min(limit)..run.end.min(limit))
.filter(|run| !run.is_empty())
.map(|run| run.start + char_shift..run.end + char_shift)
.collect();
}
PaneBlock {
lines,
marks: shifted,
}
}
pub fn compose_panes(
root: &LayoutNode,
blocks: &[PaneBlock],
gap: usize,
row_gap: usize,
) -> PaneBlock {
match root {
LayoutNode::Pane(id) => blocks.get(id.0).cloned().unwrap_or_default(),
LayoutNode::Column(children) => {
let parts: Vec<PaneBlock> = children
.iter()
.map(|child| compose_panes(child, blocks, gap, row_gap))
.collect();
stack(&parts, row_gap)
}
LayoutNode::Row(children) => {
let parts: Vec<PaneBlock> = children
.iter()
.map(|child| compose_panes(child, blocks, gap, row_gap))
.collect();
beside(&parts, gap)
}
}
}
fn stack(parts: &[PaneBlock], row_gap: usize) -> PaneBlock {
let mut out = PaneBlock::default();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
for _ in 0..row_gap {
out.lines.push(String::new());
out.marks.push(LineMark::default());
}
}
for (row, line) in part.lines.iter().enumerate() {
out.lines.push(line.clone());
out.marks
.push(part.marks.get(row).cloned().unwrap_or_default());
}
}
out
}
fn beside(parts: &[PaneBlock], gap: usize) -> PaneBlock {
let widths: Vec<usize> = parts
.iter()
.map(|part| {
part.lines
.iter()
.map(|l| display_width(l))
.max()
.unwrap_or(0)
})
.collect();
let height = parts.iter().map(|part| part.lines.len()).max().unwrap_or(0);
let gap_text = " ".repeat(gap);
let mut out = PaneBlock::default();
for row in 0..height {
let segments: Vec<&str> = parts
.iter()
.map(|part| part.lines.get(row).map(String::as_str).unwrap_or(""))
.collect();
let Some(last) = segments.iter().rposition(|s| !s.is_empty()) else {
out.lines.push(String::new());
out.marks.push(LineMark::default());
continue;
};
let mut line = String::new();
let mut mark = LineMark::default();
let mut chars = 0usize;
for (i, segment) in segments[..=last].iter().enumerate() {
if i > 0 {
line.push_str(&gap_text);
chars += gap;
}
let padded = if i < last {
pad_display(segment, widths[i], Align::Left)
} else {
(*segment).to_string()
};
if let Some(source) = parts[i].marks.get(row) {
mark.changed |= source.changed;
mark.cells.extend(
source
.cells
.iter()
.map(|run| run.start + chars..run.end + chars),
);
}
chars += chunks(&padded)
.filter(|c| matches!(c, Chunk::Text(..)))
.count();
line.push_str(&padded);
}
out.lines.push(line);
out.marks.push(mark);
}
out
}
fn chrome_row(chrome: &PaneChrome<'_>, cols: usize, profile: ColorProfile) -> String {
let mut text = format!("{} · {}", chrome.cadence, chrome.stamp);
push_badge(&mut text, chrome.failure);
push_badge(&mut text, chrome.looping.then_some("looping"));
push_badge(&mut text, chrome.truncated);
let faint = StyleSpec {
faint: true,
..StyleSpec::default()
};
let text = truncate_display(&text, cols, ELLIPSIS);
pad_display(&faint.render(&text, profile), cols, Align::Right)
}
fn push_badge(text: &mut String, badge: Option<&str>) {
if let Some(badge) = badge {
text.push_str(" · ");
text.push_str(badge);
}
}
#[cfg(test)]
#[allow(clippy::single_range_in_vec_init)]
mod tests {
use super::*;
use crate::core::box_model::{BorderPreset, Sides};
use crate::core::join::{VAlign, join_horizontal, join_vertical};
use crate::core::registry::{PaneWidth, SourceId};
use crate::theme::{Appearance, AppearanceSource};
fn palette() -> Palette {
Palette::builtin(Appearance::Dark, AppearanceSource::Default)
}
fn pane(height: u16, border: BorderPreset, padding: Sides, chrome: bool) -> PaneBox {
PaneBox {
height,
width: PaneWidth::Weight(1),
overflow: Overflow::default(),
border,
padding,
title: None,
chrome,
}
}
fn geom(pane: &PaneBox, cells: u16) -> PaneGeometry {
PaneGeometry {
cells,
rows: pane.height,
inner_cols: cells - pane.frame_cols(),
inner_rows: pane.height - pane.frame_rows(),
}
}
fn chrome<'a>(failure: Option<&'a str>) -> PaneChrome<'a> {
PaneChrome {
title: "plan",
cadence: "every 5s",
stamp: "12:04:31",
failure,
looping: false,
truncated: None,
}
}
fn lines(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn sides(top: usize, right: usize, bottom: usize, left: usize) -> Sides {
Sides {
top,
right,
bottom,
left,
}
}
fn block(lines_in: &[&str], marks_in: &[LineMark]) -> PaneBlock {
PaneBlock {
lines: lines(lines_in),
marks: marks_in.to_vec(),
}
}
fn marked(cells: std::ops::Range<usize>) -> LineMark {
LineMark {
changed: true,
cells: vec![cells],
}
}
fn row(n: usize) -> LayoutNode {
LayoutNode::Row((0..n).map(|i| LayoutNode::Pane(SourceId(i))).collect())
}
fn column(n: usize) -> LayoutNode {
LayoutNode::Column((0..n).map(|i| LayoutNode::Pane(SourceId(i))).collect())
}
#[test]
fn a_column_of_rows_has_a_run_constant_height() {
let top = block(
&["aaa", "bbb", "ccc"],
&[
LineMark::default(),
LineMark::default(),
LineMark::default(),
],
);
let bottom = block(
&["ddd", "eee", "fff", "ggg"],
&[
marked(0..3),
LineMark::default(),
LineMark::default(),
LineMark::default(),
],
);
let composed = compose_panes(&column(2), &[top.clone(), bottom.clone()], 1, 1);
assert_eq!(composed.lines.len(), 3 + 1 + 4);
assert_eq!(composed.marks.len(), composed.lines.len());
assert_eq!(
composed.lines,
join_vertical(&[top.lines.clone(), bottom.lines.clone()], 1, Align::Left)
);
assert_eq!(composed.marks[3], LineMark::default());
assert_eq!(composed.marks[4], marked(0..3));
let top2 = block(&["zzz", "yyy", "xxx"], &vec![LineMark::default(); 3]);
let again = compose_panes(&column(2), &[top2, bottom], 1, 1);
assert_eq!(again.lines.len(), composed.lines.len());
}
#[test]
fn a_row_takes_the_max_height_of_its_panes() {
let short = block(&["a", "b", "c"], &vec![LineMark::default(); 3]);
let tall = block(&["1", "2", "3", "4", "5"], &vec![LineMark::default(); 5]);
let composed = compose_panes(&row(2), &[short.clone(), tall.clone()], 1, 0);
assert_eq!(composed.lines.len(), 5);
assert_eq!(composed.marks.len(), composed.lines.len());
assert_eq!(
composed.lines,
join_horizontal(&[short.lines, tall.lines], 1, VAlign::Top)
);
}
#[test]
fn marks_rebase_across_a_horizontal_join() {
let left = block(&["aaaa"], &[LineMark::default()]);
let right = block(&["bb"], &[marked(0..2)]);
let composed = compose_panes(&row(2), &[left.clone(), right.clone()], 1, 0);
assert_eq!(composed.lines, vec!["aaaa bb".to_string()]);
assert_eq!(composed.marks[0].cells, vec![5..7]);
assert!(composed.marks[0].changed);
assert_eq!(
composed.lines,
join_horizontal(&[left.lines, right.lines], 1, VAlign::Top)
);
}
#[test]
fn marks_rebase_by_chars_not_cells() {
let left = block(
&["日本語ab", "z"],
&[LineMark::default(), LineMark::default()],
);
let right = block(&["xy", "xy"], &[marked(0..2), marked(0..2)]);
assert_eq!(display_width("日本語ab"), 8);
assert_eq!("日本語ab".chars().count(), 5);
let composed = compose_panes(&row(2), &[left.clone(), right.clone()], 0, 0);
assert_eq!(composed.lines, lines(&["日本語abxy", "z xy"]));
assert_eq!(composed.marks[0].cells, vec![5..7]);
assert_eq!(composed.marks[1].cells, vec![8..10]);
assert_eq!(
composed.lines,
join_horizontal(&[left.lines, right.lines], 0, VAlign::Top)
);
for (line, mark) in composed.lines.iter().zip(&composed.marks) {
let chars: Vec<char> = line.chars().collect();
let run = &mark.cells[0];
assert_eq!(&chars[run.start..run.end], &['x', 'y'], "in {line:?}");
}
}
#[test]
fn a_ragged_tail_row_marks_nothing() {
let tall = block(
&["aaa", "bbb", " ", ""],
&[
marked(0..3),
LineMark::default(),
LineMark::default(),
LineMark::default(),
],
);
let short = block(&["zz"], &[marked(0..2)]);
let composed = compose_panes(&row(2), &[tall.clone(), short.clone()], 1, 0);
assert_eq!(composed.marks.len(), 4);
assert_eq!(composed.marks[1], LineMark::default());
assert_eq!(composed.marks[2], LineMark::default(), "whitespace tail");
assert_eq!(composed.marks[3], LineMark::default(), "empty tail");
assert!(composed.marks[0].changed);
assert_eq!(composed.marks[0].cells, vec![0..3, 4..6]);
assert_eq!(
composed.lines,
join_horizontal(&[tall.lines, short.lines], 1, VAlign::Top)
);
}
#[test]
fn an_empty_registry_composes_nothing() {
assert_eq!(
compose_panes(&LayoutNode::Column(Vec::new()), &[], 1, 1),
PaneBlock::default()
);
assert_eq!(
compose_panes(&LayoutNode::Row(Vec::new()), &[], 1, 1),
PaneBlock::default()
);
assert_eq!(
compose_panes(&LayoutNode::Pane(SourceId(9)), &[], 1, 1),
PaneBlock::default()
);
}
#[test]
fn a_pane_is_exactly_its_declared_size() {
let bare = pane(4, BorderPreset::None, Sides::default(), true);
let block = render_pane(
&lines(&["ab"]),
&[LineMark::default()],
&bare,
geom(&bare, 20),
&PaneChrome {
cadence: "once",
..chrome(None)
},
&palette(),
ColorProfile::Ascii,
);
assert_eq!(
block.lines,
lines(&[
"ab ",
" ",
" ",
" once · 12:04:31",
])
);
let boxed = pane(7, BorderPreset::Normal, sides(0, 1, 0, 1), true);
let block = render_pane(
&lines(&["line one", "line two"]),
&[LineMark::default(), LineMark::default()],
&boxed,
geom(&boxed, 30),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines.len(), 7);
for line in &block.lines {
assert_eq!(display_width(line), 30, "ragged row: {line:?}");
}
assert_eq!(block.marks.len(), block.lines.len());
assert!(
block.lines[0].contains("plan"),
"the title rides the border"
);
}
#[test]
fn overflow_keep_top_drops_the_tail() {
let pane = pane(3, BorderPreset::None, Sides::default(), false);
let block = render_pane(
&lines(&["1", "2", "3", "4", "5"]),
&vec![LineMark::default(); 5],
&pane,
geom(&pane, 5),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines, lines(&["1 ", "2 ", "3 "]));
}
#[test]
fn overflow_keep_bottom_keeps_the_tail() {
let pane = PaneBox {
overflow: Overflow::KeepBottom,
..pane(3, BorderPreset::None, Sides::default(), false)
};
let mut marks = vec![LineMark::default(); 5];
marks[4] = LineMark {
changed: true,
cells: vec![0..1],
};
let block = render_pane(
&lines(&["1", "2", "3", "4", "5"]),
&marks,
&pane,
geom(&pane, 5),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines, lines(&["3 ", "4 ", "5 "]));
assert_eq!(block.marks[2].cells, vec![0..1]);
assert!(!block.marks[0].changed && !block.marks[1].changed);
}
#[test]
fn a_short_pane_pads_with_blank_rows() {
let pane = pane(3, BorderPreset::None, Sides::default(), false);
let block = render_pane(
&lines(&["only"]),
&[LineMark::default()],
&pane,
geom(&pane, 5),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines, lines(&["only ", " ", " "]));
let block = render_pane(
&[],
&[],
&pane,
geom(&pane, 5),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines, lines(&[" ", " ", " "]));
}
#[test]
fn the_chrome_row_is_the_last_inner_row_and_right_aligned() {
let pane = pane(8, BorderPreset::Normal, sides(0, 1, 1, 1), true);
let block = render_pane(
&lines(&["x"]),
&[LineMark::default()],
&pane,
geom(&pane, 30),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines.len(), 8);
assert_eq!(block.lines[5], "│ every 5s · 12:04:31 │");
assert_eq!(block.lines[6], format!("│{}│", " ".repeat(28)));
}
#[test]
fn a_failure_badge_rides_the_chrome_row_without_changing_the_height() {
let pane = pane(7, BorderPreset::Normal, sides(0, 1, 0, 1), true);
let block = render_pane(
&lines(&["boom"]),
&[LineMark::default()],
&pane,
geom(&pane, 40),
&chrome(Some("exit 3")),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines.len(), 7, "a failure never changes the height");
assert!(
block.lines[5].contains("every 5s · 12:04:31 · exit 3"),
"got {:?}",
block.lines[5]
);
let block = render_pane(
&lines(&["boom"]),
&[LineMark::default()],
&pane,
geom(&pane, 24),
&chrome(Some("exit 3")),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines.len(), 7);
for line in &block.lines {
assert_eq!(display_width(line), 24, "ragged row: {line:?}");
}
}
#[test]
fn marks_shift_by_the_border_and_padding_rows() {
let pane = pane(8, BorderPreset::Normal, sides(1, 2, 0, 2), true);
let marks = vec![
LineMark {
changed: true,
cells: vec![0..3],
},
LineMark::default(),
];
let block = render_pane(
&lines(&["abc", "d"]),
&marks,
&pane,
geom(&pane, 30),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.marks.len(), block.lines.len());
assert!(block.marks[2].changed);
assert!(!block.marks[0].changed && !block.marks[1].changed);
assert_eq!(block.marks[2].cells, vec![3..6]);
let stripped: Vec<char> = crate::core::measure::strip_escapes(&block.lines[2])
.chars()
.collect();
assert_eq!(&stripped[3..6], &['a', 'b', 'c']);
}
#[test]
fn marks_past_the_truncation_cut_never_leave_the_pane() {
let pane = pane(4, BorderPreset::Normal, sides(0, 0, 0, 0), false);
let source = "abcdefghijklmnopqrstuvwxyz0123";
let hidden = vec![marked(20..25), LineMark::default()];
let block = render_pane(
&lines(&[source, "x"]),
&hidden,
&pane,
geom(&pane, 22),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert!(
block.marks[1].changed,
"the line DID change — the gutter must still say so"
);
assert!(
block.marks[1].cells.is_empty(),
"a change hidden in the tail paints no highlight: {:?}",
block.marks[1].cells
);
let straddling = vec![marked(15..25), LineMark::default()];
let block = render_pane(
&lines(&[source, "x"]),
&straddling,
&pane,
geom(&pane, 22),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.marks[1].cells, vec![16..21]);
let stripped: Vec<char> = crate::core::measure::strip_escapes(&block.lines[1])
.chars()
.collect();
assert_eq!(
stripped[20], '…',
"a run reaching the cut marks the ellipsis"
);
}
#[test]
fn a_wide_rune_cut_clips_marks_in_chars_not_cells() {
let pane = pane(3, BorderPreset::Normal, sides(0, 0, 0, 0), false);
let source = "日本語abcd";
let marks = vec![marked(1..5)];
let block = render_pane(
&lines(&[source]),
&marks,
&pane,
geom(&pane, 8),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.marks[1].cells, vec![2..4]);
}
#[test]
fn a_truncated_panes_highlight_never_reaches_its_neighbour() {
let pane_box = pane(3, BorderPreset::Normal, sides(0, 0, 0, 0), false);
let long = "abcdefghijklmnopqrstuvwxyz0123";
let left = render_pane(
&lines(&[long]),
&[marked(20..25)],
&pane_box,
geom(&pane_box, 22),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
let right = render_pane(
&lines(&["quiet"]),
&[LineMark::default()],
&pane_box,
geom(&pane_box, 22),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
let left_chars = crate::core::measure::strip_escapes(&left.lines[1])
.chars()
.count();
let composed = compose_panes(&row(2), &[left, right], 1, 0);
for (i, mark) in composed.marks.iter().enumerate() {
for run in &mark.cells {
assert!(
run.end <= left_chars,
"row {i}: run {run:?} leaks past the left pane ({left_chars} chars)"
);
}
}
}
#[test]
fn a_looping_pane_shows_the_badge() {
let row = chrome_row(
&PaneChrome {
looping: true,
..chrome(None)
},
40,
ColorProfile::Ascii,
);
assert!(row.contains("· looping"), "got {row:?}");
}
#[test]
fn a_pane_can_be_both_failing_and_looping() {
let row = chrome_row(
&PaneChrome {
looping: true,
..chrome(Some("exit 3"))
},
60,
ColorProfile::Ascii,
);
assert!(
row.contains("every 5s · 12:04:31 · exit 3 · looping"),
"got {row:?}"
);
}
#[test]
fn a_truncated_pane_shows_the_marker_after_the_other_badges() {
let row = chrome_row(
&PaneChrome {
looping: true,
truncated: Some("1.2k lines dropped"),
..chrome(Some("exit 3"))
},
80,
ColorProfile::Ascii,
);
assert!(
row.contains("every 5s · 12:04:31 · exit 3 · looping · 1.2k lines dropped"),
"got {row:?}"
);
}
#[test]
fn a_narrow_pane_loses_the_marker_before_it_loses_the_cadence() {
let truncated = PaneChrome {
cadence: "on trigger",
truncated: Some("90 lines dropped"),
..chrome(None)
};
assert_eq!(
chrome_row(&truncated, 40, ColorProfile::Ascii),
"on trigger · 12:04:31 · 90 lines dropped"
);
let narrow = chrome_row(&truncated, 39, ColorProfile::Ascii);
assert!(
narrow.starts_with("on trigger · 12:04:31"),
"got {narrow:?}"
);
assert!(!narrow.contains("dropped"), "got {narrow:?}");
}
#[test]
fn a_narrow_pane_loses_the_badge_rather_than_the_cadence() {
let looping = PaneChrome {
cadence: "on trigger",
looping: true,
..chrome(None)
};
assert_eq!(
chrome_row(&looping, 31, ColorProfile::Ascii),
"on trigger · 12:04:31 · looping"
);
let narrow = chrome_row(&looping, 30, ColorProfile::Ascii);
assert!(
narrow.starts_with("on trigger · 12:04:31"),
"the cadence and the stamp survive: {narrow:?}"
);
assert!(!narrow.contains("looping"), "got {narrow:?}");
assert_eq!(display_width(&narrow), 30, "and the row still fits");
}
#[test]
fn a_pane_with_no_chrome_row_renders_no_badge_and_does_not_panic() {
let bare = pane(4, BorderPreset::None, Sides::default(), false);
let block = render_pane(
&lines(&["ab"]),
&[LineMark::default()],
&bare,
geom(&bare, 20),
&PaneChrome {
looping: true,
..chrome(Some("exit 3"))
},
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines.len(), 4, "a chrome-less pane is all content");
assert!(!block.lines.iter().any(|l| l.contains("looping")));
assert!(!block.lines.iter().any(|l| l.contains("exit 3")));
}
}