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:
parent
3c63e6d7c8
commit
be7d12f00c
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -137,4 +137,6 @@ mod cache_tests;
|
|||
#[cfg(test)]
|
||||
mod list_tests;
|
||||
#[cfg(test)]
|
||||
mod rtl_list_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"}}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue