Skip to main content

stipple_core/
render.rs

1//! Layout and paint passes: turn an [`Element`] tree into a retained
2//! [`LayoutNode`] tree, then paint that tree into a [`Scene`].
3//!
4//! Three phases:
5//! 1. **measure** — bottom-up natural sizing (content + padding, honoring fixed
6//!    overrides; text measures via the active [`Font`]).
7//! 2. **layout** — top-down assignment of absolute bounds, using
8//!    [`stipple_layout::solve_main_axis`] to distribute the main axis and
9//!    [`Align`] to position children on the cross axis. Produces a
10//!    [`LayoutNode`] tree that survives the frame so pointer events can be
11//!    routed against it (see [`crate::hit_test`]).
12//! 3. **paint** — walk the layout tree, drawing each node's decoration and text.
13
14use crate::element::{Align, BoxStyle, Element, ElementKind};
15use crate::runtime::{
16    ActionId, FocusId, LayoutNode, NodeContent, ScrollId, find_action, find_focus, first_text,
17};
18use stipple_geometry::{Point, Rect, Size, Vec2};
19use stipple_layout::{Axis, FlexItem, solve_main_axis};
20use stipple_render::{Color, Font, Scene};
21
22/// Neutral fill painted into an embedded-content viewport before its content is
23/// composited in, so the reserved area reads as a defined surface (a dark slate
24/// matching the GPU path's placeholder in `stipple-gpu`).
25const VIEWPORT_PLACEHOLDER: Color = Color::rgb(0x20, 0x24, 0x2c);
26
27/// Natural (desired) size of `el` given the `avail` space and active `font`.
28pub fn measure(el: &Element, avail: Size, font: Option<&Font>) -> Size {
29    let pad = el.layout.padding;
30    let inner = avail.deflate(pad);
31
32    let content = match &el.kind {
33        // A viewport sizes purely to its width/height overrides (or grow); it
34        // has no intrinsic content size.
35        ElementKind::Leaf | ElementKind::Viewport { .. } => Size::ZERO,
36        ElementKind::Text { text, size, .. } => match font {
37            // Wrapping text takes the available width and grows in height.
38            Some(f) if el.wrap && inner.width.is_finite() => {
39                let lines = f.wrap(text, *size, inner.width);
40                let w = lines
41                    .iter()
42                    .map(|l| f.measure(l, *size).width)
43                    .fold(0.0_f64, f64::max);
44                Size::new(w, f.line_height(*size) * lines.len() as f64)
45            }
46            Some(f) => f.measure(text, *size),
47            None => Size::ZERO,
48        },
49        ElementKind::Stack {
50            axis,
51            gap,
52            children,
53            ..
54        } => {
55            let mut main = 0.0;
56            let mut cross: f64 = 0.0;
57            for c in children {
58                let cs = measure(c, inner, font);
59                main += axis.main(cs);
60                cross = cross.max(axis.cross(cs));
61            }
62            if children.len() > 1 {
63                main += gap * (children.len() as f64 - 1.0);
64            }
65            axis.size(main, cross)
66        }
67    };
68
69    let w = el
70        .layout
71        .size
72        .width
73        .unwrap_or(content.width + pad.horizontal());
74    let h = el
75        .layout
76        .size
77        .height
78        .unwrap_or(content.height + pad.vertical());
79    Size::new(w, h)
80}
81
82/// Lay `el` out within `bounds`, producing a retained [`LayoutNode`] tree with
83/// absolute bounds, decorations, text content, and action handles.
84pub fn layout(el: &Element, bounds: Rect, font: Option<&Font>) -> LayoutNode {
85    let content = match &el.kind {
86        ElementKind::Text { text, size, color } => NodeContent::Text {
87            text: text.clone(),
88            size: *size,
89            color: *color,
90        },
91        ElementKind::Viewport { id } => NodeContent::Viewport(*id),
92        _ => NodeContent::None,
93    };
94    let mut node = LayoutNode {
95        bounds,
96        decoration: el.decoration,
97        content,
98        action: el.action,
99        focus: el.focus,
100        drag: el.drag,
101        context: el.context,
102        caret: el.caret,
103        selection: el.selection,
104        text_pos: el.text_pos,
105        wrap: el.wrap,
106        scroll: el.scroll,
107        clip: el.clip,
108        children: Vec::new(),
109    };
110
111    let ElementKind::Stack {
112        axis,
113        gap,
114        main_align,
115        cross_align,
116        children,
117    } = &el.kind
118    else {
119        return node;
120    };
121    if children.is_empty() {
122        return node;
123    }
124
125    let inner = bounds.inset(el.layout.padding);
126    let avail = inner.size;
127    let axis = *axis;
128
129    // Main-axis distribution.
130    let measured: Vec<Size> = children.iter().map(|c| measure(c, avail, font)).collect();
131    // A scroll container lays its children out at their *natural* main size
132    // (so content can overflow the viewport), stacked from the start with no
133    // grow/shrink; the offset + clip are applied afterward (see `apply_scroll`).
134    let (spans, main_shift) = if el.scroll.is_some() {
135        let mut spans = Vec::with_capacity(children.len());
136        let mut cursor = 0.0;
137        for m in &measured {
138            let length = axis.main(*m);
139            spans.push(stipple_layout::Span {
140                offset: cursor,
141                length,
142            });
143            cursor += length + *gap;
144        }
145        (spans, 0.0)
146    } else {
147        let items: Vec<FlexItem> = children
148            .iter()
149            .zip(&measured)
150            .map(|(c, m)| FlexItem {
151                basis: axis.main(*m),
152                grow: c.layout.grow,
153            })
154            .collect();
155        let spans = solve_main_axis(axis.main(avail), *gap, &items);
156        // If nothing grows, the block may be shorter than the content area;
157        // shift it as a whole per the main-axis alignment.
158        let used_main = spans.last().map(|s| s.offset + s.length).unwrap_or(0.0);
159        let leftover = (axis.main(avail) - used_main).max(0.0);
160        let main_shift = match main_align {
161            Align::Start | Align::Stretch => 0.0,
162            Align::Center => leftover / 2.0,
163            Align::End => leftover,
164        };
165        (spans, main_shift)
166    };
167
168    node.children.reserve(children.len());
169    for ((child, m), span) in children.iter().zip(&measured).zip(&spans) {
170        let cross_avail = axis.cross(avail);
171        let cross_len = match cross_align {
172            Align::Stretch => cross_avail,
173            _ => axis.cross(*m).min(cross_avail),
174        };
175        let cross_off = match cross_align {
176            Align::Start | Align::Stretch => 0.0,
177            Align::Center => (cross_avail - cross_len) / 2.0,
178            Align::End => cross_avail - cross_len,
179        };
180        let child_bounds = child_rect(
181            axis,
182            inner,
183            span.offset + main_shift,
184            span.length,
185            cross_off,
186            cross_len,
187        );
188        node.children.push(layout(child, child_bounds, font));
189    }
190    node
191}
192
193/// Overlay focus affordances for the focused element: a `ring` around its
194/// bounds, a `selection` highlight behind any selected text range, and a
195/// `caret` bar at the text's caret index (or its end when no caret is set).
196/// No-op if `focused` isn't in the tree.
197pub fn paint_focus(
198    tree: &LayoutNode,
199    focused: FocusId,
200    scene: &mut Scene,
201    font: Option<&Font>,
202    ring: Color,
203    caret: Color,
204    selection: Color,
205) {
206    let Some(node) = find_focus(tree, focused) else {
207        return;
208    };
209    scene.stroke_rect(node.bounds, ring, 2.0);
210
211    let Some(leaf) = first_text(node) else { return };
212    let NodeContent::Text { text, size, .. } = &leaf.content else {
213        return;
214    };
215    let bounds = leaf.bounds;
216    let size = *size;
217    let line_h = font.map(|f| f.line_height(size)).unwrap_or(size);
218    // Width of a text slice (0 without a font).
219    let width = |slice: &str| font.map(|f| f.measure(slice, size).width).unwrap_or(0.0);
220    // Map a byte index to its (x, line) on screen.
221    let pos = |i: usize| -> (f64, usize) {
222        let i = i.min(text.len());
223        let ls = text[..i].rfind('\n').map(|n| n + 1).unwrap_or(0);
224        let line = text[..ls].bytes().filter(|&b| b == b'\n').count();
225        (bounds.min_x() + width(&text[ls..i]), line)
226    };
227
228    // Selection highlight, drawn as one rectangle per spanned line (under the
229    // caret; translucent so the text reads through).
230    if let Some((s, e)) = leaf.selection
231        && e > s
232    {
233        let mut ls = 0usize;
234        let mut line = 0usize;
235        loop {
236            let le = text[ls..].find('\n').map(|n| ls + n).unwrap_or(text.len());
237            // Intersect [s, e) with this line, including the trailing '\n' when
238            // the selection continues onto the next line.
239            let nl = if le < text.len() { 1 } else { 0 };
240            let sel_s = s.max(ls);
241            let sel_e = e.min(le + nl);
242            if sel_e > sel_s && sel_s <= le {
243                let x0 = bounds.min_x() + width(&text[ls..sel_s.min(le)]);
244                let x1 = bounds.min_x() + width(&text[ls..sel_e.min(le)]);
245                let extra = if sel_e > le { 6.0 } else { 0.0 }; // hint the newline
246                let y = bounds.min_y() + line as f64 * line_h;
247                scene.fill_rect(Rect::from_xywh(x0, y, (x1 - x0) + extra, line_h), selection);
248            }
249            if le >= text.len() {
250                break;
251            }
252            ls = le + 1;
253            line += 1;
254        }
255    }
256
257    // Caret bar on its line (or end of text when unset).
258    let (cx, cline) = pos(leaf.caret.unwrap_or(text.len()));
259    let cx = (cx + 1.0).min(bounds.max_x().max(bounds.min_x() + 1.0));
260    let cy = bounds.min_y() + cline as f64 * line_h;
261    scene.fill_rect(Rect::from_xywh(cx, cy, 2.0, line_h), caret);
262}
263
264/// Resolve a pointer position (logical pixels, absolute) to the nearest caret
265/// byte index within `node`'s first text leaf. The `y` picks the line and the
266/// `x` the column within it; returns the boundary whose x is closest. Returns
267/// `None` if `node` has no text or no `font`.
268pub fn caret_index_at(node: &LayoutNode, point: Point, font: Option<&Font>) -> Option<usize> {
269    let leaf = first_text(node)?;
270    let NodeContent::Text { text, size, .. } = &leaf.content else {
271        return None;
272    };
273    let font = font?;
274    let line_h = font.line_height(*size).max(1.0);
275    let line = ((point.y - leaf.bounds.min_y()) / line_h).floor().max(0.0) as usize;
276
277    // Byte range [ls, le) of the chosen line (clamped to the last line if
278    // `line` overruns the line count).
279    let (mut ls, mut le) = (0usize, text.len());
280    let mut start = 0usize;
281    for (i, part) in text.split('\n').enumerate() {
282        let end = start + part.len();
283        ls = start;
284        le = end;
285        if i == line {
286            break;
287        }
288        start = end + 1; // skip the '\n'
289    }
290
291    let local = point.x - leaf.bounds.min_x();
292    if local <= 0.0 {
293        return Some(ls);
294    }
295    // Pick the char boundary in [ls, le] whose x is nearest the pointer.
296    let line_text = &text[ls..le];
297    let mut best = ls;
298    let mut best_dist = f64::INFINITY;
299    for (off, _) in line_text
300        .char_indices()
301        .map(|(i, _)| (i, ()))
302        .chain(std::iter::once((line_text.len(), ())))
303    {
304        let w = font.measure(&line_text[..off], *size).width;
305        let d = (w - local).abs();
306        if d < best_dist {
307            best_dist = d;
308            best = ls + off;
309        }
310    }
311    Some(best)
312}
313
314/// Overlay a `highlight` (typically translucent) on the hovered tappable
315/// element, matching its corner radius. No-op if `hovered` isn't in the tree.
316pub fn paint_hover(tree: &LayoutNode, hovered: ActionId, scene: &mut Scene, highlight: Color) {
317    if let Some(node) = find_action(tree, hovered) {
318        if node.decoration.radius > 0.0 {
319            scene.fill_round_rect(node.bounds, node.decoration.radius, highlight);
320        } else {
321            scene.fill_rect(node.bounds, highlight);
322        }
323    }
324}
325
326/// Paint a laid-out tree into `scene`, parents before children.
327pub fn paint(node: &LayoutNode, scene: &mut Scene, font: Option<&Font>) {
328    paint_decoration(&node.decoration, node.bounds, scene);
329    match &node.content {
330        NodeContent::Text { text, size, color } => {
331            if let Some(f) = font {
332                if node.wrap {
333                    // Wrap to the laid-out width; fill_text renders the joined lines.
334                    let wrapped = f.wrap(text, *size, node.bounds.width()).join("\n");
335                    scene.fill_text(f, &wrapped, node.bounds.origin, *size, *color);
336                } else {
337                    scene.fill_text(f, text, node.bounds.origin, *size, *color);
338                }
339            }
340        }
341        // Reserve the embedded-content area: paint a placeholder and record the
342        // viewport command so the app/GPU composites real content into the rect.
343        NodeContent::Viewport(id) => {
344            scene.fill_viewport(node.bounds, id.0 as u64, VIEWPORT_PLACEHOLDER);
345        }
346        NodeContent::None => {}
347    }
348    // Clip children to this node's bounds (scroll containers, overlay panels)
349    // so overflowing content is masked to the viewport.
350    if node.clip && !node.children.is_empty() {
351        scene.push_clip(node.bounds);
352        for child in &node.children {
353            paint(child, scene, font);
354        }
355        scene.pop_clip();
356    } else {
357        for child in &node.children {
358            paint(child, scene, font);
359        }
360    }
361}
362
363/// Apply scroll offsets to a laid-out tree: for each scroll container, clamp its
364/// stored offset to the content overflow and shift its descendants up by that
365/// amount (so the offset is the source of truth, re-applied each frame). Returns
366/// nothing; clamps `offsets` in place so the app keeps valid values.
367pub fn apply_scroll(node: &mut LayoutNode, offsets: &mut std::collections::HashMap<ScrollId, f64>) {
368    if let Some(id) = node.scroll {
369        // Content height = furthest child extent below the viewport top; viewport
370        // height = this node's bounds height.
371        let top = node.bounds.min_y();
372        let content_bottom = node
373            .children
374            .iter()
375            .map(|c| c.bounds.max_y())
376            .fold(top, f64::max);
377        let content_h = content_bottom - top;
378        let max_off = (content_h - node.bounds.height()).max(0.0);
379        let off = offsets.entry(id).or_insert(0.0);
380        *off = off.clamp(0.0, max_off);
381        let dy = *off;
382        if dy > 0.0 {
383            for child in &mut node.children {
384                translate(child, -dy);
385            }
386        }
387    }
388    for child in &mut node.children {
389        apply_scroll(child, offsets);
390    }
391}
392
393/// Shift a node and all its descendants vertically by `dy` (logical pixels).
394fn translate(node: &mut LayoutNode, dy: f64) {
395    node.bounds = node.bounds.translate(Vec2::new(0.0, dy));
396    for child in &mut node.children {
397        translate(child, dy);
398    }
399}
400
401fn child_rect(
402    axis: Axis,
403    content: Rect,
404    main_off: f64,
405    main_len: f64,
406    cross_off: f64,
407    cross_len: f64,
408) -> Rect {
409    match axis {
410        Axis::Horizontal => Rect::from_xywh(
411            content.min_x() + main_off,
412            content.min_y() + cross_off,
413            main_len,
414            cross_len,
415        ),
416        Axis::Vertical => Rect::from_xywh(
417            content.min_x() + cross_off,
418            content.min_y() + main_off,
419            cross_len,
420            main_len,
421        ),
422    }
423}
424
425fn paint_decoration(deco: &BoxStyle, bounds: Rect, scene: &mut Scene) {
426    if let Some(fill) = deco.fill {
427        if deco.radius > 0.0 {
428            scene.fill_round_rect(bounds, deco.radius, fill);
429        } else {
430            scene.fill_rect(bounds, fill);
431        }
432    }
433    if let Some((color, width)) = deco.border {
434        scene.stroke_rect(bounds, color, width);
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::element::BoxStyle;
442    use stipple_render::Color;
443
444    #[test]
445    fn measure_sums_children_with_gap_and_padding() {
446        let child = || Element::boxed(BoxStyle::default()).width(20.0).height(10.0);
447        let stack = Element::stack(Axis::Vertical, vec![child(), child(), child()])
448            .gap(5.0)
449            .padding(stipple_geometry::Insets::uniform(4.0));
450        // main (vertical): 3*10 + 2*5 + 2*4 = 48; cross (width): 20 + 2*4 = 28
451        let size = measure(&stack, Size::new(1000.0, 1000.0), None);
452        assert_eq!(size, Size::new(28.0, 48.0));
453    }
454
455    #[test]
456    fn caret_index_at_resolves_pointer_to_byte_index() {
457        let Some(font) = Font::system_default() else {
458            eprintln!("skipping: no system font found");
459            return;
460        };
461        // A text leaf "hello" at x origin 10.
462        let el = Element::text("hello", 16.0, Color::BLACK);
463        let node = layout(&el, Rect::from_xywh(10.0, 0.0, 200.0, 20.0), Some(&font));
464        let y = node.bounds.min_y() + 1.0;
465        // Far left → index 0; far right → end (5).
466        assert_eq!(
467            caret_index_at(&node, Point::new(0.0, y), Some(&font)),
468            Some(0)
469        );
470        assert_eq!(
471            caret_index_at(&node, Point::new(9.0, y), Some(&font)),
472            Some(0)
473        );
474        assert_eq!(
475            caret_index_at(&node, Point::new(10_000.0, y), Some(&font)),
476            Some(5)
477        );
478        // A point near the middle lands on an interior boundary (1..=4).
479        let mid = node.bounds.min_x() + font.measure("hel", 16.0).width;
480        let i = caret_index_at(&node, Point::new(mid, y), Some(&font)).unwrap();
481        assert!((1..=4).contains(&i), "mid index {i} out of range");
482        // Without a font, no resolution is possible.
483        assert_eq!(caret_index_at(&node, Point::new(mid, y), None), None);
484    }
485
486    #[test]
487    fn viewport_reserves_its_rect_and_paints_a_viewport_command() {
488        use crate::runtime::{ViewportId, collect_viewports, viewport_at};
489        use stipple_render::DrawCmd;
490
491        let el = Element::viewport(ViewportId(3)).width(120.0).height(80.0);
492        // Sizes to its overrides, not to content.
493        assert_eq!(
494            measure(&el, Size::new(1000.0, 1000.0), None),
495            Size::new(120.0, 80.0)
496        );
497
498        let tree = layout(&el, Rect::from_xywh(20.0, 10.0, 120.0, 80.0), None);
499        assert!(matches!(tree.content, NodeContent::Viewport(ViewportId(3))));
500
501        // collect_viewports / viewport_at locate it by id + bounds for the compositor.
502        let mut found = Vec::new();
503        collect_viewports(&tree, &mut found);
504        assert_eq!(
505            found,
506            vec![(ViewportId(3), Rect::from_xywh(20.0, 10.0, 120.0, 80.0))]
507        );
508        assert_eq!(
509            viewport_at(&tree, Point::new(30.0, 20.0)).map(|(id, _)| id),
510            Some(ViewportId(3))
511        );
512        assert_eq!(viewport_at(&tree, Point::new(0.0, 0.0)), None);
513
514        // Painting records a Viewport draw command carrying the id.
515        let mut scene = Scene::new(Size::new(200.0, 120.0));
516        paint(&tree, &mut scene, None);
517        assert!(
518            scene
519                .commands()
520                .iter()
521                .any(|c| matches!(c, DrawCmd::Viewport { id: 3, .. }))
522        );
523    }
524
525    #[test]
526    fn grow_child_fills_main_axis() {
527        // A row with one fixed 40px box and one grow=1 box in 200px width.
528        let fixed = Element::boxed(BoxStyle::default()).width(40.0).height(10.0);
529        let flex = Element::boxed(BoxStyle {
530            fill: Some(Color::WHITE),
531            ..BoxStyle::default()
532        })
533        .grow(1.0);
534        let row =
535            Element::stack(Axis::Horizontal, vec![fixed, flex]).align(Align::Start, Align::Stretch);
536
537        let tree = layout(&row, Rect::from_xywh(0.0, 0.0, 200.0, 20.0), None);
538        // The flex child occupies the leftover: 200 - 40 = 160px, at x=40,
539        // stretched to the full 20px cross height.
540        assert_eq!(
541            tree.children[1].bounds,
542            Rect::from_xywh(40.0, 0.0, 160.0, 20.0)
543        );
544
545        let mut scene = Scene::new(Size::new(200.0, 20.0));
546        paint(&tree, &mut scene, None);
547        assert_eq!(scene.len(), 1); // only the filled flex box paints
548    }
549}