use crate::element::{Align, BoxStyle, Element, ElementKind};
use crate::runtime::{
ActionId, FocusId, LayoutNode, NodeContent, ScrollId, find_action, find_focus, first_text,
};
use stipple_geometry::{Point, Rect, Size, Vec2};
use stipple_layout::{Axis, FlexItem, solve_main_axis};
use stipple_render::{Color, Font, Scene};
const VIEWPORT_PLACEHOLDER: Color = Color::rgb(0x20, 0x24, 0x2c);
pub fn measure(el: &Element, avail: Size, font: Option<&Font>) -> Size {
let pad = el.layout.padding;
let inner = avail.deflate(pad);
let content = match &el.kind {
ElementKind::Leaf | ElementKind::Viewport { .. } => Size::ZERO,
ElementKind::Text { text, size, .. } => match font {
Some(f) if el.wrap && inner.width.is_finite() => {
let lines = f.wrap(text, *size, inner.width);
let w = lines
.iter()
.map(|l| f.measure(l, *size).width)
.fold(0.0_f64, f64::max);
Size::new(w, f.line_height(*size) * lines.len() as f64)
}
Some(f) => f.measure(text, *size),
None => Size::ZERO,
},
ElementKind::Stack {
axis,
gap,
children,
..
} => {
let mut main = 0.0;
let mut cross: f64 = 0.0;
for c in children {
let cs = measure(c, inner, font);
main += axis.main(cs);
cross = cross.max(axis.cross(cs));
}
if children.len() > 1 {
main += gap * (children.len() as f64 - 1.0);
}
axis.size(main, cross)
}
};
let w = el
.layout
.size
.width
.unwrap_or(content.width + pad.horizontal());
let h = el
.layout
.size
.height
.unwrap_or(content.height + pad.vertical());
Size::new(w, h)
}
pub fn layout(el: &Element, bounds: Rect, font: Option<&Font>) -> LayoutNode {
let content = match &el.kind {
ElementKind::Text { text, size, color } => NodeContent::Text {
text: text.clone(),
size: *size,
color: *color,
},
ElementKind::Viewport { id } => NodeContent::Viewport(*id),
_ => NodeContent::None,
};
let mut node = LayoutNode {
bounds,
decoration: el.decoration,
content,
action: el.action,
focus: el.focus,
drag: el.drag,
context: el.context,
caret: el.caret,
selection: el.selection,
text_pos: el.text_pos,
wrap: el.wrap,
scroll: el.scroll,
clip: el.clip,
children: Vec::new(),
};
let ElementKind::Stack {
axis,
gap,
main_align,
cross_align,
children,
} = &el.kind
else {
return node;
};
if children.is_empty() {
return node;
}
let inner = bounds.inset(el.layout.padding);
let avail = inner.size;
let axis = *axis;
let measured: Vec<Size> = children.iter().map(|c| measure(c, avail, font)).collect();
let (spans, main_shift) = if el.scroll.is_some() {
let mut spans = Vec::with_capacity(children.len());
let mut cursor = 0.0;
for m in &measured {
let length = axis.main(*m);
spans.push(stipple_layout::Span {
offset: cursor,
length,
});
cursor += length + *gap;
}
(spans, 0.0)
} else {
let items: Vec<FlexItem> = children
.iter()
.zip(&measured)
.map(|(c, m)| FlexItem {
basis: axis.main(*m),
grow: c.layout.grow,
})
.collect();
let spans = solve_main_axis(axis.main(avail), *gap, &items);
let used_main = spans.last().map(|s| s.offset + s.length).unwrap_or(0.0);
let leftover = (axis.main(avail) - used_main).max(0.0);
let main_shift = match main_align {
Align::Start | Align::Stretch => 0.0,
Align::Center => leftover / 2.0,
Align::End => leftover,
};
(spans, main_shift)
};
node.children.reserve(children.len());
for ((child, m), span) in children.iter().zip(&measured).zip(&spans) {
let cross_avail = axis.cross(avail);
let cross_len = match cross_align {
Align::Stretch => cross_avail,
_ => axis.cross(*m).min(cross_avail),
};
let cross_off = match cross_align {
Align::Start | Align::Stretch => 0.0,
Align::Center => (cross_avail - cross_len) / 2.0,
Align::End => cross_avail - cross_len,
};
let child_bounds = child_rect(
axis,
inner,
span.offset + main_shift,
span.length,
cross_off,
cross_len,
);
node.children.push(layout(child, child_bounds, font));
}
node
}
pub fn paint_focus(
tree: &LayoutNode,
focused: FocusId,
scene: &mut Scene,
font: Option<&Font>,
ring: Color,
caret: Color,
selection: Color,
) {
let Some(node) = find_focus(tree, focused) else {
return;
};
scene.stroke_rect(node.bounds, ring, 2.0);
let Some(leaf) = first_text(node) else { return };
let NodeContent::Text { text, size, .. } = &leaf.content else {
return;
};
let bounds = leaf.bounds;
let size = *size;
let line_h = font.map(|f| f.line_height(size)).unwrap_or(size);
let width = |slice: &str| font.map(|f| f.measure(slice, size).width).unwrap_or(0.0);
let pos = |i: usize| -> (f64, usize) {
let i = i.min(text.len());
let ls = text[..i].rfind('\n').map(|n| n + 1).unwrap_or(0);
let line = text[..ls].bytes().filter(|&b| b == b'\n').count();
(bounds.min_x() + width(&text[ls..i]), line)
};
if let Some((s, e)) = leaf.selection
&& e > s
{
let mut ls = 0usize;
let mut line = 0usize;
loop {
let le = text[ls..].find('\n').map(|n| ls + n).unwrap_or(text.len());
let nl = if le < text.len() { 1 } else { 0 };
let sel_s = s.max(ls);
let sel_e = e.min(le + nl);
if sel_e > sel_s && sel_s <= le {
let x0 = bounds.min_x() + width(&text[ls..sel_s.min(le)]);
let x1 = bounds.min_x() + width(&text[ls..sel_e.min(le)]);
let extra = if sel_e > le { 6.0 } else { 0.0 }; let y = bounds.min_y() + line as f64 * line_h;
scene.fill_rect(Rect::from_xywh(x0, y, (x1 - x0) + extra, line_h), selection);
}
if le >= text.len() {
break;
}
ls = le + 1;
line += 1;
}
}
let (cx, cline) = pos(leaf.caret.unwrap_or(text.len()));
let cx = (cx + 1.0).min(bounds.max_x().max(bounds.min_x() + 1.0));
let cy = bounds.min_y() + cline as f64 * line_h;
scene.fill_rect(Rect::from_xywh(cx, cy, 2.0, line_h), caret);
}
pub fn caret_index_at(node: &LayoutNode, point: Point, font: Option<&Font>) -> Option<usize> {
let leaf = first_text(node)?;
let NodeContent::Text { text, size, .. } = &leaf.content else {
return None;
};
let font = font?;
let line_h = font.line_height(*size).max(1.0);
let line = ((point.y - leaf.bounds.min_y()) / line_h).floor().max(0.0) as usize;
let (mut ls, mut le) = (0usize, text.len());
let mut start = 0usize;
for (i, part) in text.split('\n').enumerate() {
let end = start + part.len();
ls = start;
le = end;
if i == line {
break;
}
start = end + 1; }
let local = point.x - leaf.bounds.min_x();
if local <= 0.0 {
return Some(ls);
}
let line_text = &text[ls..le];
let mut best = ls;
let mut best_dist = f64::INFINITY;
for (off, _) in line_text
.char_indices()
.map(|(i, _)| (i, ()))
.chain(std::iter::once((line_text.len(), ())))
{
let w = font.measure(&line_text[..off], *size).width;
let d = (w - local).abs();
if d < best_dist {
best_dist = d;
best = ls + off;
}
}
Some(best)
}
pub fn paint_hover(tree: &LayoutNode, hovered: ActionId, scene: &mut Scene, highlight: Color) {
if let Some(node) = find_action(tree, hovered) {
if node.decoration.radius > 0.0 {
scene.fill_round_rect(node.bounds, node.decoration.radius, highlight);
} else {
scene.fill_rect(node.bounds, highlight);
}
}
}
pub fn paint(node: &LayoutNode, scene: &mut Scene, font: Option<&Font>) {
paint_decoration(&node.decoration, node.bounds, scene);
match &node.content {
NodeContent::Text { text, size, color } => {
if let Some(f) = font {
if node.wrap {
let wrapped = f.wrap(text, *size, node.bounds.width()).join("\n");
scene.fill_text(f, &wrapped, node.bounds.origin, *size, *color);
} else {
scene.fill_text(f, text, node.bounds.origin, *size, *color);
}
}
}
NodeContent::Viewport(id) => {
scene.fill_viewport(node.bounds, id.0 as u64, VIEWPORT_PLACEHOLDER);
}
NodeContent::None => {}
}
if node.clip && !node.children.is_empty() {
scene.push_clip(node.bounds);
for child in &node.children {
paint(child, scene, font);
}
scene.pop_clip();
} else {
for child in &node.children {
paint(child, scene, font);
}
}
}
pub fn apply_scroll(node: &mut LayoutNode, offsets: &mut std::collections::HashMap<ScrollId, f64>) {
if let Some(id) = node.scroll {
let top = node.bounds.min_y();
let content_bottom = node
.children
.iter()
.map(|c| c.bounds.max_y())
.fold(top, f64::max);
let content_h = content_bottom - top;
let max_off = (content_h - node.bounds.height()).max(0.0);
let off = offsets.entry(id).or_insert(0.0);
*off = off.clamp(0.0, max_off);
let dy = *off;
if dy > 0.0 {
for child in &mut node.children {
translate(child, -dy);
}
}
}
for child in &mut node.children {
apply_scroll(child, offsets);
}
}
fn translate(node: &mut LayoutNode, dy: f64) {
node.bounds = node.bounds.translate(Vec2::new(0.0, dy));
for child in &mut node.children {
translate(child, dy);
}
}
fn child_rect(
axis: Axis,
content: Rect,
main_off: f64,
main_len: f64,
cross_off: f64,
cross_len: f64,
) -> Rect {
match axis {
Axis::Horizontal => Rect::from_xywh(
content.min_x() + main_off,
content.min_y() + cross_off,
main_len,
cross_len,
),
Axis::Vertical => Rect::from_xywh(
content.min_x() + cross_off,
content.min_y() + main_off,
cross_len,
main_len,
),
}
}
fn paint_decoration(deco: &BoxStyle, bounds: Rect, scene: &mut Scene) {
if let Some(fill) = deco.fill {
if deco.radius > 0.0 {
scene.fill_round_rect(bounds, deco.radius, fill);
} else {
scene.fill_rect(bounds, fill);
}
}
if let Some((color, width)) = deco.border {
scene.stroke_rect(bounds, color, width);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::element::BoxStyle;
use stipple_render::Color;
#[test]
fn measure_sums_children_with_gap_and_padding() {
let child = || Element::boxed(BoxStyle::default()).width(20.0).height(10.0);
let stack = Element::stack(Axis::Vertical, vec![child(), child(), child()])
.gap(5.0)
.padding(stipple_geometry::Insets::uniform(4.0));
let size = measure(&stack, Size::new(1000.0, 1000.0), None);
assert_eq!(size, Size::new(28.0, 48.0));
}
#[test]
fn caret_index_at_resolves_pointer_to_byte_index() {
let Some(font) = Font::system_default() else {
eprintln!("skipping: no system font found");
return;
};
let el = Element::text("hello", 16.0, Color::BLACK);
let node = layout(&el, Rect::from_xywh(10.0, 0.0, 200.0, 20.0), Some(&font));
let y = node.bounds.min_y() + 1.0;
assert_eq!(
caret_index_at(&node, Point::new(0.0, y), Some(&font)),
Some(0)
);
assert_eq!(
caret_index_at(&node, Point::new(9.0, y), Some(&font)),
Some(0)
);
assert_eq!(
caret_index_at(&node, Point::new(10_000.0, y), Some(&font)),
Some(5)
);
let mid = node.bounds.min_x() + font.measure("hel", 16.0).width;
let i = caret_index_at(&node, Point::new(mid, y), Some(&font)).unwrap();
assert!((1..=4).contains(&i), "mid index {i} out of range");
assert_eq!(caret_index_at(&node, Point::new(mid, y), None), None);
}
#[test]
fn viewport_reserves_its_rect_and_paints_a_viewport_command() {
use crate::runtime::{ViewportId, collect_viewports, viewport_at};
use stipple_render::DrawCmd;
let el = Element::viewport(ViewportId(3)).width(120.0).height(80.0);
assert_eq!(
measure(&el, Size::new(1000.0, 1000.0), None),
Size::new(120.0, 80.0)
);
let tree = layout(&el, Rect::from_xywh(20.0, 10.0, 120.0, 80.0), None);
assert!(matches!(tree.content, NodeContent::Viewport(ViewportId(3))));
let mut found = Vec::new();
collect_viewports(&tree, &mut found);
assert_eq!(
found,
vec![(ViewportId(3), Rect::from_xywh(20.0, 10.0, 120.0, 80.0))]
);
assert_eq!(
viewport_at(&tree, Point::new(30.0, 20.0)).map(|(id, _)| id),
Some(ViewportId(3))
);
assert_eq!(viewport_at(&tree, Point::new(0.0, 0.0)), None);
let mut scene = Scene::new(Size::new(200.0, 120.0));
paint(&tree, &mut scene, None);
assert!(
scene
.commands()
.iter()
.any(|c| matches!(c, DrawCmd::Viewport { id: 3, .. }))
);
}
#[test]
fn grow_child_fills_main_axis() {
let fixed = Element::boxed(BoxStyle::default()).width(40.0).height(10.0);
let flex = Element::boxed(BoxStyle {
fill: Some(Color::WHITE),
..BoxStyle::default()
})
.grow(1.0);
let row =
Element::stack(Axis::Horizontal, vec![fixed, flex]).align(Align::Start, Align::Stretch);
let tree = layout(&row, Rect::from_xywh(0.0, 0.0, 200.0, 20.0), None);
assert_eq!(
tree.children[1].bounds,
Rect::from_xywh(40.0, 0.0, 160.0, 20.0)
);
let mut scene = Scene::new(Size::new(200.0, 20.0));
paint(&tree, &mut scene, None);
assert_eq!(scene.len(), 1); }
}