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

339
360
361
+Karta ustawień złożona z drzewa układu (`measure`/`allocate`):
362
+
363
+
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