Repository: zng-ui/zng (language: rust)

Issue:
Implement Justify in Wrap and Text
Extend the inline layout API to support justify. Each line segment can potentially stretch, and the Wrap layout can insert space between segments.


Discussion / hints:
#617 Implemented for single `Text!`, next implement it for `Wrap!`.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5bcb175bc..924d0e29f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 # Unreleased
 
+* Implemented text justify for *rich text* composed of `Wrap!` and `Text!`.
 
 # 0.13.11
 
diff --git a/crates/zng-app/Cargo.toml b/crates/zng-app/Cargo.toml
index 6b1430c36..ceb7ddb63 100644
--- a/crates/zng-app/Cargo.toml
+++ b/crates/zng-app/Cargo.toml
@@ -120,7 +120,7 @@ minidumper = { version = "0.8", optional = true }
 crash-handler = { version = "0.6", optional = true }
 minidump = { version = "0.24", optional = true }
 linkme = { version = "=0.3.31", optional = true }
-uuid = { version = "1.1", optional = true }
+uuid = { version = "1.3", features = ["v4"], optional = true }
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 tracing-web = "0.1"
diff --git a/crates/zng-app/src/widget/base.rs b/crates/zng-app/src/widget/base.rs
index a27a446d7..9920fc09c 100644
--- a/crates/zng-app/src/widget/base.rs
+++ b/crates/zng-app/src/widget/base.rs
@@ -779,7 +779,7 @@ pub mod node {
                             ] {
                                 let width = size.width.0 as f32;
                                 let sum_width = segs.iter().map(|s| s.width).sum::<f32>();
-                                if sum_width > width {
+                                if sum_width > width + 0.1 {
                                     tracing::error!(
                                         "widget {:?} measured inline {name} row has {width} width, but row segs sum to {sum_width} width",
                                         WIDGET.id()
diff --git a/crates/zng-ext-font/src/shaping.rs b/crates/zng-ext-font/src/shaping.rs
index afbf5375a..5b75d3e1a 100644
--- a/crates/zng-ext-font/src/shaping.rs
+++ b/crates/zng-ext-font/src/shaping.rs
@@ -10,7 +10,7 @@ use zng_ext_image::{ImageDataFormat, ImageSource, ImageVar, IMAGES};
 use zng_ext_l10n::{lang, Lang};
 use zng_layout::{
     context::{InlineConstraintsLayout, InlineConstraintsMeasure, InlineSegmentPos, LayoutDirection, TextSegmentKind},
-    unit::{euclid, Align, Factor2d, FactorUnits, Px, PxBox, PxConstraints2d, PxPoint, PxRect, PxSize},
+    unit::{about_eq, euclid, Align, Factor2d, FactorUnits, Px, PxBox, PxConstraints2d, PxPoint, PxRect, PxSize},
 };
 use zng_txt::Txt;
 use zng_var::{AnyVar, Var as _};
@@ -797,7 +797,9 @@ impl ShapedText {
 
                 let first_line = self.lines.first_mut();
                 first_line.x_offset = first.origin.x.0 as f32;
-                first_line.width = first.size.width.0 as f32;
+                // width is the same measured, unless the parent inliner changed it to fill,
+                // in that case we need the original width in `reshape_lines_justify`.
+                // first_line.width = first.size.width.0 as f32;
             }
             if !first_segs.is_empty() {
                 // parent set first_segs.
@@ -835,7 +837,8 @@ impl ShapedText {
 
                 let last_line = self.lines.last_mut();
                 last_line.x_offset = last.origin.x.0 as f32;
-                last_line.width = last.size.width.0 as f32;
+                // width is the same measured, unless the parent inliner changed it to justify, that is handled later.
+                // last_line.width = last.size.width.0 as f32;
             }
             if !last_segs.is_empty() {
                 // parent set last_segs.
@@ -996,16 +999,13 @@ impl ShapedText {
     }
 
     fn justify_lines_range(&self) -> ops::Range<usize> {
-        let mut range = 0..self.lines_len().saturating_sub(1); // skip last line
+        let mut range = 0..self.lines_len();
 
-        if self.is_inlined {
-            // justify on inline not implemented
-            range.start += 1;
+        if !self.is_inlined {
+            // skip last line
             range.end = range.end.saturating_sub(1);
-            if range.start > range.end {
-                range = 0..0
-            }
         }
+        // else inlined fills the first and last line rects
 
         range
     }
@@ -1026,6 +1026,7 @@ impl ShapedText {
         let range = self.justify_lines_range();
 
         let fill_width = self.align_size.width.0 as f32;
+        let last_li = range.end.saturating_sub(1);
 
         for li in range.clone() {
             let mut count;
@@ -1034,6 +1035,16 @@ impl ShapedText {
             let mut offset = 0.0;
             let mut last_is_space = false;
 
+            let mut fill_width = fill_width;
+            if self.is_inlined {
+                // inlining parent provides the fill space for the first and last segment
+                if li == 0 {
+                    fill_width = self.first_line.width().0 as f32;
+                } else if li == last_li {
+                    fill_width = self.last_line.width().0 as f32;
+                }
+            }
+
             {
                 // line scope
                 let line = self.line(li).unwrap();
@@ -1065,14 +1076,18 @@ impl ShapedText {
                 let mut first_is_space = false;
 
                 if let Some(s) = line.seg(0) {
-                    if s.kind().is_space() {
+                    if s.kind().is_space() && (!self.is_inlined || li > 0 || self.first_line.origin.x == Px(0)) {
+                        // trim start, unless it inlining and the first seg is actually a continuation of another text on the same row
                         first_is_space = true;
                         count -= 1;
                         space += s.advance();
                     }
                 }
                 if let Some(s) = line.seg(line.segs_len().saturating_sub(1)) {
-                    if s.kind().is_space() {
+                    if s.kind().is_space()
+                        && (!self.is_inlined || li < range.end - 1 || about_eq(self.first_line.size.width.0 as f32, fill_width, 1.0))
+                    {
+                        // trim end, unless its inlining and the last seg continues
                         last_is_space = true;
                         count -= 1;
                         space += s.advance();
@@ -1111,7 +1126,11 @@ impl ShapedText {
                     gsi = line.seg_range.start() + si;
                 }
 
-                let mut cluster = self.clusters[glyphs_range.start()];
+                let mut cluster = if self.clusters.is_empty() {
+                    0
+                } else {
+                    self.clusters[glyphs_range.start()]
+                };
                 for gi in glyphs_range {
                     self.glyphs[gi].point.x += offset;
 
@@ -1212,7 +1231,11 @@ impl ShapedText {
                     gsi = line.seg_range.start() + si;
                 }
 
-                let mut cluster = self.clusters[glyphs_range.start()];
+                let mut cluster = if self.clusters.is_empty() {
+                    0
+                } else {
+                    self.clusters[glyphs_range.start()]
+                };
                 for gi in glyphs_range {
                     self.glyphs[gi].point.x -= offset;
 
diff --git a/crates/zng-ext-input/Cargo.toml b/crates/zng-ext-input/Cargo.toml
index e5deaa4a9..0d02c2646 100644
--- a/crates/zng-ext-input/Cargo.toml
+++ b/crates/zng-ext-input/Cargo.toml
@@ -34,4 +34,4 @@ parking_lot = "0.12"
 serde = { version = "1.0", features = ["derive"] }
 bitflags = { version = "2.5", features = ["serde", "bytemuck"] }
 atomic = "0.6"
-uuid = "1.1"
+uuid = { version = "1.3", features = ["v4", "js"] }
diff --git a/crates/zng-layout/src/context.rs b/crates/zng-layout/src/context.rs
index 6776f2088..84f28cb4c 100644
--- a/crates/zng-layout/src/context.rs
+++ b/crates/zng-layout/src/context.rs
@@ -282,7 +282,7 @@ pub struct InlineConstraintsMeasure {
     pub first_max: Px,
     /// Current height of the row in the parent. If the widget wraps and defines the first
     /// row in *this* parent's row, the `mid_clear` value will be the extra space needed to clear
-    /// this minimum or zero if the first how is taller. The widget must use this value to estimate the `mid_clear`
+    /// this minimum or zero if the first row is taller. The widget must use this value to estimate the `mid_clear`
     /// value and include it in the overall measured height of the widget.
     pub mid_clear_min: Px,
 }
diff --git a/crates/zng-task/Cargo.toml b/crates/zng-task/Cargo.toml
index a029fcd8a..1537433c2 100644
--- a/crates/zng-task/Cargo.toml
+++ b/crates/zng-task/Cargo.toml
@@ -80,14 +80,13 @@ async-trait = { version = "0.1", optional = true }
 sha2 = { version = "0.10", optional = true }
 base64 = { version = "0.22", optional = true }
 remove_dir_all = { version = "1", optional = true }
-
-ipc-channel = { version = "0.19", optional = true }
 bincode = { version = "1.3", optional = true }
 dunce = "1.0"
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 duct = { version = "0.13", optional = true }
 fs4 = { version = "0.12", optional = true }
+ipc-channel = { version = "0.19", optional = true }
 
 [build-dependencies]
 cfg_aliases = "0.2"
diff --git a/crates/zng-view-api/Cargo.toml b/crates/zng-view-api/Cargo.toml
index 491826cd8..12ad1a35b 100644
--- a/crates/zng-view-api/Cargo.toml
+++ b/crates/zng-view-api/Cargo.toml
@@ -45,7 +45,6 @@ rustc-hash = "2.0"
 bitflags = { version = "2.5", features = ["serde", "bytemuck"] }
 bytemuck = { version = "1.15", features = ["derive"] }
 
-ipc-channel = { version = "0.19", optional = true }
 bincode = "1.3"
 parking_lot = "0.12"
 serde_variant = "0.1"
@@ -58,6 +57,9 @@ tracing-shared = { version = "0.1.5", default-features = false, features = ["log
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 web-time = "1.0"
 
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+ipc-channel = { version = "0.19", optional = true }
+
 [build-dependencies]
 cfg_aliases = "0.2"
 
diff --git a/crates/zng-wgt-wrap/src/lib.rs b/crates/zng-wgt-wrap/src/lib.rs
index 30bdb4664..472ec2b0f 100644
--- a/crates/zng-wgt-wrap/src/lib.rs
+++ b/crates/zng-wgt-wrap/src/lib.rs
@@ -83,9 +83,6 @@ pub fn children(children: impl UiNodeList) {}
 pub fn spacing(spacing: impl IntoVar<GridSpacing>) {}
 
 /// Children align.
-///
-/// [`TEXT_ALIGN_VAR`]: zng_wgt_text::TEXT_ALIGN_VAR
-/// [`txt_align`]: fn@zng_wgt_text::txt_align
 #[property(LAYOUT, capture, widget_impl(Wrap))]
 pub fn children_align(align: impl IntoVar<Align>) {}
 
@@ -530,6 +527,12 @@ impl InlineLayout {
             inline.rows.clear();
         }
 
+        let fill_width = if child_align.is_fill_x() {
+            Some(panel_width.0 as f32)
+        } else {
+            None
+        };
+
         LAYOUT.with_constraints(child_constraints, || {
             let mut row = first;
             let mut row_segs = &self.rows[0].item_segs;
@@ -537,6 +540,14 @@ impl InlineLayout {
             let mut next_row_i = 1;
             let mut row_segs_i_start = 0;
 
+            let mut fill_scale = None;
+            if let Some(mut f) = fill_width {
+                if wl.is_inline() {
+                    f = first.width().0 as f32;
+                }
+                fill_scale = Some(f / self.rows[0].size.width.0 as f32);
+            }
+
             children.for_each(|i, child, o| {
                 if next_row_i < self.rows.len() && self.rows[next_row_i].first_child == i {
                     // new row
@@ -559,6 +570,17 @@ impl InlineLayout {
                     row_segs_i_start = self.rows[next_row_i].first_child;
                     next_row_i += 1;
                     row_advance = Px(0);
+
+                    fill_scale = None;
+                    if let Some(mut f) = fill_width {
+                        if wl.is_inline() || next_row_i < self.rows.len() {
+                            if wl.is_inline() && next_row_i == self.rows.len() {
+                                f = last.width().0 as f32;
+                            }
+                            // fill row, if it is not the last in a block layout
+                            fill_scale = Some(f / self.rows[next_row_i - 1].size.width.0 as f32);
+                        }
+                    }
                 }
 
                 let (bidi_x, bidi_width, bidi_segs) = if self.has_bidi_inline {
@@ -627,6 +649,24 @@ impl InlineLayout {
                             child_last.size.width = last_bidi_width;
                         }
 
+                        if let Some(s) = fill_scale {
+                            child_first.size.width *= s;
+
+                            // child wraps, so last is different row
+                            if wl.is_inline() || next_row_i < self.rows.len() - 1 {
+                                let mut f = fill_width.unwrap();
+                                if wl.is_inline() && next_row_i == self.rows.len() - 1 {
+                                    f = last.width().0 as f32;
+                                }
+                                fill_scale = Some(f / next_row.size.width.0 as f32);
+                                let s = fill_scale.unwrap();
+                                child_last.size.width *= s;
+                            } else {
+                                // only fill last row if Wrap! is nested/inlined
+                                fill_scale = None;
+                            }
+                        }
+
                         let (_, define_ref_frame) =
                             wl.with_child(|wl| wl.layout_inline(child_first, child_mid, child_last, bidi_segs, last_bidi_segs, child));
                         o.child_offset = PxVector::new(Px(0), row.origin.y);
@@ -672,6 +712,12 @@ impl InlineLayout {
                             child_last.size.width = bidi_width;
                         }
 
+                        if let Some(s) = fill_scale {
+                            child_first.size.width *= s;
+                            child_last.size.width *= s;
+                            max_size.width *= s;
+                        }
+
                         let (_, define_ref_frame) = wl.with_child(|wl| {
                             LAYOUT.with_constraints(child_constraints.with_fill(false, false).with_max_size(max_size), || {
                                 wl.layout_inline(child_first, child_mid, child_last, bidi_segs.clone(), bidi_segs, child)
@@ -874,11 +920,17 @@ impl InlineLayout {
 
             if (sum_width - width) > Px(1) {
                 if metrics.inline_constraints().is_some() && (i == 0 || i == self.rows.len() - 1) {
-                    tracing::error!("Wrap! panel row {i} inline width is {width}, but sum of segs is {sum_width}");
+                    tracing::error!(
+                        "Wrap![{}] panel row {i} inline width is {width}, but sum of segs is {sum_width}",
+                        WIDGET.id()
+                    );
                     continue;
                 }
 
-                tracing::error!("Wrap! panel row {i} computed width {width}, but sum of segs is {sum_width}");
+                tracing::error!(
+                    "Wrap![{}] panel row {i} computed width {width}, but sum of segs is {sum_width}",
+                    WIDGET.id()
+                );
             }
         }
     }
diff --git a/crates/zng/src/layout.rs b/crates/zng/src/layout.rs
index 7123e6962..8dcd0e038 100644
--- a/crates/zng/src/layout.rs
+++ b/crates/zng/src/layout.rs
@@ -287,11 +287,11 @@
 //!
 //! ## Inline
 //!
-//! Layout has two modes, blocks and inline, in block layout the shape of the laidout widgets is not changed, they are always
+//! Layout has two modes, block and inline, in block layout the shape of the laidout widgets is not changed, they are always
 //! rectangular, inline layout expands layout to alter the shape of laidout widgets to potentially split into multiple rectangles that
 //! define the first line, the middle block of lines and the last line.
 //!
-//! The example below declares a `Wrap!` with 3 `Text!` children, both the wrap and text widgets support inline layout so the end-result
+//! The example below declares a `Wrap!` with three `Text!` children, both the wrap and text widgets support inline layout so the end-result
 //! is that the green text will be reshaped as two rectangles, one after the red text and one before the blue text.
 //!
 //! ```
@@ -329,7 +329,7 @@
 //! during layout and [`LAYOUT`] will have inline constraints. During layout the [`WidgetLayout::inline`] value can
 //! be set to the final inline info.
 //!
-//! After inline layout the are positioned so that the last line of the previous sibling connects with the first line of the next, all
+//! After inline layout the children are positioned so that the last line of the previous sibling connects with the first line of the next, all
 //! of the widget visual properties must support this however, the [`WIDGET.bounds().inline()`] is available during render with cached
 //! negative space clips that can quickly be used. If a visual property is not aware of inline it can potentially render over the
 //! previous sibling, inline should be disabled for the widget if the property cannot support inline.


Test patch: