🐛 Fix RTL auto-width text growing away from its right edge (#11775)

This makes RTL texts in auto-width to grow towards their left side in
the text editor v3.

AI-assisted-by: claude-opus-5
This commit is contained in:
Belén Albeza 2026-09-23 13:02:17 +02:00 committed by GitHub
parent 89e91ba372
commit 142f3d9de8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 618 additions and 29 deletions

View File

@ -175,6 +175,14 @@
[node] [node]
(= "root" (:type 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? (defn is-node?
[node] [node]
(or ^boolean (is-text-node? node) (or ^boolean (is-text-node? node)

View File

@ -15,6 +15,7 @@
[app.common.geom.matrix :as gmt] [app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.common.types.text :as ctt]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw] [app.main.data.workspace :as-alias dw]
[app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.modifiers :as dwm]
@ -63,12 +64,16 @@
([shape] ([shape]
(resize-wasm-text-modifiers shape (:content 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)] (when-let [new-size (get-wasm-text-new-size shape content)]
(let [width-scale (/ (:width new-size) (:width selrect)) (let [width-scale (/ (:width new-size) (:width selrect))
height-scale (/ (:height new-size) (:height selrect)) height-scale (/ (:height new-size) (:height selrect))
resize-v (gpt/point width-scale height-scale) 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 {id
{:modifiers {:modifiers
(ctm/resize-modifiers (ctm/resize-modifiers

View File

@ -25,11 +25,18 @@
;; finalize-only), so measure the live WASM text for the growing axes: ;; finalize-only), so measure the live WASM text for the growing axes:
;; width grows on auto-width, height on auto-width/auto-height. ;; width grows on auto-width, height on auto-width/auto-height.
grow-type (:grow-type shape) 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-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 [:rect.main.viewport-selrect
{:x (:x selrect) {:x sr-x
:y (:y selrect) :y (:y selrect)
:width sr-width :width sr-width
:height sr-height :height sr-height

View File

@ -214,7 +214,7 @@
(font-family-from-font-id (:font-id font))) fallback-fonts) (font-family-from-font-id (:font-id font))) fallback-fonts)
[{:keys [x y width height]} transform] [{: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) selrect-transform (mf/deref refs/workspace-selrect)
vbox (mf/deref refs/vbox) vbox (mf/deref refs/vbox)
[selrect transform] (dsh/get-selrect selrect-transform shape) [selrect transform] (dsh/get-selrect selrect-transform shape)
@ -231,13 +231,20 @@
overlay-width (if (= (:grow-type shape) :auto-width) overlay-width (if (= (:grow-type shape) :auto-width)
(+ max-width viewport-width) (+ max-width viewport-width)
max-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) valign (-> shape :content :vertical-align)
y (:y selrect) y (:y selrect)
y (case valign y (case valign
"bottom" (+ y (- selrect-height height)) "bottom" (+ y (- selrect-height height))
"center" (+ y (/ (- selrect-height height) 2)) "center" (+ y (/ (- selrect-height height) 2))
y)] 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 on-composition-start
(mf/use-fn (mf/use-fn

View File

@ -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"))))))))

View File

@ -513,3 +513,53 @@
(t/testing "a non-root content (e.g. paragraph) is left alone" (t/testing "a non-root content (e.g. paragraph) is left alone"
(let [node {:type "paragraph" :children []}] (let [node {:type "paragraph" :children []}]
(t/is (= node (dwt/ensure-valid-text-content node)))))) (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)))))

View File

@ -20,6 +20,7 @@
[frontend-tests.data.svg-upload-test] [frontend-tests.data.svg-upload-test]
[frontend-tests.data.uploads-test] [frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test] [frontend-tests.data.viewer-test]
[frontend-tests.data.wasm-text-test]
[frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-colors-test]
[frontend-tests.data.workspace-comments-test] [frontend-tests.data.workspace-comments-test]
[frontend-tests.data.workspace-interactions-test] [frontend-tests.data.workspace-interactions-test]
@ -141,6 +142,7 @@
'frontend-tests.data.svg-upload-test 'frontend-tests.data.svg-upload-test
'frontend-tests.data.uploads-test 'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test 'frontend-tests.data.viewer-test
'frontend-tests.data.wasm-text-test
'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-colors-test
'frontend-tests.data.workspace-comments-test 'frontend-tests.data.workspace-comments-test
'frontend-tests.data.workspace-interactions-test 'frontend-tests.data.workspace-interactions-test

View File

@ -434,6 +434,26 @@ pub fn resize_matrix(
child_bounds: &Bounds, child_bounds: &Bounds,
new_width: f32, new_width: f32,
new_height: 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 { ) -> Matrix {
let mut result = Matrix::default(); let mut result = Matrix::default();
@ -455,7 +475,7 @@ pub fn resize_matrix(
parent_transform.pre_translate(-center); parent_transform.pre_translate(-center);
let parent_transform_inv = &parent_transform.invert().unwrap_or_default(); 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)); let mut scale = Matrix::scale((scale_width, scale_height));
scale.post_translate(origin); scale.post_translate(origin);
@ -597,4 +617,40 @@ mod tests {
assert!((m.translate_x() - 0.0).abs() <= 0.1); assert!((m.translate_x() - 0.0).abs() <= 0.1);
assert!((m.translate_y() - 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);
}
} }

View File

@ -2,9 +2,25 @@ use crate::render::options::RenderOptions;
use crate::shapes::{vertical_align_offset, Shape, TextContent, Type}; use crate::shapes::{vertical_align_offset, Shape, TextContent, Type};
use crate::state::{TextEditorState, TextSelection}; use crate::state::{TextEditorState, TextSelection};
use crate::view::Viewbox; 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}; 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( pub fn render_overlay(
canvas: &Canvas, canvas: &Canvas,
viewbox: &Viewbox, viewbox: &Viewbox,
@ -112,6 +128,14 @@ fn render_selection(
canvas.restore(); 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( fn paragraphs_vertical_offset(
shape: &Shape, shape: &Shape,
layout_paragraphs: &[&skia_safe::textlayout::Paragraph], layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
@ -141,6 +165,7 @@ fn calculate_cursor_rect(
return None; return None;
} }
let x_offset = paragraphs_horizontal_offset(shape, text_content);
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs); let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph { if idx == cursor.paragraph {
@ -158,8 +183,13 @@ fn calculate_cursor_rect(
// Skia ranges are UTF-16 code units, not characters. // Skia ranges are UTF-16 code units, not characters.
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height // No glyph to anchor to: sit where the first character will appear.
(0.0, 0.0, 1.0, laid_out_para.height()) 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 { } else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range( let rects = laid_out_para.get_rects_for_range(
0..para.char_utf16_len_at(0), 0..para.char_utf16_len_at(0),
@ -168,7 +198,7 @@ fn calculate_cursor_rect(
); );
if !rects.is_empty() { if !rects.is_empty() {
let r = &rects[0].rect; let r = &rects[0].rect;
(r.left(), r.top(), r.width(), r.height()) (leading_edge(&rects[0]), r.top(), r.width(), r.height())
} else { } else {
(0.0, 0.0, 1.0, laid_out_para.height()) (0.0, 0.0, 1.0, laid_out_para.height())
} }
@ -182,14 +212,15 @@ fn calculate_cursor_rect(
); );
if !rects.is_empty() { if !rects.is_empty() {
let r = &rects[0].rect; 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() { } else if let Some(line) = laid_out_para.get_line_metrics().last() {
( // No glyph box to measure: use the end of the line.
line.left as f32 + line.width as f32, let line_end = if para.text_direction() == TextDirection::RTL {
0.0, line.left as f32
1.0, } else {
laid_out_para.height(), line.left as f32 + line.width as f32
) };
(line_end, 0.0, 1.0, laid_out_para.height())
} else { } else {
(0.0, 0.0, 1.0, laid_out_para.height()) (0.0, 0.0, 1.0, laid_out_para.height())
} }
@ -202,7 +233,7 @@ fn calculate_cursor_rect(
); );
if !rects.is_empty() { if !rects.is_empty() {
let r = &rects[0].rect; let r = &rects[0].rect;
(r.left(), r.top(), r.width(), r.height()) (leading_edge(&rects[0]), r.top(), r.width(), r.height())
} else { } else {
// Fallback: use glyph position // Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); 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( return Some(Rect::from_xywh(
cursor_x, x_offset + cursor_x,
y_offset + cursor_y, y_offset + cursor_y,
cursor_width, // cursor_width cursor_width, // cursor_width
cursor_height, cursor_height,
@ -236,6 +267,7 @@ fn calculate_selection_rects(
let paragraphs = text_content.paragraphs(); let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); 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); let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() { 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 { for text_box in text_boxes {
let r = text_box.rect; let r = text_box.rect;
rects.push(Rect::from_xywh( rects.push(Rect::from_xywh(
r.left(), x_offset + r.left(),
y_offset + r.top(), y_offset + r.top(),
r.width(), r.width(),
r.height(), r.height(),
@ -291,3 +323,92 @@ fn calculate_selection_rects(
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(&ltr), 10.0);
assert_eq!(trailing_edge(&ltr), 30.0);
assert_eq!(leading_edge(&rtl), 30.0);
assert_eq!(trailing_edge(&rtl), 10.0);
}
}

View File

@ -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,
&shape_bounds_after, &shape_bounds_after,
new_width, new_width,
new_height, new_height,
anchor,
); );
shape_bounds_after = shape_bounds_after.transform(&resize_transform); shape_bounds_after = shape_bounds_after.transform(&resize_transform);
transform.post_concat(&resize_transform); transform.post_concat(&resize_transform);

View File

@ -476,18 +476,26 @@ impl TextContent {
/// [`cached_layout_paint_offset`] on the canvas so both move together. /// [`cached_layout_paint_offset`] on the canvas so both move together.
pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point { pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point {
self.layout_paint_origin 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. /// Canvas translation from the baked paint origin to the current selrect.
/// Zero when there is no recorded origin (fall back to painting at selrect). /// Zero when there is no recorded origin (fall back to painting at selrect).
pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point { pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point {
match self.layout_paint_origin { 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), 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 /// Text content for paint when [`Rect`] size may differ from stored bounds
/// (e.g. modifier transform). Reuses `self` when width/height match; otherwise /// (e.g. modifier transform). Reuses `self` when width/height match; otherwise
/// clones paragraphs into a rebound copy with an empty layout cache. /// 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 /// Compute a tight text rect from laid-out Skia paragraphs using glyph
/// metrics (fm.top for overshoot, line descent for bottom, line left/width /// metrics (fm.top for overshoot, line descent for bottom, line left/width
/// for horizontal extent). /// for horizontal extent).
@ -720,7 +748,6 @@ impl TextContent {
} }
pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect {
let x = selrect.x();
let mut y = selrect.y(); let mut y = selrect.y();
let width = if self.grow_type() == GrowType::AutoWidth { let width = if self.grow_type() == GrowType::AutoWidth {
@ -729,6 +756,8 @@ impl TextContent {
selrect.width() selrect.width()
}; };
let x = self.layout_origin_x(selrect, width);
let height = if self.size.width.round() != width.round() { let height = if self.size.width.round() != width.round() {
self.get_height(width) self.get_height(width)
} else { } else {
@ -1153,8 +1182,8 @@ impl TextContent {
self.layout.set(result.0, result.1); self.layout.set(result.0, result.1);
self.size self.size
.copy_finite_size(result.2, default_width, default_height); .copy_finite_size(result.2, default_width, default_height);
// Paragraph paints (incl. absolute image/gradient shaders) were built // Absolute image/gradient shaders are built against `self.bounds()`, so the
// against `self.bounds()` in `paragraph_builder_group_from_text`. // 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())); 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 selrect_width = shape.selrect().width();
let text_width = text_content.get_width(selrect_width); let text_width = text_content.get_width(selrect_width);
let selrect_height = shape.selrect().height(); 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 base_y = shape.selrect.y();
let mut position_data: Vec<PositionData> = Vec::new(); let mut position_data: Vec<PositionData> = Vec::new();
let mut previous_line_height = text_content.normalized_line_height(); let mut previous_line_height = text_content.normalized_line_height();
@ -2383,4 +2412,170 @@ mod tests {
layout.clear(); layout.clear();
assert!(layout.needs_update()); 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);
}
} }