hephaestus 0.1.0

Backend-agnostic 2D scene renderer for data visualization.
Documentation
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
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! Block-level layout pass — resolve each [`crate::text::rich::RichTextRun`]'s
//! block layouts + container blocks into auxiliary drawing primitives.
//!
//! Per-block parley layouts (built by `run.rs`) already know their
//! screen-space geometry: `left_px`, `y_px`, `shape_width_px`,
//! `height_px`, plus own padding. This pass converts each block's
//! outer rect into a paint instruction — a background fill and/or a
//! border stroke — that [`crate::text::rich::draw_rich_text`] emits
//! before the glyph runs.
//!
//! **Container paints.** A non-leaf container (BlockQuote / Div /
//! List) paints over the *union* of its contained leaves' rects.
//! Container padding contributes to the block leaves' `left_px` +
//! `right_inset_px`, so the union rect (inflated by the container's
//! own padding.top / .bottom) already includes the visual "box" the
//! container reserves.
//!
//! **Order.** Outer-first: containers paint before their leaves, so
//! a leaf's background lands on top of its enclosing container's.
//! Emitted in outermost → innermost order.

use crate::geometry::Rect;

use super::length::swap_lr;
use super::run::{BlockLayout, RichTextRun};
use crate::color::Color;
use crate::style_vocab::{Palette, ThemeColor};

/// A drawing instruction for one block-level box. Emitted by
/// `compute_block_paints` in outer-first order.
#[derive(Debug, Clone, PartialEq)]
pub struct BlockPaint {
    /// Outer rectangle (background + border edge) in RichTextRun-
    /// local coordinates.
    pub outer_rect: Rect,
    /// Background fill colour. `None` = no background pass.
    pub background: Option<Color>,
    /// Border stroke. `None` = no border pass.
    pub border: Option<BlockBorder>,
    /// Uniform corner radius in pixels. `0.0` = square corners.
    pub corner_radius: f32,
}

/// Border descriptor on a [`BlockPaint`]. Per-side widths let a
/// blockquote express its left-edge bar as `[0, 0, 0, 3]` rather
/// than a full rectangular stroke.
#[derive(Debug, Clone, PartialEq)]
pub struct BlockBorder {
    /// Resolved stroke colour (single colour for all sides in v1).
    pub color: Color,
    /// Per-side widths in pixels: `[top, right, bottom, left]`. A
    /// side with width `0.0` is skipped at draw time.
    pub widths_px: [f32; 4],
    /// Optional dash / marker pattern from
    /// [`crate::text::rich::StyleDelta::border_type`], carried in pt
    /// (raw form). The draw pass routes marker-free patterns through
    /// kurbo's `with_dashes` fast path and marker-bearing patterns
    /// through `draw_linetype_with_markers`
    /// (per-polyline chain). `None` = solid stroke.
    pub linetype_pt: Option<std::sync::Arc<[crate::scales::value::LinetypeStep]>>,
}

impl BlockBorder {
    /// True when every side has the same width — the draw pass can
    /// then emit a single rectangular stroke (which cooperates with
    /// `corner_radius`) instead of four independent line segments.
    pub fn is_uniform(&self) -> bool {
        let w0 = self.widths_px[0];
        self.widths_px.iter().all(|&w| (w - w0).abs() < 1e-3)
    }
}

