diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 04db3bb313..9a63ab11e5 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -175,6 +175,14 @@ [node] (= "root" (:type node))) +(defn rtl-content? + "True when the content has paragraphs and all of them are `\"rtl\"`; picks the + growth anchor of auto-width text. Mixed, \"none\" and empty content are ltr." + [content] + (boolean + (when-let [paragraphs (node-seq is-paragraph-node? content)] + (every? #(= "rtl" (:text-direction %)) paragraphs)))) + (defn is-node? [node] (or ^boolean (is-text-node? node) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 435c8082c4..ddd4f81400 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -15,6 +15,7 @@ [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.types.modifiers :as ctm] + [app.common.types.text :as ctt] [app.main.data.helpers :as dsh] [app.main.data.workspace :as-alias dw] [app.main.data.workspace.modifiers :as dwm] @@ -63,12 +64,16 @@ ([shape] (resize-wasm-text-modifiers shape (:content shape))) - ([{:keys [id points selrect] :as shape} content] + ([{:keys [id points selrect grow-type] :as shape} content] (when-let [new-size (get-wasm-text-new-size shape content)] (let [width-scale (/ (:width new-size) (:width selrect)) height-scale (/ (:height new-size) (:height selrect)) resize-v (gpt/point width-scale height-scale) - origin (first points)] + ;; Rtl text grows leftward, so anchor it on the top-right. + origin (if (and (= :auto-width grow-type) + (ctt/rtl-content? content)) + (second points) + (first points))] {id {:modifiers (ctm/resize-modifiers diff --git a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs index 93e7562438..ca4c74632e 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs @@ -25,11 +25,18 @@ ;; finalize-only), so measure the live WASM text for the growing axes: ;; width grows on auto-width, height on auto-width/auto-height. grow-type (:grow-type shape) - {live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape)) + {live-x :x live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape)) sr-width (if (= grow-type :auto-width) live-width (:width selrect)) - sr-height (if (= grow-type :fixed) (:height selrect) live-height)] + sr-height (if (= grow-type :fixed) (:height selrect) live-height) + ;; Rtl auto-width text is anchored on its right edge, so use the origin + ;; wasm reports. A zero measurement means it has no layout yet. + sr-x (if (and (= grow-type :auto-width) + (some? live-x) + (pos? live-width)) + live-x + (:x selrect))] [:rect.main.viewport-selrect - {:x (:x selrect) + {:x sr-x :y (:y selrect) :width sr-width :height sr-height diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 1c95406ef5..832f1f1b07 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -214,7 +214,7 @@ (font-family-from-font-id (:font-id font))) fallback-fonts) [{:keys [x y width height]} transform] - (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) + (let [{text-x :x :keys [width height]} (wasm.api/get-text-dimensions shape-id) selrect-transform (mf/deref refs/workspace-selrect) vbox (mf/deref refs/vbox) [selrect transform] (dsh/get-selrect selrect-transform shape) @@ -231,13 +231,20 @@ overlay-width (if (= (:grow-type shape) :auto-width) (+ max-width viewport-width) max-width) + ;; `on-pointer-down` feeds offsets within this element to wasm as + ;; paragraph-local coords, so this edge must sit on the text's. + x (if (and (= (:grow-type shape) :auto-width) + (some? text-x) + (pos? width)) + text-x + (:x selrect)) valign (-> shape :content :vertical-align) y (:y selrect) y (case valign "bottom" (+ y (- selrect-height height)) "center" (+ y (/ (- selrect-height height) 2)) y)] - [(assoc selrect :y y :width overlay-width :height max-height) transform]) + [(assoc selrect :x x :y y :width overlay-width :height max-height) transform]) on-composition-start (mf/use-fn diff --git a/frontend/test/frontend_tests/data/wasm_text_test.cljs b/frontend/test/frontend_tests/data/wasm_text_test.cljs new file mode 100644 index 0000000000..5624e40c04 --- /dev/null +++ b/frontend/test/frontend_tests/data/wasm_text_test.cljs @@ -0,0 +1,131 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.wasm-text-test + "Growth anchor of auto-grow wasm text shapes (see `resize-wasm-text-modifiers`). + Tests stub the wasm bridge and assert on the shape the modifiers produce." + (:require + [app.common.geom.shapes :as gsh] + [app.common.types.modifiers :as ctm] + [app.common.types.shape :as cts] + [app.main.data.workspace.wasm-text :as dwwt] + [cljs.test :as t :include-macros true])) + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn- make-content + "Text content whose paragraphs carry the given `:text-direction` values." + [& directions] + {:type "root" + :children [{:type "paragraph-set" + :children (vec (for [direction directions] + {:type "paragraph" + :text-direction direction + :children [{:text "hello"}]}))}]}) + +(defn- make-text-shape + [& {:keys [x y width height grow-type rotation] + :or {x 100 y 50 width 60 height 20 grow-type :auto-width}}] + (let [shape (-> (cts/setup-shape {:type :text + :x x + :y y + :width width + :height height}) + (assoc :grow-type grow-type))] + (if (some? rotation) + (gsh/transform-shape + shape + (ctm/rotation-modifiers shape (gsh/shape->center shape) rotation)) + shape))) + +(defn- resized + "Shape that results from `resize-wasm-text-modifiers` when the renderer + measures `new-size`." + [shape content new-size] + ;; The stub needs the real fn's arities: a variadic `fn` has no `arity$2` + ;; dispatch, so the 2-arity call site blows up. + (with-redefs [dwwt/get-wasm-text-new-size (fn ([_] new-size) ([_ _] new-size))] + (let [modifiers (dwwt/resize-wasm-text-modifiers shape content)] + (gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers]))))) + +(defn- close? [a b] + (< (abs (- a b)) 0.01)) + +;; --------------------------------------------------------------------------- +;; Growth anchor +;; --------------------------------------------------------------------------- + +(t/deftest rtl-auto-width-keeps-its-right-edge-when-growing + (t/testing "an rtl auto-width shape extends leftward: the right and top edges + stay put and x moves left" + (let [shape (make-text-shape) + content (make-content "rtl") + before (:selrect shape) + after (:selrect (resized shape content {:width 120 :height 20}))] + (t/is (close? (+ (:x after) (:width after)) + (+ (:x before) (:width before))) + "right edge preserved") + (t/is (close? (:y after) (:y before)) "top edge preserved") + (t/is (close? (:x after) 40) "x moved left by the growth") + (t/is (close? (:width after) 120))))) + +(t/deftest rtl-auto-width-keeps-its-right-edge-when-shrinking + (t/testing "deleting text shrinks the box from the left, right edge preserved" + (let [shape (make-text-shape) + content (make-content "rtl") + before (:selrect shape) + after (:selrect (resized shape content {:width 30 :height 20}))] + (t/is (close? (+ (:x after) (:width after)) + (+ (:x before) (:width before))) + "right edge preserved") + (t/is (close? (:x after) 130) "x moved right as the box narrowed")))) + +(t/deftest ltr-auto-width-keeps-its-left-edge + (t/testing "ltr content is untouched: the left edge stays anchored" + (let [shape (make-text-shape) + content (make-content "ltr") + after (:selrect (resized shape content {:width 120 :height 20}))] + (t/is (close? (:x after) 100) "left edge preserved") + (t/is (close? (:width after) 120))))) + +(t/deftest mixed-direction-auto-width-keeps-its-left-edge + (t/testing "a box with one rtl and one ltr paragraph keeps the previous + left-anchored growth" + (let [shape (make-text-shape) + content (make-content "rtl" "ltr") + after (:selrect (resized shape content {:width 120 :height 20}))] + (t/is (close? (:x after) 100) "left edge preserved")))) + +(t/deftest rtl-auto-height-keeps-its-left-edge + (t/testing "auto-height only ever changes height, so the anchor is irrelevant + and x must not move" + (let [shape (make-text-shape :grow-type :auto-height) + content (make-content "rtl") + after (:selrect (resized shape content {:width 60 :height 80}))] + (t/is (close? (:x after) 100) "x preserved") + (t/is (close? (:width after) 60) "width preserved") + (t/is (close? (:height after) 80) "height grew")))) + +(t/deftest rotated-rtl-auto-width-grows-along-its-own-axis + (t/testing "a rotated rtl shape keeps its own top-right corner, not the + axis-aligned one" + (let [shape (make-text-shape :rotation 30) + content (make-content "rtl") + before (:points shape) + after (:points (resized shape content {:width 120 :height 20}))] + ;; points are [top-left top-right bottom-right bottom-left] + (t/is (close? (:x (second after)) (:x (second before))) + "top-right x preserved") + (t/is (close? (:y (second after)) (:y (second before))) + "top-right y preserved")))) + +(t/deftest no-modifiers-when-the-renderer-has-no-size + (t/testing "a nil measurement (shape absent from wasm state) skips the resize" + (let [shape (make-text-shape)] + (with-redefs [dwwt/get-wasm-text-new-size (fn ([_] nil) ([_ _] nil))] + (t/is (nil? (dwwt/resize-wasm-text-modifiers shape (make-content "rtl")))))))) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index e460e68aa5..252a86cc09 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -513,3 +513,53 @@ (t/testing "a non-root content (e.g. paragraph) is left alone" (let [node {:type "paragraph" :children []}] (t/is (= node (dwt/ensure-valid-text-content node)))))) + +;; --------------------------------------------------------------------------- +;; txt/rtl-content? +;; --------------------------------------------------------------------------- + +(defn- make-content + "Text content whose paragraphs carry the given `:text-direction` values; a + `nil` entry leaves the attribute out entirely." + [& directions] + {:type "root" + :children [{:type "paragraph-set" + :children (vec (for [direction directions] + (cond-> {:type "paragraph" + :children [{:text "hello"}]} + (some? direction) + (assoc :text-direction direction))))}]}) + +(t/deftest rtl-content-single-rtl-paragraph + (t/testing "a lone rtl paragraph makes the content rtl" + (t/is (true? (txt/rtl-content? (make-content "rtl")))))) + +(t/deftest rtl-content-every-paragraph-rtl + (t/testing "several paragraphs, all rtl" + (t/is (true? (txt/rtl-content? (make-content "rtl" "rtl" "rtl")))))) + +(t/deftest rtl-content-mixed-directions + (t/testing "a mix of rtl and ltr is not rtl" + (t/is (false? (txt/rtl-content? (make-content "rtl" "ltr")))))) + +(t/deftest rtl-content-missing-direction-on-one-paragraph + (t/testing "a paragraph without :text-direction defaults to ltr, so the + content is not rtl" + (t/is (false? (txt/rtl-content? (make-content "rtl" nil)))))) + +(t/deftest rtl-content-all-ltr + (t/testing "all-ltr content is not rtl" + (t/is (false? (txt/rtl-content? (make-content "ltr" "ltr")))))) + +(t/deftest rtl-content-none-direction-is-ltr + (t/testing "\"none\" (the sidebar's un-toggled value) is ltr, matching what + translate-text-direction sends to the renderer" + (t/is (false? (txt/rtl-content? (make-content "none")))))) + +(t/deftest rtl-content-without-paragraphs + (t/testing "a root with no paragraph nodes is not rtl" + (t/is (false? (txt/rtl-content? {:type "root" :children []}))))) + +(t/deftest rtl-content-nil + (t/testing "nil content is not rtl and does not throw" + (t/is (false? (txt/rtl-content? nil))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 560ec71bce..6e7adc9fb5 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -20,6 +20,7 @@ [frontend-tests.data.svg-upload-test] [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] + [frontend-tests.data.wasm-text-test] [frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-comments-test] [frontend-tests.data.workspace-interactions-test] @@ -141,6 +142,7 @@ 'frontend-tests.data.svg-upload-test 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test + 'frontend-tests.data.wasm-text-test 'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-comments-test 'frontend-tests.data.workspace-interactions-test diff --git a/render-wasm/src/math.rs b/render-wasm/src/math.rs index 0b8feeed04..de5d425fb5 100644 --- a/render-wasm/src/math.rs +++ b/render-wasm/src/math.rs @@ -434,6 +434,26 @@ pub fn resize_matrix( child_bounds: &Bounds, new_width: f32, new_height: f32, +) -> Matrix { + resize_matrix_from( + parent_bounds, + child_bounds, + new_width, + new_height, + child_bounds.nw, + ) +} + +/* + * Like `resize_matrix`, but scaling about an explicit corner instead of `nw`. + * Rtl auto-width text anchors on `ne` so it extends leftward as it grows. + */ +pub fn resize_matrix_from( + parent_bounds: &Bounds, + child_bounds: &Bounds, + new_width: f32, + new_height: f32, + anchor: Point, ) -> Matrix { let mut result = Matrix::default(); @@ -455,7 +475,7 @@ pub fn resize_matrix( parent_transform.pre_translate(-center); let parent_transform_inv = &parent_transform.invert().unwrap_or_default(); - let origin = parent_transform_inv.map_point(child_bounds.nw); + let origin = parent_transform_inv.map_point(anchor); let mut scale = Matrix::scale((scale_width, scale_height)); scale.post_translate(origin); @@ -597,4 +617,40 @@ mod tests { assert!((m.translate_x() - 0.0).abs() <= 0.1); assert!((m.translate_y() - 0.0).abs() <= 0.1); } + + fn axis_aligned_bounds(x: f32, y: f32, w: f32, h: f32) -> Bounds { + Bounds::new( + Point::new(x, y), + Point::new(x + w, y), + Point::new(x + w, y + h), + Point::new(x, y + h), + ) + } + + #[test] + fn resize_matrix_keeps_the_north_west_corner() { + let bounds = axis_aligned_bounds(100.0, 50.0, 60.0, 20.0); + let matrix = resize_matrix(&bounds, &bounds, 120.0, 20.0); + let resized = bounds.transform(&matrix); + assert!(is_close_to(resized.nw.x, 100.0)); + assert!(is_close_to(resized.ne.x, 220.0)); + } + + #[test] + fn resize_matrix_from_north_east_keeps_the_right_edge() { + let bounds = axis_aligned_bounds(100.0, 50.0, 60.0, 20.0); + let matrix = resize_matrix_from(&bounds, &bounds, 120.0, 20.0, bounds.ne); + let resized = bounds.transform(&matrix); + assert!(is_close_to(resized.ne.x, 160.0), "right edge preserved"); + assert!(is_close_to(resized.nw.x, 40.0), "grew leftward"); + assert!(is_close_to(resized.nw.y, 50.0), "top edge preserved"); + } + + #[test] + fn resize_matrix_from_north_west_matches_the_default() { + let bounds = axis_aligned_bounds(100.0, 50.0, 60.0, 20.0); + let default = resize_matrix(&bounds, &bounds, 120.0, 40.0); + let explicit = resize_matrix_from(&bounds, &bounds, 120.0, 40.0, bounds.nw); + assert_eq!(default, explicit); + } } diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index d1825aeca8..b894d9f233 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -2,9 +2,25 @@ use crate::render::options::RenderOptions; use crate::shapes::{vertical_align_offset, Shape, TextContent, Type}; use crate::state::{TextEditorState, TextSelection}; use crate::view::Viewbox; -use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; +use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle, TextBox, TextDirection}; use skia_safe::{BlendMode, Canvas, Color, Paint, Rect}; +/// Caret x where a character typed *before* this glyph would land. +fn leading_edge(text_box: &TextBox) -> f32 { + match text_box.direct { + TextDirection::RTL => text_box.rect.right(), + _ => text_box.rect.left(), + } +} + +/// Caret x where a character typed *after* this glyph would land. +fn trailing_edge(text_box: &TextBox) -> f32 { + match text_box.direct { + TextDirection::RTL => text_box.rect.left(), + _ => text_box.rect.right(), + } +} + pub fn render_overlay( canvas: &Canvas, viewbox: &Viewbox, @@ -112,6 +128,14 @@ fn render_selection( canvas.restore(); } +/// Caret and selection rects are drawn through `shape.get_matrix()`, which +/// translates by the *selrect's* `left_top()`; this shifts them onto the text. +fn paragraphs_horizontal_offset(shape: &Shape, text_content: &TextContent) -> f32 { + let selrect = shape.selrect(); + let width = text_content.get_width(selrect.width()); + text_content.layout_origin_x(&selrect, width) - selrect.x() +} + fn paragraphs_vertical_offset( shape: &Shape, layout_paragraphs: &[&skia_safe::textlayout::Paragraph], @@ -141,6 +165,7 @@ fn calculate_cursor_rect( return None; } + let x_offset = paragraphs_horizontal_offset(shape, text_content); let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs); for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { if idx == cursor.paragraph { @@ -158,8 +183,13 @@ fn calculate_cursor_rect( // Skia ranges are UTF-16 code units, not characters. let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { - // Empty paragraph - use default height - (0.0, 0.0, 1.0, laid_out_para.height()) + // No glyph to anchor to: sit where the first character will appear. + let empty_x = if para.text_direction() == TextDirection::RTL { + laid_out_para.max_width() + } else { + 0.0 + }; + (empty_x, 0.0, 1.0, laid_out_para.height()) } else if char_pos == 0 { let rects = laid_out_para.get_rects_for_range( 0..para.char_utf16_len_at(0), @@ -168,7 +198,7 @@ fn calculate_cursor_rect( ); if !rects.is_empty() { let r = &rects[0].rect; - (r.left(), r.top(), r.width(), r.height()) + (leading_edge(&rects[0]), r.top(), r.width(), r.height()) } else { (0.0, 0.0, 1.0, laid_out_para.height()) } @@ -182,14 +212,15 @@ fn calculate_cursor_rect( ); if !rects.is_empty() { let r = &rects[0].rect; - (r.right(), r.top(), r.width(), r.height()) + (trailing_edge(&rects[0]), r.top(), r.width(), r.height()) } else if let Some(line) = laid_out_para.get_line_metrics().last() { - ( - line.left as f32 + line.width as f32, - 0.0, - 1.0, - laid_out_para.height(), - ) + // No glyph box to measure: use the end of the line. + let line_end = if para.text_direction() == TextDirection::RTL { + line.left as f32 + } else { + line.left as f32 + line.width as f32 + }; + (line_end, 0.0, 1.0, laid_out_para.height()) } else { (0.0, 0.0, 1.0, laid_out_para.height()) } @@ -202,7 +233,7 @@ fn calculate_cursor_rect( ); if !rects.is_empty() { let r = &rects[0].rect; - (r.left(), r.top(), r.width(), r.height()) + (leading_edge(&rects[0]), r.top(), r.width(), r.height()) } else { // Fallback: use glyph position let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); @@ -211,7 +242,7 @@ fn calculate_cursor_rect( }; return Some(Rect::from_xywh( - cursor_x, + x_offset + cursor_x, y_offset + cursor_y, cursor_width, // cursor_width cursor_height, @@ -236,6 +267,7 @@ fn calculate_selection_rects( let paragraphs = text_content.paragraphs(); let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); + let x_offset = paragraphs_horizontal_offset(shape, text_content); let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs); for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() { @@ -278,7 +310,7 @@ fn calculate_selection_rects( for text_box in text_boxes { let r = text_box.rect; rects.push(Rect::from_xywh( - r.left(), + x_offset + r.left(), y_offset + r.top(), r.width(), r.height(), @@ -291,3 +323,92 @@ fn calculate_selection_rects( rects } + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::{FontFamily, FontStyle, GrowType, Paragraph, TextAlign, TextSpan}; + use crate::uuid::Uuid; + + fn rtl_span() -> TextSpan { + TextSpan::new( + "نام".to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + 14.0, + 1.2, + 0.0, + None, + None, + TextDirection::RTL, + 400, + Uuid::nil(), + vec![], + ) + } + + /// Auto-width rtl content measured wider than its stale selrect: mid-edit, + /// where the overlay origin and the selrect diverge. + fn grown_rtl_shape(measured_width: f32) -> Shape { + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + let mut content = TextContent::new(selrect, GrowType::AutoWidth); + content.add_paragraph(Paragraph::new( + TextAlign::Right, + TextDirection::RTL, + None, + None, + 1.2, + 0.0, + vec![rtl_span()], + )); + content.size.width = measured_width; + content.size.height = 20.0; + + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(selrect.left, selrect.top, selrect.right, selrect.bottom); + shape.set_shape_type(Type::Text(content)); + shape + } + + fn text_content_of(shape: &Shape) -> &TextContent { + match &shape.shape_type { + Type::Text(content) => content, + _ => unreachable!(), + } + } + + #[test] + fn horizontal_offset_shifts_the_overlay_onto_grown_rtl_text() { + let shape = grown_rtl_shape(120.0); + // right edge 160 - measured 120 = 40, i.e. 60 left of selrect.x + assert_eq!( + paragraphs_horizontal_offset(&shape, text_content_of(&shape)), + -60.0 + ); + } + + #[test] + fn horizontal_offset_is_zero_once_the_selrect_is_committed() { + let shape = grown_rtl_shape(60.0); + assert_eq!( + paragraphs_horizontal_offset(&shape, text_content_of(&shape)), + 0.0 + ); + } + + #[test] + fn leading_and_trailing_edges_follow_the_run_direction() { + let rect = Rect::from_ltrb(10.0, 0.0, 30.0, 12.0); + let ltr = TextBox { + rect, + direct: TextDirection::LTR, + }; + let rtl = TextBox { + rect, + direct: TextDirection::RTL, + }; + assert_eq!(leading_edge(<r), 10.0); + assert_eq!(trailing_edge(<r), 30.0); + assert_eq!(leading_edge(&rtl), 30.0); + assert_eq!(trailing_edge(&rtl), 10.0); + } +} diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 1898ed7276..1bb6b9d3cb 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -320,11 +320,18 @@ fn propagate_transform( } } } - let resize_transform = math::resize_matrix( + // Rtl text keeps its right edge, so scale about the ne corner. + let anchor = if text_content.is_rtl() { + shape_bounds_after.ne + } else { + shape_bounds_after.nw + }; + let resize_transform = math::resize_matrix_from( &shape_bounds_after, &shape_bounds_after, new_width, new_height, + anchor, ); shape_bounds_after = shape_bounds_after.transform(&resize_transform); transform.post_concat(&resize_transform); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 8122ee21be..a13b4c02e7 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -476,18 +476,26 @@ impl TextContent { /// [`cached_layout_paint_offset`] on the canvas so both move together. pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point { self.layout_paint_origin - .unwrap_or_else(|| Point::new(selrect.x(), selrect.y())) + .unwrap_or_else(|| Point::new(self.selrect_origin_x(selrect), selrect.y())) } /// Canvas translation from the baked paint origin to the current selrect. /// Zero when there is no recorded origin (fall back to painting at selrect). pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point { match self.layout_paint_origin { - Some(origin) => Point::new(selrect.x() - origin.x, selrect.y() - origin.y), + Some(origin) => Point::new( + self.selrect_origin_x(selrect) - origin.x, + selrect.y() - origin.y, + ), None => Point::new(0.0, 0.0), } } + /// [`layout_origin_x`] for this content's own measured width. + fn selrect_origin_x(&self, selrect: &Rect) -> f32 { + self.layout_origin_x(selrect, self.size.width) + } + /// Text content for paint when [`Rect`] size may differ from stored bounds /// (e.g. modifier transform). Reuses `self` when width/height match; otherwise /// clones paragraphs into a rebound copy with an empty layout cache. @@ -568,6 +576,26 @@ impl TextContent { } } + /// Non-empty and every paragraph RTL. Mixed content counts as LTR, mirroring + /// `app.common.types.text/rtl-content?` on the ClojureScript side. + pub fn is_rtl(&self) -> bool { + !self.paragraphs.is_empty() + && self + .paragraphs + .iter() + .all(|paragraph| paragraph.text_direction() == TextDirection::RTL) + } + + /// Left edge of the laid-out text of `width` inside `selrect`. RTL auto-width + /// text anchors right so the box extends leftward as it grows. + pub fn layout_origin_x(&self, selrect: &Rect, width: f32) -> f32 { + if self.grow_type() == GrowType::AutoWidth && self.is_rtl() { + selrect.right() - width + } else { + selrect.x() + } + } + /// Compute a tight text rect from laid-out Skia paragraphs using glyph /// metrics (fm.top for overshoot, line descent for bottom, line left/width /// for horizontal extent). @@ -720,7 +748,6 @@ impl TextContent { } pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { - let x = selrect.x(); let mut y = selrect.y(); let width = if self.grow_type() == GrowType::AutoWidth { @@ -729,6 +756,8 @@ impl TextContent { selrect.width() }; + let x = self.layout_origin_x(selrect, width); + let height = if self.size.width.round() != width.round() { self.get_height(width) } else { @@ -1153,8 +1182,8 @@ impl TextContent { self.layout.set(result.0, result.1); self.size .copy_finite_size(result.2, default_width, default_height); - // Paragraph paints (incl. absolute image/gradient shaders) were built - // against `self.bounds()` in `paragraph_builder_group_from_text`. + // Absolute image/gradient shaders are built against `self.bounds()`, so the + // origin matches them and `cached_layout_paint_offset` carries any rtl shift. self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y())); } @@ -1824,7 +1853,7 @@ pub fn calculate_text_layout_data( let selrect_width = shape.selrect().width(); let text_width = text_content.get_width(selrect_width); let selrect_height = shape.selrect().height(); - let x = shape.selrect.x(); + let x = text_content.layout_origin_x(&shape.selrect, text_width); let base_y = shape.selrect.y(); let mut position_data: Vec = Vec::new(); let mut previous_line_height = text_content.normalized_line_height(); @@ -2383,4 +2412,170 @@ mod tests { layout.clear(); assert!(layout.needs_update()); } + + // ----------------------------------------------------------------------- + // RTL auto-width growth anchor + // ----------------------------------------------------------------------- + + fn directed_paragraph(direction: TextDirection) -> Paragraph { + let span = TextSpan::new( + "hello".to_string(), + FontFamily::new(Uuid::nil(), 400, crate::shapes::FontStyle::Normal), + 14.0, + 1.2, + 0.0, + None, + None, + direction, + 400, + Uuid::nil(), + vec![], + ); + Paragraph::new(TextAlign::Left, direction, None, None, 1.2, 0.0, vec![span]) + } + + /// Auto-width content measured wider than its stale selrect: mid-edit, where + /// the anchor is observable. + fn grown_content(directions: &[TextDirection], measured_width: f32) -> TextContent { + let bounds = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + let mut content = TextContent::new(bounds, GrowType::AutoWidth); + for direction in directions { + content.add_paragraph(directed_paragraph(*direction)); + } + content.size.width = measured_width; + content.size.height = 20.0; + content + } + + #[test] + fn is_rtl_false_when_no_paragraphs() { + let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), GrowType::AutoWidth); + assert!(!content.is_rtl()); + } + + #[test] + fn is_rtl_true_when_every_paragraph_is_rtl() { + let content = grown_content(&[TextDirection::RTL, TextDirection::RTL], 120.0); + assert!(content.is_rtl()); + } + + #[test] + fn is_rtl_false_when_directions_are_mixed() { + let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0); + assert!(!content.is_rtl()); + } + + #[test] + fn is_rtl_false_when_every_paragraph_is_ltr() { + let content = grown_content(&[TextDirection::LTR], 120.0); + assert!(!content.is_rtl()); + } + + #[test] + fn layout_origin_x_right_anchors_rtl_auto_width() { + let content = grown_content(&[TextDirection::RTL], 120.0); + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + // right edge (160) minus the measured width (120) + assert_eq!(content.layout_origin_x(&selrect, 120.0), 40.0); + } + + #[test] + fn layout_origin_x_left_anchors_ltr_auto_width() { + let content = grown_content(&[TextDirection::LTR], 120.0); + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0); + } + + #[test] + fn layout_origin_x_left_anchors_mixed_direction_auto_width() { + let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0); + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0); + } + + #[test] + fn layout_origin_x_ignores_direction_for_fixed_and_auto_height() { + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + for grow_type in [GrowType::Fixed, GrowType::AutoHeight] { + let mut content = grown_content(&[TextDirection::RTL], 120.0); + content.set_grow_type(grow_type); + assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0); + } + } + + #[test] + fn layout_origin_x_is_selrect_x_once_the_selrect_is_committed() { + let content = grown_content(&[TextDirection::RTL], 60.0); + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + assert_eq!(content.layout_origin_x(&selrect, 60.0), selrect.x()); + } + + #[test] + fn content_rect_right_anchors_grown_rtl_auto_width() { + let content = grown_content(&[TextDirection::RTL], 120.0); + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + let rect = content.content_rect(&selrect, VerticalAlign::Top); + assert_eq!(rect.x(), 40.0); + assert_eq!(rect.right(), selrect.right()); + assert_eq!(rect.width(), 120.0); + assert_eq!(rect.y(), selrect.y()); + } + + #[test] + fn content_rect_left_anchors_grown_ltr_auto_width() { + let content = grown_content(&[TextDirection::LTR], 120.0); + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + let rect = content.content_rect(&selrect, VerticalAlign::Top); + assert_eq!(rect.x(), selrect.x()); + assert_eq!(rect.width(), 120.0); + } + + #[test] + fn content_rect_unchanged_for_fixed_rtl_content() { + // A mismatch sends `content_rect` into `get_height`, which needs global + // font state tests do not have. + let mut content = grown_content(&[TextDirection::RTL], 60.0); + content.set_grow_type(GrowType::Fixed); + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + let rect = content.content_rect(&selrect, VerticalAlign::Top); + assert_eq!(rect.x(), selrect.x()); + assert_eq!(rect.width(), selrect.width()); + } + + #[test] + fn cached_paint_anchor_plus_offset_lands_on_the_layout_origin() { + let mut content = grown_content(&[TextDirection::RTL], 120.0); + content.layout_paint_origin = Some(Point::new(100.0, 50.0)); + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + let anchor = content.cached_layout_paint_anchor(&selrect); + let offset = content.cached_layout_paint_offset(&selrect); + assert_eq!( + anchor.x + offset.x, + content.layout_origin_x(&selrect, 120.0) + ); + } + + #[test] + fn cached_paint_offset_stays_a_pure_translation_when_the_shape_moves() { + let mut content = grown_content(&[TextDirection::RTL], 120.0); + content.layout_paint_origin = Some(Point::new(100.0, 50.0)); + let before = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + let after = Rect::from_xywh(130.0, 70.0, 60.0, 20.0); + let moved_by = Point::new( + content.cached_layout_paint_offset(&after).x + - content.cached_layout_paint_offset(&before).x, + content.cached_layout_paint_offset(&after).y + - content.cached_layout_paint_offset(&before).y, + ); + assert_eq!(moved_by, Point::new(30.0, 20.0)); + } + + #[test] + fn cached_paint_anchor_right_anchors_when_no_origin_was_baked() { + let content = grown_content(&[TextDirection::RTL], 120.0); + let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); + assert_eq!(content.layout_paint_origin, None); + assert_eq!(content.cached_layout_paint_anchor(&selrect).x, 40.0); + assert_eq!(content.cached_layout_paint_offset(&selrect).x, 0.0); + } }