1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
//! Headless, render-only markdown view: turns markdown text into a Vello [`Scene`]
//! (a CPU display list) with no window, GPU device, editor, or diff machinery. It is
//! the reusable core of writ's renderer, meant to be embedded in another app that owns
//! its own surface and rasterizes the scene however it likes.
//!
//! Feed content in one of two ways: [`MarkdownView::set_markdown`] replaces the whole
//! document, and [`MarkdownView::push_str`] appends at the end — the streaming entry
//! point for incrementally growing output (e.g. tokens from an LLM). Then call
//! [`MarkdownView::render`] to draw the document body into a [`Scene`]. The consumer is
//! responsible for creating the GPU device/renderer and painting the scene to pixels.
//!
//! ```ignore
//! use vello::Scene;
//! use writ::MarkdownView;
//!
//! let mut view = MarkdownView::new();
//! view.push_str("# Streaming\n\n");
//! view.push_str("More text arrives later.\n");
//!
//! let mut scene = Scene::new();
//! view.render(&mut scene, 800.0, 600.0, 1.0); // scene is now a display list
//! // ... hand `scene` to a vello::Renderer to rasterize onto your own surface.
//! ```
use std::collections::HashMap;
use vello::Scene;
use vello::kurbo::{Affine, Rect};
use vello::peniko::Fill;
use crate::buffer::Buffer;
use crate::consts::{FONT_SIZE, LINE_HEIGHT, PADDING};
use crate::doc_layout::{DocLayout, HeightCache, LayoutParams, LineCache, RenderCache, TableCache};
use crate::editor::EditorTheme;
use crate::image_cache::{ImageCache, decode};
use crate::text_engine::{TextEngine, peniko_color};
/// A headless markdown renderer. Holds the render-only subset of writ's document
/// engine (text shaping, layout/render caches, theme, buffer, and the laid-out
/// document) without any editor, diff, GitHub, IME, or windowing state.
pub struct MarkdownView {
text_engine: TextEngine,
line_cache: LineCache,
render_cache: RenderCache,
height_cache: HeightCache,
table_cache: TableCache,
theme: EditorTheme,
buffer: Buffer,
doc: Option<DocLayout>,
images: ImageCache,
scroll_y: f32,
/// The `(width, scale)` the current `doc` was built at, so `render` can detect a
/// size change and relayout. `None` whenever `doc` is stale/absent.
laid_out: Option<(f32, f32)>,
}
impl MarkdownView {
/// An empty view with the Dracula theme.
pub fn new() -> Self {
Self::with_theme(EditorTheme::dracula())
}
pub fn with_theme(theme: EditorTheme) -> Self {
Self {
text_engine: TextEngine::new(),
line_cache: LineCache::new(),
render_cache: RenderCache::new(),
height_cache: HeightCache::new(),
table_cache: TableCache::new(),
theme,
buffer: Buffer::new(),
doc: None,
images: ImageCache::new(),
scroll_y: 0.0,
laid_out: None,
}
}
/// Register a font (TTF/OTF bytes) and make it the document body family. Required in
/// environments with no system fonts (wasm/browser); the bundled face must be monospace
/// to preserve writ's deterministic wrapping. Invalidates any built layout.
pub fn register_font(&mut self, data: Vec<u8>) {
if let Some(family) = self.text_engine.register_font(data) {
self.text_engine.set_font_family(Some(family));
self.invalidate_layout();
}
}
/// Discard the built layout so the next `layout`/`render` rebuilds.
fn invalidate_layout(&mut self) {
self.doc = None;
self.laid_out = None;
}
/// Replace the entire document with `text`, discarding any prior content, layout,
/// and scroll position.
pub fn set_markdown(&mut self, text: &str) {
self.buffer = Buffer::new();
self.buffer.insert(0, text, 0);
self.invalidate_layout();
self.scroll_y = 0.0;
}
/// Append `text` to the end of the document (the streaming entry point). Invalidates
/// the built layout so the next `layout`/`render` rebuilds; scroll is preserved.
pub fn push_str(&mut self, text: &str) {
let end = self.buffer.len_bytes();
self.buffer.insert(end, text, 0);
self.invalidate_layout();
}
/// Lay out the whole document at `width`/`scale` (a single anchor-0, infinite-
/// viewport build, so every line is materialized). Render-only: no cursor, diff,
/// GitHub, or IME data.
pub fn layout(&mut self, width: f32, scale: f32) {
let version = self.buffer.version();
let snapshot = self.buffer.render_snapshot();
let params = LayoutParams {
content_x0: 0.0,
content_w: width,
scale,
pad_x: PADDING,
pad_top: PADDING,
pad_bottom: PADDING * 2.0,
base_font_size: FONT_SIZE,
line_height: LINE_HEIGHT,
fg: peniko_color(self.theme.foreground),
};
// `usize::MAX` is a safe "no cursor" sentinel: `build` clamps it to `len_bytes`
// for the caret-line lookup, and `cursor_key_for` never reports any line as
// holding the cursor (so no marker is revealed), which is exactly what we want.
let doc = DocLayout::build(
&mut self.text_engine,
&mut self.line_cache,
&mut self.render_cache,
&mut self.height_cache,
&mut self.table_cache,
version,
&snapshot,
&self.theme,
None,
None,
&self.images,
usize::MAX,
¶ms,
None,
0,
f32::INFINITY,
&[],
&(0..0),
&HashMap::new(),
);
self.doc = Some(doc);
self.laid_out = Some((width, scale));
}
/// The standalone-image URLs referenced by the currently-built layout (empty if not
/// laid out yet). A consumer fetches these bytes however it likes — its own
/// tokio/reqwest, a Xilem `worker` view, a sync fs read — and pushes them back in via
/// [`set_image_bytes`]/[`set_image_failed`]; the component never does IO itself.
///
/// [`set_image_bytes`]: MarkdownView::set_image_bytes
/// [`set_image_failed`]: MarkdownView::set_image_failed
pub fn image_urls(&self) -> &[String] {
self.doc.as_ref().map_or(&[], |d| d.image_urls())
}
/// Hand decoded image bytes for `url` to the view (the consumer fetched them). On a
/// successful decode the image is cached, the built layout is invalidated so the next
/// `render` reflows around the now-known image height (scroll is preserved), and this
/// returns `true`; on decode failure the URL is marked failed and this returns `false`.
/// The component does no IO — the consumer supplies the bytes.
pub fn set_image_bytes(&mut self, url: &str, bytes: &[u8]) -> bool {
match decode(bytes) {
Some(img) => {
self.images.set_loaded(url, img);
self.invalidate_layout();
true
}
None => {
self.images.set_failed(url);
false
}
}
}
/// Mark `url` as failed (the consumer's fetch errored), invalidating the built layout
/// so the next `render` reflows around the placeholder. Scroll is preserved.
pub fn set_image_failed(&mut self, url: &str) {
self.images.set_failed(url);
self.invalidate_layout();
}
/// The full content height of the built document (0 if nothing is laid out yet).
pub fn content_height(&self) -> f32 {
self.doc.as_ref().map(|d| d.content_height()).unwrap_or(0.0)
}
/// Set the absolute scroll offset. The lower bound is clamped here; the upper bound
/// is clamped in `scroll_by`/`render`, which know the viewport height.
pub fn set_scroll(&mut self, y: f32) {
self.scroll_y = y.max(0.0);
if let Some(doc) = self.doc.as_mut() {
doc.scroll_y = self.scroll_y;
}
}
/// Scroll by `dy`, clamped to `[0, max_scroll]` for `viewport_h` (via the doc).
pub fn scroll_by(&mut self, dy: f32, viewport_h: f32) {
if let Some(doc) = self.doc.as_mut() {
doc.scroll_y = self.scroll_y;
doc.scroll_by(dy, viewport_h);
self.scroll_y = doc.scroll_y;
} else {
self.scroll_y = (self.scroll_y + dy).max(0.0);
}
}
pub fn theme(&self) -> &EditorTheme {
&self.theme
}
/// Draw the document body into `scene`, relaying out first if the target size
/// changed since the last build. Draws backgrounds, quote gutters, rules, images,
/// and glyphs — no caret, selection, status bar, or other chrome. `height` is the
/// viewport extent used for scroll clamping and off-screen culling.
pub fn render(&mut self, scene: &mut Scene, width: f32, height: f32, scale: f32) {
if self.laid_out != Some((width, scale)) || self.doc.is_none() {
self.layout(width, scale);
}
let Some(doc) = self.doc.as_mut() else {
return;
};
doc.scroll_y = self.scroll_y;
doc.clamp_scroll(height);
self.scroll_y = doc.scroll_y;
// Mirror `paint_document`: clip to the viewport so lines partially scrolled past
// the top/bottom edges don't bleed outside it.
let clip = Rect::new(0.0, 0.0, width as f64, height as f64);
scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &clip);
doc.draw_added_backgrounds(scene, height);
doc.draw_blockquote_gutters(scene, height);
doc.draw_horizontal_rules(scene, height);
doc.draw_images(&mut self.text_engine, scene, height);
doc.draw_tables(&self.text_engine, scene, height);
doc.draw(&self.text_engine, scene, height);
scene.pop_layer();
}
}
impl Default for MarkdownView {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn streaming_grows_content_height() {
let mut view = MarkdownView::new();
view.push_str("# Hello\n\n");
view.layout(800.0, 1.0);
let first = view.content_height();
view.push_str("more paragraph text\n\nand another\n");
view.layout(800.0, 1.0);
let second = view.content_height();
assert!(
second > first,
"streaming append should grow content height: {first} -> {second}"
);
}
#[test]
fn set_markdown_replaces_content() {
let mut view = MarkdownView::new();
view.set_markdown("# A heading\n\nwith a paragraph beneath it\n");
view.layout(800.0, 1.0);
let full = view.content_height();
assert!(full > 0.0);
view.set_markdown("");
view.layout(800.0, 1.0);
let empty = view.content_height();
assert!(
empty < full,
"empty doc should be shorter: {empty} vs {full}"
);
view.set_markdown("# A\n");
view.layout(800.0, 1.0);
assert!(view.content_height() > 0.0);
}
#[test]
fn render_into_scene_does_not_panic() {
let mut view = MarkdownView::new();
view.push_str("# Title\n\n");
view.push_str("- one\n- two\n\n");
view.push_str("> a quote\n\n");
view.push_str("```rust\nfn main() {}\n```\n");
let mut scene = Scene::new();
view.render(&mut scene, 800.0, 600.0, 1.0);
}
#[test]
fn image_urls_lists_standalone_images() {
let mut view = MarkdownView::new();
// A paragraph that is only `` is a standalone image, collected into
// the layout's `image_urls` (see render.rs `standalone_image_detected...`).
view.set_markdown("\n");
view.layout(800.0, 1.0);
assert!(
view.image_urls().iter().any(|u| u == "pic.png"),
"standalone image url should be listed: {:?}",
view.image_urls()
);
}
#[test]
fn set_image_bytes_rejects_garbage() {
let mut view = MarkdownView::new();
assert!(!view.set_image_bytes("x.png", b"not an image"));
}
#[test]
fn set_image_bytes_accepts_valid_png() {
use image::{ImageFormat, RgbaImage};
use std::io::Cursor;
let mut png = Vec::new();
RgbaImage::from_pixel(1, 1, image::Rgba([1, 2, 3, 255]))
.write_to(&mut Cursor::new(&mut png), ImageFormat::Png)
.unwrap();
let mut view = MarkdownView::new();
view.set_markdown("\n");
view.layout(800.0, 1.0);
assert!(view.set_image_bytes("pic.png", &png));
// Layout was invalidated; re-layout succeeds around the now-known image height.
view.layout(800.0, 1.0);
assert!(view.content_height() > 0.0);
}
#[test]
fn render_relayouts_on_width_change() {
let mut view = MarkdownView::new();
view.push_str("A longer paragraph that will wrap differently at narrow and wide widths so the two layouts genuinely differ.\n");
let mut scene = Scene::new();
view.render(&mut scene, 1200.0, 600.0, 1.0);
let mut scene = Scene::new();
view.render(&mut scene, 400.0, 600.0, 1.0);
assert_eq!(view.laid_out, Some((400.0, 1.0)));
// A narrower width wraps more, so the doc is at least as tall as the wide one.
assert!(view.content_height() > 0.0);
}
}