use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::ops::Range;
use std::rc::Rc;
use parley::{Affinity, Cluster, Cursor, PositionedLayoutItem};
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, NakedUrl, RawGitHubMatch, StyledRegion, github_refs_to_styled_regions,
naked_urls_to_styled_regions,
};
use crate::render::{ImageRef, InlineImageRef, LineRender, build_line_render};
use crate::segment_map::SegmentMap;
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>>>;
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()
}
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>;
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,
}
struct InlineImageDraw {
brush: Option<(ImageBrush, f32, 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 device_width: 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,
&[],
);
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) => (
ImageBlock {
alt: img.alt.clone(),
kind: ImageBlockKind::Failed,
},
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 })
}
_ => (
INLINE_IMG_PLACEHOLDER_W * scale,
INLINE_IMG_PLACEHOLDER_H * scale,
InlineImageDraw { brush: None },
),
};
inline_boxes.push((ii.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;
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>,
img_vpad: f32,
img_label_size: f32,
image_border: Color,
image_bg: Color,
code_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,
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,
) -> Self {
let LayoutParams {
device_width,
scale,
pad_x,
pad_top,
pad_bottom,
base_font_size,
line_height,
fg,
} = *params;
let left = (pad_x * scale).max((device_width - MAX_CONTENT_WIDTH * scale) / 2.0);
let max_advance = (device_width - 2.0 * left).max(1.0);
let n = snapshot.line_count();
cache.begin();
render_cache.begin();
height_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 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(),
});
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 preedit_caret: Option<(usize, usize)> = None;
let line_styles = snapshot.inline_styles_by_line();
let mut layouts = Vec::with_capacity(n);
let mut renders = Vec::with_capacity(n);
let mut line_ranges = Vec::with_capacity(n);
let mut line_diffs = Vec::with_capacity(n);
let mut ghosts = Vec::with_capacity(n);
let mut ghost_height = Vec::with_capacity(n);
let mut quote_bars: Vec<Vec<f32>> = Vec::with_capacity(n);
let mut image_blocks: Vec<Option<ImageBlock>> = Vec::with_capacity(n);
let mut inline_draws: Vec<Vec<InlineImageDraw>> = Vec::with_capacity(n);
let mut image_urls: Vec<String> = Vec::new();
let content_w = max_advance;
let img_vpad = IMG_VPAD * scale;
let mut heights = Vec::with_capacity(n);
#[allow(clippy::needless_range_loop)]
for i in 0..n {
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_key_for(&range, cursor_offset));
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);
heights.push(gh + h);
layouts.push(empty_layout.clone());
renders.push(empty_render.clone());
line_ranges.push(range);
line_diffs.push(LineDiff::default());
ghosts.push(Vec::new());
ghost_height.push(gh);
quote_bars.push(Vec::new());
image_blocks.push(None);
inline_draws.push(Vec::new());
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_key_for(&range, cursor_offset));
let extra = github.map(|g| g.extra_regions(i)).unwrap_or_default();
let lr = if extra.is_empty() {
render_cache.get_or_build(rkey, || {
Rc::new(build_line_render(
snapshot,
i,
theme,
base_font_size,
cursor_offset,
&line_styles[i],
&[],
))
})
} else {
Rc::new(build_line_render(
snapshot,
i,
theme,
base_font_size,
cursor_offset,
&line_styles[i],
&extra,
))
};
let (inline_boxes, line_inline_draws) = resolve_inline_images(
&lr.inline_images,
images,
&mut image_urls,
max_advance,
scale,
);
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 (image_block, line_h) = 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;
}
}
heights.push(total_h);
layouts.push(layout);
renders.push(lr);
quote_bars.push(bars);
line_ranges.push(range);
line_diffs.push(line_diff);
ghosts.push(line_ghosts);
ghost_height.push(gh);
image_blocks.push(image_block);
inline_draws.push(line_inline_draws);
}
cache.sweep();
render_cache.sweep();
height_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),
};
Self {
layouts,
renders,
line_ranges,
line_diffs,
ghosts,
ghost_height,
trailing_ghosts,
trailing_ghost_height,
quote_bars,
image_blocks,
inline_draws,
image_urls,
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: peniko_color_alpha(theme.comment, 0.22),
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: device_width,
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)
}
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)
}
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);
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 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_word_ranges(
&self,
scene: &mut Scene,
layout: &parley::Layout<Brush>,
ranges: &[Range<usize>],
top_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 + self.pad_x as f64,
bb.y0 + top_y,
bb.x1 + self.pad_x as f64,
bb.y1 + top_y,
),
);
}
}
}
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;
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 image_urls(&self) -> &[String] {
&self.image_urls
}
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 failed = matches!(block.kind, ImageBlockKind::Failed);
let label = if block.alt.is_empty() {
if failed {
"broken image"
} else {
"loading image…"
}
.to_string()
} else if failed {
format!("⚠ {}", block.alt)
} else {
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, PADDING};
use crate::text_engine::peniko_color;
const TEST_CONTENT_W: f32 = 800.0;
fn test_params(theme: &EditorTheme, device_width: f32) -> LayoutParams {
LayoutParams {
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(),
})
})
.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(),
img_vpad: 0.0,
img_label_size: 14.0,
image_border: Color::TRANSPARENT,
image_bg: Color::TRANSPARENT,
code_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(),
0,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
0,
f32::INFINITY,
);
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 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(),
0,
&snapshot,
&theme,
Some(&diff),
None,
&ImageCache::new(),
usize::MAX,
¶ms,
None,
0,
f32::INFINITY,
);
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(),
0,
&snapshot,
&theme,
Some(&diff),
None,
&ImageCache::new(),
usize::MAX,
¶ms,
None,
0,
f32::INFINITY,
);
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(),
0,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
0,
f32::INFINITY,
)
};
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(),
7,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
cursor,
¶ms,
None,
0,
f32::INFINITY,
)
};
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(),
1,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
0, viewport_h,
);
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(),
1,
&snapshot,
&theme,
None,
None,
&ImageCache::new(),
0,
¶ms,
None,
anchor,
viewport_h,
);
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);
}
}