/// Walk the run's leaf layouts + non-leaf containers and produce one
/// [`BlockPaint`] per block that carries a background or border.
/// Outer-first ordering: containers come before any leaf they wrap.
pub(crate) fn compute_block_paints(run: &RichTextRun) -> Vec<BlockPaint> {
    let blocks = run.blocks.borrow();
    let mut paints: Vec<BlockPaint> = Vec::new();
    let palette = &run.palette;
    let dpi = run.dpi;
    let px = |pt: f64| (pt * dpi / 72.0) as f32;

    // Containers come first, outermost → innermost, so a leaf's
    // background lands on top of its enclosing container's. `shape`
    // already sorted `run.containers` outer-first.
    for container in &run.containers {
        if !has_paint(&container.style.background, &container.style.border_color) {
            continue;
        }
        // Union rect over every leaf whose range is contained in this
        // container's range.
        let leaves: Vec<&BlockLayout> = blocks
            .iter()
            .filter(|bl| {
                bl.text_range.start >= container.range.start
                    && bl.text_range.end <= container.range.end
            })
            .collect();
        if leaves.is_empty() {
            continue;
        }
        let mut x0 = f32::INFINITY;
        let mut y0 = f32::INFINITY;
        let mut x1 = f32::NEG_INFINITY;
        let mut y1 = f32::NEG_INFINITY;
        for bl in &leaves {
            let r = bl.outer_rect();
            x0 = x0.min(r.x0 as f32);
            y0 = y0.min(r.y0 as f32);
            x1 = x1.max(r.x1 as f32);
            y1 = y1.max(r.y1 as f32);
        }
        // Effective block-axis direction for the container: inherit
        // from the first descendant leaf, whose `is_rtl` already
        // applied the same cascade that included this container. Under
        // Rtl the container's `.left` / `.right` padding +
        // border_width are start / end sides, swapped to physical by
        // `swap_lr`.
        let is_rtl = leaves.first().map(|bl| bl.is_rtl).unwrap_or(false);
        // Inflate outward by the container's own padding.
        let pad = swap_lr(container.style.padding_pt, is_rtl);
        x0 -= px(pad[3]);
        x1 += px(pad[1]);
        y0 -= px(pad[0]);
        y1 += px(pad[2]);
        let outer_rect = Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64);
        let bg = container
            .style
            .background
            .as_ref()
            .map(|c| c.resolve(palette));
        let border = border_for(
            &container.style.border_color,
            container.style.border_width_pt,
            container.style.border_type.as_deref(),
            palette,
            dpi,
            is_rtl,
        );
        let corner_radius = px(container.style.border_radius_pt);
        if bg.is_none() && border.is_none() {
            continue;
        }
        paints.push(BlockPaint {
            outer_rect,
            background: bg,
            border,
            corner_radius,
        });
    }

    // Then leaves.
    for bl in blocks.iter() {
        let d = &bl.style;
        if !has_paint(&d.background, &d.border_color) {
            continue;
        }
        let outer_rect = bl.outer_rect();
        let bg = d.background.as_ref().map(|c| c.resolve(palette));
        let border = border_for(
            &d.border_color,
            d.border_width_pt,
            d.border_type.as_deref(),
            palette,
            dpi,
            bl.is_rtl,
        );
        let corner_radius = px(d.border_radius_pt);
        if bg.is_none() && border.is_none() {
            continue;
        }
        paints.push(BlockPaint {
            outer_rect,
            background: bg,
            border,
            corner_radius,
        });
    }

    paints
}

fn has_paint(bg: &Option<ThemeColor>, border: &Option<ThemeColor>) -> bool {
    bg.is_some() || border.is_some()
}

fn border_for(
    color: &Option<ThemeColor>,
    width_pt: [f64; 4],
    dash_pattern: Option<&[crate::scales::value::LinetypeStep]>,
    palette: &Palette,
    dpi: f64,
    is_rtl: bool,
) -> Option<BlockBorder> {
    let c = color.as_ref()?;
    // Swap l/r under Rtl so a class that sets `border_width.left = 3`
    // — semantically the start-side bar — paints on the physical
    // right instead. Mirrors the padding / margin l/r swap in
    // `run.rs`'s block-layout math.
    let w = swap_lr(width_pt, is_rtl);
    let widths_px = [
        (w[0] * dpi / 72.0) as f32,
        (w[1] * dpi / 72.0) as f32,
        (w[2] * dpi / 72.0) as f32,
        (w[3] * dpi / 72.0) as f32,
    ];
    if widths_px.iter().all(|&w| w <= 0.0) {
        return None;
    }
    let linetype_pt = dash_pattern.map(|steps| std::sync::Arc::from(steps.to_vec()));
    Some(BlockBorder {
        color: c.resolve(palette),
        widths_px,
        linetype_pt,
    })
}

