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, 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, 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    container_alignment_styles: InBothAbsAxis<Option<AlignItems>>,
93    baseline_shim: f32,
94    direction: Direction,
95    container_border_box_width: f32,
96    container_border: Rect<f32>,
97    #[cfg(feature = "content_size")] container_is_scroll_container: bool,
98) -> (Rect<f32>, f32, f32) {
99    let grid_area_size = Size { width: grid_area.right - grid_area.left, height: grid_area.bottom - grid_area.top };
100
101    let style = tree.get_grid_child_style(node);
102
103    let overflow = style.overflow();
104    #[cfg(feature = "content_size")]
105    let contain = style.contain();
106    let scrollbar_width = style.scrollbar_width();
107    let aspect_ratio = style.aspect_ratio();
108    // Resolve writing-mode-relative self-start/self-end keywords against the item's own
109    // direction. The horizontal axis is the inline axis (Taffy only supports horizontal-tb);
110    // the vertical (block) axis resolves them to plain start/end.
111    let item_direction = style.direction();
112    let justify_self = style.justify_self().map(|align| align.resolve_self_relative(item_direction, direction, true));
113    let align_self = style.align_self().map(|align| align.resolve_self_relative(item_direction, direction, false));
114    let container_alignment_styles = InBothAbsAxis {
115        horizontal: container_alignment_styles
116            .horizontal
117            .map(|align| align.resolve_self_relative(item_direction, direction, true)),
118        vertical: container_alignment_styles
119            .vertical
120            .map(|align| align.resolve_self_relative(item_direction, direction, false)),
121    };
122
123    let position = style.position();
124    let inset_horizontal = style
125        .inset()
126        .horizontal_components()
127        .map(|size| size.resolve_to_option(grid_area_size.width, |val, basis| tree.calc(val, basis)));
128    let inset_vertical = style
129        .inset()
130        .vertical_components()
131        .map(|size| size.resolve_to_option(grid_area_size.height, |val, basis| tree.calc(val, basis)));
132    let padding =
133        style.padding().map(|p| p.resolve_or_zero(Some(grid_area_size.width), |val, basis| tree.calc(val, basis)));
134    let border =
135        style.border().map(|p| p.resolve_or_zero(Some(grid_area_size.width), |val, basis| tree.calc(val, basis)));
136    let padding_border_size = (padding + border).sum_axes();
137
138    let box_sizing_adjustment =
139        if style.box_sizing() == BoxSizing::ContentBox { padding_border_size } else { Size::ZERO };
140
141    let size_style = style.size();
142    let inherent_size = size_style
143        .maybe_resolve(grid_area_size, |val, basis| tree.calc(val, basis))
144        .maybe_apply_aspect_ratio(aspect_ratio)
145        .maybe_add(box_sizing_adjustment);
146    let min_size = style
147        .min_size()
148        .maybe_resolve(grid_area_size, |val, basis| tree.calc(val, basis))
149        .maybe_add(box_sizing_adjustment)
150        .or(padding_border_size.map(Some))
151        .maybe_max(padding_border_size)
152        .maybe_apply_aspect_ratio(aspect_ratio);
153    let max_size = style
154        .max_size()
155        .maybe_resolve(grid_area_size, |val, basis| tree.calc(val, basis))
156        .maybe_apply_aspect_ratio(aspect_ratio)
157        .maybe_add(box_sizing_adjustment);
158
159    // Resolve default alignment styles if they are set on neither the parent or the node itself
160    // Note: if the child has a preferred aspect ratio but neither width or height are set, then the width is stretched
161    // and the then height is calculated from the width according the aspect ratio
162    // See: https://www.w3.org/TR/css-grid-1/#grid-item-sizing
163    let alignment_styles = InBothAbsAxis {
164        horizontal: justify_self.or(container_alignment_styles.horizontal).unwrap_or_else(|| {
165            if inherent_size.width.is_some() || size_style.width.is_sizing_keyword() {
166                AlignSelf::START
167            } else {
168                AlignSelf::STRETCH
169            }
170        }),
171        vertical: align_self.or(container_alignment_styles.vertical).unwrap_or_else(|| {
172            if inherent_size.height.is_some() || size_style.height.is_sizing_keyword() || aspect_ratio.is_some() {
173                AlignSelf::START
174            } else {
175                AlignSelf::STRETCH
176            }
177        }),
178    };
179
180    // Note: This is not a bug. It is part of the CSS spec that both horizontal and vertical margins
181    // resolve against the WIDTH of the grid area.
182    let margin =
183        style.margin().map(|margin| margin.resolve_to_option(grid_area_size.width, |val, basis| tree.calc(val, basis)));
184
185    drop(style);
186
187    let grid_area_minus_item_margins_size = Size {
188        width: grid_area_size.width.maybe_sub(margin.left).maybe_sub(margin.right),
189        height: grid_area_size.height.maybe_sub(margin.top).maybe_sub(margin.bottom) - baseline_shim,
190    };
191
192    // A size that is a sizing keyword (min-content, max-content, fit-content,
193    // fit-content(...), stretch) either resolves to an exact size or is resolved
194    // by measuring the item under the corresponding available space constraint
195    let keyword_width = inherent_size.width.is_none().then(|| {
196        resolve_sizing_keyword(
197            size_style.width,
198            Some(grid_area_minus_item_margins_size.width),
199            Some(grid_area_size.width),
200        )
201    });
202    let keyword_height = inherent_size.height.is_none().then(|| {
203        resolve_sizing_keyword(
204            size_style.height,
205            Some(grid_area_minus_item_margins_size.height),
206            Some(grid_area_size.height),
207        )
208    });
209
210    // If both axes need to be measured then resolve them with a single measure call
211    let keyword_measured_size: Size<Option<f32>> = match (&keyword_width, &keyword_height) {
212        (
213            Some(Some(SizingKeywordResolution::Measure(available_width))),
214            Some(Some(SizingKeywordResolution::Measure(available_height))),
215        ) if position != Position::Absolute => tree
216            .measure_child_size_both(
217                node,
218                Size::NONE,
219                grid_area_size.map(Option::Some),
220                Size { width: *available_width, height: *available_height },
221                SizingMode::InherentSize,
222                Line::FALSE,
223            )
224            .map(Option::Some),
225        _ => Size::NONE,
226    };
227
228    // If node is absolutely positioned and width is not set explicitly, then deduce it
229    // from left, right and container_content_box if both are set.
230    let width = inherent_size.width.or_else(|| {
231        // Apply width derived from both the left and right properties of an absolutely
232        // positioned element being set
233        if position == Position::Absolute {
234            if let (Some(left), Some(right)) = (inset_horizontal.start, inset_horizontal.end) {
235                return Some(f32_max(grid_area_minus_item_margins_size.width - left - right, 0.0));
236            }
237        }
238
239        if let Some(Some(resolution)) = keyword_width {
240            return Some(match resolution {
241                SizingKeywordResolution::Exact(width) => width,
242                SizingKeywordResolution::Measure(available_width) => keyword_measured_size.width.unwrap_or_else(|| {
243                    tree.measure_child_size(
244                        node,
245                        Size::NONE,
246                        grid_area_size.map(Option::Some),
247                        Size {
248                            width: available_width,
249                            height: AvailableSpace::Definite(grid_area_minus_item_margins_size.height),
250                        },
251                        SizingMode::InherentSize,
252                        AbsoluteAxis::Horizontal,
253                        Line::FALSE,
254                    )
255                }),
256            });
257        }
258
259        // Apply width based on stretch alignment if:
260        //  - Alignment style is "stretch"
261        //  - The node is not absolutely positioned
262        //  - The node does not have auto margins in this axis.
263        if margin.left.is_some()
264            && margin.right.is_some()
265            && alignment_styles.horizontal == AlignSelf::STRETCH
266            && position != Position::Absolute
267        {
268            return Some(grid_area_minus_item_margins_size.width);
269        }
270
271        None
272    });
273
274    // Reapply aspect ratio after stretch and absolute position width adjustments
275    let Size { width, height } = Size { width, height: inherent_size.height }.maybe_apply_aspect_ratio(aspect_ratio);
276
277    let height = height.or_else(|| {
278        if position == Position::Absolute {
279            if let (Some(top), Some(bottom)) = (inset_vertical.start, inset_vertical.end) {
280                return Some(f32_max(grid_area_minus_item_margins_size.height - top - bottom, 0.0));
281            }
282        }
283
284        if let Some(Some(resolution)) = keyword_height {
285            return Some(match resolution {
286                SizingKeywordResolution::Exact(height) => height,
287                SizingKeywordResolution::Measure(available_height) => {
288                    keyword_measured_size.height.unwrap_or_else(|| {
289                        tree.measure_child_size(
290                            node,
291                            Size { width, height: None },
292                            grid_area_size.map(Option::Some),
293                            Size {
294                                width: width
295                                    .map(AvailableSpace::Definite)
296                                    .unwrap_or(AvailableSpace::Definite(grid_area_minus_item_margins_size.width)),
297                                height: available_height,
298                            },
299                            SizingMode::InherentSize,
300                            AbsoluteAxis::Vertical,
301                            Line::FALSE,
302                        )
303                    })
304                }
305            });
306        }
307
308        // Apply height based on stretch alignment if:
309        //  - Alignment style is "stretch"
310        //  - The node is not absolutely positioned
311        //  - The node does not have auto margins in this axis.
312        if margin.top.is_some()
313            && margin.bottom.is_some()
314            && alignment_styles.vertical == AlignSelf::STRETCH
315            && position != Position::Absolute
316        {
317            return Some(grid_area_minus_item_margins_size.height);
318        }
319
320        None
321    });
322    // Reapply aspect ratio after stretch and absolute position height adjustments
323    let Size { width, height } = Size { width, height }.maybe_apply_aspect_ratio(aspect_ratio);
324
325    // Clamp size by min and max width/height
326    let Size { width, height } = Size { width, height }.maybe_clamp(min_size, max_size);
327
328    // Layout node
329    let size = if position == Position::Absolute && (width.is_none() || height.is_none()) {
330        tree.measure_child_size_both(
331            node,
332            Size { width, height },
333            grid_area_size.map(Option::Some),
334            grid_area_minus_item_margins_size.map(AvailableSpace::Definite),
335            SizingMode::InherentSize,
336            Line::FALSE,
337        )
338        .map(Some)
339    } else {
340        Size { width, height }
341    };
342
343    let layout_output = tree.perform_child_layout(
344        node,
345        size,
346        grid_area_size.map(Option::Some),
347        grid_area_minus_item_margins_size.map(AvailableSpace::Definite),
348        SizingMode::InherentSize,
349        Line::FALSE,
350    );
351
352    // Resolve final size
353    let Size { width, height } = size.unwrap_or(layout_output.size).maybe_clamp(min_size, max_size);
354
355    let (x, x_margin) = align_item_within_area(
356        Line { start: grid_area.left, end: grid_area.right },
357        justify_self.unwrap_or(alignment_styles.horizontal),
358        width,
359        position,
360        inset_horizontal,
361        margin.horizontal_components(),
362        0.0,
363        direction,
364    );
365    let (y, y_margin) = align_item_within_area(
366        Line { start: grid_area.top, end: grid_area.bottom },
367        align_self.unwrap_or(alignment_styles.vertical),
368        height,
369        position,
370        inset_vertical,
371        margin.vertical_components(),
372        baseline_shim,
373        Direction::Ltr,
374    );
375
376    let scrollbar_size = Size {
377        width: if overflow.y == Overflow::Scroll { scrollbar_width } else { 0.0 },
378        height: if overflow.x == Overflow::Scroll { scrollbar_width } else { 0.0 },
379    };
380
381    let resolved_margin = Rect { left: x_margin.start, right: x_margin.end, top: y_margin.start, bottom: y_margin.end };
382
383    tree.set_unrounded_layout(
384        node,
385        &Layout {
386            order,
387            location: Point { x, y },
388            size: Size { width, height },
389            #[cfg(feature = "content_size")]
390            scrollable_overflow_rect: layout_output.scrollable_overflow_rect,
391            scrollbar_size,
392            padding,
393            border,
394            margin: resolved_margin,
395        },
396    );
397
398    #[cfg(feature = "content_size")]
399    let contribution = {
400        // Contributions to the container's scrollable overflow rect are measured from the
401        // container's padding-box origin (mirrored for RTL), matching the scrollable overflow region.
402        let contribution_location = if direction.is_rtl() {
403            Point { x: container_border_box_width - (x + width) - container_border.right, y: y - container_border.top }
404        } else {
405            Point { x: x - container_border.left, y: y - container_border.top }
406        };
407        compute_scrollable_overflow_contribution(
408            contribution_location,
409            Size { width, height },
410            layout_output.scrollable_overflow_rect,
411            overflow,
412            contain,
413            container_is_scroll_container,
414        )
415    };
416    #[cfg(not(feature = "content_size"))]
417    let contribution = Rect::ZERO;
418
419    (contribution, y, height)
420}
421
422/// Align and size a grid item along a single axis
423#[allow(clippy::too_many_arguments)]
424pub(super) fn align_item_within_area(
425    grid_area: Line<f32>,
426    alignment_style: AlignSelf,
427    resolved_size: f32,
428    position: Position,
429    inset: Line<Option<f32>>,
430    margin: Line<Option<f32>>,
431    baseline_shim: f32,
432    direction: Direction,
433) -> (f32, Line<f32>) {
434    // Calculate grid area dimension in the axis
435    let non_auto_margin = Line { start: margin.start.unwrap_or(0.0) + baseline_shim, end: margin.end.unwrap_or(0.0) };
436    let grid_area_size = f32_max(grid_area.end - grid_area.start, 0.0);
437    let free_space = f32_max(grid_area_size - resolved_size - non_auto_margin.sum(), 0.0);
438
439    // Expand auto margins to fill available space
440    let auto_margin_count = margin.start.is_none() as u8 + margin.end.is_none() as u8;
441    let auto_margin_size = if auto_margin_count > 0 { free_space / auto_margin_count as f32 } else { 0.0 };
442    let resolved_margin = Line {
443        start: margin.start.unwrap_or(auto_margin_size) + baseline_shim,
444        end: margin.end.unwrap_or(auto_margin_size),
445    };
446
447    let overflows = resolved_size + non_auto_margin.sum() > grid_area_size;
448    let alignment_keyword = resolve_self_alignment_safety(alignment_style, overflows);
449
450    // Compute offset in the axis
451    let alignment_based_offset = match alignment_keyword {
452        // TODO: Add support for baseline alignment. For now we treat it as "start".
453        AlignItemsKeyword::Start
454        | AlignItemsKeyword::FlexStart
455        | AlignItemsKeyword::Baseline
456        | AlignItemsKeyword::Stretch => {
457            if direction.is_rtl() {
458                grid_area_size - resolved_size - resolved_margin.end
459            } else {
460                resolved_margin.start
461            }
462        }
463        AlignItemsKeyword::End | AlignItemsKeyword::FlexEnd => {
464            if direction.is_rtl() {
465                resolved_margin.start
466            } else {
467                grid_area_size - resolved_size - resolved_margin.end
468            }
469        }
470        AlignItemsKeyword::Center => {
471            (grid_area_size - resolved_size + resolved_margin.start - resolved_margin.end) / 2.0
472        }
473        // SelfStart/SelfEnd are resolved to Start/End against the item's own direction in
474        // `align_and_position_item`.
475        AlignItemsKeyword::SelfStart | AlignItemsKeyword::SelfEnd => unreachable!(),
476    };
477
478    let offset_within_area = if position == Position::Absolute {
479        match (inset.start, inset.end) {
480            (Some(start), Some(end)) => {
481                if direction.is_rtl() {
482                    grid_area_size - end - resolved_size - non_auto_margin.end
483                } else {
484                    start + non_auto_margin.start
485                }
486            }
487            (Some(start), None) => start + non_auto_margin.start,
488            (None, Some(end)) => grid_area_size - end - resolved_size - non_auto_margin.end,
489            (None, None) => alignment_based_offset,
490        }
491    } else {
492        alignment_based_offset
493    };
494
495    let mut start = grid_area.start + offset_within_area;
496    if position == Position::Relative {
497        let relative_inset = if direction.is_rtl() {
498            inset.end.map(|pos| -pos).or(inset.start)
499        } else {
500            inset.start.or(inset.end.map(|pos| -pos))
501        };
502        start += relative_inset.unwrap_or(0.0);
503    }
504
505    (start, resolved_margin)
506}