Skip to main content

cssbox_core/
flex.rs

1//! Flexbox layout algorithm.
2//!
3//! Implements CSS Flexible Box Layout Module Level 1.
4//! Reference: https://www.w3.org/TR/css-flexbox-1/#layout-algorithm
5
6use crate::box_model::BoxModel;
7use crate::fragment::{Fragment, FragmentKind};
8use crate::geometry::{Point, Size};
9use crate::layout::{self, LayoutContext};
10use crate::style::*;
11use crate::tree::NodeId;
12use crate::values::LengthPercentageAuto;
13
14/// Layout a flex container and its items.
15pub fn layout_flex(
16    ctx: &LayoutContext,
17    node: NodeId,
18    containing_block_width: f32,
19    containing_block_height: f32,
20) -> Fragment {
21    let style = ctx.tree.style(node);
22    let mut fragment = Fragment::new(node, FragmentKind::Box);
23
24    // Resolve container box model
25    let border = BoxModel::resolve_border(style);
26    let padding = BoxModel::resolve_padding(style, containing_block_width);
27    let margin = BoxModel::resolve_margin(style, containing_block_width);
28
29    fragment.border = border;
30    fragment.padding = padding;
31    fragment.margin = margin;
32
33    // Determine container dimensions
34    let content_box_width = match style.width.resolve(containing_block_width) {
35        Some(mut w) => {
36            if style.box_sizing == BoxSizing::BorderBox {
37                w = (w - border.horizontal() - padding.horizontal()).max(0.0);
38            }
39            let min_w = style.min_width.resolve(containing_block_width);
40            let max_w = style
41                .max_width
42                .resolve(containing_block_width)
43                .unwrap_or(f32::INFINITY);
44            w.max(min_w).min(max_w)
45        }
46        None => (containing_block_width
47            - border.horizontal()
48            - padding.horizontal()
49            - margin.horizontal())
50        .max(0.0),
51    };
52
53    let is_row = style.flex_direction.is_row();
54    let is_reverse = style.flex_direction.is_reverse();
55    let is_wrap = style.flex_wrap != FlexWrap::Nowrap;
56    let _is_wrap_reverse = style.flex_wrap == FlexWrap::WrapReverse;
57
58    let main_size = if is_row {
59        content_box_width
60    } else {
61        style
62            .height
63            .resolve(containing_block_height)
64            .unwrap_or(containing_block_height)
65    };
66    let cross_size_available = if is_row {
67        style.height.resolve(containing_block_height)
68    } else {
69        Some(content_box_width)
70    };
71
72    // §9.2: Collect flex items
73    let children = ctx.tree.children(node);
74    let mut items: Vec<FlexItem> = Vec::new();
75
76    for &child_id in children {
77        let child_style = ctx.tree.style(child_id);
78        if child_style.display.is_none() {
79            continue;
80        }
81        if child_style.position.is_absolutely_positioned() {
82            // Absolutely positioned children are not flex items
83            let child_frag =
84                layout::layout_node(ctx, child_id, content_box_width, containing_block_height);
85            fragment.children.push(child_frag);
86            continue;
87        }
88
89        let item = collect_flex_item(
90            ctx,
91            child_id,
92            is_row,
93            content_box_width,
94            containing_block_height,
95        );
96        items.push(item);
97    }
98
99    // §9.3: Determine the flex base size and hypothetical main size
100    // (already done in collect_flex_item)
101
102    // §9.4: Determine the main size of the flex container (already done above)
103
104    // §9.5: Collect flex items into flex lines
105    let lines = collect_flex_lines(&items, main_size, is_wrap);
106
107    // §9.7: Resolve flexible lengths + §9.4/9.5: cross sizes
108    let mut resolved_lines = Vec::new();
109
110    for line in &lines {
111        let resolved = resolve_flexible_lengths(line, &items, main_size);
112        resolved_lines.push(resolved);
113    }
114
115    // §9.4 cross sizes: determine cross size of each item
116    let mut line_cross_sizes: Vec<f32> = Vec::new();
117    for (line_idx, line) in lines.iter().enumerate() {
118        let mut max_cross: f32 = 0.0;
119        for &item_idx in &line.item_indices {
120            let item = &items[item_idx];
121            let resolved_main = resolved_lines[line_idx].sizes[&item_idx];
122
123            // Layout item with resolved main size to get cross size
124            let cross = compute_item_cross_size(
125                ctx,
126                item,
127                resolved_main,
128                is_row,
129                content_box_width,
130                containing_block_height,
131            );
132            max_cross = max_cross.max(cross);
133        }
134        line_cross_sizes.push(max_cross);
135    }
136
137    // Determine final cross size of container
138    let total_cross: f32 = line_cross_sizes.iter().sum();
139    let container_cross = cross_size_available.unwrap_or(total_cross);
140
141    // §9.9: Align items and distribute space
142
143    // Position items
144    let mut cross_offset: f32 = 0.0;
145
146    // align-content distribution
147    let extra_cross = (container_cross - total_cross).max(0.0);
148    let (cross_start, cross_between) =
149        distribute_alignment(style.align_content, extra_cross, resolved_lines.len());
150    cross_offset += cross_start;
151
152    for (line_idx, line) in lines.iter().enumerate() {
153        let line_cross = line_cross_sizes[line_idx];
154        let resolved = &resolved_lines[line_idx];
155
156        // Main axis alignment (justify-content)
157        let total_main: f32 = line
158            .item_indices
159            .iter()
160            .map(|&i| resolved.sizes[&i] + items[i].main_margin())
161            .sum();
162        let extra_main = (main_size - total_main).max(0.0);
163        let (main_start, main_between) =
164            distribute_justify(style.justify_content, extra_main, line.item_indices.len());
165
166        let mut main_offset = main_start;
167
168        let indices: Vec<usize> = if is_reverse {
169            line.item_indices.iter().rev().copied().collect()
170        } else {
171            line.item_indices.clone()
172        };
173
174        for &item_idx in &indices {
175            let item = &items[item_idx];
176            let item_main = resolved.sizes[&item_idx];
177
178            // Layout the item
179            let (item_width, item_height) = if is_row {
180                (item_main, line_cross)
181            } else {
182                (line_cross, item_main)
183            };
184
185            let mut child_frag = layout_flex_item(
186                ctx,
187                item.node,
188                item_width,
189                item_height,
190                is_row,
191                content_box_width,
192                containing_block_height,
193            );
194
195            // Cross-axis alignment (align-items / align-self)
196            let align = effective_align(style.align_items, ctx.tree.style(item.node).align_self);
197            let item_cross = if is_row {
198                child_frag.border_box().height
199            } else {
200                child_frag.border_box().width
201            };
202            let cross_align_offset = match align {
203                AlignItems::FlexStart | AlignItems::Start => 0.0,
204                AlignItems::FlexEnd | AlignItems::End => line_cross - item_cross,
205                AlignItems::Center => (line_cross - item_cross) / 2.0,
206                AlignItems::Stretch => 0.0,
207                AlignItems::Baseline => 0.0, // simplified
208            };
209
210            // Set position
211            if is_row {
212                child_frag.position = Point::new(
213                    main_offset + child_frag.margin.left,
214                    cross_offset + cross_align_offset + child_frag.margin.top,
215                );
216            } else {
217                child_frag.position = Point::new(
218                    cross_offset + cross_align_offset + child_frag.margin.left,
219                    main_offset + child_frag.margin.top,
220                );
221            }
222
223            main_offset += item_main + item.main_margin() + main_between;
224            fragment.children.push(child_frag);
225        }
226
227        cross_offset += line_cross + cross_between;
228    }
229
230    // Set container size
231    let content_height = if is_row { container_cross } else { main_size };
232    let final_height = style
233        .height
234        .resolve(containing_block_height)
235        .unwrap_or(content_height);
236
237    let min_h = style.min_height.resolve(containing_block_height);
238    let max_h = style
239        .max_height
240        .resolve(containing_block_height)
241        .unwrap_or(f32::INFINITY);
242
243    fragment.size = Size::new(content_box_width, final_height.max(min_h).min(max_h));
244
245    fragment
246}
247
248/// A collected flex item before flexible length resolution.
249struct FlexItem {
250    node: NodeId,
251    flex_base_size: f32,
252    hypothetical_main_size: f32,
253    flex_grow: f32,
254    flex_shrink: f32,
255    min_main: f32,
256    max_main: f32,
257    main_margin_start: f32,
258    main_margin_end: f32,
259}
260
261impl FlexItem {
262    fn main_margin(&self) -> f32 {
263        self.main_margin_start + self.main_margin_end
264    }
265}
266
267struct FlexLine {
268    item_indices: Vec<usize>,
269}
270
271struct ResolvedLine {
272    sizes: std::collections::HashMap<usize, f32>,
273}
274
275fn collect_flex_item(
276    ctx: &LayoutContext,
277    node: NodeId,
278    is_row: bool,
279    containing_width: f32,
280    containing_height: f32,
281) -> FlexItem {
282    let style = ctx.tree.style(node);
283    let box_model = BoxModel::resolve(style, containing_width);
284
285    // §9.2.3: Determine the flex base size
286    let flex_base_size = match &style.flex_basis {
287        LengthPercentageAuto::Length(v) => *v,
288        LengthPercentageAuto::Percentage(pct) => {
289            let reference = if is_row {
290                containing_width
291            } else {
292                containing_height
293            };
294            reference * pct
295        }
296        LengthPercentageAuto::Auto => {
297            // Use the main size property if definite
298            let main_size = if is_row { &style.width } else { &style.height };
299            let reference = if is_row {
300                containing_width
301            } else {
302                containing_height
303            };
304            match main_size.resolve(reference) {
305                Some(v) => v,
306                None => {
307                    // Content-based: layout to determine
308                    let child_frag =
309                        layout::layout_node(ctx, node, containing_width, containing_height);
310                    if is_row {
311                        child_frag.size.width
312                    } else {
313                        child_frag.size.height
314                    }
315                }
316            }
317        }
318    };
319
320    let (min_main, max_main) = if is_row {
321        (
322            style.min_width.resolve(containing_width),
323            style
324                .max_width
325                .resolve(containing_width)
326                .unwrap_or(f32::INFINITY),
327        )
328    } else {
329        (
330            style.min_height.resolve(containing_height),
331            style
332                .max_height
333                .resolve(containing_height)
334                .unwrap_or(f32::INFINITY),
335        )
336    };
337
338    let hypothetical = flex_base_size.max(min_main).min(max_main);
339
340    let (main_margin_start, main_margin_end) = if is_row {
341        (box_model.margin.left, box_model.margin.right)
342    } else {
343        (box_model.margin.top, box_model.margin.bottom)
344    };
345
346    FlexItem {
347        node,
348        flex_base_size,
349        hypothetical_main_size: hypothetical,
350        flex_grow: style.flex_grow,
351        flex_shrink: style.flex_shrink,
352        min_main,
353        max_main,
354        main_margin_start,
355        main_margin_end,
356    }
357}
358
359/// §9.5: Collect items into lines.
360fn collect_flex_lines(items: &[FlexItem], main_size: f32, wrap: bool) -> Vec<FlexLine> {
361    if items.is_empty() {
362        return vec![FlexLine {
363            item_indices: Vec::new(),
364        }];
365    }
366
367    if !wrap {
368        return vec![FlexLine {
369            item_indices: (0..items.len()).collect(),
370        }];
371    }
372
373    let mut lines = Vec::new();
374    let mut current_line = Vec::new();
375    let mut line_main = 0.0f32;
376
377    for (i, item) in items.iter().enumerate() {
378        let item_outer = item.hypothetical_main_size + item.main_margin();
379        if !current_line.is_empty() && line_main + item_outer > main_size {
380            lines.push(FlexLine {
381                item_indices: std::mem::take(&mut current_line),
382            });
383            line_main = 0.0;
384        }
385        current_line.push(i);
386        line_main += item_outer;
387    }
388
389    if !current_line.is_empty() {
390        lines.push(FlexLine {
391            item_indices: current_line,
392        });
393    }
394
395    lines
396}
397
398/// §9.7: Resolve flexible lengths.
399fn resolve_flexible_lengths(line: &FlexLine, items: &[FlexItem], main_size: f32) -> ResolvedLine {
400    let mut sizes = std::collections::HashMap::new();
401
402    // Calculate used space and free space
403    let total_hypothetical: f32 = line
404        .item_indices
405        .iter()
406        .map(|&i| items[i].hypothetical_main_size + items[i].main_margin())
407        .sum();
408    let free_space = main_size - total_hypothetical;
409
410    let growing = free_space > 0.0;
411
412    // §9.7 step 1: Freeze items that won't flex
413    let mut frozen: Vec<bool> = vec![false; items.len()];
414    let mut target_sizes: Vec<f32> = items.iter().map(|i| i.hypothetical_main_size).collect();
415
416    for &idx in &line.item_indices {
417        let item = &items[idx];
418        if (growing && item.flex_grow == 0.0) || (!growing && item.flex_shrink == 0.0) {
419            frozen[idx] = true;
420        }
421        // Freeze if hypothetical size violates constraints
422        if growing && item.flex_base_size > item.hypothetical_main_size {
423            frozen[idx] = true;
424        }
425        if !growing && item.flex_base_size < item.hypothetical_main_size {
426            frozen[idx] = true;
427        }
428    }
429
430    // §9.7 iterative resolution
431    for _ in 0..10 {
432        // Calculate remaining free space
433        let frozen_space: f32 = line
434            .item_indices
435            .iter()
436            .filter(|&&i| frozen[i])
437            .map(|&i| target_sizes[i] + items[i].main_margin())
438            .sum();
439
440        let unfrozen_base: f32 = line
441            .item_indices
442            .iter()
443            .filter(|&&i| !frozen[i])
444            .map(|&i| items[i].flex_base_size + items[i].main_margin())
445            .sum();
446
447        let remaining = main_size - frozen_space - unfrozen_base;
448
449        // Distribute space
450        if growing {
451            let total_grow: f32 = line
452                .item_indices
453                .iter()
454                .filter(|&&i| !frozen[i])
455                .map(|&i| items[i].flex_grow)
456                .sum();
457
458            if total_grow > 0.0 {
459                for &idx in &line.item_indices {
460                    if !frozen[idx] {
461                        let ratio = items[idx].flex_grow / total_grow;
462                        target_sizes[idx] = items[idx].flex_base_size + remaining * ratio;
463                    }
464                }
465            }
466        } else {
467            let total_shrink_scaled: f32 = line
468                .item_indices
469                .iter()
470                .filter(|&&i| !frozen[i])
471                .map(|&i| items[i].flex_shrink * items[i].flex_base_size)
472                .sum();
473
474            if total_shrink_scaled > 0.0 {
475                for &idx in &line.item_indices {
476                    if !frozen[idx] {
477                        let ratio = (items[idx].flex_shrink * items[idx].flex_base_size)
478                            / total_shrink_scaled;
479                        target_sizes[idx] = items[idx].flex_base_size + remaining * ratio;
480                    }
481                }
482            }
483        }
484
485        // Clamp and freeze violated items
486        let mut any_frozen = false;
487        for &idx in &line.item_indices {
488            if frozen[idx] {
489                continue;
490            }
491            let clamped = target_sizes[idx]
492                .max(items[idx].min_main)
493                .min(items[idx].max_main);
494            if (clamped - target_sizes[idx]).abs() > 0.001 {
495                target_sizes[idx] = clamped;
496                frozen[idx] = true;
497                any_frozen = true;
498            }
499        }
500
501        if !any_frozen {
502            // Freeze all remaining
503            for &idx in &line.item_indices {
504                frozen[idx] = true;
505            }
506            break;
507        }
508    }
509
510    // Ensure non-negative
511    for &idx in &line.item_indices {
512        target_sizes[idx] = target_sizes[idx].max(0.0);
513        sizes.insert(idx, target_sizes[idx]);
514    }
515
516    ResolvedLine { sizes }
517}
518
519fn compute_item_cross_size(
520    ctx: &LayoutContext,
521    item: &FlexItem,
522    main_size: f32,
523    is_row: bool,
524    containing_width: f32,
525    containing_height: f32,
526) -> f32 {
527    let style = ctx.tree.style(item.node);
528    let box_model = BoxModel::resolve(style, containing_width);
529
530    if is_row {
531        // Cross is height
532        match style.height.resolve(containing_height) {
533            Some(h) => h + box_model.border.vertical() + box_model.padding.vertical(),
534            None => {
535                let frag = layout::layout_node(ctx, item.node, main_size, containing_height);
536                frag.border_box().height
537            }
538        }
539    } else {
540        // Cross is width
541        match style.width.resolve(containing_width) {
542            Some(w) => w + box_model.border.horizontal() + box_model.padding.horizontal(),
543            None => {
544                let frag = layout::layout_node(ctx, item.node, containing_width, main_size);
545                frag.border_box().width
546            }
547        }
548    }
549}
550
551fn layout_flex_item(
552    ctx: &LayoutContext,
553    node: NodeId,
554    width: f32,
555    height: f32,
556    _is_row: bool,
557    _containing_width: f32,
558    _containing_height: f32,
559) -> Fragment {
560    // For flex items, we need to set the main size as a constraint
561
562    layout::layout_node(ctx, node, width, height)
563}
564
565fn effective_align(container: AlignItems, item: AlignSelf) -> AlignItems {
566    match item {
567        AlignSelf::Auto => container,
568        AlignSelf::Stretch => AlignItems::Stretch,
569        AlignSelf::FlexStart => AlignItems::FlexStart,
570        AlignSelf::FlexEnd => AlignItems::FlexEnd,
571        AlignSelf::Center => AlignItems::Center,
572        AlignSelf::Baseline => AlignItems::Baseline,
573        AlignSelf::Start => AlignItems::Start,
574        AlignSelf::End => AlignItems::End,
575    }
576}
577
578/// Distribute space for align-content.
579fn distribute_alignment(align: AlignContent, extra: f32, line_count: usize) -> (f32, f32) {
580    if line_count == 0 {
581        return (0.0, 0.0);
582    }
583    match align {
584        AlignContent::FlexStart | AlignContent::Start => (0.0, 0.0),
585        AlignContent::FlexEnd | AlignContent::End => (extra, 0.0),
586        AlignContent::Center => (extra / 2.0, 0.0),
587        AlignContent::SpaceBetween => {
588            if line_count <= 1 {
589                (0.0, 0.0)
590            } else {
591                (0.0, extra / (line_count - 1) as f32)
592            }
593        }
594        AlignContent::SpaceAround => {
595            let gap = extra / line_count as f32;
596            (gap / 2.0, gap)
597        }
598        AlignContent::SpaceEvenly => {
599            let gap = extra / (line_count + 1) as f32;
600            (gap, gap)
601        }
602        AlignContent::Stretch => (0.0, 0.0),
603    }
604}
605
606/// Distribute space for justify-content.
607fn distribute_justify(justify: JustifyContent, extra: f32, item_count: usize) -> (f32, f32) {
608    if item_count == 0 {
609        return (0.0, 0.0);
610    }
611    match justify {
612        JustifyContent::FlexStart | JustifyContent::Start => (0.0, 0.0),
613        JustifyContent::FlexEnd | JustifyContent::End => (extra, 0.0),
614        JustifyContent::Center => (extra / 2.0, 0.0),
615        JustifyContent::SpaceBetween => {
616            if item_count <= 1 {
617                (0.0, 0.0)
618            } else {
619                (0.0, extra / (item_count - 1) as f32)
620            }
621        }
622        JustifyContent::SpaceAround => {
623            let gap = extra / item_count as f32;
624            (gap / 2.0, gap)
625        }
626        JustifyContent::SpaceEvenly => {
627            let gap = extra / (item_count + 1) as f32;
628            (gap, gap)
629        }
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use crate::layout::{compute_layout, FixedWidthTextMeasure};
637    use crate::style::ComputedStyle;
638    use crate::tree::BoxTreeBuilder;
639
640    #[test]
641    fn test_flex_row_equal_items() {
642        let mut builder = BoxTreeBuilder::new();
643        let root_style = ComputedStyle {
644            display: Display::FLEX,
645            ..ComputedStyle::block()
646        };
647        let root = builder.root(root_style);
648
649        for _ in 0..3 {
650            let mut item_style = ComputedStyle::block();
651            item_style.flex_grow = 1.0;
652            item_style.height = LengthPercentageAuto::px(50.0);
653            builder.element(root, item_style);
654        }
655
656        let tree = builder.build();
657        let result = compute_layout(&tree, &FixedWidthTextMeasure, Size::new(900.0, 600.0));
658
659        // Each item should be 300px wide (900 / 3)
660        let children = tree.children(tree.root());
661        for (i, &child) in children.iter().enumerate() {
662            let rect = result.bounding_rect(child).unwrap();
663            assert!(
664                (rect.width - 300.0).abs() < 1.0,
665                "item {} width: {}",
666                i,
667                rect.width
668            );
669        }
670    }
671
672    #[test]
673    fn test_flex_justify_space_between() {
674        let mut builder = BoxTreeBuilder::new();
675        let root_style = ComputedStyle {
676            display: Display::FLEX,
677            justify_content: JustifyContent::SpaceBetween,
678            ..ComputedStyle::block()
679        };
680        let root = builder.root(root_style);
681
682        for _ in 0..3 {
683            let mut item_style = ComputedStyle::block();
684            item_style.width = LengthPercentageAuto::px(100.0);
685            item_style.height = LengthPercentageAuto::px(50.0);
686            builder.element(root, item_style);
687        }
688
689        let tree = builder.build();
690        let result = compute_layout(&tree, &FixedWidthTextMeasure, Size::new(600.0, 600.0));
691
692        let children = tree.children(tree.root());
693        let r0 = result.bounding_rect(children[0]).unwrap();
694        let r1 = result.bounding_rect(children[1]).unwrap();
695        let r2 = result.bounding_rect(children[2]).unwrap();
696
697        assert!((r0.x - 0.0).abs() < 1.0);
698        assert!((r1.x - 250.0).abs() < 1.0); // (600-300)/2 + 100
699        assert!((r2.x - 500.0).abs() < 1.0);
700    }
701
702    #[test]
703    fn test_distribute_justify_space_evenly() {
704        let (start, between) = distribute_justify(JustifyContent::SpaceEvenly, 400.0, 3);
705        assert_eq!(start, 100.0);
706        assert_eq!(between, 100.0);
707    }
708}