Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion moli-core/src/page/renderer_command_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1673,7 +1673,14 @@ impl Page {
}

pub fn start_layout_metrics(&self) -> Result<PendingPageCommand> {
self.start_page_command(RendererPageCommand::LayoutMetrics)
self.start_layout_metrics_with_publication(false)
}

pub fn start_layout_metrics_with_publication(
&self,
publish_layout: bool,
) -> Result<PendingPageCommand> {
self.start_page_command(RendererPageCommand::LayoutMetrics { publish_layout })
}

pub fn finish_layout_metrics(
Expand Down
19 changes: 19 additions & 0 deletions moli-layout/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,9 @@ where
let mut output = Vec::new();
let mut current = Some(principal);
let mut run = Vec::new();
let mut blocks: Option<crate::world::InlineSplitBlockRun> = None;
let mut preceding_inline = principal;
world.inline_split_sources.insert(owner);

for child in children {
if self.is_block_in_flow(world, child) {
Expand All @@ -634,18 +637,34 @@ where
std::mem::take(&mut run),
)?;
output.push(fragment);
preceding_inline = fragment;
}
if let Some(run) = blocks.as_mut() {
run.last = child;
} else {
blocks = Some(crate::world::InlineSplitBlockRun {
preceding_inline,
first: child,
last: child,
});
}
output.push(child);
continue;
}

if let Some(run) = blocks.take() {
world.inline_split_block_runs.push(run);
}
if current.is_none() {
current =
Some(self.allocate_inline_continuation(world, owner, style, semantics.clone()));
}
run.push(child);
}

