mirror of
https://github.com/penpot/penpot.git
synced 2026-09-24 04:46:14 +00:00
🐛 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:
parent
89e91ba372
commit
142f3d9de8
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
131
frontend/test/frontend_tests/data/wasm_text_test.cljs
Normal file
131
frontend/test/frontend_tests/data/wasm_text_test.cljs
Normal 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"))))))))
|
||||
@ -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)))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<PositionData> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user