use std::borrow::Cow;
use std::ops::Range;
use crate::buffer::RenderSnapshot;
use crate::editor::EditorTheme;
use crate::inline::{MathSpan, StyledRegion, TextStyle};
use crate::marker::MarkerKind;
use crate::segment_map::{SegmentMap, Special};
use crate::table::RowKind;
use crate::text_engine::{StyleRun, peniko_color};
fn cursor_in(range: &Range<usize>, cursor: usize) -> bool {
cursor >= range.start && cursor <= range.end
}
#[derive(Clone)]
pub struct ImageRef {
pub url: String,
pub alt: String,
}
#[derive(Clone)]
pub struct InlineImageRef {
pub url: String,
pub alt: String,
pub display_offset: usize,
}
#[derive(Clone)]
pub struct InlineMathRef {
pub latex: String,
pub display_offset: usize,
}
#[derive(Clone)]
pub struct TableCtx {
pub block: Range<usize>,
pub kind: RowKind,
}
#[derive(Clone, Copy)]
pub struct TableLineRef {
pub table_start: usize,
pub kind: RowKind,
}
#[derive(Clone)]
pub struct LineRender {
pub text: String,
pub font_size: f32,
pub runs: Vec<StyleRun>,
pub map: SegmentMap,
pub content_start: usize,
pub quote_bar_bytes: Vec<usize>,
pub is_hr: bool,
pub code_ranges: Vec<Range<usize>>,
pub image: Option<ImageRef>,
pub inline_images: Vec<InlineImageRef>,
pub inline_math: Vec<InlineMathRef>,
pub table: Option<TableLineRef>,
}
fn heading_scale(level: u8) -> f32 {
match level {
1 => 3.05, 2 => 2.44, 3 => 1.95, 4 => 1.56, 5 => 1.25, _ => 1.0, }
}
#[allow(clippy::too_many_arguments)]
pub fn build_line_render(
snapshot: &RenderSnapshot,
line_idx: usize,
theme: &EditorTheme,
base_font_size: f32,
cursor_offset: usize,
line_styles: &[StyledRegion],
extra_regions: &[StyledRegion],
table_ctx: Option<TableCtx>,
math_spans: &[MathSpan],
) -> LineRender {
let markers = snapshot.line_markers(line_idx);
let range = markers.range.clone();
let line_start = range.start;
let rope = &snapshot.rope;
let byte_end = if range.end > range.start && rope.get_byte(range.end - 1) == Some(b'\n') {
range.end - 1
} else {
range.end
};
let line_text = rope
.slice(rope.byte_to_char(range.start)..rope.byte_to_char(byte_end))
.to_string();
let line_end = line_start + line_text.len();
let heading_level = markers.heading_level().unwrap_or(0);
let font_size = base_font_size * heading_scale(heading_level);
let cursor_on_line = cursor_in(&range, cursor_offset);
let in_code_block = markers.in_code_block || markers.is_fence();
let cursor_line_start = snapshot
.line_byte_range(rope.byte_to_line(cursor_offset.min(rope.len_bytes())))
.start;
let table_ref = table_ctx.as_ref().and_then(|tc| {
(!tc.block.contains(&cursor_line_start)).then_some(TableLineRef {
table_start: tc.block.start,
kind: tc.kind,
})
});
let is_grid_table = table_ref.is_some();
let has_checkbox_style = markers
.markers
.iter()
.any(|m| matches!(m.kind, MarkerKind::Checkbox { .. }));
let inline: Cow<[StyledRegion]> = if !has_checkbox_style && extra_regions.is_empty() {
Cow::Borrowed(line_styles)
} else {
let mut inline: Vec<StyledRegion> = line_styles.to_vec();
for marker in &markers.markers {
if let MarkerKind::Checkbox { checked } = marker.kind {
let checkbox_range = marker.range.start..marker.range.start + 3;
inline.push(StyledRegion {
full_range: checkbox_range.clone(),
content_range: checkbox_range,
style: TextStyle::default(),
link_url: None,
is_image: false,
checkbox: Some(checked),
display_text: None,
});
}
}
inline.sort_by_key(|s| s.full_range.start);
inline.extend_from_slice(extra_regions);
Cow::Owned(inline)
};
let cursor_inside: Vec<bool> = inline
.iter()
.map(|r| cursor_in(&r.full_range, cursor_offset))
.collect();
let trimmed_start = line_start + (line_text.len() - line_text.trim_start().len());
let trimmed_end = line_start + line_text.trim_end().len();
let image = if !in_code_block && !cursor_on_line && trimmed_start < trimmed_end {
let mut images = inline.iter().filter(|r| r.is_image);
match (images.next(), images.next()) {
(Some(r), None)
if r.full_range.start == trimmed_start
&& r.full_range.end == trimmed_end
&& r.content_range.start >= line_start
&& r.content_range.end <= line_end =>
{
r.link_url.as_ref().map(|url| {
let alt = line_text
[r.content_range.start - line_start..r.content_range.end - line_start]
.to_string();
ImageRef {
url: url.clone(),
alt,
}
})
}
_ => None,
}
} else {
None
};
let is_standalone_image = image.is_some();
let is_hr = markers.is_thematic_break() && !cursor_on_line && !in_code_block;
let mut specials: Vec<Special> = Vec::new();
let mut inline_image_specs: Vec<(usize, String, String)> = Vec::new();
if is_grid_table {
if line_end > line_start {
specials.push(Special::Hidden(line_start..line_end));
}
} else if !in_code_block {
if heading_level > 0
&& !cursor_on_line
&& let Some(mr) = markers.marker_range()
&& mr.end <= line_end
{
specials.push(Special::Hidden(mr));
}
if is_hr && line_end > line_start {
specials.push(Special::Hidden(line_start..line_end));
}
if is_standalone_image && line_end > line_start {
specials.push(Special::Hidden(line_start..line_end));
}
for marker in &markers.markers {
let sub = match &marker.kind {
MarkerKind::ListItem { ordered: false, .. } if has_checkbox_style => Some(""),
MarkerKind::ListItem {
ordered: false,
unordered_marker,
..
} => Some(unordered_marker.as_ref().map_or("• ", |m| m.bullet())),
MarkerKind::BlockQuote => Some(" "),
_ => None,
};
if let Some(display) = sub
&& marker.range.start >= line_start
&& marker.range.end <= line_end
{
specials.push(Special::Collapsed {
buffer: marker.range.clone(),
display: display.to_string(),
});
}
}
for (i, region) in inline.iter().enumerate() {
if is_standalone_image {
break; }
if cursor_inside[i] {
continue; }
if region.is_image
&& region.content_range.start >= line_start
&& region.content_range.end <= line_end
&& let Some(url) = ®ion.link_url
{
specials.push(Special::Hidden(region.full_range.clone()));
let alt = line_text[region.content_range.start - line_start
..region.content_range.end - line_start]
.to_string();
inline_image_specs.push((region.full_range.start, url.clone(), alt));
continue;
}
if let Some(dt) = ®ion.display_text {
specials.push(Special::Collapsed {
buffer: region.full_range.clone(),
display: dt.clone(),
});
} else {
if region.content_range.start > region.full_range.start {
specials.push(Special::Hidden(
region.full_range.start..region.content_range.start,
));
}
if region.full_range.end > region.content_range.end {
specials.push(Special::Hidden(
region.content_range.end..region.full_range.end,
));
}
}
}
}
let mut inline_math_specs: Vec<(usize, String)> = Vec::new();
if !is_grid_table && !is_standalone_image {
for span in math_spans {
if span.full_range.start < line_start
|| span.full_range.end > line_end
|| cursor_in(&span.full_range, cursor_offset)
{
continue;
}
specials.push(Special::Hidden(span.full_range.clone()));
let latex = line_text
[span.content_range.start - line_start..span.content_range.end - line_start]
.to_string();
inline_math_specs.push((span.full_range.start, latex));
}
}
let (text, map) = SegmentMap::build(&line_text, line_start, &specials);
let mut inline_images: Vec<InlineImageRef> = inline_image_specs
.into_iter()
.map(|(buffer_start, url, alt)| InlineImageRef {
url,
alt,
display_offset: map.buffer_to_display(buffer_start),
})
.collect();
inline_images.sort_by_key(|i| i.display_offset);
let mut inline_math: Vec<InlineMathRef> = inline_math_specs
.into_iter()
.map(|(buffer_start, latex)| InlineMathRef {
latex,
display_offset: map.buffer_to_display(buffer_start),
})
.collect();
inline_math.sort_by_key(|m| m.display_offset);
let quote_bar_bytes: Vec<usize> = if in_code_block {
Vec::new()
} else {
let mut bars: Vec<usize> = markers
.markers
.iter()
.filter(|m| matches!(m.kind, MarkerKind::BlockQuote))
.map(|m| map.buffer_to_display(m.range.start))
.collect();
bars.sort_unstable(); bars
};
let fg = peniko_color(theme.foreground);
let mut runs = Vec::new();
let mut code_ranges: Vec<Range<usize>> = Vec::new();
if is_grid_table {
} else if in_code_block {
if !text.is_empty() {
let mut base = StyleRun::new(0..text.len(), fg);
base.mono = true;
runs.push(base);
}
if markers.is_fence() {
let bytes = text.as_bytes();
if let Some(start) = bytes.iter().position(|&b| b == b'`' || b == b'~') {
let fc = bytes[start];
let end = start + bytes[start..].iter().take_while(|&&b| b == fc).count();
let mut delim = StyleRun::new(start..end, peniko_color(theme.comment));
delim.mono = true;
runs.push(delim);
if end < text.len() {
let mut lang = StyleRun::new(end..text.len(), peniko_color(theme.green));
lang.mono = true;
runs.push(lang);
}
}
}
for span in snapshot.code_highlights_for_line(line_idx) {
let r = map.buffer_range_to_display(span.range.clone());
if !r.is_empty() {
let mut run = StyleRun::new(
r,
peniko_color(theme.color_for_highlight(span.highlight_id)),
);
run.mono = true;
runs.push(run);
}
}
} else {
let dim = markers.checkbox() == Some(true);
let content_color = if dim { peniko_color(theme.comment) } else { fg };
if dim && !text.is_empty() {
runs.push(StyleRun::new(0..text.len(), content_color));
}
if heading_level > 0 && !text.is_empty() {
let mut h = StyleRun::new(0..text.len(), peniko_color(theme.purple));
h.bold = true;
runs.push(h);
}
for (i, region) in inline.iter().enumerate() {
let style_range = if cursor_inside[i] {
region.full_range.clone()
} else {
region.content_range.clone()
};
let r = map.buffer_range_to_display(style_range);
if r.is_empty() {
continue;
}
let style = ®ion.style;
let color = match region.checkbox {
Some(true) => peniko_color(theme.green),
Some(false) => peniko_color(theme.comment),
None if region.link_url.is_some() => peniko_color(theme.cyan),
None if style.code => peniko_color(theme.green),
None => content_color, };
if style.code && region.checkbox.is_none() && region.link_url.is_none() {
code_ranges.push(r.clone());
}
let mut run = StyleRun::new(r, color);
run.bold = style.bold || heading_level > 0 || region.checkbox == Some(true);
run.italic = style.italic;
run.mono = style.code;
run.strikethrough = style.strikethrough;
run.underline = region.link_url.is_some();
runs.push(run);
}
}
let content_start = map.buffer_to_display(markers.content_start());
LineRender {
text,
font_size,
runs,
map,
content_start,
quote_bar_bytes,
is_hr,
image,
code_ranges,
inline_images,
inline_math,
table: table_ref,
}
}
pub fn build_cell_render(
snapshot: &RenderSnapshot,
theme: &EditorTheme,
cell_range: Range<usize>,
line_styles: &[StyledRegion],
) -> (String, Vec<StyleRun>, SegmentMap, Vec<Range<usize>>) {
let rope = &snapshot.rope;
let start = cell_range.start;
let end = cell_range.end.max(start);
let cell_text = rope
.slice(rope.byte_to_char(start)..rope.byte_to_char(end))
.to_string();
let regions: Vec<&StyledRegion> = line_styles
.iter()
.filter(|r| r.full_range.start >= start && r.full_range.end <= end)
.collect();
let mut specials: Vec<Special> = Vec::new();
for r in ®ions {
if let Some(dt) = &r.display_text {
specials.push(Special::Collapsed {
buffer: r.full_range.clone(),
display: dt.clone(),
});
} else {
if r.content_range.start > r.full_range.start {
specials.push(Special::Hidden(r.full_range.start..r.content_range.start));
}
if r.full_range.end > r.content_range.end {
specials.push(Special::Hidden(r.content_range.end..r.full_range.end));
}
}
}
let (text, map) = SegmentMap::build(&cell_text, start, &specials);
let fg = peniko_color(theme.foreground);
let mut runs = Vec::new();
let mut code_ranges = Vec::new();
for r in ®ions {
let dr = map.buffer_range_to_display(r.content_range.clone());
if dr.is_empty() {
continue;
}
let style = &r.style;
if style.code && r.checkbox.is_none() && r.link_url.is_none() {
code_ranges.push(dr.clone());
}
let color = match r.checkbox {
Some(true) => peniko_color(theme.green),
Some(false) => peniko_color(theme.comment),
None if r.link_url.is_some() => peniko_color(theme.cyan),
None if style.code => peniko_color(theme.green),
None => fg,
};
let mut run = StyleRun::new(dr, color);
run.bold = style.bold;
run.italic = style.italic;
run.mono = style.code;
run.strikethrough = style.strikethrough;
run.underline = r.link_url.is_some();
runs.push(run);
}
(text, runs, map, code_ranges)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::Buffer;
use crate::inline::TextStyle;
use std::ops::Range;
fn link_region(range: Range<usize>) -> StyledRegion {
StyledRegion {
full_range: range.clone(),
content_range: range,
style: TextStyle::default(),
link_url: Some("https://github.com/o/r/issues/1".to_string()),
is_image: false,
checkbox: None,
display_text: None,
}
}
#[test]
fn extra_link_region_styles_underlined_link() {
let mut buffer: Buffer = "See #1 today\n".parse().unwrap();
let snapshot = buffer.render_snapshot();
let theme = EditorTheme::dracula();
let extra = [link_region(4..6)];
let lr = build_line_render(
&snapshot,
0,
&theme,
18.0,
usize::MAX,
&[],
&extra,
None,
&[],
);
let link_run = lr
.runs
.iter()
.find(|r| r.underline)
.expect("a link run should exist");
assert_eq!(link_run.color, peniko_color(theme.cyan));
let ds = lr.map.buffer_to_display(4);
let de = lr.map.buffer_to_display(6);
assert_eq!(link_run.range, ds..de);
}
#[test]
fn no_extra_regions_no_link() {
let mut buffer: Buffer = "See #1 today\n".parse().unwrap();
let snapshot = buffer.render_snapshot();
let theme = EditorTheme::dracula();
let lr = build_line_render(&snapshot, 0, &theme, 18.0, usize::MAX, &[], &[], None, &[]);
assert!(!lr.runs.iter().any(|r| r.underline));
}
#[test]
fn completed_task_dims_content() {
let theme = EditorTheme::dracula();
let comment = peniko_color(theme.comment);
let mut buf: Buffer = "- [x] done\n- [ ] todo\n".parse().unwrap();
let snap = buf.render_snapshot();
let done = build_line_render(&snap, 0, &theme, 18.0, usize::MAX, &[], &[], None, &[]);
assert!(
done.runs
.iter()
.any(|r| r.color == comment && r.range.len() >= done.text.len()),
"completed task content is dimmed across the line"
);
assert!(
done.runs
.iter()
.any(|r| r.color == peniko_color(theme.green)),
"the checkbox itself stays green"
);
let todo = build_line_render(&snap, 1, &theme, 18.0, usize::MAX, &[], &[], None, &[]);
assert!(
!todo
.runs
.iter()
.any(|r| r.color == comment && r.range.len() >= todo.text.len()),
"active task content is not dimmed (only its empty box is muted)"
);
}
#[test]
fn inline_code_exposes_code_ranges() {
let theme = EditorTheme::dracula();
let mut buf: Buffer = "call `foo()` now\nplain line\n".parse().unwrap();
let snap = buf.render_snapshot();
let styles = snap.inline_styles_by_line();
let coded = build_line_render(
&snap,
0,
&theme,
18.0,
usize::MAX,
&styles[0],
&[],
None,
&[],
);
assert_eq!(coded.code_ranges.len(), 1, "one code span");
let (s, e) = (coded.code_ranges[0].start, coded.code_ranges[0].end);
assert_eq!(&coded.text[s..e], "foo()");
let plain = build_line_render(
&snap,
1,
&theme,
18.0,
usize::MAX,
&styles[1],
&[],
None,
&[],
);
assert!(plain.code_ranges.is_empty(), "no code span, no chip");
}
#[test]
fn thematic_break_hides_text_and_flags_rule() {
let theme = EditorTheme::dracula();
let mut buffer: Buffer = "a\n\n---\n\nb\n".parse().unwrap();
let snap = buffer.render_snapshot();
let lr = build_line_render(&snap, 2, &theme, 18.0, 0, &[], &[], None, &[]);
assert!(lr.is_hr, "off-line thematic break renders as a rule");
assert!(lr.text.is_empty(), "the `---` text is hidden");
let on = snap.line_markers(2).range.start;
let lr = build_line_render(&snap, 2, &theme, 18.0, on, &[], &[], None, &[]);
assert!(!lr.is_hr, "revealed for editing when cursor is on it");
assert_eq!(lr.text, "---");
}
#[test]
fn standalone_image_detected_off_line_revealed_on_line() {
let theme = EditorTheme::dracula();
let mut buffer: Buffer = "\n".parse().unwrap();
let snap = buffer.render_snapshot();
let styles = snap.inline_styles_by_line();
let off = build_line_render(
&snap,
0,
&theme,
18.0,
usize::MAX,
&styles[0],
&[],
None,
&[],
);
let img = off
.image
.as_ref()
.expect("standalone image detected off-line");
assert_eq!(img.url, "img/badge.png");
assert_eq!(img.alt, "a badge");
assert!(off.text.is_empty(), "the `` markdown is hidden");
let on = build_line_render(&snap, 0, &theme, 18.0, 0, &styles[0], &[], None, &[]);
assert!(
on.image.is_none(),
"cursor on the line reveals raw markdown"
);
assert_eq!(on.text, "");
}
#[test]
fn inline_image_is_not_standalone() {
let theme = EditorTheme::dracula();
let mut buffer: Buffer = "see  here\n".parse().unwrap();
let snap = buffer.render_snapshot();
let styles = snap.inline_styles_by_line();
let lr = build_line_render(
&snap,
0,
&theme,
18.0,
usize::MAX,
&styles[0],
&[],
None,
&[],
);
assert!(
lr.image.is_none(),
"image amid other text is not standalone"
);
}
#[test]
fn inline_images_reveal_per_image() {
let theme = EditorTheme::dracula();
let mut buffer: Buffer = " and \n".parse().unwrap();
let snap = buffer.render_snapshot();
let styles = snap.inline_styles_by_line();
let off = build_line_render(
&snap,
0,
&theme,
18.0,
usize::MAX,
&styles[0],
&[],
None,
&[],
);
assert_eq!(off.inline_images.len(), 2, "both inline images detected");
assert!(
off.inline_images[0].display_offset <= off.inline_images[1].display_offset,
"display offsets ascend left-to-right"
);
assert!(
!off.text.contains("!["),
"markdown hidden, got {:?}",
off.text
);
assert!(off.image.is_none(), "not a standalone image");
let in_first = build_line_render(&snap, 0, &theme, 18.0, 2, &styles[0], &[], None, &[]);
assert_eq!(
in_first.inline_images.len(),
1,
"only the other image stays a box"
);
assert!(in_first.text.contains("![a]"), "caret's image shown raw");
let between = build_line_render(&snap, 0, &theme, 18.0, 13, &styles[0], &[], None, &[]);
assert_eq!(
between.inline_images.len(),
2,
"off-caret images stay boxes"
);
}
#[test]
fn blockquote_exposes_gutter_offsets_not_glyph() {
let theme = EditorTheme::dracula();
let mut single: Buffer = "> quoted line\n".parse().unwrap();
let snap = single.render_snapshot();
let lr = build_line_render(&snap, 0, &theme, 18.0, usize::MAX, &[], &[], None, &[]);
assert!(!lr.text.contains('▎'), "bar must not be a text glyph");
assert_eq!(lr.quote_bar_bytes, vec![0], "one gutter at the line start");
let mut nested: Buffer = "> > deeply quoted\n".parse().unwrap();
let snap = nested.render_snapshot();
let lr = build_line_render(&snap, 0, &theme, 18.0, usize::MAX, &[], &[], None, &[]);
assert_eq!(lr.quote_bar_bytes.len(), 2, "one gutter per nesting level");
assert_eq!(lr.quote_bar_bytes[0], 0, "outer gutter at line start");
assert!(
lr.quote_bar_bytes[1] > lr.quote_bar_bytes[0],
"inner gutter sits to the right of the outer"
);
}
#[test]
fn table_row_hides_off_cursor_reveals_on_cursor() {
let theme = EditorTheme::dracula();
let mut buf: Buffer = "| a | b |\n|---|---|\n| 1 | 2 |\n".parse().unwrap();
let snap = buf.render_snapshot();
let styles = snap.inline_styles_by_line();
let (table, _) = snap.table_row_at_line(0).unwrap();
let ctx = TableCtx {
block: table.block.clone(),
kind: RowKind::Header,
};
let off = build_line_render(
&snap,
0,
&theme,
18.0,
usize::MAX,
&styles[0],
&[],
Some(ctx.clone()),
&[],
);
assert!(off.table.is_some(), "off-cursor header is a grid row");
assert!(off.text.is_empty(), "grid row text hidden");
let on = build_line_render(&snap, 0, &theme, 18.0, 2, &styles[0], &[], Some(ctx), &[]);
assert!(on.table.is_none(), "cursor in block reveals raw text");
assert_eq!(on.text, "| a | b |");
}
#[test]
fn table_reveals_at_last_row_end_without_trailing_newline() {
let theme = EditorTheme::dracula();
let mut buf: Buffer = "| a | b |\n|---|---|\n| 1 | 2 |".parse().unwrap();
let snap = buf.render_snapshot();
let styles = snap.inline_styles_by_line();
let last = snap.line_count() - 1;
let (table, kind) = snap.table_row_at_line(last).unwrap();
let eof = snap.rope.len_bytes();
assert_eq!(eof, table.block.end, "table block ends at EOF");
let ctx = TableCtx {
block: table.block.clone(),
kind,
};
let lr = build_line_render(
&snap,
last,
&theme,
18.0,
eof,
&styles[last],
&[],
Some(ctx),
&[],
);
assert!(
lr.table.is_none(),
"caret at block.end reveals the last row"
);
assert_eq!(lr.text, "| 1 | 2 |");
}
#[test]
fn cell_render_styles_bold_and_hides_markers() {
let theme = EditorTheme::dracula();
let mut buf: Buffer = "| **hi** | plain |\n|---|---|\n| 1 | 2 |\n"
.parse()
.unwrap();
let snap = buf.render_snapshot();
let styles = snap.inline_styles_by_line();
let (table, _) = snap.table_row_at_line(0).unwrap();
let bold_cell = table.header.cells[0].content.clone();
let (text, runs, _map, _code) = build_cell_render(&snap, &theme, bold_cell, &styles[0]);
assert_eq!(text, "hi", "markers hidden, content shown");
assert!(runs.iter().any(|r| r.bold), "cell content is bold");
let plain_cell = table.header.cells[1].content.clone();
let (ptext, pruns, _, _) = build_cell_render(&snap, &theme, plain_cell, &styles[0]);
assert_eq!(ptext, "plain");
assert!(pruns.is_empty(), "a plain cell needs no style runs");
}
}