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::content_size::compute_content_size_contribution;
17use crate::{BoxSizing, Direction, LayoutGridContainer};
18
19/// Align the grid tracks within the grid according to the align-content (rows) or
20/// justify-content (columns) property. This only does anything if the size of the
21/// grid is not equal to the size of the grid container in the axis being aligned.
22pub(super) fn align_tracks(
23    grid_container_content_box_size: f32,
24    padding: Line<f32>,
25    border: Line<f32>,
26    tracks: &mut [GridTrack],
27    track_alignment_style: AlignContent,
28    axis_is_reversed: bool,
29) {
30    let used_size: f32 = tracks.iter().map(|track| track.base_size).sum();
31    let free_space = grid_container_content_box_size - used_size;
32    let origin = padding.start + border.start;
33
34    // Count the number of non-collapsed tracks (not counting gutters)
35    let num_tracks = tracks.iter().skip(1).step_by(2).filter(|track| !track.is_collapsed).count();
36
37    // Grid layout treats gaps as full tracks rather than applying them at alignment so we
38    // simply pass zero here. Grid layout is never reversed.
39    let gap = 0.0;
40    let layout_is_reversed = false;
41    let track_alignment = apply_alignment_fallback(free_space, num_tracks, track_alignment_style);
42    let track_alignment = if axis_is_reversed { track_alignment.reversed() } else { track_alignment };
43
44    // If every track is collapsed then no track receives the alignment offset below, but the
45    // grid's lines should still be aligned within the container (e.g. at the inline-start edge
46    // for RTL), so apply the offset to the origin instead.
47    let empty_grid_offset = if num_tracks == 0 {
48        compute_alignment_offset(free_space, num_tracks, gap, track_alignment, layout_is_reversed, true)
49    } else {
50        0.0
51    };
52
53    // Compute offsets
54    let mut total_offset = origin + empty_grid_offset;
55    let mut seen_non_collapsed_track = false;
56    tracks.iter_mut().enumerate().for_each(|(i, track)| {
57        // Odd tracks are gutters (but slices are zero-indexed, so odd tracks have even indices)
58        let is_gutter = i % 2 == 0;
59        let is_non_collapsed_track = !is_gutter && !track.is_collapsed;
60
61        // Alignment offsets should be applied only to non-collapsed tracks.
62        let is_first = is_non_collapsed_track && !seen_non_collapsed_track;
63
64        let offset = if is_non_collapsed_track {
65            compute_alignment_offset(free_space, num_tracks, gap, track_alignment, layout_is_reversed, is_first)
66        } else {
67            0.0
68        };
69
70        track.offset = total_offset + offset;
71        total_offset = total_offset + offset + track.base_size;
72        if is_non_collapsed_track {
73            seen_non_collapsed_track = true;
74        }
75    });
76}
77
78/// Align and size a grid item into it's final position
79#[allow(clippy::too_many_arguments)]
80pub(super) fn align_and_position_item(
81    tree: &mut impl LayoutGridContainer,
82    node: NodeId,
83    order: u32,
84    grid_area: Rect<f32>,
85    container_alignment_styles: InBothAbsAxis<Option<AlignItems>>,
86    baseline_shim: f32,
87    direction: Direction,
88    container_border_box_width: f32,
89    container_border: Rect<f32>,
90) -> (Size<f32>, f32, f32) {
91    let grid_area_size = Size { width: grid_area.right - grid_area.left, height: grid_area.bottom - grid_area.top };
92
93    let style = tree.get_grid_child_style(node);
94
95    let overflow = style.overflow();
96    let scrollbar_width = style.scrollbar_width();
97    let aspect_ratio = style.aspect_ratio();
98    // Resolve writing-mode-relative self-start/self-end keywords against the item's own
99    // direction. The horizontal axis is the inline axis (Taffy only supports horizontal-tb);
100    // the vertical (block) axis resolves them to plain start/end.
101    let item_direction = style.direction();
102    let justify_self = style.justify_self().map(|align| align.resolve_self_relative(item_direction, direction, true));
103    let align_self = style.align_self().map(|align| align.resolve_self_relative(item_direction, direction, false));
104    let container_alignment_styles = InBothAbsAxis {
105        horizontal: container_alignment_styles
106            .horizontal
107            .map(|align| align.resolve_self_relative(item_direction, direction, true)),
108        vertical: container_alignment_styles
109            .vertical
110            .map(|align| align.resolve_self_relative(item_direction, direction, false)),
111    };
112
113    let position = style.position();
114    let inset_horizontal = style
115        .inset()
116        .horizontal_components()
117        .map(|size| size.resolve_to_option(grid_area_size.width, |val, basis| tree.calc(val, basis)));
118    let inset_vertical = style
119        .inset()
120        .vertical_components()
121        .map(|size| size.resolve_to_option(grid_area_size.height, |val, basis| tree.calc(val, basis)));
122    let padding =
123        style.padding().map(|p| p.resolve_or_zero(Some(grid_area_size.width), |val, basis| tree.calc(val, basis)));
124    let border =
125        style.border().map(|p| p.resolve_or_zero(Some(grid_area_size.width), |val, basis| tree.calc(val, basis)));
126    let padding_border_size = (padding + border).sum_axes();
127
128    let box_sizing_adjustment =
129        if style.box_sizing() == BoxSizing::ContentBox { padding_border_size } else { Size::ZERO };
130
131    let inherent_size = style
132        .size()
133        .maybe_resolve(grid_area_size, |val, basis| tree.calc(val, basis))
134        .maybe_apply_aspect_ratio(aspect_ratio)
135        .maybe_add(box_sizing_adjustment);
136    let min_size = style
137        .min_size()
138        .maybe_resolve(grid_area_size, |val, basis| tree.calc(val, basis))
139        .maybe_add(box_sizing_adjustment)
140        .or(padding_border_size.map(Some))
141        .maybe_max(padding_border_size)
142        .maybe_apply_aspect_ratio(aspect_ratio);
143    let max_size = style
144        .max_size()
145        .maybe_resolve(grid_area_size, |val, basis| tree.calc(val, basis))
146        .maybe_apply_aspect_ratio(aspect_ratio)
147        .maybe_add(box_sizing_adjustment);
148
149    // Resolve default alignment styles if they are set on neither the parent or the node itself
150    // Note: if the child has a preferred aspect ratio but neither width or height are set, then the width is stretched
151    // and the then height is calculated from the width according the aspect ratio
152    // See: https://www.w3.org/TR/css-grid-1/#grid-item-sizing
153    let alignment_styles = InBothAbsAxis {
154        horizontal: justify_self.or(container_alignment_styles.horizontal).unwrap_or_else(|| {
155            if inherent_size.width.is_some() {
156                AlignSelf::START
157            } else {
158                AlignSelf::STRETCH
159            }
160        }),
161        vertical: align_self.or(container_alignment_styles.vertical).unwrap_or_else(|| {
162            if inherent_size.height.is_some() || aspect_ratio.is_some() {
163                AlignSelf::START
164            } else {
165                AlignSelf::STRETCH
166            }
167        }),
168    };
169
170    // Note: This is not a bug. It is part of the CSS spec that both horizontal and vertical margins
171    // resolve against the WIDTH of the grid area.
172    let margin =
173        style.margin().map(|margin| margin.resolve_to_option(grid_area_size.width, |val, basis| tree.calc(val, basis)));
174
175    let grid_area_minus_item_margins_size = Size {
176        width: grid_area_size.width.maybe_sub(margin.left).maybe_sub(margin.right),
177        height: grid_area_size.height.maybe_sub(margin.top).maybe_sub(margin.bottom) - baseline_shim,
178    };
179
180    // If node is absolutely positioned and width is not set explicitly, then deduce it
181    // from left, right and container_content_box if both are set.
182    let width = inherent_size.width.or_else(|| {
183        // Apply width derived from both the left and right properties of an absolutely
184        // positioned element being set
185        if position == Position::Absolute {
186            if let (Some(left), Some(right)) = (inset_horizontal.start, inset_horizontal.end) {
187                return Some(f32_max(grid_area_minus_item_margins_size.width - left - right, 0.0));
188            }
189        }
190
191        // Apply width based on stretch alignment if:
192        //  - Alignment style is "stretch"
193        //  - The node is not absolutely positioned
194        //  - The node does not have auto margins in this axis.
195        if margin.left.is_some()
196            && margin.right.is_some()
197            && alignment_styles.horizontal == AlignSelf::STRETCH
198            && position != Position::Absolute
199        {
200            return Some(grid_area_minus_item_margins_size.width);
201        }
202
203        None
204    });
205
206    // Reapply aspect ratio after stretch and absolute position width adjustments
207    let Size { width, height } = Size { width, height: inherent_size.height }.maybe_apply_aspect_ratio(aspect_ratio);
208
209    let height = height.or_else(|| {
210        if position == Position::Absolute {
211            if let (Some(top), Some(bottom)) = (inset_vertical.start, inset_vertical.end) {
212                return Some(f32_max(grid_area_minus_item_margins_size.height - top - bottom, 0.0));
213            }
214        }
215
216        // Apply height based on stretch alignment if:
217        //  - Alignment style is "stretch"
218        //  - The node is not absolutely positioned
219        //  - The node does not have auto margins in this axis.
220        if margin.top.is_some()
221            && margin.bottom.is_some()
222            && alignment_styles.vertical == AlignSelf::STRETCH
223            && position != Position::Absolute
224        {
225            return Some(grid_area_minus_item_margins_size.height);
226        }
227
228        None
229    });
230    // Reapply aspect ratio after stretch and absolute position height adjustments
231    let Size { width, height } = Size { width, height }.maybe_apply_aspect_ratio(aspect_ratio);
232
233    // Clamp size by min and max width/height
234    let Size { width, height } = Size { width, height }.maybe_clamp(min_size, max_size);
235
236    // Layout node
237    drop(style);
238
239    let size = if position == Position::Absolute && (width.is_none() || height.is_none()) {
240        tree.measure_child_size_both(
241            node,
242            Size { width, height },
243            grid_area_size.map(Option::Some),
244            grid_area_minus_item_margins_size.map(AvailableSpace::Definite),
245            SizingMode::InherentSize,
246            Line::FALSE,
247        )
248        .map(Some)
249    } else {
250        Size { width, height }
251    };
252
253    let layout_output = tree.perform_child_layout(
254        node,
255        size,
256        grid_area_size.map(Option::Some),
257        grid_area_minus_item_margins_size.map(AvailableSpace::Definite),
258        SizingMode::InherentSize,
259        Line::FALSE,
260    );
261
262    // Resolve final size
263    let Size { width, height } = size.unwrap_or(layout_output.size).maybe_clamp(min_size, max_size);
264
265    let (x, x_margin) = align_item_within_area(
266        Line { start: grid_area.left, end: grid_area.right },
267        justify_self.unwrap_or(alignment_styles.horizontal),
268        width,
269        position,
270        inset_horizontal,
271        margin.horizontal_components(),
272        0.0,
273        direction,
274    );
275    let (y, y_margin) = align_item_within_area(
276        Line { start: grid_area.top, end: grid_area.bottom },
277        align_self.unwrap_or(alignment_styles.vertical),
278        height,
279        position,
280        inset_vertical,
281        margin.vertical_components(),
282        baseline_shim,
283        Direction::Ltr,
284    );
285
286    let scrollbar_size = Size {
287        width: if overflow.y == Overflow::Scroll { scrollbar_width } else { 0.0 },
288        height: if overflow.x == Overflow::Scroll { scrollbar_width } else { 0.0 },
289    };
290
291    let resolved_margin = Rect { left: x_margin.start, right: x_margin.end, top: y_margin.start, bottom: y_margin.end };
292
293    tree.set_unrounded_layout(
294        node,
295        &Layout {
296            order,
297            location: Point { x, y },
298            size: Size { width, height },
299            #[cfg(feature = "content_size")]
300            content_size: layout_output.content_size,
301            scrollbar_size,
302            padding,
303            border,
304            margin: resolved_margin,
305        },
306    );
307
308    #[cfg(feature = "content_size")]
309    let contribution = {
310        // Contributions to the container's content size are measured from the container's
311        // padding-box origin (mirrored for RTL), matching the scrollable overflow region.
312        let contribution_location = if direction.is_rtl() {
313            Point { x: container_border_box_width - (x + width) - container_border.right, y: y - container_border.top }
314        } else {
315            Point { x: x - container_border.left, y: y - container_border.top }
316        };
317        compute_content_size_contribution(
318            contribution_location,
319            Size { width, height },
320            layout_output.content_size,
321            overflow,
322        )
323    };
324    #[cfg(not(feature = "content_size"))]
325    let contribution = Size::ZERO;
326
327    (contribution, y, height)
328}
329
330/// Align and size a grid item along a single axis
331#[allow(clippy::too_many_arguments)]
332pub(super) fn align_item_within_area(
333    grid_area: Line<f32>,
334    alignment_style: AlignSelf,
335    resolved_size: f32,
336    position: Position,
337    inset: Line<Option<f32>>,
338    margin: Line<Option<f32>>,
339    baseline_shim: f32,
340    direction: Direction,
341) -> (f32, Line<f32>) {
342    // Calculate grid area dimension in the axis
343    let non_auto_margin = Line { start: margin.start.unwrap_or(0.0) + baseline_shim, end: margin.end.unwrap_or(0.0) };
344    let grid_area_size = f32_max(grid_area.end - grid_area.start, 0.0);
345    let free_space = f32_max(grid_area_size - resolved_size - non_auto_margin.sum(), 0.0);
346
347    // Expand auto margins to fill available space
348    let auto_margin_count = margin.start.is_none() as u8 + margin.end.is_none() as u8;
349    let auto_margin_size = if auto_margin_count > 0 { free_space / auto_margin_count as f32 } else { 0.0 };
350    let resolved_margin = Line {
351        start: margin.start.unwrap_or(auto_margin_size) + baseline_shim,
352        end: margin.end.unwrap_or(auto_margin_size),
353    };
354
355    let overflows = resolved_size + non_auto_margin.sum() > grid_area_size;
356    let alignment_keyword = resolve_self_alignment_safety(alignment_style, overflows);
357
358    // Compute offset in the axis
359    let alignment_based_offset = match alignment_keyword {
360        // TODO: Add support for baseline alignment. For now we treat it as "start".
361        AlignItemsKeyword::Start
362        | AlignItemsKeyword::FlexStart
363        | AlignItemsKeyword::Baseline
364        | AlignItemsKeyword::Stretch => {
365            if direction.is_rtl() {
366                grid_area_size - resolved_size - resolved_margin.end
367            } else {
368                resolved_margin.start
369            }
370        }
371        AlignItemsKeyword::End | AlignItemsKeyword::FlexEnd => {
372            if direction.is_rtl() {
373                resolved_margin.start
374            } else {
375                grid_area_size - resolved_size - resolved_margin.end
376            }
377        }
378        AlignItemsKeyword::Center => {
379            (grid_area_size - resolved_size + resolved_margin.start - resolved_margin.end) / 2.0
380        }
381        // SelfStart/SelfEnd are resolved to Start/End against the item's own direction in
382        // `align_and_position_item`.
383        AlignItemsKeyword::SelfStart | AlignItemsKeyword::SelfEnd => unreachable!(),
384    };
385
386    let offset_within_area = if position == Position::Absolute {
387        match (inset.start, inset.end) {
388            (Some(start), Some(end)) => {
389                if direction.is_rtl() {
390                    grid_area_size - end - resolved_size - non_auto_margin.end
391                } else {
392                    start + non_auto_margin.start
393                }
394            }
395            (Some(start), None) => start + non_auto_margin.start,
396            (None, Some(end)) => grid_area_size - end - resolved_size - non_auto_margin.end,
397            (None, None) => alignment_based_offset,
398        }
399    } else {
400        alignment_based_offset
401    };
402
403    let mut start = grid_area.start + offset_within_area;
404    if position == Position::Relative {
405        let relative_inset = if direction.is_rtl() {
406            inset.end.map(|pos| -pos).or(inset.start)
407        } else {
408            inset.start.or(inset.end.map(|pos| -pos))
409        };
410        start += relative_inset.unwrap_or(0.0);
411    }
412
413    (start, resolved_margin)
414}