← luna

Commit 3c2afd2

6
files
+730
added
-2
deleted
@@ -309,6 +309,10 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
309 309 a także `Card` (karta z nagłówkiem), `ActionRow` (wiersz ustawień), `Toast` (powiadomienie
310 310 nakładkowe z akcją, np. „Cofnij”) oraz `StatusPage` (ekran stanu: ikona, tytuł, opis, akcja —
311 311 używany jako „pusty stan” listy) i wspólny `PaintCtx`.
312 +- `libluna-widgets::layout`: **dwuprzebiegowy układ** (retencyjne drzewo `measure`/`allocate`) —
313 + [`Node`] (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`),
314 + [`Constraints`](min/max) i [`Layout`](`desired_size`/`fit`). Liście pochłaniają nadmiar
315 + (`Expanded`/`Spacer`), `Label` mierzy się realną czcionką.
312 316 - `luna-demo`: `HeaderBar` jako widget (tytuł + `[—][□][✕]` po prawej + treść: `◧`, `+`,
313 317 „Opcje ▾”, `☰`); karty i wiersze ustawień rysowane widgetami (`Card`, `ActionRow`);
314 318 **popover `☰`**, chowany i rozciągany (drag) panel boczny (`NavList`),
@@ -322,6 +326,23 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
322 326 „stylowalna” jak kolory — nie ma już stałych typów w widgetach (`HeaderBar::HEIGHT`,
323 327 `Switch::WIDTH`, `Button::HEIGHT`…).
324 328
329 +## Układ (measure/allocate)
330 +
331 +Prostokąty można nadal liczyć ręcznie (jak karta „Pliki” w demo), ale `libluna-widgets::layout`
332 +daje **retencyjne drzewo**: aplikacja składa [`Node`], silnik robi **pomiar** (`measure`, względem
333 +`Constraints` min/max) i **rozmieszczenie** (`allocate`) — dwuprzebiegowo, jak w klasycznych
334 +toolkitach. Kontenery: `VBox`/`HBox` (odstęp `gap`), `Padding`, `Expanded`/`Spacer` (pochłaniają
335 +nadmiar wzdłuż osi), `Center` (naturalny rozmiar wyśrodkowany). Liściami są `Fixed`, `Label`
336 +(mierzy się czcionką) i `Spacer`; `Node::visit` pozwala obejść drzewo z prostokątami, a
337 +`Layout::fit` zwraca prostokąty liści w kolejności DFS.
338 +
339 +Minimalny przykład (`apps/luna-demo/src/bin/layout.rs`) buduje kartę ustawień **wyłącznie**
340 +z drzewa i renderuje ją headless:
341 +
342 +```sh
343 +cargo run -p luna-demo --bin layout # → docs/images/layout.png
344 +```
345 +
325 346 ## Zrzuty ekranu
326 347
327 348 Jasny i ciemny motyw (Luna) oraz wersja niemiecka z fioletowym akcentem i francuska
@@ -337,6 +358,10 @@ karta 12 i ekran stanu („Brak wyników”) klatka 14:
337 358
338 359 ![Interakcje demo](docs/images/demo-montage.png)
339 360
361 +Karta ustawień złożona z drzewa układu (`measure`/`allocate`):
362 +
363 +![Układ z drzewa](docs/images/layout.png)
364 +
340 365 ## Motywacja i kontekst
341 366
342 367 Środowisko **Luna** (kompozytor **Selene**) ma docelowo zastąpić GTK4/libadwaita w aplikacjach
343 368 new file mode 100644
@@ -0,0 +1,203 @@
1 +//! Przykład: okno (karta ustawień) zbudowane **wyłącznie** z drzewa układu
2 +//! (`measure`/`allocate`) — bez ręcznego liczenia prostokątów.
3 +//!
4 +//! Uruchom: `cargo run -p luna-demo --bin layout`
5 +//! Efekt: `docs/images/layout.png`
6 +
7 +use std::error::Error;
8 +
9 +use libluna_core::style::{weight, FontStyle};
10 +use libluna_core::{Insets, Point, Rect, Size, Theme};
11 +use libluna_render::{Scene, SoftwareRenderer, TextEngine};
12 +use libluna_widgets::icon;
13 +use libluna_widgets::layout::{Layout, LayoutCtx, Node};
14 +use libluna_widgets::PaintCtx;
15 +
16 +const W: u32 = 560;
17 +const H: u32 = 420;
18 +const PAD: f64 = 16.0;
19 +
20 +/// Wiersz ustawień: etykiety po lewej, kontrolka (wyśrodkowana) po prawej.
21 +fn row(label: &str, style: FontStyle, control: Node) -> Node {
22 + Node::padding(
23 + Insets::symmetric(12.0, PAD),
24 + Node::hbox(vec![
25 + Node::label(label, style),
26 + Node::spacer(),
27 + Node::center(control),
28 + ])
29 + .gap(12.0),
30 + )
31 +}
32 +
33 +fn main() -> Result<(), Box<dyn Error>> {
34 + let theme = Theme::dark();
35 + let text = TextEngine::system();
36 + let size = Size::new(W as f64, H as f64);
37 + let ctx = LayoutCtx {
38 + theme: &theme,
39 + text: &text,
40 + };
41 +
42 + // --- Drzewo układu (retencyjne, dwa przebiegi: measure → allocate) ---
43 + let tree = Node::padding(
44 + Insets::splat(PAD),
45 + Node::vbox(vec![
46 + Node::padding(
47 + Insets::symmetric(14.0, PAD),
48 + Node::label("Appearance", theme.typography.heading),
49 + ),
50 + Node::fixed(0.0, 1.0), // separator
51 + row("Theme", theme.typography.body, Node::fixed(160.0, 32.0)),
52 + Node::fixed(0.0, 1.0),
53 + row("Volume", theme.typography.body, Node::fixed(160.0, 32.0)),
54 + Node::fixed(0.0, 1.0),
55 + row("Language", theme.typography.body, Node::fixed(160.0, 32.0)),
56 + Node::spacer(),
57 + Node::padding(
58 + Insets::symmetric(12.0, PAD),
59 + Node::hbox(vec![Node::spacer(), Node::center(Node::fixed(120.0, 34.0))]).gap(12.0),
60 + ),
61 + ]),
62 + );
63 +
64 + let rects = Layout::new(tree).fit(Rect::from_size(size), &ctx);
65 +
66 + let mut scene = Scene::new();
67 + let mut paint = PaintCtx {
68 + scene: &mut scene,
69 + theme: &theme,
70 + text: &text,
71 + };
72 + let colors = theme.colors;
73 +
74 + // Tło okna i karty.
75 + paint
76 + .scene
77 + .fill_rect(Rect::from_size(size), colors.window_bg);
78 + let card = Rect::from_size(size).deflate(Insets::splat(PAD));
79 + paint
80 + .scene
81 + .fill_rounded_rect(card, theme.radius.large, colors.card_bg);
82 + paint
83 + .scene
84 + .stroke_rounded_rect(card, theme.radius.large, 1.0, colors.border);
85 +
86 + // Liście w kolejności DFS (zgodnie ze strukturą drzewa powyżej):
87 + // 0 nagłówek · 1/5/9 separatory · 2/6/10 etykiety · 4/8/12 kontrolki · 15 przycisk.
88 + let heading = theme.typography.heading.size;
89 + let body = theme.typography.body.size;
90 + let caption = theme.typography.caption.size;
91 +
92 + paint.label_left(
93 + rects[0].x(),
94 + rects[0].center().y,
95 + "Appearance",
96 + heading,
97 + weight::BOLD,
98 + colors.text,
99 + );
100 +
101 + for sep in [1, 5, 9] {
102 + let r = rects[sep];
103 + paint.scene.fill_rect(
104 + Rect::from_xywh(r.x(), r.center().y, r.width(), 1.0),
105 + colors.border,
106 + );
107 + }
108 +
109 + // Etykiety wierszy.
110 + for (i, name) in [(2, "Theme"), (6, "Volume"), (10, "Language")] {
111 + let r = rects[i];
112 + paint.label_left(r.x(), r.center().y, name, body, weight::NORMAL, colors.text);
113 + }
114 +
115 + // Kontrolka 4 — pole z chevronem (jak segmentowy przełącznik).
116 + let seg = rects[4];
117 + paint
118 + .scene
119 + .fill_rounded_rect(seg, theme.radius.small, colors.shade);
120 + paint.label_center(
121 + seg.x() + seg.width() * 0.4,
122 + seg.center().y,
123 + "Dark",
124 + body,
125 + weight::MEDIUM,
126 + colors.text,
127 + );
128 + icon::chevron_down(
129 + &mut paint,
130 + colors.dim_text,
131 + Point::new(seg.right() - 14.0, seg.center().y),
132 + 4.0,
133 + 2.0,
134 + );
135 +
136 + // Kontrolka 8 — suwak głośności.
137 + let vol = rects[8];
138 + let track = Rect::from_xywh(vol.x(), vol.center().y - 3.0, vol.width(), 6.0);
139 + paint
140 + .scene
141 + .fill_rounded_rect(track, 3.0, colors.shade.scale_alpha(1.8));
142 + let filled = Rect::from_xywh(track.x(), track.y(), track.width() * 0.65, track.height());
143 + paint.scene.fill_rounded_rect(filled, 3.0, colors.accent);
144 + paint.scene.fill_rounded_rect(
145 + Rect::from_xywh(filled.right() - 9.0, vol.center().y - 9.0, 18.0, 18.0),
146 + 9.0,
147 + colors.accent_fg,
148 + );
149 + paint.label_center(
150 + vol.x() - 28.0,
151 + vol.center().y,
152 + "65%",
153 + caption,
154 + weight::NORMAL,
155 + colors.text,
156 + );
157 +
158 + // Kontrolka 12 — lista rozwijana (pole + chevron).
159 + let dd = rects[12];
160 + paint
161 + .scene
162 + .fill_rounded_rect(dd, theme.radius.small, colors.shade);
163 + paint.label_left(
164 + dd.x() + 12.0,
165 + dd.center().y,
166 + "Polski",
167 + body,
168 + weight::NORMAL,
169 + colors.text,
170 + );
171 + icon::chevron_down(
172 + &mut paint,
173 + colors.dim_text,
174 + Point::new(dd.right() - 16.0, dd.center().y),
175 + 4.0,
176 + 2.0,
177 + );
178 +
179 + // Przycisk 15 — akcja sugerowana.
180 + let btn = rects[15];
181 + paint
182 + .scene
183 + .fill_rounded_rect(btn, theme.radius.small, colors.accent);
184 + paint.label_center(
185 + btn.center().x,
186 + btn.center().y,
187 + "Save changes",
188 + body,
189 + weight::MEDIUM,
190 + colors.accent_fg,
191 + );
192 +
193 + // Render do PNG.
194 + let mut renderer = SoftwareRenderer::new(W, H);
195 + renderer.draw(&scene, 1.0, colors.window_bg);
196 + let png = renderer.encode_png().ok_or("failed to encode PNG")?;
197 + std::fs::write("docs/images/layout.png", png)?;
198 + println!(
199 + "saved docs/images/layout.png ({W}x{H}, {} leaves)",
200 + rects.len()
201 + );
202 + Ok(())
203 +}
0 204 new file mode 100644
@@ -0,0 +1,492 @@
1 +//! `layout` — dwuprzebiegowy układ (`measure` → `allocate`) w modelu retencyjnym.
2 +//!
3 +//! Zamiast ręcznie liczyć prostokąty (jak w `luna-demo`), aplikacja buduje **drzewo**
4 +//! [`Node`] (stosy, wcięcia, liście), a silnik:
5 +//!
6 +//! 1. **mierzy** je ([`Node::measure`]) względem [`Constraints`] (min/max) — ile miejsca
7 +//! chce poddrzewo (tekst mierzy się realną czcionką przez [`LayoutCtx::text`]),
8 +//! 2. **rozmieszcza** ([`Node::allocate`]) w zadanym prostokącie — dzieci dzielą przestrzeń,
9 +//! [`Node::Expanded`]/[`Node::Spacer`] pochłaniają nadmiar wzdłuż osi stosu.
10 +//!
11 +//! [`Node::allocate`] zapisuje prostokąty **liści** w kolejności DFS; aplikacja mapuje je na
12 +//! konkretne widgety (te same, co dotąd: `paint` przyjmuje prostokąt).
13 +//!
14 +//! ```
15 +//! use libluna_core::{Rect, Size, Insets, Theme};
16 +//! use libluna_render::TextEngine;
17 +//! use libluna_widgets::layout::{Layout, LayoutCtx, Node};
18 +//!
19 +//! let theme = Theme::light();
20 +//! let text = TextEngine::system();
21 +//! let ctx = LayoutCtx { theme: &theme, text: &text };
22 +//! let tree = Node::padding(
23 +//! Insets::splat(12.0),
24 +//! Node::vbox(vec![
25 +//! Node::fixed(0.0, 40.0),
26 +//! Node::spacer(),
27 +//! Node::fixed(0.0, 32.0),
28 +//! ])
29 +//! .gap(8.0),
30 +//! );
31 +//! let rects = Layout::new(tree).fit(Rect::from_size(Size::new(320.0, 240.0)), &ctx);
32 +//! assert_eq!(rects.len(), 3);
33 +//! ```
34 +
35 +use libluna_core::style::FontStyle;
36 +use libluna_core::{Insets, Rect, Size, Theme};
37 +use libluna_render::TextEngine;
38 +
39 +/// Ograniczenia pomiaru: dopuszczalny zakres rozmiaru (min/max).
40 +#[derive(Debug, Clone, Copy, PartialEq)]
41 +pub struct Constraints {
42 + pub min: Size,
43 + pub max: Size,
44 +}
45 +
46 +impl Constraints {
47 + /// Luźne: minimum zerowe, maksimum podane (typowe dla dziecka stosu).
48 + pub const fn loose(max: Size) -> Self {
49 + Self {
50 + min: Size::ZERO,
51 + max,
52 + }
53 + }
54 +
55 + /// Ciasne: wymiar wymuszony (`min == max`).
56 + pub const fn tight(size: Size) -> Self {
57 + Self {
58 + min: size,
59 + max: size,
60 + }
61 + }
62 +
63 + /// Bez ograniczeń (maksimum = nieskończoność).
64 + pub fn unconstrained() -> Self {
65 + Self {
66 + min: Size::ZERO,
67 + max: Size::new(f64::INFINITY, f64::INFINITY),
68 + }
69 + }
70 +
71 + /// Zmniejsza granice o marginesy (np. dla dziecka [`Node::Padding`]).
72 + pub fn deflate(&self, insets: Insets) -> Constraints {
73 + Constraints {
74 + min: Size::new(
75 + (self.min.width - insets.horizontal()).max(0.0),
76 + (self.min.height - insets.vertical()).max(0.0),
77 + ),
78 + max: Size::new(
79 + (self.max.width - insets.horizontal()).max(0.0),
80 + (self.max.height - insets.vertical()).max(0.0),
81 + ),
82 + }
83 + }
84 +
85 + /// Przycina rozmiar do dopuszczalnego zakresu.
86 + pub fn constrain(&self, size: Size) -> Size {
87 + Size::new(
88 + size.width.clamp(self.min.width, self.max.width),
89 + size.height.clamp(self.min.height, self.max.height),
90 + )
91 + }
92 +}
93 +
94 +/// Kontekst układu: motyw (odstępy) i silnik tekstu (pomiar napisów).
95 +pub struct LayoutCtx<'a> {
96 + pub theme: &'a Theme,
97 + pub text: &'a TextEngine,
98 +}
99 +
100 +/// Stos: lista dzieci wzdłuż osi + odstęp między nimi.
101 +#[derive(Debug, Clone)]
102 +pub struct Stack {
103 + pub children: Vec<Node>,
104 + pub gap: f64,
105 +}
106 +
107 +/// Węzeł drzewa układu.
108 +#[derive(Debug, Clone)]
109 +pub enum Node {
110 + /// Liść o zadanym rozmiarze (wskazówka pomiaru; w stosie oś poprzeczna jest rozciągana).
111 + Fixed(Size),
112 + /// Liść tekstowy (mierzy się realną czcionką).
113 + Label { text: String, style: FontStyle },
114 + /// Rozciągliwa pustka (pochłania nadmiar wzdłuż osi stosu).
115 + Spacer,
116 + /// Stos pionowy (dzieci jedna pod drugą).
117 + VBox(Stack),
118 + /// Stos poziomy (dzieci jedna za drugą).
119 + HBox(Stack),
120 + /// Wcięcie wokół dziecka.
121 + Padding { insets: Insets, child: Box<Node> },
122 + /// Dziecko zajmujące nadmiar wzdłuż osi stosu.
123 + Expanded(Box<Node>),
124 + /// Dziecko o naturalnym rozmiarze, wyśrodkowane w przydzielonym prostokącie.
125 + Center(Box<Node>),
126 +}
127 +
128 +impl Node {
129 + pub fn fixed(width: f64, height: f64) -> Self {
130 + Node::Fixed(Size::new(width, height))
131 + }
132 +
133 + pub fn label(text: impl Into<String>, style: FontStyle) -> Self {
134 + Node::Label {
135 + text: text.into(),
136 + style,
137 + }
138 + }
139 +
140 + pub fn spacer() -> Self {
141 + Node::Spacer
142 + }
143 +
144 + pub fn vbox(children: Vec<Node>) -> Self {
145 + Node::VBox(Stack { children, gap: 0.0 })
146 + }
147 +
148 + pub fn hbox(children: Vec<Node>) -> Self {
149 + Node::HBox(Stack { children, gap: 0.0 })
150 + }
151 +
152 + pub fn padding(insets: Insets, child: Node) -> Self {
153 + Node::Padding {
154 + insets,
155 + child: Box::new(child),
156 + }
157 + }
158 +
159 + pub fn expanded(child: Node) -> Self {
160 + Node::Expanded(Box::new(child))
161 + }
162 +
163 + pub fn center(child: Node) -> Self {
164 + Node::Center(Box::new(child))
165 + }
166 +
167 + /// Ustawia odstęp stosu (dla [`Node::VBox`]/[`Node::HBox`]; inaczej bez efektu).
168 + pub fn gap(mut self, gap: f64) -> Self {
169 + match &mut self {
170 + Node::VBox(stack) | Node::HBox(stack) => stack.gap = gap,
171 + _ => {}
172 + }
173 + self
174 + }
175 +
176 + /// Opakowuje węzeł we wcięcie.
177 + pub fn padded(self, insets: Insets) -> Self {
178 + Node::padding(insets, self)
179 + }
180 +
181 + fn is_flexible(&self) -> bool {
182 + matches!(self, Node::Spacer | Node::Expanded(_))
183 + }
184 +
185 + /// Naturalny rozmiar poddrzewa dla zadanych ograniczeń.
186 + pub fn measure(&self, c: Constraints, ctx: &LayoutCtx) -> Size {
187 + let size = match self {
188 + Node::Fixed(s) => *s,
189 + Node::Label { text, style } => {
190 + let w = ctx.text.measure(text, style.size, style.weight).width;
191 + Size::new(w, style.line_height)
192 + }
193 + Node::Spacer => Size::ZERO,
194 + Node::VBox(stack) => stack_size(stack, c, ctx, true),
195 + Node::HBox(stack) => stack_size(stack, c, ctx, false),
196 + Node::Padding { insets, child } => {
197 + let inner = child.measure(c.deflate(*insets), ctx);
198 + Size::new(
199 + inner.width + insets.horizontal(),
200 + inner.height + insets.vertical(),
201 + )
202 + }
203 + Node::Expanded(child) | Node::Center(child) => child.measure(c, ctx),
204 + };
205 + c.constrain(size)
206 + }
207 +
208 + /// Odwiedza liście poddrzewa z ich prostokątami (kolejność DFS).
209 + ///
210 + /// Wygodne do rysowania: dla każdego liścia wiadomo, *jaki* to węzeł i *gdzie* leży.
211 + pub fn visit(&self, rect: Rect, ctx: &LayoutCtx, f: &mut impl FnMut(&Node, Rect)) {
212 + match self {
213 + Node::Fixed(_) | Node::Label { .. } | Node::Spacer => f(self, rect),
214 + Node::Padding { insets, child } => child.visit(rect.deflate(*insets), ctx, f),
215 + Node::Expanded(child) => child.visit(rect, ctx, f),
216 + Node::Center(child) => {
217 + let natural = child.measure(Constraints::loose(rect.size), ctx);
218 + let w = natural.width.min(rect.width());
219 + let h = natural.height.min(rect.height());
220 + let child_rect = Rect::from_xywh(
221 + rect.x() + (rect.width() - w) / 2.0,
222 + rect.y() + (rect.height() - h) / 2.0,
223 + w,
224 + h,
225 + );
226 + child.visit(child_rect, ctx, f);
227 + }
228 + Node::VBox(stack) => visit_stack(stack, rect, ctx, f, true),
229 + Node::HBox(stack) => visit_stack(stack, rect, ctx, f, false),
230 + }
231 + }
232 +
233 + /// Rozmieszcza poddrzewo w `rect`, zapisując prostokąty liści (DFS) do `out`.
234 + pub fn allocate(&self, rect: Rect, ctx: &LayoutCtx, out: &mut Vec<Rect>) {
235 + self.visit(rect, ctx, &mut |_node, r| out.push(r));
236 + }
237 +}
238 +
239 +fn stack_size(stack: &Stack, c: Constraints, ctx: &LayoutCtx, vertical: bool) -> Size {
240 + let child_c = Constraints::loose(c.max);
241 + let mut cross: f64 = 0.0;
242 + let mut main: f64 = 0.0;
243 + for (i, ch) in stack.children.iter().enumerate() {
244 + let s = ch.measure(child_c, ctx);
245 + let (m, cr) = if vertical {
246 + (s.height, s.width)
247 + } else {
248 + (s.width, s.height)
249 + };
250 + main += m;
251 + cross = cross.max(cr);
252 + if i + 1 < stack.children.len() {
253 + main += stack.gap;
254 + }
255 + }
256 + if vertical {
257 + Size::new(cross, main)
258 + } else {
259 + Size::new(main, cross)
260 + }
261 +}
262 +
263 +fn visit_stack(
264 + stack: &Stack,
265 + rect: Rect,
266 + ctx: &LayoutCtx,
267 + f: &mut impl FnMut(&Node, Rect),
268 + vertical: bool,
269 +) {
270 + let n = stack.children.len();
271 + if n == 0 {
272 + return;
273 + }
274 + let meas_c = Constraints::loose(rect.size);
275 + let naturals: Vec<Size> = stack
276 + .children
277 + .iter()
278 + .map(|c| c.measure(meas_c, ctx))
279 + .collect();
280 +
281 + let (avail, cross) = if vertical {
282 + (rect.height(), rect.width())
283 + } else {
284 + (rect.width(), rect.height())
285 + };
286 +
287 + let gap_total = stack.gap * (n as f64 - 1.0);
288 + let used: f64 = naturals
289 + .iter()
290 + .map(|s| if vertical { s.height } else { s.width })
291 + .sum::<f64>()
292 + + gap_total;
293 + let extra = (avail - used).max(0.0);
294 + let flex_count = stack.children.iter().filter(|c| c.is_flexible()).count() as f64;
295 + let bonus = if flex_count > 0.0 {
296 + extra / flex_count
297 + } else {
298 + 0.0
299 + };
300 +
301 + let mut cursor = if vertical { rect.y() } else { rect.x() };
302 + for (i, ch) in stack.children.iter().enumerate() {
303 + let natural_main = if vertical {
304 + naturals[i].height
305 + } else {
306 + naturals[i].width
307 + };
308 + let main = natural_main + if ch.is_flexible() { bonus } else { 0.0 };
309 + let child_rect = if vertical {
310 + Rect::from_xywh(rect.x(), cursor, cross, main)
311 + } else {
312 + Rect::from_xywh(cursor, rect.y(), main, cross)
313 + };
314 + ch.visit(child_rect, ctx, f);
315 + cursor += main + stack.gap;
316 + }
317 +}
318 +
319 +/// Gotowy układ (korzeń drzewa) — wygodne wejście dla aplikacji.
320 +#[derive(Debug, Clone)]
321 +pub struct Layout {
322 + root: Node,
323 +}
324 +
325 +impl Layout {
326 + pub fn new(root: Node) -> Self {
327 + Self { root }
328 + }
329 +
330 + pub fn root(&self) -> &Node {
331 + &self.root
332 + }
333 +
334 + /// Pożądany rozmiar drzewa dla danych ograniczeń (przebieg `measure`).
335 + pub fn desired_size(&self, c: Constraints, ctx: &LayoutCtx) -> Size {
336 + self.root.measure(c, ctx)
337 + }
338 +
339 + /// Dwuprzebiegowe złożenie: `measure`, a następnie `allocate` w `rect`.
340 + /// Zwraca prostokąty liści w kolejności DFS.
341 + pub fn fit(&self, rect: Rect, ctx: &LayoutCtx) -> Vec<Rect> {
342 + let _desired = self.root.measure(Constraints::loose(rect.size), ctx);
343 + let mut out = Vec::new();
344 + self.root.allocate(rect, ctx, &mut out);
345 + out
346 + }
347 +}
348 +
349 +#[cfg(test)]
350 +mod tests {
351 + use super::*;
352 +
353 + fn theme() -> Theme {
354 + Theme::light()
355 + }
356 +
357 + #[test]
358 + fn constraints_clamp() {
359 + let c = Constraints::loose(Size::new(100.0, 50.0));
360 + assert_eq!(c.constrain(Size::new(200.0, 10.0)), Size::new(100.0, 10.0));
361 + let t = Constraints::tight(Size::new(40.0, 40.0));
362 + assert_eq!(t.constrain(Size::new(0.0, 0.0)), Size::new(40.0, 40.0));
363 + }
364 +
365 + #[test]
366 + fn vbox_stacks_with_gap_and_stretches_cross_axis() {
367 + let theme = theme();
368 + let text = TextEngine::system();
369 + let ctx = LayoutCtx {
370 + theme: &theme,
371 + text: &text,
372 + };
373 + let node = Node::vbox(vec![Node::fixed(10.0, 20.0), Node::fixed(10.0, 30.0)]).gap(5.0);
374 + let rect = Rect::from_xywh(0.0, 0.0, 100.0, 200.0);
375 + let mut out = Vec::new();
376 + node.allocate(rect, &ctx, &mut out);
377 + assert_eq!(out.len(), 2);
378 + assert_eq!(out[0], Rect::from_xywh(0.0, 0.0, 100.0, 20.0));
379 + assert_eq!(out[1], Rect::from_xywh(0.0, 25.0, 100.0, 30.0));
380 + }
381 +
382 + #[test]
383 + fn spacer_absorbs_extra_in_hbox() {
384 + let theme = theme();
385 + let text = TextEngine::system();
386 + let ctx = LayoutCtx {
387 + theme: &theme,
388 + text: &text,
389 + };
390 + let node = Node::hbox(vec![
391 + Node::fixed(10.0, 10.0),
392 + Node::spacer(),
393 + Node::fixed(10.0, 10.0),
394 + ]);
395 + let rect = Rect::from_xywh(0.0, 0.0, 100.0, 10.0);
396 + let mut out = Vec::new();
397 + node.allocate(rect, &ctx, &mut out);
398 + assert_eq!(out[0], Rect::from_xywh(0.0, 0.0, 10.0, 10.0));
399 + assert_eq!(out[1], Rect::from_xywh(10.0, 0.0, 80.0, 10.0));
400 + assert_eq!(out[2], Rect::from_xywh(90.0, 0.0, 10.0, 10.0));
401 + }
402 +
403 + #[test]
404 + fn expanded_grows_along_main_axis() {
405 + let theme = theme();
406 + let text = TextEngine::system();
407 + let ctx = LayoutCtx {
408 + theme: &theme,
409 + text: &text,
410 + };
411 + let node = Node::vbox(vec![
412 + Node::fixed(0.0, 10.0),
413 + Node::expanded(Node::fixed(0.0, 10.0)),
414 + ]);
415 + let rect = Rect::from_xywh(0.0, 0.0, 50.0, 100.0);
416 + let mut out = Vec::new();
417 + node.allocate(rect, &ctx, &mut out);
418 + assert_eq!(out[0], Rect::from_xywh(0.0, 0.0, 50.0, 10.0));
419 + assert_eq!(out[1], Rect::from_xywh(0.0, 10.0, 50.0, 90.0));
420 + }
421 +
422 + #[test]
423 + fn padding_insets_child() {
424 + let theme = theme();
425 + let text = TextEngine::system();
426 + let ctx = LayoutCtx {
427 + theme: &theme,
428 + text: &text,
429 + };
430 + let node = Node::padding(
431 + Insets::splat(5.0),
432 + Node::vbox(vec![Node::fixed(0.0, 10.0), Node::fixed(0.0, 10.0)]),
433 + );
434 + let rect = Rect::from_xywh(0.0, 0.0, 100.0, 100.0);
435 + let mut out = Vec::new();
436 + node.allocate(rect, &ctx, &mut out);
437 + assert_eq!(out[0], Rect::from_xywh(5.0, 5.0, 90.0, 10.0));
438 + assert_eq!(out[1], Rect::from_xywh(5.0, 15.0, 90.0, 10.0));
439 + }
440 +
441 + #[test]
442 + fn center_keeps_natural_size() {
443 + let theme = theme();
444 + let text = TextEngine::system();
445 + let ctx = LayoutCtx {
446 + theme: &theme,
447 + text: &text,
448 + };
449 + let node = Node::center(Node::fixed(40.0, 24.0));
450 + let rect = Rect::from_xywh(0.0, 0.0, 100.0, 60.0);
451 + let mut out = Vec::new();
452 + node.allocate(rect, &ctx, &mut out);
453 + assert_eq!(out[0], Rect::from_xywh(30.0, 18.0, 40.0, 24.0));
454 + }
455 +
456 + #[test]
457 + fn label_measures_text() {
458 + let theme = theme();
459 + let text = TextEngine::system();
460 + let ctx = LayoutCtx {
461 + theme: &theme,
462 + text: &text,
463 + };
464 + let node = Node::label("Przykładowy napis", theme.typography.body);
465 + let size = node.measure(Constraints::unconstrained(), &ctx);
466 + assert!(size.width > 0.0);
467 + assert_eq!(size.height, theme.typography.body.line_height);
468 + }
469 +
470 + #[test]
471 + fn fit_collects_leaf_rects() {
472 + let theme = theme();
473 + let text = TextEngine::system();
474 + let ctx = LayoutCtx {
475 + theme: &theme,
476 + text: &text,
477 + };
478 + let tree = Node::padding(
479 + Insets::splat(12.0),
480 + Node::vbox(vec![
481 + Node::fixed(0.0, 40.0),
482 + Node::spacer(),
483 + Node::fixed(0.0, 32.0),
484 + ])
485 + .gap(8.0),
486 + );
487 + let rects = Layout::new(tree).fit(Rect::from_size(Size::new(320.0, 240.0)), &ctx);
488 + assert_eq!(rects.len(), 3);
489 + // Ostatni liść dotyka dolnej krawędzi wewnątrz wcięcia.
490 + assert!((rects[2].bottom() - (240.0 - 12.0)).abs() < 1e-9);
491 + }
492 +}
@@ -32,6 +32,8 @@
32 32 //! - [`Toast`] — krótkie powiadomienie (nakładka) z opcjonalną akcją.
33 33 //! - [`StatusPage`] — ekran stanu (pusty/powitalny): ikona, tytuł, opis, akcja.
34 34 //!
35 +//! Układ (retencyjne drzewo, `measure`/`allocate`): [`layout`] ([`Node`], [`Layout`], [`Constraints`]).
36 +//!
35 37 //! ```no_run
36 38 //! use libluna_widgets::{PaintCtx, ViewSwitcher};
37 39 //! # fn demo(ctx: &mut PaintCtx, rect: libluna_core::Rect) {
@@ -51,6 +53,7 @@ pub mod entry;
51 53 pub mod header_bar;
52 54 pub mod icon;
53 55 pub mod item_view;
56 +pub mod layout;
54 57 pub mod menu;
55 58 pub mod nav_list;
56 59 pub mod pager;
@@ -77,6 +80,7 @@ pub use dropdown::DropDown;
77 80 pub use entry::{Entry, EntryIcon, TextInput};
78 81 pub use header_bar::{ControlsLayout, ControlsSide, HeaderBar, TitleAlign, WindowControl};
79 82 pub use item_view::{Item, ItemKind, ItemView, ViewMode};
83 +pub use layout::{Constraints, Layout, LayoutCtx, Node, Stack};
80 84 pub use menu::{Menu, MenuEntry, MenuRow};
81 85 pub use nav_list::{NavEntry, NavList, NavRow};
82 86 pub use pager::Pager;
83 87 new file mode 100644
84 88 Binary files /dev/null and b/docs/images/layout.png differ
@@ -49,11 +49,15 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
49 49 - Motywy: wariant (light/dark/auto) + akcent, z trwałością. **Component metrics**: rozmiary
50 50 i paddingi kontrolek wyniesione z widgetów do tokenów (`Theme::metrics`), więc styl steruje
51 51 też geometrią (README, „Własny wygląd”).
52 +- Układ: **retencyjne drzewo `measure`/`allocate`** (`libluna-widgets::layout`) — `Node`
53 + (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`), `Constraints`,
54 + `Layout::fit`; przykład karty złożonej z drzewa (`bin/layout.rs` → `docs/images/layout.png`).
52 55 - Dalej: **style instalowane jako pliki** (jak w GNOME/KDE) z **oknem podglądu** przy wyborze,
53 - a potem scroll/overflow w `TabBar`, retencyjne drzewo `measure`/`allocate`.
56 + a potem scroll/overflow w `TabBar`, migracja `luna-demo` na drzewo układu.
54 57
55 58 ## M2 — Układ + widgety bazowe
56 -- Dwuprzebiegowy silnik measure/allocate.
59 +- Dwuprzebiegowy silnik measure/allocate — ✅ **częściowo** (silnik + kontenery `VBox`/`HBox`,
60 + `Padding`, `Expanded`, `Center`, `Spacer`, `Label`; do zrobienia: migracja demo i zaokrąglenia API).
57 61 - `Box` (h/v), `Label`, `Icon`, `Button`, `Separator`, `Spacer`.
58 62 - Stany: hover/pressed/disabled/focus + pierścień fokusu klawiatury.
59 63