🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
//! `layout` — dwuprzebiegowy układ (`measure` → `allocate`) w modelu retencyjnym.
//!
//! Zamiast ręcznie liczyć prostokąty (jak w `luna-demo`), aplikacja buduje **drzewo**
//! [`Node`] (stosy, wcięcia, liście), a silnik:
//!
//! 1. **mierzy** je ([`Node::measure`]) względem [`Constraints`] (min/max) — ile miejsca
//! chce poddrzewo (tekst mierzy się realną czcionką przez [`LayoutCtx::text`]),
//! 2. **rozmieszcza** ([`Node::allocate`]) w zadanym prostokącie — dzieci dzielą przestrzeń,
//! [`Node::Expanded`]/[`Node::Spacer`] pochłaniają nadmiar wzdłuż osi stosu.
//!
//! [`Node::allocate`] zapisuje prostokąty **liści** w kolejności DFS; aplikacja mapuje je na
//! konkretne widgety (te same, co dotąd: `paint` przyjmuje prostokąt).
//!
//! ```
//! use libluna_core::{Rect, Size, Insets, Theme};
//! use libluna_render::TextEngine;
//! use libluna_widgets::layout::{Layout, LayoutCtx, Node};
//!
//! let theme = Theme::light();
//! let text = TextEngine::system();
//! let ctx = LayoutCtx { theme: &theme, text: &text };
//! let tree = Node::padding(
//! Insets::splat(12.0),
//! Node::vbox(vec![
//! Node::fixed(0.0, 40.0),
//! Node::spacer(),
//! Node::fixed(0.0, 32.0),
//! ])
//! .gap(8.0),
//! );
//! let rects = Layout::new(tree).fit(Rect::from_size(Size::new(320.0, 240.0)), &ctx);
//! assert_eq!(rects.len(), 3);
//! ```
use libluna_core::style::FontStyle;
use libluna_core::{Insets, Rect, Size, Theme};
use libluna_render::TextEngine;
/// Ograniczenia pomiaru: dopuszczalny zakres rozmiaru (min/max).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Constraints {
pub min: Size,
pub max: Size,
}
impl Constraints {
/// Luźne: minimum zerowe, maksimum podane (typowe dla dziecka stosu).
pub const fn loose(max: Size) -> Self {
Self {
min: Size::ZERO,
max,
}
}
/// Ciasne: wymiar wymuszony (`min == max`).
pub const fn tight(size: Size) -> Self {
Self {
min: size,
max: size,
}
}
/// Bez ograniczeń (maksimum = nieskończoność).
pub fn unconstrained() -> Self {
Self {
min: Size::ZERO,
max: Size::new(f64::INFINITY, f64::INFINITY),
}
}
/// Zmniejsza granice o marginesy (np. dla dziecka [`Node::Padding`]).
pub fn deflate(&self, insets: Insets) -> Constraints {
Constraints {
min: Size::new(
(self.min.width - insets.horizontal()).max(0.0),
(self.min.height - insets.vertical()).max(0.0),
),
max: Size::new(
(self.max.width - insets.horizontal()).max(0.0),
(self.max.height - insets.vertical()).max(0.0),
),
}
}
/// Przycina rozmiar do dopuszczalnego zakresu.
pub fn constrain(&self, size: Size) -> Size {
Size::new(
size.width.clamp(self.min.width, self.max.width),
size.height.clamp(self.min.height, self.max.height),
)
}
}
/// Kontekst układu: motyw (odstępy) i silnik tekstu (pomiar napisów).
pub struct LayoutCtx<'a> {
pub theme: &'a Theme,
pub text: &'a TextEngine,
}
/// Stos: lista dzieci wzdłuż osi + odstęp między nimi.
#[derive(Debug, Clone)]
pub struct Stack {
pub children: Vec<Node>,
pub gap: f64,
}
/// Węzeł drzewa układu.
#[derive(Debug, Clone)]
pub enum Node {
/// Liść o zadanym rozmiarze (wskazówka pomiaru; w stosie oś poprzeczna jest rozciągana).
Fixed(Size),
/// Liść tekstowy (mierzy się realną czcionką).
Label { text: String, style: FontStyle },
/// Rozciągliwa pustka (pochłania nadmiar wzdłuż osi stosu).
Spacer,
/// Stos pionowy (dzieci jedna pod drugą).
VBox(Stack),
/// Stos poziomy (dzieci jedna za drugą).
HBox(Stack),
/// Wcięcie wokół dziecka.
Padding { insets: Insets, child: Box<Node> },
/// Dziecko zajmujące nadmiar wzdłuż osi stosu.
Expanded(Box<Node>),
/// Dziecko o naturalnym rozmiarze, wyśrodkowane w przydzielonym prostokącie.
Center(Box<Node>),
}
impl Node {
pub fn fixed(width: f64, height: f64) -> Self {
Node::Fixed(Size::new(width, height))
}
pub fn label(text: impl Into<String>, style: FontStyle) -> Self {
Node::Label {
text: text.into(),
style,
}
}
pub fn spacer() -> Self {
Node::Spacer
}
pub fn vbox(children: Vec<Node>) -> Self {
Node::VBox(Stack { children, gap: 0.0 })
}
pub fn hbox(children: Vec<Node>) -> Self {
Node::HBox(Stack { children, gap: 0.0 })
}
pub fn padding(insets: Insets, child: Node) -> Self {
Node::Padding {
insets,
child: Box::new(child),
}
}
pub fn expanded(child: Node) -> Self {
Node::Expanded(Box::new(child))
}
pub fn center(child: Node) -> Self {
Node::Center(Box::new(child))
}
/// Ustawia odstęp stosu (dla [`Node::VBox`]/[`Node::HBox`]; inaczej bez efektu).
pub fn gap(mut self, gap: f64) -> Self {
match &mut self {
Node::VBox(stack) | Node::HBox(stack) => stack.gap = gap,
_ => {}
}
self
}
/// Opakowuje węzeł we wcięcie.
pub fn padded(self, insets: Insets) -> Self {
Node::padding(insets, self)
}
fn is_flexible(&self) -> bool {
matches!(self, Node::Spacer | Node::Expanded(_))
}
/// Naturalny rozmiar poddrzewa dla zadanych ograniczeń.
pub fn measure(&self, c: Constraints, ctx: &LayoutCtx) -> Size {
let size = match self {
Node::Fixed(s) => *s,
Node::Label { text, style } => {
let w = ctx.text.measure(text, style.size, style.weight).width;
Size::new(w, style.line_height)
}
Node::Spacer => Size::ZERO,
Node::VBox(stack) => stack_size(stack, c, ctx, true),
Node::HBox(stack) => stack_size(stack, c, ctx, false),
Node::Padding { insets, child } => {
let inner = child.measure(c.deflate(*insets), ctx);
Size::new(
inner.width + insets.horizontal(),
inner.height + insets.vertical(),
)
}
Node::Expanded(child) | Node::Center(child) => child.measure(c, ctx),
};
c.constrain(size)
}
/// Odwiedza liście poddrzewa z ich prostokątami (kolejność DFS).
///
/// Wygodne do rysowania: dla każdego liścia wiadomo, *jaki* to węzeł i *gdzie* leży.
pub fn visit(&self, rect: Rect, ctx: &LayoutCtx, f: &mut impl FnMut(&Node, Rect)) {
match self {
Node::Fixed(_) | Node::Label { .. } | Node::Spacer => f(self, rect),
Node::Padding { insets, child } => child.visit(rect.deflate(*insets), ctx, f),
Node::Expanded(child) => child.visit(rect, ctx, f),
Node::Center(child) => {
let natural = child.measure(Constraints::loose(rect.size), ctx);
let w = natural.width.min(rect.width());
let h = natural.height.min(rect.height());
let child_rect = Rect::from_xywh(
rect.x() + (rect.width() - w) / 2.0,
rect.y() + (rect.height() - h) / 2.0,
w,
h,
);
child.visit(child_rect, ctx, f);
}
Node::VBox(stack) => visit_stack(stack, rect, ctx, f, true),
Node::HBox(stack) => visit_stack(stack, rect, ctx, f, false),
}
}
/// Rozmieszcza poddrzewo w `rect`, zapisując prostokąty liści (DFS) do `out`.
pub fn allocate(&self, rect: Rect, ctx: &LayoutCtx, out: &mut Vec<Rect>) {
self.visit(rect, ctx, &mut |_node, r| out.push(r));
}
}
fn stack_size(stack: &Stack, c: Constraints, ctx: &LayoutCtx, vertical: bool) -> Size {
let child_c = Constraints::loose(c.max);
let mut cross: f64 = 0.0;
let mut main: f64 = 0.0;
for (i, ch) in stack.children.iter().enumerate() {
let s = ch.measure(child_c, ctx);
let (m, cr) = if vertical {
(s.height, s.width)
} else {
(s.width, s.height)
};
main += m;
cross = cross.max(cr);
if i + 1 < stack.children.len() {
main += stack.gap;
}
}
if vertical {
Size::new(cross, main)
} else {
Size::new(main, cross)
}
}
fn visit_stack(
stack: &Stack,
rect: Rect,
ctx: &LayoutCtx,
f: &mut impl FnMut(&Node, Rect),
vertical: bool,
) {
let n = stack.children.len();
if n == 0 {
return;
}
let meas_c = Constraints::loose(rect.size);
let naturals: Vec<Size> = stack
.children
.iter()
.map(|c| c.measure(meas_c, ctx))
.collect();
let (avail, cross) = if vertical {
(rect.height(), rect.width())
} else {
(rect.width(), rect.height())
};
let gap_total = stack.gap * (n as f64 - 1.0);
let used: f64 = naturals
.iter()
.map(|s| if vertical { s.height } else { s.width })
.sum::<f64>()
+ gap_total;
let extra = (avail - used).max(0.0);
let flex_count = stack.children.iter().filter(|c| c.is_flexible()).count() as f64;
let bonus = if flex_count > 0.0 {
extra / flex_count
} else {
0.0
};
let mut cursor = if vertical { rect.y() } else { rect.x() };
for (i, ch) in stack.children.iter().enumerate() {
let natural_main = if vertical {
naturals[i].height
} else {
naturals[i].width
};
let main = natural_main + if ch.is_flexible() { bonus } else { 0.0 };
let child_rect = if vertical {
Rect::from_xywh(rect.x(), cursor, cross, main)
} else {
Rect::from_xywh(cursor, rect.y(), main, cross)
};
ch.visit(child_rect, ctx, f);
cursor += main + stack.gap;
}
}
/// Gotowy układ (korzeń drzewa) — wygodne wejście dla aplikacji.
#[derive(Debug, Clone)]
pub struct Layout {
root: Node,
}
impl Layout {
pub fn new(root: Node) -> Self {
Self { root }
}
pub fn root(&self) -> &Node {
&self.root
}
/// Pożądany rozmiar drzewa dla danych ograniczeń (przebieg `measure`).
pub fn desired_size(&self, c: Constraints, ctx: &LayoutCtx) -> Size {
self.root.measure(c, ctx)
}
/// Dwuprzebiegowe złożenie: `measure`, a następnie `allocate` w `rect`.
/// Zwraca prostokąty liści w kolejności DFS.
pub fn fit(&self, rect: Rect, ctx: &LayoutCtx) -> Vec<Rect> {
let _desired = self.root.measure(Constraints::loose(rect.size), ctx);
let mut out = Vec::new();
self.root.allocate(rect, ctx, &mut out);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn theme() -> Theme {
Theme::light()
}
#[test]
fn constraints_clamp() {
let c = Constraints::loose(Size::new(100.0, 50.0));
assert_eq!(c.constrain(Size::new(200.0, 10.0)), Size::new(100.0, 10.0));
let t = Constraints::tight(Size::new(40.0, 40.0));
assert_eq!(t.constrain(Size::new(0.0, 0.0)), Size::new(40.0, 40.0));
}
#[test]
fn vbox_stacks_with_gap_and_stretches_cross_axis() {
let theme = theme();
let text = TextEngine::system();
let ctx = LayoutCtx {
theme: &theme,
text: &text,
};
let node = Node::vbox(vec![Node::fixed(10.0, 20.0), Node::fixed(10.0, 30.0)]).gap(5.0);
let rect = Rect::from_xywh(0.0, 0.0, 100.0, 200.0);
let mut out = Vec::new();
node.allocate(rect, &ctx, &mut out);
assert_eq!(out.len(), 2);
assert_eq!(out[0], Rect::from_xywh(0.0, 0.0, 100.0, 20.0));
assert_eq!(out[1], Rect::from_xywh(0.0, 25.0, 100.0, 30.0));
}
#[test]
fn spacer_absorbs_extra_in_hbox() {
let theme = theme();
let text = TextEngine::system();
let ctx = LayoutCtx {
theme: &theme,
text: &text,
};
let node = Node::hbox(vec![
Node::fixed(10.0, 10.0),
Node::spacer(),
Node::fixed(10.0, 10.0),
]);
let rect = Rect::from_xywh(0.0, 0.0, 100.0, 10.0);
let mut out = Vec::new();
node.allocate(rect, &ctx, &mut out);
assert_eq!(out[0], Rect::from_xywh(0.0, 0.0, 10.0, 10.0));
assert_eq!(out[1], Rect::from_xywh(10.0, 0.0, 80.0, 10.0));
assert_eq!(out[2], Rect::from_xywh(90.0, 0.0, 10.0, 10.0));
}
#[test]
fn expanded_grows_along_main_axis() {
let theme = theme();
let text = TextEngine::system();
let ctx = LayoutCtx {
theme: &theme,
text: &text,
};
let node = Node::vbox(vec![
Node::fixed(0.0, 10.0),
Node::expanded(Node::fixed(0.0, 10.0)),
]);
let rect = Rect::from_xywh(0.0, 0.0, 50.0, 100.0);
let mut out = Vec::new();
node.allocate(rect, &ctx, &mut out);
assert_eq!(out[0], Rect::from_xywh(0.0, 0.0, 50.0, 10.0));
assert_eq!(out[1], Rect::from_xywh(0.0, 10.0, 50.0, 90.0));
}
#[test]
fn padding_insets_child() {
let theme = theme();
let text = TextEngine::system();
let ctx = LayoutCtx {
theme: &theme,
text: &text,
};
let node = Node::padding(
Insets::splat(5.0),
Node::vbox(vec![Node::fixed(0.0, 10.0), Node::fixed(0.0, 10.0)]),
);
let rect = Rect::from_xywh(0.0, 0.0, 100.0, 100.0);
let mut out = Vec::new();
node.allocate(rect, &ctx, &mut out);
assert_eq!(out[0], Rect::from_xywh(5.0, 5.0, 90.0, 10.0));
assert_eq!(out[1], Rect::from_xywh(5.0, 15.0, 90.0, 10.0));
}
#[test]
fn center_keeps_natural_size() {
let theme = theme();
let text = TextEngine::system();
let ctx = LayoutCtx {
theme: &theme,
text: &text,
};
let node = Node::center(Node::fixed(40.0, 24.0));
let rect = Rect::from_xywh(0.0, 0.0, 100.0, 60.0);
let mut out = Vec::new();
node.allocate(rect, &ctx, &mut out);
assert_eq!(out[0], Rect::from_xywh(30.0, 18.0, 40.0, 24.0));
}
#[test]
fn label_measures_text() {
let theme = theme();
let text = TextEngine::system();
let ctx = LayoutCtx {
theme: &theme,
text: &text,
};
let node = Node::label("Przykładowy napis", theme.typography.body);
let size = node.measure(Constraints::unconstrained(), &ctx);
assert!(size.width > 0.0);
assert_eq!(size.height, theme.typography.body.line_height);
}
#[test]
fn fit_collects_leaf_rects() {
let theme = theme();
let text = TextEngine::system();
let ctx = LayoutCtx {
theme: &theme,
text: &text,
};
let tree = Node::padding(
Insets::splat(12.0),
Node::vbox(vec![
Node::fixed(0.0, 40.0),
Node::spacer(),
Node::fixed(0.0, 32.0),
])
.gap(8.0),
);
let rects = Layout::new(tree).fit(Rect::from_size(Size::new(320.0, 240.0)), &ctx);
assert_eq!(rects.len(), 3);
// Ostatni liść dotyka dolnej krawędzi wewnątrz wcięcia.
assert!((rects[2].bottom() - (240.0 - 12.0)).abs() < 1e-9);
}
}