From 1274e6e7a6a25ab6a23c4c1dcd019b0cd954ce3f Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Tue, 4 Nov 2025 14:42:55 +0000 Subject: [PATCH 1/6] Implement text-wrap-mode style Signed-off-by: Nico Burns --- parley/src/layout/data.rs | 8 +++++--- parley/src/layout/line_break.rs | 5 +++-- parley/src/layout/mod.rs | 4 +++- parley/src/resolve/mod.rs | 11 ++++++++++- parley/src/style/mod.rs | 18 ++++++++++++++++++ 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index 638291aa..f860d440 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -5,7 +5,7 @@ use crate::inline_box::InlineBox; use crate::layout::{ContentWidths, Glyph, LineMetrics, RunMetrics, Style}; use crate::style::Brush; use crate::util::nearly_zero; -use crate::{FontData, LineHeight, OverflowWrap}; +use crate::{FontData, LineHeight, OverflowWrap, TextWrapMode}; use core::ops::Range; use swash::text::cluster::{Boundary, Whitespace}; @@ -550,8 +550,10 @@ impl LayoutData { for cluster in clusters { let boundary = cluster.info.boundary(); let style = &self.styles[cluster.style_index as usize]; - if matches!(boundary, Boundary::Line | Boundary::Mandatory) - || style.overflow_wrap == OverflowWrap::Anywhere + if boundary == Boundary::Mandatory + || (style.text_wrap_mode == TextWrapMode::Wrap + && (boundary == Boundary::Line + || style.overflow_wrap == OverflowWrap::Anywhere)) { let trailing_whitespace = whitespace_advance(prev_cluster); min_width = min_width.max(running_min_width - trailing_whitespace); diff --git a/parley/src/layout/line_break.rs b/parley/src/layout/line_break.rs index efb88af3..327c6eae 100644 --- a/parley/src/layout/line_break.rs +++ b/parley/src/layout/line_break.rs @@ -10,12 +10,12 @@ use swash::text::cluster::Whitespace; #[allow(unused_imports)] use core_maths::CoreFloat; -use crate::OverflowWrap; use crate::layout::{ BreakReason, Layout, LayoutData, LayoutItem, LayoutItemKind, LineData, LineItemData, LineMetrics, Run, }; use crate::style::Brush; +use crate::{OverflowWrap, TextWrapMode}; use swash::text::cluster::Boundary; use core::ops::Range; @@ -290,7 +290,8 @@ impl<'a, B: Brush> BreakLines<'a, B> { let boundary = cluster.info().boundary(); let style = &self.layout.data.styles[cluster.data.style_index as usize]; - if boundary == Boundary::Line { + if boundary == Boundary::Line && style.text_wrap_mode == TextWrapMode::Wrap + { // We do not currently handle breaking within a ligature, so we ignore boundaries in such a position. // // We also don't record boundaries when the advance is 0. As we do not want overflowing content to cause extra consecutive diff --git a/parley/src/layout/mod.rs b/parley/src/layout/mod.rs index 1d66a11b..fcf698f3 100644 --- a/parley/src/layout/mod.rs +++ b/parley/src/layout/mod.rs @@ -42,7 +42,7 @@ pub use crate::editing::{Cursor, Selection}; // TODO - Move the following to `style` module and submodules. use crate::style::Brush; -use crate::{LineHeight, OverflowWrap}; +use crate::{LineHeight, OverflowWrap, TextWrapMode}; #[allow(clippy::partial_pub_fields)] /// Style properties. @@ -58,6 +58,8 @@ pub struct Style { pub(crate) line_height: LineHeight, /// Per-cluster overflow-wrap setting pub(crate) overflow_wrap: OverflowWrap, + /// Per-cluster text-wrap-mode setting + pub(crate) text_wrap_mode: TextWrapMode, } /// Underline or strikethrough decoration. diff --git a/parley/src/resolve/mod.rs b/parley/src/resolve/mod.rs index 04ce8359..5344935e 100644 --- a/parley/src/resolve/mod.rs +++ b/parley/src/resolve/mod.rs @@ -17,7 +17,7 @@ use super::style::{ use crate::font::FontContext; use crate::style::TextStyle; use crate::util::nearly_eq; -use crate::{LineHeight, OverflowWrap, WordBreakStrength, layout}; +use crate::{LineHeight, OverflowWrap, TextWrapMode, WordBreakStrength, layout}; use core::borrow::Borrow; use core::ops::Range; use fontique::FamilyId; @@ -157,6 +157,7 @@ impl ResolveContext { StyleProperty::LetterSpacing(value) => LetterSpacing(*value * scale), StyleProperty::WordBreak(value) => WordBreak(*value), StyleProperty::OverflowWrap(value) => OverflowWrap(*value), + StyleProperty::TextWrapMode(value) => TextWrapMode(*value), } } @@ -193,6 +194,7 @@ impl ResolveContext { letter_spacing: raw_style.letter_spacing * scale, word_break: raw_style.word_break, overflow_wrap: raw_style.overflow_wrap, + text_wrap_mode: raw_style.text_wrap_mode, } } @@ -374,6 +376,8 @@ pub(crate) enum ResolvedProperty { WordBreak(WordBreakStrength), /// Control over "emergency" line-breaking. OverflowWrap(OverflowWrap), + /// Control over non-"emergency" line-breaking. + TextWrapMode(TextWrapMode), } /// Flattened group of style properties. @@ -411,6 +415,8 @@ pub(crate) struct ResolvedStyle { pub(crate) word_break: WordBreakStrength, /// Control over "emergency" line-breaking. pub(crate) overflow_wrap: OverflowWrap, + /// Control over non-"emergency" line-breaking. + pub(crate) text_wrap_mode: TextWrapMode, } impl ResolvedStyle { @@ -440,6 +446,7 @@ impl ResolvedStyle { LetterSpacing(value) => self.letter_spacing = value, WordBreak(value) => self.word_break = value, OverflowWrap(value) => self.overflow_wrap = value, + TextWrapMode(value) => self.text_wrap_mode = value, } } @@ -468,6 +475,7 @@ impl ResolvedStyle { LetterSpacing(value) => nearly_eq(self.letter_spacing, *value), WordBreak(value) => self.word_break == *value, OverflowWrap(value) => self.overflow_wrap == *value, + TextWrapMode(value) => self.text_wrap_mode == *value, } } @@ -478,6 +486,7 @@ impl ResolvedStyle { strikethrough: self.strikethrough.as_layout_decoration(&self.brush), line_height: self.line_height, overflow_wrap: self.overflow_wrap, + text_wrap_mode: self.text_wrap_mode, } } } diff --git a/parley/src/style/mod.rs b/parley/src/style/mod.rs index 57799657..2a875a56 100644 --- a/parley/src/style/mod.rs +++ b/parley/src/style/mod.rs @@ -25,6 +25,19 @@ pub enum WhiteSpaceCollapse { Preserve, } +/// Control over non-"emergency" line-breaking. +/// +/// See for more information. +#[derive(Copy, Clone, Default, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum TextWrapMode { + /// Wrap at non-emergency soft-wrap opportunities when necessary to prevent overflow. + #[default] + Wrap, + /// Do not wrap at non-emergency soft-wrap opportunities. + NoWrap, +} + /// Control over "emergency" line-breaking. /// /// See for more information. @@ -132,6 +145,8 @@ pub enum StyleProperty<'a, B: Brush> { WordBreak(WordBreakStrength), /// Control over "emergency" line-breaking. OverflowWrap(OverflowWrap), + /// Control over non-"emergency" line-breaking. + TextWrapMode(TextWrapMode), } /// Unresolved styles. @@ -181,6 +196,8 @@ pub struct TextStyle<'a, B: Brush> { pub word_break: WordBreakStrength, /// Control over "emergency" line-breaking. pub overflow_wrap: OverflowWrap, + /// Control over non-"emergency" line-breaking. + pub text_wrap_mode: TextWrapMode, } impl Default for TextStyle<'_, B> { @@ -208,6 +225,7 @@ impl Default for TextStyle<'_, B> { letter_spacing: 0.0, word_break: WordBreakStrength::default(), overflow_wrap: OverflowWrap::default(), + text_wrap_mode: TextWrapMode::default(), } } } From 2551980d9f60ed3a05d0b786d72aa41b0149b3f2 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Tue, 4 Nov 2025 14:43:24 +0000 Subject: [PATCH 2/6] Lag text-wrap-mode style by one cluster + support inline boxes Signed-off-by: Nico Burns --- parley/src/layout/data.rs | 12 +++++++++--- parley/src/layout/line_break.rs | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index f860d440..59a59d5c 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -535,14 +535,15 @@ impl LayoutData { let mut min_width = 0.0_f32; let mut max_width = 0.0_f32; + let mut running_min_width = 0.0; let mut running_max_width = 0.0; + let mut text_wrap_mode = TextWrapMode::Wrap; let mut prev_cluster: Option<&ClusterData> = None; let is_rtl = self.base_level & 1 == 1; for item in &self.items { match item.kind { LayoutItemKind::TextRun => { let run = &self.runs[item.index]; - let mut running_min_width = 0.0; let clusters = &self.clusters[run.cluster_range.clone()]; if is_rtl { prev_cluster = clusters.first(); @@ -550,8 +551,10 @@ impl LayoutData { for cluster in clusters { let boundary = cluster.info.boundary(); let style = &self.styles[cluster.style_index as usize]; + let prev_text_wrap_mode = text_wrap_mode; + text_wrap_mode = style.text_wrap_mode; if boundary == Boundary::Mandatory - || (style.text_wrap_mode == TextWrapMode::Wrap + || (prev_text_wrap_mode == TextWrapMode::Wrap && (boundary == Boundary::Line || style.overflow_wrap == OverflowWrap::Anywhere)) { @@ -573,8 +576,11 @@ impl LayoutData { } LayoutItemKind::InlineBox => { let ibox = &self.inline_boxes[item.index]; - min_width = min_width.max(ibox.width); running_max_width += ibox.width; + if text_wrap_mode == TextWrapMode::Wrap { + min_width = min_width.max(ibox.width); + running_min_width = 0.0; + } prev_cluster = None; } } diff --git a/parley/src/layout/line_break.rs b/parley/src/layout/line_break.rs index 327c6eae..21cb0184 100644 --- a/parley/src/layout/line_break.rs +++ b/parley/src/layout/line_break.rs @@ -42,6 +42,10 @@ struct LineState { /// Of the line currently being built, the maximum line height seen so far. /// This represents a lower-bound on the eventual line height of the line. running_line_height: f32, + + /// We lag the text-wrap-mode by one cluster due to line-breaking boundaries only + /// being triggered on the cluster after the linebreak. + text_wrap_mode: TextWrapMode, } #[derive(Clone, Default)] @@ -240,7 +244,8 @@ impl<'a, B: Brush> BreakLines<'a, B> { // If the box fits on the current line (or we are at the start of the current line) // then simply move on to the next item - if next_x <= max_advance { + if next_x <= max_advance || self.state.line.text_wrap_mode != TextWrapMode::Wrap + { // println!("BOX FITS"); self.state.item_idx += 1; @@ -290,8 +295,11 @@ impl<'a, B: Brush> BreakLines<'a, B> { let boundary = cluster.info().boundary(); let style = &self.layout.data.styles[cluster.data.style_index as usize]; - if boundary == Boundary::Line && style.text_wrap_mode == TextWrapMode::Wrap - { + // Lag text_wrap_mode style by one cluster + let text_wrap_mode = self.state.line.text_wrap_mode; + self.state.line.text_wrap_mode = style.text_wrap_mode; + + if boundary == Boundary::Line && text_wrap_mode == TextWrapMode::Wrap { // We do not currently handle breaking within a ligature, so we ignore boundaries in such a position. // // We also don't record boundaries when the advance is 0. As we do not want overflowing content to cause extra consecutive @@ -313,6 +321,7 @@ impl<'a, B: Brush> BreakLines<'a, B> { } else if // This text can contribute "emergency" line breaks. style.overflow_wrap != OverflowWrap::Normal && !is_ligature_continuation + && text_wrap_mode == TextWrapMode::Wrap // If we're at the start of the line, this particular cluster will never fit, so it's not a valid emergency break opportunity. && self.state.line.x != 0.0 { @@ -354,7 +363,7 @@ impl<'a, B: Brush> BreakLines<'a, B> { // Else we line break: else { // Handle case where cluster is space character. Hang overflowing whitespace. - if is_space { + if is_space && text_wrap_mode == TextWrapMode::Wrap { let line_height = run.metrics().line_height; self.state.append_cluster_to_line(next_x, line_height); if try_commit_line!(BreakReason::Regular) { From 50f480a15bd7bec539e44b945fd6110fdbdc5cfe Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Tue, 4 Nov 2025 16:48:53 +0000 Subject: [PATCH 3/6] Add text_wrap_mode style to tests Signed-off-by: Nico Burns --- parley/src/tests/test_builders.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/parley/src/tests/test_builders.rs b/parley/src/tests/test_builders.rs index 28cf23e9..59ffb7ab 100644 --- a/parley/src/tests/test_builders.rs +++ b/parley/src/tests/test_builders.rs @@ -12,7 +12,7 @@ use swash::text::WordBreakStrength; use super::utils::{ColorBrush, FONT_STACK, asserts::assert_eq_layout_data, create_font_context}; use crate::{ FontContext, FontSettings, FontStack, Layout, LayoutContext, LineHeight, OverflowWrap, - RangedBuilder, StyleProperty, TextStyle, TreeBuilder, + RangedBuilder, StyleProperty, TextStyle, TextWrapMode, TreeBuilder, }; /// Set of options for [`build_layout_with_ranged`]. @@ -184,6 +184,7 @@ fn create_root_style() -> TextStyle<'static, ColorBrush> { letter_spacing: 1.5, word_break: WordBreakStrength::BreakAll, overflow_wrap: OverflowWrap::Anywhere, + text_wrap_mode: TextWrapMode::Wrap, } } From b3f5f1add1ee72cf694f4900f1e1ac39ad084fcc Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Tue, 11 Nov 2025 13:36:47 +0000 Subject: [PATCH 4/6] Fixup min content size --- parley/src/layout/data.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index 59a59d5c..e590eb1d 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -578,8 +578,12 @@ impl LayoutData { let ibox = &self.inline_boxes[item.index]; running_max_width += ibox.width; if text_wrap_mode == TextWrapMode::Wrap { + let trailing_whitespace = whitespace_advance(prev_cluster); + min_width = min_width.max(running_min_width - trailing_whitespace); min_width = min_width.max(ibox.width); running_min_width = 0.0; + } else { + running_min_width += ibox.width; } prev_cluster = None; } @@ -588,6 +592,9 @@ impl LayoutData { max_width = max_width.max(running_max_width - trailing_whitespace); } + let trailing_whitespace = whitespace_advance(prev_cluster); + min_width = min_width.max(running_min_width - trailing_whitespace); + ContentWidths { min: min_width, max: max_width, From 0bec35b18f94dd912b07db5ff3e13ce3f90de7d8 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Tue, 11 Nov 2025 20:06:23 +0000 Subject: [PATCH 5/6] Improve comments on line-breaking logic Signed-off-by: Nico Burns --- parley/src/layout/line_break.rs | 34 +++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/parley/src/layout/line_break.rs b/parley/src/layout/line_break.rs index 21cb0184..72b7b951 100644 --- a/parley/src/layout/line_break.rs +++ b/parley/src/layout/line_break.rs @@ -347,11 +347,9 @@ impl<'a, B: Brush> BreakLines<'a, B> { // println!("Cluster {} next_x: {}", self.state.cluster_idx, next_x); - // if break_opportunity { - // println!("==="); - // } - - // If that x position does NOT exceed max_advance then we simply add the cluster(s) to the current line + // If the content fits (the x position does NOT exceed max_advance) + // + // We simply append the cluster(s) to the current line if next_x <= max_advance { let line_height = run.metrics().line_height; self.state.append_cluster_to_line(next_x, line_height); @@ -360,9 +358,15 @@ impl<'a, B: Brush> BreakLines<'a, B> { self.state.line.num_spaces += 1; } } - // Else we line break: + // Else we attempt to line break: + // + // This will only succeed if there is an available line-break opportunity that has been marked earlier + // in the line. If there is no such line-breaking opportunity (such as if wrapping is disabled), then + // we fall back to appending the content to the line anyway. else { - // Handle case where cluster is space character. Hang overflowing whitespace. + // Case: cluster is a space character (and wrapping is enabled) + // + // We hang any overflowing whitespace and then line-break. if is_space && text_wrap_mode == TextWrapMode::Wrap { let line_height = run.metrics().line_height; self.state.append_cluster_to_line(next_x, line_height); @@ -372,7 +376,7 @@ impl<'a, B: Brush> BreakLines<'a, B> { return self.start_new_line(); } } - // Handle the (common) case where we have previously encountered a line-breaking opportunity in the current line + // Case: we have previously encountered a REGULAR line-breaking opportunity in the current line // // We "take" the line-breaking opportunity by starting a new line and resetting our // item/run/cluster iteration state back to how it was when the line-breaking opportunity was encountered @@ -391,7 +395,10 @@ impl<'a, B: Brush> BreakLines<'a, B> { return self.start_new_line(); } } - // Otherwise perform an emergency line break + // Case: we have previously encountered an EMERGENCY line-breaking opportunity in the current line + // + // We "take" the line-breaking opportunity by starting a new line and resetting our + // item/run/cluster iteration state back to how it was when the line-breaking opportunity was encountered else if let Some(prev_emergency) = self.state.emergency_boundary.take() { @@ -404,7 +411,14 @@ impl<'a, B: Brush> BreakLines<'a, B> { return self.start_new_line(); } - } else { + } + // Case: no line-breaking opportunities available + // + // This can happen when wrapping is disabled (TextWrapMode::NoWrap) or when no wrapping opportunities + // (according to our `OverflowWrap` and `WordBreak` styles) have yet been encountered. + // + // We fall back to appending the content to the line. + else { let line_height = run.metrics().line_height; self.state.append_cluster_to_line(next_x, line_height); self.state.cluster_idx += 1; From f394c3319553bd6c1a69f2da17c18ff9789bf385 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Tue, 11 Nov 2025 20:36:30 +0000 Subject: [PATCH 6/6] Add TextWrapMode tests Signed-off-by: Nico Burns --- parley/src/tests/test_wrap.rs | 130 +++++++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/parley/src/tests/test_wrap.rs b/parley/src/tests/test_wrap.rs index 0983014d..ff498b45 100644 --- a/parley/src/tests/test_wrap.rs +++ b/parley/src/tests/test_wrap.rs @@ -4,7 +4,8 @@ use peniko::color::palette::css; use crate::{ - Alignment, AlignmentOptions, OverflowWrap, StyleProperty, WordBreakStrength, test_name, + Alignment, AlignmentOptions, OverflowWrap, StyleProperty, TextWrapMode, WordBreakStrength, + test_name, }; use super::utils::{ColorBrush, TestEnv}; @@ -277,3 +278,130 @@ fn word_break_keep_all() { // Jamo decomposed on purpose test_text("애기판다 애기판다", "korean_hangul_jamos", 90.0); } + +#[test] +fn text_wrap_mode_nowrap_disables_soft_wraps() { + let mut env = TestEnv::new(test_name!(), None); + + let text = "Most words are short. But Antidisestablishmentarianism is long and needs to wrap."; + let wrap_width = 120.0; + + let mut baseline_layout = env.ranged_builder(text).build(text); + baseline_layout.break_all_lines(Some(wrap_width)); + assert!( + baseline_layout.len() > 1, + "Expected baseline layout to wrap with width {wrap_width}" + ); + + let mut builder = env.ranged_builder(text); + builder.push_default(StyleProperty::TextWrapMode(TextWrapMode::NoWrap)); + let mut layout = builder.build(text); + layout.break_all_lines(Some(wrap_width)); + + assert_eq!( + layout.len(), + 1, + "Applying TextWrapMode::NoWrap should prevent soft wrapping" + ); + + let line_advance = layout + .lines() + .next() + .expect("layout should have one line") + .metrics() + .advance; + assert!( + line_advance > wrap_width, + "Line advance {line_advance} should overflow the requested width {wrap_width}" + ); +} + +#[test] +fn text_wrap_mode_allows_break_before_nowrap_span() { + let mut env = TestEnv::new(test_name!(), None); + + let text = "Hello world!"; + let prefix = "Hello "; + + let prefix_width = { + let mut layout = env.ranged_builder(prefix).build(prefix); + layout.break_all_lines(None); + layout.width() + }; + + let wrap_width = prefix_width + 1.0; + + let start = text.find("world!").unwrap(); + let mut builder = env.ranged_builder(text); + builder.push( + StyleProperty::TextWrapMode(TextWrapMode::NoWrap), + // `start..text.len()` === "world!" + start..text.len(), + ); + + let mut layout = builder.build(text); + layout.break_all_lines(Some(wrap_width)); + + assert_eq!( + layout.len(), + 2, + "Layout should still wrap before the NoWrap span boundary" + ); + + let first_line = layout.get(0).unwrap(); + let second_line = layout.get(1).unwrap(); + assert_eq!( + &text[first_line.text_range()], + "Hello ", + "First line should end before the NoWrap span" + ); + assert_eq!( + &text[second_line.text_range()], + "world!", + "Second line should contain the NoWrap span" + ); +} + +#[test] +fn text_wrap_mode_updates_min_content_width() { + let mut env = TestEnv::new(test_name!(), None); + + let text = "Tags: London UK Paris FR"; + let span_text = "London UK"; + let span_start = text.find(span_text).unwrap(); + let span_range = span_start..span_start + span_text.len(); + + let widths_wrap = { + let layout = env.ranged_builder(text).build(text); + layout.calculate_content_widths() + }; + + let widths_nowrap = { + let mut builder = env.ranged_builder(text); + builder.push( + StyleProperty::TextWrapMode(TextWrapMode::NoWrap), + span_range.clone(), + ); + let layout = builder.build(text); + layout.calculate_content_widths() + }; + + let span_width = { + let mut layout = env.ranged_builder(span_text).build(span_text); + layout.break_all_lines(None); + layout.width() + }; + + assert!( + widths_wrap.min < span_width, + "Without NoWrap, min content width {} should be smaller than the span width {}", + widths_wrap.min, + span_width + ); + assert!( + widths_nowrap.min >= span_width - 0.5, + "With NoWrap, min content width {} should be at least the span width {}", + widths_nowrap.min, + span_width + ); +}