🐛 Show the size badge on paths and straight lines (#12066)

* 🐛 Show the size badge on paths and straight lines

The badge was hidden when the shape was smaller than the badge in
either side. Flat paths have a zero width or height, so they never
showed it, and straight two-point paths hid it on purpose.

Paths now only compare their longest side with the badge width. A
straight line compares its length, and the badge runs along the
line, rotated with it and kept upright. Near the bottom of the
viewport the badge moves to the other side of the line.

The badge now reads the size from the shape :selrect, like the
measures inputs, so a rotated path shows the same values in both
places. The three badge branches share a single render block.

AI-assisted-by: claude-opus-5-5

* ♻️ Extract the size badge layout and add tests for it

Move the badge geometry out of selection-size-badge* into the pure
size-badge-layout function, so it can be tested without rendering.
The component now only picks the color and draws the result.

Add unit tests for rects, flat paths and straight lines: when the
badge hides, where it sits, how it rotates and how it moves when it
reaches the bottom of the viewport. Keep the line rotation between
-90 and 90 degrees instead of returning 360 for lines drawn leftward.

The e2e test still expected straight lines to hide the badge. It now
checks that the badge shows and follows the line angle.

AI-assisted-by: claude-opus-5-5

* ✨ Show the length of straight lines in the size badge

A straight two-point path is measured by its length, not by the box
around it, so its size badge now shows the length with two decimals at
most instead of width x height. Other shapes and paths keep width x
height.

The shorter text makes the badge narrower, so it also shows on shorter
lines; the rule is the same, the badge needs the line to be at least as
long as the badge.

Relates to #12064

AI-assisted-by: claude-opus-5-5

* 🐛 Hide the path size badge when the path is lower than it

Paths other than a straight line only hid the size badge when their
longest side was narrower than it, so a wide but low path, or a narrow
but tall one, still showed it. Flat paths no longer need that special
case, since straight lines get their own length badge. Use the same
rule as any other shape: hide the badge when either the width or the
height of the path is smaller than the badge.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Eva Marco 2026-10-07 09:18:57 +02:00 committed by GitHub
parent 84f068aca7
commit 2f47be6c15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 265 additions and 82 deletions

View File

@ -171,8 +171,8 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) =
// Workaround: hover viewport first to avoid nil mouse position crash
await workspacePage.viewport.hover();
// Draw a path with two segments; a single straight segment shows
// endpoint controls instead of the size badge
// Draw a path with two segments; a single straight segment places
// the size badge along the line instead
await workspacePage.pathButton.click();
await workspacePage.clickAt(779, 163);
await workspacePage.clickAt(951, 258);
@ -189,7 +189,7 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) =
await expect(badgeText).toHaveText(/\d+\.?\d* x \d+\.?\d*/);
});
test("Selection size badge is hidden for straight line paths", async ({
test("Selection size badge runs along straight line paths", async ({
page,
}) => {
const workspacePage = new WasmWorkspacePage(page);
@ -204,7 +204,7 @@ test("Selection size badge is hidden for straight line paths", async ({
// Workaround: hover viewport first to avoid nil mouse position crash
await workspacePage.viewport.hover();
// Draw a path with a single straight segment
// Draw a path with a single straight segment, going down to the right
await workspacePage.pathButton.click();
await workspacePage.clickAt(779, 163);
await workspacePage.clickAt(951, 258);
@ -216,7 +216,16 @@ test("Selection size badge is hidden for straight line paths", async ({
await page.keyboard.press("Escape");
await expect(page.locator(".line-controls")).toBeVisible();
await expect(page.locator(".selection-size-badge")).toHaveCount(0);
// A straight line shows its length, with two decimals at most
const badge = page.locator(".selection-size-badge");
await expect(badge.locator("text")).toHaveText(/^\d+(\.\d{1,2})?$/);
// The badge follows the line angle, about 29 degrees
const transform = await badge.getAttribute("transform");
const rotation = Number(transform.match(/rotate\(([^)]+)\)/)[1]);
expect(rotation).toBeGreaterThan(20);
expect(rotation).toBeLessThan(40);
});
test("User makes a group", async ({ page }) => {

View File

@ -16,6 +16,7 @@
[app.common.math :as mth]
[app.common.types.component :as ctk]
[app.common.types.path :as path]
[app.common.types.path.helpers :as path.helpers]
[app.common.uuid :as uuid]
[app.main.constants :as mconst]
[app.main.ui.formats :as fmt]
@ -239,28 +240,41 @@
(<= (+ cy (/ badge-height 2))
(+ (:y vbox) (:height vbox)))))
(mf/defc selection-size-badge*
[{:keys [zoom shapes vbox]}]
(defn- line-badge-position
"Badge center and rotation for a badge running along the segment `p1`-`p2`.
The direction is flipped when needed so the text stays upright."
[p1 p2 offset]
(let [dir (gpt/subtract p2 p1)
angle (gpt/angle dir)
upright? (and (> angle -90) (<= angle 90))
dir (if upright? dir (gpt/negate dir))
rot (cond
upright? angle
(pos? angle) (- angle 180)
:else (+ angle 180))
mid-point (gpt/lerp p1 p2 0.5)
normal (gpt/normal-left dir)]
{:cx (+ (:x mid-point) (* (:x normal) offset))
:cy (+ (:y mid-point) (* (:y normal) offset))
:rot rot}))
(defn size-badge-layout
"Geometry of the size badge for the selected `shapes`, in viewport
units. Returns nil when the badge is hidden, otherwise a map with the
badge `:text`, `:width`, `:height`, its center `:cx`/`:cy` and its
rotation `:rot` in degrees."
[shapes zoom vbox]
(let [badge-height (/ selection-badge-height zoom)
badge-padding-x (/ selection-badge-padding-x zoom)
badge-gap (/ selection-badge-vertical-gap zoom)
badge-radius (/ selection-badge-border-radius zoom)
badge-char-width (/ selection-badge-char-width zoom)
single-shape (and (= (count shapes) 1) (first shapes))
;; Straight paths use endpoint controls instead of a size badge.
single-line? (and single-shape
(cfh/path-shape? single-shape)
(path/single-line? (dm/get-prop single-shape :content)))
component-color? (if single-shape
(ctk/instance-head? single-shape)
(every? ctk/instance-head? shapes))
badge-bg-color (if component-color?
selection-badge-bg-color-component
selection-badge-bg-color)
rotation (when single-shape (dm/get-prop single-shape :rotation))
has-rotation? (and rotation (not (mth/almost-zero? rotation)))
@ -269,86 +283,101 @@
;; including component instances and shapes with transforms.
selrect (gsh/shapes->rect shapes)
;; For single shapes we show the original dimensions,
;; for multiple shapes show the bounding box
shape-width (if single-shape
(or (dm/get-prop single-shape :width)
(:width selrect))
(:width selrect))
shape-height (if single-shape
(or (dm/get-prop single-shape :height)
(:height selrect))
(:height selrect))
;; For single shapes we show the same dimensions as the measures
;; inputs (the shape :selrect, which ignores rotation and also
;; exists for paths); for multiple shapes show the bounding box
shape-rect (if single-shape
(dm/get-prop single-shape :selrect)
selrect)
shape-width (:width shape-rect)
shape-height (:height shape-rect)
text (dm/str (fmt/format-number shape-width) " x " (fmt/format-number shape-height))
[line-p1 line-p2]
(when single-line?
(let [content (dm/get-prop single-shape :content)]
[(path.helpers/segment->point (nth content 0))
(path.helpers/segment->point (nth content 1))]))
line-length (when single-line?
(gpt/distance line-p1 line-p2))
;; A straight line shows its length (two decimals at most);
;; other shapes show their width and height.
text (if single-line?
(fmt/format-number line-length)
(dm/str (fmt/format-number shape-width) " x " (fmt/format-number shape-height)))
text-width (* (count text) badge-char-width)
badge-width (+ text-width (* 2 badge-padding-x))
offset (+ badge-gap (/ badge-height 2))
hidden? (or single-line?
(< shape-width badge-width)
(< shape-height badge-height))]
;; A straight line only has a length; any other shape, paths
;; included, must fit the badge in both dimensions.
hidden? (if single-line?
(< line-length badge-width)
(or (< shape-width badge-width)
(< shape-height badge-height)))]
(when-not ^boolean hidden?
(if has-rotation?
(let [edge (get-edge-for-badge rotation)
points (dm/get-prop single-shape :points)
offset (+ badge-gap (/ badge-height 2))
(-> (cond
single-line?
(let [position (line-badge-position line-p1 line-p2 offset)]
(if (fits-above-vbox-bottom? (:cy position) badge-height vbox)
position
;; Same rotation, opposite side of the line.
(let [flipped (line-badge-position line-p1 line-p2 (- offset))]
(assoc flipped :rot (:rot position)))))
has-rotation?
(let [edge (get-edge-for-badge rotation)
points (dm/get-prop single-shape :points)
position (edge-badge-position points edge offset)
position (if (fits-above-vbox-bottom? (:cy position) badge-height vbox)
position
(edge-badge-position points (opposite-edge edge) offset))]
;; Rotation follows the original edge even if the position
;; flips to the opposite one for lack of space, so the text
;; stays upright.
badge-rot (+ rotation (edge-rot-offset edge))
(assoc position :rot (+ rotation (edge-rot-offset edge))))
position (edge-badge-position points edge offset)
position (if (fits-above-vbox-bottom? (:cy position) badge-height vbox)
position
(edge-badge-position points (opposite-edge edge) offset))
:else
(let [below-cy (+ (:y selrect) (:height selrect) offset)]
{:cx (+ (:x selrect) (/ (:width selrect) 2))
:cy (if (fits-above-vbox-bottom? below-cy badge-height vbox)
below-cy
(- (:y selrect) offset))
:rot 0}))
(assoc :text text
:width badge-width
:height badge-height)))))
badge-x (- (/ badge-width 2))
badge-y (- (/ badge-height 2))
badge-cx (:cx position)
badge-cy (:cy position)]
(mf/defc selection-size-badge*
[{:keys [zoom shapes vbox]}]
(let [single-shape (and (= (count shapes) 1) (first shapes))
component-color? (if single-shape
(ctk/instance-head? single-shape)
(every? ctk/instance-head? shapes))
badge-bg-color (if component-color?
selection-badge-bg-color-component
selection-badge-bg-color)
badge-radius (/ selection-badge-border-radius zoom)]
[:g.selection-size-badge {:pointer-events "none"
:transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")}
[:rect {:x badge-x
:y badge-y
:width badge-width
:height badge-height
:rx badge-radius
:ry badge-radius
:style {:fill badge-bg-color}}]
[:text {:class (stl/css :badge-text)
:x 0
:y 0
:text-anchor "middle"
:dominant-baseline "middle"}
text]])
(let [badge-x (- (/ badge-width 2))
badge-y (- (/ badge-height 2))
badge-cx (+ (:x selrect) (/ (:width selrect) 2))
below-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2))
badge-cy (if (fits-above-vbox-bottom? below-cy badge-height vbox)
below-cy
(- (:y selrect) badge-gap (/ badge-height 2)))]
[:g.selection-size-badge {:pointer-events "none"
:transform (dm/str "translate(" badge-cx "," badge-cy ")")}
[:rect {:x badge-x
:y badge-y
:width badge-width
:height badge-height
:rx badge-radius
:ry badge-radius
:style {:fill badge-bg-color}}]
[:text {:class (stl/css :badge-text)
:x 0
:y 0
:text-anchor "middle"
:dominant-baseline "middle"}
text]])))))
(when-let [{:keys [text width height cx cy rot]} (size-badge-layout shapes zoom vbox)]
[:g.selection-size-badge {:pointer-events "none"
:transform (dm/str "translate(" cx "," cy ") rotate(" rot ")")}
[:rect {:x (- (/ width 2))
:y (- (/ height 2))
:width width
:height height
:rx badge-radius
:ry badge-radius
:style {:fill badge-bg-color}}]
[:text {:class (stl/css :badge-text)
:x 0
:y 0
:text-anchor "middle"
:dominant-baseline "middle"}
text]])))
(mf/defc distance-display* [{:keys [from to zoom bounds]}]
(let [fixed-x (if (gsh/fully-contained? from to)

View File

@ -108,6 +108,7 @@
[frontend-tests.ui.history-test]
[frontend-tests.ui.inspect-stroke-tokens-test]
[frontend-tests.ui.layout-container-multiple-test]
[frontend-tests.ui.measurements-test]
[frontend-tests.ui.measures-menu-props-test]
[frontend-tests.ui.organization-team-switch-test]
[frontend-tests.ui.routes-test]
@ -242,6 +243,7 @@
'frontend-tests.ui.history-test
'frontend-tests.ui.inspect-stroke-tokens-test
'frontend-tests.ui.layout-container-multiple-test
'frontend-tests.ui.measurements-test
'frontend-tests.ui.measures-menu-props-test
'frontend-tests.ui.organization-team-switch-test
'frontend-tests.ui.routes-test

View File

@ -0,0 +1,143 @@
;; 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.ui.measurements-test
(:require
[app.common.geom.rect :as grc]
[app.common.math :as mth]
[app.common.types.shape :as cts]
[app.main.ui.measurements :as msr]
[cljs.test :as t :include-macros true]))
;; At zoom 1 the badge is 16 high, has 6 of horizontal padding, 6.5 per
;; character, and sits 8 away from the shape: its center is 16 away.
;; A flat path selrect has a minimum side of 0.01, which the measures
;; inputs show too.
(def ^:private big-vbox {:x -1000 :y -1000 :width 5000 :height 5000})
(defn- rect
[x y width height]
(cts/setup-shape {:type :rect :x x :y y :width width :height height}))
(defn- polyline
"A path through `points`, each a [x y] pair."
[& points]
(let [[[x0 y0] & others] points]
(cts/setup-shape
{:type :path
:content (into [{:command :move-to :params {:x x0 :y y0}}]
(map (fn [[x y]] {:command :line-to :params {:x x :y y}}))
others)})))
(defn- close-layout?
[expected layout]
(and (some? layout)
(every? (fn [[k v]]
(let [actual (get layout k)]
(if (number? v)
(mth/close? v actual 0.001)
(= v actual))))
expected)))
;; --- Plain shapes
(t/deftest rect-badge-sits-centered-below-the-shape
(let [layout (msr/size-badge-layout [(rect 0 0 100 50)] 1 big-vbox)]
;; "100 x 50" has 8 characters: 8 * 6.5 + 2 * 6
(t/is (close-layout? {:text "100 x 50" :width 64 :height 16
:cx 50 :cy 66 :rot 0}
layout))))
(t/deftest rect-narrower-than-the-badge-hides-it
(t/is (nil? (msr/size-badge-layout [(rect 0 0 40 40)] 1 big-vbox))))
(t/deftest rect-lower-than-the-badge-hides-it
(t/is (nil? (msr/size-badge-layout [(rect 0 0 200 10)] 1 big-vbox))))
(t/deftest badge-size-scales-with-the-zoom
;; At zoom 2 the badge is 28.75 x 8, so a 40 x 40 rect fits it.
(let [layout (msr/size-badge-layout [(rect 0 0 40 40)] 2 big-vbox)]
(t/is (close-layout? {:text "40 x 40" :width 28.75 :height 8 :cy 48}
layout))))
(t/deftest rect-badge-moves-above-at-the-vbox-bottom
(let [vbox {:x 0 :y 0 :width 1000 :height 60}
layout (msr/size-badge-layout [(rect 0 0 100 50)] 1 vbox)]
(t/is (close-layout? {:cx 50 :cy -16 :rot 0} layout))))
(t/deftest multiple-shapes-show-the-bounding-box
(let [layout (msr/size-badge-layout [(rect 0 0 100 50) (rect 200 100 100 50)]
1 big-vbox)]
(t/is (close-layout? {:text "300 x 150" :cx 150 :cy 166} layout))))
;; --- Paths
(t/deftest path-badge-sits-centered-below-the-path
;; Three points, so not a straight line: it uses the box badge.
(let [layout (msr/size-badge-layout [(polyline [0 0] [100 100] [200 0])]
1 big-vbox)]
(t/is (close-layout? {:text "200 x 100" :cx 100 :cy 116 :rot 0}
layout))))
(t/deftest path-lower-than-the-badge-hides-it
(t/is (nil? (msr/size-badge-layout [(polyline [0 0] [100 10] [200 0])]
1 big-vbox)))
;; A flat path, too: its height is the 0.01 minimum.
(t/is (nil? (msr/size-badge-layout [(polyline [0 100] [100 100] [200 100])]
1 big-vbox))))
(t/deftest path-narrower-than-the-badge-hides-it
(t/is (nil? (msr/size-badge-layout [(polyline [0 0] [10 100] [20 0])]
1 big-vbox))))
(t/deftest path-badge-reads-the-selrect-like-the-inputs
;; The points enclose a larger box than the selrect, as they do on a
;; rotated path. The text must match the measures inputs.
(let [path (-> (polyline [0 0] [100 50] [200 0])
(assoc :points (grc/rect->points (grc/make-rect 0 0 300 300))))
layout (msr/size-badge-layout [path] 1 big-vbox)]
(t/is (= "200 x 50" (:text layout)))))
;; --- Straight two-point lines
(t/deftest horizontal-line-badge-runs-below-the-line
(let [layout (msr/size-badge-layout [(polyline [0 50] [200 50])] 1 big-vbox)]
(t/is (close-layout? {:text "200" :cx 100 :cy 66 :rot 0} layout))))
(t/deftest line-drawn-right-to-left-keeps-the-text-upright
(let [layout (msr/size-badge-layout [(polyline [200 50] [0 50])] 1 big-vbox)]
(t/is (close-layout? {:cx 100 :cy 66 :rot 0} layout))))
(t/deftest vertical-line-badge-runs-along-the-line
(let [down (msr/size-badge-layout [(polyline [0 0] [0 200])] 1 big-vbox)
up (msr/size-badge-layout [(polyline [0 200] [0 0])] 1 big-vbox)]
(t/is (close-layout? {:text "200" :cx -16 :cy 100 :rot 90} down))
(t/is (close-layout? {:cx -16 :cy 100 :rot 90} up))))
(t/deftest diagonal-line-badge-follows-the-line-angle
(let [layout (msr/size-badge-layout [(polyline [0 0] [100 100])] 1 big-vbox)
offset (/ 16 (mth/sqrt 2))]
(t/is (close-layout? {:text "141.42" :cx (- 50 offset) :cy (+ 50 offset) :rot 45}
layout))))
(t/deftest line-shorter-than-the-badge-hides-it
;; "20" needs a 25 wide badge.
(t/is (nil? (msr/size-badge-layout [(polyline [0 0] [20 0])] 1 big-vbox))))
(t/deftest line-badge-shows-the-length-with-two-decimals-at-most
(t/is (= "70.71" (:text (msr/size-badge-layout [(polyline [0 0] [50 50])] 1 big-vbox))))
(t/is (= "50" (:text (msr/size-badge-layout [(polyline [0 0] [30 40])] 1 big-vbox)))))
(t/deftest diagonal-line-uses-its-length-to-fit-the-badge
;; Both sides (33) are narrower than the badge for "46.67" (44.5), but
;; the line is about 46.67 long, so the badge fits along it.
(t/is (some? (msr/size-badge-layout [(polyline [0 0] [33 33])] 1 big-vbox))))
(t/deftest line-badge-moves-to-the-other-side-at-the-vbox-bottom
(let [vbox {:x 0 :y 0 :width 1000 :height 60}
layout (msr/size-badge-layout [(polyline [0 50] [200 50])] 1 vbox)]
(t/is (close-layout? {:cx 100 :cy 34 :rot 0} layout))))