🐛 Fix grow direction of boards containing RTL shapes

This commit is contained in:
Belén Albeza 2026-09-29 15:01:55 +02:00
parent be63107ed6
commit 6508f72914
7 changed files with 401 additions and 156 deletions

View File

@ -88,8 +88,8 @@
parent-selrect (:selrect parent)
padding (when (and (not (nil? parent)) (> (count shapes) 0))
{:p1 (- min-y (:y1 parent-selrect))
:p2 (- min-x (:x1 parent-selrect))})]
{:p1 (max 0 (- min-y (:y1 parent-selrect)))
:p2 (max 0 (- min-x (:x1 parent-selrect)))})]
(cond-> {:layout-flex-dir direction :layout-gap layout-gap}
(not (nil? padding))

View File

@ -154,8 +154,10 @@
layout-grid-columns (mapv (constantly ctl/default-track-value) cols)
parent-childs-vector (gpt/to-vec (gpo/origin (:points parent)) (gpt/point all-shapes-rect))
p-left (:x parent-childs-vector)
p-top (:y parent-childs-vector)]
;; Children poking out of the frame (or off by float noise) can't
;; give a negative padding.
p-left (max 0 (:x parent-childs-vector))
p-top (max 0 (:y parent-childs-vector))]
(-> {:layout-grid-columns layout-grid-columns
:layout-grid-rows layout-grid-rows

View File

@ -1265,6 +1265,18 @@
geom-keys)
base)))
(defn- commit-finalized-text
"Commits the text finalization, taking the final geometry from the state at
emit time so it keeps what the parent layout set; the rest comes from `state`."
[it state id opts]
(ptk/reify ::commit-finalized-text
ptk/WatchEvent
(watch [_ current-state _]
(let [resize-geom (some-> (dsh/lookup-shape current-state id)
(select-keys [:selrect :points :width :height]))
opts (assoc opts :resize-geom resize-geom)]
(rx/of (dch/commit-changes (build-finalize-commit-changes it state id opts)))))))
(defn v2-update-text-shape-content
[id content & {:keys [update-name? name finalize? save-undo? original-content]
:or {update-name? false name nil finalize? false save-undo? true original-content nil}}]
@ -1303,13 +1315,8 @@
;; modifier machinery, made auto-width typing very laggy.
new-size (when (and finalize? (not= :fixed (:grow-type shape)))
(dwwt/get-wasm-text-new-size shape content))
;; Also compute the resized geometry for the finalize commit; the
;; async `apply-wasm-modifiers` below never updates this `state`.
resize-modifiers (when (some? new-size)
(dwwt/resize-wasm-text-modifiers shape content))
resize-geom (when resize-modifiers
(-> (gsh/transform-shape shape (get-in resize-modifiers [id :modifiers]))
(select-keys [:selrect :points :width :height])))
;; New shapes: single undo on finalize only (no per-keystroke undo)
effective-save-undo? (if new-shape? finalize? save-undo?)
effective-stack-undo? (and new-shape? finalize?)
@ -1358,7 +1365,8 @@
:stack-undo? effective-stack-undo?
:undo-group (when new-shape? id)})
;; Push the auto-grow geometry to WASM/app state; the commit persists it via `resize-geom`.
;; Push the auto-grow geometry to WASM/app state, reflowing any parent
;; layout; `commit-finalized-text` reads the result back to persist it.
;; Skipped for a no-op finalize: applying it would record an undo transaction.
(when (and (some? resize-modifiers) (not finalize-no-op?))
(dwm/apply-wasm-modifiers resize-modifiers {:undo-group (when new-shape? id)})))
@ -1380,22 +1388,20 @@
(rx/concat
(if (and content-has-text? (not finalize-no-op?))
(rx/of
(dch/commit-changes
(build-finalize-commit-changes it state id
{:new-shape? new-shape?
:content-has-text? content-has-text?
:content content
;; Undo baseline for the finalize commit: restore the
;; content as it was right before this commit. For existing
;; shapes that's `prev-content`; using the (unset, nil)
;; `original-content` here wiped `:content` to nil on undo,
;; which emptied the shape and crashed the WASM editor's
;; select-all on 0 paragraphs. New shapes keep the previous
;; behavior (their create is bundled in the undo group).
:original-content (if new-shape? original-content prev-content)
:update-name? update-name?
:name name
:resize-geom resize-geom})))
(commit-finalized-text it state id
{:new-shape? new-shape?
:content-has-text? content-has-text?
:content content
;; Undo baseline for the finalize commit: restore the
;; content as it was right before this commit. For existing
;; shapes that's `prev-content`; using the (unset, nil)
;; `original-content` here wiped `:content` to nil on undo,
;; which emptied the shape and crashed the WASM editor's
;; select-all on 0 paragraphs. New shapes keep the previous
;; behavior (their create is bundled in the undo group).
:original-content (if new-shape? original-content prev-content)
:update-name? update-name?
:name name}))
(rx/empty))
(rx/of (dwt/finish-transform)
(fn [state]

View File

@ -451,6 +451,7 @@ fn reflow_shape(
reflown: &mut HashSet<Uuid>,
entries: &mut VecDeque<Modifier>,
bounds: &mut HashMap<Uuid, Bounds>,
grow_from_right: bool,
) -> Result<()> {
let Some(shape) = state.shapes.get(id) else {
return Ok(());
@ -463,18 +464,61 @@ fn reflow_shape(
};
if let Some(Layout::FlexLayout(layout_data, flex_data)) = &frame_data.layout {
let mut children =
flex_layout::reflow_flex_layout(shape, layout_data, flex_data, shapes, bounds)?;
let mut children = flex_layout::reflow_flex_layout(
shape,
layout_data,
flex_data,
shapes,
bounds,
grow_from_right,
)?;
entries.append(&mut children);
} else if let Some(Layout::GridLayout(layout_data, grid_data)) = &frame_data.layout {
let mut children =
grid_layout::reflow_grid_layout(shape, layout_data, grid_data, shapes, bounds)?;
let mut children = grid_layout::reflow_grid_layout(
shape,
layout_data,
grid_data,
shapes,
bounds,
grow_from_right,
)?;
entries.append(&mut children);
}
reflown.insert(*id);
Ok(())
}
/// Layouts that auto-size from their right edge: the ancestors of the resized
/// texts, when every text resized inside them is rtl auto-width.
fn rtl_grow_layouts(state: &State, modifiers: &[TransformEntry]) -> HashSet<Uuid> {
let mut rtl = HashSet::new();
let mut ltr = HashSet::new();
for entry in modifiers {
let Some(shape) = state.shapes.get(&entry.id) else {
continue;
};
let Type::Text(text_content) = &shape.shape_type else {
continue;
};
let Some(parent_id) = shape.parent_id else {
continue;
};
let target = if text_content.grow_type() == GrowType::AutoWidth && text_content.is_rtl() {
&mut rtl
} else {
&mut ltr
};
target.extend(shapes::all_with_ancestors(
&[parent_id],
&state.shapes,
true,
));
}
rtl.difference(&ltr).copied().collect()
}
/// Propagates a set of transforms through the shape tree, returning one
/// transform per affected shape.
///
@ -498,6 +542,7 @@ pub fn propagate_modifiers(
.collect();
let shapes = &state.shapes;
let rtl_layouts = rtl_grow_layouts(state, modifiers);
let mut modifiers = HashMap::<Uuid, Matrix>::new();
let mut bounds = HashMap::<Uuid, Bounds>::new();
let mut reflown = HashSet::<Uuid>::new();
@ -569,7 +614,14 @@ pub fn propagate_modifiers(
if reflown.contains(id) {
continue;
}
reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds_temp)?;
reflow_shape(
id,
state,
&mut reflown,
&mut entries,
&mut bounds_temp,
rtl_layouts.contains(id),
)?;
}
}
@ -934,4 +986,156 @@ mod tests {
assert_eq!(bounds.width(), 4.0);
assert_eq!(bounds.height(), 4.0);
}
fn directed_text(direction: skia_safe::textlayout::TextDirection, width: f32) -> Type {
let span = TextSpan::new(
"hello".to_string(),
FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
14.0,
1.2,
0.0,
None,
None,
direction,
400,
Uuid::nil(),
vec![],
);
let mut content = TextContent::new(
math::Rect::from_xywh(100.0, 50.0, 60.0, 20.0),
GrowType::AutoWidth,
);
content.add_paragraph(Paragraph::new(
TextAlign::Right,
direction,
None,
None,
1.2,
0.0,
vec![span],
));
// The renderer has already measured the grown text.
content.size.width = width;
content.size.height = 20.0;
Type::Text(content)
}
/// An auto-width flex board wrapping one auto-width text, both at
/// x = 100..160, after the text grows to 120: returns (board, text) bounds.
fn grow_text_in_auto_board(
direction: skia_safe::textlayout::TextDirection,
) -> (Bounds, Bounds) {
let board_id = Uuid::new_v4();
let text_id = Uuid::new_v4();
let mut state = State::new();
state.shapes.initialize(10);
{
let text = state.shapes.add_shape(text_id);
text.set_selrect(100.0, 50.0, 160.0, 70.0);
text.set_shape_type(directed_text(direction, 120.0));
text.set_parent(board_id);
}
{
let board = state.shapes.add_shape(board_id);
board.set_shape_type(Type::Frame(Frame::default()));
board.set_selrect(100.0, 50.0, 160.0, 70.0);
board.add_child(text_id);
board.set_flex_layout_data(
FlexDirection::Row,
0.0,
0.0,
AlignItems::Start,
AlignContent::Start,
JustifyItems::Start,
JustifyContent::Start,
WrapType::NoWrap,
0.0,
0.0,
0.0,
0.0,
);
board.set_flex_layout_child_data(
0.0,
0.0,
0.0,
0.0,
Sizing::Auto,
Sizing::Auto,
None,
None,
None,
None,
None,
false,
None,
);
}
// The resize the frontend sends: rtl grows from the top-right corner.
let text_bounds = state.shapes.get(&text_id).unwrap().bounds();
let anchor = if direction == skia_safe::textlayout::TextDirection::RTL {
text_bounds.ne
} else {
text_bounds.nw
};
let resize = math::resize_matrix_from(&text_bounds, &text_bounds, 120.0, 20.0, anchor);
let entry = TransformEntry::from_input(text_id, resize);
let result = propagate_modifiers(&state, &[entry], PixelPrecision::Disabled).unwrap();
let bounds_of = |id: Uuid| {
let transform = result
.iter()
.find(|entry| entry.id == id)
.map(|entry| entry.transform)
.unwrap_or_default();
state
.shapes
.get(&id)
.unwrap()
.bounds()
.transform(&transform)
};
(bounds_of(board_id), bounds_of(text_id))
}
#[test]
fn test_auto_board_grows_left_with_rtl_text() {
let (board, text) = grow_text_in_auto_board(skia_safe::textlayout::TextDirection::RTL);
assert!(
is_close_to(board.ne.x, 160.0),
"board right edge {}",
board.ne.x
);
assert!(
is_close_to(board.nw.x, 40.0),
"board left edge {}",
board.nw.x
);
assert!(is_close_to(text.nw.x, 40.0), "text left edge {}", text.nw.x);
assert!(
is_close_to(text.ne.x, 160.0),
"text right edge {}",
text.ne.x
);
}
#[test]
fn test_auto_board_grows_right_with_ltr_text() {
let (board, text) = grow_text_in_auto_board(skia_safe::textlayout::TextDirection::LTR);
assert!(
is_close_to(board.nw.x, 100.0),
"board left edge {}",
board.nw.x
);
assert!(
is_close_to(board.ne.x, 220.0),
"board right edge {}",
board.ne.x
);
assert!(
is_close_to(text.nw.x, 100.0),
"text left edge {}",
text.nw.x
);
}
}

View File

@ -1,6 +1,7 @@
use std::collections::HashMap;
use crate::math::Bounds;
use crate::error::{Error, Result};
use crate::math::{Bounds, Matrix};
use crate::shapes::Shape;
use crate::uuid::Uuid;
@ -13,3 +14,31 @@ impl GetBounds for HashMap<Uuid, Bounds> {
self.get(&shape.id).copied().unwrap_or(shape.bounds())
}
}
/// Scale that auto-sizes a layout along its own axes, keeping its top-right
/// corner when `from_right` (rtl text growth) and its top-left one otherwise.
pub fn auto_size_matrix(
layout_bounds: &Bounds,
scale_width: f32,
scale_height: f32,
from_right: bool,
) -> Result<Matrix> {
let parent_transform = layout_bounds.transform_matrix().unwrap_or_default();
let parent_transform_inv = &parent_transform.invert().ok_or(Error::CriticalError(
"Failed to invert parent transform".to_string(),
))?;
let anchor = if from_right {
layout_bounds.ne
} else {
layout_bounds.nw
};
let origin = parent_transform_inv.map_point(anchor);
let mut scale = Matrix::scale((scale_width, scale_height));
scale.post_translate(origin);
scale.post_concat(&parent_transform);
scale.pre_translate(-origin);
scale.pre_concat(parent_transform_inv);
Ok(scale)
}