// ─── Tests ──────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::color::Color;
    use crate::style_vocab::ThemeColor;
    use crate::text::rich::length::{pt, RichMargin};
    use crate::text::rich::style::StyleDelta;
    use crate::text::rich::{RichTextRun, RichTextStyleSheet};
    use crate::text::TextStyle;

    fn palette() -> Palette {
        Palette::new(
            Color::from_rgba8(255, 255, 255, 255),
            Color::from_rgba8(0, 0, 0, 255),
            Color::from_rgba8(51, 105, 232, 255),
        )
    }

    fn base_style() -> TextStyle {
        TextStyle::new(14.0)
    }

    fn shape(sheet: &RichTextStyleSheet, src: &str) -> RichTextRun {
        RichTextRun::new(
            src,
            &base_style(),
            Color::from_rgba8(0, 0, 0, 255),
            sheet,
            &palette(),
            96.0,
        )
    }

    #[test]
    fn code_block_produces_background_paint() {
        let run = shape(&RichTextStyleSheet::new(), "```\nlet x = 1;\n```");
        let paints = run.block_paints();
        assert!(
            paints.iter().any(|p| p.background.is_some()),
            "code_block should produce a background paint"
        );
    }

    #[test]
    fn paragraph_without_paint_produces_no_paint() {
        let run = shape(&RichTextStyleSheet::new(), "plain paragraph");
        let paints = run.block_paints();
        assert!(paints.is_empty(), "got {paints:?}");
    }

    #[test]
    fn container_paint_excludes_own_margin_includes_padding() {
        // A custom container with both padding and margin — the
        // paint rect should extend by `padding` beyond the child
        // but the container's `margin` should sit outside the paint
        // (CSS: bg / border paint on the border-box, not the
        // margin-box).
        let mut sheet = RichTextStyleSheet::empty();
        sheet.set(
            "block_quote",
            StyleDelta {
                padding: Some(RichMargin::all(pt(10.0))),
                margin: Some(RichMargin::all(pt(20.0))),
                background: Some(ThemeColor::Fixed(Color::from_rgba8(200, 200, 200, 255))),
                ..StyleDelta::empty()
            },
        );
        // A paragraph on either side so the quote's margins sit inside
        // the document — margins that reach the document's own edges
        // collapse out of the box entirely.
        let run = shape(&sheet, "before\n\n> hello world\n\nafter");
        let paints = run.block_paints();
        let bq = paints
            .iter()
            .find(|p| p.background.is_some())
            .expect("expected bordered/filled blockquote paint");
        // 10pt at 96dpi = 13.33px on every side.
        // Paint should be inflated by ~13.33 relative to inner text,
        // but the run's total height should include ANOTHER 20pt on
        // each of top / bottom (margin) OUTSIDE the paint.
        let paint_h = bq.outer_rect.height();
        let total_h = run.natural_height();
        let text_h = total_h - paint_h;
        // The empty sheet gives the paragraphs no margins of their
        // own, so the two gaps around the paint are exactly the
        // quote's margin: 2 × 20pt = 26.67pt at 96dpi, on top of the
        // two paragraph lines. (Non-collapsing since blockquote has
        // padding.top+bottom > 0.)
        let expected_margin_gap = 2.0 * 20.0 * 96.0 / 72.0;
        assert!(
            text_h >= expected_margin_gap * 0.9,
            "run should be taller than paint by ~2×margin plus two lines (paint={paint_h}, total={total_h}, expected gap ≈ {expected_margin_gap})",
        );
    }

    #[test]
    fn container_margin_at_the_document_edge_leaves_no_gap() {
        // Same container as above, alone in the document: both its
        // margins now reach the document's edges, collapse out of the
        // box, and leave the run exactly as tall as the paint.
        let mut sheet = RichTextStyleSheet::empty();
        sheet.set(
            "block_quote",
            StyleDelta {
                padding: Some(RichMargin::all(pt(10.0))),
                margin: Some(RichMargin::all(pt(20.0))),
                background: Some(ThemeColor::Fixed(Color::from_rgba8(200, 200, 200, 255))),
                ..StyleDelta::empty()
            },
        );
        let run = shape(&sheet, "> hello world");
        let paints = run.block_paints();
        let bq = paints
            .iter()
            .find(|p| p.background.is_some())
            .expect("expected bordered/filled blockquote paint");
        let gap = run.natural_height() - bq.outer_rect.height();
        assert!(
            gap.abs() < 0.01,
            "document-edge margins should collapse out of the box (gap={gap})"
        );
    }

    #[test]
    fn blockquote_paint_wraps_its_content() {
        // Default block_quote entry has a border (Accent alpha) — its
        // paint should exist and its outer rect should be wider than
        // an equivalent plain paragraph's ink rect.
        let quoted = shape(&RichTextStyleSheet::new(), "> hello world");
        let paints = quoted.block_paints();
        let bq = paints.iter().find(|p| p.border.is_some());
        assert!(bq.is_some(), "blockquote should produce a bordered paint");
    }

    #[test]
    fn border_only_block_produces_stroke_paint() {
        let mut sheet = RichTextStyleSheet::empty();
        sheet.set(
            "paragraph",
            StyleDelta {
                border_color: Some(ThemeColor::Ink),
                border_width: Some(RichMargin::all(pt(1.0))),
                ..StyleDelta::empty()
            },
        );
        let run = shape(&sheet, "content");
        let paints = run.block_paints();
        assert_eq!(paints.len(), 1);
        assert!(paints[0].border.is_some());
        assert!(paints[0].background.is_none());
    }

    #[test]
    fn zero_width_border_collapses_to_no_paint() {
        let mut sheet = RichTextStyleSheet::empty();
        sheet.set(
            "paragraph",
            StyleDelta {
                border_color: Some(ThemeColor::Ink),
                border_width: Some(RichMargin::all(pt(0.0))),
                ..StyleDelta::empty()
            },
        );
        let run = shape(&sheet, "content");
        assert!(run.block_paints().is_empty());
    }

    #[test]
    fn left_only_border_produces_left_edge_paint() {
        // Only the left side has non-zero width — verify the paint's
        // BlockBorder records widths_px with the left slot populated
        // and the others at zero.
        let mut sheet = RichTextStyleSheet::empty();
        sheet.set(
            "paragraph",
            StyleDelta {
                border_color: Some(ThemeColor::Ink),
                border_width: Some(RichMargin::new(pt(0.0), pt(0.0), pt(0.0), pt(4.0))),
                ..StyleDelta::empty()
            },
        );
        let run = shape(&sheet, "content");
        let paints = run.block_paints();
        assert_eq!(paints.len(), 1);
        let border = paints[0].border.as_ref().expect("expected a border");
        assert!(
            !border.is_uniform(),
            "border should be per-side, not uniform"
        );
        assert!(border.widths_px[0].abs() < 0.1, "top should be 0");
        assert!(border.widths_px[1].abs() < 0.1, "right should be 0");
        assert!(border.widths_px[2].abs() < 0.1, "bottom should be 0");
        assert!(border.widths_px[3] > 3.0, "left should be ~4pt in px");
    }

    #[test]
    fn own_padding_inflates_outer_rect() {
        let mut sheet = RichTextStyleSheet::empty();
        sheet.set(
            "paragraph",
            StyleDelta {
                background: Some(ThemeColor::Fixed(Color::from_rgba8(200, 200, 200, 255))),
                padding: Some(RichMargin::all(pt(10.0))),
                ..StyleDelta::empty()
            },
        );
        let run = shape(&sheet, "hello world");
        let paints = run.block_paints();
        assert_eq!(paints.len(), 1);
        // 10pt at 96dpi ≈ 13.33px on every side. The paragraph's
        // shaped content sits at (padding_left, padding_top) = (13.33,
        // 13.33). Its outer rect must therefore reach back to (0, 0)
        // on the top-left.
        let p = &paints[0];
        assert!(
            p.outer_rect.x0.abs() < 0.5,
            "expected outer.x0 ≈ 0 (padding pulls rect back to origin), got {}",
            p.outer_rect.x0
        );
        assert!(
            p.outer_rect.y0.abs() < 0.5,
            "expected outer.y0 ≈ 0, got {}",
            p.outer_rect.y0
        );
    }
}