🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-widgets/src/layout.rs main

492 lines Raw ← Back
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);
    }
}