use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Widget},
};
use regex::Regex;
use super::{Theme, json, markdown, markup, syntax};
const CHROME: u16 = 4;
const MIN_WIDTH: u16 = 24;
const MIN_HEIGHT: u16 = 5;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Format {
#[default]
Text,
Json,
Markup(markup::Flavour),
Markdown,
}
impl Format {
pub fn label(self) -> Option<&'static str> {
match self {
Format::Text => None,
Format::Json => Some("json"),
Format::Markup(flavour) => Some(flavour.label()),
Format::Markdown => Some("markdown"),
}
}
}
pub struct Content {
pub format: Format,
pub raw: bool,
pub lines: Vec<Line<'static>>,
pub chars: usize,
empty: bool,
}
impl Content {
pub fn new(value: &str, raw: bool, theme: &Theme) -> Self {
let (format, document) = match json::reindent(value) {
Some(document) => (Format::Json, Some(document)),
None => match markup::reindent(value) {
Some((document, flavour)) => (Format::Markup(flavour), Some(document)),
None => match markdown::format(value) {
Some(document) => (Format::Markdown, Some(document)),
None => (Format::Text, None),
},
},
};
let lines = match document {
Some(document) if !raw => syntax::lines(&document, theme),
_ => plain(value),
};
Self {
format,
raw,
lines,
chars: value.chars().count(),
empty: value.is_empty(),
}
}
pub fn switchable(&self) -> bool {
self.format != Format::Text
}
}
fn plain(value: &str) -> Vec<Line<'static>> {
value
.split('\n')
.map(|line| Line::raw(line.to_string()))
.collect()
}
fn line_chars(line: &Line<'_>) -> usize {
line.spans
.iter()
.map(|span| span.content.chars().count())
.sum()
}
pub fn wrap(lines: &[Line<'static>], width: u16) -> Vec<Line<'static>> {
wrapped(lines, width).0
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Origin {
pub line: usize,
pub start: usize,
pub indent: usize,
}
pub fn wrapped(lines: &[Line<'static>], width: u16) -> (Vec<Line<'static>>, Vec<Origin>) {
let mut wrapper = Wrapper {
width: (width as usize).max(1),
indent: 0,
out: Vec::new(),
origins: Vec::new(),
current: Vec::new(),
have: 0,
source: 0,
consumed: 0,
began: 0,
padding: 0,
};
for (nth, line) in lines.iter().enumerate() {
wrapper.line(nth, line);
}
(wrapper.out, wrapper.origins)
}
struct Wrapper {
width: usize,
indent: usize,
out: Vec<Line<'static>>,
origins: Vec<Origin>,
current: Vec<Span<'static>>,
have: usize,
source: usize,
consumed: usize,
began: usize,
padding: usize,
}
impl Wrapper {
fn line(&mut self, nth: usize, line: &Line<'static>) {
self.source = nth;
self.consumed = 0;
self.began = 0;
self.padding = 0;
self.indent = leading_spaces(line).min(self.width / 2);
for span in &line.spans {
for chunk in span.content.split_inclusive(' ') {
self.chunk(chunk, span.style);
}
}
self.emit();
}
fn emit(&mut self) {
self.out.push(Line::from(std::mem::take(&mut self.current)));
self.origins.push(Origin {
line: self.source,
start: self.began,
indent: self.padding,
});
self.have = 0;
self.began = self.consumed;
self.padding = 0;
}
fn chunk(&mut self, chunk: &str, style: Style) {
let len = chunk.chars().count();
if self.have + len <= self.width {
self.push(chunk.to_string(), style);
return;
}
if self.have > self.indent {
self.wrap_here();
}
if self.have + len <= self.width {
self.push(chunk.to_string(), style);
return;
}
let chars: Vec<char> = chunk.chars().collect();
let mut at = 0;
while at < chars.len() {
if self.have >= self.width {
self.wrap_here();
}
let take = (self.width - self.have).min(chars.len() - at);
self.push(chars[at..at + take].iter().collect(), style);
at += take;
}
}
fn push(&mut self, text: String, style: Style) {
let chars = text.chars().count();
self.have += chars;
self.consumed += chars;
self.current.push(Span::styled(text, style));
}
fn wrap_here(&mut self) {
self.emit();
if self.indent > 0 {
self.have += self.indent;
self.padding = self.indent;
self.current
.push(Span::styled(" ".repeat(self.indent), Style::new()));
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Hit {
pub nth: usize,
pub line: usize,
pub start: usize,
pub end: usize,
}
fn plain_text(line: &Line<'_>) -> String {
line.spans.iter().map(|span| &*span.content).collect()
}
struct Match {
line: usize,
start: usize,
end: usize,
}
pub fn find(
source: &[Line<'static>],
wrapped: &[Line<'static>],
origins: &[Origin],
regex: &Regex,
) -> Vec<Hit> {
let mut found = Vec::new();
for (line, text) in source.iter().enumerate() {
let text = plain_text(text);
let mut chars = text.char_indices().enumerate().peekable();
let mut char_at = |byte: usize| {
while let Some(&(nth, (at, _))) = chars.peek() {
if at >= byte {
return nth;
}
chars.next();
}
text.chars().count()
};
for one in regex.find_iter(&text) {
if one.start() == one.end() {
continue;
}
found.push(Match {
line,
start: char_at(one.start()),
end: char_at(one.end()),
});
}
}
place(&found, wrapped, origins)
}
fn place(found: &[Match], wrapped: &[Line<'static>], origins: &[Origin]) -> Vec<Hit> {
let mut hits = Vec::new();
for (nth, one) in found.iter().enumerate() {
for (line, origin) in origins.iter().enumerate() {
if origin.line != one.line {
continue;
}
let held = line_chars(&wrapped[line]).saturating_sub(origin.indent);
let (from, to) = (origin.start, origin.start + held);
let (start, end) = (one.start.max(from), one.end.min(to));
if start >= end {
continue;
}
hits.push(Hit {
nth,
line,
start: origin.indent + (start - from),
end: origin.indent + (end - from),
});
}
}
hits
}
pub fn matches(hits: &[Hit]) -> usize {
hits.last().map_or(0, |hit| hit.nth + 1)
}
pub fn match_lines(hits: &[Hit]) -> Vec<usize> {
let mut lines: Vec<usize> = Vec::with_capacity(matches(hits));
for hit in hits {
if hit.nth == lines.len() {
lines.push(hit.line);
}
}
lines
}
fn highlighted(
line: &Line<'static>,
hits: &[Hit],
current: Option<usize>,
theme: &Theme,
) -> Line<'static> {
if hits.is_empty() {
return line.clone();
}
let found = Style::new().bg(theme.match_bg).fg(theme.match_fg);
let here = Style::new()
.bg(theme.cursor_bg)
.fg(theme.cursor_fg)
.add_modifier(Modifier::BOLD);
let mut spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len());
let mut at = 0;
for span in &line.spans {
let mut cuts: Vec<usize> = Vec::new();
let end = at + span.content.chars().count();
for hit in hits {
for edge in [hit.start, hit.end] {
if edge > at && edge < end {
cuts.push(edge);
}
}
}
cuts.sort_unstable();
cuts.dedup();
let chars: Vec<char> = span.content.chars().collect();
let mut from = at;
for to in cuts.into_iter().chain(std::iter::once(end)) {
let text: String = chars[from - at..to - at].iter().collect();
let hit = hits.iter().find(|h| h.start <= from && to <= h.end);
let style = match hit {
Some(hit) if current == Some(hit.nth) => span.style.patch(here),
Some(_) => span.style.patch(found),
None => span.style,
};
spans.push(Span::styled(text, style));
from = to;
}
at = end;
}
Line::from(spans)
}
fn leading_spaces(line: &Line<'_>) -> usize {
let mut count = 0;
for span in &line.spans {
for c in span.content.chars() {
if c != ' ' {
return count;
}
count += 1;
}
}
count
}
pub fn window(lines: &[Line<'static>], title: &str, area: Rect) -> Layout {
let longest = lines.iter().map(line_chars).max().unwrap_or(0);
let widest = fraction(area.width, 9, 10).clamp(MIN_WIDTH.min(area.width), area.width);
let width = (longest + CHROME as usize)
.max(title.chars().count() + 2)
.min(widest as usize)
.max(MIN_WIDTH.min(area.width) as usize) as u16;
let (lines, origins) = wrapped(lines, width.saturating_sub(CHROME));
let tallest = fraction(area.height, 4, 5).clamp(MIN_HEIGHT.min(area.height), area.height);
let height = (lines.len() + 2)
.min(tallest as usize)
.max(MIN_HEIGHT.min(area.height) as usize) as u16;
let rect = Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width,
height,
};
Layout {
rect,
lines,
origins,
}
}
pub struct Layout {
pub rect: Rect,
pub lines: Vec<Line<'static>>,
pub origins: Vec<Origin>,
}
fn fraction(n: u16, num: u32, den: u32) -> u16 {
(n as u32 * num / den) as u16
}
pub fn text_rows(window: Rect) -> usize {
window.height.saturating_sub(2) as usize
}
pub fn max_scroll(lines: usize, rows: usize) -> usize {
lines.saturating_sub(rows.max(1))
}
fn titles(name: &str, dtype: &str, content: &Content) -> [String; 3] {
let plural = if content.chars == 1 { "" } else { "s" };
let what = match (content.format.label(), content.raw) {
(Some(label), false) => label.to_string(),
(Some(label), true) => format!("{label}, raw"),
(None, _) => dtype.to_string(),
};
[
format!(" {name} — {what}, {} character{plural} ", content.chars),
format!(" {name} — {} character{plural} ", content.chars),
format!(" {name} "),
]
}
pub fn title(name: &str, dtype: &str, content: &Content) -> String {
let [widest, ..] = titles(name, dtype, content);
widest
}
fn fitting(options: &[String], width: u16) -> String {
options
.iter()
.find(|text| text.chars().count() <= width as usize)
.unwrap_or_else(|| options.last().expect("never empty"))
.clone()
}
pub struct CellWindow<'a> {
pub name: &'a str,
pub dtype: &'a str,
pub content: &'a Content,
pub scroll: usize,
pub hits: &'a [Hit],
pub current: Option<usize>,
pub pattern: Option<&'a str>,
pub theme: &'a Theme,
}
impl Widget for CellWindow<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let ladder = titles(self.name, self.dtype, self.content);
let Layout {
rect: popup, lines, ..
} = window(&self.content.lines, &ladder[0], area);
if popup.width == 0 || popup.height == 0 {
return;
}
let rows = text_rows(popup);
let scroll = self.scroll.min(max_scroll(lines.len(), rows));
let title = fitting(&ladder, popup.width.saturating_sub(2));
let switch = match (self.content.switchable(), self.content.raw) {
(false, _) => String::new(),
(true, false) => " r raw".to_string(),
(true, true) => format!(
" r {}",
self.content.format.label().unwrap_or("formatted")
),
};
let found = match (self.pattern, matches(self.hits)) {
(None, _) => String::new(),
(Some(pattern), 0) => format!(" /{pattern} none"),
(Some(pattern), total) => format!(
" /{pattern} {}/{total}",
self.current.map_or(0, |at| at + 1)
),
};
let steps = if matches(self.hits) > 1 { " n/N" } else { "" };
let footer = if lines.len() > rows {
let (first, last, total) = (scroll + 1, (scroll + rows).min(lines.len()), lines.len());
fitting(
&[
format!(
" {first}–{last} of {total}{found} j/k ^d/^u g/G /{steps}{switch} q close "
),
format!(
" {first}–{last} of {total}{found} j/k /{steps}{switch} q close "
),
format!(" {first}–{last} of {total}{found} j/k / q close "),
format!(" {first}–{last} of {total}{found} "),
format!(" {first}–{last} of {total} "),
],
popup.width.saturating_sub(2),
)
} else {
let said = match found.trim_start() {
"" => String::new(),
text => format!("{text} "),
};
fitting(
&[
format!(" {said}/{steps}{switch} q close "),
format!(" {said}/ q close "),
" / q close ".to_string(),
" q ".to_string(),
],
popup.width.saturating_sub(2),
)
};
Clear.render(popup, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(self.theme.border))
.title(Span::styled(
title,
Style::new()
.fg(self.theme.header)
.add_modifier(Modifier::BOLD),
))
.title_bottom(Span::styled(
footer,
Style::new().fg(self.theme.row_num).add_modifier(
Modifier::ITALIC,
),
));
let inner = block.inner(popup);
block.render(popup, buf);
let text = Rect {
x: inner.x + 1,
width: inner.width.saturating_sub(2),
..inner
};
if self.content.empty {
Paragraph::new(Line::from(Span::styled(
"·",
Style::new().fg(self.theme.null_fg),
)))
.render(text, buf);
return;
}
let shown: Vec<Line> = lines
.into_iter()
.enumerate()
.skip(scroll)
.take(rows)
.map(|(nth, line)| {
let on_line: Vec<Hit> = self
.hits
.iter()
.copied()
.filter(|hit| hit.line == nth)
.collect();
highlighted(&line, &on_line, self.current, self.theme)
})
.collect();
Paragraph::new(shown).render(text, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
fn content(value: &str) -> Content {
Content::new(value, false, &Theme::catppuccin_mocha())
}
fn text(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
fn render(value: &str, area: Rect) -> Vec<String> {
render_content(&content(value), area, 0)
}
fn render_content(content: &Content, area: Rect, scroll: usize) -> Vec<String> {
let theme = Theme::catppuccin_mocha();
let mut buf = Buffer::empty(area);
CellWindow {
name: "note",
dtype: "str",
content,
scroll,
hits: &[],
current: None,
pattern: None,
theme: &theme,
}
.render(area, &mut buf);
(0..area.height)
.map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
}
fn hits_in(value: &str, pattern: &str, width: u16) -> Vec<Hit> {
let content = content(value);
let (lines, origins) = wrapped(&content.lines, width);
find(
&content.lines,
&lines,
&origins,
&Regex::new(pattern).unwrap(),
)
}
#[test]
fn a_match_is_found_where_the_wrap_put_it() {
let hits = hits_in("alpha beta gamma delta", "delta", 12);
assert_eq!(hits.len(), 1);
assert!(hits[0].line > 0, "{hits:?}");
}
#[test]
fn every_match_is_found_in_reading_order() {
let hits = hits_in("one two one two one", "one", 80);
assert_eq!(hits.len(), 3);
assert!(hits.windows(2).all(|w| w[0].start < w[1].start));
}
#[test]
fn an_empty_match_is_not_a_match() {
assert!(hits_in("abc", "x*", 80).is_empty());
}
#[test]
fn offsets_are_characters_and_not_bytes() {
let hits = hits_in("café note", "note", 80);
assert_eq!(hits.len(), 1);
assert_eq!((hits[0].start, hits[0].end), (5, 9));
}
#[test]
fn a_match_broken_by_the_wrap_is_still_one_match() {
let narrow = hits_in("alpha beta gamma", "beta gamma", 12);
let wide = hits_in("alpha beta gamma", "beta gamma", 80);
assert_eq!(matches(&narrow), 1, "found across the break: {narrow:?}");
assert_eq!(matches(&wide), 1);
assert_eq!(narrow.len(), 2, "and drawn in both places");
assert_eq!(wide.len(), 1);
assert!(narrow.iter().all(|hit| hit.nth == 0));
}
#[test]
fn the_window_width_does_not_change_what_was_found() {
let value: String = (1..=60).map(|n| format!("line {n}. ")).collect();
let at = |width| matches(&hits_in(&value, "line 1[0-9]", width));
let wide = at(200);
assert_eq!(wide, 10, "line 10 through line 19");
for width in [14, 20, 33, 47, 80, 120] {
assert_eq!(at(width), wide, "at width {width}");
}
}
#[test]
fn a_hanging_indent_does_not_shift_the_highlight() {
let value = " keep alpha beta gamma delta epsilon";
let hits = hits_in(value, "epsilon", 20);
assert_eq!(matches(&hits), 1);
let content = content(value);
let (lines, _) = wrapped(&content.lines, 20);
let hit = hits[0];
let drawn: String = text(&lines)[hit.line].chars().collect();
let at: String = drawn
.chars()
.skip(hit.start)
.take(hit.end - hit.start)
.collect();
assert_eq!(at, "epsilon", "in {drawn:?}");
}
#[test]
fn a_match_is_drawn_in_the_match_colours() {
let theme = Theme::catppuccin_mocha();
let content = content("alpha beta gamma");
let area = Rect::new(0, 0, 40, 8);
let hits = hits_in("alpha beta gamma", "beta", 36);
let mut buf = Buffer::empty(area);
CellWindow {
name: "note",
dtype: "str",
content: &content,
scroll: 0,
hits: &hits,
current: Some(0),
pattern: Some("beta"),
theme: &theme,
}
.render(area, &mut buf);
let found = (0..area.height).any(|y| {
(0..area.width).any(|x| {
buf[(x, y)].symbol() == "b" && buf[(x, y)].style().bg == Some(theme.cursor_bg)
})
});
assert!(found, "the current match is drawn as the current match");
}
#[test]
fn a_highlight_keeps_the_styling_underneath_it() {
let theme = Theme::catppuccin_mocha();
let styled = Line::from(vec![Span::styled(
"deploy".to_string(),
Style::new().add_modifier(Modifier::BOLD),
)]);
let source = std::slice::from_ref(&styled);
let (lines, origins) = wrapped(source, 40);
let hits = find(source, &lines, &origins, &Regex::new("epl").unwrap());
let out = highlighted(&lines[0], &hits, Some(0), &theme);
let inside = out
.spans
.iter()
.find(|span| span.content.as_ref() == "epl")
.expect("the match is a span of its own");
assert!(inside.style.add_modifier.contains(Modifier::BOLD));
assert_eq!(inside.style.bg, Some(theme.cursor_bg));
}
#[test]
fn the_footer_says_which_match_of_how_many() {
let theme = Theme::catppuccin_mocha();
let content = content("one two one two one");
let area = Rect::new(0, 0, 60, 8);
let hits = hits_in("one two one two one", "one", 56);
let mut buf = Buffer::empty(area);
CellWindow {
name: "note",
dtype: "str",
content: &content,
scroll: 0,
hits: &hits,
current: Some(1),
pattern: Some("one"),
theme: &theme,
}
.render(area, &mut buf);
let drawn: Vec<String> = (0..area.height)
.map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect();
assert!(
drawn.iter().any(|line| line.contains("/one 2/3")),
"{drawn:#?}"
);
}
#[test]
fn a_pattern_that_matched_nothing_says_so() {
let theme = Theme::catppuccin_mocha();
let content = content("alpha beta");
let area = Rect::new(0, 0, 60, 8);
let mut buf = Buffer::empty(area);
CellWindow {
name: "note",
dtype: "str",
content: &content,
scroll: 0,
hits: &[],
current: None,
pattern: Some("zeta"),
theme: &theme,
}
.render(area, &mut buf);
let drawn: Vec<String> = (0..area.height)
.map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect();
assert!(
drawn.iter().any(|line| line.contains("/zeta none")),
"{drawn:#?}"
);
}
#[test]
fn wrapping_breaks_at_spaces() {
assert_eq!(
text(&wrap(&plain("the quick brown fox"), 10)),
["the quick ", "brown fox"],
"and not mid-word"
);
}
#[test]
fn a_word_with_nowhere_to_break_is_cut_on_the_width() {
assert_eq!(text(&wrap(&plain("aaaaaaaa"), 3)), ["aaa", "aaa", "aa"]);
assert_eq!(text(&wrap(&plain("hi aaaaaa"), 3)), ["hi ", "aaa", "aaa"]);
}
#[test]
fn wrapping_keeps_the_values_own_lines_and_indentation() {
assert_eq!(
text(&wrap(&plain("{\n \"a\": 1\n}"), 40)),
["{", " \"a\": 1", "}"]
);
assert_eq!(
text(&wrap(&plain("a\n\nb"), 40)),
["a", "", "b"],
"a blank line survives"
);
assert_eq!(
text(&wrap(&plain(""), 40)),
[""],
"and an empty value is one line"
);
}
#[test]
fn wrapping_counts_characters_not_bytes() {
assert_eq!(text(&wrap(&plain("éàü"), 2)), ["éà", "ü"]);
}
#[test]
fn a_wrapped_line_picks_its_indentation_back_up() {
let deep = plain(" aaa bbb ccc ddd");
assert_eq!(text(&wrap(&deep, 12)), [" aaa bbb ", " ccc ddd"]);
}
#[test]
fn wrapping_keeps_the_styling_of_what_it_splits() {
let line = Line::from(vec![
Span::styled("key ", Style::new().fg(Color::Red)),
Span::styled("value words here", Style::new().fg(Color::Blue)),
]);
let wrapped = wrap(&[line], 10);
assert_eq!(text(&wrapped), ["key value ", "words here"]);
assert_eq!(wrapped[0].spans[0].style.fg, Some(Color::Red));
assert_eq!(wrapped[0].spans[1].style.fg, Some(Color::Blue));
assert_eq!(wrapped[1].spans[0].style.fg, Some(Color::Blue));
}
#[test]
fn the_window_sizes_to_the_value_but_not_past_the_screen() {
let area = Rect::new(0, 0, 80, 24);
let Layout { rect: small, .. } = window(&plain("short"), " note ", area);
assert_eq!(small.width, MIN_WIDTH, "a short value still gets a window");
assert_eq!(small.height, MIN_HEIGHT);
let Layout {
rect: big, lines, ..
} = window(&plain(&"x".repeat(10_000)), " note ", area);
assert!(big.width <= 72, "nine tenths of the width at most: {big:?}");
assert!(big.height <= 19, "four fifths of the height: {big:?}");
assert!(lines.len() > text_rows(big), "so it has to scroll");
}
#[test]
fn the_window_is_never_narrower_than_what_it_has_to_say() {
let content = content("hi");
let title = title("a-long-column-name", "str", &content);
let Layout { rect: popup, .. } = window(&content.lines, &title, Rect::new(0, 0, 80, 24));
assert!(
popup.width as usize >= title.chars().count() + 2,
"{popup:?} for {title:?}"
);
}
#[test]
fn the_window_is_centred() {
let area = Rect::new(0, 0, 80, 24);
let Layout { rect: popup, .. } = window(&plain("hello"), " note ", area);
assert_eq!(popup.x, (area.width - popup.width) / 2);
assert_eq!(popup.y, (area.height - popup.height) / 2);
}
#[test]
fn a_terminal_smaller_than_the_window_is_not_overflowed() {
let area = Rect::new(0, 0, 10, 3);
let Layout { rect: popup, .. } =
window(&plain("a value that will not fit"), " note ", area);
assert!(
popup.width <= area.width && popup.height <= area.height,
"{popup:?}"
);
render("a value that will not fit", area);
}
#[test]
fn the_last_line_scrolls_to_the_bottom_and_no_further() {
assert_eq!(
max_scroll(30, 10),
20,
"line 21 at the top, 30 at the bottom"
);
assert_eq!(max_scroll(4, 10), 0, "a value that fits does not scroll");
}
#[test]
fn the_title_names_the_column_the_type_and_the_size() {
let drawn = render("hello world", Rect::new(0, 0, 60, 12));
let top = drawn.iter().find(|line| line.contains('┌')).unwrap();
assert!(top.contains("note"), "{top}");
assert!(top.contains("str"), "{top}");
assert!(top.contains("11 characters"), "{top}");
}
#[test]
fn a_narrow_window_gives_up_the_sizes_before_the_name() {
let ladder = titles("note", "str", &content("hello world"));
assert_eq!(fitting(&ladder, 60), ladder[0]);
assert_eq!(fitting(&ladder, 23), ladder[1]);
assert_eq!(fitting(&ladder, 10), ladder[2]);
assert_eq!(fitting(&ladder, 2), ladder[2], "something has to be drawn");
}
#[test]
fn scrolling_moves_the_value_and_the_footer_says_where_it_is() {
let value: String = (1..=40).map(|n| format!("line{n}\n")).collect();
let content = content(&value);
let area = Rect::new(0, 0, 60, 14);
let Layout {
rect: popup, lines, ..
} = window(&content.lines, &title("note", "str", &content), area);
let rows = text_rows(popup);
let bottom = (popup.y + popup.height - 1) as usize;
let top = render(&value, area);
assert!(top.iter().any(|l| l.contains("line1 ")), "{top:?}");
let down = render_content(&content, area, 5);
assert!(!down.iter().any(|l| l.contains("line1 ")), "{down:?}");
assert!(down.iter().any(|l| l.contains("line6")), "{down:?}");
assert!(
down[bottom].contains(&format!("6–{} of {}", 5 + rows, lines.len())),
"{}",
down[bottom]
);
let end = render_content(&content, area, 9_999);
assert!(end.iter().any(|l| l.contains("line40")), "{end:?}");
assert!(
end[bottom].contains(&format!("of {}", lines.len())),
"{}",
end[bottom]
);
}
#[test]
fn an_empty_cell_reads_as_nothing_here() {
let drawn = render("", Rect::new(0, 0, 40, 8));
assert!(drawn.iter().any(|line| line.contains('·')), "{drawn:?}");
assert!(!drawn.iter().any(|line| line.contains("null")), "{drawn:?}");
}
#[test]
fn a_json_cell_is_shown_as_a_document() {
let content = content(r#"{"id":4821,"tags":["a","b"]}"#);
assert_eq!(content.format, Format::Json);
assert_eq!(
text(&content.lines),
[
"{",
" \"id\": 4821,",
" \"tags\": [",
" \"a\",",
" \"b\"",
" ]",
"}"
]
);
assert_eq!(
content.chars, 28,
"the size is the cell's, not the formatting's"
);
}
#[test]
fn the_title_says_which_format_it_recognised_and_when_it_is_showing_bytes() {
let value = r#"{"id":4821}"#;
let theme = Theme::catppuccin_mocha();
let formatted = Content::new(value, false, &theme);
assert!(title("note", "str", &formatted).contains("json,"));
let raw = Content::new(value, true, &theme);
assert_eq!(raw.format, Format::Json, "still known for what it is");
assert_eq!(text(&raw.lines), [value], "but shown as it is written");
assert!(title("note", "str", &raw).contains("json, raw"));
}
#[test]
fn the_footer_offers_the_way_back_only_where_there_is_one() {
let area = Rect::new(0, 0, 60, 12);
let plain = render("hello world", area);
assert!(
!plain.iter().any(|line| line.contains("r raw")),
"nothing to switch to: {plain:?}"
);
let json = render(r#"{"id":4821}"#, area);
assert!(json.iter().any(|line| line.contains("r raw")), "{json:?}");
let raw = render_content(
&Content::new(r#"{"id":4821}"#, true, &Theme::catppuccin_mocha()),
area,
0,
);
assert!(raw.iter().any(|line| line.contains("r json")), "{raw:?}");
}
#[test]
fn a_markup_cell_is_laid_out_and_named_for_what_it_is() {
let html = content(r#"<div class="note"><p>Hi <b>there</b></p><br></div>"#);
assert_eq!(html.format, Format::Markup(markup::Flavour::Html));
assert_eq!(
text(&html.lines),
[
r#"<div class="note">"#,
" <p>Hi <b>there</b></p>",
" <br>",
"</div>",
]
);
assert!(title("note", "str", &html).contains("html,"));
let xml = content("<note><to>you</to></note>");
assert_eq!(xml.format, Format::Markup(markup::Flavour::Xml));
assert!(title("note", "str", &xml).contains("xml,"));
assert!(xml.switchable(), "and `r` gets back to the bytes");
}
#[test]
fn a_markdown_cell_is_marked_up_without_being_rewritten() {
let note = "## Deploy\n\n- rollback with `plv down`\n- **check** the queue";
let content = content(note);
assert_eq!(content.format, Format::Markdown);
assert!(title("note", "str", &content).contains("markdown,"));
assert_eq!(
text(&content.lines).join("\n"),
note,
"every character the cell holds is still on screen"
);
}
#[test]
fn a_heading_is_drawn_bold_and_its_hashes_are_not() {
let theme = Theme::catppuccin_mocha();
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
CellWindow {
name: "note",
dtype: "str",
content: &content("## Deploy\n- a\n- b"),
scroll: 0,
hits: &[],
current: None,
pattern: None,
theme: &theme,
}
.render(area, &mut buf);
let row = (0..area.height)
.find(|&y| {
(0..area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.contains("## Deploy")
})
.expect("the heading is on screen");
let at = |needle: char| {
(0..area.width)
.find(|&x| buf[(x, row)].symbol() == needle.to_string())
.expect("found")
};
let hash = at('#');
let word = at('D');
assert!(
buf[(word, row)]
.style()
.add_modifier
.contains(Modifier::BOLD),
"the heading's words are bold"
);
assert!(
!buf[(hash, row)]
.style()
.add_modifier
.contains(Modifier::BOLD),
"and its marker recedes rather than joining in"
);
assert_eq!(buf[(hash, row)].style().fg, Some(theme.syntax_punct));
}
#[test]
fn a_document_full_of_markers_is_still_the_document_it_is() {
let json = r##"{"note":"* not a bullet","body":"# not a heading"}"##;
assert_eq!(content(json).format, Format::Json);
let html = "<ul><li>* not a bullet</li><li># not a heading</li></ul>";
assert_eq!(
content(html).format,
Format::Markup(markup::Flavour::Xml),
"and markup is asked before markdown too"
);
}
#[test]
fn a_value_that_is_not_a_document_is_left_alone() {
let content = content("just a note, {not} json, and 3 < 4 <p>");
assert_eq!(content.format, Format::Text);
assert!(!content.switchable(), "and offers no way back");
assert_eq!(
text(&content.lines),
["just a note, {not} json, and 3 < 4 <p>"]
);
}
}