diff --git a/ROADMAP.md b/ROADMAP.md index f034eb6..57cd49b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/engine/src/frame_label_tests.rs b/crates/engine/src/frame_label_tests.rs new file mode 100644 index 0000000..9260a6f --- /dev/null +++ b/crates/engine/src/frame_label_tests.rs @@ -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::() / 4.0, q.iter().map(|p| p.y).sum::() / 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); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5427587..48ec84a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 05e98e1..ce230f0 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -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, + /// 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 { + 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))) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 08711f4..b07555c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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; diff --git a/crates/render/src/rtl_list_tests.rs b/crates/render/src/rtl_list_tests.rs new file mode 100644 index 0000000..3dde82f --- /dev/null +++ b/crates/render/src/rtl_list_tests.rs @@ -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 = 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:?}"); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c379eab..c724ded 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -258,7 +258,8 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } // 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, box_height: Option) 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, box_height: Option) 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); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 65be2a5..c8f76e0 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -137,4 +137,6 @@ mod cache_tests; #[cfg(test)] mod list_tests; #[cfg(test)] +mod rtl_list_tests; +#[cfg(test)] mod tests; diff --git a/crates/text/src/list_tests.rs b/crates/text/src/list_tests.rs index 4086bbc..6acfc2a 100644 --- a/crates/text/src/list_tests.rs +++ b/crates/text/src/list_tests.rs @@ -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); } diff --git a/crates/text/src/rtl_list_tests.rs b/crates/text/src/rtl_list_tests.rs new file mode 100644 index 0000000..f5c816b --- /dev/null +++ b/crates/text/src/rtl_list_tests.rs @@ -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(¶s, ListStyle::Numbered, TextAlignH::Right), 300.0); + if !drawable(&l) { + return; + } + assert_eq!(l.lines.len(), 3); + let starts: Vec = (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}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3a1a629..29764c6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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<(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 { /// 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), diff --git a/crates/ui-egui/src/frame_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs new file mode 100644 index 0000000..38ebef0 --- /dev/null +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -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::() / 4.0, q.iter().map(|p| p.y).sum::() / 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); +} diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs new file mode 100644 index 0000000..a57d95c --- /dev/null +++ b/crates/ui-egui/src/frame_labels.rs @@ -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); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ac91599..bce2154 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c2d68ad..8da1ca2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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"}}}