use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
use parley::{Affinity, Cluster, Cursor, PositionedLayoutItem};
use ropey::Rope;
use vello::Scene;
use vello::kurbo::{Affine, Rect, Stroke};
use vello::peniko::{Brush, Color, Fill, ImageBrush};
use crate::buffer::RenderSnapshot;
use crate::consts::{MAX_CONTENT_WIDTH, UI_LINE_HEIGHT};
use crate::diff::{DiffState, InlineChange};
use crate::editor::EditorTheme;
use crate::image_cache::{ImageCache, ImageState};
use crate::inline::{
GitHubContext, MathSpan, NakedUrl, RawGitHubMatch, StyledRegion, github_refs_to_styled_regions,
naked_urls_to_styled_regions,
};
#[cfg(feature = "math")]
use crate::math;
#[cfg(feature = "mermaid")]
use crate::mermaid;
#[cfg(feature = "math")]
use crate::render::InlineMathRef;
use crate::render::{
CalloutHeader, ImageRef, InlineImageRef, LineRender, TableCtx, build_cell_render,
build_line_render,
};
use crate::segment_map::SegmentMap;
use crate::table::{Align, RowKind, TableInfo, TableRow};
use crate::text_engine::{
StyleRun, TextEngine, display_range_selection, peniko_color, peniko_color_alpha,
};
use crate::validation::GitHubValidationCache;
pub type ScreenRect = (f64, f64, f64, f64);
pub struct PreeditView<'a> {
pub text: &'a str,
pub cursor: Option<(usize, usize)>,
}
pub struct GithubRenderData<'a> {
pub refs_by_line: &'a HashMap<usize, Vec<RawGitHubMatch>>,
pub urls_by_line: &'a HashMap<usize, Vec<NakedUrl>>,
pub cache: &'a GitHubValidationCache,
pub context: Option<&'a GitHubContext>,
}
impl GithubRenderData<'_> {
fn extra_regions(&self, line: usize) -> Vec<StyledRegion> {
let mut v = self
.refs_by_line
.get(&line)
.map(|m| github_refs_to_styled_regions(m, self.cache))
.unwrap_or_default();
if let Some(urls) = self.urls_by_line.get(&line) {
v.extend(naked_urls_to_styled_regions(urls, self.cache, self.context));
}
v
}
}
fn line_key(
text: &str,
scale: f32,
font_size: f32,
line_height: f32,
max_advance: f32,
runs: &[StyleRun],
content_start: usize,
) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
scale.to_bits().hash(&mut h);
font_size.to_bits().hash(&mut h);
line_height.to_bits().hash(&mut h);
max_advance.to_bits().hash(&mut h);
content_start.hash(&mut h);
for r in runs {
r.range.start.hash(&mut h);
r.range.end.hash(&mut h);
for c in r.color.components {
c.to_bits().hash(&mut h);
}
(r.bold, r.italic, r.mono, r.underline, r.strikethrough).hash(&mut h);
}
h.finish()
}
pub struct SweptCache<V> {
map: HashMap<u64, V>,
used: HashSet<u64>,
}
impl<V> Default for SweptCache<V> {
fn default() -> Self {
Self {
map: HashMap::new(),
used: HashSet::new(),
}
}
}
impl<V: Clone> SweptCache<V> {
pub fn new() -> Self {
Self::default()
}
fn begin(&mut self) {
self.used.clear();
}
fn get(&mut self, key: u64) -> Option<V> {
let v = self.map.get(&key).cloned();
if v.is_some() {
self.used.insert(key);
}
v
}
fn get_or_build(&mut self, key: u64, build: impl FnOnce() -> V) -> V {
self.used.insert(key);
self.map.entry(key).or_insert_with(build).clone()
}
fn set(&mut self, key: u64, value: V) {
self.used.insert(key);
self.map.insert(key, value);
}
fn sweep(&mut self) {
let used = &self.used;
self.map.retain(|k, _| used.contains(k));
}
}
pub type LineCache = SweptCache<Rc<parley::Layout<Brush>>>;
#[cfg(any(feature = "mermaid", feature = "math"))]
fn ranges_overlap(a: &Range<usize>, b: &Range<usize>) -> bool {
a.start < b.end && b.start < a.end
}
fn render_key(version: u64, line_idx: usize, cursor_key: usize) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
version.hash(&mut h);
line_idx.hash(&mut h);
cursor_key.hash(&mut h);
h.finish()
}
const REVEAL_ELSEWHERE: usize = usize::MAX - 1;
fn cursor_key_for(range: &Range<usize>, cursor: usize) -> usize {
let on = if range.start == range.end {
cursor == range.start
} else {
cursor >= range.start && cursor <= range.end
};
if on { cursor } else { usize::MAX }
}
pub type RenderCache = SweptCache<Rc<LineRender>>;
pub type HeightCache = SweptCache<f32>;
struct CellSlot {
layout: Rc<parley::Layout<Brush>>,
map: SegmentMap,
empty_landing: Option<usize>,
code_ranges: Vec<Range<usize>>,
}
type CellLayout = Option<CellSlot>;
type GridLayouts = Vec<Vec<CellLayout>>;
struct MeasuredCell {
text: String,
runs: Vec<StyleRun>,
map: SegmentMap,
empty_landing: Option<usize>,
code_ranges: Vec<Range<usize>>,
}
type CellData = Option<MeasuredCell>;
type GridCells = Vec<Vec<CellData>>;
fn cell_caret_landing(rope: &Rope, content: &Range<usize>) -> usize {
if content.start < content.end {
return content.start;
}
let closing = content.start;
let mut open = closing;
while open > 0 && rope.get_byte(open - 1) != Some(b'|') {
open -= 1;
}
(open + 1).min(closing.saturating_sub(1)).max(open)
}
pub struct TableLayout {
col_x: Vec<f32>,
col_w: Vec<f32>,
aligns: Vec<Align>,
row_heights: Vec<f32>,
delim_height: f32,
row_layouts: GridLayouts,
}
impl TableLayout {
fn row_index(kind: RowKind) -> Option<usize> {
match kind {
RowKind::Header => Some(0),
RowKind::Body(i) => Some(i + 1),
RowKind::Delimiter => None,
}
}
fn height(&self, kind: RowKind) -> f32 {
match Self::row_index(kind) {
Some(r) => self.row_heights.get(r).copied().unwrap_or(0.0),
None => self.delim_height,
}
}
fn hit_test(&self, kind: RowKind, lx: f32, cell_pad_x: f32) -> Option<usize> {
let row_idx = Self::row_index(kind)?;
let row = self.row_layouts.get(row_idx)?;
let cols = self.col_x.len();
if cols == 0 {
return None;
}
let mut col = self
.col_x
.partition_point(|&cx| cx <= lx)
.saturating_sub(1)
.min(cols - 1);
while col > 0 && row.get(col).map(|c| c.is_none()).unwrap_or(true) {
col -= 1;
}
let slot = row.get(col)?.as_ref()?;
if let Some(landing) = slot.empty_landing {
return Some(landing);
}
let align = self.aligns.get(col).copied().unwrap_or(Align::Left);
let extra = (self.col_w[col] - slot.layout.width()).max(0.0);
let shift = match align {
Align::Left => 0.0,
Align::Right => extra,
Align::Center => extra / 2.0,
};
let local_x = (lx - (self.col_x[col] + cell_pad_x + shift)).max(0.0);
let display_off = Cursor::from_point(&slot.layout, local_x, 0.0).index();
Some(slot.map.display_to_buffer(display_off))
}
}
pub type TableCache = SweptCache<Rc<TableLayout>>;
const TABLE_CELL_PAD_X: f32 = 8.0;
const TABLE_CELL_PAD_Y: f32 = 4.0;
const TABLE_BORDER: f32 = 1.0;
const TABLE_COL_CAP_FACTOR: f32 = 2.5;
const TABLE_MAX_BODY_ROWS: usize = 200;
const TABLE_MAX_CELLS: usize = 1000;
fn table_grid_ok(t: &TableInfo) -> bool {
t.body.len() <= TABLE_MAX_BODY_ROWS
&& (t.body.len() + 1).saturating_mul(t.ncols.max(1)) <= TABLE_MAX_CELLS
}
fn table_key(
content_hash: u64,
block_start: usize,
scale: f32,
font_size: f32,
max_advance: f32,
) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
content_hash.hash(&mut h);
block_start.hash(&mut h);
scale.to_bits().hash(&mut h);
font_size.to_bits().hash(&mut h);
max_advance.to_bits().hash(&mut h);
h.finish()
}
fn table_content_hash(snapshot: &RenderSnapshot, block: &Range<usize>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let rope = &snapshot.rope;
let end = block.end.min(rope.len_bytes());
if block.start < end {
for chunk in rope
.slice(rope.byte_to_char(block.start)..rope.byte_to_char(end))
.chunks()
{
chunk.hash(&mut h);
}
}
h.finish()
}
fn build_table_layout(
engine: &mut TextEngine,
snapshot: &RenderSnapshot,
theme: &EditorTheme,
table: &TableInfo,
line_styles: &[Vec<StyledRegion>],
params: &LayoutParams,
max_advance: f32,
) -> TableLayout {
let LayoutParams {
scale,
base_font_size,
line_height,
fg,
..
} = *params;
let ncols = table.ncols.max(1);
let cell_pad_x = TABLE_CELL_PAD_X * scale;
let cell_pad_y = TABLE_CELL_PAD_Y * scale;
let border = TABLE_BORDER * scale;
let n_lines = snapshot.line_count();
let rows: Vec<&TableRow> = std::iter::once(&table.header)
.chain(table.body.iter())
.collect();
let mut grid: GridCells = Vec::with_capacity(rows.len());
let mut natural = vec![0f32; ncols];
for (r, row) in rows.iter().enumerate() {
let line_idx = snapshot
.rope
.byte_to_line(row.line.start)
.min(n_lines.saturating_sub(1));
let empty: Vec<StyledRegion> = Vec::new();
let styles = line_styles.get(line_idx).unwrap_or(&empty);
let is_header = r == 0;
let mut rowdata = Vec::with_capacity(ncols);
#[allow(clippy::needless_range_loop)]
for c in 0..ncols {
match row.cells.get(c) {
Some(cell) => {
let (text, mut runs, map, code_ranges) =
build_cell_render(snapshot, theme, cell.content.clone(), styles);
if is_header && !text.is_empty() {
let mut base = StyleRun::new(0..text.len(), fg);
base.bold = true;
runs.insert(0, base);
}
let w = engine
.build_line(&text, scale, base_font_size, line_height, fg, None, &runs)
.width();
natural[c] = natural[c].max(w);
let empty_landing = (cell.content.start >= cell.content.end)
.then(|| cell_caret_landing(&snapshot.rope, &cell.content));
rowdata.push(Some(MeasuredCell {
text,
runs,
map,
empty_landing,
code_ranges,
}));
}
None => rowdata.push(None),
}
}
grid.push(rowdata);
}
let overhead = ncols as f32 * 2.0 * cell_pad_x + (ncols as f32 + 1.0) * border;
let avail = (max_advance - overhead).max(1.0);
let col_cap = (avail / ncols as f32) * TABLE_COL_CAP_FACTOR;
let mut col_w: Vec<f32> = natural.iter().map(|&w| w.min(col_cap).max(1.0)).collect();
let sum: f32 = col_w.iter().sum();
if sum > avail {
let s = avail / sum;
for w in &mut col_w {
*w *= s;
}
}
let mut col_x = Vec::with_capacity(ncols);
let mut x = border;
for &w in &col_w {
col_x.push(x);
x += w + 2.0 * cell_pad_x + border;
}
let mut row_layouts: GridLayouts = Vec::with_capacity(rows.len());
let mut row_heights = Vec::with_capacity(rows.len());
for rowdata in grid {
let mut layouts = Vec::with_capacity(ncols);
let mut max_h = 0f32;
for (c, cell) in rowdata.into_iter().enumerate() {
match cell {
Some(MeasuredCell {
text,
runs,
map,
empty_landing,
code_ranges,
}) => {
let layout = Rc::new(engine.build_line(
&text,
scale,
base_font_size,
line_height,
fg,
Some(col_w[c].max(1.0)),
&runs,
));
max_h = max_h.max(layout.height());
layouts.push(Some(CellSlot {
layout,
map,
empty_landing,
code_ranges,
}));
}
None => layouts.push(None),
}
}
if max_h <= 0.0 {
max_h = base_font_size * line_height * scale;
}
row_heights.push(max_h + 2.0 * cell_pad_y);
row_layouts.push(layouts);
}
TableLayout {
col_x,
col_w,
aligns: table.aligns.clone(),
row_heights,
delim_height: 0.0,
row_layouts,
}
}
const IMG_VPAD: f32 = 8.0;
const IMG_PLACEHOLDER_H: f32 = 120.0;
const INLINE_IMG_PLACEHOLDER_W: f32 = 80.0;
const INLINE_IMG_PLACEHOLDER_H: f32 = 20.0;
struct ImageBlock {
alt: String,
kind: ImageBlockKind,
}
enum ImageBlockKind {
Loaded {
brush: ImageBrush,
nat_w: f32,
nat_h: f32,
dest_w: f32,
dest_h: f32,
},
Loading,
Failed {
reason: Option<Arc<str>>,
},
}
struct InlineImageDraw {
brush: Option<(ImageBrush, f32, f32)>,
baseline_offset: f32,
}
#[derive(Default)]
struct LineDiff {
is_addition: bool,
inline: Vec<Range<usize>>,
}
struct Ghost {
layout: parley::Layout<Brush>,
height: f32,
inline: Vec<Range<usize>>,
}
struct DiffColors {
added_bg: Color,
added_inline: Color,
deleted_bg: Color,
deleted_inline: Color,
}
pub struct LayoutParams {
pub content_x0: f32,
pub content_w: f32,
pub scale: f32,
pub pad_x: f32,
pub pad_top: f32,
pub pad_bottom: f32,
pub base_font_size: f32,
pub line_height: f32,
pub fg: Color,
}
const GHOST_SHAPE_CAP: usize = 300;
fn estimate_ghost_height(diff: Option<&DiffState>, new_line: usize, min_row: f32) -> f32 {
diff.and_then(|d| d.ghost_lines_before(new_line))
.map(|r| r.len() as f32 * min_row)
.unwrap_or(0.0)
}
fn build_ghosts_before(
engine: &mut TextEngine,
diff: Option<&DiffState>,
new_line: usize,
theme: &EditorTheme,
params: &LayoutParams,
max_advance: f32,
min_row: f32,
) -> (Vec<Ghost>, f32) {
let LayoutParams {
scale,
base_font_size,
line_height,
fg,
..
} = *params;
let Some(d) = diff else {
return (Vec::new(), 0.0);
};
let Some(old_range) = d.ghost_lines_before(new_line) else {
return (Vec::new(), 0.0);
};
let old = &d.old_snapshot;
let count = old_range.len();
let shape_from = old_range.start + count.saturating_sub(GHOST_SHAPE_CAP);
let mut block_height = (shape_from - old_range.start) as f32 * min_row;
let mut out = Vec::new();
for old_line in shape_from..old_range.end {
if old_line >= old.line_count() {
break;
}
let styles = old.tree_styles_for_line(old_line);
let lr = build_line_render(
old,
old_line,
theme,
base_font_size,
usize::MAX,
&styles,
&[],
None,
&[],
&[],
None,
);
let layout = engine.build_line_hanging(
&lr.text,
scale,
lr.font_size,
line_height,
fg,
Some(max_advance),
&lr.runs,
lr.content_start,
&[],
);
let line_start = old.line_markers(old_line).range.start;
let inline = d
.old_inline_changes(old_line)
.map(|changes| map_changes_to_display(&lr.map, line_start, changes))
.unwrap_or_default();
block_height += layout.height();
out.push(Ghost {
height: layout.height(),
layout,
inline,
});
}
(out, block_height)
}
fn map_changes_to_display(
map: &SegmentMap,
base: usize,
changes: &[InlineChange],
) -> Vec<Range<usize>> {
changes
.iter()
.filter_map(|c| {
let dr = map.buffer_range_to_display(base + c.range.start..base + c.range.end);
(!dr.is_empty()).then_some(dr)
})
.collect()
}
fn build_image_block(
images: &ImageCache,
img: &ImageRef,
content_w: f32,
scale: f32,
vpad: f32,
) -> (ImageBlock, f32) {
match images.get(&img.url) {
Some(ImageState::Loaded(loaded)) => {
let iw = loaded.display_w * scale;
let ih = loaded.display_h * scale;
let dw = iw.min(content_w);
let dh = if iw > 0.0 { ih * dw / iw } else { 0.0 };
let block = ImageBlock {
alt: img.alt.clone(),
kind: ImageBlockKind::Loaded {
brush: loaded.brush.clone(),
nat_w: loaded.width as f32,
nat_h: loaded.height as f32,
dest_w: dw,
dest_h: dh,
},
};
(block, dh + 2.0 * vpad)
}
Some(ImageState::Failed(reason)) => (
ImageBlock {
alt: img.alt.clone(),
kind: ImageBlockKind::Failed { reason },
},
IMG_PLACEHOLDER_H * scale,
),
Some(ImageState::Loading) | None => (
ImageBlock {
alt: img.alt.clone(),
kind: ImageBlockKind::Loading,
},
IMG_PLACEHOLDER_H * scale,
),
}
}
fn resolve_inline_images(
inline_images: &[InlineImageRef],
images: &ImageCache,
image_urls: &mut Vec<String>,
max_advance: f32,
scale: f32,
) -> (Vec<(usize, f32, f32)>, Vec<InlineImageDraw>) {
let mut inline_boxes: Vec<(usize, f32, f32)> = Vec::new();
let draws: Vec<InlineImageDraw> = inline_images
.iter()
.map(|ii| {
if !image_urls.iter().any(|u| u == &ii.url) {
image_urls.push(ii.url.clone());
}
let (w, h, draw) = match images.get(&ii.url) {
Some(ImageState::Loaded(loaded)) => {
let mut w = loaded.display_w * scale;
let mut h = loaded.display_h * scale;
if w > max_advance && w > 0.0 {
h *= max_advance / w;
w = max_advance;
}
let brush = Some((
loaded.brush.clone(),
loaded.width as f32,
loaded.height as f32,
));
(
w,
h,
InlineImageDraw {
brush,
baseline_offset: 0.0,
},
)
}
_ => (
INLINE_IMG_PLACEHOLDER_W * scale,
INLINE_IMG_PLACEHOLDER_H * scale,
InlineImageDraw {
brush: None,
baseline_offset: 0.0,
},
),
};
inline_boxes.push((ii.display_offset, w, h));
draw
})
.collect();
(inline_boxes, draws)
}
#[cfg(feature = "math")]
fn resolve_inline_math(
inline_math: &[InlineMathRef],
images: &ImageCache,
math_sources: &mut Vec<(String, math::MathJob)>,
font_px: f32,
fg: (f32, f32, f32),
max_advance: f32,
scale: f32,
) -> (Vec<(usize, f32, f32)>, Vec<InlineImageDraw>) {
let mut inline_boxes: Vec<(usize, f32, f32)> = Vec::new();
let draws: Vec<InlineImageDraw> = inline_math
.iter()
.map(|im| {
let job = math::MathJob {
latex: im.latex.clone(),
display: false,
font_px,
scale,
fg,
};
let key = math::key_for(&job);
if !math_sources.iter().any(|(k, _)| k == &key) {
math_sources.push((key.clone(), job));
}
let (w, h, draw) = match images.get(&key) {
Some(ImageState::Loaded(loaded)) => {
let mut w = loaded.display_w * scale;
let mut h = loaded.display_h * scale;
let mut descent =
(loaded.display_h - loaded.baseline.unwrap_or(loaded.display_h)) * scale;
if w > max_advance && w > 0.0 {
let s = max_advance / w;
w = max_advance;
h *= s;
descent *= s;
}
let brush = Some((
loaded.brush.clone(),
loaded.width as f32,
loaded.height as f32,
));
(
w,
h,
InlineImageDraw {
brush,
baseline_offset: descent,
},
)
}
_ => (
INLINE_IMG_PLACEHOLDER_W * scale,
INLINE_IMG_PLACEHOLDER_H * scale,
InlineImageDraw {
brush: None,
baseline_offset: 0.0,
},
),
};
inline_boxes.push((im.display_offset, w, h));
draw
})
.collect();
(inline_boxes, draws)
}
fn splice_preedit(
text: &str,
runs: &[StyleRun],
caret_disp: usize,
preedit: &str,
fg: Color,
) -> (String, Vec<StyleRun>) {
let spliced_text = format!("{}{}{}", &text[..caret_disp], preedit, &text[caret_disp..]);
let shift = preedit.len();
let mut spliced_runs: Vec<StyleRun> = runs
.iter()
.map(|r| {
let mut r = r.clone();
if r.range.start >= caret_disp {
r.range = r.range.start + shift..r.range.end + shift;
}
r
})
.collect();
let mut under = StyleRun::new(caret_disp..caret_disp + shift, fg);
under.underline = true;
under.mono = true;
spliced_runs.push(under);
(spliced_text, spliced_runs)
}
fn compute_tops(heights: &[f32], pad_top: f32) -> Vec<f32> {
let mut tops = Vec::with_capacity(heights.len() + 1);
let mut y = pad_top;
tops.push(y);
for &h in heights {
y += h;
tops.push(y);
}
tops
}
const MEASURE_OVERSCAN_PX: f32 = 800.0;
const MEASURE_OVERSCAN_LINES: usize = 30;
struct Bands {
heights: Vec<f32>,
layouts: Vec<Rc<parley::Layout<Brush>>>,
renders: Vec<Rc<LineRender>>,
line_ranges: Vec<Range<usize>>,
line_diffs: Vec<LineDiff>,
ghosts: Vec<Vec<Ghost>>,
ghost_height: Vec<f32>,
quote_bars: Vec<Vec<f32>>,
image_blocks: Vec<Option<ImageBlock>>,
inline_draws: Vec<Vec<InlineImageDraw>>,
table_lines: Vec<Option<(Rc<TableLayout>, RowKind)>>,
empty_layout: Rc<parley::Layout<Brush>>,
empty_render: Rc<LineRender>,
}
impl Bands {
fn new(
n: usize,
empty_layout: Rc<parley::Layout<Brush>>,
empty_render: Rc<LineRender>,
) -> Self {
Bands {
heights: Vec::with_capacity(n),
layouts: Vec::with_capacity(n),
renders: Vec::with_capacity(n),
line_ranges: Vec::with_capacity(n),
line_diffs: Vec::with_capacity(n),
ghosts: Vec::with_capacity(n),
ghost_height: Vec::with_capacity(n),
quote_bars: Vec::with_capacity(n),
image_blocks: Vec::with_capacity(n),
inline_draws: Vec::with_capacity(n),
table_lines: Vec::with_capacity(n),
empty_layout,
empty_render,
}
}
fn push_placeholder(
&mut self,
height: f32,
range: Range<usize>,
image_block: Option<ImageBlock>,
) {
self.heights.push(height);
self.layouts.push(self.empty_layout.clone());
self.renders.push(self.empty_render.clone());
self.line_ranges.push(range);
self.line_diffs.push(LineDiff::default());
self.ghosts.push(Vec::new());
self.ghost_height.push(0.0);
self.quote_bars.push(Vec::new());
self.image_blocks.push(image_block);
self.inline_draws.push(Vec::new());
self.table_lines.push(None);
}
}
pub struct DocLayout {
layouts: Vec<Rc<parley::Layout<Brush>>>,
renders: Vec<Rc<LineRender>>,
line_ranges: Vec<Range<usize>>,
line_diffs: Vec<LineDiff>,
ghosts: Vec<Vec<Ghost>>,
image_blocks: Vec<Option<ImageBlock>>,
inline_draws: Vec<Vec<InlineImageDraw>>,
image_urls: Vec<String>,
#[cfg(feature = "mermaid")]
mermaid_sources: Vec<(String, String)>,
#[cfg(feature = "math")]
math_sources: Vec<(String, math::MathJob)>,
img_vpad: f32,
img_label_size: f32,
image_border: Color,
image_bg: Color,
code_bg: Color,
table_lines: Vec<Option<(Rc<TableLayout>, RowKind)>>,
table_cell_pad_x: f32,
table_cell_pad_y: f32,
table_border: f32,
table_border_color: Color,
table_bg: Color,
table_header_bg: Color,
quote_bars: Vec<Vec<f32>>,
ghost_height: Vec<f32>,
trailing_ghosts: Vec<Ghost>,
trailing_ghost_height: f32,
tops: Vec<f32>,
measured_start: usize,
measured_count: usize,
preedit_caret: Option<(usize, usize)>,
diff_colors: DiffColors,
quote_bar_width: f32,
quote_bar_color: Color,
pub scroll_y: f32,
width: f32,
pad_top: f32,
pad_bottom: f32,
pad_x: f32,
}
impl DocLayout {
#[allow(clippy::too_many_arguments)]
pub fn build(
engine: &mut TextEngine,
cache: &mut LineCache,
render_cache: &mut RenderCache,
height_cache: &mut HeightCache,
table_cache: &mut TableCache,
version: u64,
snapshot: &RenderSnapshot,
theme: &EditorTheme,
diff: Option<&DiffState>,
github: Option<&GithubRenderData>,
images: &ImageCache,
cursor_offset: usize,
params: &LayoutParams,
preedit: Option<&PreeditView>,
anchor_line: usize,
viewport_h: f32,
folds: &[Range<usize>],
selection: &Range<usize>,
math_spans: &HashMap<usize, Vec<MathSpan>>,
) -> Self {
#[cfg(not(any(feature = "mermaid", feature = "math")))]
let _ = selection;
let LayoutParams {
content_x0,
content_w,
scale,
pad_x,
pad_top,
pad_bottom,
base_font_size,
line_height,
fg,
} = *params;
let inset = (content_w - MAX_CONTENT_WIDTH * scale).max(0.0) / 2.0;
let left = content_x0 + inset.max(pad_x * scale);
let max_advance = (content_w - 2.0 * (left - content_x0)).max(1.0);
let content_right = content_x0 + content_w;
let n = snapshot.line_count();
cache.begin();
render_cache.begin();
height_cache.begin();
table_cache.begin();
let cal_text = "the quick brown fox jumps over the lazy dog and then runs along";
let cal = engine.build_line(cal_text, scale, base_font_size, line_height, fg, None, &[]);
let k = (cal.width() / cal_text.chars().count().max(1) as f32).max(0.1);
let min_row = base_font_size * line_height * scale;
let cursor_line = snapshot
.rope
.byte_to_line(cursor_offset.min(snapshot.rope.len_bytes()));
let cursor_line_start = snapshot.line_byte_range(cursor_line).start;
let empty_layout: Rc<parley::Layout<Brush>> = Rc::new(parley::Layout::new());
let empty_render: Rc<LineRender> = Rc::new(LineRender {
text: String::new(),
font_size: base_font_size,
runs: Vec::new(),
map: SegmentMap::identity("", 0).1,
content_start: 0,
quote_bar_bytes: Vec::new(),
is_hr: false,
image: None,
code_ranges: Vec::new(),
inline_images: Vec::new(),
inline_math: Vec::new(),
table: None,
});
let measure_from_line = anchor_line.saturating_sub(MEASURE_OVERSCAN_LINES);
let band_span = viewport_h + MEASURE_OVERSCAN_PX;
let mut band_y = 0.0f32; let mut past_band = false; let mut measured_start = n; let mut measured_count = n; let mut fold_idx = 0usize; let mut preedit_caret: Option<(usize, usize)> = None;
let line_styles = snapshot.inline_styles_by_line();
#[cfg(feature = "mermaid")]
let mermaid_blocks = snapshot.mermaid_blocks();
let mut bands = Bands::new(n, empty_layout.clone(), empty_render.clone());
let mut image_urls: Vec<String> = Vec::new();
#[cfg(feature = "mermaid")]
let mut mermaid_sources: Vec<(String, String)> = Vec::new();
#[cfg(feature = "math")]
let mut math_sources: Vec<(String, math::MathJob)> = Vec::new();
#[cfg(feature = "math")]
let math_blocks = snapshot.math_blocks();
#[cfg(feature = "math")]
let math_fg = {
let c = fg.components;
(c[0], c[1], c[2])
};
let content_w = max_advance;
let img_vpad = IMG_VPAD * scale;
let cursor_component = |i: usize, range: &Range<usize>| -> usize {
let onkey = cursor_key_for(range, cursor_offset);
match snapshot
.table_row_at_line(i)
.filter(|(t, _)| table_grid_ok(t))
{
Some((t, _)) => {
if !t.block.contains(&cursor_line_start) {
usize::MAX
} else if onkey != usize::MAX {
onkey
} else {
REVEAL_ELSEWHERE
}
}
None => onkey,
}
};
#[allow(clippy::needless_range_loop)]
for i in 0..n {
while fold_idx < folds.len() && folds[fold_idx].end <= i {
fold_idx += 1;
}
if fold_idx < folds.len() && folds[fold_idx].start <= i {
bands.push_placeholder(0.0, snapshot.line_byte_range(i), None);
continue;
}
#[cfg(feature = "mermaid")]
let mermaid = {
let ls = snapshot.line_byte_range(i).start;
mermaid_blocks
.iter()
.find(|m| m.block.contains(&ls))
.filter(|m| {
let selected = ranges_overlap(&m.block, selection);
!selected && !m.block.contains(&cursor_line_start)
})
};
#[cfg(feature = "mermaid")]
if let Some(m) = &mermaid
&& i != m.anchor_line
{
bands.push_placeholder(0.0, snapshot.line_byte_range(i), None);
continue;
}
#[cfg(feature = "math")]
let math_block = {
let ls = snapshot.line_byte_range(i).start;
math_blocks
.iter()
.find(|m| m.block.contains(&ls))
.filter(|m| {
let selected = ranges_overlap(&m.block, selection);
!selected && !m.block.contains(&cursor_line_start)
})
};
#[cfg(feature = "math")]
if let Some(m) = &math_block
&& i != m.anchor_line
{
bands.push_placeholder(0.0, snapshot.line_byte_range(i), None);
continue;
}
if i >= measure_from_line && !past_band && measured_start == n {
measured_start = i;
}
let estimate = i < measure_from_line || past_band;
if estimate {
let range = snapshot.line_byte_range(i);
let rkey = render_key(version, i, cursor_component(i, &range));
let h = height_cache.get(rkey).unwrap_or_else(|| {
let byte_len = range.len().saturating_sub(1) as f32; let est_rows = (byte_len * k / max_advance).ceil().max(1.0);
est_rows * min_row
});
let gh = estimate_ghost_height(diff, i, min_row);
bands.heights.push(gh + h);
bands.layouts.push(empty_layout.clone());
bands.renders.push(empty_render.clone());
bands.line_ranges.push(range);
bands.line_diffs.push(LineDiff::default());
bands.ghosts.push(Vec::new());
bands.ghost_height.push(gh);
bands.quote_bars.push(Vec::new());
bands.image_blocks.push(None);
bands.inline_draws.push(Vec::new());
bands.table_lines.push(None);
continue;
}
#[cfg(feature = "mermaid")]
if let Some(m) = &mermaid {
let source = snapshot
.rope
.slice(
snapshot.rope.byte_to_char(m.content.start)
..snapshot.rope.byte_to_char(m.content.end),
)
.to_string();
let key = mermaid::key_for(&source);
if !mermaid_sources.iter().any(|(k, _)| k == &key) {
mermaid_sources.push((key.clone(), source));
}
let img = ImageRef {
url: key,
alt: "mermaid diagram".to_string(),
};
let (block, block_h) = build_image_block(images, &img, content_w, scale, img_vpad);
let range = snapshot.line_byte_range(i);
height_cache.set(render_key(version, i, cursor_component(i, &range)), block_h);
if i >= anchor_line {
band_y += block_h;
if band_y >= band_span {
past_band = true;
measured_count = i + 1;
}
}
bands.push_placeholder(block_h, range, Some(block));
continue;
}
#[cfg(feature = "math")]
if let Some(m) = &math_block {
let latex = snapshot
.rope
.slice(
snapshot.rope.byte_to_char(m.content.start)
..snapshot.rope.byte_to_char(m.content.end),
)
.to_string();
let job = math::MathJob {
latex,
display: true,
font_px: base_font_size * 1.1,
scale,
fg: math_fg,
};
let key = math::key_for(&job);
if !math_sources.iter().any(|(k, _)| k == &key) {
math_sources.push((key.clone(), job));
}
let img = ImageRef {
url: key,
alt: "math".to_string(),
};
let (block, block_h) = build_image_block(images, &img, content_w, scale, img_vpad);
let range = snapshot.line_byte_range(i);
height_cache.set(render_key(version, i, cursor_component(i, &range)), block_h);
if i >= anchor_line {
band_y += block_h;
if band_y >= band_span {
past_band = true;
measured_count = i + 1;
}
}
bands.push_placeholder(block_h, range, Some(block));
continue;
}
let (line_ghosts, gh) =
build_ghosts_before(engine, diff, i, theme, params, max_advance, min_row);
let range = snapshot.line_byte_range(i);
let rkey = render_key(version, i, cursor_component(i, &range));
let table_ctx = snapshot
.table_row_at_line(i)
.filter(|(t, _)| table_grid_ok(t))
.map(|(t, kind)| TableCtx {
block: t.block.clone(),
kind,
});
let callout_ctx = snapshot.callout_header_at_line(i).map(|c| CalloutHeader {
display: format!("{} {}", c.kind.icon(), c.title),
color: peniko_color(c.kind.color(theme)),
});
let extra = github.map(|g| g.extra_regions(i)).unwrap_or_default();
#[cfg(feature = "math")]
let latex_ranges: Vec<Range<usize>> = {
let mut lr = Vec::new();
if let Some(spans) = math_spans.get(&i) {
for s in spans {
if cursor_offset >= s.full_range.start && cursor_offset <= s.full_range.end
{
let a = s.content_range.start.max(range.start);
let b = s.content_range.end.min(range.end);
if a < b {
lr.push(a..b);
}
}
}
}
for m in &math_blocks {
let revealed = (ranges_overlap(&m.block, selection))
|| m.block.contains(&cursor_line_start);
if revealed {
let a = m.content.start.max(range.start);
let b = m.content.end.min(range.end);
if a < b {
lr.push(a..b);
}
}
}
lr
};
#[cfg(not(feature = "math"))]
let latex_ranges: Vec<Range<usize>> = Vec::new();
let lr = if extra.is_empty() {
let tc = table_ctx.clone();
let cc = callout_ctx.clone();
render_cache.get_or_build(rkey, || {
Rc::new(build_line_render(
snapshot,
i,
theme,
base_font_size,
cursor_offset,
&line_styles[i],
&[],
tc,
math_spans.get(&i).map_or(&[][..], |v| v.as_slice()),
&latex_ranges,
cc,
))
})
} else {
Rc::new(build_line_render(
snapshot,
i,
theme,
base_font_size,
cursor_offset,
&line_styles[i],
&extra,
table_ctx.clone(),
math_spans.get(&i).map_or(&[][..], |v| v.as_slice()),
&latex_ranges,
callout_ctx,
))
};
#[cfg_attr(not(feature = "math"), allow(unused_mut))]
let (mut inline_boxes, mut line_inline_draws) = resolve_inline_images(
&lr.inline_images,
images,
&mut image_urls,
max_advance,
scale,
);
#[cfg(feature = "math")]
if !lr.inline_math.is_empty() {
let (math_boxes, math_draws) = resolve_inline_math(
&lr.inline_math,
images,
&mut math_sources,
lr.font_size,
math_fg,
max_advance,
scale,
);
inline_boxes.extend(math_boxes);
line_inline_draws.extend(math_draws);
}
let key = line_key(
&lr.text,
scale,
lr.font_size,
line_height,
max_advance,
&lr.runs,
lr.content_start,
);
let layout = if inline_boxes.is_empty() {
cache.get_or_build(key, || {
Rc::new(engine.build_line_hanging(
&lr.text,
scale,
lr.font_size,
line_height,
fg,
Some(max_advance),
&lr.runs,
lr.content_start,
&[],
))
})
} else {
Rc::new(engine.build_line_hanging(
&lr.text,
scale,
lr.font_size,
line_height,
fg,
Some(max_advance),
&lr.runs,
lr.content_start,
&inline_boxes,
))
};
let layout = match preedit {
Some(pv) if i == cursor_line => {
let caret_disp = lr.map.buffer_to_display(cursor_offset);
let (sp_text, sp_runs) =
splice_preedit(&lr.text, &lr.runs, caret_disp, pv.text, fg);
let caret_off = caret_disp + pv.cursor.map(|(s, _)| s).unwrap_or(pv.text.len());
preedit_caret = Some((i, caret_off));
Rc::new(engine.build_line_hanging(
&sp_text,
scale,
lr.font_size,
line_height,
fg,
Some(max_advance),
&sp_runs,
lr.content_start,
&[],
))
}
_ => layout,
};
let line_diff = match diff {
Some(d) if d.is_addition(i) => {
let inline = d
.new_inline_changes(i)
.map(|changes| map_changes_to_display(&lr.map, range.start, changes))
.unwrap_or_default();
LineDiff {
is_addition: true,
inline,
}
}
_ => LineDiff::default(),
};
let bars: Vec<f32> = lr
.quote_bar_bytes
.iter()
.filter_map(|&b| {
Cluster::from_byte_index(&layout, b).and_then(|c| c.visual_offset())
})
.collect();
let table_line: Option<(Rc<TableLayout>, RowKind)> = if lr.table.is_some() {
snapshot
.table_row_at_line(i)
.filter(|(t, _)| table_grid_ok(t))
.map(|(t, kind)| {
let tkey = table_key(
table_content_hash(snapshot, &t.block),
t.block.start,
scale,
base_font_size,
max_advance,
);
let tl = table_cache.get_or_build(tkey, || {
Rc::new(build_table_layout(
engine,
snapshot,
theme,
t,
&line_styles,
params,
max_advance,
))
});
(tl, kind)
})
} else {
None
};
let (image_block, line_h) = if let Some((tl, kind)) = &table_line {
(None, tl.height(*kind))
} else {
match &lr.image {
Some(img) => {
if !image_urls.iter().any(|u| u == &img.url) {
image_urls.push(img.url.clone());
}
let (block, block_h) =
build_image_block(images, img, content_w, scale, img_vpad);
(Some(block), block_h)
}
None => (None, layout.height()),
}
};
height_cache.set(rkey, line_h);
let total_h = gh + line_h;
if i >= anchor_line {
band_y += total_h;
if band_y >= band_span {
past_band = true;
measured_count = i + 1;
}
}
bands.heights.push(total_h);
bands.layouts.push(layout);
bands.renders.push(lr);
bands.quote_bars.push(bars);
bands.line_ranges.push(range);
bands.line_diffs.push(line_diff);
bands.ghosts.push(line_ghosts);
bands.ghost_height.push(gh);
bands.image_blocks.push(image_block);
bands.inline_draws.push(line_inline_draws);
bands.table_lines.push(table_line);
}
cache.sweep();
render_cache.sweep();
height_cache.sweep();
table_cache.sweep();
let (trailing_ghosts, trailing_ghost_height) =
build_ghosts_before(engine, diff, n, theme, params, max_advance, min_row);
let diff_colors = DiffColors {
added_bg: peniko_color_alpha(theme.green, 0.15),
added_inline: peniko_color_alpha(theme.green, 0.40),
deleted_bg: peniko_color_alpha(theme.red, 0.15),
deleted_inline: peniko_color_alpha(theme.red, 0.40),
};
let Bands {
heights,
layouts,
renders,
line_ranges,
line_diffs,
ghosts,
ghost_height,
quote_bars,
image_blocks,
inline_draws,
table_lines,
..
} = bands;
Self {
layouts,
renders,
line_ranges,
line_diffs,
ghosts,
ghost_height,
trailing_ghosts,
trailing_ghost_height,
quote_bars,
image_blocks,
inline_draws,
image_urls,
#[cfg(feature = "mermaid")]
mermaid_sources,
#[cfg(feature = "math")]
math_sources,
img_vpad,
img_label_size: 14.0 * scale,
image_border: peniko_color(theme.comment),
image_bg: peniko_color_alpha(theme.comment, 0.08),
code_bg: Color::TRANSPARENT,
table_lines,
table_cell_pad_x: TABLE_CELL_PAD_X * scale,
table_cell_pad_y: TABLE_CELL_PAD_Y * scale,
table_border: TABLE_BORDER * scale,
table_border_color: peniko_color(theme.selection),
table_bg: peniko_color_alpha(theme.comment, 0.05),
table_header_bg: peniko_color(theme.surface),
diff_colors,
quote_bar_width: (k * 0.16).max(1.5),
quote_bar_color: peniko_color(theme.comment),
tops: compute_tops(&heights, pad_top * scale),
measured_start: measured_start.min(measured_count),
measured_count,
preedit_caret,
scroll_y: 0.0,
width: content_right,
pad_top: pad_top * scale,
pad_bottom: pad_bottom * scale,
pad_x: left,
}
}
pub fn line_map(&self, line: usize) -> Option<&SegmentMap> {
self.renders.get(line).map(|r| &r.map)
}
pub fn line_of(&self, buffer_off: usize) -> usize {
let n = self.line_ranges.len();
if n == 0 {
return 0;
}
self.line_ranges
.partition_point(|r| r.start <= buffer_off)
.saturating_sub(1)
.min(n - 1)
}
pub fn line_top_screen(&self, line: usize) -> Option<f32> {
(line < self.layouts.len()).then(|| self.real_top(line) - self.scroll_y)
}
pub fn line_text_height(&self, line: usize) -> f32 {
self.layouts.get(line).map(|l| l.height()).unwrap_or(0.0)
}
pub fn body_left(&self) -> f32 {
self.pad_x
}
fn real_top(&self, line: usize) -> f32 {
self.tops[line] + self.ghost_height[line]
}
pub fn is_image_line(&self, line: usize) -> bool {
self.renders.get(line).is_some_and(|r| r.image.is_some())
}
pub fn offset_in_line_at_x(&self, line: usize, x: f32) -> Option<usize> {
let layout = self.layouts.get(line)?;
let lx = (x - self.pad_x).max(0.0);
let display_off = Cursor::from_point(layout, lx, 0.0).index();
Some(self.renders[line].map.display_to_buffer(display_off))
}
fn caret_rect_at(
&self,
line: usize,
display_off: usize,
caret_width: f32,
) -> Option<ScreenRect> {
let layout = self.layouts.get(line)?;
let cursor = Cursor::from_byte_index(layout, display_off, Affinity::Downstream);
let bb = cursor.geometry(layout, caret_width);
let dx = self.pad_x as f64;
let dy = (self.real_top(line) - self.scroll_y) as f64;
Some((bb.x0 + dx, bb.y0 + dy, bb.x1 + dx, bb.y1 + dy))
}
pub fn caret_rect(&self, buffer_off: usize, caret_width: f32) -> Option<ScreenRect> {
let line = self.line_of(buffer_off);
let display_off = self.renders.get(line)?.map.buffer_to_display(buffer_off);
self.caret_rect_at(line, display_off, caret_width)
}
pub fn preedit_caret_rect(&self, caret_width: f32) -> Option<ScreenRect> {
let (line, display_off) = self.preedit_caret?;
self.caret_rect_at(line, display_off, caret_width)
}
pub fn selection_rects(&self, selection: Range<usize>) -> Vec<ScreenRect> {
if selection.start >= selection.end {
return Vec::new();
}
let first = self.line_of(selection.start);
let last = self.line_of(selection.end);
let mut rects = Vec::new();
for line in first..=last.min(self.layouts.len().saturating_sub(1)) {
let range = &self.line_ranges[line];
let s = selection.start.max(range.start);
let e = selection.end.min(range.end);
if s >= e {
continue;
}
let map = &self.renders[line].map;
let ds = map.buffer_to_display(s);
let de = map.buffer_to_display(e);
if ds >= de {
continue;
}
let layout = &self.layouts[line];
let sel = display_range_selection(layout, ds..de);
let dx = self.pad_x as f64;
let dy = (self.real_top(line) - self.scroll_y) as f64;
for (bb, _) in sel.geometry(layout) {
rects.push((bb.x0 + dx, bb.y0 + dy, bb.x1 + dx, bb.y1 + dy));
}
}
rects
}
pub fn hit_test(&self, x: f32, y: f32) -> Option<usize> {
let n = self.layouts.len();
if n == 0 {
return None;
}
let cy = y + self.scroll_y;
let line = self.tops[..n]
.partition_point(|&t| t <= cy)
.saturating_sub(1)
.min(n - 1);
let real_top = self.real_top(line);
if cy < real_top {
return Some(self.line_ranges[line].start);
}
let layout = &self.layouts[line];
let lx = (x - self.pad_x).max(0.0);
let ly = (cy - real_top).max(0.0);
if let Some((tl, kind)) = &self.table_lines[line]
&& let Some(off) = tl.hit_test(*kind, lx, self.table_cell_pad_x)
{
return Some(off);
}
if self.image_blocks[line].is_some() {
return Some(self.line_ranges[line].start);
}
let display_off = Cursor::from_point(layout, lx, ly).index();
Some(self.renders[line].map.display_to_buffer(display_off))
}
pub fn scroll_to(&mut self, buffer_off: usize, viewport_h: f32) {
let line = self.line_of(buffer_off);
let top = self.tops[line];
let bottom = self.tops[line + 1];
if top < self.scroll_y {
self.scroll_y = top;
} else if bottom > self.scroll_y + viewport_h {
self.scroll_y = bottom - viewport_h;
}
self.clamp_scroll(viewport_h);
}
pub fn scroll_line_to_top(&mut self, line: usize, viewport_h: f32) {
let idx = line.min(self.tops.len().saturating_sub(1));
self.scroll_y = self.tops[idx];
self.clamp_scroll(viewport_h);
}
pub fn line_count(&self) -> usize {
self.layouts.len()
}
pub fn needs_remeasure(&self, viewport_h: f32) -> bool {
let (first, last) = self.visible_range(viewport_h);
(self.measured_start > 0 && first < self.measured_start)
|| (self.measured_count < self.layouts.len() && last > self.measured_count)
}
pub fn scroll_anchor(&self) -> (usize, f32) {
let n = self.layouts.len();
if n == 0 {
return (0, 0.0);
}
let line = self.tops[1..]
.partition_point(|&b| b <= self.scroll_y)
.min(n - 1);
(line, self.scroll_y - self.tops[line])
}
pub fn anchor_scroll_y(&self, line: usize, offset: f32) -> f32 {
let line = line.min(self.tops.len().saturating_sub(1));
self.tops[line] + offset
}
pub fn content_height(&self) -> f32 {
self.tops.last().copied().unwrap_or(self.pad_top)
+ self.trailing_ghost_height
+ self.pad_bottom
}
pub fn max_scroll(&self, viewport_h: f32) -> f32 {
(self.content_height() - viewport_h).max(0.0)
}
pub fn scroll_by(&mut self, dy: f32, viewport_h: f32) {
self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll(viewport_h));
}
pub fn clamp_scroll(&mut self, viewport_h: f32) {
self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll(viewport_h));
}
pub fn visible_range(&self, viewport_h: f32) -> (usize, usize) {
let n = self.layouts.len();
if n == 0 {
return (0, 0);
}
let top = self.scroll_y;
let bottom = self.scroll_y + viewport_h;
let first = self.tops[1..].partition_point(|&b| b <= top).min(n);
let last = self.tops[..n].partition_point(|&t| t < bottom).min(n);
(first, last.max(first))
}
fn fill_display_ranges(
&self,
scene: &mut Scene,
layout: &parley::Layout<Brush>,
ranges: &[Range<usize>],
origin_x: f64,
origin_y: f64,
color: Color,
) {
for r in ranges {
let sel = display_range_selection(layout, r.clone());
for (bb, _) in sel.geometry(layout) {
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
color,
None,
&Rect::new(
bb.x0 + origin_x,
bb.y0 + origin_y,
bb.x1 + origin_x,
bb.y1 + origin_y,
),
);
}
}
}
fn fill_word_ranges(
&self,
scene: &mut Scene,
layout: &parley::Layout<Brush>,
ranges: &[Range<usize>],
top_y: f64,
color: Color,
) {
if color == Color::TRANSPARENT {
return;
}
self.fill_display_ranges(scene, layout, ranges, self.pad_x as f64, top_y, color);
}
fn draw_inline_images(&self, scene: &mut Scene, i: usize, gy: f32) {
let draws = &self.inline_draws[i];
if draws.is_empty() {
return;
}
for line in self.layouts[i].lines() {
for item in line.items() {
let PositionedLayoutItem::InlineBox(pib) = item else {
continue;
};
let Some(draw) = draws.get(pib.id as usize) else {
continue;
};
let x = self.pad_x as f64 + pib.x as f64;
let y = gy as f64 + pib.y as f64 + draw.baseline_offset as f64;
match &draw.brush {
Some((brush, nat_w, nat_h)) => {
let transform = Affine::translate((x, y))
* Affine::scale_non_uniform(
pib.width as f64 / *nat_w as f64,
pib.height as f64 / *nat_h as f64,
);
scene.draw_image(brush, transform);
}
None => {
let rect = Rect::new(x, y, x + pib.width as f64, y + pib.height as f64)
.to_rounded_rect(3.0);
scene.fill(Fill::NonZero, Affine::IDENTITY, self.image_bg, None, &rect);
scene.stroke(
&Stroke::new(1.0),
Affine::IDENTITY,
self.image_border,
None,
&rect,
);
}
}
}
}
}
pub fn draw(&self, engine: &TextEngine, scene: &mut Scene, viewport_h: f32) {
let (first, last) = self.visible_range(viewport_h);
for i in first..last {
let real_top = self.real_top(i) - self.scroll_y;
let shaped_h: f32 = self.ghosts[i].iter().map(|g| g.height).sum();
let mut gy = real_top - shaped_h;
for ghost in &self.ghosts[i] {
self.draw_ghost(engine, scene, ghost, gy, viewport_h);
gy += ghost.height;
}
let code = &self.renders[i].code_ranges;
if !code.is_empty() {
self.fill_word_ranges(scene, &self.layouts[i], code, real_top as f64, self.code_bg);
}
self.draw_inline_images(scene, i, real_top);
engine.draw_line(scene, &self.layouts[i], (self.pad_x, real_top));
}
let mut gy = self.tops[self.layouts.len()] - self.scroll_y;
for ghost in &self.trailing_ghosts {
self.draw_ghost(engine, scene, ghost, gy, viewport_h);
gy += ghost.height;
}
}
fn draw_ghost(
&self,
engine: &TextEngine,
scene: &mut Scene,
ghost: &Ghost,
gy: f32,
viewport_h: f32,
) {
if gy + ghost.height < 0.0 || gy > viewport_h {
return; }
let top = gy as f64;
let bottom = (gy + ghost.height) as f64;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
self.diff_colors.deleted_bg,
None,
&Rect::new(
self.pad_x as f64,
top,
(self.width - self.pad_x) as f64,
bottom,
),
);
self.fill_word_ranges(
scene,
&ghost.layout,
&ghost.inline,
top,
self.diff_colors.deleted_inline,
);
engine.draw_line(scene, &ghost.layout, (self.pad_x, gy));
}
pub fn draw_blockquote_gutters(&self, scene: &mut Scene, viewport_h: f32) {
let (first, last) = self.visible_range(viewport_h);
for i in first..last {
let bars = &self.quote_bars[i];
if bars.is_empty() {
continue;
}
let top = (self.real_top(i) - self.scroll_y) as f64;
let bottom = (self.tops[i + 1] - self.scroll_y) as f64;
for &bx in bars {
let x = (self.pad_x + bx) as f64;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
self.quote_bar_color,
None,
&Rect::new(x, top, x + self.quote_bar_width as f64, bottom),
);
}
}
}
pub fn draw_horizontal_rules(&self, scene: &mut Scene, viewport_h: f32) {
let (first, last) = self.visible_range(viewport_h);
for i in first..last {
if !self.renders[i].is_hr {
continue;
}
let mid = (self.real_top(i) + self.tops[i + 1]) / 2.0 - self.scroll_y;
let h = self.quote_bar_width as f64;
let x0 = self.pad_x as f64;
let x1 = (self.width - self.pad_x) as f64;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
self.quote_bar_color,
None,
&Rect::new(x0, mid as f64 - h / 2.0, x1, mid as f64 + h / 2.0),
);
}
}
pub fn draw_tables(&self, engine: &TextEngine, scene: &mut Scene, viewport_h: f32) {
let (first, last) = self.visible_range(viewport_h);
let pad_x = self.pad_x as f64;
let cell_pad_x = self.table_cell_pad_x;
let cell_pad_y = self.table_cell_pad_y;
let border = self.table_border as f64;
for i in first..last {
let Some((tl, kind)) = &self.table_lines[i] else {
continue;
};
let Some(row_idx) = TableLayout::row_index(*kind) else {
continue;
};
let top = (self.real_top(i) - self.scroll_y) as f64;
let bottom = (self.tops[i + 1] - self.scroll_y) as f64;
let is_header = matches!(kind, RowKind::Header);
let bg = if is_header {
self.table_header_bg
} else {
self.table_bg
};
for c in 0..tl.col_x.len() {
let cx = pad_x + tl.col_x[c] as f64;
let box_w = (tl.col_w[c] + 2.0 * cell_pad_x) as f64;
let rect = Rect::new(cx, top, cx + box_w, bottom);
scene.fill(Fill::NonZero, Affine::IDENTITY, bg, None, &rect);
scene.stroke(
&Stroke::new(border),
Affine::IDENTITY,
self.table_border_color,
None,
&rect,
);
if let Some(Some(slot)) = tl.row_layouts.get(row_idx).and_then(|r| r.get(c)) {
let layout = &slot.layout;
let align = tl.aligns.get(c).copied().unwrap_or(Align::Left);
let extra = (tl.col_w[c] - layout.width()).max(0.0);
let shift = match align {
Align::Left => 0.0,
Align::Right => extra,
Align::Center => extra / 2.0,
};
let gx = cx as f32 + cell_pad_x + shift;
let gy = top as f32 + cell_pad_y;
if !slot.code_ranges.is_empty() {
self.fill_display_ranges(
scene,
layout,
&slot.code_ranges,
gx as f64,
gy as f64,
self.code_bg,
);
}
engine.draw_line(scene, layout, (gx, gy));
}
}
}
}
pub fn image_urls(&self) -> &[String] {
&self.image_urls
}
#[cfg(feature = "mermaid")]
pub fn mermaid_sources(&self) -> &[(String, String)] {
&self.mermaid_sources
}
#[cfg(feature = "math")]
pub fn math_sources(&self) -> &[(String, math::MathJob)] {
&self.math_sources
}
pub fn draw_images(&self, engine: &mut TextEngine, scene: &mut Scene, viewport_h: f32) {
let (first, last) = self.visible_range(viewport_h);
for i in first..last {
let Some(block) = &self.image_blocks[i] else {
continue;
};
let x = self.pad_x as f64;
let top = (self.real_top(i) - self.scroll_y + self.img_vpad) as f64;
match &block.kind {
ImageBlockKind::Loaded {
brush,
nat_w,
nat_h,
dest_w,
dest_h,
} => {
let transform = Affine::translate((x, top))
* Affine::scale_non_uniform(
(*dest_w / *nat_w) as f64,
(*dest_h / *nat_h) as f64,
);
scene.draw_image(brush, transform);
}
ImageBlockKind::Loading | ImageBlockKind::Failed { .. } => {
let line_bottom = (self.tops[i + 1] - self.scroll_y) as f64;
let x1 = (self.width - self.pad_x) as f64;
let bottom = line_bottom - self.img_vpad as f64;
let rect = Rect::new(x, top, x1, bottom.max(top)).to_rounded_rect(4.0);
scene.fill(Fill::NonZero, Affine::IDENTITY, self.image_bg, None, &rect);
scene.stroke(
&Stroke::new(1.0),
Affine::IDENTITY,
self.image_border,
None,
&rect,
);
let reason = match &block.kind {
ImageBlockKind::Failed { reason } => Some(reason),
_ => None,
};
let label = match reason {
Some(Some(msg)) if !block.alt.is_empty() => {
format!("âš {}: {msg}", block.alt)
}
Some(Some(msg)) => format!("âš {msg}"),
Some(None) if block.alt.is_empty() => "broken image".to_string(),
Some(None) => format!("âš {}", block.alt),
None if block.alt.is_empty() => "loading image…".to_string(),
None => block.alt.clone(),
};
let pad = self.img_label_size * 0.5;
let layout = engine.build_line(
&label,
1.0,
self.img_label_size,
UI_LINE_HEIGHT,
self.image_border,
Some(((x1 - x) as f32 - 2.0 * pad).max(1.0)),
&[],
);
let ly = top as f32 + ((bottom - top) as f32 - layout.height()) * 0.5;
engine.draw_line(scene, &layout, (x as f32 + pad, ly.max(top as f32)));
}
}
}
}
pub fn draw_added_backgrounds(&self, scene: &mut Scene, viewport_h: f32) {
let (first, last) = self.visible_range(viewport_h);
for i in first..last {
let d = &self.line_diffs[i];
if !d.is_addition {
continue;
}
let top = (self.real_top(i) - self.scroll_y) as f64;
let bottom = (self.tops[i + 1] - self.scroll_y) as f64;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
self.diff_colors.added_bg,
None,
&Rect::new(
self.pad_x as f64,
top,
(self.width - self.pad_x) as f64,
bottom,
),
);
self.fill_word_ranges(
scene,
&self.layouts[i],
&d.inline,
top,
self.diff_colors.added_inline,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::consts::{FONT_SIZE, LINE_HEIGHT, OUTLINE_WIDTH, PADDING};
use crate::text_engine::peniko_color;
const TEST_CONTENT_W: f32 = 800.0;
fn test_params(theme: &EditorTheme, device_width: f32) -> LayoutParams {
LayoutParams {
content_x0: 0.0,
content_w: device_width,
scale: 1.0,
pad_x: PADDING,
pad_top: PADDING,
pad_bottom: PADDING * 2.0,
base_font_size: FONT_SIZE,
line_height: LINE_HEIGHT,
fg: peniko_color(theme.foreground),
}
}
fn cache_image(cache: &ImageCache, url: &str, w: u32, h: u32) -> ImageRef {
use image::{ImageFormat, RgbaImage};
use std::io::Cursor;
let img = RgbaImage::from_pixel(w, h, image::Rgba([1, 2, 3, 255]));
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
.unwrap();
cache.set_loaded(url, crate::image_cache::decode(&buf).unwrap());
ImageRef {
url: url.to_string(),
alt: String::new(),
}
}
#[test]
fn image_block_fills_width_preserving_aspect() {
let cache = ImageCache::new();
let content_w = TEST_CONTENT_W;
let vpad = IMG_VPAD;
let wide = cache_image(&cache, "wide", 2000, 100);
let (block, _) = build_image_block(&cache, &wide, content_w, 1.0, vpad);
let ImageBlockKind::Loaded { dest_w, dest_h, .. } = block.kind else {
panic!("expected loaded");
};
assert!(
(dest_w - content_w).abs() < 0.5,
"wide image fills content width"
);
assert!(
(dest_h - content_w * 100.0 / 2000.0).abs() < 0.5,
"aspect preserved"
);
let big = cache_image(&cache, "big", 1000, 2000);
let (block, _) = build_image_block(&cache, &big, content_w, 1.0, vpad);
let ImageBlockKind::Loaded { dest_w, dest_h, .. } = block.kind else {
panic!("expected loaded");
};
assert!(
(dest_w - content_w).abs() < 0.5,
"fills width even when tall"
);
assert!(
(dest_h - content_w * 2000.0 / 1000.0).abs() < 0.5,
"height uncapped"
);
let small = cache_image(&cache, "small", 40, 30);
let (block, block_h) = build_image_block(&cache, &small, content_w, 1.0, vpad);
let ImageBlockKind::Loaded { dest_w, dest_h, .. } = block.kind else {
panic!("expected loaded");
};
assert_eq!(
(dest_w, dest_h),
(40.0, 30.0),
"small image is not upscaled"
);
assert!(
(block_h - (30.0 + 2.0 * vpad)).abs() < 0.5,
"block adds vertical padding"
);
}
#[test]
fn image_block_placeholder_height_when_unloaded() {
let cache = ImageCache::new();
let img = ImageRef {
url: "pending.png".to_string(),
alt: "x".to_string(),
};
let (block, block_h) = build_image_block(&cache, &img, TEST_CONTENT_W, 1.0, IMG_VPAD);
assert!(matches!(block.kind, ImageBlockKind::Loading));
assert_eq!(block_h, IMG_PLACEHOLDER_H);
}
#[test]
fn splice_preedit_inserts_underlined_composition() {
let fg = Color::WHITE;
let text = "abcdef";
let before = StyleRun::new(0..2, fg); let after = StyleRun::new(4..6, fg); let runs = vec![before, after];
let caret = 3;
let preedit = "XY"; let (out_text, out_runs) = splice_preedit(text, &runs, caret, preedit, fg);
assert_eq!(out_text, "abcXYdef");
assert_eq!(out_runs[0].range, 0..2, "run before caret is unchanged");
assert_eq!(
out_runs[1].range,
6..8,
"run at/after caret shifts by preedit.len()"
);
let unders: Vec<_> = out_runs.iter().filter(|r| r.underline).collect();
assert_eq!(unders.len(), 1, "exactly one underline run");
assert_eq!(unders[0].range, 3..5, "underline covers the preedit span");
}
#[test]
fn tops_prefix_sum_has_n_plus_one() {
let heights = [10.0, 20.0, 5.0];
let tops = compute_tops(&heights, 4.0);
assert_eq!(tops.len(), 4); assert_eq!(tops, vec![4.0, 14.0, 34.0, 39.0]);
}
#[test]
fn tops_empty_doc() {
let tops = compute_tops(&[], 4.0);
assert_eq!(tops, vec![4.0]); }
fn fixture(heights: &[f32], pad_top: f32, pad_bottom: f32) -> DocLayout {
DocLayout {
layouts: heights
.iter()
.map(|_| Rc::new(parley::Layout::new()))
.collect(),
renders: heights
.iter()
.map(|_| {
Rc::new(LineRender {
text: String::new(),
font_size: 18.0,
runs: Vec::new(),
map: SegmentMap::identity("", 0).1,
content_start: 0,
quote_bar_bytes: Vec::new(),
is_hr: false,
image: None,
code_ranges: Vec::new(),
inline_images: Vec::new(),
inline_math: Vec::new(),
table: None,
})
})
.collect(),
line_ranges: heights.iter().map(|_| 0..0).collect(),
line_diffs: heights.iter().map(|_| LineDiff::default()).collect(),
ghosts: heights.iter().map(|_| Vec::new()).collect(),
ghost_height: heights.iter().map(|_| 0.0).collect(),
trailing_ghosts: Vec::new(),
trailing_ghost_height: 0.0,
quote_bars: heights.iter().map(|_| Vec::new()).collect(),
image_blocks: heights.iter().map(|_| None).collect(),
inline_draws: heights.iter().map(|_| Vec::new()).collect(),
image_urls: Vec::new(),
#[cfg(feature = "mermaid")]
mermaid_sources: Vec::new(),
#[cfg(feature = "math")]
math_sources: Vec::new(),
img_vpad: 0.0,
img_label_size: 14.0,
image_border: Color::TRANSPARENT,
image_bg: Color::TRANSPARENT,
code_bg: Color::TRANSPARENT,
table_lines: heights.iter().map(|_| None).collect(),
table_cell_pad_x: 0.0,
table_cell_pad_y: 0.0,
table_border: 0.0,
table_border_color: Color::TRANSPARENT,
table_bg: Color::TRANSPARENT,
table_header_bg: Color::TRANSPARENT,
quote_bar_width: 2.0,
quote_bar_color: Color::TRANSPARENT,
measured_start: 0,
measured_count: heights.len(),
preedit_caret: None,
diff_colors: DiffColors {
added_bg: Color::TRANSPARENT,
added_inline: Color::TRANSPARENT,
deleted_bg: Color::TRANSPARENT,
deleted_inline: Color::TRANSPARENT,
},
tops: compute_tops(heights, pad_top),
scroll_y: 0.0,
width: 100.0,
pad_top,
pad_bottom,
pad_x: 0.0,
}
}
#[test]
fn visible_range_covers_viewport() {
let doc = fixture(&[10.0; 5], 0.0, 0.0);
assert_eq!(doc.visible_range(25.0), (0, 3));
let mut doc = doc;
doc.scroll_y = 25.0;
assert_eq!(doc.visible_range(20.0), (2, 5));
}
#[test]
fn scroll_clamps_to_content() {
let mut doc = fixture(&[10.0; 5], 0.0, 0.0); doc.scroll_by(1000.0, 20.0);
assert_eq!(doc.scroll_y, 30.0); doc.scroll_by(-1000.0, 20.0);
assert_eq!(doc.scroll_y, 0.0);
}
#[test]
fn short_doc_has_no_scroll() {
let mut doc = fixture(&[10.0; 2], 0.0, 0.0); doc.scroll_by(50.0, 100.0);
assert_eq!(doc.scroll_y, 0.0);
assert_eq!(doc.max_scroll(100.0), 0.0);
}
#[test]
fn caret_and_hit_test_roundtrip() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let mut buffer: Buffer = "hello world\nsecond line here\n".parse().unwrap();
let snapshot = buffer.render_snapshot();
let params = test_params(&theme, 1200.0);
let doc = DocLayout::build(
&mut engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
);
for &off in &[0usize, 6, 11, 12, 19, 27] {
let (x0, y0, x1, y1) = doc.caret_rect(off, 2.0).expect("caret rect");
let cx = ((x0 + x1) / 2.0) as f32;
let cy = ((y0 + y1) / 2.0) as f32;
let got = doc.hit_test(cx, cy).expect("hit test");
assert_eq!(got, off, "offset {off} round-trip (got {got})");
}
}
#[test]
fn folded_lines_are_zero_height() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let mut buffer: Buffer = "line0\nline1\nline2\nline3\nline4\n".parse().unwrap();
let snapshot = buffer.render_snapshot();
let params = test_params(&theme, 1200.0);
let build = |engine: &mut TextEngine, folds: &[Range<usize>]| {
DocLayout::build(
engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
0,
f32::INFINITY,
folds,
&(0..0),
&std::collections::HashMap::new(),
)
};
let full = build(&mut engine, &[]);
let folded = build(&mut engine, std::slice::from_ref(&(1..3)));
assert_eq!(folded.tops[1], folded.tops[2]);
assert_eq!(folded.tops[2], folded.tops[3]);
let hidden_h = full.tops[3] - full.tops[1];
assert!((full.content_height() - folded.content_height() - hidden_h).abs() < 1e-3);
let y3 = folded.line_top_screen(3).expect("line 3 visible") + 1.0;
let hit = folded.hit_test(folded.pad_x + 1.0, y3).expect("hit");
assert_eq!(folded.line_of(hit), 3);
}
#[test]
fn content_region_inset_narrows_layout() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let mut buffer: Buffer = "hello world\nsecond line here\n".parse().unwrap();
let snapshot = buffer.render_snapshot();
let window_w = 1100.0f32;
let scale = 1.0f32;
let content_w = window_w - OUTLINE_WIDTH * scale; let params = LayoutParams {
content_x0: 0.0,
content_w,
scale,
pad_x: PADDING,
pad_top: PADDING,
pad_bottom: PADDING * 2.0,
base_font_size: FONT_SIZE,
line_height: LINE_HEIGHT,
fg: peniko_color(theme.foreground),
};
let mut build = |params: &LayoutParams| {
DocLayout::build(
&mut engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
params,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
)
};
let doc = build(¶ms);
assert_eq!(doc.width, content_w);
assert_eq!(doc.pad_x, PADDING * scale);
let body_right = doc.width - doc.pad_x;
assert!(body_right <= content_w);
let full = LayoutParams {
content_w: window_w,
..params
};
let doc_full = build(&full);
assert_eq!(doc_full.width, window_w);
assert!(
doc_full.pad_x > doc.pad_x,
"wider region → larger centering inset"
);
for &off in &[0usize, 6, 11] {
let (x0, _, x1, _) = doc.caret_rect(off, 2.0).expect("caret rect");
assert!(x0 >= doc.pad_x as f64 - 1.0 && x1 <= doc.width as f64);
}
}
#[test]
fn scroll_line_to_top_pins_and_clamps() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let src = (0..40)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let mut buffer: Buffer = src.parse().unwrap();
let snapshot = buffer.render_snapshot();
let params = test_params(&theme, 800.0);
let viewport_h = 200.0;
let mut doc = DocLayout::build(
&mut engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
);
doc.scroll_line_to_top(10, viewport_h);
assert_eq!(doc.scroll_y, doc.tops[10]);
doc.scroll_line_to_top(39, viewport_h);
assert_eq!(doc.scroll_y, doc.max_scroll(viewport_h));
doc.scroll_line_to_top(9999, viewport_h);
assert_eq!(doc.scroll_y, doc.max_scroll(viewport_h));
}
#[test]
fn ghost_rows_offset_and_are_inert() {
use crate::buffer::Buffer;
use crate::diff::DiffState;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let old_text = "line one\nline two\n";
let new_text = "line one changed here\nline two\n";
let mut base: Buffer = old_text.parse().unwrap();
let diff = DiffState::compute(base.render_snapshot(), old_text, new_text);
assert!(diff.has_hunks());
let mut buf: Buffer = new_text.parse().unwrap();
let snapshot = buf.render_snapshot();
let params = test_params(&theme, 1200.0);
let doc = DocLayout::build(
&mut engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
&snapshot,
&theme,
Some(&diff),
None,
&ImageCache::new(),
usize::MAX,
¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
);
assert!(doc.ghost_height[0] > 0.0, "expected a ghost above line 0");
assert!(doc.real_top(0) > doc.tops[0]);
let (_, cy0, _, _) = doc.caret_rect(0, 2.0).unwrap();
assert!(cy0 as f32 >= doc.real_top(0) - 1.0);
let ghost_mid = doc.tops[0] + doc.ghost_height[0] * 0.5;
assert_eq!(doc.hit_test(50.0, ghost_mid), Some(0));
}
#[test]
fn trailing_deletions_render_as_ghosts() {
use crate::buffer::Buffer;
use crate::diff::DiffState;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let old_text = "keep\ndelete one\ndelete two\n";
let new_text = "keep"; let mut base: Buffer = old_text.parse().unwrap();
let diff = DiffState::compute(base.render_snapshot(), old_text, new_text);
assert!(diff.has_hunks());
let mut buf: Buffer = new_text.parse().unwrap();
let snapshot = buf.render_snapshot();
let params = test_params(&theme, 1200.0);
let doc = DocLayout::build(
&mut engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
&snapshot,
&theme,
Some(&diff),
None,
&ImageCache::new(),
usize::MAX,
¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
);
assert!(
!doc.trailing_ghosts.is_empty(),
"trailing deletion should render ghost rows (was: vanished)"
);
assert!(doc.trailing_ghost_height > 0.0);
assert!(doc.content_height() > doc.tops[doc.line_count()]);
}
#[test]
fn line_cache_reuses_and_speeds_up() {
use crate::buffer::Buffer;
use std::time::Instant;
let mut engine = TextEngine::new();
let mut cache = LineCache::new();
let theme = EditorTheme::dracula();
let text: String = (0..400)
.map(|i| format!("Line {i}: the quick brown fox jumps over the lazy dog.\n"))
.collect();
let mut buffer: Buffer = text.parse().unwrap();
let snapshot = buffer.render_snapshot();
let params = test_params(&theme, 1000.0);
let build = |engine: &mut TextEngine, cache: &mut LineCache| {
DocLayout::build(
engine,
cache,
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
)
};
let t0 = Instant::now();
let _ = build(&mut engine, &mut cache);
let cold = t0.elapsed();
let t1 = Instant::now();
let _ = build(&mut engine, &mut cache);
let warm = t1.elapsed();
println!("[cache] cold={cold:?} warm={warm:?}");
assert_eq!(
cache.map.len(),
401,
"one entry per distinct line (+trailing)"
);
assert!(
warm < cold,
"warm rebuild ({warm:?}) should beat cold ({cold:?})"
);
}
#[test]
fn render_cache_recomputes_cursor_line() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let mut line_cache = LineCache::new();
let mut render_cache = RenderCache::new();
let mut buffer: Buffer = "# Title\nbody line two\n".parse().unwrap();
let snapshot = buffer.render_snapshot();
let params = test_params(&theme, 1200.0);
let build =
|engine: &mut TextEngine, lc: &mut LineCache, rc: &mut RenderCache, cursor: usize| {
DocLayout::build(
engine,
lc,
rc,
&mut HeightCache::new(),
&mut TableCache::new(),
7,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
cursor,
¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
)
};
let doc_off = build(&mut engine, &mut line_cache, &mut render_cache, 10);
assert_eq!(
doc_off.line_map(0).unwrap().buffer_to_display(2),
0,
"with cursor off the heading, `# ` is hidden so content starts at display 0"
);
let doc_on = build(&mut engine, &mut line_cache, &mut render_cache, 0);
assert_eq!(
doc_on.line_map(0).unwrap().buffer_to_display(2),
2,
"cursor on the heading must recompute it (not serve the stale hidden render)"
);
}
#[test]
fn virtualized_build_materializes_only_visible() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let text: String = (0..3000)
.map(|i| format!("Line {i}: the quick brown fox jumps over the lazy dog.\n"))
.collect();
let mut buffer: Buffer = text.parse().unwrap();
let snapshot = buffer.render_snapshot();
let viewport_h = 600.0f32;
let params = test_params(&theme, 1000.0);
let doc = DocLayout::build(
&mut engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
1,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
0, viewport_h,
&[],
&(0..0),
&std::collections::HashMap::new(),
);
let n = doc.line_count();
assert!(n >= 3000);
assert!(
doc.measured_count < n,
"should estimate the tail, got measured_count={} of {n}",
doc.measured_count
);
assert!(
doc.measured_count >= 10,
"should materialize at least the visible band, got {}",
doc.measured_count
);
assert!(!doc.needs_remeasure(viewport_h));
let (first, last) = doc.visible_range(viewport_h);
assert_eq!(first, 0);
assert!(
last <= doc.measured_count,
"visible range {last} must stay within materialized {}",
doc.measured_count
);
let (x0, y0, x1, y1) = doc.caret_rect(0, 2.0).expect("caret");
let got = doc
.hit_test(((x0 + x1) / 2.0) as f32, ((y0 + y1) / 2.0) as f32)
.expect("hit");
assert_eq!(got, 0);
assert!(doc.content_height().is_finite() && doc.content_height() > 0.0);
}
#[test]
fn virtualized_build_around_deep_anchor() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let text: String = (0..3000)
.map(|i| format!("Line {i}: the quick brown fox jumps over the lazy dog.\n"))
.collect();
let mut buffer: Buffer = text.parse().unwrap();
let snapshot = buffer.render_snapshot();
let viewport_h = 600.0f32;
let params = test_params(&theme, 1000.0);
let anchor = 1500usize;
let doc = DocLayout::build(
&mut engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
1,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
anchor,
viewport_h,
&[],
&(0..0),
&std::collections::HashMap::new(),
);
let n = doc.line_count();
assert!(
doc.measured_start > 0 && doc.measured_start <= anchor,
"head should be virtualized around the anchor, got start={}",
doc.measured_start
);
assert!(
doc.measured_count > anchor && doc.measured_count < n,
"band should cover the anchor and estimate the tail, got count={} of {n}",
doc.measured_count
);
assert_eq!(doc.layouts[0].height(), 0.0, "head line placeholder");
assert_eq!(doc.layouts[n - 1].height(), 0.0, "tail line placeholder");
assert!(
doc.layouts[anchor].height() > 0.0,
"anchor line materialized"
);
assert!(
doc.measured_count - doc.measured_start < 400,
"materialized band {} should be a small window of {n}",
doc.measured_count - doc.measured_start
);
}
#[test]
fn anchor_repin_is_stable() {
let mut ha = vec![10.0f32; 200];
let mut hb = vec![40.0f32; 200];
for i in 100..200 {
ha[i] = 25.0;
hb[i] = 25.0;
}
let mut a = fixture(&ha, 4.0, 4.0);
let b = fixture(&hb, 4.0, 4.0);
a.scroll_y = a.tops[100] + 5.0;
let (line, off) = a.scroll_anchor();
assert_eq!(line, 100);
assert!((off - 5.0).abs() < 1e-3);
let repinned = b.anchor_scroll_y(line, off);
assert!((b.tops[100] - repinned - (a.tops[100] - a.scroll_y)).abs() < 1e-3);
}
#[test]
fn table_layout_columns_and_widest_cell() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let mut buf: Buffer =
"| a | b |\n|---|---|\n| x | a much much much longer cell |\n| y | z |\n"
.parse()
.unwrap();
let snap = buf.render_snapshot();
let styles = snap.inline_styles_by_line();
let params = test_params(&theme, 1200.0);
let max_advance = 1000.0;
let table = snap.table_containing_offset(0).unwrap();
let tl = build_table_layout(
&mut engine,
&snap,
&theme,
table,
&styles,
¶ms,
max_advance,
);
assert_eq!(tl.col_x.len(), 2);
assert!(tl.col_x[1] > tl.col_x[0], "col_x strictly increasing");
assert!(
tl.col_w[1] > tl.col_w[0],
"the long cell's column is wider: {:?}",
tl.col_w
);
let cell_pad_x = TABLE_CELL_PAD_X * params.scale;
let border = TABLE_BORDER * params.scale;
let total_w =
tl.col_x.last().unwrap() + tl.col_w.last().unwrap() + 2.0 * cell_pad_x + border;
assert!(
total_w <= max_advance + 0.5,
"grid fits the content width, total_w={total_w}"
);
assert_eq!(tl.row_heights.len(), 3);
assert!(tl.row_heights.iter().all(|&h| h > 0.0));
}
#[test]
fn table_cell_inline_code_reaches_slot() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let mut buf: Buffer = "| a | b |\n|---|---|\n| plain | `code` |\n"
.parse()
.unwrap();
let snap = buf.render_snapshot();
let styles = snap.inline_styles_by_line();
let params = test_params(&theme, 1200.0);
let table = snap.table_containing_offset(0).unwrap();
let tl = build_table_layout(&mut engine, &snap, &theme, table, &styles, ¶ms, 1000.0);
let body = &tl.row_layouts[1];
let code_cell = body[1].as_ref().expect("body code cell present");
assert!(
!code_cell.code_ranges.is_empty(),
"inline-code cell must carry code ranges for the chip"
);
let plain_cell = body[0].as_ref().expect("body plain cell present");
assert!(
plain_cell.code_ranges.is_empty(),
"a plain cell has no code ranges"
);
}
#[test]
fn table_size_cap_engages() {
use crate::buffer::Buffer;
let mut doc = String::from("| a | b |\n|---|---|\n");
for i in 0..(TABLE_MAX_BODY_ROWS + 5) {
doc.push_str(&format!("| r{i} | v{i} |\n"));
}
let mut buf: Buffer = doc.parse().unwrap();
let snap = buf.render_snapshot();
let table = snap.table_containing_offset(0).unwrap();
assert!(table.body.len() > TABLE_MAX_BODY_ROWS);
assert!(
!table_grid_ok(table),
"oversized table must not grid-render"
);
let mut small: Buffer = "| a | b |\n|---|---|\n| 1 | 2 |\n".parse().unwrap();
let snap2 = small.render_snapshot();
assert!(table_grid_ok(snap2.table_containing_offset(0).unwrap()));
}
#[test]
fn doc_layout_grid_rows_off_cursor() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let mut buf: Buffer = "# Title\n\npara\n\n| a | b |\n|:--|--:|\n| 1 | 2 |\n| 3 | 4 |\n"
.parse()
.unwrap();
let snap = buf.render_snapshot();
let params = test_params(&theme, 1200.0);
let doc = DocLayout::build(
&mut engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
&snap,
&theme,
None,
None,
&ImageCache::new(),
0, ¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
);
for (line, want) in [
(4, RowKind::Header),
(5, RowKind::Delimiter),
(6, RowKind::Body(0)),
(7, RowKind::Body(1)),
] {
let entry = doc.table_lines[line]
.as_ref()
.unwrap_or_else(|| panic!("line {line} should be a grid row"));
assert_eq!(entry.1, want, "line {line} row kind");
assert!(
doc.renders[line].table.is_some(),
"line {line} render flags a table row"
);
assert!(
doc.renders[line].text.is_empty(),
"line {line} text is hidden in grid mode"
);
}
assert!(doc.table_lines[0].is_none());
assert!(doc.table_lines[2].is_none());
for w in doc.tops.windows(2) {
assert!(w[1] >= w[0], "tops monotonic");
}
let header_h = doc.tops[5] - doc.real_top(4);
assert!(header_h > 0.0);
assert!(doc.content_height() > 0.0 && doc.content_height().is_finite());
}
#[test]
fn doc_layout_table_reveal_flip() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let theme = EditorTheme::dracula();
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut buf: Buffer = src.parse().unwrap();
let snap = buf.render_snapshot();
let params = test_params(&theme, 1200.0);
let build = |engine: &mut TextEngine, cursor: usize| {
DocLayout::build(
engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
&snap,
&theme,
None,
None,
&ImageCache::new(),
cursor,
¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
)
};
let off = build(&mut engine, src.len());
assert!(
off.renders[0].table.is_some(),
"header is a grid row off-cursor"
);
assert!(off.renders[0].text.is_empty(), "grid row text hidden");
let on = build(&mut engine, 30);
for line in 0..4 {
assert!(
on.renders[line].table.is_none(),
"line {line} reverts to raw text when the cursor is in the table"
);
assert!(
!on.renders[line].text.is_empty(),
"line {line} shows raw pipe text in reveal mode"
);
assert!(on.table_lines[line].is_none());
}
}
fn build_grid_doc(engine: &mut TextEngine, snap: &RenderSnapshot, cursor: usize) -> DocLayout {
let theme = EditorTheme::dracula();
let params = test_params(&theme, 1200.0);
DocLayout::build(
engine,
&mut LineCache::new(),
&mut RenderCache::new(),
&mut HeightCache::new(),
&mut TableCache::new(),
0,
snap,
&theme,
None,
None,
&ImageCache::new(),
cursor,
¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&std::collections::HashMap::new(),
)
}
#[test]
fn hit_test_lands_inside_clicked_table_cell() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let src = "para\n\n| aa | bb | cc |\n| --- | --- | --- |\n| 11 | 22 | 33 |\n";
let mut buf: Buffer = src.parse().unwrap();
let snap = buf.render_snapshot();
let doc = build_grid_doc(&mut engine, &snap, 0);
let body_line = 4;
let (tl, kind) = doc.table_lines[body_line]
.as_ref()
.expect("body row is a grid row");
assert_eq!(*kind, RowKind::Body(0));
let line_start = snap.line_byte_range(body_line).start;
let table = snap.table_containing_offset(line_start).unwrap();
let y = doc.real_top(body_line) + 2.0;
for c in 0..3 {
let content = table.body[0].cells[c].content.clone();
let x = doc.pad_x + tl.col_x[c] + doc.table_cell_pad_x + tl.col_w[c] * 0.5;
let off = doc.hit_test(x, y).expect("hit-test returns an offset");
assert!(
off >= content.start && off <= content.end,
"click on column {c} lands in its cell content {content:?}, got {off}"
);
assert_ne!(off, line_start, "click does not collapse to the line start");
}
}
#[test]
fn hit_test_table_cell_start_before_end() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let src = "para\n\n| aaaa | bbbb |\n| --- | --- |\n| wxyz | 2222 |\n";
let mut buf: Buffer = src.parse().unwrap();
let snap = buf.render_snapshot();
let doc = build_grid_doc(&mut engine, &snap, 0);
let (tl, _) = doc.table_lines[4].as_ref().unwrap();
let y = doc.real_top(4) + 2.0;
let base = doc.pad_x + tl.col_x[0] + doc.table_cell_pad_x;
let near_start = doc.hit_test(base + 1.0, y).unwrap();
let near_end = doc.hit_test(base + tl.col_w[0] - 1.0, y).unwrap();
assert!(
near_end > near_start,
"click near the cell end ({near_end}) is past click near its start ({near_start})"
);
}
#[test]
fn hit_test_table_clamps_to_edge_columns() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let src = "para\n\n| aa | bb | cc |\n| --- | --- | --- |\n| 11 | 22 | 33 |\n";
let mut buf: Buffer = src.parse().unwrap();
let snap = buf.render_snapshot();
let doc = build_grid_doc(&mut engine, &snap, 0);
let line_start = snap.line_byte_range(4).start;
let table = snap.table_containing_offset(line_start).unwrap();
let y = doc.real_top(4) + 2.0;
let first = table.body[0].cells[0].content.clone();
let last = table.body[0].cells[2].content.clone();
let left = doc.hit_test(0.0, y).unwrap();
assert!(
left >= first.start && left <= first.end,
"far-left click clamps to the first cell {first:?}, got {left}"
);
let right = doc.hit_test(1199.0, y).unwrap();
assert!(
right >= last.start && right <= last.end,
"far-right click clamps to the last cell {last:?}, got {right}"
);
}
#[test]
fn hit_test_empty_table_cell_lands_off_pipe() {
use crate::buffer::Buffer;
let mut engine = TextEngine::new();
let src = "para\n\n| a | b |\n| --- | --- |\n| | |\n";
let mut buf: Buffer = src.parse().unwrap();
let snap = buf.render_snapshot();
let doc = build_grid_doc(&mut engine, &snap, 0);
let body_line = 4;
let (tl, _) = doc.table_lines[body_line].as_ref().unwrap();
let y = doc.real_top(body_line) + 2.0;
let x = doc.pad_x + tl.col_x[0] + doc.table_cell_pad_x + tl.col_w[0] * 0.5;
let off = doc.hit_test(x, y).unwrap();
assert_eq!(
snap.rope.get_byte(off),
Some(b' '),
"empty-cell click lands on a space between the pipes, not on a pipe"
);
}
}