diff --git a/moli-layout/src/table.rs b/moli-layout/src/table.rs index 904f08a3c..948d44d77 100644 --- a/moli-layout/src/table.rs +++ b/moli-layout/src/table.rs @@ -25,8 +25,10 @@ use crate::{ style::{LayoutInlineAlignment, resolve_stylo_calc_value}, }; +mod block; mod collapsed_borders; mod columns; +mod rows; pub(crate) use collapsed_borders::CollapsedTableBorders; use collapsed_borders::{prepare_collapsed_table_borders, set_collapsed_border_geometry}; @@ -45,6 +47,7 @@ struct TableCell { column: usize, row_span: usize, column_span: usize, + block_layout: Option, } #[derive(Clone, Copy)] @@ -52,7 +55,7 @@ struct TableRow { id: LayoutBoxId, group: Option, index: usize, - track: taffy::TrackSizingFunction, + grid_index: usize, } #[derive(Clone, Copy)] @@ -133,8 +136,13 @@ struct TableContext { collapsed_borders: bool, column_count: usize, column_constraints: Vec, + column_sizes: Vec, + sections: Vec, + section_boxes: Vec, + section_tracks: Vec>, layout_mode: TableLayoutMode, inline_border_spacing: f32, + block_border_spacing: f32, writing_mode: WritingMode, } @@ -274,7 +282,31 @@ where { let mut context = build_table_context(world, root); context.collect_cell_inline_constraints(world); - let grid_inputs = context.resolve_column_tracks(inputs); + let mut grid_inputs = context.resolve_column_tracks(inputs); + let allocated_wrapper = world.boxes[root.index()] + .layout_parent + .is_some_and(|parent| { + let display = world.boxes[parent.index()].style.display(); + display.is_flex_container() || display.is_grid_container() + }); + if allocated_wrapper && !context.captions.is_empty() { + let mut space = grid_inputs.constraint_space(context.writing_mode); + if let Some(block_size) = space.known_size.block_size { + // Flex/Grid allocate the complete wrapper. Caption space must not + // be allocated to the table grid a second time. + let captions = layout_captions( + world, + &context.captions, + space.known_size.inline_size.unwrap_or(0.0), + 0.0, + context.writing_mode, + RunMode::ComputeSize, + ); + space.known_size.block_size = Some((block_size - captions).max(0.0)); + grid_inputs = space.into_layout_input(); + } + } + let grid_inputs = context.resolve_row_tracks(world, grid_inputs); let mut output = { let mut wrapper = TableTreeWrapper { world, @@ -283,7 +315,7 @@ where compute_grid_layout(&mut wrapper, NodeId::from(0usize), grid_inputs) }; - if inputs.run_mode == RunMode::PerformLayout { + { let caption_parent_writing_mode = world.boxes[root.index()].style.writing_mode(); let top_captions = context .captions @@ -303,16 +335,21 @@ where output.size.width, 0.0, caption_parent_writing_mode, + inputs.run_mode, ); - shift_grid_children(world, &context.cells, top_height); let bottom_height = layout_captions( world, &bottom_captions, output.size.width, top_height + output.size.height, caption_parent_writing_mode, + inputs.run_mode, ); - apply_structural_layout(world, root, &context, top_height, output.size); + if inputs.run_mode == RunMode::PerformLayout { + context.align_row_baselines(world, &mut output); + shift_grid_children(world, &context.cells, top_height); + apply_structural_layout(world, root, &context, top_height, output.size); + } if let Some(first_baseline) = &mut output.first_baselines.y { *first_baseline += top_height; } @@ -351,6 +388,8 @@ where let mut columns = Vec::new(); let mut max_columns = 0usize; let mut column_tracks = Vec::new(); + let mut sections = Vec::new(); + let mut section_boxes = Vec::new(); let layout_mode = if root_style.uses_fixed_table_layout() { TableLayoutMode::Fixed } else { @@ -361,7 +400,31 @@ where collect_columns(world, column, None, &mut columns, &mut column_tracks); } for section in grouped_children.sections() { + let start = rows.len(); collect_rows(world, section, None, &mut rows, &mut cells); + { + let dimension = writing_mode + .to_logical(world.boxes[section.index()].style.taffy.size) + .block_size; + let is_group = matches!( + world.boxes[section.index()].kind, + LayoutBoxKind::TableRowGroup + | LayoutBoxKind::TableHeaderGroup + | LayoutBoxKind::TableFooterGroup + | LayoutBoxKind::AnonymousTableRowGroup + ); + sections.push(rows::SectionConstraint { + rows: start..rows.len(), + fixed: (is_group && dimension.tag() == taffy::CompactLength::LENGTH_TAG) + .then(|| dimension.value().max(0.0)), + percent: (is_group && dimension.tag() == taffy::CompactLength::PERCENT_TAG) + .then(|| dimension.value().max(0.0)), + is_body: Some(section) != grouped_children.header + && Some(section) != grouped_children.footer, + size: 0.0, + }); + section_boxes.push(section); + } } place_table_cells(&mut cells, &rows, &mut max_columns); max_columns = max_columns.max(column_tracks.len()).max(1); @@ -376,7 +439,7 @@ where end: style_helpers::span(cell.row_span as u16), }; clear_table_cell_inline_sizing(&mut cell.style, writing_mode); - normalize_table_cell_block_sizing(&mut cell.style, writing_mode); + block::clear_cell_block_sizing(&mut cell.style, writing_mode); } let placeholder_track: taffy::TrackSizingFunction = style_helpers::auto(); style.grid_template_columns = @@ -384,7 +447,7 @@ where style.grid_template_rows = if rows.is_empty() { vec![style_helpers::auto()] } else { - rows.iter().map(|row| row.track.into()).collect() + vec![style_helpers::auto(); rows.len()] }; style.gap = Size { width: style_helpers::length(spacing.width), @@ -411,8 +474,13 @@ where collapsed_borders: collapsed, column_count: max_columns, column_constraints: column_tracks, + column_sizes: Vec::new(), + sections, + section_boxes, + section_tracks: Vec::new(), layout_mode, inline_border_spacing: spacing.width, + block_border_spacing: spacing.height, writing_mode, } } @@ -518,12 +586,13 @@ impl TableContext { ) }; self.style.grid_template_columns = column_sizes - .into_iter() - .map(|size| { + .iter() + .map(|&size| { let track: taffy::TrackSizingFunction = style_helpers::length(size); track.into() }) .collect(); + self.column_sizes = column_sizes; let numeric_inline_size = if self.style.box_sizing == taffy::BoxSizing::ContentBox { (used_inline_size - inline_insets).max(0.0) @@ -718,9 +787,7 @@ fn collect_rows( id: current, group, index: row_index, - track: minimum_dimension_track( - world.boxes[current.index()].style.taffy.size.height, - ), + grid_index: row_index, }); for cell in world.boxes[current.index()].children.iter().copied() { if !matches!( @@ -752,6 +819,7 @@ fn collect_rows( column: 0, row_span, column_span, + block_layout: None, }); } } @@ -848,20 +916,6 @@ fn dimension_track(dimension: Dimension) -> TableColumnConstraint { } } -fn minimum_dimension_track(dimension: Dimension) -> taffy::TrackSizingFunction { - match dimension.tag() { - taffy::CompactLength::LENGTH_TAG => style_helpers::minmax( - style_helpers::length(dimension.value()), - style_helpers::auto(), - ), - taffy::CompactLength::PERCENT_TAG => style_helpers::minmax( - style_helpers::percent(dimension.value()), - style_helpers::auto(), - ), - _ => style_helpers::auto(), - } -} - fn authored_table_cell_inline_constraint( style: &Style, table_writing_mode: WritingMode, @@ -1047,22 +1101,6 @@ fn clear_table_cell_inline_sizing(style: &mut Style, writing_mode: Writing set_physical_inline_dimension(writing_mode, &mut style.max_size, Dimension::auto()); } -fn normalize_table_cell_block_sizing(style: &mut Style, writing_mode: WritingMode) { - let size = writing_mode.to_logical(style.size).block_size; - let min_size = writing_mode.to_logical(style.min_size).block_size; - if writing_mode.is_horizontal() { - if min_size.is_auto() { - style.min_size.height = size; - } - style.size.height = Dimension::auto(); - } else { - if min_size.is_auto() { - style.min_size.width = size; - } - style.size.width = Dimension::auto(); - } -} - fn set_physical_inline_dimension( writing_mode: WritingMode, size: &mut Size, @@ -1081,6 +1119,7 @@ fn layout_captions( width: f32, mut y: f32, parent_writing_mode: WritingMode, + run_mode: RunMode, ) -> f32 where N: Copy + Debug + Eq + Hash, @@ -1112,20 +1151,22 @@ where }, sizing_mode: SizingMode::InherentSize, sizing_purpose: SizingPurpose::Layout, - run_mode: RunMode::PerformLayout, + run_mode, axis: taffy::RequestedAxis::Both, block_auto_behavior: AutoSizeBehavior::FitContent, vertical_margins_are_collapsible: Line::FALSE, }; let output = world.compute_child_layout(caption.to_taffy(), inputs); - set_box_layout( - world, - caption, - Point { x: margin.left, y }, - output, - order, - Some(width), - ); + if run_mode == RunMode::PerformLayout { + set_box_layout( + world, + caption, + Point { x: margin.left, y }, + output, + order, + Some(width), + ); + } y += output.size.height + margin.bottom; } y - start @@ -1169,9 +1210,50 @@ fn apply_structural_layout( let row_starts = track_starts(origin.y, &detailed.rows.sizes, &detailed.rows.gutters); let column_starts = track_starts(origin.x, &detailed.columns.sizes, &detailed.columns.gutters); let content_width = track_extent(&detailed.columns.sizes, &detailed.columns.gutters); - let content_height = track_extent(&detailed.rows.sizes, &detailed.rows.gutters); + // Empty tables retain spacing in their intrinsic inline contribution, + // while their row/group rectangles span the content box when no real + // columns exist. Keep that distinction out of column sizing. + let empty_inline_spacing = if context.cells.is_empty() && context.columns.is_empty() { + context.inline_border_spacing + } else { + 0.0 + }; + let part_x = origin.x - empty_inline_spacing; + let part_width = content_width + 2.0 * empty_inline_spacing; + let occupied_sections = || { + context + .sections + .iter() + .zip(&context.section_tracks) + .filter(|(section, _)| !section.rows.is_empty() || section.size > 0.0) + }; + let first_section_track = occupied_sections() + .next() + .map_or(0, |(_, range)| range.start); + let last_section_track = occupied_sections() + .next_back() + .map_or(detailed.rows.sizes.len(), |(_, range)| range.end); + let content_top = row_starts + .get(first_section_track) + .copied() + .unwrap_or(origin.y); + let content_height = track_range_extent( + &detailed.rows.sizes, + &detailed.rows.gutters, + first_section_track, + last_section_track, + ); if context.collapsed_borders { - let mut row_lines = row_starts.clone(); + // Border conflicts are indexed by actual rows. Empty-section tracks + // occupy space but must not change the conflict grid's row count. + let mut row_lines: Vec<_> = context + .rows + .iter() + .map(|row| row_starts[row.grid_index]) + .collect(); + if let Some(first) = row_lines.first_mut() { + *first = origin.y; + } row_lines.push(origin.y + content_height); let mut column_lines = column_starts.clone(); column_lines.push(origin.x + content_width); @@ -1179,31 +1261,33 @@ fn apply_structural_layout( } for row in &context.rows { - let y = row_starts.get(row.index).copied().unwrap_or(origin.y); - let height = detailed.rows.sizes.get(row.index).copied().unwrap_or(0.0); - set_table_part_layout(world, row.id, origin.x, y, content_width, height); + let y = row_starts.get(row.grid_index).copied().unwrap_or(origin.y); + let height = detailed + .rows + .sizes + .get(row.grid_index) + .copied() + .unwrap_or(0.0); + set_table_part_layout(world, row.id, part_x, y, part_width, height); } - let mut groups = context - .rows - .iter() - .filter_map(|row| row.group) - .collect::>(); - groups.sort_by_key(|id| id.index()); - groups.dedup(); - for group in groups { - let group_rows = context.rows.iter().filter(|row| row.group == Some(group)); - let mut start = usize::MAX; - let mut end = 0usize; - for row in group_rows { - start = start.min(row.index); - end = end.max(row.index + 1); - } - if start != usize::MAX { - let y = row_starts.get(start).copied().unwrap_or(origin.y); - let height = - track_range_extent(&detailed.rows.sizes, &detailed.rows.gutters, start, end); - set_table_part_layout(world, group, origin.x, y, content_width, height); + for (&group, tracks) in context.section_boxes.iter().zip(&context.section_tracks) { + if matches!( + world.boxes[group.index()].kind, + LayoutBoxKind::TableRow | LayoutBoxKind::AnonymousTableRow + ) { + continue; } + let y = row_starts + .get(tracks.start) + .copied() + .unwrap_or(origin.y + content_height); + let height = track_range_extent( + &detailed.rows.sizes, + &detailed.rows.gutters, + tracks.start, + tracks.end, + ); + set_table_part_layout(world, group, part_x, y, part_width, height); } for column in &context.columns { let x = column_starts.get(column.start).copied().unwrap_or(origin.x); @@ -1213,7 +1297,7 @@ fn apply_structural_layout( column.start, column.start.saturating_add(column.span), ); - set_table_part_layout(world, column.id, x, origin.y, width, content_height); + set_table_part_layout(world, column.id, x, content_top, width, content_height); } let mut column_groups = context .columns @@ -1241,7 +1325,7 @@ fn apply_structural_layout( start, end, ); - set_table_part_layout(world, group, x, origin.y, width, content_height); + set_table_part_layout(world, group, x, content_top, width, content_height); } } @@ -1451,19 +1535,26 @@ where fn compute_child_layout(&mut self, node_id: NodeId, inputs: LayoutInput) -> LayoutOutput { let cell_index = usize::from(node_id); + let layout = self.context.cells[cell_index].block_layout; + let mode = self.context.writing_mode; // The virtual table grid owns the used grid-item style: margins are // zero, column sizing has consumed every applicable inline constraint, // and cell block size is a minimum contribution. - self.with_grid_cell_style(cell_index, |world, cell| { - world.compute_child_layout(cell.to_taffy(), inputs) - }) + let output = self.with_grid_cell_style(cell_index, |world, cell| { + block::layout_cell(world, cell, inputs, mode, layout) + }); + if inputs.run_mode == RunMode::PerformLayout + && let Some(layout) = &mut self.context.cells[cell_index].block_layout + && layout.baseline.is_some() + { + layout.baseline = output.first_baselines.y; + } + output } fn compute_child_size(&mut self, node_id: NodeId, inputs: LayoutInput) -> IntrinsicSizeResult { - let cell_index = usize::from(node_id); - self.with_grid_cell_style(cell_index, |world, cell| { - world.compute_child_size(cell.to_taffy(), inputs) - }) + self.compute_child_layout(node_id, inputs) + .into_intrinsic_size_result() } } @@ -1496,6 +1587,127 @@ where mod tests { use super::*; + #[test] + fn row_group_and_caption_sizes_agree_in_cold_measurement_and_warm_layout() { + use crate::{LayoutDisplay, PaintColor, ResolvedLayoutStyle}; + + let make_box = |kind, display, height: Option| { + LayoutWorld::::new_box( + None, + None, + None, + "table-height-test".into(), + None, + None, + None, + kind, + ResolvedLayoutStyle::synthetic( + display, + Style { + size: Size { + width: Dimension::length(200.0), + height: height.map_or(Dimension::auto(), Dimension::length), + }, + ..Style::default() + }, + PaintColor::TRANSPARENT, + ), + None, + ) + }; + let mut world = LayoutWorld::new( + make_box(LayoutBoxKind::TableWrapper, LayoutDisplay::Table, None), + false, + ); + let root = world.root(); + let caption = world.allocate(make_box( + LayoutBoxKind::TableCaption, + LayoutDisplay::TableCaption, + Some(20.0), + )); + let group = world.allocate(make_box( + LayoutBoxKind::TableRowGroup, + LayoutDisplay::TableRowGroup, + Some(80.0), + )); + world.boxes[root.index()].children = vec![caption, group]; + let mut cells = Vec::new(); + for height in [10.0, 30.0] { + let row = world.allocate(make_box( + LayoutBoxKind::TableRow, + LayoutDisplay::TableRow, + None, + )); + let cell = world.allocate(make_box( + LayoutBoxKind::TableCell, + LayoutDisplay::TableCell, + Some(height), + )); + world.boxes[group.index()].children.push(row); + world.boxes[row.index()].children.push(cell); + cells.push(cell); + } + prepare_table_layout_trees(&mut world); + let inputs = LayoutInput { + known_dimensions: Size { + width: Some(200.0), + height: None, + }, + definite_dimensions: Size { + width: Some(200.0), + height: None, + }, + parent_size: Size { + width: Some(200.0), + height: None, + }, + parent_writing_mode: WritingMode::HorizontalTb, + available_space: Size { + width: AvailableSpace::Definite(200.0), + height: AvailableSpace::MaxContent, + }, + run_mode: RunMode::ComputeSize, + sizing_mode: SizingMode::InherentSize, + sizing_purpose: SizingPurpose::Layout, + axis: RequestedAxis::Both, + block_auto_behavior: AutoSizeBehavior::FitContent, + vertical_margins_are_collapsible: Line::FALSE, + }; + let cold = world.compute_child_layout(root.to_taffy(), inputs); + assert_eq!( + cold.size, + Size { + width: 200.0, + height: 100.0 + } + ); + for id in cells.iter().chain([&caption, &group]) { + assert_eq!(world.boxes[id.index()].unrounded_layout.size, Size::ZERO); + } + let final_layout = world.compute_child_layout( + root.to_taffy(), + LayoutInput { + run_mode: RunMode::PerformLayout, + ..inputs + }, + ); + let warm = world.compute_child_layout(root.to_taffy(), inputs); + assert_eq!(cold.size, final_layout.size); + assert_eq!(cold.size, warm.size); + assert_eq!( + world.boxes[group.index()].unrounded_layout.size.height, + 80.0 + ); + assert_eq!( + world.boxes[cells[0].index()].unrounded_layout.size.height, + 20.0 + ); + assert_eq!( + world.boxes[cells[1].index()].unrounded_layout.size.height, + 60.0 + ); + } + #[test] fn percentage_dependent_fixed_table_has_unbounded_parent_max_content_size() { let grid = columns::TableGridInlineMinMax { min: 4.0, max: 4.0 }; diff --git a/moli-layout/src/table/block.rs b/moli-layout/src/table/block.rs new file mode 100644 index 000000000..b62eb7592 --- /dev/null +++ b/moli-layout/src/table/block.rs @@ -0,0 +1,536 @@ +//! Measure cells at resolved column widths and pass solved rows to Grid. +use super::*; +use rows::{RowConstraint, RowspanConstraint}; + +#[derive(Clone, Copy)] +pub(super) struct CellBlockLayout { + pub size: f32, + pub natural_size: f32, + pub definite: bool, + pub baseline: Option, +} + +pub(super) fn set_block(mode: WritingMode, size: &mut Size, value: T) { + if mode.is_horizontal() { + size.height = value; + } else { + size.width = value; + } +} + +pub(super) fn clear_cell_block_sizing(style: &mut Style, mode: WritingMode) { + set_block(mode, &mut style.size, Dimension::auto()); + set_block(mode, &mut style.min_size, Dimension::auto()); + set_block(mode, &mut style.max_size, Dimension::auto()); +} + +fn fixed(dimension: Dimension) -> Option { + (dimension.tag() == taffy::CompactLength::LENGTH_TAG).then(|| dimension.value().max(0.0)) +} + +fn percent(dimension: Dimension) -> Option { + (dimension.tag() == taffy::CompactLength::PERCENT_TAG).then(|| dimension.value().max(0.0)) +} + +fn block_sum(mode: WritingMode, rect: Rect) -> f32 { + if mode.is_horizontal() { + rect.top + rect.bottom + } else { + rect.left + rect.right + } +} + +impl TableContext { + pub(super) fn align_row_baselines( + &self, + world: &mut LayoutWorld, + output: &mut LayoutOutput, + ) where + N: Copy + Debug + Eq + Hash, + { + let mut baselines: Vec> = vec![None; self.rows.len()]; + for cell in &self.cells { + if let Some(baseline) = cell.block_layout.and_then(|layout| layout.baseline) { + baselines[cell.row] = Some(baselines[cell.row].unwrap_or(0.0).max(baseline)); + } + } + for cell in &self.cells { + if let Some(baseline) = cell.block_layout.and_then(|layout| layout.baseline) { + let offset = baselines[cell.row].unwrap_or(baseline) - baseline; + if offset > 0.0 { + shift_cell_contents(world, cell.id, offset); + } + } + } + if let Some(detailed) = &self.detailed { + let padding = self + .style + .padding + .resolve_or_zero(Some(output.size.width), resolve_stylo_calc_value); + let border = self + .style + .border + .resolve_or_zero(Some(output.size.width), resolve_stylo_calc_value); + let starts = track_starts( + padding.top + border.top, + &detailed.rows.sizes, + &detailed.rows.gutters, + ); + if let Some(Some(baseline)) = baselines.first() { + output.first_baselines.y = Some(starts[self.rows[0].grid_index] + baseline); + } + if let Some(Some(baseline)) = baselines.last() { + output.last_baselines.y = + Some(starts[self.rows.last().unwrap().grid_index] + baseline); + } + } + } + + pub(super) fn resolve_row_tracks( + &mut self, + world: &mut LayoutWorld, + inputs: LayoutInput, + ) -> LayoutInput + where + N: Copy + Debug + Eq + Hash, + { + let mode = self.writing_mode; + // Inline-only intrinsic probes do not need a second cell measurement. + if inputs.run_mode == RunMode::ComputeSize + && inputs.axis == RequestedAxis::from(mode.inline_axis()) + { + return inputs; + } + let mut space = inputs.constraint_space(mode); + let percentage_basis = space.margin_padding_percentage_basis(); + let padding = self + .style + .padding + .resolve_or_zero(percentage_basis, resolve_stylo_calc_value); + let border = self + .style + .border + .resolve_or_zero(percentage_basis, resolve_stylo_calc_value); + let insets = block_sum(mode, padding) + block_sum(mode, border); + let preferred = mode.to_logical(self.style.size).block_size; + let adjustment = if self.style.box_sizing == taffy::BoxSizing::ContentBox { + // Outer cell spacing is projected as Grid padding, but belongs + // inside the CSS table's content box. + insets - 2.0 * self.block_border_spacing + } else { + 0.0 + }; + let resolve = |d: Dimension| { + d.maybe_resolve( + space.percentage_resolution_size.block_size, + resolve_stylo_calc_value, + ) + .map(|v| v + adjustment) + }; + let authored = inputs.sizing_mode == SizingMode::InherentSize; + let target = space + .known_size + .block_size + .or_else(|| authored.then(|| resolve(preferred)).flatten()); + let min = authored + .then(|| resolve(mode.to_logical(self.style.min_size).block_size)) + .flatten(); + let max = authored + .then(|| resolve(mode.to_logical(self.style.max_size).block_size)) + .flatten(); + let target = target + .map(|v| v.min(max.unwrap_or(f32::INFINITY))) + .unwrap_or(0.0) + .max(min.unwrap_or(0.0)); + let mut rows: Vec<_> = self + .rows + .iter() + .map(|row| { + let dimension = mode + .to_logical(world.boxes[row.id.index()].style.taffy.size) + .block_size; + RowConstraint { + size: fixed(dimension).unwrap_or(0.0), + percent: percent(dimension), + constrained: fixed(dimension).is_some() || percent(dimension).is_some(), + ..Default::default() + } + }) + .collect(); + let mut spans = Vec::new(); + for index in 0..self.cells.len() { + let cell = &self.cells[index]; + let authored_style = &world.boxes[cell.id.index()].style.taffy; + let dimension = mode.to_logical(authored_style.size).block_size; + let cell_padding = authored_style + .padding + .resolve_or_zero(space.known_size.inline_size, resolve_stylo_calc_value); + let cell_border = authored_style + .border + .resolve_or_zero(space.known_size.inline_size, resolve_stylo_calc_value); + let cell_insets = block_sum(mode, cell_padding) + block_sum(mode, cell_border); + let css_size = outer_fixed_size(dimension, cell_insets, authored_style.box_sizing); + let cell_percent = percent(dimension); + let baseline_aligned = matches!( + world.boxes[cell.id.index()].style.vertical_align().kind, + LayoutInlineAlignment::Baseline + ) && authored_style.align_content.is_none(); + let inline = self.column_sizes[cell.column..cell.column + cell.column_span] + .iter() + .sum::() + + self.inline_border_spacing * cell.column_span.saturating_sub(1) as f32; + let measure_inputs = LayoutInput { + known_dimensions: mode.to_physical(LogicalSize { + inline_size: Some(inline), + block_size: None, + }), + definite_dimensions: mode.to_physical(LogicalSize { + inline_size: Some(inline), + block_size: None, + }), + parent_size: mode.to_physical(LogicalSize { + inline_size: space.known_size.inline_size, + block_size: None, + }), + parent_writing_mode: mode, + available_space: mode.to_physical(LogicalSize { + inline_size: AvailableSpace::Definite(inline), + block_size: AvailableSpace::MaxContent, + }), + run_mode: RunMode::ComputeSize, + sizing_mode: SizingMode::InherentSize, + sizing_purpose: SizingPurpose::Layout, + axis: RequestedAxis::Both, + block_auto_behavior: AutoSizeBehavior::FitContent, + vertical_margins_are_collapsible: Line::FALSE, + }; + // Percentage padding is relative to the table, not the cell's + // Grid area. Freeze that basis across both measurement and layout. + self.cells[index].style.padding = cell_padding.map(style_helpers::length); + let restricted = !preferred.is_auto() || fixed(dimension).is_some(); + let cell_id = self.cells[index].id; + let restored = + restrict_scrollable_percentage_children(world, cell_id, mode, restricted); + let mut wrapper = TableTreeWrapper { + world, + context: self, + }; + let output = wrapper.with_grid_cell_style(index, |world, cell| { + world.compute_child_layout(cell.to_taffy(), measure_inputs) + }); + for (id, style) in restored { + world.boxes[id.index()].style.taffy = style; + world.cache_clear(id.to_taffy()); + } + let natural = mode.to_logical(output.size).block_size; + let cell = &mut self.cells[index]; + let baseline = if mode.is_horizontal() { + output.first_baselines.y + } else { + output.first_baselines.x + }; + let end_inset = if mode.is_horizontal() { + cell_padding.bottom + cell_border.bottom + } else { + cell_padding.left + cell_border.left + }; + let baseline = (baseline_aligned && !world.boxes[cell.id.index()].children.is_empty()) + .then(|| baseline.unwrap_or((natural - end_inset).max(0.0))); + cell.block_layout = Some(CellBlockLayout { + size: 0.0, + natural_size: natural, + definite: fixed(dimension).is_some(), + baseline, + }); + let row = &mut rows[cell.row]; + if baseline_aligned && !world.boxes[cell.id.index()].children.is_empty() { + row.ascent = Some(row.ascent.unwrap_or(0.0).max(baseline.unwrap_or(0.0))); + if cell.row_span == 1 { + row.descent = row.descent.max(natural - baseline.unwrap_or(0.0)); + } + } + let minimum = natural.max(css_size.unwrap_or(0.0)); + if cell.row_span == 1 { + row.size = row.size.max(minimum); + row.constrained |= css_size.is_some() || cell_percent.is_some(); + if let Some(p) = cell_percent { + row.percent = Some(row.percent.unwrap_or(0.0).max(p)); + } + } else { + row.has_rowspan_start = true; + spans.push(RowspanConstraint { + rows: cell.row..cell.row + cell.row_span, + size: minimum, + }); + } + } + rows::resolve_minimums( + &mut rows, + &mut self.sections, + &mut spans, + self.block_border_spacing, + ); + let nonempty_sections = self + .sections + .iter() + .filter(|section| !section.rows.is_empty()) + .count(); + let minimum = self + .sections + .iter() + .map(|section| section.size) + .sum::() + + nonempty_sections.saturating_sub(1) as f32 * self.block_border_spacing + + insets + - if nonempty_sections == 0 { + 2.0 * self.block_border_spacing + } else { + 0.0 + }; + let used = minimum.max(target); + rows::distribute_table( + &mut rows, + &mut self.sections, + (used - insets).max(0.0), + self.block_border_spacing, + ); + for cell in &mut self.cells { + let layout = cell.block_layout.as_mut().unwrap(); + layout.size = rows[cell.row..cell.row + cell.row_span] + .iter() + .map(|row| row.size) + .sum::() + + cell.row_span.saturating_sub(1) as f32 * self.block_border_spacing; + layout.definite |= !preferred.is_auto() && layout.size > layout.natural_size; + } + let mut tracks = Vec::new(); + // Empty sections take up height, but do not introduce cell spacing. + // With such sections, explicit spacer tracks express the gaps before + // real rows and after the table that a uniform Grid gap cannot model. + let explicit_spacing = (self.rows.is_empty() + || self.sections.iter().any(|section| section.rows.is_empty())) + && self.block_border_spacing > 0.0; + if explicit_spacing { + self.style.gap.height = style_helpers::length(0.0); + self.style.padding.top = + style_helpers::length((padding.top - self.block_border_spacing).max(0.0)); + self.style.padding.bottom = + style_helpers::length((padding.bottom - self.block_border_spacing).max(0.0)); + } + for section in &self.sections { + let mut start = tracks.len(); + if section.rows.is_empty() { + // A section can have height even without a DOM row. Reserve a + // numeric track without creating an anonymous CSS row box. + tracks.push(section.size); + } else { + for index in section.rows.clone() { + if explicit_spacing { + tracks.push(self.block_border_spacing); + if index == section.rows.start { + start += 1; + } + } + self.rows[index].grid_index = tracks.len(); + tracks.push(rows[index].size); + } + } + self.section_tracks.push(start..tracks.len()); + } + if explicit_spacing && !self.rows.is_empty() { + tracks.push(self.block_border_spacing); + } + for cell in &mut self.cells { + cell.style.grid_row.start = style_helpers::line( + (self.rows[cell.row].grid_index + 1).min(i16::MAX as usize) as i16, + ); + cell.style.grid_row.end = style_helpers::span( + (self.rows[cell.row + cell.row_span - 1].grid_index + - self.rows[cell.row].grid_index + + 1) + .min(u16::MAX as usize) as u16, + ); + } + if tracks.is_empty() { + tracks.push( + (used - insets + + if explicit_spacing { + 2.0 * self.block_border_spacing + } else { + 0.0 + }) + .max(0.0), + ); + } + self.style.grid_template_rows = tracks + .into_iter() + .map(|size| { + let track: taffy::TrackSizingFunction = style_helpers::length(size); + track.into() + }) + .collect(); + self.style.align_content = Some(taffy::AlignContent::START); + set_block( + mode, + &mut self.style.size, + style_helpers::length(if self.style.box_sizing == taffy::BoxSizing::ContentBox { + (used - insets + + if explicit_spacing { + 2.0 * self.block_border_spacing + } else { + 0.0 + }) + .max(0.0) + } else { + used + }), + ); + set_block(mode, &mut self.style.min_size, Dimension::auto()); + set_block(mode, &mut self.style.max_size, Dimension::auto()); + space.known_size.block_size = Some(used); + // Used geometry alone is not a new percentage-resolution guarantee. + space.into_layout_input() + } +} + +pub(super) fn layout_cell( + world: &mut LayoutWorld, + cell: LayoutBoxId, + mut inputs: LayoutInput, + mode: WritingMode, + layout: Option, +) -> LayoutOutput +where + N: Copy + Debug + Eq + Hash, +{ + let Some(layout) = layout else { + return world.compute_child_layout(cell.to_taffy(), inputs); + }; + set_block(mode, &mut inputs.parent_size, None); + set_block( + mode, + &mut inputs.known_dimensions, + layout.definite.then_some(layout.size), + ); + set_block( + mode, + &mut inputs.definite_dimensions, + layout.definite.then_some(layout.size), + ); + if !layout.definite { + set_block( + mode, + &mut inputs.available_space, + AvailableSpace::MaxContent, + ); + } + let mut output = world.compute_child_layout(cell.to_taffy(), inputs); + let free = (layout.size - mode.to_logical(output.size).block_size).max(0.0); + let alignment = world.boxes[cell.index()].style.taffy.align_content; + let offset = if layout.baseline.is_some() { + 0.0 + } else { + crate::taffy_tree::single_subject_block_alignment_offset(alignment, free) + }; + if inputs.run_mode == RunMode::PerformLayout && offset != 0.0 { + shift_cell_contents(world, cell, offset); + } + if let Some(baseline) = &mut output.first_baselines.y { + *baseline += offset; + } + if let Some(baseline) = &mut output.last_baselines.y { + *baseline += offset; + } + if layout.baseline.is_some() && output.first_baselines.y.is_none() { + // Cells with no line baseline use the content's block-end edge. This + // must be sampled again after percentage descendants have laid out. + let padding = world.boxes[cell.index()] + .style + .taffy + .padding + .resolve_or_zero(inputs.parent_size.width, resolve_stylo_calc_value); + output.first_baselines.y = Some(if inputs.run_mode == RunMode::PerformLayout { + (output.content_size.height - padding.bottom).max(0.0) + } else { + layout.baseline.unwrap_or(0.0) + }); + } + output.content_size.height += offset; + set_block(mode, &mut output.size, layout.size); + output +} + +/// Blink's restricted-cell first pass sizes direct scrollable percentage +/// children to their initial minimum. The normal second pass restores the +/// authored style and resolves the percentage against the final cell height. +fn restrict_scrollable_percentage_children( + world: &mut LayoutWorld, + cell: LayoutBoxId, + mode: WritingMode, + restricted: bool, +) -> Vec<(LayoutBoxId, Style)> +where + N: Copy + Debug + Eq + Hash, +{ + if !restricted { + return Vec::new(); + } + let mut restored = Vec::new(); + for id in world.boxes[cell.index()].layout_children.clone() { + let child = &world.boxes[id.index()]; + let style = &child.style.taffy; + if !child.is_replaced() + && mode + .to_logical(style.size) + .block_size + .may_have_percentage_dependence() + && matches!( + child.style.overflow_modes()[usize::from(mode.is_horizontal())], + crate::style::LayoutOverflowMode::Auto | crate::style::LayoutOverflowMode::Scroll + ) + { + let minimum = mode + .to_logical(style.min_size) + .block_size + .maybe_resolve(None, resolve_stylo_calc_value) + .unwrap_or(0.0); + restored.push((id, style.clone())); + set_block( + mode, + &mut world.boxes[id.index()].style.taffy.size, + Dimension::length(minimum), + ); + world.cache_clear(id.to_taffy()); + } + } + restored +} + +fn shift_cell_contents(world: &mut LayoutWorld, cell: LayoutBoxId, offset: f32) +where + N: Copy + Debug + Eq + Hash, +{ + for child in world.boxes[cell.index()].layout_children.clone() { + if world.boxes[child.index()].style.taffy.position != taffy::Position::Absolute { + world.boxes[child.index()].unrounded_layout.location.y += offset; + } + } + if let Some(context) = &mut world.boxes[cell.index()].inline_layout { + for line in &mut context.line_placements { + line.translate_block_axis(offset); + } + for line in &mut context.fragments.lines { + line.rect.y += offset; + line.baseline += offset; + if let crate::inline::InlinePaintBounds::Bounded(rect) = &mut line.paint_bounds { + rect.y += offset; + } + } + for text in &mut context.fragments.text { + text.rect.y += offset; + } + for fragment in &mut context.fragments.boxes { + fragment.rect.y += offset; + } + } +} diff --git a/moli-layout/src/table/rows.rs b/moli-layout/src/table/rows.rs new file mode 100644 index 000000000..e0a176ed1 --- /dev/null +++ b/moli-layout/src/table/rows.rs @@ -0,0 +1,269 @@ +//! CSS table block-size constraints, independent of the Grid backend. +//! +//! Distribution follows Blink's table_layout_utils.cc: rowspan deficits, +//! fixed section sizes, then table -> sections -> rows. Content minima are +//! never shrunk. Percentages retain their section-specific resolution basis. + +use std::{cmp::Ordering, ops::Range}; + +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct RowConstraint { + pub size: f32, + pub percent: Option, + pub constrained: bool, + pub has_rowspan_start: bool, + pub ascent: Option, + pub descent: f32, +} + +#[derive(Clone, Debug)] +pub(super) struct SectionConstraint { + pub rows: Range, + pub fixed: Option, + pub percent: Option, + pub is_body: bool, + pub size: f32, +} + +#[derive(Clone, Debug)] +pub(super) struct RowspanConstraint { + pub rows: Range, + pub size: f32, +} + +fn extent(rows: &[RowConstraint], spacing: f32) -> f32 { + rows.iter().map(|row| row.size).sum::() + rows.len().saturating_sub(1) as f32 * spacing +} + +// Blink uses LayoutUnit (1/64 CSS px), giving the rounding remainder to the +// last eligible track. Keep that deterministic behavior for fractional shares. +fn shares(extra: f32, weights: &[f32]) -> Vec { + let sum = weights.iter().sum::(); + let mut remaining = extra; + weights + .iter() + .enumerate() + .map(|(index, weight)| { + let value = if index + 1 == weights.len() { + remaining + } else { + let ratio = if sum > 0.0 { + *weight / sum + } else { + 1.0 / weights.len() as f32 + }; + (extra * ratio * 64.0).floor() / 64.0 + }; + remaining -= value; + value + }) + .collect() +} + +fn grow_rows(rows: &mut [RowConstraint], indices: &[usize], extra: f32, proportional: bool) { + let weights: Vec<_> = indices + .iter() + .map(|&i| if proportional { rows[i].size } else { 1.0 }) + .collect(); + for (&i, delta) in indices.iter().zip(shares(extra, &weights)) { + rows[i].size += delta; + } +} + +fn distribute_rows( + rows: &mut [RowConstraint], + target: f32, + spacing: f32, + basis: Option, + rowspan: bool, +) { + let mut extra = target - extent(rows, spacing); + if extra <= 0.0 || rows.is_empty() { + return; + } + + let deficits: Vec<_> = rows + .iter() + .map(|row| { + row.percent.zip(basis).map_or(0.0, |(percent, basis)| { + (percent * basis - row.size).max(0.0) + }) + }) + .collect(); + let deficit = deficits.iter().sum::(); + if deficit > 0.0 { + let amount = extra.min(deficit); + let eligible: Vec<_> = deficits + .iter() + .enumerate() + .filter_map(|(i, &d)| (d > 0.0).then_some(i)) + .collect(); + let weights: Vec<_> = eligible.iter().map(|&i| deficits[i]).collect(); + for (&i, delta) in eligible.iter().zip(shares(amount, &weights)) { + rows[i].size += delta; + } + extra -= amount; + } + if extra <= 0.0 { + return; + } + + if rowspan { + let origins: Vec<_> = (1..rows.len()) + .filter(|&i| rows[i].has_rowspan_start) + .collect(); + if !origins.is_empty() { + grow_rows(rows, &origins, extra, false); + return; + } + } + let constrained = + |row: &RowConstraint| row.constrained && (row.percent.is_none() || basis.is_some()); + let auto_nonempty: Vec<_> = (0..rows.len()) + .filter(|&i| rows[i].size > 0.0 && !constrained(&rows[i])) + .collect(); + if !auto_nonempty.is_empty() { + grow_rows(rows, &auto_nonempty, extra, true); + return; + } + let empty: Vec<_> = (0..rows.len()).filter(|&i| rows[i].size == 0.0).collect(); + if !empty.is_empty() { + if rowspan && empty.len() == rows.len() { + rows[*empty.last().unwrap()].size += extra; + return; + } + if !rowspan { + let auto_empty: Vec<_> = empty + .iter() + .copied() + .filter(|&i| !constrained(&rows[i])) + .collect(); + grow_rows( + rows, + if auto_empty.is_empty() { + &empty + } else { + &auto_empty + }, + extra, + false, + ); + return; + } + } + let nonempty: Vec<_> = (0..rows.len()).filter(|&i| rows[i].size > 0.0).collect(); + grow_rows(rows, &nonempty, extra, true); +} + +/// Establish the content minimum before resolving the table's own height. +pub(super) fn resolve_minimums( + rows: &mut [RowConstraint], + sections: &mut [SectionConstraint], + spans: &mut [RowspanConstraint], + spacing: f32, +) { + for row in rows.iter_mut() { + row.size = row.size.max(row.ascent.unwrap_or(0.0) + row.descent); + } + for section in sections.iter() { + let mut total = 0.0_f32; + for row in &mut rows[section.rows.clone()] { + if let Some(percent) = &mut row.percent { + *percent = percent.min((1.0 - total).max(0.0)); + total += *percent; + } + } + } + spans.sort_by(|a, b| { + if a.rows == b.rows { + return b.size.total_cmp(&a.size); + } + if a.rows.start >= b.rows.start && a.rows.end <= b.rows.end { + return Ordering::Less; + } + if b.rows.start >= a.rows.start && b.rows.end <= a.rows.end { + return Ordering::Greater; + } + a.rows.start.cmp(&b.rows.start) + }); + for span in spans { + distribute_rows(&mut rows[span.rows.clone()], span.size, spacing, None, true); + } + for section in sections { + let group_rows = &mut rows[section.rows.clone()]; + let minimum = extent(group_rows, spacing); + if let Some(fixed) = section.fixed.filter(|&fixed| fixed > minimum) { + distribute_rows(group_rows, fixed, spacing, Some(fixed), false); + } + section.size = extent(group_rows, spacing).max(section.fixed.unwrap_or(0.0)); + } +} + +/// `target` excludes outer decorations and outer cell spacing, but includes +/// the gaps between nonempty sections. Empty sections have no grid tracks. +pub(super) fn distribute_table( + rows: &mut [RowConstraint], + sections: &mut [SectionConstraint], + target: f32, + spacing: f32, +) { + if sections.is_empty() { + return; + } + let target = (target - sections.len().saturating_sub(1) as f32 * spacing).max(0.0); + let mut extra = target - sections.iter().map(|s| s.size).sum::(); + if extra <= 0.0 { + return; + } + let original: Vec<_> = sections.iter().map(|s| s.size).collect(); + let deficits: Vec<_> = sections + .iter() + .map(|s| s.percent.map_or(0.0, |p| (p * target - s.size).max(0.0))) + .collect(); + let deficit = deficits.iter().sum::(); + if deficit > 0.0 { + let amount = extra.min(deficit); + let eligible: Vec<_> = (0..sections.len()).filter(|&i| deficits[i] > 0.0).collect(); + let weights: Vec<_> = eligible.iter().map(|&i| deficits[i]).collect(); + for (&i, delta) in eligible.iter().zip(shares(amount, &weights)) { + sections[i].size += delta; + } + extra -= amount; + } + if extra > 0.0 { + let has_body = sections.iter().any(|s| s.is_body); + let priority = |s: &SectionConstraint| { + if s.percent.is_some() { + 2 + } else if s.fixed.is_some() { + 1 + } else { + 0 + } + }; + let rank = sections + .iter() + .filter(|s| !has_body || s.is_body) + .map(priority) + .min() + .unwrap(); + let eligible: Vec<_> = (0..sections.len()) + .filter(|&i| (!has_body || sections[i].is_body) && priority(§ions[i]) == rank) + .collect(); + let weights: Vec<_> = eligible.iter().map(|&i| sections[i].size).collect(); + for (&i, delta) in eligible.iter().zip(shares(extra, &weights)) { + sections[i].size += delta; + } + } + for (section, original) in sections.iter().zip(original) { + if section.size > original { + distribute_rows( + &mut rows[section.rows.clone()], + section.size, + spacing, + Some(section.size), + false, + ); + } + } +} diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index b26dd18a1..a56fb6e71 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -3115,7 +3115,10 @@ impl InlineMeasurement { /// this adapter boundary instead. This is the leaf equivalent of Chromium's /// `AlignBlockContent` plus `BoxFragmentBuilder::MoveChildrenInDirection`, not /// a post-layout paint translation. -fn single_subject_block_alignment_offset(alignment: Option, free_space: f32) -> f32 { +pub(crate) fn single_subject_block_alignment_offset( + alignment: Option, + free_space: f32, +) -> f32 { let Some(alignment) = alignment else { return 0.0; };