M4: rotated frame labels; right-to-left list markers (#95)

- Frame and section name labels follow a rotated frame's top edge as shown (turned with it,
  cut to the edge's length; a mirrored frame's label stays upright, a frame turned past 90
  degrees takes its label along). Component icons, section pills, eyes and status badges turn too.
- The canvas publishes each label's area to the engine (ToolState::labels): with the Move and
  Scale tools, hovering a label highlights its frame and pressing it selects and drags the frame
  (one undo step); labels of locked, hidden, deleted or other pages' frames do nothing.
- Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on the right,
  wrapped lines hanging from the right, the paragraph indent on the right; trailing spaces of a
  right-to-left line hang outside it instead of pushing its text out of a right-aligned box.
  Canvas, PNG, PDF and SVG all draw the shared layout.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 21:58:01 +07:00 committed by GitHub
parent 3c63e6d7c8
commit be7d12f00c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 678 additions and 67 deletions

View File

@ -74,7 +74,9 @@ dimension measured side by side.
unlinks the chars edited). Live previews of a hovered named instance or font style and of a
dragged axis slider (one undo step on release, Escape cancels). Visual QA: list markers keep the
paragraph's font, renames start with the name selected and centred, Max lines shows while
truncating. *Left:* list markers on the right of right-to-left paragraphs. 1 h.
truncating. Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on
the right, wrapped lines hanging from the right, the paragraph indent on the right, trailing
spaces hanging outside the line (canvas, PNG, PDF and SVG). *Left:* visual QA. 0.5 h.
3. **Auto layout and sections depth**: *landed:* on-canvas padding/gap handles (also wrapped-row
and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed,
hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text
@ -327,7 +329,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Area | Weight | Score | Remaining work | Hours |
|---|---|---|---|---|
| Canvas & navigation | 6 | 80% | frame labels along a rotated frame's top edge, visual QA at extreme zooms | 1 |
| Canvas & navigation | 6 | 82% | visual QA at extreme zooms | 0.5–1 |
| Selection & transform | 8 | 78% | measuring and transform handles checked side by side, rotated groups and mixed selections QA | 2–4 |
| Layers panel | 5 | 88% | visual QA of long lists and deep trees | 0.5–1 |
| Shapes & vector | 8 | 76% | live boolean editing, Design panel arc fields, shape builder on strokes and open paths, one-sided widths | 4–8 |
@ -336,7 +338,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Effects | 4 | 84% | glass and progressive blur tuned side by side; SVG draws noise, texture, progressive and background blur as embedded images | 2–4 |
| Frames, groups, sections, masks | 5 | 80% | comparing what changed in ready-for-dev frames and sections, visual QA | 1–2 |
| Auto layout & constraints | 9 | 76% | grid `fr` rows around fixed-size children, QA with real-world files | 1–2 |
| Text | 8 | 78% | list markers on the right of right-to-left paragraphs, visual QA | 1–2 |
| Text | 8 | 79% | visual QA | 0.5–1 |
| Components & variants | 9 | 79% | QA with real-world (not synthetic) libraries | 1 |
| Styles & variables | 5 | 89% | variant properties and gradient stops bound to variables, REST import of bindings past the first paint and on layout grids, a colour picker for layout grid colours | 1 |
| Prototyping | 6 | 89% | dragging actions to reorder them, QA against larger prototypes with conditionals | 2–4 |

View File

@ -0,0 +1,92 @@
//! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it,
//! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it
//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing.
use figcraft_doc::NodeId;
use figcraft_geom::Point;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind, label_at};
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap()
}
/// A 200 × 100 frame at (100, 100) turned 30°, with its label: a 60 × 14 strip just outside its
/// top edge, from its top-left corner, as the canvas would publish it at zoom 1.
fn setup() -> (Session, NodeId, [Point; 4]) {
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 100, "y": 100, "width": 200, "height": 100}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": f, "rotation": 30}));
let w = s.doc().unwrap().world_transform(f);
let q = [(0.0, -18.0), (60.0, -18.0), (60.0, -4.0), (0.0, -4.0)].map(|(x, y)| w * Point::new(x, y));
s.tool.labels = vec![(f, q)];
(s, f, q)
}
fn center(q: &[Point; 4]) -> Point {
Point::new(q.iter().map(|p| p.x).sum::<f64>() / 4.0, q.iter().map(|p| p.y).sum::<f64>() / 4.0)
}
fn ev(kind: PointerKind, p: Point) -> PointerEvent {
PointerEvent::at(kind, p.x, p.y, 1.0)
}
#[test]
fn pressing_a_rotated_frames_label_selects_and_drags_the_frame() {
let (mut s, f, q) = setup();
let c = center(&q);
let page = s.page().unwrap();
// The label lies outside the frame: nothing else is there.
assert_eq!(s.doc().unwrap().hit_test(page, c, 1.0), None);
// Hover highlights the frame.
s.pointer(&ev(PointerKind::Hover, c)).unwrap();
assert_eq!(s.ds().unwrap().hover, Some(f));
let undo = s.ds().unwrap().history.undo.len();
let before = s.doc().unwrap().world_bounds(f).unwrap();
s.pointer(&ev(PointerKind::Down, c)).unwrap();
assert_eq!(s.selection(), vec![f]);
s.pointer(&ev(PointerKind::Move, c + figcraft_geom::Vec2::new(40.0, 25.0))).unwrap();
s.pointer(&ev(PointerKind::Up, c + figcraft_geom::Vec2::new(40.0, 25.0))).unwrap();
let after = s.doc().unwrap().world_bounds(f).unwrap();
assert!((after.x0 - before.x0 - 40.0).abs() < 1.0 && (after.y0 - before.y0 - 25.0).abs() < 1.0, "{before:?} → {after:?}");
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1);
// Shift+press on the label of a selected frame takes it out of the selection.
let mut shift = ev(PointerKind::Down, center(&s.tool.labels[0].1));
shift.shift = true;
s.pointer(&shift).unwrap();
s.pointer(&PointerEvent { kind: PointerKind::Up, ..shift }).unwrap();
assert!(s.selection().is_empty());
}
#[test]
fn labels_of_frames_that_cant_be_picked_do_nothing() {
let (mut s, f, q) = setup();
let c = center(&q);
let page = s.page().unwrap();
assert_eq!(label_at(s.doc().unwrap(), page, &s.tool.labels, c), Some(f));
// Just outside the strip.
assert_eq!(label_at(s.doc().unwrap(), page, &s.tool.labels, q[0] + (q[0] - q[2]) * 0.1), None);
ex(&mut s, "node.set", json!({"id": f, "locked": true}));
assert_eq!(label_at(s.doc().unwrap(), page, &s.tool.labels, c), None);
s.pointer(&ev(PointerKind::Down, c)).unwrap();
s.pointer(&ev(PointerKind::Up, c)).unwrap();
assert!(s.selection().is_empty(), "a locked frame isn't picked by its label");
ex(&mut s, "node.set", json!({"id": f, "locked": false, "visible": false}));
assert_eq!(label_at(s.doc().unwrap(), page, &s.tool.labels, c), None);
ex(&mut s, "node.set", json!({"id": f, "visible": true}));
// Another page: the old page's labels don't apply.
ex(&mut s, "page.add", json!({"name": "Two"}));
let two = s.page().unwrap();
assert_ne!(two, page);
assert_eq!(label_at(s.doc().unwrap(), two, &s.tool.labels, c), None);
// Deleted, and junk areas.
ex(&mut s, "page.select", json!({"id": page}));
ex(&mut s, "edit.delete", json!({"ids": [f]}));
assert_eq!(label_at(s.doc().unwrap(), page, &s.tool.labels, c), None);
let nan = [Point::new(f64::NAN, 0.0); 4];
let flat = [Point::new(1.0, 1.0); 4];
assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None);
}

