Skip to main content

taffy/compute/grid/
alignment.rs

1//! Alignment of tracks and final positioning of items
2use super::types::GridTrack;
3use crate::compute::common::alignment::{
4    apply_alignment_fallback, child_first_baseline, compute_alignment_offset, resolve_self_alignment_safety,
5};
6use crate::geometry::{InBothAbsAxis, Line, Point, Rect, Size};
7use crate::style::{
8    AlignContent, AlignItems, AlignItemsKeyword, AlignSelf, AvailableSpace, CoreStyle, GridItemStyle, Overflow,
9    Position,
10};
11use crate::tree::{Layout, LayoutPartialTreeExt, NodeId, OofCandidates, SizingMode};
12use crate::util::sys::f32_max;
13use crate::util::{MaybeMath, MaybeResolve, ResolveOrZero};
14
15#[cfg(feature = "content_size")]
16use crate::compute::common::scrollable_overflow::compute_scrollable_overflow_contribution;
17use crate::compute::common::sizing_keyword::{resolve_sizing_keyword, SizingKeywordResolution};
18use crate::{AbsoluteAxis, BoxSizing, Direction, LayoutGridContainer};
19
20/// Align the grid tracks within the grid according to the align-content (rows) or
21/// justify-content (columns) property. This only does anything if the size of the
22/// grid is not equal to the size of the grid container in the axis being aligned.
23pub(super) fn align_tracks(
24    grid_container_content_box_size: f32,
25    padding: Line<f32>,
26    border: Line<f32>,
27    tracks: &mut [GridTrack],
28    track_alignment_style: AlignContent,
29    axis_is_reversed: bool,
30) {
31    let used_size: f32 = tracks.iter().map(|track| track.base_size).sum();
32    let free_space = grid_container_content_box_size - used_size;
33    let origin = padding.start + border.start;
34
35    // Count the number of non-collapsed tracks (not counting gutters)
36    let num_tracks = tracks.iter().skip(1).step_by(2).filter(|track| !track.is_collapsed).count();
37
38    // Grid layout treats gaps as full tracks rather than applying them at alignment so we
39    // simply pass zero here. Grid layout is never reversed.
40    let gap = 0.0;
41    let layout_is_reversed = false;
42    let track_alignment = apply_alignment_fallback(free_space, num_tracks, track_alignment_style);
43    let track_alignment = if axis_is_reversed { track_alignment.reversed() } else { track_alignment };
44
45    // If every track is collapsed then no track receives the alignment offset below, but the
46    // grid's lines should still be aligned within the container (e.g. at the inline-start edge
47    // for RTL), so apply the offset to the origin instead.
48    let empty_grid_offset = if num_tracks == 0 {
49        compute_alignment_offset(free_space, num_tracks, gap, track_alignment, layout_is_reversed, true)
50    } else {
51        0.0
52    };
53
54    // Compute offsets. Tracks are stored in logical order; when the axis is reversed (RTL)
55    // physical offsets are assigned right-to-left by iterating the tracks in reverse.
56    let mut total_offset = origin + empty_grid_offset;
57    let mut seen_non_collapsed_track = false;
58    let mut position_track = |i: usize, track: &mut GridTrack| {
59        // Odd tracks are gutters (but slices are zero-indexed, so odd tracks have even indices)
60        let is_gutter = i % 2 == 0;
61        let is_non_collapsed_track = !is_gutter && !track.is_collapsed;
62
63        // Alignment offsets should be applied only to non-collapsed tracks.
64        let is_first = is_non_collapsed_track && !seen_non_collapsed_track;
65
66        let offset = if is_non_collapsed_track {
67            compute_alignment_offset(free_space, num_tracks, gap, track_alignment, layout_is_reversed, is_first)
68        } else {
69            0.0
70        };
71
72        track.offset = total_offset + offset;
73        total_offset = total_offset + offset + track.base_size;
74        if is_non_collapsed_track {
75            seen_non_collapsed_track = true;
76        }
77    };
78    if axis_is_reversed {
79        tracks.iter_mut().rev().enumerate().for_each(|(i, track)| position_track(i, track));
80    } else {
81        tracks.iter_mut().enumerate().for_each(|(i, track)| position_track(i, track));
82    }
83}
84
85/// Align and size a grid item into it's final position
86#[allow(clippy::too_many_arguments)]
87pub(super) fn align_and_position_item(
88    tree: &mut impl LayoutGridContainer,
89    node: NodeId,
90    order: u32,
91    grid_area: Rect<f32>,
92    // Percentage basis for the item's styles. Grid passes the grid area size; grid lanes (css-grid-3) items
93    // have the container's content box as their containing block in the stacking axis.
94    containing_block_size: Size<f32>,
95    container_alignment_styles: InBothAbsAxis<AlignItems>,
96    baseline_shim: f32,
97    direction: Direction,
98    container_border_box_width: f32,
99    container_border: Rect<f32>,
100    #[cfg(feature = "content_size")] container_is_scroll_container: bool,
101    bubbled_candidates: &mut OofCandidates,
102) -> (Rect<f32>, f32, f32, Option<f32>) {
103    let grid_area_size = Size { width: grid_area.right - grid_area.left, height: grid_area.bottom - grid_area.top };
104
105    let style = tree.get_grid_child_style(node);
106
107    let overflow = style.overflow();
108    #[cfg(feature = "content_size")]
109    let contain = style.contain();
110    let scrollbar_width = style.scrollbar_width();
111    let aspect_ratio = style.aspect_ratio();
112    let is_replaced = style.is_replaced();
113    // Fall back to the container's justify-items/align-items if unset (`None`), and then resolve
114    // writing-mode-relative self-start/self-end keywords against the item's own direction.
115    // The horizontal axis is the inline axis (Taffy only supports horizontal-tb); the vertical
116    // (block) axis resolves them to plain start/end.
117    let item_direction = style.direction();
118    let justify_self = style.justify_self().unwrap_or(container_alignment_styles.horizontal).resolve_self_relative(
119        item_direction,
120        direction,
121        true,
122    );
123    let align_self = style.align_self().unwrap_or(container_alignment_styles.vertical).resolve_self_relative(
124        item_direction,
125        direction,
126        false,
127    );
128
129    let position = style.position();
130    let inset_horizontal = style
131        .inset()
132        .horizontal_components()
133        .map(|size| size.resolve_to_option(containing_block_size.width, |val, basis| tree.calc(val, basis)));
134    let inset_vertical = style
135        .inset()
136        .vertical_components()
137        .map(|size| size.resolve_to_option(containing_block_size.height, |val, basis| tree.calc(val, basis)));
138    let padding = style
139        .padding()
140        .map(|p| p.resolve_or_zero(Some(containing_block_size.width), |val, basis| tree.calc(val, basis)));
141    let border = style
142        .border()
143        .map(|p| p.resolve_or_zero(Some(containing_block_size.width), |val, basis| tree.calc(val, basis)));
144    let padding_border_size = (padding + border).sum_axes();
145
146    let box_sizing_adjustment =
147        if style.box_sizing() == BoxSizing::ContentBox { padding_border_size } else { Size::ZERO };
148
149    let size_style = style.size();
150    let inherent_size = size_style
151        .maybe_resolve(containing_block_size, |val, basis| tree.calc(val, basis))
152        .maybe_apply_aspect_ratio(aspect_ratio)
153        .maybe_add(box_sizing_adjustment);
154    let min_size = style
155        .min_size()
156        .maybe_resolve(containing_block_size, |val, basis| tree.calc(val, basis))
157        .maybe_add(box_sizing_adjustment)
158        .or(padding_border_size.map(Some))
159        .maybe_max(padding_border_size)
160        .maybe_apply_aspect_ratio(aspect_ratio);
161    let max_size = style
162        .max_size()
163        .maybe_resolve(containing_block_size, |val, basis| tree.calc(val, basis))
164        .maybe_apply_aspect_ratio(aspect_ratio)
165        .maybe_add(box_sizing_adjustment);
166
167    // Resolve `normal` alignment (the default if alignment is set on neither the parent or the node itself)
168    // Note: if the child has a preferred aspect ratio but neither width or height are set, then the width is stretched
169    // and the then height is calculated from the width according the aspect ratio
170    // Replaced elements are not stretched: they are sized as for `start`.
171    // See: https://www.w3.org/TR/css-grid-1/#grid-item-sizing
172    let alignment_styles = InBothAbsAxis {
173        horizontal: match justify_self.keyword {
174            AlignItemsKeyword::Normal => {
175                if inherent_size.width.is_some() || size_style.width.is_sizing_keyword() || is_replaced {
176                    AlignSelf::START
177                } else {
178                    AlignSelf::STRETCH
179                }
180            }
181            _ => justify_self,
182        },
183        vertical: match align_self.keyword {
184            AlignItemsKeyword::Normal => {
185                if inherent_size.height.is_some()
186                    || size_style.height.is_sizing_keyword()
187                    || aspect_ratio.is_some()
188                    || is_replaced
189                {
190                    AlignSelf::START
191                } else {
192                    AlignSelf::STRETCH
193                }
194            }
195            _ => align_self,
196        },
197    };
198
199    // Note: This is not a bug. It is part of the CSS spec that both horizontal and vertical margins
200    // resolve against the WIDTH of the grid area.
201    let margin = style
202        .margin()
203        .map(|margin| margin.resolve_to_option(containing_block_size.width, |val, basis| tree.calc(val, basis)));
204
205    drop(style);
206
207    let grid_area_minus_item_margins_size = Size {
208        width: grid_area_size.width.maybe_sub(margin.left).maybe_sub(margin.right).max(0.0),
209        height: (grid_area_size.height.maybe_sub(margin.top).maybe_sub(margin.bottom) - baseline_shim).max(0.0),
210    };
211
212    // A size that is a sizing keyword (min-content, max-content, fit-content,
213    // fit-content(...), stretch) either resolves to an exact size or is resolved
214    // by measuring the item under the corresponding available space constraint
215    let keyword_width = inherent_size.width.is_none().then(|| {
216        resolve_sizing_keyword(
217            size_style.width,
218            Some(grid_area_minus_item_margins_size.width),
219            Some(containing_block_size.width),
220            |val, basis| tree.calc(val, basis),
221        )
222    });
223    let keyword_height = inherent_size.height.is_none().then(|| {
224        resolve_sizing_keyword(
225            size_style.height,
226            Some(grid_area_minus_item_margins_size.height),
227            Some(containing_block_size.height),
228            |val, basis| tree.calc(val, basis),
229        )
230    });
231
232    // If both axes need to be measured then resolve them with a single measure call
233    let keyword_measured_size: Size<Option<f32>> = match (&keyword_width, &keyword_height) {
234        (
235            Some(Some(SizingKeywordResolution::Measure(available_width))),
236            Some(Some(SizingKeywordResolution::Measure(available_height))),
237        ) if !position.is_out_of_flow() => tree
238            .measure_child_size_both(
239                node,
240                Size::NONE,
241                containing_block_size.map(Option::Some),
242                Size { width: *available_width, height: *available_height },
243                SizingMode::InherentSize,
244                Line::FALSE,
245            )
246            .map(Option::Some),
247        _ => Size::NONE,
248    };
249
250    // If node is absolutely positioned and width is not set explicitly, then deduce it
251    // from left, right and container_content_box if both are set.
252    let width = inherent_size.width.or_else(|| {
253        // Apply width derived from both the left and right properties of an absolutely
254        // positioned element being set
255        if position.is_out_of_flow() {
256            if let (Some(left), Some(right)) = (inset_horizontal.start, inset_horizontal.end) {
257                return Some(f32_max(grid_area_minus_item_margins_size.width - left - right, 0.0));
258            }
259        }
260
261        if let Some(Some(resolution)) = keyword_width {
262            return Some(match resolution {
263                SizingKeywordResolution::Exact(width) => width,
264                SizingKeywordResolution::Measure(available_width) => keyword_measured_size.width.unwrap_or_else(|| {
265                    tree.measure_child_size(
266                        node,
267                        Size::NONE,
268                        containing_block_size.map(Option::Some),
269                        Size {
270                            width: available_width,
271                            height: AvailableSpace::Definite(grid_area_minus_item_margins_size.height),
272                        },
273                        SizingMode::InherentSize,
274                        AbsoluteAxis::Horizontal,
275                        Line::FALSE,
276                    )
277                }),
278            });
279        }
280
281        // Apply width based on stretch alignment if:
282        //  - Alignment style is "stretch"
283        //  - The node is not absolutely positioned
284        //  - The node does not have auto margins in this axis.
285        if margin.left.is_some()
286            && margin.right.is_some()
287            && alignment_styles.horizontal == AlignSelf::STRETCH
288            && !position.is_out_of_flow()
289        {
290            return Some(grid_area_minus_item_margins_size.width);
291        }
292
293        None
294    });
295
296    // Reapply aspect ratio after stretch and absolute position width adjustments
297    let Size { width, height } = Size { width, height: inherent_size.height }.maybe_apply_aspect_ratio(aspect_ratio);
298
299    let height = height.or_else(|| {
300        if position.is_out_of_flow() {
301            if let (Some(top), Some(bottom)) = (inset_vertical.start, inset_vertical.end) {
302                return Some(f32_max(grid_area_minus_item_margins_size.height - top - bottom, 0.0));
303            }
304        }
305
306        if let Some(Some(resolution)) = keyword_height {
307            return Some(match resolution {
308                SizingKeywordResolution::Exact(height) => height,
309                SizingKeywordResolution::Measure(available_height) => {
310                    keyword_measured_size.height.unwrap_or_else(|| {
311                        tree.measure_child_size(
312                            node,
313                            Size { width, height: None },
314                            containing_block_size.map(Option::Some),
315                            Size {
316                                width: width
317                                    .map(AvailableSpace::Definite)
318                                    .unwrap_or(AvailableSpace::Definite(grid_area_minus_item_margins_size.width)),
319                                height: available_height,
320                            },
321                            SizingMode::InherentSize,
322                            AbsoluteAxis::Vertical,
323                            Line::FALSE,
324                        )
325                    })
326                }
327            });
328        }
329
330        // Apply height based on stretch alignment if:
331        //  - Alignment style is "stretch"
332        //  - The node is not absolutely positioned
333        //  - The node does not have auto margins in this axis.
334        if margin.top.is_some()
335            && margin.bottom.is_some()
336            && alignment_styles.vertical == AlignSelf::STRETCH
337            && !position.is_out_of_flow()
338        {
339            return Some(grid_area_minus_item_margins_size.height);
340        }
341
342        None
343    });
344    // Reapply aspect ratio after stretch and absolute position height adjustments
345    let Size { width, height } = Size { width, height }.maybe_apply_aspect_ratio(aspect_ratio);
346
347    // Clamp size by min and max width/height
348    let Size { width, height } = Size { width, height }.maybe_clamp(min_size, max_size);
349
350    // Layout node
351    let size = if position.is_out_of_flow() && (width.is_none() || height.is_none()) {
352        tree.measure_child_size_both(
353            node,
354            Size { width, height },
355            containing_block_size.map(Option::Some),
356            grid_area_minus_item_margins_size.map(AvailableSpace::Definite),
357            SizingMode::InherentSize,
358            Line::FALSE,
359        )
360        .map(Some)
361    } else {
362        Size { width, height }
363    };
364
365    let mut layout_output = tree.perform_child_layout(
366        node,
367        size,
368        containing_block_size.map(Option::Some),
369        grid_area_minus_item_margins_size.map(AvailableSpace::Definite),
370        SizingMode::InherentSize,
371        Line::FALSE,
372    );
373
374    // Resolve final size
375    let Size { width, height } = size.unwrap_or(layout_output.size).maybe_clamp(min_size, max_size);
376
377    let (x, x_margin) = align_item_within_area(
378        Line { start: grid_area.left, end: grid_area.right },
379        alignment_styles.horizontal,
380        width,
381        position,
382        inset_horizontal,
383        margin.horizontal_components(),
384        0.0,
385        direction,
386    );
387    let (y_without_relative_offset, y_margin) = align_item_within_area(
388        Line { start: grid_area.top, end: grid_area.bottom },
389        alignment_styles.vertical,
390        height,
391        position,
392        inset_vertical,
393        margin.vertical_components(),
394        baseline_shim,
395        Direction::Ltr,
396    );
397    // A relative offset moves the item, but does not affect the container's baseline (which is computed from the
398    // returned y position, without the offset)
399    let x = x + relative_offset(position, inset_horizontal, direction);
400    let y = y_without_relative_offset + relative_offset(position, inset_vertical, Direction::Ltr);
401
402    let scrollbar_size = Size {
403        width: if overflow.y == Overflow::Scroll { scrollbar_width } else { 0.0 },
404        height: if overflow.x == Overflow::Scroll { scrollbar_width } else { 0.0 },
405    };
406
407    let resolved_margin = Rect { left: x_margin.start, right: x_margin.end, top: y_margin.start, bottom: y_margin.end };
408
409    // Bubble out-of-flow candidates from the item's subtree, translating anchors from
410    // item-relative to container-relative coordinates
411    let mut item_candidates = layout_output.oof_candidates.take();
412    if !item_candidates.is_empty() {
413        item_candidates.translate(Point { x, y });
414        bubbled_candidates.append(&mut item_candidates);
415    }
416
417    tree.set_unrounded_layout(
418        node,
419        &Layout {
420            order,
421            location: Point { x, y },
422            size: Size { width, height },
423            #[cfg(feature = "content_size")]
424            scrollable_overflow_rect: layout_output.scrollable_overflow_rect,
425            scrollbar_size,
426            padding,
427            border,
428            margin: resolved_margin,
429        },
430    );
431
432    #[cfg(feature = "content_size")]
433    let contribution = {
434        // Contributions to the container's scrollable overflow rect are measured from the
435        // container's padding-box origin (mirrored for RTL), matching the scrollable overflow region.
436        let contribution_location = if direction.is_rtl() {
437            Point { x: container_border_box_width - (x + width) - container_border.right, y: y - container_border.top }
438        } else {
439            Point { x: x - container_border.left, y: y - container_border.top }
440        };
441        compute_scrollable_overflow_contribution(
442            contribution_location,
443            Size { width, height },
444            layout_output.scrollable_overflow_rect,
445            overflow,
446            contain,
447            container_is_scroll_container,
448        )
449    };
450    #[cfg(not(feature = "content_size"))]
451    let contribution = Rect::ZERO;
452
453    let baseline = child_first_baseline(layout_output.baselines.first, height, overflow.y);
454    (contribution, y_without_relative_offset, height, baseline)
455}
456
457/// Align and size a grid item along a single axis
458#[allow(clippy::too_many_arguments)]
459pub(super) fn align_item_within_area(
460    grid_area: Line<f32>,
461    alignment_style: AlignSelf,
462    resolved_size: f32,
463    position: Position,
464    inset: Line<Option<f32>>,
465    margin: Line<Option<f32>>,
466    baseline_shim: f32,
467    direction: Direction,
468) -> (f32, Line<f32>) {
469    // Calculate grid area dimension in the axis
470    let non_auto_margin = Line { start: margin.start.unwrap_or(0.0) + baseline_shim, end: margin.end.unwrap_or(0.0) };
471    let grid_area_size = f32_max(grid_area.end - grid_area.start, 0.0);
472    let free_space = f32_max(grid_area_size - resolved_size - non_auto_margin.sum(), 0.0);
473
474    // Expand auto margins to fill available space
475    let auto_margin_count = margin.start.is_none() as u8 + margin.end.is_none() as u8;
476    let auto_margin_size = if auto_margin_count > 0 { free_space / auto_margin_count as f32 } else { 0.0 };
477    let resolved_margin = Line {
478        start: margin.start.unwrap_or(auto_margin_size) + baseline_shim,
479        end: margin.end.unwrap_or(auto_margin_size),
480    };
481
482    let overflows = resolved_size + non_auto_margin.sum() > grid_area_size;
483    let alignment_keyword = resolve_self_alignment_safety(alignment_style, overflows);
484
485    // Compute offset in the axis
486    let alignment_based_offset = match alignment_keyword {
487        // TODO: Add support for baseline alignment. For now we treat it as "start".
488        AlignItemsKeyword::Start
489        | AlignItemsKeyword::FlexStart
490        | AlignItemsKeyword::Baseline
491        | AlignItemsKeyword::Stretch => {
492            if direction.is_rtl() {
493                grid_area_size - resolved_size - resolved_margin.end
494            } else {
495                resolved_margin.start
496            }
497        }
498        AlignItemsKeyword::End | AlignItemsKeyword::FlexEnd => {
499            if direction.is_rtl() {
500                resolved_margin.start
501            } else {
502                grid_area_size - resolved_size - resolved_margin.end
503            }
504        }
505        AlignItemsKeyword::Center => {
506            (grid_area_size - resolved_size + resolved_margin.start - resolved_margin.end) / 2.0
507        }
508        // Normal is resolved, and SelfStart/SelfEnd are resolved to Start/End against
509        // the item's own direction, in `align_and_position_item`.
510        AlignItemsKeyword::Normal | AlignItemsKeyword::SelfStart | AlignItemsKeyword::SelfEnd => unreachable!(),
511    };
512
513    let offset_within_area = if position.is_out_of_flow() {
514        match (inset.start, inset.end) {
515            (Some(start), Some(end)) => {
516                if direction.is_rtl() {
517                    grid_area_size - end - resolved_size - non_auto_margin.end
518                } else {
519                    start + non_auto_margin.start
520                }
521            }
522            (Some(start), None) => start + non_auto_margin.start,
523            (None, Some(end)) => grid_area_size - end - resolved_size - non_auto_margin.end,
524            (None, None) => alignment_based_offset,
525        }
526    } else {
527        alignment_based_offset
528    };
529
530    (grid_area.start + offset_within_area, resolved_margin)
531}
532
533/// The offset applied to an item in a single axis by its `inset` if it is `position: relative`
534fn relative_offset(position: Position, inset: Line<Option<f32>>, direction: Direction) -> f32 {
535    if position != Position::Relative {
536        return 0.0;
537    }
538    let relative_inset = if direction.is_rtl() {
539        inset.end.map(|pos| -pos).or(inset.start)
540    } else {
541        inset.start.or(inset.end.map(|pos| -pos))
542    };
543    relative_inset.unwrap_or(0.0)
544}