View File

@ -1,4 +1,4 @@
use crate::error::{Error, Result};
use crate::error::Result;
use crate::math::{self as math, Bounds, Matrix, Point, Vector, VectorExt};
use crate::shapes::{
AlignContent, AlignItems, AlignSelf, ConstraintH, ConstraintV, FlexData, JustifyContent,
@ -9,7 +9,7 @@ use crate::uuid::Uuid;
use std::collections::{HashMap, VecDeque};
use super::common::GetBounds;
use super::common::{self, GetBounds};
const MIN_SIZE: f32 = 0.01;
const MAX_SIZE: f32 = f32::INFINITY;
@ -610,83 +610,16 @@ pub fn reflow_flex_layout(
flex_data: &FlexData,
shapes: ShapesPoolRef,
bounds: &mut HashMap<Uuid, Bounds>,
grow_from_right: bool,
) -> Result<VecDeque<Modifier>> {
let mut result = VecDeque::new();
let layout_bounds = &bounds.find(shape);
let layout_axis = LayoutAxis::new(shape, layout_bounds, layout_data, flex_data);
let tracks = calculate_track_data(shape, layout_data, flex_data, layout_bounds, shapes, bounds);
for track in tracks.iter() {
let total_shapes_size = track
.shapes
.iter()
.map(|s| s.main_size + s.margin_main_start + s.margin_main_end)
.sum::<f32>();
let mut shape_anchor = first_anchor(layout_data, &layout_axis, track, total_shapes_size);
for child_axis in track.shapes.iter() {
let child_id = child_axis.id;
let Some(child) = shapes.get(&child_id) else {
continue;
};
let position = child_position(
child,
shape_anchor,
layout_data,
&layout_axis,
child_axis,
track,
);
let child_bounds = &child_axis.bounds;
let delta_v = Vector::new_points(&child_bounds.nw, &position);
let (new_width, new_height) = if flex_data.is_row() {
(child_axis.main_size, child_axis.across_size)
} else {
(child_axis.across_size, child_axis.main_size)
};
let mut transform = Matrix::default();
let mut force_reflow = false;
if (new_width - child_bounds.width()).abs() > MIN_SIZE
|| (new_height - child_bounds.height()).abs() > MIN_SIZE
{
// When the child is fill we need to force a reflow
force_reflow = true;
transform.post_concat(&math::resize_matrix(
layout_bounds,
child_bounds,
new_width,
new_height,
));
}
if delta_v.x.abs() > MIN_SIZE || delta_v.y.abs() > MIN_SIZE {
transform.post_concat(&Matrix::translate(delta_v));
}
// Skip identity: propagating it fans out through the whole subtree.
if !math::identitish(&transform) {
result.push_back(Modifier::transform_propagate(child.id, transform));
if child.has_layout() {
result.push_back(Modifier::reflow(child.id, force_reflow));
}
}
shape_anchor = next_anchor(
layout_data,
&layout_axis,
child_axis,
track,
shape_anchor,
total_shapes_size,
);
}
}
if layout_axis.is_auto_across || layout_axis.is_auto_main {
// Sized before placing children: a layout growing from the right moves its
// left edge, and the children have to follow it.
let auto_size = if layout_axis.is_auto_across || layout_axis.is_auto_main {
let width = layout_bounds.width();
let height = layout_bounds.height();
@ -747,21 +680,90 @@ pub fn reflow_flex_layout(
)
};
let parent_transform = layout_bounds.transform_matrix().unwrap_or_default();
let scale =
common::auto_size_matrix(layout_bounds, scale_width, scale_height, grow_from_right)?;
Some((scale, layout_bounds.transform(&scale)))
} else {
None
};
let shift = auto_size
.as_ref()
.filter(|_| grow_from_right)
.map(|(_, after)| Vector::new_points(&layout_bounds.nw, &after.nw))
.unwrap_or_else(|| Vector::new(0.0, 0.0));
let parent_transform_inv = &parent_transform.invert().ok_or(Error::CriticalError(
"Failed to invert parent transform".to_string(),
))?;
let origin = parent_transform_inv.map_point(layout_bounds.nw);
for track in tracks.iter() {
let total_shapes_size = track
.shapes
.iter()
.map(|s| s.main_size + s.margin_main_start + s.margin_main_end)
.sum::<f32>();
let mut shape_anchor = first_anchor(layout_data, &layout_axis, track, total_shapes_size);
let mut scale = Matrix::scale((scale_width, scale_height));
scale.post_translate(origin);
scale.post_concat(&parent_transform);
scale.pre_translate(-origin);
scale.pre_concat(parent_transform_inv);
for child_axis in track.shapes.iter() {
let child_id = child_axis.id;
let Some(child) = shapes.get(&child_id) else {
continue;
};
let layout_bounds_after = layout_bounds.transform(&scale);
let position = child_position(
child,
shape_anchor,
layout_data,
&layout_axis,
child_axis,
track,
);
let child_bounds = &child_axis.bounds;
let delta_v = Vector::new_points(&child_bounds.nw, &position);
let (new_width, new_height) = if flex_data.is_row() {
(child_axis.main_size, child_axis.across_size)
} else {
(child_axis.across_size, child_axis.main_size)
};
let mut transform = Matrix::default();
let mut force_reflow = false;
if (new_width - child_bounds.width()).abs() > MIN_SIZE
|| (new_height - child_bounds.height()).abs() > MIN_SIZE
{
// When the child is fill we need to force a reflow
force_reflow = true;
transform.post_concat(&math::resize_matrix(
layout_bounds,
child_bounds,
new_width,
new_height,
));
}
let delta_v = delta_v + shift;
if delta_v.x.abs() > MIN_SIZE || delta_v.y.abs() > MIN_SIZE {
transform.post_concat(&Matrix::translate(delta_v));
}
// Skip identity: propagating it fans out through the whole subtree.
if !math::identitish(&transform) {
result.push_back(Modifier::transform_propagate(child.id, transform));
if child.has_layout() {
result.push_back(Modifier::reflow(child.id, force_reflow));
}
}
shape_anchor = next_anchor(
layout_data,
&layout_axis,
child_axis,
track,
shape_anchor,
total_shapes_size,
);
}
}
if let Some((scale, layout_bounds_after)) = auto_size {
// Propagate the parent auto-resize to absolute children using their constraints.
for child_id in shape.children_ids_iter(true) {
let Some(child) = shapes.get(child_id) else {

View File

@ -1,4 +1,4 @@
use crate::error::{Error, Result};
use crate::error::Result;
use crate::math::{self as math, intersect_rays, Bounds, Matrix, Point, Ray, Vector, VectorExt};
use crate::shapes::{
AlignContent, AlignItems, AlignSelf, Frame, GridCell, GridData, GridTrack, GridTrackType,
@ -10,7 +10,7 @@ use crate::uuid::Uuid;
use std::collections::{HashMap, HashSet, VecDeque};
use super::common::GetBounds;
use super::common::{self, GetBounds};
const MIN_SIZE: f32 = 0.01;
const MAX_SIZE: f32 = f32::INFINITY;
@ -742,6 +742,7 @@ pub fn reflow_grid_layout(
grid_data: &GridData,
shapes: ShapesPoolRef,
bounds: &mut HashMap<Uuid, Bounds>,
grow_from_right: bool,
) -> Result<VecDeque<Modifier>> {
let mut result = VecDeque::new();
let layout_bounds = bounds.find(shape);
@ -769,6 +770,43 @@ pub fn reflow_grid_layout(
bounds,
);
// Sized before placing children: a layout growing from the right moves its
// left edge, and the children have to follow it.
let auto_size = if shape.is_layout_horizontal_auto() || shape.is_layout_vertical_auto() {
let width = layout_bounds.width();
let height = layout_bounds.height();
let mut scale_width = 1.0;
let mut scale_height = 1.0;
if shape.is_layout_horizontal_auto() {
let auto_width = column_tracks.iter().map(|t| t.size).sum::<f32>()
+ layout_data.padding_left
+ layout_data.padding_right
+ column_tracks.len().saturating_sub(1) as f32 * layout_data.column_gap;
scale_width = auto_width / width;
}
if shape.is_layout_vertical_auto() {
let auto_height = row_tracks.iter().map(|t| t.size).sum::<f32>()
+ layout_data.padding_top
+ layout_data.padding_bottom
+ row_tracks.len().saturating_sub(1) as f32 * layout_data.row_gap;
scale_height = auto_height / height;
}
let scale =
common::auto_size_matrix(&layout_bounds, scale_width, scale_height, grow_from_right)?;
Some((scale, layout_bounds.transform(&scale)))
} else {
None
};
let shift = auto_size
.as_ref()
.filter(|_| grow_from_right)
.map(|(_, after)| Vector::new_points(&layout_bounds.nw, &after.nw))
.unwrap_or_else(|| Vector::new(0.0, 0.0));
let cells = create_cell_data(
&layout_bounds,
&children,
@ -878,7 +916,7 @@ pub fn reflow_grid_layout(
h_anchor * hv.x + v_anchor * vv.x,
h_anchor * hv.y + v_anchor * vv.y,
);
let delta_v = Vector::new_points(&child_ref, &position);
let delta_v = Vector::new_points(&child_ref, &position) + shift;
if delta_v.x.abs() > MIN_SIZE || delta_v.y.abs() > MIN_SIZE {
transform.post_concat(&Matrix::translate(delta_v));
@ -893,43 +931,7 @@ pub fn reflow_grid_layout(
}
}
if shape.is_layout_horizontal_auto() || shape.is_layout_vertical_auto() {
let width = layout_bounds.width();
let height = layout_bounds.height();
let mut scale_width = 1.0;
let mut scale_height = 1.0;
if shape.is_layout_horizontal_auto() {
let auto_width = column_tracks.iter().map(|t| t.size).sum::<f32>()
+ layout_data.padding_left
+ layout_data.padding_right
+ column_tracks.len().saturating_sub(1) as f32 * layout_data.column_gap;
scale_width = auto_width / width;
}
if shape.is_layout_vertical_auto() {
let auto_height = row_tracks.iter().map(|t| t.size).sum::<f32>()
+ layout_data.padding_top
+ layout_data.padding_bottom
+ row_tracks.len().saturating_sub(1) as f32 * layout_data.row_gap;
scale_height = auto_height / height;
}
let parent_transform = layout_bounds.transform_matrix().unwrap_or_default();
let parent_transform_inv = &parent_transform.invert().ok_or(Error::CriticalError(
"Failed to invert parent transform".to_string(),
))?;
let origin = parent_transform_inv.map_point(layout_bounds.nw);
let mut scale = Matrix::scale((scale_width, scale_height));
scale.post_translate(origin);
scale.post_concat(&parent_transform);
scale.pre_translate(-origin);
scale.pre_concat(parent_transform_inv);
let layout_bounds_after = layout_bounds.transform(&scale);
if let Some((scale, layout_bounds_after)) = auto_size {
result.push_back(Modifier::parent(shape.id, scale));
bounds.insert(shape.id, layout_bounds_after);
}