if let Some(run) = blocks {
world.inline_split_block_runs.push(run);
}
if let Some(fragment) = current {
self.replace_children_and_mark_context(world, fragment, run)?;
output.push(fragment);
Expand Down
101 changes: 100 additions & 1 deletion moli-layout/src/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ pub(crate) fn relative_atomic_inset_offset(
style: &taffy::Style<style::Atom>,
containing_block_size: Size<f32>,
container_direction: InlineDirection,
) -> Point<f32> {
relative_inset_offset(style, containing_block_size.map(Some), container_direction)
}

/// The block-axis percentage basis may be indefinite even when the final
/// content height is nonzero (auto height or min-height-only sizing).
pub(crate) fn relative_inset_offset(
style: &taffy::Style<style::Atom>,
containing_block_size: Size<Option<f32>>,
container_direction: InlineDirection,
) -> Point<f32> {
let inset = taffy::Rect {
left: style.inset.left.maybe_resolve(
Expand Down Expand Up @@ -92,6 +102,7 @@ pub(crate) struct InlineTextUnit {
pub(crate) sources: Vec<SourceOrigin>,
pub(crate) control: bool,
/// A normalized CSS space that can still collapse at a line boundary.
/// Preserved spaces and non-breaking spaces retain their geometry.
pub(crate) collapsible_whitespace: bool,
pub(crate) break_spaces_opportunity: bool,
}
Expand Down Expand Up @@ -563,13 +574,56 @@ pub(crate) fn build_inline_fragments(
.iter()
.map(text_style_paint_outsets)
.collect::<Vec<_>>();
// Inline objects occupy positions between text units, not text bytes.
// Retain their logical offsets so spaces before an atomic box cannot be
// mistaken for line-end whitespace (including in bidi-reordered lines).
let atomic_offsets: HashMap<_, _> = layout
.inline_boxes()
.filter(|inline_box| {
context
.object(inline_box.id)
.is_some_and(|object| object.role == InlineObjectRole::Atomic)
})
.map(|inline_box| (inline_box.id, inline_box.index))
.collect();

for (line_index, line) in layout.lines().enumerate() {
let metrics = line.metrics();
let line_range = line.text_range();
let last_atomic_offset = line
.items()
.filter_map(|item| match item {
PositionedLayoutItem::InlineBox(positioned) => {
atomic_offsets.get(&positioned.id).copied()
}
PositionedLayoutItem::GlyphRun(_) => None,
})
.max()
.unwrap_or(line_range.start);
let trailing_start = overlapping_output_ranges(&context.text_units, &line_range)
.iter()
.rev()
.take_while(|unit| {
unit.output_range.start >= last_atomic_offset
&& (unit.control || unit.collapsible_whitespace)
})
.filter(|unit| unit.collapsible_whitespace)
.map(|unit| unit.output_range.start)
.last();
let mut collapsed_trailing_advance = 0.0;
if let Some(start) = trailing_start {
for run in line.runs() {
for cluster in run.visual_clusters() {
if cluster.text_range().start >= start {
collapsed_trailing_advance += cluster.advance().max(0.0);
}
}
}
}
let placement = line_placements
.get(line_index)
.filter(|placement| placement.line_index == line_index);
let line_rect = placement.map_or_else(
let mut line_rect = placement.map_or_else(
|| {
PaintRect::new(
metrics.inline_min_coord + metrics.offset,
Expand All @@ -580,6 +634,14 @@ pub(crate) fn build_inline_fragments(
},
|placement| placement.rect,
);
// Parley retains hanging spaces for shaping/line breaking. CSS
// collapsed line-end spaces contribute neither range/inline-box
// geometry nor scrollable overflow. Do not trim preserved or NBSP
// advances merely because Parley classifies them as whitespace.
line_rect.width = (line_rect.width - collapsed_trailing_advance).max(0.0);
if layout.is_rtl() {
line_rect.x += collapsed_trailing_advance;
}
fragments.lines.push(InlineLineFragment {
line_index,
rect: line_rect,
Expand All @@ -604,6 +666,9 @@ pub(crate) fn build_inline_fragments(
let run_metrics = run.font_metrics();
for cluster in run.visual_clusters() {
let range = cluster.text_range();
if trailing_start.is_some_and(|start| range.start >= start) {
continue;
}
let style_index = usize::from(cluster.style_index());
let vertical_offset = placement.map_or(0.0, |placement| {
placement.glyph_offset(run.index(), style_index)
Expand Down Expand Up @@ -3405,6 +3470,40 @@ mod tests {
normalizer.finish()
}

#[test]
fn only_collapsed_spaces_are_marked_for_line_end_removal() {
let text = LayoutBoxId::from_index(1);
for mode in [
InlineWhiteSpaceCollapse::Collapse,
InlineWhiteSpaceCollapse::PreserveBreaks,
InlineWhiteSpaceCollapse::Preserve,
InlineWhiteSpaceCollapse::BreakSpaces,
] {
let input = normalize(&[(text, "A \u{a0} B")], mode, InlineTextTransform::None);
let collapsed = input
.units
.iter()
.filter(|unit| unit.collapsible_whitespace)
.collect::<Vec<_>>();
assert_eq!(
collapsed.len(),
if matches!(
mode,
InlineWhiteSpaceCollapse::Collapse | InlineWhiteSpaceCollapse::PreserveBreaks
) {
2
} else {
0
}
);
assert!(
collapsed
.iter()
.all(|unit| &input.text[unit.output_range.clone()] == " ")
);
}
}

#[test]
fn preserve_merges_crlf_across_adjacent_text_nodes_with_both_origins() {
let first = LayoutBoxId::from_index(1);
Expand Down
8 changes: 5 additions & 3 deletions moli-layout/src/pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,11 +314,13 @@ where
///
/// Every iteration enters Taffy through the synthetic viewport root, but that
/// is a scheduling entry point rather than an unconditional full-tree layout.
/// `invalidate_scrollbar_feedback` clears only changed boxes and their numeric
/// ancestors. All other subtrees keep valid Taffy cache entries and return
/// `invalidate_scrollbar_feedback` clears a changed local scroll container's
/// subtree and numeric ancestor path. A viewport gutter invalidates the whole
/// tree because it changes the initial containing block. Unaffected branches
/// of a local scrollbar change keep valid Taffy cache entries and return
/// immediately. This is the same important boundary as Blink's corrective
/// scrollbar relayout: pay a follow-up pass only after state changes, and only
/// recompute the paths whose available space can have changed.
/// recompute boxes whose available space can have changed.
#[derive(Default)]
struct NumericLayoutMetrics {
pass_count: usize,
Expand Down
Loading
Loading