Skip to main content

rustdown_md/render/
mod.rs

1#![forbid(unsafe_code)]
2//! Render parsed Markdown blocks into egui widgets.
3//!
4//! Key feature: viewport culling in `show_scrollable` — only blocks
5//! overlapping the visible region are laid out, giving O(visible) cost.
6
7mod blocks;
8mod height;
9mod layout;
10mod lists;
11mod table;
12mod text;
13
14#[cfg(test)]
15mod tests;
16
17use crate::parse::{Block, parse_markdown_into};
18use crate::style::MarkdownStyle;
19
20use blocks::{render_block, render_blocks};
21pub use height::bytecount_newlines;
22use height::estimate_block_height;
23use layout::RenderContext;
24
25const PREVIEW_WHEEL_SCROLL_MULTIPLIER: f32 = 1.15;
26
27// ── Cache ──────────────────────────────────────────────────────────
28
29/// Cached pre-parsed blocks, height estimates, and the source hash.
30#[derive(Default)]
31pub struct MarkdownCache {
32    text_hash: u64,
33    text_len: usize,
34    text_ptr: usize,
35    pub blocks: Vec<Block>,
36    /// Estimated pixel height for each top-level block (same len as `blocks`).
37    pub heights: Vec<f32>,
38    /// Cumulative Y offsets: `cum_y[i]` = sum of heights[0..i].
39    pub cum_y: Vec<f32>,
40    /// Total estimated height of all blocks.
41    pub total_height: f32,
42    /// The body font size used when heights were estimated.
43    height_body_size: f32,
44    /// The wrap width used when heights were estimated.
45    height_wrap_width: f32,
46    /// Last rendered scroll-y offset (set by `show_scrollable`).
47    pub last_scroll_y: f32,
48    /// Block indices of non-empty headings, cached for O(1) `heading_y` lookup.
49    heading_block_indices: Vec<usize>,
50}
51
52impl MarkdownCache {
53    /// Invalidate the cache so the next render re-parses.
54    pub fn clear(&mut self) {
55        self.text_hash = 0;
56        self.text_len = 0;
57        self.text_ptr = 0;
58        self.blocks.clear();
59        self.heights.clear();
60        self.cum_y.clear();
61        self.total_height = 0.0;
62        self.height_body_size = 0.0;
63        self.height_wrap_width = 0.0;
64        self.last_scroll_y = 0.0;
65        self.heading_block_indices.clear();
66    }
67
68    pub fn ensure_parsed(&mut self, source: &str) {
69        // Fast pointer+length check: if the source is the same allocation
70        // and length, skip hash entirely (common in frame-to-frame rendering).
71        let ptr = source.as_ptr() as usize;
72        let len = source.len();
73        if ptr == self.text_ptr && len == self.text_len {
74            return;
75        }
76
77        // Pointer changed — compute hash once and compare.
78        let hash = simple_hash(source);
79        self.text_ptr = ptr;
80        self.text_len = len;
81
82        if self.text_hash == hash {
83            return;
84        }
85
86        // Content actually changed — re-parse, reusing the blocks allocation.
87        self.text_hash = hash;
88        self.blocks.clear();
89        parse_markdown_into(source, &mut self.blocks);
90        self.heights.clear();
91        self.cum_y.clear();
92        self.total_height = 0.0;
93
94        // Rebuild heading index for fast heading_y lookup.
95        self.heading_block_indices.clear();
96        for (idx, block) in self.blocks.iter().enumerate() {
97            if let Block::Heading { text, .. } = block
98                && !text.text.is_empty()
99            {
100                self.heading_block_indices.push(idx);
101            }
102        }
103    }
104
105    pub fn ensure_heights(&mut self, body_size: f32, wrap_width: f32, style: &MarkdownStyle) {
106        let size_bits = body_size.to_bits();
107        let width_bits = wrap_width.to_bits();
108        if !self.heights.is_empty()
109            && self.height_body_size.to_bits() == size_bits
110            && self.height_wrap_width.to_bits() == width_bits
111        {
112            return;
113        }
114        self.height_body_size = body_size;
115        self.height_wrap_width = wrap_width;
116        let n = self.blocks.len();
117        self.heights.resize(n, 0.0);
118        self.cum_y.resize(n, 0.0);
119        let mut acc = 0.0_f32;
120        for (i, block) in self.blocks.iter().enumerate() {
121            self.cum_y[i] = acc;
122            let h = estimate_block_height(block, body_size, wrap_width, style);
123            self.heights[i] = h;
124            acc += h;
125        }
126        self.total_height = acc;
127    }
128
129    /// Recompute `cum_y` and `total_height` from current `heights`.
130    fn recompute_cum_y(&mut self) {
131        let mut acc = 0.0_f32;
132        for (cum, h) in self.cum_y.iter_mut().zip(&self.heights) {
133            *cum = acc;
134            acc += h;
135        }
136        self.total_height = acc;
137    }
138
139    /// Return the Y offset for the `ordinal`th **non-empty** heading block
140    /// (0-based).  Empty headings (no visible text) are skipped so the
141    /// ordinal aligns with `nav_outline::extract_headings` which also
142    /// excludes them.
143    ///
144    /// Uses the pre-cached `heading_block_indices` for O(1) lookup.
145    #[must_use]
146    pub fn heading_y(&self, ordinal: usize) -> Option<f32> {
147        let block_idx = *self.heading_block_indices.get(ordinal)?;
148        self.cum_y.get(block_idx).copied()
149    }
150}
151
152// ── Viewer widget ──────────────────────────────────────────────────
153
154/// The main Markdown viewer widget.
155pub struct MarkdownViewer {
156    id_salt: &'static str,
157}
158
159impl MarkdownViewer {
160    #[must_use]
161    pub const fn new(id_salt: &'static str) -> Self {
162        Self { id_salt }
163    }
164
165    /// Render markdown in a scrollable area with **viewport culling**.
166    ///
167    /// Only blocks overlapping the visible viewport are actually rendered;
168    /// off-screen blocks are replaced by empty space allocations.
169    ///
170    /// If `scroll_to_y` is `Some(y)`, the scroll area will jump to that offset.
171    pub fn show_scrollable(
172        &self,
173        ui: &mut egui::Ui,
174        cache: &mut MarkdownCache,
175        style: &MarkdownStyle,
176        source: &str,
177        scroll_to_y: Option<f32>,
178    ) {
179        cache.ensure_parsed(source);
180
181        let body_size = ui.text_style_height(&egui::TextStyle::Body);
182        let wrap_width = ui.available_width();
183        cache.ensure_heights(body_size, wrap_width, style);
184
185        if cache.blocks.is_empty() {
186            return;
187        }
188
189        let mut scroll_area = egui::ScrollArea::vertical()
190            .id_salt(self.id_salt)
191            .auto_shrink([false, false])
192            .wheel_scroll_multiplier(egui::vec2(1.0, PREVIEW_WHEEL_SCROLL_MULTIPLIER));
193
194        if let Some(y) = scroll_to_y
195            && y.is_finite()
196        {
197            scroll_area = scroll_area.vertical_scroll_offset(y);
198        }
199
200        // Track whether any estimated heights were corrected by measurement.
201        let mut heights_changed = false;
202
203        scroll_area.show_viewport(ui, |ui, viewport| {
204            // Record current scroll offset for external sync.
205            cache.last_scroll_y = viewport.min.y;
206
207            // Allocate total height so scroll thumb is correct.
208            ui.set_min_height(cache.total_height);
209
210            let vis_top = viewport.min.y;
211            let vis_bottom = viewport.max.y;
212
213            // Binary search for first visible block.
214            let first = match cache
215                .cum_y
216                .binary_search_by(|y| y.partial_cmp(&vis_top).unwrap_or(std::cmp::Ordering::Equal))
217            {
218                Ok(i) => i,
219                Err(i) => i.saturating_sub(1),
220            };
221
222            // Allocate space for all blocks above viewport.
223            if first > 0 {
224                let skip_h = cache.cum_y[first];
225                ui.add_space(skip_h);
226            }
227
228            // Render visible blocks, measuring actual heights.
229            let mut idx = first;
230            while idx < cache.blocks.len() {
231                let block_y = cache.cum_y[idx];
232                if block_y > vis_bottom {
233                    break;
234                }
235
236                // ── Progressive height refinement ──────────────────
237                // Measure the full child-ui rect instead of cursor deltas;
238                // labels/galleys can extend without advancing the parent
239                // cursor in the same way spaces do.
240                let rendered = ui.scope(|ui| {
241                    render_block(ui, &cache.blocks[idx], style, RenderContext::root(ui));
242                });
243                let actual_h = rendered.response.rect.height();
244
245                if actual_h > 0.0 && (cache.heights[idx] - actual_h).abs() > 2.0 {
246                    cache.heights[idx] = actual_h;
247                    heights_changed = true;
248                }
249
250                idx += 1;
251            }
252
253            // Allocate space for blocks below viewport.
254            if idx < cache.blocks.len() {
255                let remaining = cache.total_height - cache.cum_y[idx];
256                if remaining > 0.0 {
257                    ui.add_space(remaining);
258                }
259            }
260        });
261
262        // Recompute cumulative offsets outside the viewport pass so the
263        // *next* frame sees corrected positions without perturbing the
264        // current frame's layout.
265        if heights_changed {
266            cache.recompute_cum_y();
267        }
268    }
269
270    /// Render markdown inline (no scroll area, no culling).
271    pub fn show(
272        &self,
273        ui: &mut egui::Ui,
274        cache: &mut MarkdownCache,
275        style: &MarkdownStyle,
276        source: &str,
277    ) {
278        cache.ensure_parsed(source);
279        render_blocks(ui, &cache.blocks, style, RenderContext::root(ui));
280    }
281}
282
283#[inline]
284pub(crate) fn simple_hash(s: &str) -> u64 {
285    // FNV-1a–inspired 64-bit hash, processing 8 bytes at a time for throughput.
286    const BASIS: u64 = 0xcbf2_9ce4_8422_2325;
287    const PRIME: u64 = 0x0100_0000_01b3;
288
289    let bytes = s.as_bytes();
290    let (chunks, remainder) = bytes.as_chunks::<8>();
291    let mut h: u64 = BASIS;
292
293    for chunk in chunks {
294        let word = u64::from_le_bytes(*chunk);
295        h ^= word;
296        h = h.wrapping_mul(PRIME);
297    }
298
299    for &b in remainder {
300        h ^= u64::from(b);
301        h = h.wrapping_mul(PRIME);
302    }
303    h
304}