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,
};
#[cfg(test)]
use crate::core::registry::Overflow;
use crate::core::registry::{LayoutNode, PaneBox, PaneGeometry, SourceId};
use crate::style_spec::StyleSpec;
use crate::term::marks::LineMark;
#[cfg(test)]
use crate::term::scroll::max_offset;
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 scrolled: Option<&'a str>,
pub focused: bool,
pub zoomed: Option<&'a str>,
}
#[cfg(test)]
pub fn overflow_clip(overflow: Overflow, total: usize, rows: usize) -> usize {
match overflow {
Overflow::KeepTop => 0,
Overflow::KeepBottom => max_offset(total, rows),
}
}
pub fn scroll_badge(offset: usize, window: usize, total: usize) -> String {
let shown = total.saturating_sub(offset).min(window);
format!("lines {}-{} of {total}", offset + 1, offset + shown)
}
#[allow(clippy::too_many_arguments)]
pub fn render_pane(
output: &[String],
marks: &[LineMark],
pane: &PaneBox,
geom: PaneGeometry,
dropped: usize,
chrome: &PaneChrome<'_>,
palette: &Palette,
profile: ColorProfile,
) -> PaneBlock {
let rows = geom.inner_rows as usize;
let cols = geom.inner_cols as usize;
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(if chrome.focused {
palette.accent
} else {
palette.border
}),
bold: chrome.focused,
..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)
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub struct PaneRect {
pub row: usize,
pub col: usize,
pub rows: usize,
pub cols: usize,
}
pub fn pane_rects(
root: &LayoutNode,
sizes: &[(usize, usize)],
gap: usize,
row_gap: usize,
origin: (usize, usize),
out: &mut [PaneRect],
) -> (usize, usize) {
match root {
LayoutNode::Pane(id) => {
let (rows, cols) = sizes.get(id.0).copied().unwrap_or((0, 0));
if let Some(rect) = out.get_mut(id.0) {
*rect = PaneRect {
row: origin.0,
col: origin.1,
rows,
cols,
};
}
(rows, cols)
}
LayoutNode::Column(children) => {
let (mut rows, mut cols) = (0usize, 0usize);
for (i, child) in children.iter().enumerate() {
if i > 0 {
rows += row_gap;
}
let (h, w) =
pane_rects(child, sizes, gap, row_gap, (origin.0 + rows, origin.1), out);
rows += h;
cols = cols.max(w);
}
(rows, cols)
}
LayoutNode::Row(children) => {
let (mut rows, mut cols) = (0usize, 0usize);
for (i, child) in children.iter().enumerate() {
if i > 0 {
cols += gap;
}
let (h, w) =
pane_rects(child, sizes, gap, row_gap, (origin.0, origin.1 + cols), out);
cols += w;
rows = rows.max(h);
}
(rows, cols)
}
}
}
pub fn pane_order(root: &LayoutNode) -> Vec<SourceId> {
match root {
LayoutNode::Pane(id) => vec![*id],
LayoutNode::Row(children) | LayoutNode::Column(children) => {
children.iter().flat_map(pane_order).collect()
}
}
}
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
}
pub fn render_pane_collapsed(
pane: &PaneBox,
geom: PaneGeometry,
chrome: &PaneChrome<'_>,
palette: &Palette,
profile: ColorProfile,
) -> PaneBlock {
let cols = geom.cells as usize;
let name_style = if chrome.focused {
StyleSpec {
bold: true,
foreground: Some(palette.accent),
..StyleSpec::default()
}
} else {
StyleSpec::default()
};
let name = truncate_display(chrome.title, cols, ELLIPSIS);
let budget = cols.saturating_sub(display_width(&name) + 1);
let facts = if pane.chrome {
truncate_display(&chrome_facts(chrome), budget, ELLIPSIS)
} else {
String::new()
};
let faint = StyleSpec {
faint: true,
..StyleSpec::default()
};
let left = pad_display(
&name_style.render(&name, profile),
cols - display_width(&facts),
Align::Left,
);
PaneBlock {
lines: vec![format!("{left}{}", faint.render(&facts, profile))],
marks: vec![LineMark::default()],
}
}
fn chrome_facts(chrome: &PaneChrome<'_>) -> 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);
text
}
fn chrome_row(chrome: &PaneChrome<'_>, cols: usize, profile: ColorProfile) -> String {
let mut text = chrome_facts(chrome);
push_badge(&mut text, chrome.scrolled);
push_badge(&mut text, chrome.zoomed);
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;
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,
scrolled: None,
focused: false,
zoomed: None,
}
}
fn lines(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[allow(clippy::too_many_arguments)]
fn render_at_rest(
output: &[String],
marks: &[LineMark],
pane: &PaneBox,
geom: PaneGeometry,
chrome: &PaneChrome<'_>,
palette: &Palette,
profile: ColorProfile,
) -> PaneBlock {
let dropped = overflow_clip(pane.overflow, output.len(), geom.inner_rows as usize);
render_pane(output, marks, pane, geom, dropped, chrome, palette, profile)
}
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_at_rest(
&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_at_rest(
&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_at_rest(
&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_at_rest(
&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_at_rest(
&lines(&["only"]),
&[LineMark::default()],
&pane,
geom(&pane, 5),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines, lines(&["only ", " ", " "]));
let block = render_at_rest(
&[],
&[],
&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_at_rest(
&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_at_rest(
&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_at_rest(
&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_at_rest(
&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_at_rest(
&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_at_rest(
&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_at_rest(
&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_at_rest(
&lines(&[long]),
&[marked(20..25)],
&pane_box,
geom(&pane_box, 22),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
let right = render_at_rest(
&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_at_rest(
&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")));
}
#[test]
fn the_rect_walk_mirrors_the_stack_and_beside_offsets() {
let root = LayoutNode::Column(vec![
LayoutNode::Row(vec![
LayoutNode::Pane(SourceId(0)),
LayoutNode::Pane(SourceId(1)),
]),
LayoutNode::Row(vec![
LayoutNode::Pane(SourceId(2)),
LayoutNode::Pane(SourceId(3)),
]),
]);
let sizes = [(4, 10), (4, 20), (6, 12), (6, 8)];
let mut rects = vec![PaneRect::default(); 4];
let (rows, cols) = pane_rects(&root, &sizes, 2, 1, (0, 0), &mut rects);
assert_eq!(
rects[0],
PaneRect {
row: 0,
col: 0,
rows: 4,
cols: 10
}
);
assert_eq!(
rects[1],
PaneRect {
row: 0,
col: 12,
rows: 4,
cols: 20
}
);
assert_eq!(
rects[2],
PaneRect {
row: 5,
col: 0,
rows: 6,
cols: 12
}
);
assert_eq!(
rects[3],
PaneRect {
row: 5,
col: 14,
rows: 6,
cols: 8
}
);
assert_eq!((rows, cols), (11, 32));
}
#[test]
fn a_collapsed_size_shortens_the_column_it_sits_in() {
let root = LayoutNode::Column(vec![
LayoutNode::Pane(SourceId(0)),
LayoutNode::Pane(SourceId(1)),
]);
let mut rects = vec![PaneRect::default(); 2];
assert_eq!(
pane_rects(&root, &[(5, 30), (5, 30)], 0, 0, (0, 0), &mut rects),
(10, 30)
);
assert_eq!(rects[1].row, 5);
assert_eq!(
pane_rects(&root, &[(1, 30), (5, 30)], 0, 0, (0, 0), &mut rects),
(6, 30)
);
assert_eq!(rects[1].row, 1);
}
#[test]
fn the_walk_reports_the_dimensions_compose_panes_produces() {
let tall = pane(6, BorderPreset::None, Sides::default(), false);
let short = pane(4, BorderPreset::Normal, Sides::default(), false);
let render = |p: &PaneBox, cells: u16| {
render_at_rest(
&lines(&["x"]),
&[LineMark::default()],
p,
geom(p, cells),
&chrome(None),
&palette(),
ColorProfile::Ascii,
)
};
let blocks = vec![render(&tall, 20), render(&short, 12)];
let sizes: Vec<(usize, usize)> = blocks
.iter()
.map(|b| {
(
b.lines.len(),
b.lines.iter().map(|l| display_width(l)).max().unwrap_or(0),
)
})
.collect();
let root = LayoutNode::Row(vec![
LayoutNode::Pane(SourceId(0)),
LayoutNode::Pane(SourceId(1)),
]);
let mut rects = vec![PaneRect::default(); 2];
let (rows, cols) = pane_rects(&root, &sizes, 1, 0, (0, 0), &mut rects);
let composed = compose_panes(&root, &blocks, 1, 0);
assert_eq!(rows, composed.lines.len());
assert_eq!(
cols,
composed
.lines
.iter()
.map(|l| display_width(l))
.max()
.unwrap_or(0)
);
assert_eq!(rects[1].col, 21, "past 20 cells and the gap");
let mut shifted = vec![PaneRect::default(); 2];
pane_rects(&root, &sizes, 1, 0, (1, 0), &mut shifted);
assert_eq!(shifted[0].row, 1);
assert_eq!(shifted[1].row, 1);
}
#[test]
fn pane_order_flattens_in_declaration_order() {
let root = LayoutNode::Column(vec![
LayoutNode::Row(vec![
LayoutNode::Pane(SourceId(2)),
LayoutNode::Pane(SourceId(0)),
]),
LayoutNode::Pane(SourceId(1)),
]);
assert_eq!(
pane_order(&root),
vec![SourceId(2), SourceId(0), SourceId(1)]
);
assert!(pane_order(&LayoutNode::Row(Vec::new())).is_empty());
}
#[test]
fn a_focused_pane_wears_the_accent_border_and_no_extra_cell() {
let boxed = pane(4, BorderPreset::Normal, Sides::default(), false);
let draw = |focused: bool| {
render_at_rest(
&lines(&["x"]),
&[LineMark::default()],
&boxed,
geom(&boxed, 20),
&PaneChrome {
focused,
..chrome(None)
},
&palette(),
ColorProfile::Ansi256,
)
};
let plain = draw(false);
let focused = draw(true);
assert!(
focused.lines[0].contains("\x1b[1;38;5;212m"),
"got {:?}",
focused.lines[0]
);
assert!(!plain.lines[0].contains("\x1b[1;38;5;212m"));
assert_eq!(focused.lines.len(), plain.lines.len());
for (a, b) in focused.lines.iter().zip(&plain.lines) {
assert_eq!(display_width(a), display_width(b), "{a:?} vs {b:?}");
}
}
#[test]
fn an_unfocused_pane_renders_exactly_what_it_always_did() {
let boxed = pane(4, BorderPreset::Normal, Sides::default(), true);
let block = render_at_rest(
&lines(&["x"]),
&[LineMark::default()],
&boxed,
geom(&boxed, 20),
&chrome(None),
&palette(),
ColorProfile::Ansi256,
);
assert!(block.lines[0].contains("\x1b[38;5;240m"));
assert!(!block.lines[0].contains("\x1b[1;"));
}
#[test]
fn an_out_of_range_pane_places_nothing_and_panics_at_nothing() {
let mut rects = vec![PaneRect::default(); 1];
assert_eq!(
pane_rects(
&LayoutNode::Pane(SourceId(9)),
&[(4, 10)],
1,
1,
(0, 0),
&mut rects
),
(0, 0)
);
assert_eq!(rects[0], PaneRect::default());
}
#[test]
fn the_overflow_clip_is_the_declared_viewport() {
assert_eq!(overflow_clip(Overflow::KeepTop, 100, 3), 0);
assert_eq!(overflow_clip(Overflow::KeepBottom, 100, 3), 97);
assert_eq!(
overflow_clip(Overflow::KeepBottom, 100, 3),
crate::term::scroll::max_offset(100, 3)
);
assert_eq!(overflow_clip(Overflow::KeepBottom, 2, 3), 0);
assert_eq!(overflow_clip(Overflow::KeepTop, 0, 3), 0);
}
#[test]
fn an_offset_mid_body_renders_that_window_with_its_marks() {
let pane = pane(3, BorderPreset::None, Sides::default(), false);
let body = lines(&["L1", "L2", "L3", "L4", "L5", "L6"]);
let mut marks = vec![LineMark::default(); 6];
marks[3] = marked(0..2);
let block = render_pane(
&body,
&marks,
&pane,
geom(&pane, 5),
2,
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines, lines(&["L3 ", "L4 ", "L5 "]));
assert!(block.marks[1].changed, "L4's mark rode the offset");
assert_eq!(block.marks[1].cells, vec![0..2]);
assert!(!block.marks[0].changed && !block.marks[2].changed);
let block = render_pane(
&body,
&marks,
&pane,
geom(&pane, 5),
5,
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines, lines(&["L6 ", " ", " "]));
}
#[test]
fn the_scroll_badge_is_the_scrolled_rows_phrase() {
assert_eq!(scroll_badge(11, 22, 300), "lines 12-33 of 300");
assert!(
crate::term::scroll::scrolled_notice("since 12:07:45", "", 11, 22, 300)
.contains(&scroll_badge(11, 22, 300)),
"the pane badge and the footer row must be the same phrase"
);
assert_eq!(scroll_badge(8, 22, 10), "lines 9-10 of 10");
}
#[test]
fn a_scrolled_pane_shows_its_position_after_the_other_badges() {
let row = chrome_row(
&PaneChrome {
looping: true,
truncated: Some("1.2k lines dropped"),
scrolled: Some("lines 2-5 of 6"),
..chrome(Some("exit 3"))
},
80,
ColorProfile::Ascii,
);
assert!(
row.contains(
"every 5s · 12:04:31 · exit 3 · looping · 1.2k lines dropped · lines 2-5 of 6"
),
"got {row:?}"
);
}
#[test]
fn a_pane_at_rest_shows_no_position_at_all() {
let row = chrome_row(&chrome(None), 40, ColorProfile::Ascii);
assert!(row.ends_with("every 5s · 12:04:31"), "got {row:?}");
assert!(!row.contains("lines"), "got {row:?}");
assert_eq!(display_width(&row), 40);
}
#[test]
fn a_narrow_pane_loses_the_position_before_the_dropped_marker() {
let scrolled = PaneChrome {
cadence: "on trigger",
scrolled: Some("lines 2-5 of 6"),
..chrome(None)
};
assert_eq!(
chrome_row(&scrolled, 38, ColorProfile::Ascii),
"on trigger · 12:04:31 · lines 2-5 of 6"
);
let narrow = chrome_row(&scrolled, 37, ColorProfile::Ascii);
assert!(
narrow.starts_with("on trigger · 12:04:31"),
"the cadence and the stamp survive: {narrow:?}"
);
assert_eq!(display_width(&narrow), 37, "and the row still fits");
}
#[test]
fn a_zoomed_pane_says_so_last_in_its_chrome_row() {
let pane = pane(5, BorderPreset::Rounded, sides(0, 0, 0, 0), true);
let mut chrome = chrome(Some("exit 3"));
chrome.looping = true;
chrome.truncated = Some("2 lines dropped");
chrome.zoomed = Some("zoomed 2/4");
let block = render_pane(
&lines(&["body"]),
&[],
&pane,
geom(&pane, 90),
0,
&chrome,
&palette(),
ColorProfile::Ascii,
);
let row = block
.lines
.iter()
.find(|l| l.contains("zoomed"))
.expect("the chrome row carries the badge");
let at = row.find("zoomed").expect("badge");
assert!(
row.contains("zoomed 2/4"),
"the badge carries the cycle cursor: {row:?}"
);
for earlier in ["every 5s", "exit 3", "looping", "2 lines dropped"] {
assert!(
row.find(earlier).expect(earlier) < at,
"`zoomed` is the last badge: {row:?}"
);
}
chrome.zoomed = None;
let plain = render_pane(
&lines(&["body"]),
&[],
&pane,
geom(&pane, 90),
0,
&chrome,
&palette(),
ColorProfile::Ascii,
);
assert!(
!plain.lines.iter().any(|l| l.contains("zoomed")),
"no badge when the pane is not zoomed"
);
}
#[test]
fn a_collapsed_pane_is_exactly_one_row_of_its_declared_width() {
let boxed = pane(7, BorderPreset::Normal, sides(0, 1, 0, 1), true);
let block = render_pane_collapsed(
&boxed,
geom(&boxed, 30),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines.len(), 1);
assert_eq!(display_width(&block.lines[0]), 30, "{:?}", block.lines[0]);
assert_eq!(block.marks, vec![LineMark::default()]);
assert!(
block.lines[0].starts_with("plan"),
"got {:?}",
block.lines[0]
);
assert!(
block.lines[0].ends_with("every 5s · 12:04:31"),
"got {:?}",
block.lines[0]
);
}
#[test]
fn a_collapsed_row_carries_every_badge_the_chrome_row_would_have() {
let bare = pane(4, BorderPreset::None, Sides::default(), true);
let block = render_pane_collapsed(
&bare,
geom(&bare, 70),
&PaneChrome {
looping: true,
truncated: Some("90 lines dropped"),
..chrome(Some("exit 3"))
},
&palette(),
ColorProfile::Ascii,
);
assert!(
block.lines[0].contains("every 5s · 12:04:31 · exit 3 · looping · 90 lines dropped"),
"got {:?}",
block.lines[0]
);
}
#[test]
fn a_narrow_collapsed_row_loses_the_facts_before_the_name() {
let bare = pane(4, BorderPreset::None, Sides::default(), true);
for cells in [30u16, 12, 6, 4, 2, 1] {
let block = render_pane_collapsed(
&bare,
geom(&bare, cells),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines.len(), 1, "at {cells} cells");
assert_eq!(
display_width(&block.lines[0]),
cells as usize,
"ragged row at {cells} cells: {:?}",
block.lines[0]
);
}
let narrow = render_pane_collapsed(
&bare,
geom(&bare, 12),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(narrow.lines[0], "plan every …");
let tiny = render_pane_collapsed(
&bare,
geom(&bare, 4),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
assert_eq!(tiny.lines[0], "plan", "no room for facts, still a name");
}
#[test]
fn a_chrome_less_pane_collapses_to_its_name_alone() {
let bare = pane(4, BorderPreset::None, Sides::default(), false);
let block = render_pane_collapsed(
&bare,
geom(&bare, 20),
&PaneChrome {
looping: true,
..chrome(Some("exit 3"))
},
&palette(),
ColorProfile::Ascii,
);
assert_eq!(block.lines[0], format!("plan{}", " ".repeat(16)));
assert!(
!block.lines[0].contains("every"),
"no cadence on a chrome-less pane"
);
}
#[test]
fn a_collapsed_pane_shortens_the_column_by_the_rows_it_hides() {
let bare = pane(5, BorderPreset::None, Sides::default(), false);
let body = lines(&["r1", "r2", "r3", "r4", "r5"]);
let expanded = render_at_rest(
&body,
&vec![LineMark::default(); 5],
&bare,
geom(&bare, 10),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
let collapsed = render_pane_collapsed(
&bare,
geom(&bare, 10),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
let both = compose_panes(&column(2), &[expanded.clone(), expanded.clone()], 1, 1);
let one = compose_panes(&column(2), &[collapsed, expanded], 1, 1);
assert_eq!(
both.lines.len() - one.lines.len(),
4,
"5 declared rows, 1 rendered"
);
assert_eq!(one.marks.len(), one.lines.len());
}
#[test]
fn a_collapsed_pane_in_a_row_frees_nothing_and_leaves_blanks() {
let bare = pane(5, BorderPreset::None, Sides::default(), false);
let expanded = render_at_rest(
&lines(&["r1", "r2", "r3", "r4", "r5"]),
&vec![LineMark::default(); 5],
&bare,
geom(&bare, 10),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
let collapsed = render_pane_collapsed(
&bare,
geom(&bare, 10),
&chrome(None),
&palette(),
ColorProfile::Ascii,
);
let composed = compose_panes(&row(2), &[collapsed, expanded], 1, 0);
assert_eq!(
composed.lines.len(),
5,
"the row keeps its tallest pane's height"
);
assert!(
composed.lines[1].starts_with(&" ".repeat(11)),
"the collapsed pane's missing rows are blanks: {:?}",
composed.lines[1]
);
assert!(composed.lines[1].contains("r2"), "the sibling is untouched");
}
#[test]
fn a_focused_collapsed_row_wears_the_accent_without_spending_a_cell() {
let bare = pane(4, BorderPreset::None, Sides::default(), true);
let accent = StyleSpec {
bold: true,
foreground: Some(palette().accent),
..StyleSpec::default()
};
let expected = accent.render("plan", ColorProfile::TrueColor);
let focused = render_pane_collapsed(
&bare,
geom(&bare, 30),
&PaneChrome {
focused: true,
..chrome(None)
},
&palette(),
ColorProfile::TrueColor,
);
let plain = render_pane_collapsed(
&bare,
geom(&bare, 30),
&chrome(None),
&palette(),
ColorProfile::TrueColor,
);
assert!(
focused.lines[0].contains(&expected),
"got {:?}",
focused.lines[0]
);
assert!(!plain.lines[0].contains(&expected));
assert_eq!(display_width(&focused.lines[0]), 30);
assert_eq!(display_width(&plain.lines[0]), 30);
}
}