use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::ops::Range;
use twig::{Alignment, ContainerOrigin, DirectiveForm, Editor, FlatNode, Kind, QueryMatch};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::style::{
Align, Baseline, FaceId, FaceRef, FaceTable, FontSize, LineHeight, MarkColor, Role, Style,
TextColor, Token,
};
#[derive(Clone)]
pub struct Glyph {
pub ch: char,
pub style: Style,
pub src: usize,
pub stop: bool,
}
#[derive(Clone)]
pub struct VRow {
pub glyphs: Vec<Glyph>,
pub end_src: usize,
pub decoration: bool,
pub code: bool,
pub code_lang: Option<String>,
pub directive: bool,
pub directive_label: Option<String>,
pub media: Option<MediaMark>,
pub task: Option<bool>,
pub leaf_directive: Option<DirectiveMark>,
pub heading: Option<u8>,
pub align: Option<Align>,
pub line_height: Option<LineHeight>,
pub boundary: Option<Boundary>,
pub mark_ends: Vec<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Boundary {
pub above: BlockClass,
pub below: BlockClass,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BlockClass {
Paragraph,
Heading,
List,
ListItem,
Quote,
Code,
Table,
Media,
Directive,
Rule,
Footnote,
Other,
}
impl BlockClass {
pub fn from_node_kind(kind: &Kind) -> BlockClass {
match kind {
Kind::Para => BlockClass::Paragraph,
Kind::Heading => BlockClass::Heading,
Kind::BulletList | Kind::OrderedList | Kind::TaskList => BlockClass::List,
Kind::ListItem | Kind::TaskListItem => BlockClass::ListItem,
Kind::BlockQuote => BlockClass::Quote,
Kind::CodeBlock => BlockClass::Code,
Kind::Table => BlockClass::Table,
Kind::Image => BlockClass::Media,
Kind::Container => BlockClass::Directive,
Kind::ThematicBreak => BlockClass::Rule,
Kind::Footnote => BlockClass::Footnote,
_ => BlockClass::Other,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectiveMark {
pub name: String,
pub attrs: Vec<(String, Option<String>)>,
pub label: String,
pub rows: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MediaKind {
Image,
Video,
Audio,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MediaStop {
Before,
After,
}
impl MediaKind {
fn sigil(self) -> char {
match self {
MediaKind::Image => '🖼',
MediaKind::Video => '🎬',
MediaKind::Audio => '🔊',
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MediaMark {
pub kind: MediaKind,
pub destination: String,
pub sources: Vec<MediaSource>,
pub alt: String,
pub poster: String,
pub rows: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MediaSource {
pub media: String,
pub srcset: String,
pub mime: String,
}
#[derive(Clone, Default)]
pub struct VisualMap {
pub rows: Vec<VRow>,
pub content_start: usize,
stops: Vec<usize>,
mark_ends: Vec<usize>,
pub tables: Vec<TableInfo>,
pub code_blocks: Vec<CodeBlockInfo>,
pub media: Vec<MediaInfo>,
pub directives: Vec<DirectiveInfo>,
faces: FaceTable,
}
impl VisualMap {
pub fn num_rows(&self) -> usize {
self.rows.len()
}
pub fn face_name(&self, id: FaceId) -> Option<&str> {
self.faces.name(id)
}
pub fn faces(&self) -> &FaceTable {
&self.faces
}
pub fn row_width(&self, row: usize) -> usize {
self.rows.get(row).map_or(0, |r| r.width())
}
pub fn pos_of_offset(&self, off: usize) -> (usize, usize) {
let mut best: Option<(usize, usize, usize)> = None; for (r, row) in self.rows.iter().enumerate() {
if row.decoration {
continue;
}
let cand = row
.glyphs
.iter()
.enumerate()
.find(|(_, g)| g.stop && g.src >= off)
.map(|(i, g)| (g.src, r, row.col_of_glyph(i)))
.or_else(|| (row.end_src >= off).then_some((row.end_src, r, row.width())));
if let Some(c) = cand {
if best.is_none_or(|b| c.0 <= b.0) {
best = Some(c);
}
}
if let (Some(b), Some(first)) = (best, row.glyphs.iter().find(|g| g.stop))
&& first.src > b.0
{
break;
}
}
match best {
Some((_, r, c)) => (r, c),
None => {
let r = self.last_stop_row();
(r, self.row_width(r))
}
}
}
pub fn row_range_for(&self, range: Range<usize>) -> (usize, usize) {
if self.rows.is_empty() {
return (0, 0);
}
let first = self.pos_of_offset(range.start).0;
let mut last = first;
for (r, row) in self.rows.iter().enumerate().skip(first) {
if row.decoration {
continue;
}
let open = row
.glyphs
.iter()
.find(|g| g.stop)
.map_or(row.end_src, |g| g.src);
if open >= range.end {
break;
}
last = r;
}
(first, last)
}
pub fn task_box_at(&self, row: usize, col: usize) -> Option<usize> {
let r = self.rows.get(row)?;
self.task_box_at_glyph(row, r.glyph_at_col(col)?)
}
pub fn task_box_at_glyph(&self, row: usize, glyph: usize) -> Option<usize> {
let r = self.rows.get(row)?;
r.task?;
let g = r.glyphs.get(glyph)?;
(g.style.role == Role::ListMarker).then_some(g.src)
}
pub fn offset_of_pos(&self, row: usize, col: usize) -> usize {
let Some(r) = self.rows.get(row) else {
return self.stops.last().copied().unwrap_or(0);
};
match r.glyph_at_col(col).and_then(|i| r.glyphs.get(i)) {
Some(g) if !g.stop => self.nearest_stop(g.src),
Some(g) => g.src,
None if r.decoration => self.nearest_stop(r.end_src),
None => r.end_src,
}
}
pub fn block_media_stop(&self, off: usize) -> Option<(MediaStop, Range<usize>)> {
for m in &self.media {
let Some(row) = self.rows.get(m.rows_span.start) else {
continue;
};
let Some(start) = row
.glyphs
.iter()
.find(|g| g.style.role == Role::Image)
.map(|g| g.src)
else {
continue;
};
if off == start {
return Some((MediaStop::Before, start..row.end_src));
}
if off == row.end_src {
return Some((MediaStop::After, start..row.end_src));
}
}
None
}
pub fn table_end_stop(&self, off: usize) -> bool {
self.tables.iter().any(|t| t.end_src == off)
}
pub fn snap_to_stop(&self, off: usize) -> usize {
self.nearest_stop(off)
}
fn nearest_stop(&self, off: usize) -> usize {
let before = Self::last_at_or_before(&self.stops, off)
.max(Self::last_at_or_before(&self.mark_ends, off));
let after = match (
Self::first_at_or_after(&self.stops, off),
Self::first_at_or_after(&self.mark_ends, off),
) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
match (before, after) {
(Some(b), Some(a)) if off - b <= a - off => b,
(_, Some(a)) => a,
(Some(b), None) => b,
(None, None) => off,
}
}
pub fn snap_to_glyph_stop(&self, off: usize) -> usize {
if self.mark_ends.binary_search(&off).is_ok()
&& let Some(next) = Self::first_at_or_after(&self.stops, off)
{
return next;
}
let before = Self::last_at_or_before(&self.stops, off);
let after = Self::first_at_or_after(&self.stops, off);
match (before, after) {
(Some(b), Some(a)) if off - b <= a - off => b,
(_, Some(a)) => a,
(Some(b), None) => b,
(None, None) => off,
}
}
fn last_at_or_before(sorted: &[usize], off: usize) -> Option<usize> {
let i = sorted.partition_point(|&s| s <= off);
i.checked_sub(1).map(|i| sorted[i])
}
fn first_at_or_after(sorted: &[usize], off: usize) -> Option<usize> {
let i = sorted.partition_point(|&s| s < off);
sorted.get(i).copied()
}
pub fn caret_stop_after(&self, off: usize) -> Option<usize> {
match (
self.stop_after(off),
Self::first_at_or_after(&self.mark_ends, off + 1),
) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
}
}
pub fn caret_stop_before(&self, off: usize) -> Option<usize> {
self.stop_before(off).max(
off.checked_sub(1)
.and_then(|o| Self::last_at_or_before(&self.mark_ends, o)),
)
}
pub fn row_is_navigable(&self, row: usize) -> bool {
self.rows.get(row).is_some_and(|r| !r.decoration)
}
pub fn row_start(&self, row: usize) -> Option<usize> {
let r = self.rows.get(row).filter(|r| !r.decoration)?;
Some(
r.glyphs
.iter()
.find(|g| g.stop)
.map_or(r.end_src, |g| g.src),
)
}
fn last_stop_row(&self) -> usize {
(0..self.rows.len())
.rev()
.find(|&r| self.row_is_navigable(r))
.unwrap_or(0)
}
pub fn navigable_above(&self, row: usize) -> Option<usize> {
(0..row.min(self.rows.len()))
.rev()
.find(|&r| self.row_is_navigable(r))
}
pub fn navigable_below(&self, row: usize) -> Option<usize> {
((row + 1)..self.rows.len()).find(|&r| self.row_is_navigable(r))
}
pub fn stop_before(&self, off: usize) -> Option<usize> {
let i = self.stops.partition_point(|&s| s < off);
i.checked_sub(1).map(|i| self.stops[i])
}
pub fn stop_after(&self, off: usize) -> Option<usize> {
let i = self.stops.partition_point(|&s| s <= off);
self.stops.get(i).copied()
}
pub fn stop_at_or_after(&self, off: usize) -> Option<usize> {
let i = self.stops.partition_point(|&s| s < off);
self.stops.get(i).copied()
}
pub fn stop_at_or_before(&self, off: usize) -> Option<usize> {
let i = self.stops.partition_point(|&s| s <= off);
i.checked_sub(1).map(|i| self.stops[i])
}
pub fn is_stop(&self, off: usize) -> bool {
self.stops.binary_search(&off).is_ok() || self.mark_ends.binary_search(&off).is_ok()
}
pub fn visible_text(&self, from: usize, to: usize) -> String {
self.visible_items(from, to)
.into_iter()
.map(|(_, ch)| ch.unwrap_or('\n'))
.collect()
}
pub fn visible_utf16_len(&self, from: usize, to: usize) -> usize {
self.visible_items(from, to)
.into_iter()
.map(|(_, ch)| ch.map_or(1, char::len_utf16))
.sum()
}
pub fn offset_at_visible_utf16(&self, to: usize, index: usize) -> Option<usize> {
let mut seen = 0usize;
for (src, ch) in self.visible_items(0, to) {
let len = ch.map_or(1, char::len_utf16);
if index < seen + len {
return Some(src);
}
seen += len;
}
None
}
fn visible_items(&self, from: usize, to: usize) -> Vec<(usize, Option<char>)> {
let from = self.snap_to_glyph_stop(from);
let lo = self.stops.partition_point(|&s| s < from);
let last = self.stops.len().saturating_sub(1);
let hi = self.stops.partition_point(|&s| s < to).min(last).max(lo);
let stops = &self.stops[lo..hi];
let mut glyphs: Vec<(usize, char)> = self
.rows
.iter()
.filter(|r| !r.decoration)
.flat_map(|r| r.glyphs.iter())
.filter(|g| g.stop && g.src >= from && g.src < to)
.map(|g| (g.src, g.ch))
.collect();
glyphs.sort_by_key(|&(src, _)| src);
glyphs.dedup_by_key(|&mut (src, _)| src);
let mut cell_ends: Vec<usize> = self
.tables
.iter()
.flat_map(|t| t.grid.iter())
.flat_map(|r| r.cells.iter())
.map(|c| c.end)
.filter(|&e| e >= from && e < to)
.collect();
cell_ends.sort_unstable();
cell_ends.dedup();
let mut gi = 0;
stops
.iter()
.map(|&s| {
while gi < glyphs.len() && glyphs[gi].0 < s {
gi += 1;
}
let ch = match glyphs.get(gi) {
Some(&(src, ch)) if src == s && cell_ends.binary_search(&s).is_err() => {
Some(ch)
}
_ => None,
};
(s, ch)
})
.collect()
}
}
fn collect_stops(rows: &[VRow]) -> Vec<usize> {
let mut stops: Vec<usize> = rows
.iter()
.filter(|r| !r.decoration)
.flat_map(|r| {
r.glyphs
.iter()
.filter(|g| g.stop)
.map(|g| g.src)
.chain(std::iter::once(r.end_src))
})
.collect();
stops.sort_unstable();
stops.dedup();
stops
}
fn collect_mark_ends(rows: &[VRow]) -> Vec<usize> {
let mut ends: Vec<usize> = rows
.iter()
.filter(|r| !r.decoration)
.flat_map(|r| r.mark_ends.iter().copied())
.collect();
ends.sort_unstable();
ends.dedup();
ends
}
fn code_block_spans(rows: &[VRow]) -> Vec<CodeBlockInfo> {
let mut blocks = Vec::new();
let mut start: Option<usize> = None;
for (i, row) in rows.iter().enumerate() {
match (row.code, start) {
(true, None) => start = Some(i),
(false, Some(s)) => {
blocks.push(CodeBlockInfo {
rows_span: s..i,
lang: rows[s].code_lang.clone(),
});
start = None;
}
_ => {}
}
}
if let Some(s) = start {
blocks.push(CodeBlockInfo {
rows_span: s..rows.len(),
lang: rows[s].code_lang.clone(),
});
}
blocks
}
fn attr_of(node: &FlatNode, key: &str) -> Option<String> {
node.attrs
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| v.clone())
}
fn media_spans(rows: &[VRow]) -> Vec<MediaInfo> {
rows.iter()
.enumerate()
.filter_map(|(i, row)| {
row.media.as_ref().map(|m| MediaInfo {
rows_span: i..i + m.rows.max(1),
kind: m.kind,
destination: m.destination.clone(),
sources: m.sources.clone(),
alt: m.alt.clone(),
poster: m.poster.clone(),
})
})
.collect()
}
fn label_media_boundaries(rows: &mut [VRow]) {
let spans: Vec<Range<usize>> = rows
.iter()
.enumerate()
.filter_map(|(i, row)| row.media.as_ref().map(|m| i..i + m.rows.max(1)))
.collect();
fn in_gap(row: &VRow) -> bool {
row.boundary.is_some() || (!row.decoration && row.glyphs.is_empty())
}
for span in spans {
for i in (0..span.start).rev() {
if !in_gap(&rows[i]) {
break;
}
if let Some(b) = rows[i].boundary.as_mut() {
b.below = BlockClass::Media;
}
}
for row in rows.iter_mut().skip(span.end) {
if !in_gap(row) {
break;
}
if let Some(b) = row.boundary.as_mut() {
b.above = BlockClass::Media;
}
}
}
}
fn directive_spans(rows: &[VRow]) -> Vec<DirectiveInfo> {
rows.iter()
.enumerate()
.filter_map(|(i, row)| {
row.leaf_directive.as_ref().map(|m| DirectiveInfo {
rows_span: i..i + m.rows.max(1),
name: m.name.clone(),
attrs: m.attrs.clone(),
label: m.label.clone(),
})
})
.collect()
}
pub fn code_info_span(source: &str, block_start: usize) -> Option<Range<usize>> {
let rest = source.get(block_start..)?;
let line_len = rest.find('\n').unwrap_or(rest.len());
let line = &rest[..line_len];
let indent = line.len() - line.trim_start().len();
if indent > 3 {
return None;
}
let fence = line[indent..].chars().next()?;
if fence != '`' && fence != '~' {
return None; }
let fence_len = line[indent..].chars().take_while(|&c| c == fence).count();
let info_start = block_start + indent + fence_len;
Some(info_start..block_start + line_len)
}
pub fn code_language(source: &str, block_start: usize) -> Option<String> {
let span = code_info_span(source, block_start)?;
let text = source.get(span)?.trim();
(!text.is_empty()).then(|| text.to_string())
}
const UNWRAPPED_RULE_WIDTH: usize = 40;
pub fn build(
nodes: &[FlatNode],
source: &str,
wrap: Option<usize>,
preserve_soft: bool,
media_rows: &HashMap<String, usize>,
reveal: Option<Range<usize>>,
) -> VisualMap {
let Some(doc) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
return VisualMap::default();
};
let top = top_level(nodes, doc);
let mut b = Builder {
nodes,
source,
wrap: wrap.map(|w| w.max(8)),
rows: Vec::new(),
tables: Vec::new(),
last_off: 0,
stepped_over: 0,
media_rows,
break_glyph: Cell::new(' '),
preserve_soft,
reveal: reveal.clone(),
pending_mark_ends: RefCell::new(Vec::new()),
presentation: Presentation::default(),
faces: RefCell::new(FaceTable::default()),
};
let last_drawn = b.top_blocks(&top);
let hidden_end = hidden_prefix_end(source, metadata_end_of(nodes, doc));
b.emit_trailing_blank_lines(last_drawn.unwrap_or(BlockClass::Paragraph), hidden_end);
let content_start = top.first().map_or(hidden_end, |&i| nodes[i].span.start);
let stops = collect_stops(&b.rows);
let mark_ends = collect_mark_ends(&b.rows);
label_media_boundaries(&mut b.rows);
let code_blocks = code_block_spans(&b.rows);
let media = media_spans(&b.rows);
let directives = directive_spans(&b.rows);
VisualMap {
rows: b.rows,
content_start,
stops,
mark_ends,
tables: b.tables,
code_blocks,
media,
directives,
faces: b.faces.into_inner(),
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_cached(
top: &[QueryMatch],
source: &str,
wrap: Option<usize>,
preserve_soft: bool,
media_rows: &HashMap<String, usize>,
reveal: Option<Range<usize>>,
cache: &mut BlockCache,
mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
) -> VisualMap {
let wrap = wrap.map(|w| w.max(8));
if cache.wrap != Some(wrap) {
cache.entries.clear();
cache.wrap = Some(wrap);
}
cache.generation = cache.generation.wrapping_add(1);
let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
let mut b = Builder {
nodes: &[],
source,
wrap,
rows: Vec::new(),
tables: Vec::new(),
last_off: 0,
stepped_over: 0,
media_rows,
break_glyph: Cell::new(' '),
preserve_soft,
reveal: reveal.clone(),
pending_mark_ends: RefCell::new(Vec::new()),
presentation: Presentation::default(),
faces: RefCell::new(FaceTable::default()),
};
let mut layout_blocks: Vec<BlockLayout> = Vec::with_capacity(blocks.len());
let mut faces = FaceTable::default();
let mut all_shift_safe = true;
let mut above: Option<BlockClass> = None;
for block in &blocks {
let start = block.span.start;
let before_sep = b.rows.len();
if let Some(above) = above {
b.emit_separators_before(
start,
&[],
true,
Boundary {
above,
below: BlockClass::from_node_kind(&block.kind),
},
);
}
let after_sep = b.rows.len();
let bytes = block_bytes(source, &block.span);
let hash = block_hash(bytes);
let rkey = reveal_key(&reveal, &block.span);
if let Some(hit) = cache.reuse(hash, bytes, &rkey) {
faces.merge(&hit.faces);
let delta = start as isize - hit.built_start as isize;
for row in &hit.rows {
b.rows.push(shift_row(row, delta));
}
b.last_off = (hit.last_off as isize + delta) as usize;
if hit.stepped_over > 0 {
let stepped = (hit.stepped_over as isize + delta) as usize;
b.stepped_over = b.stepped_over.max(stepped);
}
} else {
let subtree = fetch_subtree(block.node_id);
if !subtree.is_empty() {
let mut sub = Builder {
nodes: &subtree,
source,
wrap,
rows: Vec::new(),
tables: Vec::new(),
last_off: 0,
stepped_over: 0,
media_rows,
break_glyph: Cell::new(' '),
preserve_soft,
reveal: reveal.clone(),
pending_mark_ends: RefCell::new(Vec::new()),
presentation: Presentation::default(),
faces: RefCell::new(FaceTable::default()),
};
sub.block(0, &[], &[]);
let last_off = if sub.rows.is_empty() {
block.span.end
} else {
sub.last_off
};
let stepped_over = sub.stepped_over;
b.stepped_over = b.stepped_over.max(stepped_over);
let block_faces = sub.faces.into_inner();
faces.merge(&block_faces);
if sub.tables.is_empty() {
if rows_within(&sub.rows, &block.span) {
cache.store(
hash,
bytes,
start,
sub.rows.clone(),
last_off,
stepped_over,
rkey,
block_faces,
);
}
b.rows.extend(sub.rows);
} else {
let base = b.rows.len();
for t in &mut sub.tables {
t.rows_span = (t.rows_span.start + base)..(t.rows_span.end + base);
}
b.rows.extend(sub.rows);
b.tables.extend(sub.tables);
}
b.last_off = last_off;
}
}
let content_rows = b.rows.len() - after_sep;
let sep_rows = if content_rows == 0 {
b.rows.truncate(before_sep);
b.last_off = b.last_off.max(block.span.end);
b.stepped_over = b.stepped_over.max(block.span.end);
0
} else {
above = Some(BlockClass::from_node_kind(&block.kind));
all_shift_safe &= rows_within(&b.rows[after_sep..], &block.span);
after_sep - before_sep
};
layout_blocks.push(BlockLayout {
span: block.span.clone(),
kind: block.kind.clone(),
sep_rows,
content_rows,
});
}
let before_trailing = b.rows.len();
let hidden_end = hidden_prefix_end(
source,
top.iter()
.filter(|m| m.kind == Kind::Metadata)
.map(|m| m.span.end)
.next_back(),
);
b.emit_trailing_blank_lines(above.unwrap_or(BlockClass::Paragraph), hidden_end);
let trailing_rows = b.rows.len() - before_trailing;
let g = cache.generation;
cache.entries.retain(|_, bucket| {
bucket.retain(|e| e.generation == g);
!bucket.is_empty()
});
cache.layout = Layout {
blocks: layout_blocks,
trailing_rows,
built_len: source.len(),
has_tables: !b.tables.is_empty(),
all_shift_safe,
reveal: reveal.clone(),
};
let content_start = blocks.first().map_or(hidden_end, |m| m.span.start);
let stops = collect_stops(&b.rows);
let mark_ends = collect_mark_ends(&b.rows);
label_media_boundaries(&mut b.rows);
let code_blocks = code_block_spans(&b.rows);
let media = media_spans(&b.rows);
let directives = directive_spans(&b.rows);
VisualMap {
rows: b.rows,
content_start,
stops,
mark_ends,
tables: b.tables,
code_blocks,
media,
directives,
faces,
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_spliced(
prev: VisualMap,
source: &str,
wrap: Option<usize>,
preserve_soft: bool,
top: &[QueryMatch],
dirty: Range<usize>,
media_rows: &HashMap<String, usize>,
reveal: Option<Range<usize>>,
cache: &mut BlockCache,
mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
) -> Option<VisualMap> {
let wrap = wrap.map(|w| w.max(8));
if cache.wrap != Some(wrap) {
return None;
}
if cache.layout.reveal != reveal {
return None;
}
let prev_layout = std::mem::take(&mut cache.layout);
if prev_layout.built_len == 0 || prev_layout.has_tables || !prev_layout.all_shift_safe {
return None;
}
let described_rows = prev_layout
.blocks
.iter()
.map(|pl| pl.sep_rows + pl.content_rows)
.sum::<usize>()
+ prev_layout.trailing_rows;
if described_rows != prev.rows.len() {
return None;
}
let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
if blocks.is_empty() || blocks.len() != prev_layout.blocks.len() {
return None;
}
let delta = source.len() as isize - prev_layout.built_len as isize;
let k = blocks
.iter()
.position(|m| m.span.start <= dirty.start && dirty.end <= m.span.end)?;
for (i, (m, pl)) in blocks.iter().zip(&prev_layout.blocks).enumerate() {
if m.kind != pl.kind {
return None;
}
if i == k {
continue;
}
let want = if i < k {
pl.span.clone()
} else {
(pl.span.start as isize + delta) as usize..(pl.span.end as isize + delta) as usize
};
if m.span != want {
return None;
}
}
let pk_start = prev_layout.blocks[k].span.start;
let pk_end = prev_layout.blocks[k].span.end;
let pk_sep = prev_layout.blocks[k].sep_rows;
let pk_content = prev_layout.blocks[k].content_rows;
if blocks[k].span.start != pk_start || blocks[k].span.end != (pk_end as isize + delta) as usize
{
return None;
}
let subtree = fetch_subtree(blocks[k].node_id);
if subtree.is_empty() {
return None;
}
let mut sub = Builder {
nodes: &subtree,
source,
wrap,
rows: Vec::new(),
tables: Vec::new(),
last_off: 0,
stepped_over: 0,
media_rows,
break_glyph: Cell::new(' '),
preserve_soft,
reveal: reveal.clone(),
pending_mark_ends: RefCell::new(Vec::new()),
presentation: Presentation::default(),
faces: RefCell::new(FaceTable::default()),
};
sub.block(0, &[], &[]);
if !sub.tables.is_empty() || !rows_within(&sub.rows, &blocks[k].span) {
return None;
}
let mut faces = prev.faces;
faces.merge(&sub.faces.into_inner());
let new_content = sub.rows;
let new_content_len = new_content.len();
let new_stops = collect_stops(&new_content);
let new_mark_ends = collect_mark_ends(&new_content);
let content_start_row: usize = prev_layout.blocks[..k]
.iter()
.map(|pl| pl.sep_rows + pl.content_rows)
.sum::<usize>()
+ pk_sep;
let content_end_row = content_start_row + pk_content;
let mut rows = prev.rows;
let mut suffix = rows.split_off(content_end_row);
rows.truncate(content_start_row);
for row in &mut suffix {
shift_row_in_place(row, delta);
}
rows.reserve(new_content_len + suffix.len());
rows.extend(new_content);
rows.extend(suffix);
let p1 = prev.stops.partition_point(|&s| s < pk_start);
let p2 = prev.stops.partition_point(|&s| s <= pk_end);
let mut stops = Vec::with_capacity(p1 + new_stops.len() + (prev.stops.len() - p2));
stops.extend_from_slice(&prev.stops[..p1]);
stops.extend(new_stops);
for &s in &prev.stops[p2..] {
stops.push((s as isize + delta) as usize);
}
let m1 = prev.mark_ends.partition_point(|&s| s < pk_start);
let m2 = prev.mark_ends.partition_point(|&s| s <= pk_end);
let mut mark_ends = Vec::with_capacity(m1 + new_mark_ends.len() + (prev.mark_ends.len() - m2));
mark_ends.extend_from_slice(&prev.mark_ends[..m1]);
mark_ends.extend(new_mark_ends);
for &s in &prev.mark_ends[m2..] {
mark_ends.push((s as isize + delta) as usize);
}
let mut new_blocks = prev_layout.blocks;
for (pl, m) in new_blocks.iter_mut().zip(&blocks) {
pl.span = m.span.clone();
}
new_blocks[k].content_rows = new_content_len;
cache.layout = Layout {
blocks: new_blocks,
trailing_rows: prev_layout.trailing_rows,
built_len: source.len(),
has_tables: false,
all_shift_safe: true,
reveal,
};
label_media_boundaries(&mut rows);
let code_blocks = code_block_spans(&rows);
let media = media_spans(&rows);
let directives = directive_spans(&rows);
Some(VisualMap {
rows,
content_start: blocks[0].span.start,
stops,
mark_ends,
tables: Vec::new(),
code_blocks,
media,
directives,
faces,
})
}
#[derive(Default)]
pub struct BlockCache {
wrap: Option<Option<usize>>,
generation: u64,
entries: HashMap<u64, Vec<CachedBlock>>,
layout: Layout,
}
#[derive(Default)]
struct Layout {
blocks: Vec<BlockLayout>,
trailing_rows: usize,
built_len: usize,
has_tables: bool,
all_shift_safe: bool,
reveal: Option<Range<usize>>,
}
struct BlockLayout {
span: Range<usize>,
kind: Kind,
sep_rows: usize,
content_rows: usize,
}
struct CachedBlock {
bytes: Box<[u8]>,
built_start: usize,
rows: Vec<VRow>,
last_off: usize,
stepped_over: usize,
reveal: Option<Range<usize>>,
faces: FaceTable,
generation: u64,
}
fn reveal_key(reveal: &Option<Range<usize>>, span: &Range<usize>) -> Option<Range<usize>> {
let r = reveal.as_ref()?;
(span.start <= r.end && r.start <= span.end).then(|| {
let start = r.start.max(span.start) - span.start;
let end = r.end.min(span.end) - span.start;
start..end
})
}
impl BlockCache {
fn reuse(
&mut self,
hash: u64,
bytes: &[u8],
reveal: &Option<Range<usize>>,
) -> Option<&CachedBlock> {
let g = self.generation;
let bucket = self.entries.get_mut(&hash)?;
let e = bucket
.iter_mut()
.find(|e| &*e.bytes == bytes && &e.reveal == reveal)?;
e.generation = g;
Some(&*e)
}
#[allow(clippy::too_many_arguments)]
fn store(
&mut self,
hash: u64,
bytes: &[u8],
built_start: usize,
rows: Vec<VRow>,
last_off: usize,
stepped_over: usize,
reveal: Option<Range<usize>>,
faces: FaceTable,
) {
let g = self.generation;
let bucket = self.entries.entry(hash).or_default();
if let Some(e) = bucket
.iter_mut()
.find(|e| &*e.bytes == bytes && e.reveal == reveal)
{
e.built_start = built_start;
e.rows = rows;
e.last_off = last_off;
e.stepped_over = stepped_over;
e.faces = faces;
e.generation = g;
} else {
bucket.push(CachedBlock {
bytes: bytes.into(),
built_start,
rows,
last_off,
stepped_over,
reveal,
faces,
generation: g,
});
}
}
}
fn block_bytes<'a>(source: &'a str, span: &Range<usize>) -> &'a [u8] {
let bytes = source.as_bytes();
let start = span.start.min(bytes.len());
&bytes[start..span.end.clamp(start, bytes.len())]
}
fn block_hash(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &x in bytes {
h ^= x as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn shift_row(row: &VRow, delta: isize) -> VRow {
let shift = |off: usize| (off as isize + delta) as usize;
VRow {
glyphs: row
.glyphs
.iter()
.map(|g| Glyph {
ch: g.ch,
style: g.style,
src: shift(g.src),
stop: g.stop,
})
.collect(),
end_src: shift(row.end_src),
decoration: row.decoration,
code: row.code,
code_lang: row.code_lang.clone(),
directive: row.directive,
directive_label: row.directive_label.clone(),
media: row.media.clone(),
task: row.task,
leaf_directive: row.leaf_directive.clone(),
heading: row.heading,
align: row.align,
line_height: row.line_height,
boundary: row.boundary,
mark_ends: row.mark_ends.iter().map(|&o| shift(o)).collect(),
}
}
fn shift_row_in_place(row: &mut VRow, delta: isize) {
for g in &mut row.glyphs {
g.src = (g.src as isize + delta) as usize;
}
row.end_src = (row.end_src as isize + delta) as usize;
for o in &mut row.mark_ends {
*o = (*o as isize + delta) as usize;
}
}
fn rows_within(rows: &[VRow], span: &Range<usize>) -> bool {
rows.iter().all(|r| {
r.end_src >= span.start
&& r.end_src <= span.end
&& r.glyphs
.iter()
.all(|g| g.src >= span.start && g.src <= span.end)
})
}
fn hidden_prefix_end(source: &str, meta_end: Option<usize>) -> usize {
let Some(end) = meta_end else { return 0 };
let end = end.min(source.len());
let rest = &source[end..];
if rest.starts_with("\r\n") {
end + 2
} else if rest.starts_with('\n') {
end + 1
} else {
end
}
}
fn metadata_end_of(nodes: &[FlatNode], doc: usize) -> Option<usize> {
let mut end = None;
let mut child = nodes[doc].first_child;
while let Some(cid) = child {
let n = &nodes[cid.0 as usize];
if n.kind == Kind::Metadata {
end = Some(n.span.end);
}
child = n.next_sibling;
}
end
}
fn top_level(nodes: &[FlatNode], doc: usize) -> Vec<usize> {
let mut out = Vec::new();
let mut child = nodes[doc].first_child;
while let Some(cid) = child {
let n = &nodes[cid.0 as usize];
if n.kind != Kind::Metadata {
out.push(cid.0 as usize);
}
child = n.next_sibling;
}
out.extend(
nodes
.iter()
.enumerate()
.filter(|(_, n)| n.parent.is_none() && is_placed_definition(&n.kind, &n.span))
.map(|(i, _)| i),
);
out.sort_by_key(|&i| nodes[i].span.start);
out
}
fn is_placed_definition(kind: &Kind, span: &Range<usize>) -> bool {
match *kind {
Kind::Footnote => true,
Kind::Reference => span.end > span.start,
_ => false,
}
}
pub(crate) fn top_blocks(editor: &mut Editor) -> Vec<QueryMatch> {
let mut top = editor.child_spans(None).unwrap_or_default();
let defs: Vec<QueryMatch> = definitions(editor)
.into_iter()
.filter(|m| is_placed_definition(&m.kind, &m.span))
.collect();
if defs.is_empty() {
return top;
}
top.extend(defs);
top.sort_by_key(|m| m.span.start);
top
}
pub(crate) fn footnote_definitions(editor: &mut Editor) -> Vec<QueryMatch> {
definitions(editor)
.into_iter()
.filter(|m| m.kind == Kind::Footnote)
.collect()
}
fn definitions(editor: &mut Editor) -> Vec<QueryMatch> {
let Ok(mut doc) = editor.document() else {
return Vec::new();
};
doc.definitions().unwrap_or_default()
}
pub(crate) fn footnote_label(source: &str, start: usize) -> Option<&str> {
let rest = source.get(start..)?.strip_prefix("[^")?;
let end = rest.find("]:")?;
Some(&rest[..end])
}
pub(crate) fn footnote_body_span(source: &str, span: Range<usize>) -> Option<Range<usize>> {
let rest = source.get(span.clone())?.strip_prefix("[^")?;
let marker = rest.find("]:")?;
let after_marker = span.start + 2 + marker + 2;
let raw = source.get(after_marker..span.end)?;
let start = after_marker + (raw.len() - raw.trim_start().len());
Some(start..start + raw.trim().len())
}
pub(crate) fn footnote_reference_label(source: &str, span: Range<usize>) -> Option<&str> {
let rest = source.get(span)?.strip_prefix("[^")?;
let end = rest.find(']')?;
Some(&rest[..end])
}
fn heading_content_start(source: &str, span: &Range<usize>) -> usize {
let end = span.end.min(source.len());
let Some(line) = source.get(span.start..end) else {
return span.start;
};
let line = line.split('\n').next().unwrap_or("");
let hashes = line.len() - line.trim_start_matches('#').len();
if hashes == 0 {
return span.start;
}
let after = &line[hashes..];
span.start + hashes + (after.len() - after.trim_start_matches([' ', '\t']).len())
}
struct Builder<'a> {
nodes: &'a [FlatNode],
source: &'a str,
wrap: Option<usize>,
rows: Vec<VRow>,
tables: Vec<TableInfo>,
last_off: usize,
stepped_over: usize,
media_rows: &'a HashMap<String, usize>,
break_glyph: Cell<char>,
preserve_soft: bool,
reveal: Option<Range<usize>>,
pending_mark_ends: RefCell<Vec<usize>>,
presentation: Presentation,
faces: RefCell<FaceTable>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct Presentation {
align: Option<Align>,
line_height: Option<LineHeight>,
size: Option<FontSize>,
font: Option<FaceRef>,
color: Option<TextColor>,
}
impl Presentation {
fn under(self, attrs: &[(String, Option<String>)], faces: &RefCell<FaceTable>) -> Self {
Self {
align: Align::from_attrs(attrs).or(self.align),
line_height: LineHeight::from_attrs(attrs).or(self.line_height),
size: FontSize::from_attrs(attrs).or(self.size),
font: faces.borrow_mut().face_from_attrs(attrs).or(self.font),
color: TextColor::from_attrs(attrs).or(self.color),
}
}
fn over(self, base: Style) -> Style {
base.size(self.size).font(self.font).color(self.color)
}
}
impl Builder<'_> {
fn note_mark_end(&self, id: usize) {
let node = &self.nodes[id];
if let Some(content) = &node.content_span
&& content.end < node.span.end
&& !content.is_empty()
{
self.pending_mark_ends.borrow_mut().push(content.end);
}
}
fn take_mark_ends(&self, end_src: usize) -> Vec<usize> {
let mut pending = self.pending_mark_ends.borrow_mut();
let (taken, kept): (Vec<usize>, Vec<usize>) =
pending.drain(..).partition(|&o| o <= end_src);
*pending = kept;
taken
}
fn revealed(&self, span: &Range<usize>) -> bool {
self.reveal
.as_ref()
.is_some_and(|r| span.start <= r.end && r.start <= span.end)
}
fn delims(&self, id: usize) -> Option<(Range<usize>, Range<usize>)> {
let node = &self.nodes[id];
let content = node.content_span.clone()?;
let span = node.span.clone();
if content.start < span.start || content.end > span.end {
return None;
}
let (open, close) = (span.start..content.start, content.end..span.end);
let multiline =
|r: &Range<usize>| self.source.get(r.clone()).is_some_and(|s| s.contains('\n'));
if multiline(&open) || multiline(&close) {
return None;
}
(!open.is_empty() || !close.is_empty()).then_some((open, close))
}
fn push_delim(&self, out: &mut Vec<Glyph>, range: &Range<usize>, base: Style) {
let Some(text) = self.source.get(range.clone()) else {
return;
};
push_text(out, text, range.start, base.role(Role::Delimiter));
}
fn inline_delimited(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
let show = self
.revealed(&self.nodes[id].span)
.then(|| self.delims(id))
.flatten();
if let Some((open, _)) = &show {
self.push_delim(out, open, style);
}
self.recurse(id, style, out);
match &show {
Some((_, close)) => self.push_delim(out, close, style),
None => self.note_mark_end(id),
}
}
fn children(&self, id: usize) -> Vec<usize> {
let mut out = Vec::new();
let mut c = self.nodes[id].first_child;
while let Some(cid) = c {
out.push(cid.0 as usize);
c = self.nodes[cid.0 as usize].next_sibling;
}
out
}
fn blocks(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph], tight: bool) {
let kids: Vec<usize> = self
.children(id)
.into_iter()
.filter(|&c| self.nodes[c].kind != Kind::Metadata)
.collect();
let mut above: Option<BlockClass> = None;
for child in kids {
let below = BlockClass::from_node_kind(&self.nodes[child].kind);
let before_sep = self.rows.len();
if let Some(above) = above {
self.emit_separators_before(
self.nodes[child].span.start,
pc,
!tight,
Boundary { above, below },
);
}
let first = if above.is_none() { pf } else { pc };
if self.block_or_hidden(child, before_sep, first, pc) {
above = Some(below);
}
}
}
fn block_or_hidden(
&mut self,
child: usize,
before_sep: usize,
pf: &[Glyph],
pc: &[Glyph],
) -> bool {
let after_sep = self.rows.len();
self.block(child, pf, pc);
if self.rows.len() > after_sep {
return true;
}
self.rows.truncate(before_sep);
let end = self.nodes[child].span.end;
self.last_off = self.last_off.max(end);
self.stepped_over = self.stepped_over.max(end);
false
}
fn top_blocks(&mut self, ids: &[usize]) -> Option<BlockClass> {
let mut above: Option<BlockClass> = None;
for &child in ids {
let below = BlockClass::from_node_kind(&self.nodes[child].kind);
let before_sep = self.rows.len();
if let Some(above) = above {
self.emit_separators_before(
self.nodes[child].span.start,
&[],
true,
Boundary { above, below },
);
}
if self.block_or_hidden(child, before_sep, &[], &[]) {
above = Some(below);
}
}
above
}
fn emit_separators_before(
&mut self,
next_start: usize,
pc: &[Glyph],
synthetic: bool,
boundary: Boundary,
) {
let mut offs = self.blank_rows_between(self.last_off, next_start);
if offs.is_empty() {
if !synthetic {
return;
}
offs.push(self.blank_line_offset(self.last_off, next_start));
}
let last = offs.len() - 1;
for (k, end_src) in offs.into_iter().enumerate() {
let drawn = !self.preserve_soft && (k == 0 || k == last);
self.rows.push(VRow {
glyphs: pc.to_vec(),
end_src,
decoration: drawn,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
align: None,
line_height: None,
boundary: drawn.then_some(boundary),
mark_ends: Vec::new(),
});
}
}
fn block(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
if element_tag(&self.nodes[id]) == Some("div") {
let saved = self.presentation;
self.presentation = saved.under(&self.nodes[id].attrs, &self.faces);
self.block_kind(id, pf, pc);
self.presentation = saved;
let end = self.nodes[id].span.end;
self.last_off = self.last_off.max(end);
self.stepped_over = self.stepped_over.max(end);
return;
}
self.block_kind(id, pf, pc);
}
fn block_kind(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
let node = &self.nodes[id];
match node.kind.as_str() {
"doc" | "section" => self.blocks(id, pf, pc, false),
"heading" => {
if let Some((m, kind)) = self.media_only(id) {
self.block_media(m, kind, id, pf);
return;
}
let level = node.level.unwrap_or(1);
let pres = self.presentation.under(&node.attrs, &self.faces);
let style = pres.over(heading_style(level));
let mut glyphs = Vec::new();
if let Some((open, close)) =
self.revealed(&node.span).then(|| self.delims(id)).flatten()
{
self.push_delim(&mut glyphs, &open, style);
glyphs.extend(self.inline_children_with_trailing(id, style));
self.push_delim(&mut glyphs, &close, style);
} else {
glyphs = self.inline_children_with_trailing(id, style);
}
let home = heading_content_start(self.source, &node.span);
let first = self.rows.len();
self.emit_wrapped(glyphs, home, pf, pc);
for row in &mut self.rows[first..] {
row.heading = Some(level.min(255) as u8);
row.align = pres.align;
row.line_height = pres.line_height;
}
}
"block_quote" => {
let (start, end) = (node.span.start, node.span.end);
let gutter = synth("│ ", Role::QuoteGutter, start);
let f = concat(pf, &gutter);
let c = concat(pc, &gutter);
if self.children(id).is_empty() {
self.push_row_at(f, end.min(self.source.len()));
} else {
self.blocks(id, &f, &c, false);
self.emit_quote_trailing_lines(&c, end);
}
}
"container"
if container_is_directive(node)
&& node.directive_form == Some(DirectiveForm::Leaf) =>
{
self.block_directive(id, pf);
}
"container"
if container_is_directive(node)
&& node.directive_form == Some(DirectiveForm::Container)
&& node.name.as_deref().unwrap_or_default().is_empty()
&& self.children(id).is_empty()
&& !leaf_directive_identity(node).0.is_empty() =>
{
self.block_directive(id, pf);
}
"container" if container_is_directive(node) => {
let label = directive_attr_label(&node.attrs);
let start_row = self.rows.len();
self.blocks(id, pf, pc, false);
for (i, row) in self.rows[start_row..].iter_mut().enumerate() {
row.directive = true;
if i == 0 {
row.directive_label = label.clone();
}
}
self.last_off = node.span.end;
}
"bullet_list" | "ordered_list" | "task_list" => {
let ordered = node.kind == Kind::OrderedList;
let mut item_no = 0usize;
let kids = self.children(id);
for (i, child) in kids.iter().copied().enumerate() {
let kind = &self.nodes[child].kind;
if *kind == Kind::ListItem || *kind == Kind::TaskListItem {
let start = self.nodes[child].span.start;
item_no += 1;
let checked = self.nodes[child].checked;
let marker = match (checked, ordered) {
(Some(true), _) => "☑ ".to_string(),
(Some(false), _) => "☐ ".to_string(),
(None, true) => format!("{item_no}. "),
(None, false) => "• ".to_string(),
};
let bullet = synth(&marker, Role::ListMarker, start);
let indent = synth(&" ".repeat(text_width(&marker)), Role::Body, start);
let first_row = self.rows.len();
self.block(child, &concat(pc, &bullet), &concat(pc, &indent));
if let (Some(c), Some(row)) = (checked, self.rows.get_mut(first_row)) {
row.task = Some(c);
}
} else {
if i > 0 {
self.emit_separators_before(
self.nodes[child].span.start,
pc,
true,
Boundary {
above: BlockClass::from_node_kind(
&self.nodes[kids[i - 1]].kind,
),
below: BlockClass::from_node_kind(&self.nodes[child].kind),
},
);
}
self.block(child, pc, pc);
}
}
}
"list_item" | "task_list_item" => {
if self.children(id).is_empty() {
let home = self.nodes[id].span.end.min(self.source.len());
self.push_row_at(pf.to_vec(), home);
} else {
self.blocks(id, pf, pc, true);
}
}
"footnote" => {
let (start, end) = (node.span.start, node.span.end);
let source = self.source;
let marker = format!("[{}] ", footnote_label(source, start).unwrap_or(""));
let indent = " ".repeat(text_width(&marker));
let f = concat(pf, &synth(&marker, Role::ListMarker, start));
let c = concat(pc, &synth(&indent, Role::Body, start));
if self.children(id).is_empty() {
self.push_row_at(f, end.min(source.len()));
} else {
self.blocks(id, &f, &c, false);
}
}
"reference" => {}
"table" => self.table(id, pf, pc),
"code_block" => {
let style = Style::default().role(Role::Code);
let text = node.text.clone().unwrap_or_default();
let lines: Vec<&str> = text
.strip_suffix('\n')
.unwrap_or(text.as_str())
.split('\n')
.collect();
let offs = node
.content_span
.as_ref()
.and_then(|c| self.code_line_offsets(c, &lines));
let lang = code_language(self.source, node.span.start);
let tokens = lang.as_deref().and_then(|l| code_tokens(l, &lines));
for (i, raw) in lines.iter().enumerate() {
let at = offs.as_ref().map_or(node.span.start, |o| o[i]);
let mut glyphs: Vec<Glyph> = pf.to_vec();
match tokens.as_ref().and_then(|t| t.get(i)) {
Some(spans) => push_code_text(&mut glyphs, raw, at, style, spans),
None => push_text(&mut glyphs, raw, at, style),
}
self.push_row_at(glyphs, at + raw.len());
if let Some(row) = self.rows.last_mut() {
row.code = true;
if i == 0 {
row.code_lang = lang.clone();
}
}
}
self.last_off = node.span.end;
}
"thematic_break" => {
let full = self.wrap.unwrap_or(UNWRAPPED_RULE_WIDTH);
let w = full.saturating_sub(prefix_width(pf)).max(4);
let mut glyphs = pf.to_vec();
for _ in 0..w {
glyphs.push(Glyph {
ch: '─',
style: Style::default().role(Role::Rule),
src: node.span.start,
stop: true,
});
}
let after_line = node.span.end
+ self.source[node.span.end..]
.strip_prefix("\r\n")
.map_or_else(
|| usize::from(self.source[node.span.end..].starts_with('\n')),
|_| 2,
);
self.push_row_at(glyphs, after_line);
}
"image" => self.block_media(id, MediaKind::Image, id, pf),
"container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
let kind = match element_tag(node) {
Some("audio") => MediaKind::Audio,
_ => MediaKind::Video,
};
self.block_media(id, kind, id, pf);
}
_ => {
let kids = self.children(id);
if let Some((m, kind)) = self.media_only(id) {
self.block_media(m, kind, id, pf);
return;
}
let inline = !kids.is_empty() && kids.iter().all(|&c| is_inline(&self.nodes[c]));
if inline || kids.is_empty() {
let pres = self.presentation.under(&node.attrs, &self.faces);
let glyphs =
self.inline_children_with_trailing(id, pres.over(Style::default()));
if !glyphs.is_empty() {
let first = self.rows.len();
self.emit_wrapped(glyphs, node.span.start, pf, pc);
for row in &mut self.rows[first..] {
row.align = pres.align;
row.line_height = pres.line_height;
}
}
} else {
self.blocks(id, pf, pc, false);
}
}
}
}
fn table(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
let node_end = self.nodes[id].span.end;
let row_ids: Vec<usize> = self
.children(id)
.into_iter()
.filter(|&c| self.nodes[c].kind == Kind::Row)
.collect();
if row_ids.is_empty() {
return;
}
let grid: Vec<Vec<TableCell>> = row_ids.iter().map(|&r| self.row_cells(r)).collect();
let heads: Vec<bool> = row_ids
.iter()
.map(|&r| self.nodes[r].head.unwrap_or(false))
.collect();
let cols = grid.iter().map(|r| r.len()).max().unwrap_or(0);
if cols == 0 {
return;
}
let mut widths = vec![0usize; cols];
for row in &grid {
for (c, cell) in row.iter().enumerate() {
widths[c] = widths[c].max(cell_width(&cell.glyphs));
}
}
if let Some(w) = self.wrap {
fit_widths(&mut widths, w.saturating_sub(prefix_width(pc)));
}
let rows_start = self.rows.len();
let anchor = grid[0].first().map(|c| c.start).unwrap_or(node_end);
self.push_rule(&rule_text(&widths, '┌', '┬', '┐'), anchor, pf);
for (ri, row) in grid.iter().enumerate() {
self.push_table_row(row, &widths, pc);
let ends_head = heads[ri] && heads.get(ri + 1) == Some(&false);
if ends_head {
let next = grid[ri + 1].first().map(|c| c.start).unwrap_or(node_end);
self.push_rule(&rule_text(&widths, '├', '┼', '┤'), next, pc);
}
}
self.push_rule_with_home(&rule_text(&widths, '└', '┴', '┘'), node_end, pc);
self.tables.push(TableInfo {
rows_span: rows_start..self.rows.len(),
end_src: node_end,
prefix: pc.to_vec(),
grid: grid
.into_iter()
.zip(heads)
.map(|(cells, head)| TableRow { head, cells })
.collect(),
});
self.last_off = node_end;
}
fn row_cells(&self, row: usize) -> Vec<TableCell> {
self.break_glyph.set('\n');
let cells = self
.children(row)
.into_iter()
.filter(|&c| self.nodes[c].kind == Kind::Cell)
.enumerate()
.map(|(col, c)| {
let n = &self.nodes[c];
let style = if n.head.unwrap_or(false) {
Style::default().bold()
} else {
Style::default()
};
let span = n.content_span.clone().unwrap_or_else(|| {
let off = empty_cell_offset(
&self.source[n.span.start.min(self.source.len())
..n.span.end.min(self.source.len())],
n.span.start,
col,
);
off..off
});
TableCell {
glyphs: self.inline_children(c, style),
start: span.start,
end: span.end,
align: n.alignment.unwrap_or(Alignment::Default),
}
})
.collect();
self.break_glyph.set(' ');
cells
}
fn push_rule(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
self.push_rule_row(text, src, prefix, true);
}
fn push_rule_with_home(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
self.push_rule_row(text, src, prefix, false);
}
fn push_rule_row(&mut self, text: &str, src: usize, prefix: &[Glyph], decoration: bool) {
let glyphs = concat(prefix, &synth(text, Role::Rule, src));
self.rows.push(VRow {
glyphs,
end_src: src,
decoration,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
align: None,
line_height: None,
boundary: None,
mark_ends: Vec::new(),
});
}
fn push_table_row(&mut self, cells: &[TableCell], widths: &[usize], prefix: &[Glyph]) {
let fallback = cells.last().map(|c| c.end).unwrap_or(0);
let laid: Vec<Vec<Vec<Glyph>>> = cells
.iter()
.enumerate()
.map(|(ci, c)| wrap_glyphs(&c.glyphs, widths.get(ci).copied().unwrap_or(0)))
.collect();
let height = laid.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
for j in 0..height {
let mut glyphs = prefix.to_vec();
for (ci, &w) in widths.iter().enumerate() {
let cell = cells.get(ci);
let line = laid.get(ci).and_then(|l| l.get(j));
let at = line
.and_then(|l| l.first().map(|g| g.src))
.or_else(|| cell.map(|c| c.start))
.unwrap_or(fallback);
glyphs.extend(synth("│", Role::Rule, at));
match (cell, line) {
(Some(cell), Some(line)) => {
let pad = w.saturating_sub(glyphs_width(line));
let (lead, trail) = match cell.align {
Alignment::Right => (pad, 0),
Alignment::Center => (pad / 2, pad - pad / 2),
Alignment::Left | Alignment::Default => (0, pad),
};
let last = laid[ci].len() == j + 1;
let end = match last {
true => cell.end,
false => line
.last()
.map(|g| g.src + g.ch.len_utf8())
.unwrap_or(cell.end),
};
glyphs.extend(synth(&" ".repeat(lead + 1), Role::Body, at));
glyphs.extend(line.iter().cloned());
glyphs.push(Glyph {
ch: ' ',
style: Style::default(),
src: end,
stop: true,
});
glyphs.extend(synth(&" ".repeat(trail), Role::Body, end));
}
_ => {
let at = cell.map(|c| c.end).unwrap_or(fallback);
glyphs.extend(synth(&" ".repeat(w + 2), Role::Body, at));
}
}
}
glyphs.extend(synth("│", Role::Rule, fallback));
let end_src = glyphs
.iter()
.rev()
.find(|g| g.stop)
.map_or(fallback, |g| g.src);
let mark_ends = self.take_mark_ends(end_src);
self.rows.push(VRow {
glyphs,
end_src,
decoration: false,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
align: None,
line_height: None,
boundary: None,
mark_ends,
});
}
}
fn block_media(&mut self, img: usize, kind: MediaKind, wrapper: usize, pf: &[Glyph]) {
let node = &self.nodes[img];
let start = node.span.start;
let end = node.span.end;
let destination = match kind {
MediaKind::Image => node.destination.clone().unwrap_or_default(),
MediaKind::Video | MediaKind::Audio => attr_of(node, "src").unwrap_or_default(),
};
let poster = match kind {
MediaKind::Video => attr_of(node, "poster").unwrap_or_default(),
MediaKind::Image | MediaKind::Audio => String::new(),
};
let sources = match kind {
MediaKind::Image => self.media_sources(wrapper),
MediaKind::Video | MediaKind::Audio => self.media_sources(img),
};
let alt = self.image_alt(img);
let sigil = kind.sigil();
let label = if alt.is_empty() {
let named = if destination.is_empty() {
sources
.first()
.map(|s| s.srcset.as_str())
.unwrap_or_default()
} else {
&destination
};
format!("{sigil} {}", media_label(named))
} else {
format!("{sigil} {alt}")
};
let style = Style::default().role(Role::Image);
let mut glyphs = pf.to_vec();
for ch in label.chars() {
glyphs.push(Glyph {
ch,
style,
src: start,
stop: true,
});
}
let rows = self
.media_rows
.get(&destination)
.copied()
.unwrap_or(1)
.max(1);
self.push_row_at(glyphs, end);
if let Some(row) = self.rows.last_mut() {
row.media = Some(MediaMark {
kind,
destination,
sources,
alt,
poster,
rows,
});
}
for _ in 1..rows {
self.rows.push(VRow {
glyphs: Vec::new(),
end_src: end,
decoration: true,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
align: None,
line_height: None,
boundary: None,
mark_ends: Vec::new(),
});
}
self.last_off = end;
}
fn media_sources(&self, wrapper: usize) -> Vec<MediaSource> {
let mut out = Vec::new();
self.collect_sources(wrapper, &mut out);
out
}
fn collect_sources(&self, id: usize, out: &mut Vec<MediaSource>) {
for c in self.children(id) {
let node = &self.nodes[c];
if node.name.as_deref() == Some("source") {
let url = attr_of(node, "srcset").or_else(|| attr_of(node, "src"));
if let Some(srcset) = url {
out.push(MediaSource {
media: attr_of(node, "media").unwrap_or_default(),
srcset,
mime: attr_of(node, "type").unwrap_or_default(),
});
}
}
self.collect_sources(c, out);
}
}
fn media_only(&self, id: usize) -> Option<(usize, MediaKind)> {
let mut found = None;
let mut count = 0usize;
let mut has_text = false;
self.scan_visual(id, &mut found, &mut count, &mut has_text);
(count == 1 && !has_text).then(|| found.unwrap())
}
fn scan_visual(
&self,
id: usize,
found: &mut Option<(usize, MediaKind)>,
count: &mut usize,
has_text: &mut bool,
) {
for c in self.children(id) {
let node = &self.nodes[c];
match node.kind.as_str() {
"image" => {
*found = Some((c, MediaKind::Image));
*count += 1;
}
"container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
let kind = match element_tag(node) {
Some("audio") => MediaKind::Audio,
_ => MediaKind::Video,
};
*found = Some((c, kind));
*count += 1;
}
"str" | "smart_punctuation" | "verbatim" | "inline_math" => {
if node.text.as_deref().is_some_and(|t| !t.trim().is_empty()) {
*has_text = true;
}
}
"soft_break" | "hard_break" | "non_breaking_space" => {}
_ => self.scan_visual(c, found, count, has_text),
}
}
}
fn block_directive(&mut self, id: usize, pf: &[Glyph]) {
let node = &self.nodes[id];
let (start, end) = (node.span.start, node.span.end);
let (name, attrs) = leaf_directive_identity(node);
let label = self.image_alt(id); let shown = if label.is_empty() { &name } else { &label };
let style = Style::default().role(Role::Image);
let mut glyphs = pf.to_vec();
for ch in format!("⧉ {shown}").chars() {
glyphs.push(Glyph {
ch,
style,
src: start,
stop: true,
});
}
self.push_row_at(glyphs, end);
if let Some(row) = self.rows.last_mut() {
row.directive = true;
row.leaf_directive = Some(DirectiveMark {
name,
attrs,
label,
rows: 1,
});
}
self.last_off = end;
}
fn image_alt(&self, id: usize) -> String {
let mut out = String::new();
self.collect_text(id, &mut out);
out
}
fn collect_text(&self, id: usize, out: &mut String) {
for c in self.children(id) {
if let Some(t) = &self.nodes[c].text {
out.push_str(t);
}
self.collect_text(c, out);
}
}
fn inline_children(&self, id: usize, base: Style) -> Vec<Glyph> {
let mut out = Vec::new();
for c in self.children(id) {
self.inline(c, base, &mut out);
}
out
}
fn inline_children_with_trailing(&self, id: usize, base: Style) -> Vec<Glyph> {
let mut out = self.inline_children(id, base);
out.extend(self.trailing_ws_glyphs(id, base));
out
}
fn trailing_ws_glyphs(&self, id: usize, style: Style) -> Vec<Glyph> {
let node = &self.nodes[id];
let Some(content) = &node.content_span else {
return Vec::new();
};
let (from, to) = (content.end, node.span.end);
let Some(slice) = (from < to).then(|| self.source.get(from..to)).flatten() else {
return Vec::new();
};
if slice.is_empty() || slice.bytes().any(|b| b != b' ') {
return Vec::new();
}
slice
.bytes()
.enumerate()
.map(|(i, _)| Glyph {
ch: ' ',
style,
src: from + i,
stop: true,
})
.collect()
}
fn inline(&self, id: usize, base: Style, out: &mut Vec<Glyph>) {
let node = &self.nodes[id];
match node.kind.as_str() {
"str" | "smart_punctuation" => push_escaped_text(
out,
node.text.as_deref().unwrap_or(""),
node.span.clone(),
self.source,
base,
),
"soft_break" | "hard_break" | "non_breaking_space" => {
let src = if node.span.start != 0 {
node.span.start
} else {
out.last().map(|g| g.src + g.ch.len_utf8()).unwrap_or(0)
};
let ch = if node.kind == Kind::HardBreak {
self.break_glyph.get()
} else if node.kind == Kind::SoftBreak
&& self.preserve_soft
&& self.break_glyph.get() == ' '
{
'\n'
} else {
' '
};
out.push(Glyph {
ch,
style: base,
src,
stop: true,
});
}
"raw_inline" if self.break_glyph.get() == '\n' && is_br(node.text.as_deref()) => {
out.push(Glyph {
ch: '\n',
style: base,
src: node.span.start,
stop: true,
});
}
"emph" => self.inline_delimited(id, base.italic(), out),
"strong" => self.inline_delimited(id, base.bold(), out),
"mark" => {
let color = MarkColor::from_attrs(&node.attrs);
self.inline_delimited(id, base.role(Role::Mark(color)), out)
}
"insert" => self.inline_delimited(id, base.underline(), out),
"delete" => self.inline_delimited(id, base.strikethrough(), out),
"superscript" => self.inline_delimited(id, base.baseline(Baseline::Super), out),
"subscript" => self.inline_delimited(id, base.baseline(Baseline::Sub), out),
"verbatim" | "inline_math" => {
let at = node
.content_span
.as_ref()
.map_or(node.span.start + 1, |c| c.start);
let style = base.role(Role::Code);
let show = self.revealed(&node.span).then(|| self.delims(id)).flatten();
if let Some((open, _)) = &show {
self.push_delim(out, open, style);
}
push_text(out, node.text.as_deref().unwrap_or(""), at, style);
match &show {
Some((_, close)) => self.push_delim(out, close, style),
None => self.note_mark_end(id),
}
}
"container" if is_run_span(node) && !self.children(id).is_empty() => {
self.recurse(id, run_style(node, base, &self.faces), out)
}
"container" if container_is_directive(node) && !self.children(id).is_empty() => {
self.recurse(id, base, out)
}
"container" if container_is_directive(node) && node.attrs.is_empty() => {
let span = node.span.clone();
push_text(
out,
self.source.get(span.clone()).unwrap_or(""),
span.start,
base,
);
}
"container" if container_is_directive(node) => {
let start = node.span.start;
let name = node.name.clone().unwrap_or_default();
let shown = match directive_attr_label(&node.attrs) {
Some(attrs) if !name.is_empty() => format!("⧉ {name} {attrs}"),
Some(attrs) => format!("⧉ {attrs}"),
None => format!("⧉ {name}"),
};
let style = base.role(Role::Image);
for (i, ch) in shown.chars().enumerate() {
out.push(Glyph {
ch,
style,
src: start,
stop: i == 0,
});
}
}
"footnote_reference" => {
let style = base.role(Role::Link);
if self.revealed(&node.span) {
self.push_delim(out, &node.span, style);
return;
}
let style = style.baseline(Baseline::Super);
let (label, at) = match &node.content_span {
Some(c) => (self.source.get(c.clone()).unwrap_or(""), c.start),
None => (node.text.as_deref().unwrap_or(""), node.span.start + 2),
};
out.push(Glyph {
ch: '[',
style,
src: node.span.start,
stop: false,
});
push_text(out, label, at, style);
out.push(Glyph {
ch: ']',
style,
src: node.span.end.saturating_sub(1),
stop: false,
});
}
"link" | "url" | "email" => {
let style = base.role(Role::Link);
if self.children(id).is_empty() {
push_text(
out,
node.destination
.as_deref()
.or(node.text.as_deref())
.unwrap_or("link"),
node.span.start,
style,
);
} else {
self.inline_delimited(id, style, out);
}
}
_ => {
if self.children(id).is_empty() {
if let Some(t) = &node.text {
push_text(out, t, node.span.start, base);
}
} else {
self.recurse(id, base, out);
}
}
}
}
fn recurse(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
for c in self.children(id) {
self.inline(c, style, out);
}
}
fn emit_wrapped(&mut self, glyphs: Vec<Glyph>, block_start: usize, pf: &[Glyph], pc: &[Glyph]) {
if !glyphs.iter().any(|g| g.ch == '\n') {
self.emit_line(glyphs, block_start, pf, pc, None);
return;
}
let mut run: Vec<Glyph> = Vec::new();
let mut first = true;
for g in glyphs {
if g.ch == '\n' {
let lead = if first { pf } else { pc };
self.emit_line(std::mem::take(&mut run), block_start, lead, pc, Some(g.src));
first = false;
} else {
run.push(g);
}
}
if !run.is_empty() {
let lead = if first { pf } else { pc };
self.emit_line(run, block_start, lead, pc, None);
}
}
fn emit_line(
&mut self,
glyphs: Vec<Glyph>,
block_start: usize,
pf: &[Glyph],
pc: &[Glyph],
end: Option<usize>,
) {
let push_last = |b: &mut Self, row: Vec<Glyph>| match end {
Some(e) => b.push_row_at(row, e),
None => b.push_row(row, block_start),
};
let Some(width) = self.wrap else {
let row = if glyphs.is_empty() {
pf.to_vec()
} else {
concat(pf, &glyphs)
};
push_last(self, row);
return;
};
let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
let mut word: Vec<Glyph> = Vec::new();
for g in glyphs {
if g.ch == ' ' {
words.push((std::mem::take(&mut word), Some(g)));
} else {
word.push(g);
}
}
if !word.is_empty() {
words.push((word, None));
}
if words.is_empty() {
push_last(self, pf.to_vec());
return;
}
let mut line: Vec<Glyph> = Vec::new();
let mut used = 0usize;
let mut first = true;
for (w, space) in words {
let avail = width
.saturating_sub(prefix_width(if first { pf } else { pc }))
.max(1);
let cells = glyphs_width(&w);
if used > 0 && used + cells > avail {
let row = concat(if first { pf } else { pc }, &line);
self.push_row(row, block_start);
line = Vec::new();
used = 0;
first = false;
}
used += cells;
line.extend(w);
if let Some(sp) = space {
used += 1;
line.push(sp);
}
}
let row = concat(if first { pf } else { pc }, &line);
push_last(self, row);
}
fn code_line_offsets(&self, content: &Range<usize>, lines: &[&str]) -> Option<Vec<usize>> {
let mut src_lines: Vec<(usize, &str)> = Vec::new();
let mut at = content.start;
for l in self.source.get(content.start..content.end)?.split('\n') {
src_lines.push((at, l));
at += l.len() + 1;
}
if src_lines.len() != lines.len() {
return None;
}
Some(
lines
.iter()
.zip(&src_lines)
.map(|(l, (start, sl))| start + sl.len().saturating_sub(l.len()))
.collect(),
)
}
fn push_row(&mut self, glyphs: Vec<Glyph>, fallback: usize) {
let end_src = glyphs
.last()
.map(|g| {
let at = g.src.min(self.source.len());
at + self.source[at..].chars().next().map_or(0, char::len_utf8)
})
.unwrap_or(fallback);
self.push_row_at(glyphs, end_src);
}
fn push_row_at(&mut self, glyphs: Vec<Glyph>, end_src: usize) {
self.last_off = end_src;
let mark_ends = self.take_mark_ends(end_src);
self.rows.push(VRow {
glyphs,
end_src,
decoration: false,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
align: None,
line_height: None,
boundary: None,
mark_ends,
});
}
fn emit_quote_trailing_lines(&mut self, pc: &[Glyph], end: usize) {
let end = end.min(self.source.len());
let mut at = self.rows.last().map_or(0, |r| r.end_src);
while at < end {
let Some(k) = self.source[at..end].find('\n') else {
break;
};
let line_start = at + k + 1;
let line_end = self.source[line_start..end]
.find('\n')
.map_or(end, |i| line_start + i);
self.push_row_at(pc.to_vec(), line_end);
at = line_end;
}
}
fn blank_line_offset(&self, prev_end: usize, next_start: usize) -> usize {
let after_nl = self.source[prev_end..]
.find('\n')
.map_or(prev_end, |p| prev_end + p + 1);
after_nl.min(next_start.saturating_sub(1)).max(prev_end)
}
fn blank_rows_between(&self, prev_end: usize, next_start: usize) -> Vec<usize> {
if next_start <= prev_end {
return Vec::new();
}
let gap = &self.source[prev_end..next_start];
let Some(nl) = gap.find('\n') else {
return Vec::new();
};
let next_line_start = self.source[..next_start].rfind('\n').map_or(0, |p| p + 1);
let mut offs = Vec::new();
let mut start = prev_end + nl + 1;
while start < next_line_start {
offs.push(start);
match self.source[start..next_start].find('\n') {
Some(k) => start += k + 1,
None => break,
}
}
offs
}
fn emit_trailing_blank_lines(&mut self, above: BlockClass, hidden_end: usize) {
let last_end = self
.rows
.last()
.map_or(hidden_end, |r| r.end_src)
.max(self.stepped_over);
if last_end >= self.source.len() {
return;
}
let extra = self.source[last_end..].matches('\n').count();
if extra < 2 {
return;
}
for k in 1..=extra {
self.rows.push(VRow {
glyphs: Vec::new(),
end_src: last_end + k,
decoration: !self.preserve_soft && k == 1,
code: false,
code_lang: None,
directive: false,
directive_label: None,
media: None,
task: None,
leaf_directive: None,
heading: None,
align: None,
line_height: None,
boundary: (!self.preserve_soft && k == 1).then_some(Boundary {
above,
below: BlockClass::Paragraph,
}),
mark_ends: Vec::new(),
});
}
}
}
pub fn text_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
struct Cluster {
glyph: usize,
col: usize,
cells: usize,
}
fn clusters(glyphs: &[Glyph]) -> Vec<Cluster> {
let text: String = glyphs.iter().map(|g| g.ch).collect();
let mut out = Vec::new();
let (mut glyph, mut col) = (0, 0);
for cluster in text.graphemes(true) {
let cells = text_width(cluster);
out.push(Cluster { glyph, col, cells });
glyph += cluster.chars().count();
col += cells;
}
out
}
fn glyphs_width(glyphs: &[Glyph]) -> usize {
clusters(glyphs).last().map_or(0, |c| c.col + c.cells)
}
fn cell_width(glyphs: &[Glyph]) -> usize {
glyphs
.split(|g| g.ch == '\n')
.map(glyphs_width)
.max()
.unwrap_or(0)
}
fn is_br(text: Option<&str>) -> bool {
let Some(t) = text else { return false };
matches!(
t.trim().to_ascii_lowercase().replace(' ', "").as_str(),
"<br>" | "<br/>"
)
}
impl VRow {
fn width(&self) -> usize {
glyphs_width(&self.glyphs)
}
fn col_of_glyph(&self, i: usize) -> usize {
clusters(&self.glyphs)
.iter()
.rev()
.find(|c| c.glyph <= i)
.map_or(0, |c| c.col)
}
fn glyph_at_col(&self, col: usize) -> Option<usize> {
clusters(&self.glyphs)
.into_iter()
.find(|c| col < c.col + c.cells)
.map(|c| c.glyph)
}
}
fn empty_cell_offset(src: &str, start: usize, col: usize) -> usize {
let bytes = src.as_bytes();
let mut pipes = Vec::new();
for (i, &b) in bytes.iter().enumerate() {
if b == b'|' && (i == 0 || bytes[i - 1] != b'\\') {
pipes.push(i);
}
}
let whole_row = pipes.len() > 1 || pipes.first().is_some_and(|&i| i != 0);
let (open, close) = if whole_row {
(pipes.get(col).copied(), pipes.get(col + 1).copied())
} else {
(pipes.first().copied(), Some(src.len()))
};
match (open, close) {
(Some(open), Some(close)) => {
let lo = open + 1; let hi = close.saturating_sub(1); let inside = if hi < lo {
lo
} else {
(open + 2).clamp(lo, hi)
};
start + inside
}
(Some(open), None) => start + open + 1,
_ => start,
}
}
#[derive(Clone)]
pub struct TableCell {
pub glyphs: Vec<Glyph>,
pub start: usize,
pub end: usize,
pub align: Alignment,
}
#[derive(Clone)]
pub struct TableRow {
pub head: bool,
pub cells: Vec<TableCell>,
}
#[derive(Clone)]
pub struct TableInfo {
pub rows_span: Range<usize>,
pub end_src: usize,
pub prefix: Vec<Glyph>,
pub grid: Vec<TableRow>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CodeBlockInfo {
pub rows_span: Range<usize>,
pub lang: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MediaInfo {
pub rows_span: Range<usize>,
pub kind: MediaKind,
pub destination: String,
pub sources: Vec<MediaSource>,
pub alt: String,
pub poster: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectiveInfo {
pub rows_span: Range<usize>,
pub name: String,
pub attrs: Vec<(String, Option<String>)>,
pub label: String,
}
impl DirectiveInfo {
pub fn attr(&self, key: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| v.as_deref())
}
}
impl MediaInfo {
pub fn resolve(&self, scheme: ColorScheme) -> &str {
if let Some(url) = self
.sources
.iter()
.find(|s| media_matches(&s.media, scheme))
.and_then(|s| first_srcset_url(&s.srcset))
{
return url;
}
if self.destination.is_empty()
&& let Some(url) = self
.sources
.iter()
.find_map(|s| first_srcset_url(&s.srcset))
{
return url;
}
&self.destination
}
pub fn still(&self, scheme: ColorScheme) -> Option<&str> {
match self.kind {
MediaKind::Image => Some(self.resolve(scheme)),
MediaKind::Video if !self.poster.is_empty() => Some(&self.poster),
MediaKind::Video | MediaKind::Audio => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorScheme {
Light,
Dark,
}
fn media_matches(media: &str, scheme: ColorScheme) -> bool {
let media = media.trim();
if media.is_empty() {
return true;
}
let lower = media.to_ascii_lowercase();
let Some(after) = lower
.split_once("prefers-color-scheme")
.map(|(_, rest)| rest)
else {
return false;
};
let value = after.trim_start_matches([':', ' ', '\t']);
let wanted = match scheme {
ColorScheme::Light => "light",
ColorScheme::Dark => "dark",
};
value.starts_with(wanted)
}
fn first_srcset_url(srcset: &str) -> Option<&str> {
let first = srcset.split(',').next()?.trim();
first.split_whitespace().next().filter(|u| !u.is_empty())
}
const MIN_COL_WIDTH: usize = 3;
fn fit_widths(widths: &mut [usize], avail: usize) {
let budget = avail.saturating_sub(3 * widths.len() + 1);
while widths.iter().sum::<usize>() > budget {
let Some(w) = widths.iter_mut().filter(|w| **w > MIN_COL_WIDTH).max() else {
return;
};
*w -= 1;
}
}
fn wrap_glyphs(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
if glyphs.iter().any(|g| g.ch == '\n') {
return glyphs
.split(|g| g.ch == '\n')
.flat_map(|seg| wrap_segment(seg, width))
.collect();
}
wrap_segment(glyphs, width)
}
fn wrap_segment(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
let width = width.max(1);
let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
let mut word: Vec<Glyph> = Vec::new();
for g in glyphs {
if g.ch == ' ' {
words.push((std::mem::take(&mut word), Some(g.clone())));
} else {
word.push(g.clone());
}
}
if !word.is_empty() {
words.push((word, None));
}
let mut lines: Vec<Vec<Glyph>> = Vec::new();
let mut line: Vec<Glyph> = Vec::new();
let mut used = 0usize;
let mut gap: Option<Glyph> = None;
for (word, space) in words {
for chunk in hard_break(&word, width) {
let sep = gap.is_some() as usize;
let cells = glyphs_width(chunk);
if !line.is_empty() && used + sep + cells > width {
lines.push(std::mem::take(&mut line));
used = 0;
gap = None; }
if let Some(sp) = gap.take() {
line.push(sp);
used += 1;
}
line.extend_from_slice(chunk);
used += cells;
}
gap = space;
}
if !line.is_empty() || lines.is_empty() {
lines.push(line);
}
lines
}
fn hard_break(word: &[Glyph], width: usize) -> Vec<&[Glyph]> {
let mut out = Vec::new();
if word.is_empty() {
return out;
}
let (mut start, mut used) = (0usize, 0usize);
for c in clusters(word) {
if used > 0 && used + c.cells > width {
out.push(&word[start..c.glyph]);
start = c.glyph;
used = 0;
}
used += c.cells;
}
out.push(&word[start..]);
out
}
fn rule_text(widths: &[usize], left: char, mid: char, right: char) -> String {
let mut s = String::new();
s.push(left);
for (i, w) in widths.iter().enumerate() {
if i > 0 {
s.push(mid);
}
for _ in 0..w + 2 {
s.push('─');
}
}
s.push(right);
s
}
fn push_text(out: &mut Vec<Glyph>, text: &str, base_src: usize, style: Style) {
for (gi, cluster) in text.grapheme_indices(true) {
for (ci, ch) in cluster.char_indices() {
out.push(Glyph {
ch,
style,
src: base_src + gi + ci,
stop: ci == 0,
});
}
}
}
fn push_code_text(
out: &mut Vec<Glyph>,
text: &str,
base_src: usize,
style: Style,
spans: &[(Range<usize>, Token)],
) {
let mut spans = spans.iter().peekable();
for (gi, cluster) in text.grapheme_indices(true) {
while spans.peek().is_some_and(|(r, _)| r.end <= gi) {
spans.next();
}
let token = spans
.peek()
.filter(|(r, _)| r.contains(&gi))
.map(|(_, t)| *t);
let style = style.token(token);
for (ci, ch) in cluster.char_indices() {
out.push(Glyph {
ch,
style,
src: base_src + gi + ci,
stop: ci == 0,
});
}
}
}
type LineTokens = Vec<(Range<usize>, Token)>;
#[cfg(feature = "syntax")]
fn code_tokens(lang: &str, lines: &[&str]) -> Option<Vec<LineTokens>> {
crate::syntax::highlight(lang, lines)
}
#[cfg(not(feature = "syntax"))]
fn code_tokens(_lang: &str, _lines: &[&str]) -> Option<Vec<LineTokens>> {
None
}
fn push_escaped_text(
out: &mut Vec<Glyph>,
text: &str,
span: Range<usize>,
source: &str,
style: Style,
) {
let end = span.end.min(source.len());
let src = source.get(span.start..end).unwrap_or("");
if src.len() == text.len() {
push_text(out, text, span.start, style);
return;
}
let sb = src.as_bytes();
let mut si = 0usize;
'text: for (_, cluster) in text.grapheme_indices(true) {
for (ci, ch) in cluster.char_indices() {
if si >= sb.len() {
break 'text;
}
while si < sb.len() && !src[si..].starts_with(ch) {
si += src[si..].chars().next().map_or(1, char::len_utf8);
}
out.push(Glyph {
ch,
style,
src: span.start + si.min(src.len()),
stop: ci == 0,
});
si += src[si..]
.chars()
.next()
.map_or(ch.len_utf8(), char::len_utf8);
}
}
}
fn synth(text: &str, role: Role, src: usize) -> Vec<Glyph> {
let style = Style::default().role(role);
text.chars()
.map(|ch| Glyph {
ch,
style,
src,
stop: false,
})
.collect()
}
fn concat(a: &[Glyph], b: &[Glyph]) -> Vec<Glyph> {
let mut v = a.to_vec();
v.extend_from_slice(b);
v
}
fn prefix_width(prefix: &[Glyph]) -> usize {
glyphs_width(prefix)
}
fn media_label(dest: &str) -> String {
if dest.is_empty() {
return "image".to_string();
}
if dest.starts_with("data:") {
return "data:…".to_string();
}
let clean = dest.split(['?', '#']).next().unwrap_or(dest);
let tail = clean
.trim_end_matches('/')
.rsplit(['/', '\\'])
.next()
.unwrap_or(clean);
if tail.is_empty() {
dest.to_string()
} else {
tail.to_string()
}
}
fn directive_attr_label(attrs: &[(String, Option<String>)]) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
for (k, v) in attrs {
if k == "class" {
if let Some(v) = v
&& !v.is_empty()
{
parts.push(v.clone());
}
} else if v.as_deref().unwrap_or("").is_empty() {
parts.push(k.clone());
}
}
(!parts.is_empty()).then(|| parts.join(" "))
}
fn heading_style(level: u32) -> Style {
Style::default().role(Role::Heading(level.min(255) as u8))
}
pub(crate) fn container_is_directive(node: &FlatNode) -> bool {
node.origin == Some(ContainerOrigin::Directive)
}
pub(crate) fn element_tag(node: &FlatNode) -> Option<&str> {
(node.origin == Some(ContainerOrigin::Element))
.then_some(node.name.as_deref())
.flatten()
}
fn leaf_directive_identity(node: &FlatNode) -> (String, Vec<(String, Option<String>)>) {
let named = node.name.clone().unwrap_or_default();
if !named.is_empty() {
return (named, node.attrs.clone());
}
let class = node
.attrs
.iter()
.find(|(k, _)| k == "class")
.and_then(|(_, v)| v.as_deref())
.unwrap_or_default();
let mut tokens = class.split_whitespace();
let Some(name) = tokens.next().map(str::to_string) else {
return (named, node.attrs.clone());
};
let rest = tokens.collect::<Vec<_>>().join(" ");
let attrs = node
.attrs
.iter()
.filter_map(|(k, v)| {
if k != "class" {
return Some((k.clone(), v.clone()));
}
(!rest.is_empty()).then(|| (k.clone(), Some(rest.clone())))
})
.collect();
(name, attrs)
}
pub(crate) fn is_run_span(node: &FlatNode) -> bool {
if node.kind != Kind::Container {
return false;
}
match node.name.as_deref() {
None | Some("") => node.directive_form == Some(DirectiveForm::Text),
Some("span") => node.origin == Some(ContainerOrigin::Element),
Some(_) => false,
}
}
fn run_style(node: &FlatNode, base: Style, faces: &RefCell<FaceTable>) -> Style {
Style {
size: FontSize::from_attrs(&node.attrs).or(base.size),
font: faces
.borrow_mut()
.face_from_attrs(&node.attrs)
.or(base.font),
color: TextColor::from_attrs(&node.attrs).or(base.color),
..base
}
}
pub(crate) fn is_inline(node: &FlatNode) -> bool {
if node.kind == Kind::Container {
return node.directive_form == Some(DirectiveForm::Text);
}
is_inline_kind(&node.kind)
}
pub(crate) fn is_inline_kind(kind: &Kind) -> bool {
matches!(
kind,
Kind::Str
| Kind::SoftBreak
| Kind::HardBreak
| Kind::NonBreakingSpace
| Kind::Emph
| Kind::Strong
| Kind::Mark
| Kind::Insert
| Kind::Delete
| Kind::Verbatim
| Kind::InlineMath
| Kind::DisplayMath
| Kind::Url
| Kind::Email
| Kind::Link
| Kind::Image
| Kind::SmartPunctuation
| Kind::Superscript
| Kind::Subscript
| Kind::FootnoteReference
)
}
#[cfg(test)]
pub(crate) fn assert_maps_eq(a: &VisualMap, b: &VisualMap, ctx: &str) {
assert_eq!(a.rows.len(), b.rows.len(), "row count ({ctx})");
for (i, (ra, rb)) in a.rows.iter().zip(&b.rows).enumerate() {
assert_eq!(ra.end_src, rb.end_src, "row {i} end_src ({ctx})");
assert_eq!(ra.decoration, rb.decoration, "row {i} decoration ({ctx})");
assert_eq!(ra.boundary, rb.boundary, "row {i} boundary ({ctx})");
assert_eq!(ra.code, rb.code, "row {i} code ({ctx})");
assert_eq!(ra.code_lang, rb.code_lang, "row {i} code_lang ({ctx})");
assert_eq!(ra.align, rb.align, "row {i} align ({ctx})");
assert_eq!(
ra.line_height, rb.line_height,
"row {i} line_height ({ctx})"
);
assert_eq!(
ra.glyphs.len(),
rb.glyphs.len(),
"row {i} glyph count ({ctx})"
);
for (j, (ga, gb)) in ra.glyphs.iter().zip(&rb.glyphs).enumerate() {
assert_eq!(
(ga.ch, ga.src, ga.stop, ga.style),
(gb.ch, gb.src, gb.stop, gb.style),
"row {i} glyph {j} ({ctx})"
);
}
}
assert_eq!(a.content_start, b.content_start, "content_start ({ctx})");
assert_eq!(a.stops, b.stops, "stops ({ctx})");
assert_eq!(a.mark_ends, b.mark_ends, "mark_ends ({ctx})");
assert_eq!(a.tables.len(), b.tables.len(), "table count ({ctx})");
for (i, (ta, tb)) in a.tables.iter().zip(&b.tables).enumerate() {
assert_eq!(ta.rows_span, tb.rows_span, "table {i} rows_span ({ctx})");
assert_eq!(ta.end_src, tb.end_src, "table {i} end_src ({ctx})");
}
assert_eq!(a.code_blocks, b.code_blocks, "code_blocks ({ctx})");
assert_eq!(a.media, b.media, "images ({ctx})");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::{FontFamily, LineSpacing, SizeStep};
use twig::{Editor, Format, NodeId};
fn map(src: &str) -> VisualMap {
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
build_t(&ed.nodes().unwrap(), src, Some(80))
}
fn map_djot(src: &str) -> VisualMap {
let mut ed = Editor::new_str(src, Format::Djot).unwrap();
build_t(&ed.nodes().unwrap(), src, Some(80))
}
fn baselines_of(m: &VisualMap, ch: char) -> Vec<Baseline> {
m.rows
.iter()
.flat_map(|r| r.glyphs.iter())
.filter(|g| g.ch == ch)
.map(|g| g.style.baseline)
.collect()
}
fn map_at(src: &str, wrap: Option<usize>) -> VisualMap {
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
build_t(&ed.nodes().unwrap(), src, wrap)
}
fn map_directives(src: &str) -> VisualMap {
let mut ed = Editor::new_ext(
src.as_bytes(),
Format::Markdown,
twig::MarkdownExtensions {
directives: true,
..Default::default()
},
)
.unwrap();
build_t(&ed.nodes().unwrap(), src, Some(80))
}
fn map_leaf(src: &str, format: Format) -> VisualMap {
let mut ed =
Editor::new_ext(src.as_bytes(), format, crate::doc::parse_extensions()).unwrap();
build_t(&ed.nodes().unwrap(), src, Some(80))
}
fn line_facts(m: &VisualMap) -> Vec<(Option<Align>, Option<LineHeight>)> {
m.rows
.iter()
.filter(|r| r.glyphs.iter().any(|g| !g.ch.is_whitespace()))
.map(|r| (r.align, r.line_height))
.collect()
}
fn style_of(m: &VisualMap, ch: char) -> Style {
m.rows
.iter()
.flat_map(|r| r.glyphs.iter())
.find(|g| g.ch == ch)
.unwrap_or_else(|| panic!("no glyph {ch:?} in the map"))
.style
}
fn map_preserve(src: &str, wrap: Option<usize>) -> VisualMap {
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
build(&ed.nodes().unwrap(), src, wrap, true, &HashMap::new(), None)
}
fn build_t(nodes: &[FlatNode], src: &str, wrap: Option<usize>) -> VisualMap {
build(nodes, src, wrap, false, &HashMap::new(), None)
}
#[test]
fn a_source_shorter_than_the_arena_built_over_it_renders_rather_than_panicking() {
let src = "alpha \\*beta\\* gamma delta epsilon\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let nodes = ed.nodes().unwrap();
for cut in 0..=src.len() {
if !src.is_char_boundary(cut) {
continue;
}
let map = build_t(&nodes, &src[..cut], Some(80));
for row in &map.rows {
for g in &row.glyphs {
assert!(
g.src <= src.len(),
"cut {cut}: glyph {:?} points past the source at {}",
g.ch,
g.src
);
}
}
}
}
fn rendered(m: &VisualMap) -> String {
m.rows
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
fn render_both(
ed: &mut Editor,
src: &str,
wrap: Option<usize>,
cache: &mut BlockCache,
) -> (VisualMap, VisualMap) {
let all = ed.nodes().unwrap();
let media_rows = HashMap::new();
let plain = build(&all, src, wrap, false, &media_rows, None);
let top = top_blocks(ed);
let cached = build_cached(&top, src, wrap, false, &media_rows, None, cache, |id| {
ed.subtree(NodeId(id)).unwrap_or_default()
});
(plain, cached)
}
#[test]
fn a_glyph_never_lands_inside_the_character_before_it() {
let src = "> engage with it rather than look away. […]\n>\n> The through-line\n";
let vmap = map(src);
for (r, row) in vmap.rows.iter().enumerate() {
assert!(
src.is_char_boundary(row.end_src.min(src.len())),
"row {r} ends at {} — inside a character",
row.end_src
);
for g in &row.glyphs {
assert!(
src.is_char_boundary(g.src.min(src.len())),
"row {r} has {:?} at {}, which is inside a character",
g.ch,
g.src
);
}
}
let text: String = vmap
.rows
.iter()
.flat_map(|r| r.glyphs.iter().map(|g| g.ch))
.collect();
assert!(text.contains("[…]"), "the elision should render: {text:?}");
let close = vmap
.rows
.iter()
.flat_map(|r| r.glyphs.iter())
.find(|g| g.ch == ']')
.expect("a closing bracket");
assert_eq!(
src[close.src..].chars().next(),
Some(']'),
"the bracket glyph should stand on the source's own `]`"
);
}
#[test]
fn build_cached_matches_build() {
let docs = [
"# Title\n\nThe quick brown fox.\n\nAnother paragraph here.\n",
"## H\n\n- one\n- two\n- three\n\n> a quote\n> continued\n",
"para one\n\n```\ncode\nlines\n```\n\nafter code\n",
"| a | b |\n|---|---|\n| 1 | 2 |\n\ntext after a table\n",
"line\n- \nsetext?\n\nreal para\n\n\n\ntrailing blanks\n",
"> quote with **bold** and a [link](https://x.dev)\n>\n> - item\n> - item2\n\ntail\n",
"intro\n\n\n\nbetween\n\n\n\nend\n",
"- text item\n- \n- more text\n",
"A claim[^1] and another[^src].\n\n[^1]: First note.\n\n[^src]: Second.\n\ntail\n",
"note[^a]\n\n[^a]: body **bold**\n wrapped on\n three lines\n\nafter\n",
"# Title\n\nThe quick brown fox.\n\nA tail with no newline",
"A claim[^1] and another[^src].\n\n[^1]: First note.\n[^src]: Second, ending the file.",
"<!-- lead -->\n\npara\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n\n<!-- trail -->\n",
"above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
"above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n",
"see [a] and [b]\n\n[a]: /a\n\nmid\n[b]: /b \"bee\"\n\nend [c]\n\n<!-- links -->\n[c]: /c\n",
];
for wrap in [None, Some(80usize), Some(20)] {
for src in docs {
let ctx = format!("wrap={wrap:?} src={src:?}");
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let mut cache = BlockCache::default();
let (plain, cached) = render_both(&mut ed, src, wrap, &mut cache);
assert_maps_eq(&plain, &cached, &format!("fresh {ctx}"));
let at = (src.len() / 2..=src.len())
.find(|&i| src.is_char_boundary(i))
.unwrap();
ed.edit_range(at, at, "Z").unwrap();
let src2 = ed.source_str().unwrap();
let (plain2, cached2) = render_both(&mut ed, &src2, wrap, &mut cache);
assert_maps_eq(&plain2, &cached2, &format!("after insert {ctx}"));
ed.edit_range(at, at + 1, "").unwrap();
let src3 = ed.source_str().unwrap();
let (plain3, cached3) = render_both(&mut ed, &src3, wrap, &mut cache);
assert_maps_eq(&plain3, &cached3, &format!("after delete {ctx}"));
}
}
}
#[test]
fn a_block_running_past_the_last_byte_still_keys_the_cache_by_its_own_bytes() {
let src = "A claim[^1] worth checking.\n\n# A heading with a reference[^1] in it\n\n[^1]: The first note.\n[^note]: A note with a word for a label.";
let mut ed = Editor::new_str(src, Format::Djot).unwrap();
let (plain, cached) = render_both(&mut ed, src, Some(80), &mut BlockCache::default());
assert_maps_eq(&plain, &cached, "a definition ending the file");
let text = rendered(&cached);
assert!(
text.ends_with("[note] A note with a word for a label."),
"the last definition should render itself: {text:?}"
);
assert_eq!(
text.matches("A heading with a reference").count(),
1,
"the heading should render exactly once: {text:?}"
);
let mut cache = BlockCache::default();
let first = "first para\n\n# A heading\n\nlast para with no newline";
let mut ed = Editor::new_str(first, Format::Djot).unwrap();
let (_, warm) = render_both(&mut ed, first, Some(80), &mut cache);
assert!(rendered(&warm).ends_with("last para with no newline"));
let second = "first para\n\n# A heading\n\nDIFFERENT text without a newline";
let mut ed = Editor::new_str(second, Format::Djot).unwrap();
let (plain, cached) = render_both(&mut ed, second, Some(80), &mut cache);
assert_maps_eq(&plain, &cached, "edited last block, warm cache");
let text = rendered(&cached);
assert!(
text.ends_with("DIFFERENT text without a newline"),
"the warm cache served the pre-edit rows: {text:?}"
);
}
#[test]
fn resolves_markup_to_plain_text() {
let text = rendered(&map("# Title\n\na **bold** word\n"));
assert!(!text.contains('#'), "heading marker shown: {text:?}");
assert!(!text.contains("**"), "strong delimiters shown: {text:?}");
assert!(text.contains("Title") && text.contains("bold word"));
}
#[test]
fn every_glyph_points_at_its_source_byte() {
let src = "a **bold** c\n";
let m = map(src);
for row in &m.rows {
for g in &row.glyphs {
if g.src < src.len()
&& src.is_char_boundary(g.src)
&& let Some(sc) = src[g.src..].chars().next()
&& sc == g.ch
{
continue;
}
panic!("glyph {:?} at src {} doesn't match source", g.ch, g.src);
}
}
}
#[test]
fn offset_and_position_round_trip_on_visible_text() {
let m = map("hello world\n");
let (r, c) = m.pos_of_offset(6); assert_eq!(m.offset_of_pos(r, c), 6);
}
#[test]
fn visible_utf16_indices_count_the_text_the_system_sees() {
let src = "a **b\u{1F600}** c\n\nd\n";
let m = map(src);
let end = m.snap_to_stop(src.len());
let text = m.visible_text(0, end);
assert_eq!(text, "a b\u{1F600} c\nd");
for (i, (src_off, _)) in m.visible_items(0, end).iter().enumerate() {
let expect: usize = text.chars().take(i).map(char::len_utf16).sum();
assert_eq!(
m.visible_utf16_len(0, *src_off),
expect,
"utf16 index of source offset {src_off}"
);
assert_eq!(m.offset_at_visible_utf16(end, expect), Some(*src_off));
}
let emoji_src = src.find('\u{1F600}').unwrap();
let emoji_idx = m.visible_utf16_len(0, emoji_src);
assert_eq!(
m.offset_at_visible_utf16(end, emoji_idx + 1),
Some(emoji_src)
);
let total = m.visible_utf16_len(0, end);
assert_eq!(total, text.encode_utf16().count());
assert_eq!(m.offset_at_visible_utf16(end, total), None);
}
#[test]
fn visible_text_spends_exactly_one_character_on_every_stop() {
let src = "- one\n- two\n\n| a | b |\n| - | - |\n| c | d |\n\n```\nx\ny\n```\n\nend\n";
let m = map(src);
let end = m.snap_to_stop(src.len());
assert_eq!(m.visible_text(0, end), "one\ntwo\na\nb\nc\nd\n\nx\ny\nend");
let first = m.snap_to_glyph_stop(0);
let stops: Vec<usize> = std::iter::successors(Some(first), |&o| m.stop_after(o)).collect();
for (i, &a) in stops.iter().enumerate() {
for (j, &b) in stops.iter().enumerate().skip(i) {
assert_eq!(
m.visible_text(a, b).chars().count(),
j - i,
"text between stops {a} and {b}"
);
}
}
let a_end = src.find("a |").unwrap() + 1;
assert_eq!(m.visible_text(a_end, a_end + 1), "\n");
}
#[test]
fn unwrapped_mode_emits_one_row_per_paragraph() {
let long = "one two three four five six seven eight nine ten eleven twelve\n";
let mut ed = Editor::new_str(long, Format::Markdown).unwrap();
let wrapped = build_t(&ed.nodes().unwrap(), long, Some(12));
let unwrapped = build_t(&ed.nodes().unwrap(), long, None);
assert!(wrapped.num_rows() > 1, "narrow column should wrap");
assert_eq!(unwrapped.num_rows(), 1, "no budget should keep it one row");
let text: String = unwrapped.rows[0].glyphs.iter().map(|g| g.ch).collect();
assert_eq!(text.trim_end(), long.trim_end());
}
fn line_texts(m: &VisualMap) -> Vec<String> {
m.rows
.iter()
.map(|r| {
r.glyphs
.iter()
.map(|g| g.ch)
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
#[test]
fn preserve_lays_each_soft_break_on_its_own_row() {
let src = "one two\nthree four\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let folded = build_t(&ed.nodes().unwrap(), src, None);
assert_eq!(folded.num_rows(), 1, "fold: one reflowed row");
assert_eq!(
line_texts(&folded),
vec!["one two three four"],
"break folded to a space"
);
let kept = map_preserve(src, None);
assert_eq!(
line_texts(&kept),
vec!["one two", "three four"],
"preserve: a row per line"
);
}
#[test]
fn a_preserved_break_keeps_the_newline_offset_as_a_caret_stop() {
let src = "one two\nthree four\n";
let m = map_preserve(src, None);
assert!(
!m.rows[0].glyphs.iter().any(|g| g.ch == '\n'),
"the break glyph is dropped"
);
assert_eq!(
m.rows[0].end_src, 7,
"the first row ends at the newline byte"
);
assert!(m.is_stop(7), "the newline offset is a caret stop");
let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
assert!(
offs.windows(2).all(|w| w[0] < w[1]),
"offsets not unique: {offs:?}"
);
}
#[test]
fn preserved_lines_wrap_independently() {
let src = "alpha beta gamma\ndelta epsilon\n";
let m = map_preserve(src, Some(12));
assert_eq!(
line_texts(&m),
vec!["alpha beta", "gamma", "delta", "epsilon"],
"each source line wraps on its own"
);
}
#[test]
fn an_empty_paragraph_between_blocks_renders_its_own_rows() {
let m = map("A\n\n\n\nB\n");
let text: Vec<String> = m
.rows
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect())
.collect();
assert_eq!(text, vec!["A", "", "", "", "B"], "got {text:?}");
let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
assert!(
offs.windows(2).all(|w| w[0] < w[1]),
"offsets not unique: {offs:?}"
);
}
#[test]
fn a_tight_block_boundary_still_gets_one_separator() {
let m = map("# H\ntext\n");
let text: Vec<String> = m
.rows
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect())
.collect();
assert_eq!(text, vec!["H", "", "text"], "got {text:?}");
}
#[test]
fn an_escaped_delimiter_renders_without_its_backslash_and_maps_true_offsets() {
let m = map("a\\*b\n");
let row: Vec<(char, usize)> = m.rows[0].glyphs.iter().map(|g| (g.ch, g.src)).collect();
assert_eq!(row, vec![('a', 0), ('*', 2), ('b', 3)], "got {row:?}");
}
#[test]
fn an_escaped_hash_stays_a_paragraph_and_shows_the_hash() {
let m = map("\\# hi\n");
let text: String = m.rows[0].glyphs.iter().map(|g| g.ch).collect();
assert_eq!(text, "# hi");
assert_eq!(
m.rows[0].glyphs[0].src, 1,
"the # is at source byte 1, past the \\"
);
}
#[test]
fn a_tight_nested_list_hangs_its_sublist_directly_under_the_item() {
assert_eq!(rendered(&map("- a\n - b\n")), "• a\n • b");
}
#[test]
fn a_loose_nested_list_keeps_its_real_blank_line() {
assert_eq!(rendered(&map("- a\n\n - b\n")), "• a\n \n • b");
}
#[test]
fn frontmatter_is_hidden_and_the_document_opens_into_its_content() {
let fm = "---\nconfig: prov.yaml\ncontents:\n- '[Sample](sample.md)'\n---\n";
let src = format!("{fm}# leaf\n\nA line.\n");
let m = map(&src);
let text = rendered(&m);
assert!(
!text.contains("config"),
"frontmatter body leaked: {text:?}"
);
assert!(!text.contains("prov"), "frontmatter body leaked: {text:?}");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"leaf"
);
assert_eq!(
m.content_start,
fm.len(),
"floor should be the first real block"
);
}
#[test]
fn a_frontmatter_only_document_puts_the_floor_after_the_frontmatter() {
let src = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
let m = map(src);
assert_eq!(m.content_start, src.len(), "floor must clear the metadata");
assert!(
m.rows.is_empty(),
"frontmatter must render no rows: {:?}",
rendered(&m)
);
assert!(
m.stops.is_empty(),
"no stop may sit inside the metadata: {:?}",
m.stops
);
}
#[test]
fn a_frontmatter_only_document_still_counts_its_real_blank_lines() {
let fm = "---\ntitle: n\n---\n";
let m = map(&format!("{fm}\n\n"));
assert_eq!(m.content_start, fm.len());
assert_eq!(m.rows.len(), 2, "the two trailing newlines each open a row");
assert!(
m.rows.iter().all(|r| r.end_src > fm.len()),
"rows must sit past the frontmatter"
);
}
#[test]
fn a_document_without_frontmatter_has_a_zero_floor() {
let m = map("# leaf\n\nbody\n");
assert_eq!(m.content_start, 0);
}
#[test]
fn trailing_spaces_become_caret_stops_so_the_caret_can_be_drawn_past_them() {
let m = map("hello \n");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"hello "
);
assert_eq!(
m.rows[0].end_src, 6,
"the row now ends past the trailing space"
);
assert_eq!(m.pos_of_offset(5), (0, 5), "between 'o' and the space");
assert_eq!(m.pos_of_offset(6), (0, 6), "past the space");
let m = map("hello \n");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"hello "
);
assert_eq!(m.pos_of_offset(7), (0, 7));
}
#[test]
fn a_headings_trailing_space_is_a_caret_stop_too() {
let m = map("# hi \n");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"hi "
);
assert_eq!(m.pos_of_offset(5), (0, 3));
}
#[test]
fn a_table_cells_trailing_padding_is_not_mistaken_for_block_trailing_space() {
let text = rendered(&map(TABLE));
assert!(
text.contains("│ Pear │ 3 │"),
"cell padding disturbed:\n{text}"
);
}
#[test]
fn a_click_below_the_last_row_lands_on_the_last_stop_not_offset_zero() {
let fm = "---\ntitle: n\n---\n";
let m = map(&format!("{fm}# Hi\n\nbody\n"));
let below = m.num_rows() + 5;
let off = m.offset_of_pos(below, 0);
assert!(
m.is_stop(off),
"offset {off} from a below-content click is not a stop"
);
assert_eq!(
off,
m.stops.last().copied().unwrap(),
"should be the document's last stop"
);
assert!(
off > fm.len(),
"must not fall onto the hidden frontmatter floor"
);
}
#[test]
fn offset_of_pos_is_a_stop_for_every_row_including_past_the_end() {
for src in [
"hello \n",
"# A heading here \n\nbody text goes on \n",
"---\nk: v\n---\n# Title\n\nprose here that wraps a bit \n",
] {
let m = map(src);
for row in 0..m.num_rows() + 3 {
for col in 0..30 {
let off = m.offset_of_pos(row, col);
assert!(
m.is_stop(off),
"row {row} col {col} → {off} is not a stop in {src:?}"
);
}
}
}
}
const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
#[test]
fn a_table_renders_as_an_aligned_grid() {
let text = rendered(&map(TABLE));
assert_eq!(
text,
"┌──────┬─────┐\n\
│ Name │ Qty │\n\
├──────┼─────┤\n\
│ Pear │ 3 │\n\
│ Fig │ 12 │\n\
└──────┴─────┘",
"got:\n{text}"
);
}
#[test]
fn table_columns_honour_their_alignment() {
let text = rendered(&map("| A | Bee |\n| --- | :---: |\n| x | y |\n"));
assert!(text.contains("│ x │ y │"), "centred column: {text:?}");
}
#[test]
fn table_borders_are_decoration_the_caret_never_lands_on() {
let m = map(TABLE);
for r in [0, 2] {
assert!(m.rows[r].decoration, "row {r} should be a decoration rule");
assert!(
!m.rows[r].glyphs.iter().any(|g| g.stop),
"row {r} has a stop"
);
}
let bottom = &m.rows[5];
assert!(
!bottom.decoration,
"the bottom border holds the trailing stop"
);
assert!(
!bottom.glyphs.iter().any(|g| g.stop),
"the bottom border's glyphs are not stops"
);
assert!(m.is_stop(bottom.end_src), "the trailing stop is a stop");
assert!(m.table_end_stop(bottom.end_src));
assert_eq!(
bottom.end_src,
TABLE.trim_end_matches('\n').len(),
"the trailing stop is the table's own end, before its newline"
);
assert!(
!m.table_end_stop(TABLE.rfind("12").unwrap() + 2),
"a cell's end is not the trailing stop"
);
let header = &m.rows[1];
assert!(!header.decoration);
for g in &header.glyphs {
if g.ch == '│' {
assert!(!g.stop, "a border is not a caret stop");
}
}
let stops: String = header
.glyphs
.iter()
.filter(|g| g.stop)
.map(|g| g.ch)
.collect();
assert_eq!(stops, "Name Qty ", "cell text plus one end-stop space each");
}
#[test]
fn a_cell_maps_to_its_own_source_text() {
let m = map(TABLE);
let pear = TABLE.find("Pear").unwrap();
let (r, c) = m.pos_of_offset(pear);
assert_eq!(m.rows[r].glyphs[c].ch, 'P');
assert_eq!(m.offset_of_pos(r, c), pear, "round trips");
}
#[test]
fn a_wide_table_is_cut_to_fit_and_its_cells_wrap() {
let src = "| Ingredient | Notes |\n|---|---:|\n\
| flour milled coarse | sift it twice |\n| salt | a pinch |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(30));
let text = rendered(&m);
assert_eq!(
text,
"┌──────────────┬─────────────┐\n\
│ Ingredient │ Notes │\n\
├──────────────┼─────────────┤\n\
│ flour milled │ sift it │\n\
│ coarse │ twice │\n\
│ salt │ a pinch │\n\
└──────────────┴─────────────┘",
"got:\n{text}"
);
for (r, row) in m.rows.iter().enumerate() {
assert!(
row.glyphs.len() <= 30,
"row {r} overflows: {}",
row.glyphs.len()
);
}
}
#[test]
fn a_column_too_narrow_for_a_word_breaks_it_rather_than_spilling() {
let src = "| A | B |\n|---|---|\n| antidisestablishmentarianism | x |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(20));
for (r, row) in m.rows.iter().enumerate() {
assert!(
row.glyphs.len() <= 20,
"row {r} overflows: {}",
row.glyphs.len()
);
}
let word = "antidisestablishmentarianism";
let at = src.find(word).unwrap();
for (i, ch) in word.char_indices() {
assert!(
m.rows
.iter()
.flat_map(|r| r.glyphs.iter())
.any(|g| g.stop && g.src == at + i && g.ch == ch),
"{ch:?} at {} was lost to the break",
at + i
);
}
}
#[test]
fn a_code_block_maps_each_line_to_its_own_source_text() {
let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
let m = map(src);
for row in &m.rows {
for g in row.glyphs.iter().filter(|g| g.stop) {
assert_eq!(
src[g.src..].chars().next(),
Some(g.ch),
"glyph {:?} at {} isn't the source byte it claims",
g.ch,
g.src
);
}
}
}
#[test]
fn an_indented_code_block_maps_past_its_stripped_indent() {
let src = " indented\n code\n";
let m = map(src);
let stops: Vec<(char, usize)> = m
.rows
.iter()
.flat_map(|r| r.glyphs.iter().filter(|g| g.stop).map(|g| (g.ch, g.src)))
.collect();
assert_eq!(
stops[0],
('i', 4),
"first line should start past the indent"
);
assert!(
stops.contains(&('c', 17)),
"second line misplaced: {stops:?}"
);
}
#[test]
fn a_fenced_block_whose_code_echoes_its_info_string_maps_to_the_code() {
let src = "```rust\nrust\n```\n";
let m = map(src);
let first = m.rows[0].glyphs.iter().find(|g| g.stop).unwrap();
assert_eq!(first.src, 8, "matched the info string, not the code");
}
#[test]
fn a_code_block_carries_no_gutter_and_is_published_as_a_row_span() {
let src = "para\n\n```\ncode\nlines\n```\n\nafter\n";
let m = map(src);
assert_eq!(m.code_blocks.len(), 1, "one code block");
let span = m.code_blocks[0].rows_span.clone();
let rows: Vec<String> = m.rows[span.clone()]
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect())
.collect();
assert_eq!(rows, vec!["code".to_string(), "lines".to_string()]);
assert!(!rendered(&m).contains('▏'), "gutter still drawn");
assert!(
m.rows[span].iter().all(|r| r.code),
"every row in the span is flagged code"
);
}
#[test]
fn an_empty_last_line_in_a_code_block_is_a_row_of_its_own() {
let src = "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n";
let m = map(src);
let span = m.code_blocks[0].rows_span.clone();
let rows: Vec<String> = m.rows[span.clone()]
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect())
.collect();
assert_eq!(
rows,
vec!["alpha".to_string(), "beta".to_string(), String::new()],
"the empty last line gets a row"
);
assert!(
m.rows[span.clone()].iter().all(|r| r.code),
"the empty row is flagged code like the rest of the block"
);
let empty = span.end - 1;
assert_eq!(
m.rows[empty].end_src,
src.find("beta\n\n").unwrap() + "beta\n".len(),
"the empty row maps to the line the Return opened"
);
assert_eq!(
map("```\nalpha\nbeta\n```\n").code_blocks[0]
.rows_span
.len(),
2,
"a block that ends at its last code line keeps two rows"
);
assert_eq!(
map("```\nalpha\n\n\n```\n").code_blocks[0].rows_span.len(),
3,
"two trailing empty lines are two rows"
);
}
#[test]
fn a_directive_container_is_tinted_and_labeled_on_its_first_row() {
let src = ":::vis{.public .family}\nhello\n\nworld\n:::\nafter\n";
let m = map_directives(src);
let content_rows: Vec<usize> = (0..m.rows.len()).filter(|&i| m.rows[i].directive).collect();
assert!(!content_rows.is_empty(), "some row is flagged directive");
let after_rows: Vec<usize> = (0..m.rows.len())
.filter(|&i| !content_rows.contains(&i) && !m.rows[i].glyphs.is_empty())
.collect();
assert!(
after_rows.iter().all(|&i| !m.rows[i].directive),
"content outside the fence isn't tinted"
);
let labels: Vec<&str> = content_rows
.iter()
.filter_map(|&i| m.rows[i].directive_label.as_deref())
.collect();
assert_eq!(
labels,
vec!["public family"],
"only the first row carries the label"
);
assert_eq!(
rendered(&m)
.lines()
.filter(|l| !l.is_empty())
.collect::<Vec<_>>(),
vec!["hello", "world", "after"],
"fence markers don't leak into the rendered text"
);
}
#[test]
fn a_bare_word_directive_is_labeled_same_as_dot_classes() {
let src = ":::vis{public family}\nhello\n:::\n";
let m = map_directives(src);
let label = m.rows.iter().find_map(|r| r.directive_label.clone());
assert_eq!(label.as_deref(), Some("public family"));
}
#[test]
fn a_text_directive_keeps_its_paragraph_visible() {
let src = "Text with :abbr[HTML]{title=\"HyperText\"} inline.\n";
let m = map_directives(src);
assert_eq!(rendered(&m).trim_end(), "Text with HTML inline.");
let stops: usize = m
.rows
.iter()
.map(|r| r.glyphs.iter().filter(|g| g.stop).count())
.sum();
assert_eq!(stops, "Text with HTML inline.".chars().count());
assert!(m.rows.iter().all(|r| !r.directive));
}
#[test]
fn a_text_directives_label_maps_to_its_true_source_bytes() {
let src = "x :abbr[HTML]{title=\"y\"} z\n";
let m = map_directives(src);
let stops: Vec<(char, usize)> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop)
.map(|g| (g.ch, g.src))
.collect();
assert_eq!(
stops,
[
('x', 0),
(' ', 1),
('H', 8),
('T', 9),
('M', 10),
('L', 11),
(' ', 24),
('z', 25)
]
);
}
#[test]
fn every_glyph_in_a_directive_label_points_at_its_source_byte() {
let src = "x :abbr[a *b* c] y and :vis[family only] z\n";
let m = map_directives(src);
for g in m.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.stop) {
let at = src[g.src..].chars().next();
assert_eq!(
at,
Some(g.ch),
"glyph {:?} claims byte {}, which is {at:?}",
g.ch,
g.src
);
}
assert_eq!(rendered(&m).trim_end(), "x a b c y and family only z");
}
#[test]
fn a_directive_labels_nested_emphasis_keeps_both_its_style_and_its_offsets() {
let src = "x :abbr[a *b* c] y\n";
let m = map_directives(src);
let b = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.find(|g| g.ch == 'b')
.expect("the emphasised char");
assert!(b.style.italic, "the label's *b* lost its emphasis");
assert_eq!(b.src, 11, "the label's *b* lost its source byte");
}
#[test]
fn a_bare_colon_word_renders_as_the_prose_it_almost_always_is() {
for src in ["a :word b\n", "note :see below\n", ":smile: hi\n"] {
let m = map_directives(src);
assert_eq!(
rendered(&m).trim_end(),
src.trim_end(),
"prose was eaten: {src:?}"
);
}
}
#[test]
fn a_bare_colon_word_keeps_every_byte_a_caret_stop() {
let src = "a :word b\n";
let m = map_directives(src);
let stops: Vec<(char, usize)> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop)
.map(|g| (g.ch, g.src))
.collect();
assert_eq!(
stops,
"a :word b"
.chars()
.enumerate()
.map(|(i, c)| (c, i))
.collect::<Vec<_>>()
);
}
#[test]
fn an_attribute_bearing_text_directive_draws_a_chip() {
for src in ["a :vis{.family} b\n", "a :vis{family} b\n"] {
let m = map_directives(src);
assert_eq!(rendered(&m).trim_end(), "a ⧉ vis family b", "{src:?}");
}
let m = map_directives("a :foo{title=\"x\"} b\n");
assert_eq!(rendered(&m).trim_end(), "a ⧉ foo b");
}
#[test]
fn a_directive_chip_is_one_atomic_caret_stop_at_its_own_offset() {
let src = "a :vis{.family} b\n";
let m = map_directives(src);
let stops: Vec<usize> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop)
.map(|g| g.src)
.collect();
assert_eq!(stops, [0, 1, 2, 15, 16]);
}
#[test]
fn a_paragraph_holding_only_a_chip_is_still_navigable() {
let m = map_directives(":vis{.family}\n");
assert!(
m.row_is_navigable(0),
"a chip-only paragraph has no caret home"
);
assert_eq!(
m.offset_of_pos(0, 0),
0,
"its caret home isn't the directive's start"
);
}
#[test]
fn a_ratio_or_a_clock_time_is_never_a_directive() {
let src = "ratio 3:4 and 10:30\n";
assert_eq!(
rendered(&map_directives(src)).trim_end(),
"ratio 3:4 and 10:30"
);
}
#[test]
fn a_leaf_directive_is_a_placeholder_row_with_its_attrs_published() {
let src = "before\n\n::embed{src=\"demo.html\" height=\"400\"}\n\nafter\n";
let m = map_directives(src);
let row = m
.rows
.iter()
.position(|r| r.leaf_directive.is_some())
.expect("a placeholder row");
assert_eq!(
m.rows[row].glyphs.iter().map(|g| g.ch).collect::<String>(),
"⧉ embed"
);
assert!(
m.rows[row].glyphs.iter().any(|g| g.stop),
"the caret can land on it"
);
assert!(
m.rows[row].directive,
"a frontend frames it like the container form"
);
assert_eq!(m.directives.len(), 1);
let info = &m.directives[0];
assert_eq!(info.name, "embed");
assert_eq!(info.rows_span, row..row + 1);
assert_eq!(info.attr("src"), Some("demo.html"));
assert_eq!(info.attr("height"), Some("400"));
assert_eq!(info.attr("nope"), None);
assert!(rendered(&m).contains("before") && rendered(&m).contains("after"));
}
#[test]
fn a_leaf_directive_shows_its_label_and_honours_its_prefix() {
let m = map_directives("::embed[Audience demo]{src=\"demo.html\"}\n");
assert_eq!(rendered(&m).trim_end(), "⧉ Audience demo");
assert_eq!(m.directives[0].label, "Audience demo");
let quoted = map_directives("> ::embed{src=\"x.html\"}\n");
assert_eq!(rendered("ed).trim_end(), "│ ⧉ embed");
assert_eq!(quoted.directives[0].name, "embed");
}
#[test]
fn a_container_directive_is_still_a_panel_not_a_placeholder() {
let m = map_directives(":::note{.warning}\nBody\n:::\n");
assert!(
m.directives.is_empty(),
"a container publishes no placeholder"
);
assert!(m.rows.iter().all(|r| r.leaf_directive.is_none()));
assert_eq!(rendered(&m).trim_end(), "Body");
assert!(
m.rows
.iter()
.any(|r| r.directive && r.directive_label.as_deref() == Some("warning"))
);
}
fn doc_built(src: &str) -> crate::Doc {
let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
doc.build_visual(80);
doc
}
fn containers(src: &str) -> Vec<(String, bool, Option<DirectiveForm>)> {
let mut ed = Editor::new_ext(
src.as_bytes(),
Format::Markdown,
twig::MarkdownExtensions {
directives: true,
html_elements: true,
..Default::default()
},
)
.unwrap();
ed.nodes()
.unwrap()
.iter()
.filter(|n| n.kind == Kind::Container)
.map(|n| {
(
n.name.clone().unwrap_or_default(),
container_is_directive(n),
n.directive_form,
)
})
.collect()
}
#[test]
fn a_directive_and_an_html_element_are_told_apart_by_spelling_not_by_form() {
for (src, name, want) in [
(":::note{.a}\nbody\n:::\n", "note", true),
("::embed{src=x}\n", "embed", true),
("a :vis[hi]{.b} b\n", "vis", true),
("<div class=\"x\">\nhi\n</div>\n", "div", false),
("<video src=\"v.mp4\" controls></video>\n", "video", false),
("<audio src=\"a.mp3\" controls></audio>\n", "audio", false),
("<figure>\n\nhi\n\n</figure>\n", "figure", false),
(
"<video src=\"http://x.test/v.mp4\" controls></video>\n",
"video",
false,
),
(
"<source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\n",
"source",
false,
),
] {
let found = containers(src);
let hit = found.iter().find(|(n, ..)| n == name);
let Some((_, is_directive, form)) = hit else {
panic!("no `{name}` container in {src:?} — found {found:?}");
};
assert_eq!(*is_directive, want, "{name} in {src:?} (form was {form:?})");
}
let div = containers("<div class=\"x\">\nhi\n</div>\n");
let note = containers(":::note{.a}\nbody\n:::\n");
assert_eq!(
div[0].2, note[0].2,
"if these ever differ, `directive_form` became usable and this rule can go"
);
}
#[test]
fn a_directive_nested_in_a_quote_or_list_is_still_a_directive() {
for (src, ctx) in [
("> ::embed{src=\"x\"}\n", "quoted"),
("- ::embed{src=\"x\"}\n", "listed"),
(">> ::embed{src=\"x\"}\n", "twice quoted"),
] {
let m = map_directives(src);
assert_eq!(m.directives.len(), 1, "{ctx} directive was lost");
assert_eq!(m.directives[0].name, "embed", "{ctx}");
}
}
#[test]
fn a_video_is_still_media_and_not_a_directive() {
let doc = doc_built("<video src=\"clip.mp4\" controls></video>\n");
assert_eq!(doc.vmap.media.len(), 1, "the video is block media");
assert!(
doc.vmap.rows.iter().all(|r| !r.directive),
"the video drew directive chrome"
);
}
#[test]
fn a_directive_needs_the_extension_flag() {
let src = ":::vis{.public}\nhello\n:::\n";
let m = map(src);
assert!(m.rows.iter().all(|r| !r.directive));
assert!(rendered(&m).contains(":::vis{.public}"));
}
#[test]
fn a_footnote_reference_keeps_its_paragraph_visible() {
let src = "A claim[^1] and more.\n";
let m = map(src);
assert_eq!(rendered(&m).trim_end(), "A claim[1] and more.");
assert!(!rendered(&m).contains('^'));
}
#[test]
fn a_footnote_reference_is_raised_and_the_prose_around_it_is_not() {
let m = map("A claim[^1] and more.\n");
assert_eq!(baselines_of(&m, '1'), vec![Baseline::Super]);
assert_eq!(baselines_of(&m, '['), vec![Baseline::Super]);
assert_eq!(baselines_of(&m, ']'), vec![Baseline::Super]);
assert_eq!(baselines_of(&m, 'A'), vec![Baseline::Normal]);
}
#[test]
fn a_footnote_reference_keeps_the_link_role_it_had() {
let m = map("A claim[^1].\n");
let label = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.find(|g| g.ch == '1')
.unwrap();
assert_eq!(label.style.role, Role::Link);
assert_eq!(label.style.baseline, Baseline::Super);
}
fn role_of(m: &VisualMap, ch: char) -> Role {
m.rows
.iter()
.flat_map(|r| r.glyphs.iter())
.find(|g| g.ch == ch)
.unwrap_or_else(|| panic!("no glyph spelling {ch:?}"))
.style
.role
}
#[test]
fn a_markdown_highlight_is_a_mark_and_a_coloured_one_names_its_colour() {
let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
assert_eq!(role_of(&doc.vmap, 'y'), Role::Mark(None));
assert_eq!(
role_of(&doc.vmap, 'r'),
Role::Mark(Some(MarkColor::Red)),
"the `data-color` twig stripped the emoji into"
);
assert_eq!(role_of(&doc.vmap, 'P'), Role::Body);
}
#[test]
fn the_emoji_that_named_a_highlight_is_markup_and_never_drawn() {
let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
let drawn: String = doc.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
assert_eq!(drawn, "Plain yes and red ok");
}
#[test]
fn a_superscript_and_a_subscript_sit_off_the_baseline() {
let m = map_djot("H~2~O and x^2^\n");
assert_eq!(baselines_of(&m, '2'), vec![Baseline::Sub, Baseline::Super]);
assert_eq!(baselines_of(&m, 'H'), vec![Baseline::Normal]);
assert_eq!(baselines_of(&m, 'O'), vec![Baseline::Normal]);
}
#[test]
fn a_raised_glyph_keeps_the_style_it_was_raised_out_of() {
let m = map_djot("# Heading x^2^\n");
let two = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.find(|g| g.ch == '2')
.unwrap();
assert_eq!(two.style.baseline, Baseline::Super);
assert_eq!(two.style.role, Role::Heading(1), "still heading text");
}
#[test]
fn a_footnote_references_brackets_are_decoration_and_only_its_label_is_a_stop() {
let src = "see[^note] here\n";
let m = map(src);
let stops: Vec<usize> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop)
.map(|g| g.src)
.collect();
for off in 5..9 {
assert!(
stops.contains(&off),
"label byte {off} isn't a caret stop: {stops:?}"
);
}
for off in [3usize, 4, 9] {
assert!(
!stops.contains(&off),
"delimiter byte {off} is a caret stop: {stops:?}"
);
}
}
#[test]
fn a_task_item_draws_its_box_where_the_bullet_would_be() {
let m = map("- [ ] todo\n- [x] done\n- plain\n");
assert_eq!(rendered(&m), "☐ todo\n☑ done\n• plain");
let ticks: Vec<Option<bool>> = m.rows.iter().map(|r| r.task).collect();
assert_eq!(ticks, [Some(false), Some(true), None]);
}
#[test]
fn a_task_items_box_survives_a_wrap_and_marks_only_the_first_row() {
let m = map_at(
"- [x] a much longer task that has to wrap somewhere\n",
Some(20),
);
assert!(m.rows.len() > 1, "the item should wrap: {:?}", rendered(&m));
assert_eq!(m.rows[0].task, Some(true));
assert!(
m.rows[1..].iter().all(|r| r.task.is_none()),
"only the first row"
);
assert!(
rendered(&m)
.lines()
.nth(1)
.is_some_and(|l| l.starts_with(" "))
);
}
#[test]
fn a_bracket_in_an_items_prose_is_not_a_checkbox() {
let m = map("- see [1] below\n");
assert_eq!(rendered(&m), "• see [1] below");
assert_eq!(m.rows[0].task, None);
}
#[test]
fn a_footnote_definition_renders_where_it_was_written() {
let src = "A claim[^1].\n\n[^1]: The note body.\n\nAfter.\n";
let m = map(src);
let text = rendered(&m);
assert!(
text.contains("The note body."),
"the note body is invisible: {text:?}"
);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines, ["A claim[1].", "[1] The note body.", "After."]);
}
#[test]
fn a_footnote_definitions_body_maps_to_its_own_source_bytes() {
let src = "x[^a].\n\n[^a]: body\n";
let m = map(src);
let body: Vec<(char, usize)> = m
.rows
.iter()
.flat_map(|r| &r.glyphs)
.filter(|g| g.stop && g.src >= 14)
.map(|g| (g.ch, g.src))
.collect();
assert_eq!(body, [('b', 14), ('o', 15), ('d', 16), ('y', 17)]);
}
#[test]
fn an_empty_footnote_definition_still_shows_its_marker() {
let src = "x[^1]\n\n[^1]:\n";
let m = map(src);
assert!(
rendered(&m).contains("[1] "),
"no marker row: {:?}",
rendered(&m)
);
}
#[test]
fn a_footnote_definition_wearing_a_long_label_indents_its_wrapped_body() {
let src = "x[^src]\n\n[^src]: one two three four five six seven\n";
let m = map_at(src, Some(24));
let text = rendered(&m);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines[1].trim_end(), "[src] one two three four");
assert!(
lines[2].starts_with(" "),
"body doesn't hang: {:?}",
lines[2]
);
assert_eq!(lines[2].trim(), "five six seven");
}
#[test]
fn a_code_block_leaves_exactly_one_blank_row_below_it() {
let src = "para\n\n```\ncode\n```\n\nafter\n";
let m = map(src);
let code_end = m.code_blocks[0].rows_span.end;
let after = m
.rows
.iter()
.position(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "after")
.unwrap();
assert_eq!(
after - code_end,
1,
"exactly one row between code and 'after'"
);
}
#[test]
fn a_fenced_block_publishes_its_language_on_its_code_block() {
assert_eq!(
map("```rust\nlet x = 1;\n```\n").code_blocks[0]
.lang
.as_deref(),
Some("rust")
);
assert_eq!(map("```\nplain\n```\n").code_blocks[0].lang, None);
assert_eq!(map(" indented\n").code_blocks[0].lang, None);
}
fn tokens_of(m: &VisualMap, ch: char) -> Vec<Option<Token>> {
m.rows
.iter()
.flat_map(|r| r.glyphs.iter())
.filter(|g| g.ch == ch)
.map(|g| g.style.token)
.collect()
}
#[cfg(feature = "syntax")]
#[test]
fn a_fenced_block_in_a_known_language_carries_tokens() {
let m = map("```rust\nlet x = \"s\";\n```\n");
assert_eq!(tokens_of(&m, 'l'), vec![Some(Token::Keyword)]);
assert_eq!(tokens_of(&m, 'x'), vec![None]);
assert_eq!(tokens_of(&m, '"'), vec![Some(Token::String); 2]);
assert!(
m.rows
.iter()
.filter(|r| r.code)
.flat_map(|r| r.glyphs.iter())
.all(|g| g.style.role == Role::Code),
"a token replaced the code role"
);
}
#[cfg(feature = "syntax")]
#[test]
fn a_token_changes_nothing_about_where_a_glyph_is() {
let hl = map("```rust\nlet x = 1; // c\nfn f() {}\n```\n");
let plain = map("```text\nlet x = 1; // c\nfn f() {}\n```\n");
assert_eq!(hl.rows.len(), plain.rows.len());
for (a, b) in hl.rows.iter().zip(&plain.rows) {
assert_eq!(a.end_src, b.end_src);
assert_eq!(a.glyphs.len(), b.glyphs.len());
for (ga, gb) in a.glyphs.iter().zip(&b.glyphs) {
assert_eq!((ga.ch, ga.src, ga.stop), (gb.ch, gb.src, gb.stop));
assert_eq!(ga.style.token(None), gb.style);
}
}
assert!(tokens_of(&hl, 'l').iter().any(Option::is_some));
assert!(tokens_of(&plain, 'l').iter().all(Option::is_none));
}
#[test]
fn a_block_with_no_language_to_highlight_in_carries_no_tokens() {
for src in [
"```\nlet x = 1;\n```\n",
" let x = 1;\n",
"```no-such-language\nlet x = 1;\n```\n",
"a `let x` b\n",
] {
assert!(
tokens_of(&map(src), 'l').iter().all(Option::is_none),
"{src:?} was highlighted"
);
}
#[cfg(not(feature = "syntax"))]
assert!(
tokens_of(&map("```rust\nlet x = 1;\n```\n"), 'l')
.iter()
.all(Option::is_none)
);
}
#[test]
fn inline_code_is_not_a_code_block() {
let m = map("a `snippet` b\n");
assert!(m.code_blocks.is_empty(), "inline code wrongly boxed");
assert!(
m.rows.iter().all(|r| !r.code),
"inline code flagged a code row"
);
}
#[test]
fn caret_steps_over_hidden_delimiters() {
let m = map("a **bold** c\n");
let (r, c) = m.pos_of_offset(7);
assert_eq!(m.offset_of_pos(r, c + 1), 10);
}
#[test]
fn a_table_is_published_structurally_beside_its_picture() {
let m = map(TABLE);
let t = &m.tables[0];
let cell = |r: usize, c: usize| -> String {
t.grid[r].cells[c].glyphs.iter().map(|g| g.ch).collect()
};
assert_eq!(t.grid.len(), 3, "head + two body rows");
assert_eq!(
(cell(0, 0), cell(0, 1), cell(1, 0), cell(2, 1)),
("Name".into(), "Qty".into(), "Pear".into(), "12".into())
);
assert_eq!(
t.grid.iter().map(|r| r.head).collect::<Vec<_>>(),
[true, false, false]
);
assert!(matches!(t.grid[1].cells[0].align, Alignment::Left));
assert!(matches!(t.grid[1].cells[1].align, Alignment::Right));
}
#[test]
fn a_block_media_is_published_structurally_beside_its_placeholder() {
let m = map("intro\n\n\n\nend\n");
assert_eq!(m.media.len(), 1, "one block image");
let img = &m.media[0];
assert_eq!(img.destination, "img/cat.png");
assert_eq!(img.alt, "a cat");
let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
assert_eq!(
img.rows_span.end - img.rows_span.start,
1,
"one placeholder row"
);
assert_eq!(row_text(img.rows_span.start), "🖼 a cat");
assert!(m.rows[img.rows_span.start].media.is_some());
}
#[test]
fn an_image_without_alt_labels_itself_with_its_filename() {
let m = map("\n");
let row = &m.rows[m.media[0].rows_span.start];
assert_eq!(
row.glyphs.iter().map(|g| g.ch).collect::<String>(),
"🖼 beach.jpg"
);
assert_eq!(m.media[0].alt, "");
}
#[test]
fn an_empty_cells_home_is_read_from_either_shape_of_span() {
let row = "| | |";
assert_eq!(empty_cell_offset(row, 10, 0), 12);
assert_eq!(empty_cell_offset(row, 10, 1), 15);
assert_eq!(empty_cell_offset("| ", 10, 0), 12);
assert_eq!(empty_cell_offset("| ", 13, 1), 15);
assert_eq!(empty_cell_offset("|", 10, 0), 11);
assert_eq!(empty_cell_offset("", 10, 1), 10);
}
#[test]
fn a_hidden_marks_content_end_is_a_caret_home_but_not_a_glyph_stop() {
let m = map("a **bold** b\n");
assert!(
!m.stops.contains(&8),
"8 has no glyph, so it is no glyph stop"
);
assert_eq!(m.mark_ends, vec![8]);
assert!(m.is_stop(8), "but the caret may rest there");
assert_eq!(m.snap_to_stop(8), 8, "and is left there when placed there");
assert_eq!(m.caret_stop_after(7), Some(8));
assert_eq!(m.caret_stop_after(8), Some(10));
assert_eq!(m.caret_stop_before(10), Some(8));
assert_eq!(m.caret_stop_before(8), Some(7));
assert_eq!(m.stop_after(7), Some(10));
assert_eq!(m.stop_before(10), Some(7));
assert_eq!(m.pos_of_offset(8), m.pos_of_offset(10));
}
#[test]
fn every_hidden_inline_mark_gives_its_content_end_a_home() {
assert_eq!(
map("*em* `code` [link](u) ~~del~~\n").mark_ends,
vec![3, 10, 17, 27]
);
assert_eq!(map("***both***\n").mark_ends, vec![7]);
let m = map("**bold**\n");
assert_eq!(m.mark_ends, vec![6]);
assert!(m.stops.contains(&6));
let mut ed = Editor::new_str("a **bold** b\n", Format::Markdown).unwrap();
let src = "a **bold** b\n";
let revealed = build(
&ed.nodes().unwrap(),
src,
Some(80),
false,
&HashMap::new(),
Some(0..src.len()),
);
assert!(revealed.mark_ends.is_empty());
assert!(revealed.stops.contains(&8));
}
#[test]
fn a_marks_content_end_is_a_home_inside_a_table_cell() {
let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
let m = map(src);
let end = src.find("bold").unwrap() + 4; assert_eq!(m.mark_ends, vec![end]);
assert_eq!(m.snap_to_stop(end), end);
assert_eq!(m.pos_of_offset(end), m.pos_of_offset(end + 2));
}
#[test]
fn a_block_media_gives_the_caret_a_home_before_and_after_it() {
let src = "\n";
let m = map(src);
let img = &m.rows[m.media[0].rows_span.start];
let start = 0; let end = "".len();
assert!(img.glyphs.iter().all(|g| g.src == start && g.stop));
assert_eq!(img.end_src, end, "the row ends past the image");
assert_eq!(m.stops.first(), Some(&start));
assert!(m.stops.contains(&end), "a stop sits after the image");
assert!(!m.stops.iter().any(|&s| s > start && s < end));
}
#[test]
fn an_inline_image_amid_text_is_not_a_block_media() {
let m = map("see  here\n");
assert!(m.media.is_empty(), "not a block image");
assert!(
rendered(&m).contains("a cat"),
"alt text still renders inline"
);
}
fn doc_media(src: &str) -> Vec<MediaInfo> {
let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
doc.build_visual(80);
doc.vmap.media.clone()
}
#[test]
fn a_video_block_is_media_with_its_src_poster_and_kind() {
let m = doc_media("<video src=\"clip.mp4\" poster=\"still.png\" controls>\n</video>\n");
assert_eq!(m.len(), 1, "the video is one block media");
assert_eq!(m[0].kind, MediaKind::Video);
assert_eq!(m[0].destination, "clip.mp4");
assert_eq!(m[0].poster, "still.png");
}
#[test]
fn a_single_line_video_is_a_block_too() {
let m = doc_media("<video src=\"clip.mp4\" controls></video>\n");
assert_eq!(m.len(), 1, "single-line <video> is a block");
assert_eq!(m[0].kind, MediaKind::Video);
assert_eq!(m[0].destination, "clip.mp4");
}
#[test]
fn a_single_line_picture_is_a_block_with_its_alternatives() {
let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
<img src=\"l.svg\" alt=\"banner\"></picture>\n";
let m = doc_media(src);
assert_eq!(m.len(), 1);
assert_eq!(m[0].kind, MediaKind::Image);
assert_eq!(m[0].destination, "l.svg");
assert_eq!(m[0].resolve(ColorScheme::Dark), "d.svg");
}
#[test]
fn an_audio_block_is_media_with_no_poster() {
let m = doc_media("<audio src=\"take.mp3\" controls>\n</audio>\n");
assert_eq!(m.len(), 1);
assert_eq!(m[0].kind, MediaKind::Audio);
assert_eq!(m[0].destination, "take.mp3");
assert!(m[0].poster.is_empty(), "audio has no poster frame");
}
#[test]
fn a_videos_source_children_are_its_candidates_typed_by_mime() {
let src = "<video controls>\n\
<source src=\"a.webm\" type=\"video/webm\">\n\
<source src=\"a.mp4\" type=\"video/mp4\">\n\
fallback\n\
</video>\n";
let m = doc_media(src);
assert_eq!(m.len(), 1);
assert!(
m[0].destination.is_empty(),
"no src attribute on the element"
);
assert_eq!(m[0].sources.len(), 2);
assert_eq!(m[0].sources[0].srcset, "a.webm");
assert_eq!(m[0].sources[0].mime, "video/webm");
assert_eq!(m[0].sources[1].srcset, "a.mp4");
assert_eq!(m[0].resolve(ColorScheme::Light), "a.webm");
}
#[test]
fn a_video_placeholder_row_carries_its_own_sigil_and_mark() {
let src = "<video src=\"clip.mp4\" controls>\n</video>\n";
let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
doc.build_visual(80);
let row = &doc.vmap.rows[doc.vmap.media[0].rows_span.start];
let text: String = row.glyphs.iter().map(|g| g.ch).collect();
assert!(
text.starts_with('🎬'),
"video sigil, not the image one: {text:?}"
);
assert!(row.media.is_some(), "the mark rides the placeholder row");
}
#[test]
fn a_picture_block_carries_its_source_alternatives() {
let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"banner\"></picture>\n";
let images = doc_media(src);
assert_eq!(images.len(), 1, "the picture is one block image");
let img = &images[0];
assert_eq!(img.destination, "light.svg", "fallback is the <img>");
assert_eq!(img.alt, "banner");
assert_eq!(
img.sources,
vec![MediaSource {
media: "(prefers-color-scheme: dark)".into(),
srcset: "dark.svg".into(),
mime: String::new(),
}],
);
}
#[test]
fn a_picture_inside_a_heading_is_still_a_block_media_with_sources() {
let src = "<h1><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"fig\"></picture></h1>\n";
let images = doc_media(src);
assert_eq!(images.len(), 1, "heading-wrapped picture is a block image");
assert_eq!(images[0].destination, "l.svg");
assert_eq!(images[0].sources.len(), 1);
assert_eq!(images[0].sources[0].srcset, "d.svg");
}
#[test]
fn a_plain_image_has_no_media_sources() {
let images = doc_media("\n");
assert_eq!(images.len(), 1);
assert!(
images[0].sources.is_empty(),
"no <picture>, no alternatives"
);
}
#[test]
fn resolve_picks_the_source_matching_the_scheme() {
let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"b\"></picture>\n";
let images = doc_media(src);
let img = &images[0];
assert_eq!(img.resolve(ColorScheme::Dark), "dark.svg");
assert_eq!(img.resolve(ColorScheme::Light), "light.svg");
}
#[test]
fn resolve_falls_back_for_a_plain_image_and_unknown_media() {
let plain = doc_media("\n");
assert_eq!(plain[0].resolve(ColorScheme::Dark), "p.png");
let m = doc_media(
"<picture><source media=\"print\" srcset=\"p.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"l.svg\"><img src=\"f.svg\" alt=\"x\"></picture>\n",
);
assert_eq!(m[0].resolve(ColorScheme::Light), "l.svg");
assert_eq!(
m[0].resolve(ColorScheme::Dark),
"f.svg",
"no dark source → <img>"
);
}
#[test]
fn resolve_reads_the_first_srcset_url_ignoring_descriptors() {
assert_eq!(first_srcset_url("a.png 1x, b.png 2x"), Some("a.png"));
assert_eq!(first_srcset_url(" solo.svg "), Some("solo.svg"));
assert_eq!(first_srcset_url(""), None);
assert!(media_matches("", ColorScheme::Light));
assert!(media_matches(
"(prefers-color-scheme:dark)",
ColorScheme::Dark
));
assert!(!media_matches(
"(prefers-color-scheme: dark)",
ColorScheme::Light
));
}
#[test]
fn a_block_media_carries_its_list_prefix() {
let m = map("- \n");
let row = &m.rows[m.media[0].rows_span.start];
let text: String = row.glyphs.iter().map(|g| g.ch).collect();
assert!(
text.starts_with("• "),
"the list marker prefixes the image row: {text:?}"
);
assert!(text.contains("🖼 alt"));
}
#[test]
fn the_structural_table_spans_exactly_its_drawn_rows() {
let m = map(&format!("before\n\n{TABLE}\nafter\n"));
let t = &m.tables[0];
let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
assert!(
row_text(t.rows_span.start).starts_with('┌'),
"opens on the top border"
);
assert!(
row_text(t.rows_span.end - 1).starts_with('└'),
"closes on the bottom border"
);
assert!(
!row_text(t.rows_span.start - 1).contains('┌'),
"the row before the span is not the table's"
);
assert_eq!(
row_text(t.rows_span.end),
"",
"the span ends before the gap row"
);
}
#[test]
fn a_nested_tables_structure_carries_the_block_prefix() {
let m = map("> | a | b |\n> |---|---|\n> | c | d |\n");
let t = &m.tables[0];
let prefix: String = t.prefix.iter().map(|g| g.ch).collect();
assert_eq!(prefix, "│ ", "the quote's gutter should ride the structure");
let drawn: String = m.rows[t.rows_span.start]
.glyphs
.iter()
.map(|g| g.ch)
.collect();
assert!(
drawn.starts_with(&prefix),
"picture and structure disagree: {drawn:?}"
);
}
#[test]
fn a_top_level_table_carries_no_prefix() {
assert!(map(TABLE).tables[0].prefix.is_empty());
}
#[test]
fn structural_cells_are_unwrapped_even_when_the_picture_wraps_them() {
let src = "| Name |\n|------|\n| alpha beta gamma |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(12));
let drawn = rendered(&m);
let cell: String = m.tables[0].grid[1].cells[0]
.glyphs
.iter()
.map(|g| g.ch)
.collect();
assert_eq!(
cell, "alpha beta gamma",
"structure must not carry the wrap"
);
assert!(
drawn.lines().count() > 5,
"the picture should have wrapped, else this proves nothing:\n{drawn}"
);
}
#[test]
fn a_table_column_is_as_wide_as_its_cells_are_drawn() {
for src in [
"| A | B |\n|---|---|\n| 你好 | y |\n",
"| A | B |\n|---|---|\n| a👨👩👧b | y |\n",
"| A | 漢字 |\n|---|---|\n| x | y |\n",
] {
let m = map(src);
let widths: Vec<usize> = m.rows.iter().map(|r| r.width()).collect();
assert!(
widths.windows(2).all(|w| w[0] == w[1]),
"ragged grid {widths:?} for {src:?}:\n{}",
rendered(&m)
);
}
}
#[test]
fn a_cell_wrapped_narrow_never_breaks_inside_a_character() {
let src = "| A |\n|---|\n| 👨👩👧👨👩👧 |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(8));
let boundaries: Vec<usize> = src
.grapheme_indices(true)
.map(|(i, _)| i)
.chain(std::iter::once(src.len()))
.collect();
for off in (0..=src.len()).filter(|&o| m.is_stop(o)) {
assert!(
boundaries.contains(&off),
"stop at {off} is inside a character:\n{}",
rendered(&m)
);
}
}
#[test]
fn a_wrapped_cell_keeps_every_line_inside_its_column() {
let src = "| A |\n|---|\n| 你好世界漢字 |\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let m = build_t(&ed.nodes().unwrap(), src, Some(14));
for r in &m.rows {
assert_eq!(r.width(), 14, "{:?} is not drawn to the grid", rendered(&m));
}
}
#[test]
fn a_hard_break_falls_between_clusters_and_measures_in_cells() {
let glyphs = |s: &str| {
let mut out = Vec::new();
push_text(&mut out, s, 0, Style::default());
out
};
let piece = |p: &[Glyph]| p.iter().map(|g| g.ch).collect::<String>();
let w = glyphs("你好世");
let pieces: Vec<String> = hard_break(&w, 4).iter().map(|p| piece(p)).collect();
assert_eq!(pieces, ["你好", "世"]);
let w = glyphs("你好");
let pieces: Vec<String> = hard_break(&w, 1).iter().map(|p| piece(p)).collect();
assert_eq!(pieces, ["你", "好"]);
assert!(hard_break(&[], 4).is_empty());
}
#[test]
fn an_empty_list_item_still_gets_a_bulleted_row_with_a_caret_home() {
let m = map("- item\n- \n");
assert_eq!(m.num_rows(), 2, "the empty second item needs its own row");
assert_eq!(
m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
"• ",
"the empty item draws just its bullet",
);
assert!(
m.is_stop(m.rows[1].end_src),
"the empty item's caret home is not a stop"
);
assert_eq!(
m.pos_of_offset(m.rows[1].end_src),
(1, 2),
"caret sits after '• '"
);
}
#[test]
fn a_notes_row_range_stops_at_the_note_even_when_it_ends_in_a_link() {
let src = "A[^1] B[^2].\n\n[^1]: bare text\n\n[^2]: [title](https://example.com/x)\n\n[^3]: last\n";
let m = map(src);
let body = src.find("[title]").unwrap();
let end = src.find("\n\n[^3]").unwrap();
let (first, last) = m.row_range_for(body..end);
assert_eq!(
first, last,
"a one-block note is one row, not a span onto the next"
);
assert_ne!(
m.pos_of_offset(end - 1).0,
last,
"the forward snap still leaves the note's row — that is the whole point",
);
let plain = src.find("bare text").unwrap();
let plain_end = src.find("\n\n[^2]").unwrap();
let (pf, pl) = m.row_range_for(plain..plain_end);
assert_eq!(pf, pl);
assert_eq!(m.pos_of_offset(plain_end - 1).0, pl);
}
#[test]
fn a_row_range_covers_every_row_of_a_block_that_spans_several() {
let src = "> one\n>\n> two\n\nafter\n";
let m = map(src);
let (first, last) = m.row_range_for(0..src.find("\n\nafter").unwrap());
assert_eq!((first, last), (0, 2));
let (f, l) = m.row_range_for(0..1);
assert_eq!((f, l), (0, 0));
}
#[test]
fn an_empty_block_quote_still_gets_a_gutter_row_with_a_caret_home() {
let m = map("a\n\n> \n\nb\n");
assert_eq!(
m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ ",
"the empty quote draws just its gutter",
);
assert!(
m.rows[2]
.glyphs
.iter()
.all(|g| g.style.role == Role::QuoteGutter)
);
assert!(
!m.rows[2].decoration,
"it is a line text can go on, not a drawn gap"
);
assert!(
m.is_stop(m.rows[2].end_src),
"the empty quote's caret home is not a stop"
);
assert_eq!(
m.pos_of_offset(m.rows[2].end_src),
(2, 2),
"caret sits after '│ '"
);
let m = map("> \n");
assert_eq!(m.num_rows(), 1);
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ "
);
}
#[test]
fn a_quotes_own_trailing_marker_lines_stay_inside_the_quote() {
let m = map("> a\n>\n> \n");
assert_eq!(m.num_rows(), 3, "one row per line the quote spells");
for (i, row) in m.rows.iter().enumerate() {
let text = row.glyphs.iter().map(|g| g.ch).collect::<String>();
assert!(text.starts_with("│ "), "row {i} lost the gutter: {text:?}");
assert!(
!row.decoration,
"row {i} is a line to type on, not a drawn gap"
);
assert!(m.is_stop(row.end_src), "row {i} has no caret home");
}
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ a"
);
assert!(m.rows[0].end_src < m.rows[1].end_src);
assert!(m.rows[1].end_src < m.rows[2].end_src);
let m = map("> a\n\nb\n");
assert_eq!(m.num_rows(), 3);
assert_eq!(
m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
"b"
);
assert!(
!m.rows[1]
.glyphs
.iter()
.any(|g| g.style.role == Role::QuoteGutter)
);
let m = map("> > a\n>\n");
assert_eq!(
m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ │ a"
);
assert_eq!(
m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ "
);
let m = map("> > a\n> >\n");
assert_eq!(
m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
"│ │ "
);
let m = map("> a\n>\n> b\n");
assert_eq!(m.num_rows(), 3);
assert!(
m.rows[1].decoration,
"the gap between two quoted blocks is still a gap"
);
}
#[test]
fn an_empty_ordered_item_gets_its_number_and_a_caret_home() {
let m = map("1. item\n2. \n");
assert_eq!(m.num_rows(), 2);
assert_eq!(
m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
"2. "
);
assert!(m.is_stop(m.rows[1].end_src));
assert_eq!(
m.pos_of_offset(m.rows[1].end_src),
(1, 3),
"caret sits after '2. '"
);
}
#[test]
fn an_empty_headings_caret_home_is_past_its_hidden_marker() {
let m = map("# \n");
assert_eq!(m.num_rows(), 1);
assert!(m.rows[0].glyphs.is_empty(), "the `# ` marker is hidden");
assert_eq!(m.rows[0].end_src, 2, "the caret home is past the marker");
assert!(m.is_stop(2), "the empty heading's caret home is not a stop");
}
#[test]
fn a_headings_rows_carry_its_level_even_with_nothing_typed_in_it() {
let m = map("# \n");
assert_eq!(
m.rows[0].heading,
Some(1),
"the empty heading knows its level"
);
let m = map_at(
"## a heading long enough to wrap over two rows\n\nbody\n",
Some(20),
);
let heads: Vec<Option<u8>> = m.rows.iter().map(|r| r.heading).collect();
assert!(
heads.iter().filter(|h| **h == Some(2)).count() >= 2,
"got {heads:?}"
);
assert_eq!(
m.rows.last().and_then(|r| r.heading),
None,
"the paragraph under it is not a heading",
);
}
#[test]
fn an_empty_heading_leaves_the_rows_under_it_at_their_own_offsets() {
let m = map("text\n\n# \n\n");
let end = m.rows.last().expect("a trailing blank row").end_src;
assert_eq!(end, 10, "the trailing rows must end at their real offsets");
assert_eq!(m.pos_of_offset(8), (2, 0), "the empty heading's own row");
assert!(
m.rows[3..].iter().all(|r| r.end_src > 8),
"rows below own later offsets"
);
}
fn boundaries(m: &VisualMap) -> Vec<(BlockClass, BlockClass)> {
m.rows
.iter()
.filter_map(|r| r.boundary)
.map(|b| (b.above, b.below))
.collect()
}
#[test]
fn a_boundary_says_which_blocks_it_divides() {
use BlockClass::*;
let m = map("one\n\ntwo\n\n# Head\n\ntail\n\n> quoted\n\n```\ncode\n```\n");
assert_eq!(
boundaries(&m),
vec![
(Paragraph, Paragraph),
(Paragraph, Heading),
(Heading, Paragraph),
(Paragraph, Quote),
(Quote, Code),
(Code, Paragraph),
],
"each gap names the pair it falls between, in document order"
);
}
fn row_texts(m: &VisualMap) -> Vec<String> {
m.rows
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect())
.collect()
}
#[test]
fn a_div_s_closing_tag_is_not_a_blank_row() {
let m = map("above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n");
assert_eq!(row_texts(&m), ["above", "", "hello", "", "below"]);
assert!(!m.is_stop(36), "the `</div>` line is not a caret home");
assert_eq!(
m.stop_after(34),
Some(44),
"from `hello` the next stop is `below`"
);
let m = map("above\n\n<div class=\"center\">\n\nhello\n\n</div>\n");
assert_eq!(row_texts(&m), ["above", "", "hello"], "no trailing rows");
}
#[test]
fn a_comment_between_two_blocks_is_stepped_over_not_drawn_as_a_gap() {
let m = map("para one\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n");
assert_eq!(row_texts(&m), ["para one", "", "code", "", "after"]);
assert_eq!(
boundaries(&m),
vec![
(BlockClass::Paragraph, BlockClass::Code),
(BlockClass::Code, BlockClass::Paragraph),
],
"the boundary names the drawn blocks either side, not the comment"
);
assert_eq!(
m.rows[1].end_src, 23,
"the gap row ends at the comment's end"
);
}
#[test]
fn a_comment_opening_the_document_draws_no_leading_gap() {
let m = map("<!-- lead -->\n\npara\n");
assert_eq!(row_texts(&m), ["para"]);
assert_eq!(m.content_start, 0, "the comment is still the first block");
}
#[test]
fn a_comment_closing_the_document_is_not_trailing_blank_lines() {
let m = map("para\n\n<!-- trail -->\n");
assert_eq!(row_texts(&m), ["para"]);
let m = map("para\n\n<!-- trail -->\n\n");
assert_eq!(row_texts(&m), ["para", "", ""]);
}
#[test]
fn a_comment_in_a_list_item_leaves_the_bullet_to_what_follows_it() {
let m = map("- <!-- note -->\n\n text\n- two\n");
let texts = row_texts(&m);
assert!(
texts.iter().any(|t| t == "• text"),
"the text wears the bullet: {texts:?}"
);
assert!(
!texts.iter().any(|t| t == "• "),
"no empty bullet row for the comment: {texts:?}"
);
}
#[test]
fn the_cached_build_does_not_spell_the_document_out_as_blank_rows_after_a_comment() {
let body = (0..200)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n\n");
let src = format!("intro\n\n<!-- exec -->\n{body}\n");
let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
let mut cache = BlockCache::default();
let (plain, cached) = render_both(&mut ed, &src, Some(80), &mut cache);
assert_maps_eq(&plain, &cached, "comment then 200 paragraphs");
assert_eq!(cached.rows.len(), 401);
}
#[test]
fn a_link_reference_definition_is_stepped_over_like_a_comment() {
let m = map("see [a]\n\n[a]: /a\n\nafter\n");
assert_eq!(row_texts(&m), ["see a", "", "after"]);
assert_eq!(
boundaries(&m),
vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
);
}
#[test]
fn link_reference_definitions_closing_the_document_are_not_trailing_blank_lines() {
let m = map("see [a] and [b]\n\n<!-- links -->\n[a]: /a\n[b]: /b \"bee\"\n");
assert_eq!(row_texts(&m), ["see a and b"]);
}
#[test]
fn a_definition_glued_under_a_paragraph_stays_inside_it() {
let m = map("intro\n\n[a]: /a\ntext [a]\n");
assert_eq!(row_texts(&m), ["intro", "", "text a"]);
}
#[test]
fn a_definition_with_no_span_is_left_out_of_the_walk() {
assert!(!is_placed_definition(&Kind::Reference, &(0..0)));
assert!(is_placed_definition(&Kind::Reference, &(7..14)));
assert!(is_placed_definition(&Kind::Footnote, &(0..0)));
assert!(!is_placed_definition(&Kind::Str, &(7..14)));
}
#[test]
fn the_trailing_gap_closes_the_last_block() {
let m = map("# Head\n\n\n");
assert_eq!(
boundaries(&m),
vec![(BlockClass::Heading, BlockClass::Paragraph)]
);
}
#[test]
fn only_the_drawn_gap_rows_carry_a_boundary() {
let m = map("one\n\ntwo\n");
for row in &m.rows {
assert_eq!(
row.boundary.is_some(),
row.decoration,
"a boundary is exactly a drawn gap row: {:?}",
row.glyphs.iter().map(|g| g.ch).collect::<String>()
);
}
}
#[test]
fn preserve_flow_labels_no_boundary() {
let m = map_preserve("one\n\ntwo\n\n# Head\n", Some(80));
assert!(boundaries(&m).is_empty());
}
#[test]
fn a_list_draws_no_boundary_between_its_items() {
for src in ["- one\n- two\n", "- one\n\n- two\n"] {
let m = map(src);
assert!(
boundaries(&m).is_empty(),
"no gap row inside the list of {src:?}"
);
}
let m = map("- one\n- two\n\npara\n");
assert_eq!(
boundaries(&m),
vec![(BlockClass::List, BlockClass::Paragraph)]
);
}
#[test]
fn a_nested_boundary_names_the_blocks_inside_the_container() {
let m = map("> one\n>\n> two\n");
assert_eq!(
boundaries(&m),
vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
);
}
#[test]
fn a_directive_container_draws_one_boundary_like_every_other_block() {
let fenced = map_directives(":::note\nin\n:::\n\ntwo\n");
assert_eq!(
boundaries(&fenced),
vec![(BlockClass::Directive, BlockClass::Paragraph)],
"one authored gap, one boundary row"
);
let code = map("```\nc\n```\n\ntwo\n");
assert_eq!(
boundaries(&code).len(),
boundaries(&fenced).len(),
"a fenced div spaces like a fenced code block"
);
let nested = map_directives(":::a\n:::b\nin\n:::\n:::\n\ntwo\n");
assert_eq!(
boundaries(&nested),
vec![(BlockClass::Directive, BlockClass::Paragraph)]
);
}
#[test]
fn a_block_media_names_itself_in_the_boundaries_either_side() {
use BlockClass::*;
let m = map("one\n\n\n\ntwo\n");
assert_eq!(boundaries(&m), vec![(Paragraph, Media), (Media, Paragraph)]);
let edges = map("\n\nmid\n\n\n");
assert_eq!(
boundaries(&edges),
vec![(Media, Paragraph), (Paragraph, Media)]
);
let roomy = map("one\n\n\n\n\n");
assert_eq!(
boundaries(&roomy),
vec![(Paragraph, Media), (Paragraph, Media)]
);
}
#[test]
fn a_block_video_is_media_at_its_boundaries_not_a_directive_panel() {
let mut doc = crate::Doc::from_source(
"one\n\n<video src=\"v.mp4\"></video>\n\ntwo\n".to_string(),
Format::Markdown,
)
.unwrap();
doc.build_visual(80);
assert_eq!(
boundaries(&doc.vmap),
vec![
(BlockClass::Paragraph, BlockClass::Media),
(BlockClass::Media, BlockClass::Paragraph),
]
);
}
#[test]
fn the_incremental_walk_labels_boundaries_like_the_full_one() {
let src = "one\n\n# Head\n\ntwo\n\n- a\n- b\n\n> q\n\n```\nc\n```\n\npara\n";
let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
let mut cache = BlockCache::default();
let (full, cached) = render_both(&mut ed, src, Some(80), &mut cache);
assert_maps_eq(&full, &cached, "boundary labelling");
assert!(
!boundaries(&full).is_empty(),
"the fixture has boundaries to compare"
);
}
#[test]
fn every_caret_stop_opens_a_cluster_of_its_row() {
let src = "# 標題\n\na **bold** e\u{0301}mo👨👩👧ji `x` 你好\n\n\
- 項目 one\n- e\u{0301}dge\n\n> 引用 text\n\n\
| A | 值 |\n|---|---|\n| 你好 | 👩🚀 |\n";
let m = map(src);
for (r, row) in m.rows.iter().enumerate() {
let openers: Vec<usize> = clusters(&row.glyphs).iter().map(|c| c.glyph).collect();
for (i, g) in row.glyphs.iter().enumerate() {
assert!(
!g.stop || openers.contains(&i),
"row {r}: the stop at glyph {i} ({:?}) is inside a cluster, \
so it is drawn at another glyph's column",
g.ch
);
}
}
}
#[test]
fn a_block_carries_its_alignment_on_every_row_it_draws() {
let html = map_leaf("<p class=\"lead center\">hi</p>\n", Format::Html);
assert_eq!(line_facts(&html), vec![(Some(Align::Center), None)]);
let dj = map_leaf("{.right}\nhi\n", Format::Djot);
assert_eq!(line_facts(&dj), vec![(Some(Align::Right), None)]);
let h = map_leaf("{.center}\n# a heading\n", Format::Djot);
assert_eq!(line_facts(&h), vec![(Some(Align::Center), None)]);
assert_eq!(h.rows[0].heading, Some(1));
let both = map_leaf("{.justify data-line-height=\"1.5\"}\nhi\n", Format::Djot);
assert_eq!(
line_facts(&both),
vec![(
Some(Align::Justify),
Some(LineHeight::Step(LineSpacing::OneHalf))
)]
);
let other = map_leaf("{.lead data-line-height=\"1.3\"}\nhi\n", Format::Djot);
assert_eq!(line_facts(&other), vec![(None, LineHeight::ratio(1.3))]);
let em = map_leaf("{data-line-height=\"1.3em\"}\nhi\n", Format::Djot);
assert_eq!(line_facts(&em), vec![(None, None)]);
}
#[test]
fn a_div_lends_its_alignment_to_every_block_inside_it() {
let m = map_leaf(
"<div class=\"center\" data-line-height=\"2\">\n\none\n\ntwo\n\n</div>\n",
Format::Markdown,
);
assert_eq!(
line_facts(&m),
vec![
(
Some(Align::Center),
Some(LineHeight::Step(LineSpacing::Double))
),
(
Some(Align::Center),
Some(LineHeight::Step(LineSpacing::Double))
),
]
);
let nested = map_leaf(
"<div class=\"center\">\n\n<div class=\"right\">\n\ninner\n\n</div>\n\nouter\n\n</div>\n\nafter\n",
Format::Markdown,
);
assert_eq!(
line_facts(&nested),
vec![
(Some(Align::Right), None),
(Some(Align::Center), None),
(None, None),
]
);
}
#[test]
fn a_span_s_size_beats_its_block_s_and_its_face_falls_through() {
let m = map_leaf(
"<div data-font=\"serif\">\n\nc <span data-size=\"small\">a</span> b\n\n</div>\n",
Format::Markdown,
);
let a = style_of(&m, 'a');
assert_eq!(a.size, Some(FontSize::Step(SizeStep::Small)));
assert_eq!(a.font, Some(FaceRef::Generic(FontFamily::Serif)));
let b = style_of(&m, 'b');
assert_eq!(b.size, None);
assert_eq!(b.font, Some(FaceRef::Generic(FontFamily::Serif)));
let dj = map_leaf(
"{data-size=\"large\"}\nx [y]{data-size=\"xx-large\" data-color=\"blue\"} z\n",
Format::Djot,
);
assert_eq!(
style_of(&dj, 'x').size,
Some(FontSize::Step(SizeStep::Large))
);
assert_eq!(
style_of(&dj, 'y').size,
Some(FontSize::Step(SizeStep::XxLarge))
);
assert_eq!(
style_of(&dj, 'y').color,
Some(TextColor::Named(MarkColor::Blue))
);
assert_eq!(
style_of(&dj, 'z').size,
Some(FontSize::Step(SizeStep::Large))
);
assert_eq!(style_of(&dj, 'z').color, None);
}
#[test]
fn an_exact_size_face_and_colour_reach_the_glyph_and_the_row() {
let m = map_leaf(
"<div data-line-height=\"1.3\">\n\nc <span data-size=\"14pt\" \
data-color=\"#c03030\" data-font=\"Garamond\">a</span> b\n\n</div>\n",
Format::Markdown,
);
let a = style_of(&m, 'a');
assert_eq!(a.size, FontSize::points(14.0));
assert_eq!(
a.color,
Some(TextColor::Rgb {
r: 0xc0,
g: 0x30,
b: 0x30
})
);
assert_eq!(a.font, Some(FaceRef::Named(FaceId::of("Garamond"))));
assert_eq!(m.face_name(FaceId::of("Garamond")), Some("Garamond"));
assert_eq!(line_facts(&m), vec![(None, LineHeight::ratio(1.3))]);
let b = style_of(&m, 'b');
assert_eq!((b.size, b.font, b.color), (None, None, None));
let twice = map_leaf(
"x <span data-font=\"Garamond\">a</span> y <span data-font=\"Garamond\">b</span>\n",
Format::Markdown,
);
assert_eq!(twice.faces().len(), 1);
assert_eq!(
style_of(&twice, 'a').font,
style_of(&twice, 'b').font,
"one family, one id"
);
let generic = map_leaf(
"<div data-font=\"serif\">\n\nhi\n\n</div>\n",
Format::Markdown,
);
assert_eq!(
style_of(&generic, 'h').font,
Some(FaceRef::Generic(FontFamily::Serif))
);
assert!(generic.faces().is_empty());
}
#[test]
fn a_mark_keeps_its_highlight_colour_and_a_span_colours_the_text() {
let m = map_leaf("a ==\u{1f534} red== b\n", Format::Markdown);
let r = style_of(&m, 'r');
assert_eq!(r.role, Role::Mark(Some(MarkColor::Red)));
assert_eq!(r.color, None, "a highlight is not a text colour");
let both = map_leaf(
"<span data-color=\"blue\">a ==\u{1f534} red== b</span>\n",
Format::Markdown,
);
let r = style_of(&both, 'r');
assert_eq!(r.role, Role::Mark(Some(MarkColor::Red)), "the highlight");
assert_eq!(
r.color,
Some(TextColor::Named(MarkColor::Blue)),
"the letters"
);
}
#[test]
fn a_page_break_reads_the_same_in_markdown_and_in_djot() {
for (fmt, src) in [
(Format::Markdown, "a\n\n::page-break\n\nb\n"),
(Format::Djot, "a\n\n::: page-break\n:::\n\nb\n"),
] {
let m = map_leaf(src, fmt);
let marks: Vec<&DirectiveMark> = m
.rows
.iter()
.filter_map(|r| r.leaf_directive.as_ref())
.collect();
assert_eq!(marks.len(), 1, "{fmt:?} draws one placeholder");
assert_eq!(marks[0].name, "page-break", "{fmt:?}");
assert!(marks[0].attrs.is_empty(), "{fmt:?}: {:?}", marks[0].attrs);
assert!(
m.rows
.iter()
.any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>()
== "\u{29c9} page-break"),
"{fmt:?} draws the label, got {:?}",
m.rows
.iter()
.map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
.collect::<Vec<_>>()
);
}
let empty_fence = map_leaf("::: note\n:::\n", Format::Markdown);
assert!(
empty_fence.rows.iter().all(|r| r.leaf_directive.is_none()),
"a named empty fence keeps the reading it has"
);
}
#[test]
fn a_djot_fence_s_first_class_is_the_directive_s_name_and_the_rest_is_attributes() {
let m = map_leaf("{.page-break .wide}\n:::\n:::\n", Format::Djot);
let mark = m
.rows
.iter()
.find_map(|r| r.leaf_directive.as_ref())
.expect("a placeholder");
assert_eq!(mark.name, "page-break");
assert_eq!(
mark.attrs,
vec![("class".to_string(), Some("wide".to_string()))]
);
}
}