View File

@ -1038,6 +1038,8 @@ mod effects_tests;
#[cfg(test)]
mod frame_guides_tests;
#[cfg(test)]
mod frame_label_tests;
#[cfg(test)]
mod frame_tests;
#[cfg(test)]
mod gesture_preview_tests;

View File

@ -357,6 +357,35 @@ pub struct ToolState {
pub scale_anchor: (u8, u8),
/// The last drag move handled (a held button repeats it every frame).
pub(crate) last_move: Option<PointerEvent>,
/// The name labels of top-level frames and sections as the canvas last drew them (each label's
/// corners on the page, turned with its frame): the Move and Scale tools treat a press or
/// hover on one as on its frame.
pub labels: Vec<(NodeId, [Point; 4])>,
}
/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost
/// one drawn, if it can be selected (still there, on the page, visible and unlocked).
pub fn label_at(doc: &Document, page: NodeId, labels: &[(NodeId, [Point; 4])], p: Point) -> Option<NodeId> {
labels
.iter()
.rev()
.find(|(id, q)| in_quad(q, p) && doc.page_of(*id) == Some(page) && doc.node(*id).is_some_and(|n| n.visible && !n.locked))
.map(|(id, _)| *id)
}
/// Whether `p` is inside the convex quadrilateral `q` (either winding; edges included).
fn in_quad(q: &[Point; 4], p: Point) -> bool {
let (mut left, mut right) = (false, false);
for (a, b) in q.iter().zip(q.iter().cycle().skip(1)) {
let side = (*b - *a).cross(p - *a);
if !side.is_finite() {
return false;
}
left |= side < 0.0;
right |= side > 0.0;
}
// On one side of every edge (a point can't be on all four edges of a quad with an area).
left != right
}
/// An anchor name (`tl`, `t`, `tr`, `l`, `c`, `r`, `bl`, `b`, `br`) as column and row (0 start,
@ -778,7 +807,8 @@ impl Session {
if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) {
cursor = resize_cursor(&doc, &sel, h);
} else {
hover = doc.hit_test(page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd));
hover = label_at(&doc, page, &self.tool.labels, ev.p())
.or_else(|| doc.hit_test(page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
if ev.alt || self.tool.redlines {
use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of};
// Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the
@ -861,10 +891,11 @@ impl Session {
};
return Ok(());
}
let leaf = doc.hit_test(page, ev.p(), ev.tol() * 2.0);
match leaf {
Some(leaf) => {
let target = select_target(&doc, leaf, &sel, ev.cmd);
// A frame's name label picks the frame itself.
let target = label_at(&doc, page, &self.tool.labels, ev.p())
.or_else(|| doc.hit_test(page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
match target {
Some(target) => {
// Clicking inside the current selection's box keeps it (drag them all).
let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift;
let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) {

View File

@ -377,6 +377,8 @@ mod retained_edit_tests;
#[cfg(test)]
mod retained_tests;
#[cfg(test)]
mod rtl_list_tests;
#[cfg(test)]
mod spread_tests;
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,63 @@
//! List markers of right-to-left paragraphs on the right, in what the canvas and PNG export draw
//! and in the outlines the SVG export writes: the bullet ends at the box's right edge with a clear
//! gap between it and the text on its left. (The PDF export draws the same laid-out glyphs.)
use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextData};
use super::*;
const W: f64 = 300.0;
/// A 300-wide right-aligned bulleted Hebrew paragraph at the origin.
fn doc() -> (Document, NodeId, TextData) {
let mut td = TextData::new("\u{05E9}\u{05DC}\u{05D5}\u{05DD} \u{05E2}\u{05D5}\u{05DC}\u{05DD}");
td.style.font_size = 20.0;
td.list = ListStyle::Bulleted;
td.align_h = TextAlignH::Right;
td.auto_resize = TextAutoResize::Height;
let mut d = Document::new("rtl");
let p = d.first_page().unwrap();
let id = d.create(p, NodeKind::Text(td.clone()), "T", 0.0, 0.0, W, 40.0).unwrap();
(d, id, td)
}
/// The bullet's span and where the text ends, from the layout; None without Hebrew fonts.
fn spans(td: &TextData) -> Option<((f64, f64), f64)> {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(figcraft_text::scan_system_fonts);
let l = figcraft_text::layout(td, Some(W), None);
if l.glyphs.is_empty() || l.glyphs.iter().any(|g| g.gid == 0) {
eprintln!("no font for Hebrew; skipping");
return None;
}
let m = l.glyphs.iter().find(|g| g.char_start == g.char_end)?;
let text_right = l.glyphs.iter().filter(|g| g.char_end > g.char_start).map(|g| g.x + g.advance).fold(f64::MIN, f64::max);
Some(((m.x, m.x + m.advance), text_right))
}
#[test]
fn the_bullet_of_a_right_to_left_paragraph_draws_on_the_right() {
let (d, id, td) = doc();
let Some(((m0, m1), text_right)) = spans(&td) else { return };
assert!(m0 > text_right + 8.0 && (m1 - W).abs() < 0.5, "{m0}..{m1}, text to {text_right}");
let mut pix = Pixmap::new(W as u32, 40).unwrap();
render_nodes_into(&d, &[id], &View { origin: (0.0, 0.0), scale: 1.0, width: W as u32, height: 40 }, &mut pix);
let inked = |x: u32| (0..40).any(|y| pix.pixel(x, y).is_some_and(|p| p.alpha() > 64));
assert!((m0.ceil() as u32..m1.floor() as u32).any(inked), "the bullet is drawn");
assert!(!(text_right.ceil() as u32 + 2..m0.floor() as u32 - 1).any(inked), "nothing between the text and the bullet");
assert!((0..text_right as u32).any(inked), "the text is drawn");
// The outline the SVG export writes: the same gap.
let (path, _) = draw::node_outline(&d, d.node(id).unwrap()).unwrap();
let xs: Vec<f64> = path
.elements()
.iter()
.flat_map(|e| match e {
figcraft_geom::kurbo::PathEl::MoveTo(p) | figcraft_geom::kurbo::PathEl::LineTo(p) => vec![p.x],
figcraft_geom::kurbo::PathEl::QuadTo(a, p) => vec![a.x, p.x],
figcraft_geom::kurbo::PathEl::CurveTo(a, b, p) => vec![a.x, b.x, p.x],
figcraft_geom::kurbo::PathEl::ClosePath => vec![],
})
.collect();
assert!(xs.iter().any(|x| (m0..=m1).contains(x)));
assert!(!xs.iter().any(|x| *x > text_right + 1.0 && *x < m0 - 1.0), "{xs:?}");
}

View File

@ -258,7 +258,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
// List markers sit in a gutter and wrapped lines hang at the text start; paragraph indent
// applies to plain paragraphs only.
// applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the
// indent are on the right, the text ends where they start.
let marker_width = list_gutter(text.list, &chars, styles.first(), faces.first().cloned().flatten().as_ref());
let indent = if text.list == ListStyle::None && text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 };
@ -363,13 +364,24 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let line_indent = marker_width + if para_start_line { indent } else { 0.0 };
let rtl_line = rtl_para_at(items.get(*s).map(|it| it.char_start).unwrap_or(0));
let gstart = out.glyphs.len();
let mut pen = line_indent;
// Justify: extra space per gap.
let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count();
let justify_extra = match (text.align_h, wrap) {
(TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent - width) / gaps as f64).max(0.0),
_ => 0.0,
};
// Where the visible text ends once justified.
let text_end = width + justify_extra * gaps as f64;
// A right-to-left line's trailing spaces sit at its visual left (rule L1, below) and hang
// outside it, so its visible text spans [0, text_end] with the gutter and indent after it.
let trailing: f64 = items
.get(vis_end..*e)
.unwrap_or(&[])
.iter()
.filter(|it| it.ch != '\n')
.map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 })
.sum();
let mut pen = if rtl_line { -trailing } else { line_indent };
// List marker (before the line's text; zero-length char range so carets skip it).
if para_start_line && text.list != ListStyle::None {
let marker = match text.list {
@ -391,11 +403,17 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) {
let size = sanitize_size(st.font_size);
let k = size / face.upem;
// Numbers end at the same x; bullets start at the gutter's left edge.
let mut mx = if text.list == ListStyle::Numbered {
(marker_width - size * 0.5 - marker_advance(&face, &coords, &marker, size)).max(0.0)
} else {
0.0
let numbered = text.list == ListStyle::Numbered;
// Right to left, the number reads "1." with its full stop on the left (".1").
let marker = if rtl_line && numbered { format!(".{}", marker.trim_end_matches('.')) } else { marker };
let advance = marker_advance(&face, &coords, &marker, size);
// Numbers end (start, right to left) half an em from the text; bullets sit at the
// gutter's outer edge.
let mut mx = match (rtl_line, numbered) {
(false, true) => (marker_width - size * 0.5 - advance).max(0.0),
(false, false) => 0.0,
(true, true) => text_end + size * 0.5,
(true, false) => text_end + (marker_width - advance).max(0.0),
};
for c in marker.chars() {
let gid = face.glyph_for(c);

View File

@ -137,4 +137,6 @@ mod cache_tests;
#[cfg(test)]
mod list_tests;
#[cfg(test)]
mod rtl_list_tests;
#[cfg(test)]
mod tests;

View File

@ -18,5 +18,6 @@ fn markers_use_the_paragraph_font() {
}
assert_eq!(markers[0].face.id, markers[1].face.id, "both bullets in one font");
assert_eq!(markers[0].gid, markers[1].gid);
assert!((markers[0].x - markers[1].x).abs() < 1e-9);
// The Hebrew paragraph's bullet is on its right (see `rtl_list_tests`).
assert!(markers[1].x > markers[0].x);
}

View File

@ -0,0 +1,151 @@
//! Lists and paragraph indents in right-to-left paragraphs: the marker gutter and the indent are
//! mirrored to the right, numbers read "1." with the full stop on their left, wrapped lines hang
//! from the right, and trailing spaces don't push the text out of a right-aligned box.
use figcraft_doc::{ListStyle, TextAlignH, TextAutoResize, TextData};
use crate::{TextLayout, layout};
/// Hebrew words (shalom, olam, tov), from fonts on the system or the bundled set.
const SHALOM: &str = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}";
const OLAM: &str = "\u{05E2}\u{05D5}\u{05DC}\u{05DD}";
const TOV: &str = "\u{05D8}\u{05D5}\u{05D1}";
/// Lay out `t` in a box `w` wide, with the system's fonts for scripts the bundled ones lack.
fn layout_in(t: &TextData, w: f64) -> TextLayout {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(crate::scan_system_fonts);
layout(t, Some(w), None)
}
/// Whether every glyph has a font (else the test can't measure anything and is skipped).
fn drawable(l: &TextLayout) -> bool {
let ok = !l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.gid != 0);
if !ok {
eprintln!("no font for Hebrew; skipping");
}
ok
}
fn text(chars: &str, list: ListStyle, align: TextAlignH) -> TextData {
let mut t = TextData::new(chars);
t.style.font_size = 20.0;
t.list = list;
t.align_h = align;
t.auto_resize = TextAutoResize::Height;
t
}
/// Line `li`'s marker glyphs (they cover no chars), left to right.
fn marker(l: &TextLayout, li: usize) -> Vec<&crate::Glyph> {
let ln = &l.lines[li];
l.glyphs[ln.glyph_start..ln.glyph_end].iter().filter(|g| g.char_start == g.char_end).collect()
}
/// The left and right ends of line `li`'s visible text glyphs (not spaces).
fn text_span(l: &TextLayout, li: usize) -> (f64, f64) {
let ln = &l.lines[li];
let g = l.glyphs[ln.glyph_start..ln.glyph_end].iter().filter(|g| g.char_end > g.char_start && g.gid != g.face.glyph_for(' '));
g.fold((f64::MAX, f64::MIN), |(a, b), g| (a.min(g.x), b.max(g.x + g.advance)))
}
#[test]
fn bullets_sit_right_of_right_to_left_text() {
let l = layout_in(&text(&format!("{SHALOM} {OLAM}\nabc"), ListStyle::Bulleted, TextAlignH::Right), 300.0);
if !drawable(&l) {
return;
}
assert!(l.lines[0].rtl && !l.lines[1].rtl);
// The Hebrew line: bullet on the right, ending at the box's right edge; the text left of it.
let m = marker(&l, 0);
assert_eq!(m.len(), 1);
let (_, text_right) = text_span(&l, 0);
assert!((m[0].x + m[0].advance - 300.0).abs() < 0.5, "{} + {}", m[0].x, m[0].advance);
assert!(text_right < m[0].x, "{text_right} < {}", m[0].x);
// The text keeps the gutter's width clear: 1.5 em.
assert!((300.0 - text_right - 30.0).abs() < 0.5, "{text_right}");
// The English paragraph of the same list keeps its bullet on the left of its text.
let m = marker(&l, 1);
let (text_left, _) = text_span(&l, 1);
assert!(m[0].x < text_left);
}
#[test]
fn numbers_read_with_the_full_stop_on_their_left() {
let paras = [SHALOM, OLAM, TOV].join("\n");
let l = layout_in(&text(&paras, ListStyle::Numbered, TextAlignH::Right), 300.0);
if !drawable(&l) {
return;
}
assert_eq!(l.lines.len(), 3);
let starts: Vec<f64> = (0..3).map(|li| marker(&l, li)[0].x).collect();
for li in 0..3 {
let m = marker(&l, li);
// ".1", ".2", ".3": the full stop first from the left, then the digit.
assert_eq!(m.len(), 2);
assert_eq!(m[0].gid, m[0].face.glyph_for('.'));
assert_eq!(m[1].gid, m[1].face.glyph_for(char::from(b'1' + li as u8)));
assert!(m[0].x < m[1].x);
// Half an em between the text and the number; the numbers start at one x.
let (_, text_right) = text_span(&l, li);
assert!((m[0].x - text_right - 10.0).abs() < 0.5, "{} vs {text_right}", m[0].x);
assert!((starts[li] - starts[0]).abs() < 1e-6);
// Inside the box.
assert!(m[1].x + m[1].advance <= 300.0 + 1e-6);
}
}
#[test]
fn wrapped_right_to_left_list_lines_hang_from_the_right() {
let words = [SHALOM, OLAM, TOV, SHALOM, OLAM, TOV, SHALOM].join(" ");
let l = layout_in(&text(&words, ListStyle::Bulleted, TextAlignH::Right), 160.0);
if !drawable(&l) {
return;
}
assert!(l.lines.len() >= 2, "{}", l.lines.len());
// Every line's text ends at the gutter (the box's right edge less 1.5 em); trailing spaces
// hang outside on the left instead of pushing it right.
for li in 0..l.lines.len() {
let (left, right) = text_span(&l, li);
assert!((right - (160.0 - 30.0)).abs() < 0.5, "line {li}: {right}");
assert!(left >= -1e-6, "line {li}: {left}");
}
assert_eq!(marker(&l, 0).len(), 1);
assert!(marker(&l, 1).is_empty(), "only the paragraph's first line has a bullet");
}
#[test]
fn right_to_left_paragraph_indent_is_on_the_right() {
let words = [SHALOM, OLAM, TOV, SHALOM, OLAM, TOV, SHALOM].join(" ");
let mut t = text(&words, ListStyle::None, TextAlignH::Right);
t.paragraph_indent = 40.0;
let l = layout_in(&t, 160.0);
if !drawable(&l) {
return;
}
assert!(l.lines.len() >= 2);
let (_, first) = text_span(&l, 0);
let (_, second) = text_span(&l, 1);
assert!((first - 120.0).abs() < 0.5, "{first}");
assert!((second - 160.0).abs() < 0.5, "{second}");
}
#[test]
fn left_aligned_and_justified_right_to_left_lists_keep_the_marker_after_the_text() {
let words = [SHALOM, OLAM, TOV, SHALOM, OLAM, TOV, SHALOM].join(" ");
for align in [TextAlignH::Left, TextAlignH::Justified, TextAlignH::Center] {
let l = layout_in(&text(&words, ListStyle::Numbered, align), 160.0);
if !drawable(&l) {
return;
}
let m = marker(&l, 0);
let (left, right) = text_span(&l, 0);
assert!(m[0].x > right, "{align:?}: {} > {right}", m[0].x);
assert!(m[1].x + m[1].advance <= 160.0 + 0.5, "{align:?}");
assert!(left >= -1e-6, "{align:?}: {left}");
if align == TextAlignH::Justified {
// A justified line fills the box up to the gutter.
assert!(left.abs() < 0.5, "{left}");
}
}
}

View File

@ -177,7 +177,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
let h = (f64::from(v.height) / v.scale * zoom) as f32;
painter.image(tex.id(), Rect::from_min_size(tl, vec2(w, h)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
overlays(app, &painter, &t);
app.session.tool.labels = overlays(app, &painter, &t);
// The zoom tool's area.
if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) {
let area = Rect::from_two_pos(start, p);
@ -580,8 +580,11 @@ fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option<Vec<Vec<Pos
Some(out)
}
fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
let Ok(ds) = app.session.ds() else { return };
/// Everything drawn over the rendered page; returns the frame labels drawn (frame, corners on the
/// page) for the engine to hit-test.
fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [Point; 4])> {
let mut labels = Vec::new();
let Ok(ds) = app.session.ds() else { return labels };
let doc = ds.doc.clone();
let page = ds.page;
let sel = ds.selection.clone();
@ -610,8 +613,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
for c in &boards(&doc, page) {
let Some(n) = doc.node(*c) else { continue };
let Some(b) = doc.world_bounds(*c) else { continue };
// Off-screen frames draw neither grids nor a label (labels sit just above the frame).
let on_screen = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand2(vec2(0.0, 20.0));
// Off-screen frames draw neither grids nor a label (labels sit just outside the frame).
let on_screen = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(20.0);
if !on_screen.intersects(app.canvas.rect) {
continue;
}
@ -632,50 +635,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
}
}
let labelled = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
if labelled {
let tl = app.to_screen(b.x0, b.y0);
let selected = sel.contains(c);
let hovered = ds.hover == Some(*c);
let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. });
let col = if is_comp {
COMPONENT
} else if selected || hovered {
ACCENT
} else {
t.text_secondary
};
if matches!(n.kind, NodeKind::Section { .. }) {
// Section titles are pills inside the top-left; a section hiding its contents shows
// an eye in its pill and says so in the middle.
let hidden = n.hides_contents();
let fg = if selected { Color32::WHITE } else { t.text };
let galley = crate::bidi::name_galley(painter, &n.name, semibold(11.0), fg, 480.0);
let eye = if hidden { 16.0 } else { 0.0 };
let r = Rect::from_min_size(tl, galley.size() + vec2(16.0 + eye, 8.0));
painter.rect_filled(r, CornerRadius::same(4), if selected { ACCENT } else { t.hover });
if hidden {
let ir = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(12.0, 12.0));
crate::icons::paint(painter, ir, crate::icons::Icon::EyeOff, fg);
}
painter.galley(r.min + vec2(8.0 + eye, 4.0), galley, fg);
status_badge(painter, n, pos2(r.max.x + 6.0, r.center().y));
let body = Rect::from_two_pos(tl, app.to_screen(b.x1, b.y1));
if hidden && body.width() > 120.0 && body.height() > 60.0 {
painter.text(body.center(), Align2::CENTER_CENTER, "Contents hidden", regular(11.0), t.text_tertiary);
}
} else {
let max_w = (b.width() * zoom) as f32;
let mut x = tl.x;
if is_comp {
crate::icons::paint(painter, Rect::from_min_size(pos2(x, tl.y - 16.0), vec2(12.0, 12.0)), crate::icons::Icon::Component, col);
x += 14.0;
}
// Names longer than the frame end in an ellipsis (a right-to-left name keeps its start).
let galley = crate::bidi::name_galley(painter, &n.name, regular(11.0), col, (max_w - (x - tl.x)).max(20.0));
let end = x + galley.size().x;
painter.galley(pos2(x, tl.y - 16.0), galley, col);
status_badge(painter, n, pos2(end + 6.0, tl.y - 9.0));
}
if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) {
labels.push((*c, q.map(|p| app.to_page(p))));
}
}
// Prototype connections.
@ -882,6 +843,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
if view.rulers {
rulers(app, painter, t, &doc, &sel);
}
labels
}
/// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles`
@ -1114,7 +1076,7 @@ fn boards(doc: &Document, page: NodeId) -> Vec<NodeId> {
/// A frame's or section's handoff status as a small pill left-centred on `at` ("Changed" once a
/// ready-for-dev layer was edited).
fn status_badge(painter: &egui::Painter, n: &figcraft_doc::Node, at: Pos2) {
pub(crate) fn status_badge(painter: &egui::Painter, n: &figcraft_doc::Node, at: Pos2) {
let (label, fill) = match n.dev_status {
figcraft_doc::DevStatus::None => return,
figcraft_doc::DevStatus::ReadyForDev if n.dev_changed => ("Changed", crate::theme::DEV_CHANGED),

View File

@ -0,0 +1,81 @@
//! Frame name labels on the canvas, driven through egui headless: an upright frame's label sits
//! just above its top-left corner, a rotated frame's runs along its turned top edge, and pressing
//! either label selects and drags its frame.
use egui::{Event, Pos2, pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_geom::Point;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
figcraft_engine::params::id_of(&v["id"]).unwrap()
}
/// Frames "Upright" at (0, 0) and "Turned" at (400, 0), the second rotated 30°.
fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let a = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Upright"})).unwrap());
let b = id(s.execute("frame.create", &json!({"x": 400, "y": 0, "width": 300, "height": 200, "name": "Turned"})).unwrap());
s.execute("node.set", &json!({"id": b, "rotation": 30})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
(u, a, b)
}
/// Frame `f`'s label on screen (corners) as the canvas published it.
fn label(u: &Ui, f: NodeId) -> [Pos2; 4] {
let (_, q) = u.app.session.tool.labels.iter().find(|(id, _)| *id == f).expect("the frame has a label");
q.map(|p| u.app.to_screen(p.x, p.y))
}
/// Frame `f`'s local point `(x, y)` on screen.
fn local(u: &Ui, f: NodeId, x: f64, y: f64) -> Pos2 {
let p = u.app.session.doc().unwrap().world_transform(f) * Point::new(x, y);
u.app.to_screen(p.x, p.y)
}
fn mid(q: &[Pos2; 4]) -> Pos2 {
pos2(q.iter().map(|p| p.x).sum::<f32>() / 4.0, q.iter().map(|p| p.y).sum::<f32>() / 4.0)
}
#[test]
fn labels_run_along_the_frames_top_edge() {
let (u, a, b) = setup();
// Upright: a strip right above the top-left corner.
let q = label(&u, a);
let tl = local(&u, a, 0.0, 0.0);
assert!((q[0].x - tl.x).abs() < 0.5 && q[0].y < tl.y && q[3].y < tl.y + 0.5, "{q:?} vs {tl:?}");
assert!((q[0].y - q[1].y).abs() < 1e-3, "level");
// Turned: the strip starts at the turned top-left corner and runs along the top edge.
let q = label(&u, b);
let (tl, tr) = (local(&u, b, 0.0, 0.0), local(&u, b, 300.0, 0.0));
let along = (tr - tl).normalized();
let top = (q[1] - q[0]).normalized();
assert!((top - along).length() < 1e-3, "{top:?} along {along:?}");
// Outside the frame: on the far side of the top edge from the frame's centre.
let up = vec2(along.y, -along.x);
assert!((mid(&q) - tl).dot(up) > 2.0);
assert!((local(&u, b, 150.0, 100.0) - tl).dot(up) < 0.0);
// Never longer than the edge.
assert!((q[1] - q[0]).length() <= (tr - tl).length() + 0.5);
}
#[test]
fn pressing_a_rotated_label_selects_and_drags_the_frame() {
let (mut u, _, b) = setup();
let at = mid(&label(&u, b));
let before = u.app.session.doc().unwrap().world_bounds(b).unwrap();
let undo = u.app.session.ds().unwrap().history.undo.len();
u.button(at, true);
assert_eq!(u.app.session.selection(), vec![b]);
for k in 1..=10 {
u.frame(vec![Event::PointerMoved(at + vec2(4.0 * k as f32, 2.0 * k as f32))]);
}
u.button(at + vec2(40.0, 20.0), false);
let after = u.app.session.doc().unwrap().world_bounds(b).unwrap();
assert!(after.x0 > before.x0 + 1.0 && after.y0 > before.y0 + 1.0, "{before:?} → {after:?}");
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1);
}

View File

@ -0,0 +1,199 @@
//! Name labels of top-level frames and sections on the canvas. A frame's name sits just above its
//! top edge and a section's name is a pill inside its top-left corner; on a rotated frame or
//! section the label runs along the top edge as shown, turned with it, and is cut to the edge's
//! length. Each label's area goes to the engine (`ToolState::labels`), so pressing a label selects
//! and drags its frame like pressing the frame itself.
use egui::layers::ShapeIdx;
use egui::{Align2, Color32, CornerRadius, Painter, Pos2, Rect, Shape, Vec2, emath::Rot2, pos2, vec2};
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::Point;
use crate::FigApp;
use crate::canvas::status_badge;
use crate::theme::{ACCENT, COMPONENT, Tokens, regular, semibold};
/// Where a label goes on screen: the corner it starts at, the angle it reads along (radians,
/// clockwise on screen) and the length of the frame's edge along that direction.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LabelEdge {
pub corner: Pos2,
pub angle: f32,
pub len: f32,
}
/// The label edge of a frame whose local corners (0, 0), (w, 0) and (0, h) show at `p00`, `p10`
/// and `p01`: its top edge as seen, read along the frame's rotation. A mirrored frame reads left
/// to right as far as its rotation allows (a flipped frame's label isn't upside down), and the
/// label always sits on the side away from the frame.
pub fn label_edge(p00: Pos2, p10: Pos2, p01: Pos2) -> LabelEdge {
let (ex, ey) = (p10 - p00, p01 - p00);
let len = ex.length();
if !len.is_finite() || len <= 1e-3 {
return LabelEdge { corner: p00, angle: 0.0, len: 0.0 };
}
let mirrored = ex.x * ey.y - ex.y * ey.x < 0.0;
// Read along local -x (starting at x = w) when that is the upright way for a mirrored frame.
let reversed = mirrored && ex.x < 0.0;
let d = if reversed { -ex } else { ex };
// "Up" for the text; the label goes on the edge whose outside that is.
let up = vec2(d.y, -d.x);
let bottom = up.dot(ey) > 0.0;
let corner = p00 + if reversed { ex } else { Vec2::ZERO } + if bottom { ey } else { Vec2::ZERO };
LabelEdge { corner, angle: d.y.atan2(d.x), len }
}
/// Turn every shape `painter` added since `from` by `angle` (radians, clockwise) about `pivot`.
pub fn turn_since(painter: &Painter, from: ShapeIdx, pivot: Pos2, angle: f32) {
if angle == 0.0 || !angle.is_finite() {
return;
}
let rot = Rot2::from_angle(angle);
painter.ctx().graphics_mut(|g| {
let list = g.entry(painter.layer_id());
for i in from.0..list.next_idx().0 {
list.mutate_shape(ShapeIdx(i), |c| turn(&mut c.shape, rot, angle, pivot));
}
});
}
fn turn(shape: &mut Shape, rot: Rot2, angle: f32, pivot: Pos2) {
let at = |p: &mut Pos2| *p = pivot + rot * (*p - pivot);
match shape {
Shape::Noop | Shape::Callback(_) => {}
Shape::Vec(v) => v.iter_mut().for_each(|s| turn(s, rot, angle, pivot)),
Shape::Circle(c) => at(&mut c.center),
Shape::Ellipse(e) => {
at(&mut e.center);
e.angle += angle;
}
Shape::LineSegment { points, .. } => points.iter_mut().for_each(at),
Shape::Path(p) => p.points.iter_mut().for_each(at),
Shape::Rect(r) => {
// Rectangles turn about their centre.
let mut c = r.rect.center();
at(&mut c);
r.rect = Rect::from_center_size(c, r.rect.size());
r.angle += angle;
}
Shape::Text(t) => {
at(&mut t.pos);
t.angle += angle;
}
Shape::Mesh(m) => std::sync::Arc::make_mut(m).rotate(rot, pivot),
Shape::QuadraticBezier(q) => q.points.iter_mut().for_each(at),
Shape::CubicBezier(c) => c.points.iter_mut().for_each(at),
}
}
/// Draw the label of `id` (a top-level frame or a section); the area it covers on screen (its
/// corners) for hit testing.
pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: NodeId, selected: bool, hovered: bool) -> Option<[Pos2; 4]> {
let n = doc.node(id)?;
let w = doc.world_transform(id);
let at = |x: f64, y: f64| {
let p = w * Point::new(x, y);
app.to_screen(p.x, p.y)
};
let (p00, p01) = (at(0.0, 0.0), at(0.0, n.height));
let edge = label_edge(p00, at(n.width, 0.0), p01);
let tl = edge.corner;
let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. });
let col = if is_comp {
COMPONENT
} else if selected || hovered {
ACCENT
} else {
t.text_secondary
};
// Drawn upright at the corner, then turned with the frame.
let from = painter.add(Shape::Noop);
let area = if matches!(n.kind, NodeKind::Section { .. }) {
// Section titles are pills inside the top-left; a section hiding its contents shows an
// eye in its pill and says so in the middle.
let hidden = n.hides_contents();
let fg = if selected { Color32::WHITE } else { t.text };
let eye = if hidden { 16.0 } else { 0.0 };
let galley = crate::bidi::name_galley(painter, &n.name, semibold(11.0), fg, (edge.len - 16.0 - eye).clamp(20.0, 480.0));
let r = Rect::from_min_size(tl, galley.size() + vec2(16.0 + eye, 8.0));
painter.rect_filled(r, CornerRadius::same(4), if selected { ACCENT } else { t.hover });
if hidden {
let ir = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(12.0, 12.0));
crate::icons::paint(painter, ir, crate::icons::Icon::EyeOff, fg);
}
painter.galley(r.min + vec2(8.0 + eye, 4.0), galley, fg);
status_badge(painter, n, pos2(r.max.x + 6.0, r.center().y));
r
} else {
let mut x = tl.x;
if is_comp {
crate::icons::paint(painter, Rect::from_min_size(pos2(x, tl.y - 16.0), vec2(12.0, 12.0)), crate::icons::Icon::Component, col);
x += 14.0;
}
// Names longer than the frame end in an ellipsis (a right-to-left name keeps its start).
let galley = crate::bidi::name_galley(painter, &n.name, regular(11.0), col, (edge.len - (x - tl.x)).max(20.0));
let size = galley.size();
let end = x + size.x;
painter.galley(pos2(x, tl.y - 16.0), galley, col);
status_badge(painter, n, pos2(end + 6.0, tl.y - 9.0));
Rect::from_min_max(pos2(tl.x, tl.y - 18.0), pos2(end, tl.y - 16.0 + size.y.max(12.0)))
};
turn_since(painter, from, tl, edge.angle);
if n.hides_contents() {
let height = (p01 - p00).length();
if edge.len > 120.0 && height > 60.0 {
let center = at(n.width / 2.0, n.height / 2.0);
painter.text(center, Align2::CENTER_CENTER, "Contents hidden", regular(11.0), t.text_tertiary);
}
}
let rot = Rot2::from_angle(edge.angle);
Some([area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()].map(|p| tl + rot * (p - tl)))
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: Pos2, b: Pos2) -> bool {
(a - b).length() < 1e-3
}
#[test]
fn label_edges_follow_rotation_and_stay_upright_when_mirrored() {
// Upright: the top-left corner, reading right.
let e = label_edge(pos2(10.0, 20.0), pos2(110.0, 20.0), pos2(10.0, 70.0));
assert!(close(e.corner, pos2(10.0, 20.0)) && e.angle == 0.0 && (e.len - 100.0).abs() < 1e-3);
// Turned 90° clockwise on screen: the top edge runs down the right side, read downwards.
let e = label_edge(pos2(100.0, 0.0), pos2(100.0, 80.0), pos2(50.0, 0.0));
assert!(close(e.corner, pos2(100.0, 0.0)));
assert!((e.angle - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
// Turned 180°: upside down along the bottom edge as seen (rotated with the frame).
let e = label_edge(pos2(100.0, 50.0), pos2(0.0, 50.0), pos2(100.0, 0.0));
assert!(close(e.corner, pos2(100.0, 50.0)));
assert!((e.angle.abs() - std::f32::consts::PI).abs() < 1e-5);
// Flipped horizontally: still the visual top-left, reading right.
let e = label_edge(pos2(100.0, 0.0), pos2(0.0, 0.0), pos2(100.0, 50.0));
assert!(close(e.corner, pos2(0.0, 0.0)) && e.angle.abs() < 1e-6, "{e:?}");
// Flipped vertically: the local bottom edge is the top as seen.
let e = label_edge(pos2(0.0, 50.0), pos2(100.0, 50.0), pos2(0.0, 0.0));
assert!(close(e.corner, pos2(0.0, 0.0)) && e.angle.abs() < 1e-6, "{e:?}");
// Degenerate and non-finite frames fall back to upright at their origin.
let e = label_edge(pos2(5.0, 5.0), pos2(5.0, 5.0), pos2(5.0, 9.0));
assert_eq!(e, LabelEdge { corner: pos2(5.0, 5.0), angle: 0.0, len: 0.0 });
let e = label_edge(pos2(5.0, 5.0), pos2(f32::NAN, 5.0), pos2(5.0, 9.0));
assert_eq!(e.angle, 0.0);
}
#[test]
fn turning_shapes_moves_them_about_the_pivot() {
let rot = Rot2::from_angle(std::f32::consts::FRAC_PI_2);
let mut s = Shape::LineSegment { points: [pos2(10.0, 0.0), pos2(20.0, 0.0)], stroke: egui::Stroke::NONE };
turn(&mut s, rot, std::f32::consts::FRAC_PI_2, Pos2::ZERO);
let Shape::LineSegment { points, .. } = s else { panic!() };
assert!(close(points[0], pos2(0.0, 10.0)) && close(points[1], pos2(0.0, 20.0)));
let mut r = Shape::rect_filled(Rect::from_min_size(pos2(10.0, -5.0), vec2(20.0, 10.0)), 0.0, Color32::RED);
turn(&mut r, rot, std::f32::consts::FRAC_PI_2, Pos2::ZERO);
let Shape::Rect(r) = r else { panic!() };
assert!(close(r.rect.center(), pos2(0.0, 20.0)) && (r.angle - std::f32::consts::FRAC_PI_2).abs() < 1e-6);
}
}

View File

@ -17,6 +17,7 @@ pub mod dev_mode;
pub mod dialogs;
pub mod export_panel;
pub mod font_preview;
pub mod frame_labels;
pub mod frame_presets;
pub mod guides;
pub mod handles;
@ -53,6 +54,8 @@ mod canvas_drag_tests;
#[cfg(test)]
mod comments_tests;
#[cfg(test)]
mod frame_label_tests;
#[cfg(test)]
mod layers_drag_tests;
#[cfg(test)]
mod layers_keys_tests;

View File

@ -85,7 +85,9 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)).
A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `double`, `hover`,
`drag` (needs `to: [x, y]`; `steps` intermediate moves, default 8). Position in page coordinates
(`x`, `y`) or screen pixels (`sx`, `sy`). Returns `{selection, tool}`.
(`x`, `y`) or screen pixels (`sx`, `sy`). Returns `{selection, tool}`. With the Move and Scale
tools, a press on a frame's or section's name label (as the canvas last drew it, along a rotated
frame's top edge) selects and drags that frame.
```json
{"id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}}